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
38 changes: 37 additions & 1 deletion src/islands/image/PasFoto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import {
cmToPt,
photoPx,
sheetLayout,
headGuideBox,
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;
// Framing-guide geometry in a 0–100 viewBox (drawn only on the preview).
const GUIDE = headGuideBox(100, 100);
const BG_PRESETS = [
{ color: '#e02424', label: 'Merah' },
{ color: '#1e50e0', label: 'Biru' },
Expand All @@ -45,6 +48,8 @@ const TR: Record<Lang, {
genFailed: string;
copies: (n: number) => string;
reset: string;
guide: string;
guideHint: 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.',
Expand All @@ -65,6 +70,8 @@ const TR: Record<Lang, {
genFailed: 'Could not generate the PDF.',
copies: (n) => `${n} copies per sheet`,
reset: 'Clear',
guide: 'Show framing guide',
guideHint: 'Align the crown and chin to the dashed lines for a passport-style crop.',
},
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.',
Expand All @@ -85,6 +92,8 @@ const TR: Record<Lang, {
genFailed: 'Tidak dapat membuat PDF.',
copies: (n) => `${n} salinan per lembar`,
reset: 'Bersihkan',
guide: 'Tampilkan panduan bingkai',
guideHint: 'Sejajarkan puncak kepala dan dagu ke garis putus-putus untuk hasil gaya paspor.',
},
};

Expand All @@ -98,6 +107,7 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
const [sheet, setSheet] = useState<Sheet>(SHEETS[0]); // 4R
const [zoom, setZoom] = useState(1);
const [offsetY, setOffsetY] = useState(0); // -0.5 .. 0.5 of frame height
const [showGuide, setShowGuide] = useState(true);
const [busy, setBusy] = useState(false);
const [stage, setStage] = useState('');
const [percent, setPercent] = useState(0);
Expand Down Expand Up @@ -276,8 +286,34 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
{/* Preview */}
<div className="space-y-2">
<div className="flex justify-center border-2 border-border bg-muted p-3">
<canvas ref={previewRef} className="h-auto max-h-[60vh] w-auto border border-border" />
<div className="relative inline-block border border-border">
<canvas ref={previewRef} className="block h-auto max-h-[60vh] w-auto" />
{showGuide && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="pointer-events-none absolute inset-0 h-full w-full"
>
{/* Dark halo behind, light line on top → visible on any background. */}
<g fill="none" stroke="rgba(0,0,0,0.45)" strokeWidth={1.4}>
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
</g>
<g fill="none" stroke="rgba(255,255,255,0.95)" strokeWidth={0.5} strokeDasharray="2 2">
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
</g>
</svg>
)}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-semibold">
<input type="checkbox" checked={showGuide} onChange={e => setShowGuide(e.target.checked)} className="h-4 w-4 accent-accent" />
{t.guide}
</label>
{showGuide && <p className="text-xs text-muted-foreground">{t.guideHint}</p>}
<label className="block space-y-1 text-sm">
<span className="flex justify-between font-semibold"><span>{t.zoom}</span><span>{zoom.toFixed(2)}×</span></span>
<input type="range" min={0.5} max={2} step={0.01} value={zoom}
Expand Down
9 changes: 8 additions & 1 deletion src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,15 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ?
window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
window.gtag('js', new Date());
window.gtag('config', 'AW-18373579775');
// Page-view conversion — fire on first load and on every SPA navigation.
// Page-view conversion — count once per visit. The listener stays on
// astro:page-load so a first navigation still fires, but the
// sessionStorage flag stops a single visitor browsing multiple tools
// from registering a conversion per SPA route change.
function fireConversion() {
try {
if (sessionStorage.getItem('gwt-ads-conv')) return;
sessionStorage.setItem('gwt-ads-conv', '1');
} catch (e) { /* private mode: fall through and fire */ }
window.gtag('event', 'conversion', { send_to: 'AW-18373579775/4BVPCOXG99wcEP-nmrlE' });
}
fireConversion();
Expand Down
20 changes: 19 additions & 1 deletion src/tools/image/pas-foto.lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { cmToPt, photoPx, sheetLayout, PHOTO_SIZES, SHEETS } from './pas-foto.lib';
import { cmToPt, photoPx, sheetLayout, headGuideBox, PHOTO_SIZES, SHEETS } from './pas-foto.lib';

describe('cmToPt', () => {
it('converts cm to PDF points', () => {
Expand Down Expand Up @@ -50,6 +50,24 @@ describe('sheetLayout', () => {
});
});

describe('headGuideBox', () => {
it('places crown, chin and a centered head oval for a 100x100 frame', () => {
const g = headGuideBox(100, 100);
expect(g.crownY).toBeCloseTo(8, 5);
expect(g.chinY).toBeCloseTo(85, 5);
expect(g.cx).toBeCloseTo(50, 5);
expect(g.rx).toBeCloseTo(26, 5); // widthRatio 0.52 → diameter 52 → radius 26
expect(g.ry).toBeCloseTo(38.5, 5); // (85-8)/2
expect(g.cy).toBeCloseTo(46.5, 5); // (8+85)/2
});

it('scales with the frame size', () => {
const g = headGuideBox(300, 400);
expect(g.crownY).toBeCloseTo(32, 5); // 0.08 * 400
expect(g.cx).toBeCloseTo(150, 5);
});
});

describe('constants', () => {
it('exposes the three standard photo sizes', () => {
expect(PHOTO_SIZES.map(s => s.id)).toEqual(['2x3', '3x4', '4x6']);
Expand Down
29 changes: 29 additions & 0 deletions src/tools/image/pas-foto.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ export function cmToPt(cm: number): number {
return cm * CM_TO_PT;
}

/**
* Framing guide proportions for an ID/passport-style photo, as fractions of
* the frame height (crown/chin) and width (head oval). Roughly the ICAO/
* passport convention: the head fills most of the frame, with a little
* headroom above the crown. Guides are advisory — shown on the preview only.
*/
export const HEAD_GUIDE = { crown: 0.08, chin: 0.85, widthRatio: 0.52 };

/** Guide geometry (crown/chin lines + centered head oval) for a W×H frame. */
export function headGuideBox(w: number, h: number): {
crownY: number;
chinY: number;
cx: number;
cy: number;
rx: number;
ry: number;
} {
const crownY = HEAD_GUIDE.crown * h;
const chinY = HEAD_GUIDE.chin * h;
return {
crownY,
chinY,
cx: w / 2,
cy: (crownY + chinY) / 2,
rx: (HEAD_GUIDE.widthRatio * w) / 2,
ry: (chinY - crownY) / 2,
};
}

/** 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 {
Expand Down
Loading