From 5abb7fe09cb73740d0983b4b60a975f9ee257175 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:25:32 +0700 Subject: [PATCH 1/2] feat(pdf,image): add Sign PDF, Organize PDF, and Favicon Generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sign PDF: draw or upload a signature and drag it onto a PDF page (multi-page), then download the signed file — via pdf-lib embedPng. - Organize PDF: drag-to-reorder and delete page thumbnails, optional page numbers — rebuilds page order with pdf-lib copyPages. - Favicon Generator: one image → favicon.ico + PNGs + Apple touch icon + web manifest + HTML snippet, downloaded as a ZIP. Pure geometry/manifest helpers unit-tested; EN + ID SEO with how-to for each. --- .../plans/2026-08-14-pdf-favicon-sign.md | 20 ++ src/islands/image/FaviconGenerator.tsx | 115 ++++++++ src/islands/pdf/PdfOrganize.tsx | 189 +++++++++++++ src/islands/pdf/PdfSign.tsx | 263 ++++++++++++++++++ src/registry/tool-seo.ts | 102 +++++++ src/registry/tools.ts | 35 ++- src/tools/image/favicon.lib.test.ts | 26 ++ src/tools/image/favicon.lib.ts | 72 +++++ src/tools/pdf/layout.lib.test.ts | 32 +++ src/tools/pdf/layout.lib.ts | 62 +++++ src/tools/pdf/pdf.lib.ts | 51 ++++ 11 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md create mode 100644 src/islands/image/FaviconGenerator.tsx create mode 100644 src/islands/pdf/PdfOrganize.tsx create mode 100644 src/islands/pdf/PdfSign.tsx create mode 100644 src/tools/image/favicon.lib.test.ts create mode 100644 src/tools/image/favicon.lib.ts create mode 100644 src/tools/pdf/layout.lib.test.ts create mode 100644 src/tools/pdf/layout.lib.ts diff --git a/docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md b/docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md new file mode 100644 index 0000000..6ad1d7e --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md @@ -0,0 +1,20 @@ +# Sign PDF + Organize PDF + Favicon Generator — Plan + +**Date:** 2026-08-14. One branch/PR/promotion. + +## Favicon Generator (Image, icon AppWindow, `favicon-generator`) +Lib `src/tools/image/favicon.lib.ts`: `buildManifest`/`htmlSnippet` (pure, tested); `generateFavicons(file, name)` → ICO (`imageToIco`) + PNGs (`processImage` per size) + manifest + snippet. Island → Dropzone → preview grid → `createZip` + download. + +## Organize PDF (PDF, icon ListOrdered, `pdf-organize`) +Pure `src/tools/pdf/layout.lib.ts`: `pageNumberXY` (tested). `pdf.lib.ts` gains `organizePdf(file, order, pageNumbers?)` — mupdf-normalize → copy pages in `order` into fresh doc → optional `drawText` page numbers. Island: `openPdfRenderer` thumbnails, native HTML5 drag reorder + delete, page-number controls. + +## Sign PDF (PDF, icon FileSignature, `pdf-sign`) +Pure `layout.lib.ts`: `placementToPdfRect` (top-left ratio → bottom-left rect, y-flip; tested). `pdf.lib.ts` gains `signPdf(file, pngBytes, placements)` — embedPng + drawImage per placement. Island: `signature_pad` draw or PNG upload; drag-to-place over `openPdfRenderer` page; multi-page placements → `signPdf`. + +## Notes +- pdf.lib gotcha honored: copy pages into a fresh `PDFDocument.create()` (never draw on the mupdf-loaded doc). +- No new DnD dep — native HTML5 drag for reorder. +- EN + ID SEO for all three (Sign PDF FAQ flags e-signature legality caveat). + +## DoD +libs unit-tested · EN+ID SEO · vitest+lint+build green · 6 new routes · develop→main→live verified. diff --git a/src/islands/image/FaviconGenerator.tsx b/src/islands/image/FaviconGenerator.tsx new file mode 100644 index 0000000..ec646ad --- /dev/null +++ b/src/islands/image/FaviconGenerator.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { usePasteImage } from '@/hooks/usePasteImage'; +import { generateFavicons, type FaviconFile } from '@/tools/image/favicon.lib'; +import { createZip } from '@/tools/files/zip.lib'; +import { downloadService } from '@/services/download'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + intro: 'Turn any image (ideally square, 512×512 or larger) into a complete favicon set — favicon.ico, PNGs, an Apple touch icon and a web manifest. Everything runs in your browser.', + drop: 'Drop an image or click to browse', + dropSub: 'A square PNG works best · or paste (⌘V)', + working: 'Generating favicons…', + failed: 'Could not process that image.', + result: 'Favicon set', + downloadZip: 'Download all (ZIP)', + }, + id: { + intro: 'Ubah gambar apa pun (idealnya persegi, 512×512 atau lebih) menjadi set favicon lengkap — favicon.ico, PNG, Apple touch icon, dan web manifest. Semuanya berjalan di browser Anda.', + drop: 'Letakkan gambar atau klik untuk memilih', + dropSub: 'PNG persegi paling bagus · atau tempel (⌘V)', + working: 'Membuat favicon…', + failed: 'Tidak dapat memproses gambar itu.', + result: 'Set favicon', + downloadZip: 'Unduh semua (ZIP)', + }, +}; + +export default function FaviconGenerator({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [files, setFiles] = useState([]); + const [urls, setUrls] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => () => { Object.values(urls).forEach(URL.revokeObjectURL); }, [urls]); + + const onDrop = async (dropped: File[]) => { + const file = dropped.find(f => f.type.startsWith('image/')); + if (!file) return; + setBusy(true); + setError(''); + Object.values(urls).forEach(URL.revokeObjectURL); + setFiles([]); + setUrls({}); + try { + const out = await generateFavicons(file, 'My Site'); + const u: Record = {}; + for (const f of out) if (f.size) u[f.name] = URL.createObjectURL(f.blob); + setFiles(out); + setUrls(u); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setBusy(false); + } + }; + + usePasteImage(f => onDrop([f])); + + const downloadZip = async () => { + const entries = await Promise.all( + files.map(async f => ({ name: f.name, data: new Uint8Array(await f.blob.arrayBuffer()) })), + ); + await downloadService.download(new Blob([createZip(entries)], { type: 'application/zip' }), 'favicons.zip'); + }; + + const previews = files.filter(f => f.size); + + return ( +
+

