Skip to content

Commit 7f0a250

Browse files
Add standalone PDF merge tool with drag-and-drop ordering (#127)
### Motivation - Provide a standalone browser tool that lets users upload multiple PDFs, visually reorder them, and merge them client-side into a single downloadable PDF following the repository's shared tool patterns. ### Description - Add a new `pdf-merge.html` page that follows the shared `styles.css` scaffold and provides two UI cards: an upload/dropzone card and an order+merge card. - Implement multi-file intake (file picker + drag-and-drop) with PDF validation, per-file removal, and a keyboard-accessible dropzone that opens the file picker on Enter/Space. - Render draggable vertical mini-cards in the second card to reorder files and enable a `Merge` button that uses `pdf-lib` (via CDN) to combine PDFs in the selected order and download `merged-pdf-YYYY-MM-DD.pdf`. - Add `pdf-merge.docs.md` with a short description of the tool. ------ [Codex Task](https://chatgpt.com/codex/cloud/tasks/task_e_69d6454c19d48325858a8b5a455da2a1)
1 parent 7b3f3d3 commit 7f0a250

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

pdf-merge.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This utility lets you upload multiple PDF files, reorder them by dragging cards vertically, and merge them into one file. Click **Merge** to download the combined document as `merged-pdf-year-month-day.pdf`.

pdf-merge.html

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>PDF Merge Tool</title>
8+
<link rel="stylesheet" href="styles.css">
9+
<style>
10+
body {
11+
max-width: 960px;
12+
margin: 0 auto;
13+
padding: 24px 20px 48px;
14+
}
15+
16+
main {
17+
display: grid;
18+
gap: 1.25rem;
19+
margin-top: 1.5rem;
20+
}
21+
22+
.tool-card {
23+
padding: clamp(1.1rem, 3vw, 1.8rem);
24+
display: grid;
25+
gap: 1rem;
26+
}
27+
28+
.dropzone {
29+
border: 2px dashed var(--ui-3);
30+
border-radius: var(--radius-md);
31+
padding: 1.25rem;
32+
text-align: center;
33+
background: var(--bg-2);
34+
transition: border-color 0.2s ease, background 0.2s ease;
35+
}
36+
37+
.dropzone.dragover {
38+
border-color: var(--accent);
39+
background: color-mix(in srgb, var(--bg-2) 70%, var(--accent) 30%);
40+
}
41+
42+
.upload-actions {
43+
display: flex;
44+
gap: 0.75rem;
45+
justify-content: center;
46+
flex-wrap: wrap;
47+
}
48+
49+
.file-list,
50+
.order-list {
51+
list-style: none;
52+
padding: 0;
53+
margin: 0;
54+
display: grid;
55+
gap: 0.65rem;
56+
}
57+
58+
.file-item,
59+
.order-item {
60+
display: flex;
61+
justify-content: space-between;
62+
align-items: center;
63+
gap: 0.75rem;
64+
padding: 0.7rem 0.85rem;
65+
border: 1px solid var(--ui-3);
66+
border-radius: var(--radius-sm);
67+
background: var(--bg);
68+
}
69+
70+
.order-item {
71+
cursor: grab;
72+
}
73+
74+
.order-item.dragging {
75+
opacity: 0.45;
76+
cursor: grabbing;
77+
}
78+
79+
.filename {
80+
overflow-wrap: anywhere;
81+
font-size: 0.95rem;
82+
}
83+
84+
.helper-text {
85+
color: var(--tx-2);
86+
font-size: 0.95rem;
87+
}
88+
89+
.status {
90+
min-height: 1.4rem;
91+
font-weight: 600;
92+
}
93+
94+
.status.error {
95+
color: var(--re);
96+
}
97+
98+
.status.success {
99+
color: var(--gr);
100+
}
101+
102+
.merge-actions {
103+
display: flex;
104+
justify-content: flex-end;
105+
margin-top: 0.25rem;
106+
}
107+
108+
@media (max-width: 720px) {
109+
body {
110+
padding: 18px 14px 36px;
111+
}
112+
113+
.merge-actions {
114+
justify-content: stretch;
115+
}
116+
117+
.merge-actions button {
118+
width: 100%;
119+
}
120+
}
121+
</style>
122+
</head>
123+
124+
<body>
125+
<header class="page-header">
126+
<a class="site-link" href="https://tools.mathspp.com/" aria-label="Back to tools.mathspp.com">← tools.mathspp.com</a>
127+
<h1>PDF Merge Tool</h1>
128+
<p class="lead">Upload PDFs, reorder them, and merge everything into one file.</p>
129+
</header>
130+
131+
<main>
132+
<section class="surface tool-card" aria-labelledby="upload-heading">
133+
<h2 id="upload-heading">1) Upload PDF files</h2>
134+
<div id="dropzone" class="dropzone" tabindex="0" aria-label="Drop PDF files here">
135+
<p>Drag and drop one or more PDF files here.</p>
136+
<div class="upload-actions">
137+
<button id="pick-files" type="button">Choose PDF files</button>
138+
<button id="clear-files" type="button">Clear all</button>
139+
</div>
140+
<input id="file-input" type="file" accept="application/pdf" multiple hidden>
141+
</div>
142+
<p class="helper-text">Only PDF files are accepted.</p>
143+
<ul id="uploaded-files" class="file-list" aria-live="polite"></ul>
144+
</section>
145+
146+
<section class="surface tool-card" aria-labelledby="order-heading">
147+
<h2 id="order-heading">2) Reorder and merge</h2>
148+
<p class="helper-text">Drag cards up or down to set the merge order.</p>
149+
<ul id="order-list" class="order-list" aria-live="polite"></ul>
150+
<div class="merge-actions">
151+
<button id="merge-button" type="button" disabled>Merge</button>
152+
</div>
153+
<p id="status" class="status" role="status" aria-live="polite"></p>
154+
</section>
155+
</main>
156+
157+
<footer class="page-footer">
158+
<p>Built with ❤️, 🤖, and 🐍, by <a href="https://mathspp.com/">Rodrigo Girão Serrão</a></p>
159+
</footer>
160+
161+
<script src="https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
162+
<script>
163+
(function () {
164+
const { PDFDocument } = PDFLib;
165+
const dropzone = document.getElementById('dropzone');
166+
const pickFilesButton = document.getElementById('pick-files');
167+
const clearFilesButton = document.getElementById('clear-files');
168+
const fileInput = document.getElementById('file-input');
169+
const uploadedFilesElement = document.getElementById('uploaded-files');
170+
const orderListElement = document.getElementById('order-list');
171+
const mergeButton = document.getElementById('merge-button');
172+
const statusElement = document.getElementById('status');
173+
174+
let files = [];
175+
176+
const setStatus = (message, type = '') => {
177+
statusElement.textContent = message;
178+
statusElement.className = `status ${type}`.trim();
179+
};
180+
181+
const normalizeFileList = fileList => Array.from(fileList || []).filter(file => file.type === 'application/pdf');
182+
183+
const formatBytes = bytes => {
184+
if (bytes < 1024) {
185+
return `${bytes} B`;
186+
}
187+
if (bytes < 1024 * 1024) {
188+
return `${(bytes / 1024).toFixed(1)} KB`;
189+
}
190+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
191+
};
192+
193+
const render = () => {
194+
uploadedFilesElement.innerHTML = '';
195+
orderListElement.innerHTML = '';
196+
197+
files.forEach((fileItem, index) => {
198+
const uploadLi = document.createElement('li');
199+
uploadLi.className = 'file-item';
200+
uploadLi.innerHTML = `
201+
<span class="filename">${index + 1}. ${fileItem.file.name} (${formatBytes(fileItem.file.size)})</span>
202+
<button type="button" data-remove-id="${fileItem.id}">Remove</button>
203+
`;
204+
uploadedFilesElement.appendChild(uploadLi);
205+
206+
const orderLi = document.createElement('li');
207+
orderLi.className = 'order-item';
208+
orderLi.draggable = true;
209+
orderLi.dataset.id = fileItem.id;
210+
orderLi.innerHTML = `
211+
<span class="filename">${index + 1}. ${fileItem.file.name}</span>
212+
<span aria-hidden="true">↕️</span>
213+
`;
214+
orderListElement.appendChild(orderLi);
215+
});
216+
217+
mergeButton.disabled = files.length < 2;
218+
if (files.length === 0) {
219+
uploadedFilesElement.innerHTML = '<li class="file-item"><span class="filename">No files uploaded yet.</span></li>';
220+
orderListElement.innerHTML = '<li class="order-item"><span class="filename">Add at least two PDFs to merge.</span></li>';
221+
} else if (files.length === 1) {
222+
setStatus('Add at least one more PDF to enable merge.');
223+
}
224+
};
225+
226+
const addFiles = fileList => {
227+
const pdfFiles = normalizeFileList(fileList);
228+
if (!pdfFiles.length) {
229+
setStatus('No valid PDFs found in your selection.', 'error');
230+
return;
231+
}
232+
233+
const mapped = pdfFiles.map(file => ({
234+
id: crypto.randomUUID(),
235+
file,
236+
}));
237+
238+
files = [...files, ...mapped];
239+
setStatus(`${mapped.length} PDF file(s) added.`, 'success');
240+
render();
241+
};
242+
243+
const removeFileById = id => {
244+
files = files.filter(fileItem => fileItem.id !== id);
245+
setStatus('File removed.');
246+
render();
247+
};
248+
249+
const moveItem = (draggedId, targetId) => {
250+
if (!draggedId || !targetId || draggedId === targetId) {
251+
return;
252+
}
253+
254+
const oldIndex = files.findIndex(fileItem => fileItem.id === draggedId);
255+
const newIndex = files.findIndex(fileItem => fileItem.id === targetId);
256+
if (oldIndex < 0 || newIndex < 0) {
257+
return;
258+
}
259+
260+
const updated = [...files];
261+
const [dragged] = updated.splice(oldIndex, 1);
262+
updated.splice(newIndex, 0, dragged);
263+
files = updated;
264+
render();
265+
};
266+
267+
const mergePdfs = async () => {
268+
if (files.length < 2) {
269+
setStatus('Add at least two PDFs before merging.', 'error');
270+
return;
271+
}
272+
273+
mergeButton.disabled = true;
274+
setStatus('Merging PDFs...');
275+
276+
try {
277+
const mergedPdf = await PDFDocument.create();
278+
279+
for (const fileItem of files) {
280+
const bytes = await fileItem.file.arrayBuffer();
281+
const sourcePdf = await PDFDocument.load(bytes);
282+
const pages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices());
283+
pages.forEach(page => mergedPdf.addPage(page));
284+
}
285+
286+
const mergedBytes = await mergedPdf.save();
287+
const blob = new Blob([mergedBytes], { type: 'application/pdf' });
288+
const url = URL.createObjectURL(blob);
289+
290+
const now = new Date();
291+
const yyyy = now.getFullYear();
292+
const mm = String(now.getMonth() + 1).padStart(2, '0');
293+
const dd = String(now.getDate()).padStart(2, '0');
294+
const filename = `merged-pdf-${yyyy}-${mm}-${dd}.pdf`;
295+
296+
const link = document.createElement('a');
297+
link.href = url;
298+
link.download = filename;
299+
document.body.appendChild(link);
300+
link.click();
301+
link.remove();
302+
URL.revokeObjectURL(url);
303+
304+
setStatus(`Merged ${files.length} PDF files successfully.`, 'success');
305+
} catch (error) {
306+
console.error(error);
307+
setStatus('Unable to merge PDFs. Please verify all uploaded files are valid PDFs.', 'error');
308+
} finally {
309+
mergeButton.disabled = files.length < 2;
310+
}
311+
};
312+
313+
pickFilesButton.addEventListener('click', () => fileInput.click());
314+
fileInput.addEventListener('change', event => {
315+
addFiles(event.target.files);
316+
fileInput.value = '';
317+
});
318+
319+
clearFilesButton.addEventListener('click', () => {
320+
files = [];
321+
setStatus('All files cleared.');
322+
render();
323+
});
324+
325+
['dragenter', 'dragover'].forEach(eventName => {
326+
dropzone.addEventListener(eventName, event => {
327+
event.preventDefault();
328+
dropzone.classList.add('dragover');
329+
});
330+
});
331+
332+
['dragleave', 'drop'].forEach(eventName => {
333+
dropzone.addEventListener(eventName, event => {
334+
event.preventDefault();
335+
dropzone.classList.remove('dragover');
336+
});
337+
});
338+
339+
dropzone.addEventListener('drop', event => {
340+
addFiles(event.dataTransfer.files);
341+
});
342+
343+
dropzone.addEventListener('keydown', event => {
344+
if (event.key === 'Enter' || event.key === ' ') {
345+
event.preventDefault();
346+
fileInput.click();
347+
}
348+
});
349+
350+
uploadedFilesElement.addEventListener('click', event => {
351+
const button = event.target.closest('button[data-remove-id]');
352+
if (!button) {
353+
return;
354+
}
355+
removeFileById(button.dataset.removeId);
356+
});
357+
358+
let draggedId = null;
359+
360+
orderListElement.addEventListener('dragstart', event => {
361+
const item = event.target.closest('.order-item');
362+
if (!item || !item.dataset.id) {
363+
return;
364+
}
365+
draggedId = item.dataset.id;
366+
item.classList.add('dragging');
367+
});
368+
369+
orderListElement.addEventListener('dragend', event => {
370+
const item = event.target.closest('.order-item');
371+
if (item) {
372+
item.classList.remove('dragging');
373+
}
374+
draggedId = null;
375+
});
376+
377+
orderListElement.addEventListener('dragover', event => {
378+
event.preventDefault();
379+
});
380+
381+
orderListElement.addEventListener('drop', event => {
382+
event.preventDefault();
383+
const targetItem = event.target.closest('.order-item');
384+
if (!targetItem || !targetItem.dataset.id) {
385+
return;
386+
}
387+
moveItem(draggedId, targetItem.dataset.id);
388+
});
389+
390+
mergeButton.addEventListener('click', mergePdfs);
391+
392+
render();
393+
})();
394+
</script>
395+
</body>
396+
397+
</html>

0 commit comments

Comments
 (0)