Skip to content

Commit 742ee3b

Browse files
add pdf features like merge convert etc. using pdf-lib and pdfjs-dist
1 parent 3d62e23 commit 742ee3b

14 files changed

Lines changed: 2455 additions & 0 deletions

package-lock.json

Lines changed: 381 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"@astrojs/sitemap": "^3.7.2",
1818
"@tailwindcss/vite": "^4.3.0",
1919
"astro": "^6.3.7",
20+
"pdf-lib": "^1.17.1",
21+
"pdfjs-dist": "^6.0.227",
2022
"react": "^19.2.6",
2123
"react-dom": "^19.2.6",
2224
"tailwindcss": "^4.3.0",
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
import { useCallback, useRef, useState } from "react";
2+
import { PDFDocument } from "pdf-lib";
3+
4+
type PageSize = "fit" | "a4" | "letter";
5+
type Orientation = "auto" | "portrait" | "landscape";
6+
7+
interface ImageItem {
8+
id: string;
9+
name: string;
10+
size: number;
11+
type: string;
12+
bytes: Uint8Array;
13+
previewUrl: string;
14+
width: number;
15+
height: number;
16+
}
17+
18+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
19+
const ACCEPTED = new Set(["image/png", "image/jpeg", "image/jpg"]);
20+
21+
// pt sizes (1 in = 72 pt)
22+
const SIZES: Record<Exclude<PageSize, "fit">, { w: number; h: number }> = {
23+
a4: { w: 595.28, h: 841.89 },
24+
letter: { w: 612, h: 792 },
25+
};
26+
27+
function formatBytes(bytes: number): string {
28+
if (bytes < 1024) return `${bytes} B`;
29+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
30+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
31+
}
32+
33+
function readImage(
34+
file: File,
35+
): Promise<{
36+
bytes: Uint8Array;
37+
width: number;
38+
height: number;
39+
previewUrl: string;
40+
}> {
41+
return new Promise((resolve, reject) => {
42+
const reader = new FileReader();
43+
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
44+
reader.onload = () => {
45+
const buf = reader.result as ArrayBuffer;
46+
const bytes = new Uint8Array(buf);
47+
const blob = new Blob([buf], { type: file.type });
48+
const previewUrl = URL.createObjectURL(blob);
49+
const img = new Image();
50+
img.onload = () => {
51+
resolve({
52+
bytes,
53+
width: img.naturalWidth,
54+
height: img.naturalHeight,
55+
previewUrl,
56+
});
57+
};
58+
img.onerror = () => {
59+
URL.revokeObjectURL(previewUrl);
60+
reject(new Error(`Failed to decode ${file.name}`));
61+
};
62+
img.src = previewUrl;
63+
};
64+
reader.readAsArrayBuffer(file);
65+
});
66+
}
67+
68+
function downloadBlob(blob: Blob, filename: string) {
69+
const url = URL.createObjectURL(blob);
70+
const a = document.createElement("a");
71+
a.href = url;
72+
a.download = filename;
73+
document.body.appendChild(a);
74+
a.click();
75+
document.body.removeChild(a);
76+
setTimeout(() => URL.revokeObjectURL(url), 1000);
77+
}
78+
79+
export default function ImagesToPdf() {
80+
const [images, setImages] = useState<ImageItem[]>([]);
81+
const [pageSize, setPageSize] = useState<PageSize>("fit");
82+
const [orientation, setOrientation] = useState<Orientation>("auto");
83+
const [margin, setMargin] = useState(24);
84+
const [busy, setBusy] = useState(false);
85+
const [error, setError] = useState<string | null>(null);
86+
const inputRef = useRef<HTMLInputElement>(null);
87+
88+
const handleFiles = useCallback(async (files: FileList | null) => {
89+
if (!files || files.length === 0) return;
90+
setError(null);
91+
setBusy(true);
92+
try {
93+
const added: ImageItem[] = [];
94+
for (const file of Array.from(files)) {
95+
const type = file.type.toLowerCase();
96+
if (!ACCEPTED.has(type)) {
97+
throw new Error(`"${file.name}" is not a PNG or JPEG`);
98+
}
99+
if (file.size > MAX_FILE_BYTES) {
100+
throw new Error(
101+
`"${file.name}" is larger than ${formatBytes(MAX_FILE_BYTES)}`,
102+
);
103+
}
104+
const { bytes, width, height, previewUrl } = await readImage(file);
105+
added.push({
106+
id: `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
107+
name: file.name,
108+
size: file.size,
109+
type,
110+
bytes,
111+
previewUrl,
112+
width,
113+
height,
114+
});
115+
}
116+
setImages((prev) => [...prev, ...added]);
117+
} catch (e) {
118+
setError(e instanceof Error ? e.message : "Failed to read image");
119+
} finally {
120+
setBusy(false);
121+
if (inputRef.current) inputRef.current.value = "";
122+
}
123+
}, []);
124+
125+
const move = (index: number, dir: -1 | 1) => {
126+
setImages((prev) => {
127+
const next = [...prev];
128+
const target = index + dir;
129+
if (target < 0 || target >= next.length) return prev;
130+
[next[index], next[target]] = [next[target], next[index]];
131+
return next;
132+
});
133+
};
134+
135+
const remove = (id: string) => {
136+
setImages((prev) => {
137+
const found = prev.find((i) => i.id === id);
138+
if (found) URL.revokeObjectURL(found.previewUrl);
139+
return prev.filter((i) => i.id !== id);
140+
});
141+
};
142+
143+
const clearAll = () => {
144+
setImages((prev) => {
145+
prev.forEach((i) => URL.revokeObjectURL(i.previewUrl));
146+
return [];
147+
});
148+
setError(null);
149+
};
150+
151+
const handleBuild = async () => {
152+
if (images.length === 0) {
153+
setError("Add at least one image");
154+
return;
155+
}
156+
setError(null);
157+
setBusy(true);
158+
try {
159+
const pdf = await PDFDocument.create();
160+
for (const img of images) {
161+
const embedded =
162+
img.type === "image/png"
163+
? await pdf.embedPng(img.bytes)
164+
: await pdf.embedJpg(img.bytes);
165+
166+
let pageW: number;
167+
let pageH: number;
168+
169+
if (pageSize === "fit") {
170+
// Page exactly fits the image plus margins
171+
pageW = embedded.width + margin * 2;
172+
pageH = embedded.height + margin * 2;
173+
} else {
174+
const base = SIZES[pageSize];
175+
const wantsLandscape =
176+
orientation === "landscape" ||
177+
(orientation === "auto" && embedded.width > embedded.height);
178+
pageW = wantsLandscape ? base.h : base.w;
179+
pageH = wantsLandscape ? base.w : base.h;
180+
}
181+
182+
const page = pdf.addPage([pageW, pageH]);
183+
const maxW = pageW - margin * 2;
184+
const maxH = pageH - margin * 2;
185+
const ratio = Math.min(maxW / embedded.width, maxH / embedded.height);
186+
const drawW = embedded.width * ratio;
187+
const drawH = embedded.height * ratio;
188+
const x = (pageW - drawW) / 2;
189+
const y = (pageH - drawH) / 2;
190+
page.drawImage(embedded, { x, y, width: drawW, height: drawH });
191+
}
192+
const bytes = await pdf.save();
193+
downloadBlob(
194+
new Blob([bytes.buffer as ArrayBuffer], { type: "application/pdf" }),
195+
"images.pdf",
196+
);
197+
} catch (e) {
198+
setError(e instanceof Error ? e.message : "PDF build failed");
199+
} finally {
200+
setBusy(false);
201+
}
202+
};
203+
204+
return (
205+
<div className="space-y-6">
206+
<div className="flex justify-end">
207+
<button
208+
onClick={clearAll}
209+
className="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:underline"
210+
>
211+
Reset
212+
</button>
213+
</div>
214+
215+
{error && (
216+
<div
217+
role="alert"
218+
className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm"
219+
>
220+
{error}
221+
</div>
222+
)}
223+
224+
<label
225+
htmlFor="images-pdf-input"
226+
className="flex flex-col items-center justify-center gap-2 px-6 py-10 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-900 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
227+
>
228+
<span className="text-3xl" aria-hidden="true">
229+
🖼️
230+
</span>
231+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
232+
Choose PNG or JPEG images
233+
</span>
234+
<span className="text-xs text-gray-500 dark:text-gray-400">
235+
Files stay on your device. Max {formatBytes(MAX_FILE_BYTES)} each.
236+
</span>
237+
<input
238+
ref={inputRef}
239+
id="images-pdf-input"
240+
type="file"
241+
accept="image/png,image/jpeg,.png,.jpg,.jpeg"
242+
multiple
243+
className="sr-only"
244+
onChange={(e) => handleFiles(e.target.files)}
245+
/>
246+
</label>
247+
248+
{images.length > 0 && (
249+
<>
250+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
251+
<div className="space-y-1">
252+
<label
253+
htmlFor="images-pdf-pagesize"
254+
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
255+
>
256+
Page size
257+
</label>
258+
<select
259+
id="images-pdf-pagesize"
260+
value={pageSize}
261+
onChange={(e) => setPageSize(e.target.value as PageSize)}
262+
className="w-full p-2.5 text-sm bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
263+
>
264+
<option value="fit">Fit to image</option>
265+
<option value="a4">A4 (210×297mm)</option>
266+
<option value="letter">US Letter (8.5×11in)</option>
267+
</select>
268+
</div>
269+
270+
<div className="space-y-1">
271+
<label
272+
htmlFor="images-pdf-orient"
273+
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
274+
>
275+
Orientation
276+
</label>
277+
<select
278+
id="images-pdf-orient"
279+
value={orientation}
280+
disabled={pageSize === "fit"}
281+
onChange={(e) => setOrientation(e.target.value as Orientation)}
282+
className="w-full p-2.5 text-sm bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
283+
>
284+
<option value="auto">Auto (per image)</option>
285+
<option value="portrait">Portrait</option>
286+
<option value="landscape">Landscape</option>
287+
</select>
288+
</div>
289+
290+
<div className="space-y-1">
291+
<label
292+
htmlFor="images-pdf-margin"
293+
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
294+
>
295+
Margin: {margin} pt
296+
</label>
297+
<input
298+
id="images-pdf-margin"
299+
type="range"
300+
min={0}
301+
max={72}
302+
step={1}
303+
value={margin}
304+
onChange={(e) => setMargin(parseInt(e.target.value, 10))}
305+
className="w-full"
306+
/>
307+
</div>
308+
</div>
309+
310+
<div className="space-y-2">
311+
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
312+
<span>
313+
{images.length} image{images.length === 1 ? "" : "s"}
314+
</span>
315+
<span>Use arrows to reorder</span>
316+
</div>
317+
<ul className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
318+
{images.map((img, i) => (
319+
<li
320+
key={img.id}
321+
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-900"
322+
>
323+
<div className="relative">
324+
<img
325+
src={img.previewUrl}
326+
alt={img.name}
327+
loading="lazy"
328+
className="w-full h-32 object-contain bg-gray-50 dark:bg-gray-800"
329+
/>
330+
<span className="absolute top-1 left-1 px-1.5 py-0.5 text-xs font-mono bg-black/60 text-white rounded">
331+
{i + 1}
332+
</span>
333+
</div>
334+
<div className="p-2 space-y-1.5">
335+
<p className="truncate text-xs font-medium text-gray-900 dark:text-white">
336+
{img.name}
337+
</p>
338+
<p className="text-xs text-gray-500 dark:text-gray-400">
339+
{img.width}×{img.height}{formatBytes(img.size)}
340+
</p>
341+
<div className="flex gap-1">
342+
<button
343+
onClick={() => move(i, -1)}
344+
disabled={i === 0}
345+
aria-label={`Move ${img.name} earlier`}
346+
className="flex-1 p-1.5 text-xs rounded bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-30 disabled:cursor-not-allowed focus-visible:ring-2 focus-visible:ring-blue-500"
347+
>
348+
349+
</button>
350+
<button
351+
onClick={() => move(i, 1)}
352+
disabled={i === images.length - 1}
353+
aria-label={`Move ${img.name} later`}
354+
className="flex-1 p-1.5 text-xs rounded bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-30 disabled:cursor-not-allowed focus-visible:ring-2 focus-visible:ring-blue-500"
355+
>
356+
357+
</button>
358+
<button
359+
onClick={() => remove(img.id)}
360+
aria-label={`Remove ${img.name}`}
361+
className="flex-1 p-1.5 text-xs rounded text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 focus-visible:ring-2 focus-visible:ring-red-500"
362+
>
363+
364+
</button>
365+
</div>
366+
</div>
367+
</li>
368+
))}
369+
</ul>
370+
</div>
371+
372+
<button
373+
onClick={handleBuild}
374+
disabled={busy || images.length === 0}
375+
className="w-full px-5 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-800"
376+
>
377+
{busy
378+
? "Building…"
379+
: `Build PDF (${images.length} page${images.length === 1 ? "" : "s"})`}
380+
</button>
381+
</>
382+
)}
383+
</div>
384+
);
385+
}

0 commit comments

Comments
 (0)