{t.intro}

+ + +
+

{t.drop}

+

{t.dropSub}

+
+
+ + {busy &&

{t.working}

} + {error && {error}} + + {previews.length > 0 && ( +
+
+ {t.result} + +
+
+ {previews.map(f => ( +
+
+ {f.name} +
+ {f.size}×{f.size} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/islands/pdf/PdfOrganize.tsx b/src/islands/pdf/PdfOrganize.tsx new file mode 100644 index 0000000..7d17fb4 --- /dev/null +++ b/src/islands/pdf/PdfOrganize.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ResultActions } from '@/components/ui/ResultActions'; +import { PdfPreview } from '@/components/ui/PdfPreview'; +import { openPdfRenderer } from '@/tools/pdf/render.lib'; +import { organizePdf } from '@/tools/pdf/pdf.lib'; +import type { PageNumberPosition } from '@/tools/pdf/layout.lib'; +import type { Lang } from '@/i18n/config'; + +const POSITIONS: PageNumberPosition[] = ['bottom-center', 'bottom-right', 'bottom-left', 'top-center', 'top-right', 'top-left']; + +const TR: Record = { + en: { + intro: 'Reorder or delete PDF pages by dragging the thumbnails, and optionally add page numbers. Everything runs in your browser — the PDF is never uploaded.', + drop: 'Drop a PDF or click to browse', dropSub: 'Organized on your device', + loading: 'Loading pages…', failed: 'Could not open this PDF.', + reorderHint: 'Drag pages to reorder · click ✕ to delete', + pageNumbers: 'Add page numbers', position: 'Position', startAt: 'Start at', + apply: 'Apply & download', working: 'Building…', remove: 'Remove', empty: 'All pages removed — add at least one back.', + }, + id: { + intro: 'Susun ulang atau hapus halaman PDF dengan menyeret thumbnail, dan opsional tambahkan nomor halaman. Semuanya berjalan di browser Anda — PDF tidak pernah diunggah.', + drop: 'Letakkan PDF atau klik untuk memilih', dropSub: 'Disusun di perangkat Anda', + loading: 'Memuat halaman…', failed: 'Tidak dapat membuka PDF ini.', + reorderHint: 'Seret halaman untuk menyusun ulang · klik ✕ untuk menghapus', + pageNumbers: 'Tambahkan nomor halaman', position: 'Posisi', startAt: 'Mulai dari', + apply: 'Terapkan & unduh', working: 'Membuat…', remove: 'Hapus', empty: 'Semua halaman dihapus — tambahkan minimal satu.', + }, +}; + +export default function PdfOrganize({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [file, setFile] = useState(null); + const [urls, setUrls] = useState>({}); + const [order, setOrder] = useState([]); + const [numbers, setNumbers] = useState(false); + const [position, setPosition] = useState('bottom-center'); + const [startAt, setStartAt] = useState(1); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const dragFrom = useRef(null); + const urlsRef = useRef>({}); + + useEffect(() => { urlsRef.current = urls; }, [urls]); + useEffect(() => () => { Object.values(urlsRef.current).forEach(URL.revokeObjectURL); }, []); + + const onDrop = async (files: File[]) => { + const f = files.find(x => x.type === 'application/pdf' || x.name.toLowerCase().endsWith('.pdf')); + if (!f) return; + setFile(f); + setResult(null); + setError(''); + setLoading(true); + Object.values(urlsRef.current).forEach(URL.revokeObjectURL); + setUrls({}); + setOrder([]); + try { + const renderer = await openPdfRenderer(await f.arrayBuffer()); + const next: Record = {}; + for (let i = 1; i <= renderer.pageCount; i++) { + const page = await renderer.renderPage(i, 0.4); + next[i - 1] = URL.createObjectURL(page.blob); + } + renderer.destroy(); + setUrls(next); + setOrder(Array.from({ length: renderer.pageCount }, (_, i) => i)); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setLoading(false); + } + }; + + const onDropThumb = (toPos: number) => { + const from = dragFrom.current; + dragFrom.current = null; + if (from === null || from === toPos) return; + setOrder(prev => { + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(toPos, 0, moved); + return next; + }); + }; + + const removePage = (pos: number) => setOrder(prev => prev.filter((_, i) => i !== pos)); + + const apply = async () => { + if (!file || order.length === 0) return; + setBusy(true); + setError(''); + try { + const out = await organizePdf(file, order, numbers + ? { enabled: true, position, startAt, fontSize: 11, margin: 24 } + : undefined); + setResult(out); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t.intro}

+ + {!file && ( + +
+

{t.drop}

+

{t.dropSub}

+
+
+ )} + + {loading &&

{t.loading}

} + {error && {error}} + + {order.length > 0 && ( + <> +

{t.reorderHint}

+
+ {order.map((pageIdx, pos) => ( +
{ dragFrom.current = pos; }} + onDragOver={e => e.preventDefault()} + onDrop={() => onDropThumb(pos)} + className="relative cursor-move border-2 border-border bg-muted p-1" + > + {`page + {pos + 1} + +
+ ))} +
+ +
+ + {numbers && ( + <> + + + + )} +
+ + {order.length === 0 && {t.empty}} + + + + )} + + {result && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/islands/pdf/PdfSign.tsx b/src/islands/pdf/PdfSign.tsx new file mode 100644 index 0000000..a234b6c --- /dev/null +++ b/src/islands/pdf/PdfSign.tsx @@ -0,0 +1,263 @@ +import { useEffect, useRef, useState } from 'react'; +import SignaturePadLib from 'signature_pad'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ResultActions } from '@/components/ui/ResultActions'; +import { openPdfRenderer, type PdfRenderer } from '@/tools/pdf/render.lib'; +import { signPdf } from '@/tools/pdf/pdf.lib'; +import type { SignPlacement } from '@/tools/pdf/layout.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record string; apply: string; working: string; noSig: string; +}> = { + en: { + intro: 'Add your signature to a PDF: draw or upload it, drag it onto the page, and download the signed file. Everything runs in your browser — nothing is uploaded.', + drop: 'Drop a PDF or click to browse', dropSub: 'Signed on your device', + loading: 'Loading…', failed: 'Something went wrong.', + draw: 'Draw', upload: 'Upload PNG', clear: 'Clear', useSig: 'Use this signature', + sigHint: 'Draw your signature above, then click “Use this signature”.', + page: 'Page', prev: 'Prev', next: 'Next', + placeHint: 'Drag the signature to position it, then add it to the page.', + size: 'Size', addHere: 'Add to this page', + placements: n => `${n} placement${n === 1 ? '' : 's'}`, + apply: 'Sign & download', working: 'Signing…', noSig: 'Create a signature first.', + }, + id: { + intro: 'Tambahkan tanda tangan ke PDF: gambar atau unggah, seret ke halaman, lalu unduh berkas yang sudah ditandatangani. Semuanya berjalan di browser Anda — tidak ada yang diunggah.', + drop: 'Letakkan PDF atau klik untuk memilih', dropSub: 'Ditandatangani di perangkat Anda', + loading: 'Memuat…', failed: 'Terjadi kesalahan.', + draw: 'Gambar', upload: 'Unggah PNG', clear: 'Bersihkan', useSig: 'Pakai tanda tangan ini', + sigHint: 'Gambar tanda tangan Anda di atas, lalu klik “Pakai tanda tangan ini”.', + page: 'Halaman', prev: 'Sebelumnya', next: 'Berikutnya', + placeHint: 'Seret tanda tangan untuk memosisikannya, lalu tambahkan ke halaman.', + size: 'Ukuran', addHere: 'Tambahkan ke halaman ini', + placements: n => `${n} penempatan`, + apply: 'Tandatangani & unduh', working: 'Menandatangani…', noSig: 'Buat tanda tangan dulu.', + }, +}; + +interface Sig { url: string; bytes: Uint8Array; aspect: number } + +function dataUrlToBytes(dataUrl: string): Uint8Array { + const b64 = dataUrl.split(',')[1]; + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export default function PdfSign({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [file, setFile] = useState(null); + const [pageCount, setPageCount] = useState(0); + const [pageNum, setPageNum] = useState(1); + const [pageUrl, setPageUrl] = useState(''); + const [mode, setMode] = useState<'draw' | 'upload'>('draw'); + const [sig, setSig] = useState(null); + const [box, setBox] = useState({ x: 0.3, y: 0.72, w: 0.32 }); // ratios (top-left origin) + const [placements, setPlacements] = useState([]); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const rendererRef = useRef(null); + const padCanvasRef = useRef(null); + const padRef = useRef(null); + const pageBoxRef = useRef(null); + const dragRef = useRef<{ dx: number; dy: number } | null>(null); + + // Attach the signature pad when in draw mode. + useEffect(() => { + if (mode !== 'draw' || !padCanvasRef.current) return; + const canvas = padCanvasRef.current; + const ratio = Math.max(window.devicePixelRatio || 1, 1); + canvas.width = canvas.offsetWidth * ratio; + canvas.height = canvas.offsetHeight * ratio; + canvas.getContext('2d')?.scale(ratio, ratio); + const pad = new SignaturePadLib(canvas, { penColor: '#111827' }); + padRef.current = pad; + return () => pad.off(); + }, [mode, file]); + + useEffect(() => () => { rendererRef.current?.destroy(); if (sig) URL.revokeObjectURL(sig.url); }, [sig]); + + const renderPage = async (renderer: PdfRenderer, n: number) => { + const page = await renderer.renderPage(n, 1.3); + setPageUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(page.blob); }); + }; + + const onDrop = async (files: File[]) => { + const f = files.find(x => x.type === 'application/pdf' || x.name.toLowerCase().endsWith('.pdf')); + if (!f) return; + setFile(f); setResult(null); setError(''); setPlacements([]); + try { + const renderer = await openPdfRenderer(await f.arrayBuffer()); + rendererRef.current = renderer; + setPageCount(renderer.pageCount); + setPageNum(1); + await renderPage(renderer, 1); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } + }; + + const goPage = async (n: number) => { + if (!rendererRef.current || n < 1 || n > pageCount) return; + setPageNum(n); + await renderPage(rendererRef.current, n); + }; + + const setSignature = (url: string, bytes: Uint8Array, aspect: number) => { + setSig(prev => { if (prev) URL.revokeObjectURL(prev.url); return { url, bytes, aspect }; }); + }; + + const useDrawnSig = () => { + const pad = padRef.current; + if (!pad || pad.isEmpty()) { setError(t.noSig); return; } + setError(''); + const dataUrl = pad.toDataURL('image/png'); + const c = padCanvasRef.current!; + setSignature(dataUrl, dataUrlToBytes(dataUrl), c.width / c.height); + }; + + const onUpload = async (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (!f) return; + const bytes = new Uint8Array(await f.arrayBuffer()); + const url = URL.createObjectURL(f); + const img = new Image(); + img.onload = () => setSignature(url, bytes, img.naturalWidth / img.naturalHeight); + img.src = url; + }; + + const onPointerDown = (e: React.PointerEvent) => { + const rect = pageBoxRef.current?.getBoundingClientRect(); + if (!rect) return; + dragRef.current = { dx: e.clientX - (rect.left + box.x * rect.width), dy: e.clientY - (rect.top + box.y * rect.height) }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!dragRef.current) return; + const rect = pageBoxRef.current!.getBoundingClientRect(); + const x = (e.clientX - dragRef.current.dx - rect.left) / rect.width; + const y = (e.clientY - dragRef.current.dy - rect.top) / rect.height; + setBox(b => ({ ...b, x: Math.min(Math.max(x, 0), 1 - b.w), y: Math.min(Math.max(y, 0), 1) })); + }; + const onPointerUp = () => { dragRef.current = null; }; + + const addPlacement = () => + setPlacements(p => [...p, { pageIndex: pageNum - 1, xRatio: box.x, yRatio: box.y, wRatio: box.w }]); + + const apply = async () => { + if (!file || !sig || placements.length === 0) return; + setBusy(true); setError(''); + try { + setResult(await signPdf(file, sig.bytes, placements)); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t.intro}

+ + {!file && ( + +
+

{t.drop}

+

{t.dropSub}

+
+
+ )} + + {error && {error}} + + {file && ( +
+ {/* Page with draggable signature */} +
+ {pageUrl && ( +
+ {`page + {sig && ( + signature + )} +
+ )} +
+ + {t.page} {pageNum} / {pageCount} + +
+
+ + {/* Signature panel */} +
+
+ {(['draw', 'upload'] as const).map(m => ( + + ))} +
+ + {mode === 'draw' ? ( +
+ +
+ + +
+

{t.sigHint}

+
+ ) : ( + + )} + + {sig && ( +
+

{t.placeHint}

+ + +

{t.placements(placements.length)}

+ +
+ )} +
+
+ )} + + {result && } +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index bf83152..f46c0dd 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -296,6 +296,57 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, ], }, + 'favicon-generator': { + title: 'Free Favicon Generator — ICO, PNG & Web Manifest', + description: 'Turn any image into a complete favicon set — favicon.ico, PNGs, Apple touch icon and a web manifest — then download them as a ZIP. Runs in your browser, nothing is uploaded.', + intro: 'This free favicon generator turns one image into every favicon a website needs: a multi-size favicon.ico, 16/32/48px PNGs, a 180px Apple touch icon, 192/512px Android/PWA icons, plus a site.webmanifest and the HTML snippet to paste into your . Everything is generated in your browser and downloaded as a ZIP — nothing is uploaded.', + howTo: [ + 'Drop or paste a square image (512×512 or larger works best).', + 'The favicon set is generated instantly on your device.', + 'Preview the sizes, then download the whole set as a ZIP.', + 'Unzip it into your site root and paste the included HTML snippet.', + ], + faqs: [ + { q: 'Is my image uploaded?', a: 'No. All resizing and encoding happen in your browser, so your image never leaves your device.' }, + { q: 'What files do I get?', a: 'favicon.ico (16/32/48), favicon-16/32/48 PNGs, apple-touch-icon (180px), android-chrome 192 and 512 PNGs, a site.webmanifest and an HTML snippet.' }, + { q: 'What image should I use?', a: 'A square image at least 512×512 works best so the largest icons stay sharp. Simple, high-contrast art reads best at 16px.' }, + { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps generating favicons with no connection once loaded.' }, + ], + }, + 'pdf-organize': { + title: 'Free Organize PDF — Reorder, Delete Pages & Add Page Numbers', + description: 'Organize a PDF in your browser: drag to reorder pages, delete pages, and add page numbers, then download. Nothing is uploaded.', + intro: 'This free Organize PDF tool lets you rearrange a PDF by dragging its page thumbnails, delete pages you don’t need, and optionally stamp page numbers — all in your browser. Your PDF is never uploaded, so it stays private.', + howTo: [ + 'Drop your PDF — its pages appear as thumbnails.', + 'Drag pages to reorder them, or click ✕ to delete a page.', + 'Optionally turn on page numbers and choose their position.', + 'Click Apply and download the reorganized PDF.', + ], + faqs: [ + { q: 'Is my PDF uploaded?', a: 'No. The PDF is rendered and rebuilt entirely in your browser, so it never leaves your device.' }, + { q: 'Can I delete pages too?', a: 'Yes. Click the ✕ on any page thumbnail to remove it; the rest keep your chosen order.' }, + { q: 'Where can page numbers go?', a: 'You can place page numbers at the bottom or top, aligned left, center or right, and choose the starting number.' }, + { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, + ], + }, + 'pdf-sign': { + title: 'Free Sign PDF — Add Your Signature to a PDF Online', + description: 'Sign a PDF in your browser: draw or upload your signature, drag it onto the page, and download the signed file. Private — nothing is uploaded.', + intro: 'This free Sign PDF tool lets you add a signature to a PDF without printing or scanning. Draw your signature with the mouse or touch, or upload a PNG, then drag it onto the page, size it, and place it on one or more pages. Everything happens in your browser, so your document is never uploaded.', + howTo: [ + 'Drop your PDF to open it.', + 'Draw your signature or upload a transparent PNG, then click “Use this signature”.', + 'Drag the signature onto the page and adjust its size.', + 'Add it to the page (repeat for other pages), then click Sign & download.', + ], + faqs: [ + { q: 'Is my document uploaded to a server?', a: 'No. The PDF and your signature stay in your browser — signing happens locally and nothing is uploaded, which matters for confidential documents.' }, + { q: 'Can I sign more than one page?', a: 'Yes. Position the signature and add it to each page you need; all placements are applied when you download.' }, + { q: 'Can I upload an existing signature image?', a: 'Yes. Upload a PNG (ideally with a transparent background) instead of drawing, and place it the same way.' }, + { q: 'Is a drawn signature legally binding?', a: 'A drawn or image signature is a simple electronic signature. Whether it is legally sufficient depends on your jurisdiction and the document; check local requirements for important agreements.' }, + ], + }, 'compare-lists': { title: 'Compare Two Lists — Merge, Dedupe & Diff Lines', description: 'Compare two lists of lines online: merge and remove duplicates, subtract one list from another, or find common lines. Free, private and instant — nothing is uploaded.', @@ -2094,6 +2145,57 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, ], }, + 'favicon-generator': { + title: 'Generator Favicon Gratis — ICO, PNG & Web Manifest', + description: 'Ubah gambar apa pun menjadi set favicon lengkap — favicon.ico, PNG, Apple touch icon, dan web manifest — lalu unduh sebagai ZIP. Berjalan di browser Anda, tidak ada yang diunggah.', + intro: 'Tool generator favicon gratis ini mengubah satu gambar menjadi semua favicon yang dibutuhkan situs web: favicon.ico multi-ukuran, PNG 16/32/48px, Apple touch icon 180px, ikon Android/PWA 192/512px, ditambah site.webmanifest dan snippet HTML untuk ditempel ke . Semuanya dibuat di browser Anda dan diunduh sebagai ZIP — tidak ada yang diunggah.', + howTo: [ + 'Letakkan atau tempel gambar persegi (512×512 atau lebih paling bagus).', + 'Set favicon dibuat seketika di perangkat Anda.', + 'Pratinjau ukurannya, lalu unduh seluruh set sebagai ZIP.', + 'Ekstrak ke root situs Anda dan tempel snippet HTML yang disertakan.', + ], + faqs: [ + { q: 'Apakah gambar saya diunggah?', a: 'Tidak. Semua pengubahan ukuran dan encoding terjadi di browser Anda, jadi gambar tidak pernah meninggalkan perangkat.' }, + { q: 'File apa saja yang saya dapatkan?', a: 'favicon.ico (16/32/48), PNG favicon-16/32/48, apple-touch-icon (180px), PNG android-chrome 192 dan 512, site.webmanifest, dan snippet HTML.' }, + { q: 'Gambar seperti apa yang sebaiknya dipakai?', a: 'Gambar persegi minimal 512×512 paling bagus agar ikon terbesar tetap tajam. Desain sederhana berkontras tinggi paling terbaca pada 16px.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap membuat favicon tanpa koneksi setelah dimuat.' }, + ], + }, + 'pdf-organize': { + title: 'Organize PDF Gratis — Susun Ulang, Hapus Halaman & Nomor Halaman', + description: 'Susun PDF di browser Anda: seret untuk menyusun ulang halaman, hapus halaman, dan tambahkan nomor halaman, lalu unduh. Tidak ada yang diunggah.', + intro: 'Tool Organize PDF gratis ini memungkinkan Anda menyusun ulang PDF dengan menyeret thumbnail halaman, menghapus halaman yang tidak diperlukan, dan opsional membubuhkan nomor halaman — semua di browser Anda. PDF Anda tidak pernah diunggah, jadi tetap privat.', + howTo: [ + 'Letakkan PDF Anda — halamannya muncul sebagai thumbnail.', + 'Seret halaman untuk menyusun ulang, atau klik ✕ untuk menghapus halaman.', + 'Opsional aktifkan nomor halaman dan pilih posisinya.', + 'Klik Terapkan dan unduh PDF yang telah disusun ulang.', + ], + faqs: [ + { q: 'Apakah PDF saya diunggah?', a: 'Tidak. PDF dirender dan dibangun ulang sepenuhnya di browser Anda, jadi tidak pernah meninggalkan perangkat.' }, + { q: 'Bisakah menghapus halaman juga?', a: 'Ya. Klik ✕ pada thumbnail halaman mana pun untuk menghapusnya; sisanya mempertahankan urutan pilihan Anda.' }, + { q: 'Di mana nomor halaman bisa diletakkan?', a: 'Anda bisa menempatkan nomor halaman di bawah atau atas, rata kiri, tengah, atau kanan, dan memilih nomor awal.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, + ], + }, + 'pdf-sign': { + title: 'Sign PDF Gratis — Tambahkan Tanda Tangan ke PDF Online', + description: 'Tandatangani PDF di browser Anda: gambar atau unggah tanda tangan, seret ke halaman, lalu unduh berkasnya. Privat — tidak ada yang diunggah.', + intro: 'Tool Sign PDF gratis ini memungkinkan Anda menambahkan tanda tangan ke PDF tanpa mencetak atau memindai. Gambar tanda tangan dengan mouse atau sentuhan, atau unggah PNG, lalu seret ke halaman, atur ukurannya, dan tempatkan di satu atau beberapa halaman. Semuanya terjadi di browser Anda, jadi dokumen tidak pernah diunggah.', + howTo: [ + 'Letakkan PDF Anda untuk membukanya.', + 'Gambar tanda tangan atau unggah PNG transparan, lalu klik “Pakai tanda tangan ini”.', + 'Seret tanda tangan ke halaman dan sesuaikan ukurannya.', + 'Tambahkan ke halaman (ulangi untuk halaman lain), lalu klik Tandatangani & unduh.', + ], + faqs: [ + { q: 'Apakah dokumen saya diunggah ke server?', a: 'Tidak. PDF dan tanda tangan Anda tetap di browser — penandatanganan terjadi lokal dan tidak ada yang diunggah, penting untuk dokumen rahasia.' }, + { q: 'Bisakah menandatangani lebih dari satu halaman?', a: 'Ya. Posisikan tanda tangan dan tambahkan ke tiap halaman yang diperlukan; semua penempatan diterapkan saat Anda mengunduh.' }, + { q: 'Bisakah mengunggah gambar tanda tangan yang sudah ada?', a: 'Ya. Unggah PNG (idealnya berlatar transparan) alih-alih menggambar, dan tempatkan dengan cara yang sama.' }, + { q: 'Apakah tanda tangan gambar sah secara hukum?', a: 'Tanda tangan gambar adalah tanda tangan elektronik sederhana. Keabsahannya bergantung pada yurisdiksi dan dokumen Anda; periksa ketentuan setempat untuk perjanjian penting.' }, + ], + }, 'compare-lists': { title: 'Bandingkan Dua Daftar — Gabung, Hapus Duplikat & Diff', description: 'Bandingkan dua daftar baris secara online: gabung dan hapus duplikat, kurangi satu daftar dari yang lain, atau temukan baris yang sama. Gratis, privat, instan — tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index d76da0f..644e22f 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -355,6 +355,39 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/dev/TextCleanup'), status: 'beta' }, + { + id: 'favicon-generator', + name: 'Favicon Generator', + category: 'Image', + route: '/tools/favicon-generator', + keywords: ['favicon', 'generator', 'ico', 'apple touch icon', 'manifest', 'website icon', 'png'], + icon: AppWindow, + summary: 'Turn an image into a favicon set (ICO, PNGs, manifest)', + load: () => import('@/islands/image/FaviconGenerator'), + status: 'beta' + }, + { + id: 'pdf-organize', + name: 'Organize PDF', + category: 'PDF', + route: '/tools/pdf-organize', + keywords: ['organize pdf', 'reorder pages', 'rearrange', 'delete pages', 'page numbers', 'sort pdf'], + icon: ListOrdered, + summary: 'Drag to reorder or delete PDF pages and add page numbers', + load: () => import('@/islands/pdf/PdfOrganize'), + status: 'beta' + }, + { + id: 'pdf-sign', + name: 'Sign PDF', + category: 'PDF', + route: '/tools/pdf-sign', + keywords: ['sign pdf', 'esign', 'signature', 'e-signature', 'sign document', 'draw signature'], + icon: FileSignature, + summary: 'Draw or upload a signature and place it on a PDF', + load: () => import('@/islands/pdf/PdfSign'), + status: 'beta' + }, { id: 'cron-expression', name: 'Cron Expression', diff --git a/src/tools/image/favicon.lib.test.ts b/src/tools/image/favicon.lib.test.ts new file mode 100644 index 0000000..80c6d7a --- /dev/null +++ b/src/tools/image/favicon.lib.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { buildManifest, htmlSnippet, FAVICON_SIZES } from './favicon.lib'; + +describe('buildManifest', () => { + it('is valid JSON referencing the chrome icons', () => { + const m = JSON.parse(buildManifest('My Site')); + expect(m.name).toBe('My Site'); + expect(m.icons.map((i: { sizes: string }) => i.sizes)).toEqual(['192x192', '512x512']); + expect(m.display).toBe('standalone'); + }); +}); + +describe('htmlSnippet', () => { + it('includes the ico, png, apple-touch and manifest links', () => { + const s = htmlSnippet(); + expect(s).toContain('favicon.ico'); + expect(s).toContain('apple-touch-icon'); + expect(s).toContain('site.webmanifest'); + }); +}); + +describe('FAVICON_SIZES', () => { + it('covers the standard sizes', () => { + expect(FAVICON_SIZES.map(f => f.size)).toEqual([16, 32, 48, 180, 192, 512]); + }); +}); diff --git a/src/tools/image/favicon.lib.ts b/src/tools/image/favicon.lib.ts new file mode 100644 index 0000000..bed24de --- /dev/null +++ b/src/tools/image/favicon.lib.ts @@ -0,0 +1,72 @@ +/** + * Favicon generator — turn one image into a full favicon set (ICO + PNGs + + * web manifest + HTML snippet). Browser-only encode; the manifest/snippet + * builders are pure. + */ +import { processImage } from './canvas.lib'; +import { imageToIco } from './encode.lib'; + +export const FAVICON_SIZES: { name: string; size: number }[] = [ + { name: 'favicon-16x16.png', size: 16 }, + { name: 'favicon-32x32.png', size: 32 }, + { name: 'favicon-48x48.png', size: 48 }, + { name: 'apple-touch-icon.png', size: 180 }, + { name: 'android-chrome-192x192.png', size: 192 }, + { name: 'android-chrome-512x512.png', size: 512 }, +]; + +/** Build a site.webmanifest referencing the generated PNGs. */ +export function buildManifest(appName: string): string { + return JSON.stringify( + { + name: appName, + short_name: appName, + icons: [ + { src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' }, + { src: '/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' }, + ], + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + }, + null, + 2, + ); +} + +/** The tags to paste into a site's . */ +export function htmlSnippet(): string { + return [ + '', + '', + '', + '', + '', + ].join('\n'); +} + +export interface FaviconFile { + name: string; + blob: Blob; + /** Pixel size for the PNG previews (absent for ico/manifest/snippet). */ + size?: number; +} + +/** Generate the full favicon set from a source image. */ +export async function generateFavicons(file: File, appName: string): Promise { + const files: FaviconFile[] = []; + files.push({ name: 'favicon.ico', blob: await imageToIco(file, [16, 32, 48]) }); + for (const { name, size } of FAVICON_SIZES) { + const { blob } = await processImage(file, { mimeType: 'image/png', width: size, height: size }); + files.push({ name, blob, size }); + } + files.push({ + name: 'site.webmanifest', + blob: new Blob([buildManifest(appName)], { type: 'application/manifest+json' }), + }); + files.push({ + name: 'favicon-html-snippet.txt', + blob: new Blob([htmlSnippet()], { type: 'text/plain' }), + }); + return files; +} diff --git a/src/tools/pdf/layout.lib.test.ts b/src/tools/pdf/layout.lib.test.ts new file mode 100644 index 0000000..d4c81d2 --- /dev/null +++ b/src/tools/pdf/layout.lib.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { pageNumberXY, placementToPdfRect } from './layout.lib'; + +describe('pageNumberXY', () => { + it('places bottom-center', () => { + expect(pageNumberXY('bottom-center', 600, 800, 20, 12, 30)).toEqual({ x: 290, y: 30 }); + }); + it('places bottom-right', () => { + expect(pageNumberXY('bottom-right', 600, 800, 20, 12, 30)).toEqual({ x: 550, y: 30 }); + }); + it('places top-left (y measured from bottom)', () => { + expect(pageNumberXY('top-left', 600, 800, 20, 12, 30)).toEqual({ x: 30, y: 758 }); + }); +}); + +describe('placementToPdfRect', () => { + it('flips a top-left ratio placement to bottom-left coordinates', () => { + // 50%-wide box at (10%,10% from top) on a 600×800 page, square image. + const r = placementToPdfRect({ pageIndex: 0, xRatio: 0.1, yRatio: 0.1, wRatio: 0.5 }, 600, 800, 1); + expect(r.width).toBe(300); + expect(r.height).toBe(300); + expect(r.x).toBe(60); + // yFromTop = 80, height 300 → y = 800 - 80 - 300 = 420 + expect(r.y).toBe(420); + }); + + it('derives height from a wide image aspect', () => { + const r = placementToPdfRect({ pageIndex: 0, xRatio: 0, yRatio: 0, wRatio: 1 }, 600, 800, 3); + expect(r.width).toBe(600); + expect(r.height).toBe(200); + }); +}); diff --git a/src/tools/pdf/layout.lib.ts b/src/tools/pdf/layout.lib.ts new file mode 100644 index 0000000..fee44b1 --- /dev/null +++ b/src/tools/pdf/layout.lib.ts @@ -0,0 +1,62 @@ +/** + * Pure geometry helpers for the Organize-PDF and Sign-PDF tools. No PDF engine + * dependency, so these can be unit-tested in isolation. pdf-lib's coordinate + * origin is the bottom-left of the page. + */ + +export type PageNumberPosition = + | 'bottom-center' | 'bottom-right' | 'bottom-left' + | 'top-center' | 'top-right' | 'top-left'; + +export interface PageNumberOptions { + enabled: boolean; + position: PageNumberPosition; + startAt: number; + fontSize: number; // points + margin: number; // points +} + +/** Bottom-left {x,y} for a page-number label of the given rendered width. */ +export function pageNumberXY( + position: PageNumberPosition, + pageW: number, + pageH: number, + textWidth: number, + fontSize: number, + margin: number, +): { x: number; y: number } { + const y = position.startsWith('top') ? pageH - margin - fontSize : margin; + let x: number; + if (position.endsWith('center')) x = pageW / 2 - textWidth / 2; + else if (position.endsWith('right')) x = pageW - margin - textWidth; + else x = margin; + return { x, y }; +} + +/** A signature placement in screen space (top-left origin, page-relative ratios). */ +export interface SignPlacement { + pageIndex: number; + /** left edge, fraction of page width */ + xRatio: number; + /** top edge, fraction of page height (measured from the top) */ + yRatio: number; + /** width, fraction of page width */ + wRatio: number; +} + +/** + * Convert a top-left-origin ratio placement into a pdf-lib bottom-left rect. + * Height is derived from the image aspect ratio (w/h). + */ +export function placementToPdfRect( + p: SignPlacement, + pageW: number, + pageH: number, + imgAspect: number, +): { x: number; y: number; width: number; height: number } { + const width = p.wRatio * pageW; + const height = width / imgAspect; + const x = p.xRatio * pageW; + const yFromTop = p.yRatio * pageH; + return { x, y: pageH - yFromTop - height, width, height }; +} diff --git a/src/tools/pdf/pdf.lib.ts b/src/tools/pdf/pdf.lib.ts index 23fa5f8..192aef9 100644 --- a/src/tools/pdf/pdf.lib.ts +++ b/src/tools/pdf/pdf.lib.ts @@ -1,4 +1,5 @@ import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'; +import { pageNumberXY, placementToPdfRect, type PageNumberOptions, type SignPlacement } from './layout.lib'; // Loading/parsing existing PDFs is handled by the mupdf engine (in a worker) — // it parses the wide range of real-world PDFs that pdf-lib's parser rejects. @@ -152,6 +153,56 @@ export async function buildWatermarkPreview( return out.save(); } +/** + * Rebuild a PDF with pages in `order` (0-indexed; omit an index to drop that + * page), optionally stamping page numbers. + */ +export async function organizePdf( + file: File, + order: number[], + pageNumbers?: PageNumberOptions, +): Promise { + const src = await loadViaMupdf(file); + const out = await PDFDocument.create(); + const pages = await out.copyPages(src, order); + pages.forEach(page => out.addPage(page)); + + if (pageNumbers?.enabled) { + const font = await out.embedFont(StandardFonts.Helvetica); + out.getPages().forEach((page, i) => { + const { width, height } = page.getSize(); + const label = String(pageNumbers.startAt + i); + const textWidth = font.widthOfTextAtSize(label, pageNumbers.fontSize); + const { x, y } = pageNumberXY(pageNumbers.position, width, height, textWidth, pageNumbers.fontSize, pageNumbers.margin); + page.drawText(label, { x, y, size: pageNumbers.fontSize, font, color: rgb(0, 0, 0) }); + }); + } + return toBlob(await out.save()); +} + +/** Stamp a signature PNG onto the given page placements. */ +export async function signPdf( + file: File, + signaturePng: Uint8Array, + placements: SignPlacement[], +): Promise { + const src = await loadViaMupdf(file); + const out = await PDFDocument.create(); + const pages = await out.copyPages(src, src.getPageIndices()); + pages.forEach(page => out.addPage(page)); + + const img = await out.embedPng(signaturePng); + const aspect = img.width / img.height; + const docPages = out.getPages(); + for (const placement of placements) { + const page = docPages[placement.pageIndex]; + if (!page) continue; + const { width, height } = page.getSize(); + page.drawImage(img, placementToPdfRect(placement, width, height, aspect)); + } + return toBlob(await out.save()); +} + /** Build a PDF from images (one image per page, page sized to the image). */ export async function imagesToPdf(images: File[]): Promise { const out = await PDFDocument.create(); From 62c349483fb5e4a6f4193de9b7bca93bc816b6fd Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:31:32 +0700 Subject: [PATCH 2/2] fix(pdf): extract images now decodes pages so embedded images are found With the pdf.js worker, getOperatorList() does not populate page.objs with decoded images, so extraction found nothing. Render each page (that has image XObjects) first to force decoding, then read the objects. --- src/tools/pdf/extract-images.lib.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/tools/pdf/extract-images.lib.ts b/src/tools/pdf/extract-images.lib.ts index 5468618..3d238e4 100644 --- a/src/tools/pdf/extract-images.lib.ts +++ b/src/tools/pdf/extract-images.lib.ts @@ -91,23 +91,43 @@ export async function extractPdfImages(data: ArrayBuffer | Uint8Array): Promise< pdfjs.OPS.paintImageXObjectRepeat, ]); + // A throwaway canvas used only to force pdf.js to decode each page's images + // into page.objs — with the worker, getOperatorList() alone does not. + const renderCanvas = document.createElement('canvas'); + const renderCtx = renderCanvas.getContext('2d'); + const results: ExtractedImage[] = []; const seen = new Set(); try { for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const ops = await page.getOperatorList(); + + const names: string[] = []; for (let i = 0; i < ops.fnArray.length; i++) { if (!imageOps.has(ops.fnArray[i])) continue; const name = ops.argsArray[i]?.[0]; - if (typeof name !== 'string' || seen.has(name)) continue; + if (typeof name === 'string' && !seen.has(name) && !names.includes(name)) names.push(name); + } + if (names.length === 0) continue; + + // Render the page (at a modest scale) so pdf.js decodes its image + // XObjects and populates page.objs; the objects hold full-resolution data. + if (renderCtx) { + const base = page.getViewport({ scale: 1 }); + const scale = Math.min(1, 1200 / Math.max(base.width, base.height, 1)); + const viewport = page.getViewport({ scale }); + renderCanvas.width = Math.max(1, Math.floor(viewport.width)); + renderCanvas.height = Math.max(1, Math.floor(viewport.height)); + await page.render({ canvasContext: renderCtx, viewport, canvas: renderCanvas }).promise; + } + + for (const name of names) { seen.add(name); const obj = await resolveObj(page, name); if (!obj) continue; const rendered = await objToBlob(obj); - if (rendered) { - results.push({ name: `image-${results.length + 1}.png`, ...rendered }); - } + if (rendered) results.push({ name: `image-${results.length + 1}.png`, ...rendered }); } } } finally {