diff --git a/docs/superpowers/plans/2026-08-14-pas-foto.md b/docs/superpowers/plans/2026-08-14-pas-foto.md new file mode 100644 index 0000000..3225251 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-pas-foto.md @@ -0,0 +1,56 @@ +# Pas Foto Maker Implementation Plan + +> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Ship a client-side Indonesian pas-foto maker: remove/recolor background, frame to 2×3/3×4/4×6 cm, export a print-ready PDF tiled on 4R + A4. + +**Architecture:** Pure lib `pas-foto.lib.ts` (unit math + sheet layout); thin island `PasFoto.tsx` (imgly bg-removal + canvas compositing + pdf-lib tiling); registry + EN/ID SEO. + +**Tech Stack:** Astro + React island, Vitest, `@imgly/background-removal`, `pdf-lib`, canvas. + +## Global Constraints + +- 100% client-side; reuse shipped imgly + pdf-lib patterns. +- New tool `status: 'beta'`; Image category; icon `Contact`. +- SEO REQUIRED in EN + ID with `howTo` instructions; Bahasa uses "tool" loanword, never "alat". +- Commit under personal noreply identity; no AI-attribution trailers; no absolute machine paths in committed files. + +--- + +### Task 1: Pure lib `pas-foto.lib.ts` (TDD) + +**Files:** Create `src/tools/image/pas-foto.lib.ts`, Test `src/tools/image/pas-foto.lib.test.ts`. + +**Interfaces produced:** +- `CM_TO_PT`, `DPI`, `PHOTO_SIZES`, `SHEETS` +- `cmToPt(cm): number` +- `photoPx(wCm, hCm, dpi?): { w: number; h: number }` +- `sheetLayout(pwCm, phCm, swCm, shCm, gapCm, marginCm): { cols, rows, count, positions: {x,y}[] }` + +- [ ] Failing tests: `cmToPt(1)≈28.35`; `photoPx(3,4,300)` → {w:354,h:472} (round); `sheetLayout(3,4,10.16,15.24,0.2,0.3)` → cols 3, rows 3, count 9, positions length 9, all within sheet; `sheetLayout(3,4,21,29.7,0.2,0.3)` → cols 6, rows 6, count 36; a too-big photo → count 0. +- [ ] Run — confirm fail. +- [ ] Implement lib (pure math; positions centered as a block from top-left in cm). +- [ ] Run — confirm pass. + +### Task 2: Island `PasFoto.tsx` + +**Files:** Create `src/islands/image/PasFoto.tsx`. + +- [ ] Dropzone(single)+paste. Remove-bg toggle → imgly dynamic import (copy BackgroundRemove block, determinate ProgressBar). Bg color red/blue/white swatches + ``. Size segmented (2×3/3×4/4×6). Fixed-aspect preview with zoom + vertical-position sliders (composite on hidden canvas at `photoPx`). Sheet segmented (4R/A4). Generate → single-photo JPEG via `encodeCanvas`, then pdf-lib page `cmToPt(sheet)`, `embedJpg`, `drawImage` at each `sheetLayout` position (convert to PDF bottom-left origin), thin cut borders. `PdfPreview` + `ResultActions` (`pas-foto--.pdf`). i18n TR en+id. SSR-safe; revoke objectURLs. + +### Task 3: Register + SEO + +**Files:** Modify `src/registry/tools.ts`, `src/registry/tool-seo.ts`. + +- [ ] Import `Contact` icon; add ToolDef. +- [ ] Add EN + ID `pas-foto` SEO entries (title/description/intro/howTo/faqs). + +### Task 4: Verify loop + +- [ ] `npx vitest run` green · `npm run lint` 0 errors · `npm run build` succeeds; `/tools/pas-foto` + `/id/tools/pas-foto` built; no new precache warning. +- [ ] Hand review: objectURL/canvas cleanup; imgly progress + error paths; SSR; empty/failed-removal fallback; reference-identity of derived preview props. + +### Task 5: Ship dev → prod + +- [ ] Commit on `feat/pas-foto`, PR → develop, CI green, merge. +- [ ] Promote develop → main (`--admin`), confirm Cloudflare prod build, verify live URL. diff --git a/docs/superpowers/specs/2026-08-14-pas-foto-design.md b/docs/superpowers/specs/2026-08-14-pas-foto-design.md new file mode 100644 index 0000000..11cb1eb --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-pas-foto-design.md @@ -0,0 +1,73 @@ +# Pas Foto Maker — Design + +**Date:** 2026-08-14 +**Category:** Image +**Tool id:** `pas-foto` + +## Goal + +Make a print-ready Indonesian pas foto (ID/passport photo) in the browser: upload a portrait, auto-remove and recolor the background (red / blue / white / custom), frame it to a standard size (2×3, 3×4, 4×6 cm), and export a **print-ready PDF** that tiles many copies onto a 4R photo-paper sheet or an A4 sheet — so the user can print at a photo shop or on a home printer. Everything runs client-side; nothing is uploaded. + +## Reuse (all already shipped in GWT) + +- **Background removal** — `@imgly/background-removal` (dynamic import; `publicPath: /models/imgly/`), returns a transparent PNG `Blob`. Same block as `BackgroundRemove.tsx`. +- **PDF** — `pdf-lib` (`imagesToPdf` pattern in `src/tools/pdf/pdf.lib.ts`): `PDFDocument.create()` → `embedPng` → `addPage([wPt,hPt])` → `drawImage({x,y,width,height})` → `save()`. +- **Canvas** — `encodeCanvas`, `cropImage` from `src/tools/image/canvas.lib.ts`. +- **Color** — native `` (per `ImageWatermark.tsx`). +- **Download/preview** — `ResultActions` (PDF blob → download button) + `PdfPreview`. + +## Architecture + +### Pure lib — `src/tools/image/pas-foto.lib.ts` (framework-free, unit-tested) + +- `CM_TO_PT = 28.3465`, `DPI = 300`. +- `PHOTO_SIZES: { id: '2x3'|'3x4'|'4x6'; w: number; h: number }[]` (cm; portrait, w` (default red `#e12729`-style; store hex). +3. **Size**: segmented buttons for 2×3 / 3×4 / 4×6. +4. **Framing**: a fixed-aspect preview box (target ratio) showing the recolored subject; **zoom** slider + **vertical position** slider so the face sits correctly. Compositing done on a hidden canvas: fill bg color, draw subject scaled/positioned, at `photoPx` resolution. +5. **Sheet**: segmented buttons 4R / A4. +6. **Generate**: build the single-photo PNG (canvas → `encodeCanvas('image/jpeg', 0.95)` — JPEG so white/солid bg prints clean and file stays small), then tile via `pdf-lib`: one page sized `cmToPt(sheet.w/h)`, `embedJpg` once, `drawImage` at each `sheetLayout` position (converted to PDF bottom-left origin), with a thin 0.5pt gray cut border per tile. Show `PdfPreview` + `ResultActions` (`pas-foto--.pdf`). + +i18n `TR: Record` en + id (Bahasa: "tool" loanword, never "alat"). Signature `export default function PasFoto({ lang = 'en' }: { lang?: Lang })`. SSR-safe (all canvas/imgly work inside handlers/effects). ObjectURLs revoked; the compositing canvas is local to the handler. + +### Registry — `src/registry/tools.ts` + +```ts +{ + id: 'pas-foto', + name: 'Pas Foto Maker', + category: 'Image', + route: '/tools/pas-foto', + keywords: ['pas foto', 'pasfoto', 'id photo', 'passport photo', '2x3', '3x4', '4x6', 'foto ktp', 'print', 'background'], + icon: IdCard, + summary: 'Make print-ready 2x3, 3x4 & 4x6 ID photos with a clean background', + load: () => import('@/islands/image/PasFoto'), + status: 'beta' +}, +``` +Icon: verify `IdCard` / `UserSquare` / `Contact` exists in lucide; fall back to `Image`/`SquareUser`. + +### SEO — `src/registry/tool-seo.ts` (REQUIRED, both locales) + +EN + ID `pas-foto` entries with `title`, `description`, `intro`, **`howTo`** (step instructions), `faqs`. Keyword targets: "pas foto 3x4 online", "buat pas foto", "pas foto 2x3 4x6", "ID photo maker", "foto KTP background merah/biru". Bahasa uses "tool" loanword. + +### PWA precache — `astro.config.mjs` + +The imgly model/wasm is served from `/models/imgly/` (outside hashed build output) and the ONNX wasm is already covered by the `**/ort-*.wasm` globIgnore — **no change needed** (same as `BackgroundRemove`). Confirm no new precache-size warning at build. + +## Testing + +`src/tools/image/pas-foto.lib.test.ts` — `photoPx` (each size → expected px at 300 DPI), `cmToPt`, `sheetLayout` (3×4 on 4R → 3×3=9; 3×4 on A4 → 6×6=36; positions within bounds, count = cols*rows, centered block). Island covered by build + manual smoke (imgly + canvas can't run in jsdom). + +## Definition of done + +Spec+plan committed · `pas-foto.lib.ts` unit-tested · EN + ID SEO with howTo · vitest + lint + build green · `/tools/pas-foto` + `/id/tools/pas-foto` built · merged to develop · promoted to main · Cloudflare prod build green · live URL verified · user told about PWA hard-refresh. diff --git a/src/islands/image/PasFoto.tsx b/src/islands/image/PasFoto.tsx new file mode 100644 index 0000000..2d3e841 --- /dev/null +++ b/src/islands/image/PasFoto.tsx @@ -0,0 +1,355 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { ResultActions } from '@/components/ui/ResultActions'; +import { PdfPreview } from '@/components/ui/PdfPreview'; +import { usePasteImage } from '@/hooks/usePasteImage'; +import { encodeCanvas } from '@/tools/image/canvas.lib'; +import { + PHOTO_SIZES, + SHEETS, + cmToPt, + photoPx, + sheetLayout, + type PhotoSize, + type Sheet, +} from '@/tools/image/pas-foto.lib'; +import type { Lang } from '@/i18n/config'; + +const GAP_CM = 0.2; +const MARGIN_CM = 0.3; +const BG_PRESETS = [ + { color: '#e02424', label: 'Merah' }, + { color: '#1e50e0', label: 'Biru' }, + { color: '#ffffff', label: 'Putih' }, +]; + +const TR: Record string; + reset: string; +}> = { + en: { + intro: 'Make a print-ready ID photo (pas foto): remove the background, pick a color and size, and download a PDF that tiles copies onto a photo sheet — ready to print. Everything runs in your browser.', + dropTitle: 'Drop a portrait photo or click to browse', + dropSub: 'A clear, front-facing photo works best · or paste (⌘V)', + removeBg: 'Remove background', + background: 'Background color', + size: 'Photo size', + sheet: 'Print sheet', + zoom: 'Zoom', + position: 'Vertical position', + generate: 'Generate print PDF', + generating: 'Generating…', + preparing: 'Preparing…', + downloadingModel: 'Downloading model…', + removingBg: 'Removing background…', + removeFailed: 'Background removal failed. Try another photo or turn it off.', + genFailed: 'Could not generate the PDF.', + copies: (n) => `${n} copies per sheet`, + reset: 'Clear', + }, + id: { + intro: 'Buat pas foto siap cetak: hapus latar belakang, pilih warna dan ukuran, lalu unduh PDF berisi banyak salinan dalam satu lembar foto — siap dicetak. Semuanya berjalan di browser Anda.', + dropTitle: 'Letakkan foto potret atau klik untuk memilih', + dropSub: 'Foto menghadap depan yang jelas paling bagus · atau tempel (⌘V)', + removeBg: 'Hapus latar belakang', + background: 'Warna latar', + size: 'Ukuran foto', + sheet: 'Lembar cetak', + zoom: 'Perbesar', + position: 'Posisi vertikal', + generate: 'Buat PDF cetak', + generating: 'Membuat…', + preparing: 'Menyiapkan…', + downloadingModel: 'Mengunduh model…', + removingBg: 'Menghapus latar belakang…', + removeFailed: 'Gagal menghapus latar belakang. Coba foto lain atau matikan opsi ini.', + genFailed: 'Tidak dapat membuat PDF.', + copies: (n) => `${n} salinan per lembar`, + reset: 'Bersihkan', + }, +}; + +export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [srcFile, setSrcFile] = useState(null); + const [subjectUrl, setSubjectUrl] = useState(''); + const [removeBg, setRemoveBg] = useState(true); + const [bgColor, setBgColor] = useState('#e02424'); + const [size, setSize] = useState(PHOTO_SIZES[1]); // 3x4 + const [sheet, setSheet] = useState(SHEETS[0]); // 4R + const [zoom, setZoom] = useState(1); + const [offsetY, setOffsetY] = useState(0); // -0.5 .. 0.5 of frame height + const [busy, setBusy] = useState(false); + const [stage, setStage] = useState(''); + const [percent, setPercent] = useState(0); + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + + const imgRef = useRef(null); + const [imgReady, setImgReady] = useState(0); + const previewRef = useRef(null); + + // Revoke the subject object URL when it changes/unmounts. + useEffect(() => () => { if (subjectUrl) URL.revokeObjectURL(subjectUrl); }, [subjectUrl]); + + const layout = useMemo( + () => sheetLayout(size.w, size.h, sheet.w, sheet.h, GAP_CM, MARGIN_CM), + [size, sheet], + ); + + const setSubject = (blob: Blob) => { + setSubjectUrl(prev => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(blob); + }); + }; + + // Prepare the subject bitmap: remove background (or use the original). + const prepare = async (file: File, strip: boolean) => { + setError(''); + setResult(null); + if (!strip) { + setSubject(file); + return; + } + setBusy(true); + setPercent(0); + setStage(t.preparing); + try { + const { removeBackground } = await import('@imgly/background-removal'); + const blob = await removeBackground(file, { + publicPath: new URL('/models/imgly/', location.origin).href, + output: { format: 'image/png' }, + progress: (key, current, total) => { + setPercent(total > 0 ? Math.round((current / total) * 100) : 0); + setStage(key.startsWith('fetch') ? t.downloadingModel : t.removingBg); + }, + }); + setSubject(blob); + } catch (e) { + setError(e instanceof Error ? e.message : t.removeFailed); + setSubject(file); // fall back to the original so the user isn't stuck + } finally { + setBusy(false); + setStage(''); + } + }; + + const onDrop = (files: File[]) => { + const file = files.find(f => f.type.startsWith('image/')); + if (!file) return; + setSrcFile(file); + prepare(file, removeBg); + }; + + usePasteImage(f => onDrop([f])); + + // Toggle background removal → re-prepare from the original file. + const toggleRemoveBg = () => { + const next = !removeBg; + setRemoveBg(next); + if (srcFile) prepare(srcFile, next); + }; + + // Load the subject into an for canvas compositing. + useEffect(() => { + if (!subjectUrl) { imgRef.current = null; return; } + const img = new Image(); + img.onload = () => { imgRef.current = img; setImgReady(v => v + 1); }; + img.src = subjectUrl; + }, [subjectUrl]); + + /** Composite the subject onto a colored frame of the given pixel size. */ + const compose = (canvas: HTMLCanvasElement, W: number, H: number) => { + const img = imgRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + canvas.width = W; + canvas.height = H; + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, W, H); + if (!img) return; + const cover = Math.max(W / img.naturalWidth, H / img.naturalHeight); + const s = cover * zoom; + const dw = img.naturalWidth * s; + const dh = img.naturalHeight * s; + const x = (W - dw) / 2; + const y = (H - dh) / 2 + offsetY * H; + ctx.drawImage(img, x, y, dw, dh); + }; + + // Live preview, drawn at a downscaled resolution for speed. + useEffect(() => { + const canvas = previewRef.current; + if (!canvas) return; + const ratio = size.w / size.h; + const H = 400; + compose(canvas, Math.round(H * ratio), H); + }, [imgReady, bgColor, size, zoom, offsetY]); + + const generate = async () => { + if (!srcFile || !imgRef.current) return; + setGenerating(true); + setError(''); + try { + const { w: Wpx, h: Hpx } = photoPx(size.w, size.h); + const canvas = document.createElement('canvas'); + compose(canvas, Wpx, Hpx); + const jpeg = await encodeCanvas(canvas, 'image/jpeg', 0.95); + const bytes = new Uint8Array(await jpeg.arrayBuffer()); + + const { PDFDocument, rgb } = await import('pdf-lib'); + const pdf = await PDFDocument.create(); + const page = pdf.addPage([cmToPt(sheet.w), cmToPt(sheet.h)]); + const embedded = await pdf.embedJpg(bytes); + const wPt = cmToPt(size.w); + const hPt = cmToPt(size.h); + const sheetHPt = cmToPt(sheet.h); + for (const pos of layout.positions) { + const x = cmToPt(pos.x); + // Convert top-left (cm) to PDF bottom-left origin. + const y = sheetHPt - cmToPt(pos.y) - hPt; + page.drawImage(embedded, { x, y, width: wPt, height: hPt }); + page.drawRectangle({ + x, y, width: wPt, height: hPt, + borderColor: rgb(0.8, 0.8, 0.8), + borderWidth: 0.5, + }); + } + setResult(new Blob([await pdf.save()], { type: 'application/pdf' })); + } catch (e) { + setError(e instanceof Error ? e.message : t.genFailed); + } finally { + setGenerating(false); + } + }; + + const clear = () => { + setSrcFile(null); + setSubjectUrl(''); + imgRef.current = null; + setResult(null); + setError(''); + }; + + const segClass = (active: boolean) => + `border-2 px-3 py-1 text-sm font-medium transition-all ${ + active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal' + }`; + + return ( +
+

