+ );
+}
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 };
+}