Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md
Original file line number Diff line number Diff line change
@@ -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.
115 changes: 115 additions & 0 deletions src/islands/image/FaviconGenerator.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, {
intro: string;
drop: string;
dropSub: string;
working: string;
failed: string;
result: string;
downloadZip: string;
}> = {
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<FaviconFile[]>([]);
const [urls, setUrls] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">{t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>

{busy && <p className="text-sm text-muted-foreground">{t.working}</p>}
{error && <Alert variant="error">{error}</Alert>}

{previews.length > 0 && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold">{t.result}</span>
<Button onClick={downloadZip}>{t.downloadZip}</Button>
</div>
<div className="flex flex-wrap items-end gap-4">
{previews.map(f => (
<div key={f.name} className="flex flex-col items-center gap-1">
<div className="flex h-24 w-24 items-center justify-center border-2 border-border bg-[repeating-conic-gradient(#e5e5e5_0_25%,#fff_0_50%)] bg-[length:16px_16px]">
<img src={urls[f.name]} alt={f.name} width={Math.min(f.size!, 96)} height={Math.min(f.size!, 96)} />
</div>
<span className="text-xs text-muted-foreground">{f.size}×{f.size}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
189 changes: 189 additions & 0 deletions src/islands/pdf/PdfOrganize.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, {
intro: string; drop: string; dropSub: string; loading: string; failed: string;
reorderHint: string; pageNumbers: string; position: string; startAt: string;
apply: string; working: string; remove: string; empty: string;
}> = {
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<File | null>(null);
const [urls, setUrls] = useState<Record<number, string>>({});
const [order, setOrder] = useState<number[]>([]);
const [numbers, setNumbers] = useState(false);
const [position, setPosition] = useState<PageNumberPosition>('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<Blob | null>(null);
const dragFrom = useRef<number | null>(null);
const urlsRef = useRef<Record<number, string>>({});

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<number, string> = {};
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

{!file && (
<Dropzone onDrop={onDrop} accept="application/pdf" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">{t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>
)}

{loading && <p className="text-sm text-muted-foreground">{t.loading}</p>}
{error && <Alert variant="error">{error}</Alert>}

{order.length > 0 && (
<>
<p className="text-xs text-muted-foreground">{t.reorderHint}</p>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-6">
{order.map((pageIdx, pos) => (
<div
key={pageIdx}
draggable
onDragStart={() => { dragFrom.current = pos; }}
onDragOver={e => e.preventDefault()}
onDrop={() => onDropThumb(pos)}
className="relative cursor-move border-2 border-border bg-muted p-1"
>
<img src={urls[pageIdx]} alt={`page ${pageIdx + 1}`} className="w-full" />
<span className="absolute bottom-1 left-1 bg-black/70 px-1 text-xs text-white">{pos + 1}</span>
<button
onClick={() => removePage(pos)}
title={t.remove}
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center bg-red-600 text-xs text-white"
>✕</button>
</div>
))}
</div>

<div className="flex flex-wrap items-end gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-sm font-semibold">
<input type="checkbox" checked={numbers} onChange={e => setNumbers(e.target.checked)} className="h-4 w-4 accent-accent" />
{t.pageNumbers}
</label>
{numbers && (
<>
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.position}</span>
<select value={position} onChange={e => setPosition(e.target.value as PageNumberPosition)}
className="border-2 border-border bg-background px-2 py-1.5 text-sm">
{POSITIONS.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</label>
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.startAt}</span>
<input type="number" min={0} value={startAt} onChange={e => setStartAt(Number(e.target.value))}
className="w-20 border-2 border-border bg-muted p-2 text-sm" />
</label>
</>
)}
</div>

{order.length === 0 && <Alert variant="error">{t.empty}</Alert>}

<Button onClick={apply} disabled={busy || order.length === 0}>{busy ? t.working : t.apply}</Button>
</>
)}

{result && (
<div className="space-y-2">
<ResultActions blob={result} filename="organized.pdf" disabled={busy} />
<PdfPreview source={result} />
</div>
)}
</div>
);
}
Loading
Loading