{t.intro}

+ + +
+

{t.dropTitle}

+

{t.dropSub}

+
+
+ + {error && {error}} + {busy && } + + {srcFile && ( +
+ {/* Preview */} +
+
+ +
+ + +
+ + {/* Controls */} +
+ + +
+ {t.background} +
+ {BG_PRESETS.map(p => ( +
+
+ +
+ {t.size} +
+ {PHOTO_SIZES.map(s => ( + + ))} +
+
+ +
+ {t.sheet} +
+ {SHEETS.map(s => ( + + ))} +
+

{t.copies(layout.count)}

+
+ +
+ + +
+
+
+ )} + + {result && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index e6b4f5d..ec8178d 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -725,6 +725,24 @@ const en: Record = { { q: 'Does the converter work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded it keeps converting images with no internet connection.' }, ], }, + 'pas-foto': { + title: 'Free Pas Foto Maker — 2x3, 3x4 & 4x6 ID Photos to Print', + description: 'Make a print-ready pas foto (ID photo) online: remove the background, set a red, blue or white color, choose 2x3, 3x4 or 4x6 cm, and download a PDF tiled for printing. Runs in your browser — nothing is uploaded.', + intro: 'This free pas foto maker turns a normal portrait into a print-ready ID photo. It removes the background on your device, replaces it with the red, blue or white you need, frames the photo to a standard 2x3, 3x4 or 4x6 cm size, and lays out multiple copies on a 4R photo sheet or A4 page as a PDF you can print at a photo shop or at home. Your photo never leaves your browser.', + howTo: [ + 'Drop or paste a clear, front-facing portrait photo.', + 'Keep “Remove background” on and pick a background color — red, blue, white or custom.', + 'Choose the size (2x3, 3x4 or 4x6 cm) and use zoom and position to frame the face.', + 'Pick a print sheet (4R or A4) and click Generate — download the PDF and print it.', + ], + faqs: [ + { q: 'What are the standard pas foto sizes?', a: 'The common Indonesian sizes are 2x3, 3x4 and 4x6 centimetres. This tool renders each at 300 DPI so the print is sharp, and tiles as many copies as fit on the sheet.' }, + { q: 'Can I set a red or blue background?', a: 'Yes. The background is removed automatically and replaced with red, blue or white presets, or any custom color you pick — the usual requirement for ID and document photos.' }, + { q: 'Are my photos uploaded anywhere?', a: 'No. Background removal and layout run entirely in your browser using on-device models, so your photo stays private and is never uploaded.' }, + { q: 'How do I print it?', a: 'Download the generated PDF and print it at 100% scale (no “fit to page”) on 4R photo paper or A4. The thin gray borders are cut guides for trimming each photo.' }, + { q: 'What if the background removal looks off?', a: 'Use a clear, well-lit, front-facing photo with the subject separated from the background. You can also turn off background removal to keep the original and just crop and lay it out.' }, + ], + }, 'image-heic-to-jpg': { title: 'Free HEIC to JPG Converter — Open iPhone Photos Anywhere', description: 'Convert Apple HEIC/HEIF photos to JPG right in your browser. Batch-convert a whole camera roll and download as a ZIP. Nothing is uploaded — everything runs on your device.', @@ -2268,6 +2286,24 @@ const id: Record = { { q: 'Apakah konverter bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat, konversi gambar tetap berjalan tanpa koneksi internet.' }, ], }, + 'pas-foto': { + title: 'Tool Pas Foto Gratis — Buat Pas Foto 2x3, 3x4 & 4x6 Siap Cetak', + description: 'Buat pas foto siap cetak online: hapus latar belakang, atur warna merah, biru, atau putih, pilih ukuran 2x3, 3x4, atau 4x6 cm, dan unduh PDF yang sudah disusun untuk dicetak. Berjalan di browser Anda — tidak ada yang diunggah.', + intro: 'Tool pas foto gratis ini mengubah foto potret biasa menjadi pas foto siap cetak. Latar belakang dihapus di perangkat Anda, diganti dengan warna merah, biru, atau putih sesuai kebutuhan, foto dibingkai ke ukuran standar 2x3, 3x4, atau 4x6 cm, lalu beberapa salinan disusun dalam satu lembar 4R atau A4 sebagai PDF yang bisa Anda cetak di studio foto atau di rumah. Foto Anda tidak pernah meninggalkan browser.', + howTo: [ + 'Letakkan atau tempel foto potret menghadap depan yang jelas.', + 'Biarkan “Hapus latar belakang” aktif dan pilih warna latar — merah, biru, putih, atau kustom.', + 'Pilih ukuran (2x3, 3x4, atau 4x6 cm) lalu gunakan zoom dan posisi untuk membingkai wajah.', + 'Pilih lembar cetak (4R atau A4) dan klik Buat — unduh PDF-nya lalu cetak.', + ], + faqs: [ + { q: 'Apa saja ukuran pas foto standar?', a: 'Ukuran umum di Indonesia adalah 2x3, 3x4, dan 4x6 sentimeter. Tool ini merender setiap ukuran pada 300 DPI agar hasil cetak tajam, dan menyusun sebanyak mungkin salinan yang muat dalam lembar.' }, + { q: 'Bisakah mengatur latar merah atau biru?', a: 'Ya. Latar belakang dihapus otomatis dan diganti dengan preset merah, biru, atau putih, atau warna kustom pilihan Anda — sesuai kebutuhan umum foto identitas dan dokumen.' }, + { q: 'Apakah foto saya diunggah ke suatu tempat?', a: 'Tidak. Penghapusan latar belakang dan penyusunan berjalan sepenuhnya di browser Anda menggunakan model di perangkat, jadi foto Anda tetap privat dan tidak pernah diunggah.' }, + { q: 'Bagaimana cara mencetaknya?', a: 'Unduh PDF yang dibuat lalu cetak pada skala 100% (tanpa “fit to page”) di kertas foto 4R atau A4. Garis abu-abu tipis adalah panduan potong untuk memotong tiap foto.' }, + { q: 'Bagaimana jika penghapusan latar kurang rapi?', a: 'Gunakan foto yang jelas, terang, menghadap depan, dengan subjek terpisah dari latar. Anda juga bisa mematikan penghapusan latar untuk mempertahankan foto asli dan hanya memotong serta menyusunnya.' }, + ], + }, 'image-heic-to-jpg': { title: 'Konverter HEIC ke JPG Gratis — Buka Foto iPhone di Mana Saja', description: 'Konversi foto HEIC/HEIF Apple ke JPG langsung di browser Anda. Konversi sekaligus satu album kamera dan unduh sebagai ZIP. Tidak ada yang diunggah — semuanya berjalan di perangkat Anda.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 2ec4d6d..ebed896 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 } 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 } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -487,6 +487,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/image/ImageConvert'), status: 'stable' }, + { + id: 'pas-foto', + name: 'Pas Foto Maker', + category: 'Image', + route: '/tools/pas-foto', + keywords: ['pas foto', 'pasfoto', 'id photo', 'passport photo', '2x3', '3x4', '4x6', 'foto ktp', 'print', 'background'], + icon: Contact, + summary: 'Make print-ready 2x3, 3x4 & 4x6 ID photos with a clean background', + load: () => import('@/islands/image/PasFoto'), + status: 'beta' + }, { id: 'image-heic-to-jpg', name: 'HEIC to JPG', diff --git a/src/tools/image/pas-foto.lib.test.ts b/src/tools/image/pas-foto.lib.test.ts new file mode 100644 index 0000000..da6d921 --- /dev/null +++ b/src/tools/image/pas-foto.lib.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { cmToPt, photoPx, sheetLayout, PHOTO_SIZES, SHEETS } from './pas-foto.lib'; + +describe('cmToPt', () => { + it('converts cm to PDF points', () => { + expect(cmToPt(1)).toBeCloseTo(28.3465, 3); + expect(cmToPt(21)).toBeCloseTo(595.28, 1); // A4 width + }); +}); + +describe('photoPx', () => { + it('sizes a 3x4 cm photo at 300 DPI', () => { + expect(photoPx(3, 4, 300)).toEqual({ w: 354, h: 472 }); + }); + it('sizes a 2x3 cm photo at 300 DPI', () => { + expect(photoPx(2, 3, 300)).toEqual({ w: 236, h: 354 }); + }); +}); + +describe('sheetLayout', () => { + it('fits 3x4 photos on a 4R sheet (3 x 3 = 9)', () => { + const l = sheetLayout(3, 4, 10.16, 15.24, 0.2, 0.3); + expect(l.cols).toBe(3); + expect(l.rows).toBe(3); + expect(l.count).toBe(9); + expect(l.positions).toHaveLength(9); + }); + + it('fits many 3x4 photos on A4 (6 x 6 = 36)', () => { + const l = sheetLayout(3, 4, 21.0, 29.7, 0.2, 0.3); + expect(l.cols).toBe(6); + expect(l.rows).toBe(6); + expect(l.count).toBe(36); + }); + + it('keeps every tile inside the sheet bounds', () => { + const l = sheetLayout(3, 4, 10.16, 15.24, 0.2, 0.3); + for (const p of l.positions) { + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.x + 3).toBeLessThanOrEqual(10.16 + 1e-9); + expect(p.y + 4).toBeLessThanOrEqual(15.24 + 1e-9); + } + }); + + it('returns zero tiles when the photo is larger than the sheet', () => { + const l = sheetLayout(30, 40, 10.16, 15.24, 0.2, 0.3); + expect(l.count).toBe(0); + expect(l.positions).toHaveLength(0); + }); +}); + +describe('constants', () => { + it('exposes the three standard photo sizes', () => { + expect(PHOTO_SIZES.map(s => s.id)).toEqual(['2x3', '3x4', '4x6']); + }); + it('exposes 4R and A4 sheets', () => { + expect(SHEETS.map(s => s.id)).toEqual(['4r', 'a4']); + }); +}); diff --git a/src/tools/image/pas-foto.lib.ts b/src/tools/image/pas-foto.lib.ts new file mode 100644 index 0000000..08cca05 --- /dev/null +++ b/src/tools/image/pas-foto.lib.ts @@ -0,0 +1,104 @@ +/** + * Pas foto (Indonesian ID photo) print math — pure, framework-free. + * + * All the geometry for sizing one photo at print resolution and tiling copies + * onto a print sheet lives here so it can be unit-tested without a canvas or + * PDF engine. The island does the pixel/PDF work using these numbers. + */ + +/** PostScript points per centimetre (72 pt per inch ÷ 2.54 cm per inch). */ +export const CM_TO_PT = 28.3464566929; + +/** Print resolution for the rendered photo bitmap. */ +export const DPI = 300; + +const CM_PER_INCH = 2.54; + +export interface PhotoSize { + id: '2x3' | '3x4' | '4x6'; + /** width in cm (portrait: w < h). */ + w: number; + /** height in cm. */ + h: number; +} + +export const PHOTO_SIZES: PhotoSize[] = [ + { id: '2x3', w: 2, h: 3 }, + { id: '3x4', w: 3, h: 4 }, + { id: '4x6', w: 4, h: 6 }, +]; + +export interface Sheet { + id: '4r' | 'a4'; + label: string; + /** width in cm. */ + w: number; + /** height in cm. */ + h: number; +} + +export const SHEETS: Sheet[] = [ + // 4R photo paper = 4×6 inch. + { id: '4r', label: '4R (10×15 cm)', w: 10.16, h: 15.24 }, + { id: 'a4', label: 'A4 (21×29.7 cm)', w: 21.0, h: 29.7 }, +]; + +/** Convert centimetres to PDF points. */ +export function cmToPt(cm: number): number { + return cm * CM_TO_PT; +} + +/** Pixel dimensions of one photo at the given print DPI. */ +export function photoPx(wCm: number, hCm: number, dpi: number = DPI): { w: number; h: number } { + return { + w: Math.round((wCm / CM_PER_INCH) * dpi), + h: Math.round((hCm / CM_PER_INCH) * dpi), + }; +} + +export interface SheetLayout { + cols: number; + rows: number; + count: number; + /** Top-left corner of each tile, in cm from the sheet's top-left. */ + positions: { x: number; y: number }[]; +} + +/** + * Lay out as many `photoW×photoH` tiles as fit on a `sheetW×sheetH` sheet with + * `gap` between tiles and at least `margin` around the block. The block of + * tiles is centred on the sheet, so the real margins are ≥ `margin`. + */ +export function sheetLayout( + photoW: number, + photoH: number, + sheetW: number, + sheetH: number, + gap: number, + margin: number, +): SheetLayout { + const usableW = sheetW - 2 * margin; + const usableH = sheetH - 2 * margin; + + const cols = Math.max(0, Math.floor((usableW + gap) / (photoW + gap))); + const rows = Math.max(0, Math.floor((usableH + gap) / (photoH + gap))); + + if (cols === 0 || rows === 0) return { cols: 0, rows: 0, count: 0, positions: [] }; + + const blockW = cols * photoW + (cols - 1) * gap; + const blockH = rows * photoH + (rows - 1) * gap; + const startX = (sheetW - blockW) / 2; + const startY = (sheetH - blockH) / 2; + + const positions: { x: number; y: number }[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + positions.push({ + x: startX + c * (photoW + gap), + y: startY + r * (photoH + gap), + }); + } + } + + return { cols, rows, count: cols * rows, positions }; +}