diff --git a/apps/web/package.json b/apps/web/package.json index 9e9582d..b15e06d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "exifr": "^7.1.3", "lucide-react": "^1.21.0", "offline-geocode-city": "^1.0.2", + "pdf-lib": "^1.17.1", "react": "^19.2.7", "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index f58ca2d..916466e 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -1,8 +1,10 @@ import { useLayoutEffect, useRef, useState } from 'react' import { SheetPolaroid } from '@/components/sheet-polaroid' +import { Button } from '@/components/ui/button' import { captionFontStack } from '@/lib/fonts' import { A4_MM, sheetLayout } from '@/lib/layout' +import { downloadSheetPdf } from '@/lib/pdf' import { usePhotoStore } from '@/stores/photo-store' import { useSettingsStore } from '@/stores/settings-store' @@ -14,6 +16,16 @@ export function A4Preview() { const containerRef = useRef(null) const [width, setWidth] = useState(0) + const [isExporting, setIsExporting] = useState(false) + + const handleExport = async () => { + setIsExporting(true) + try { + await downloadSheetPdf(photos, perRow) + } finally { + setIsExporting(false) + } + } useLayoutEffect(() => { const el = containerRef.current @@ -35,7 +47,16 @@ export function A4Preview() { return (
-

Print sheet (A4)

+
+

Print sheet (A4)

+ +
{ + return new Promise((resolve) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => resolve(null) + img.src = url + }) +} + +/** + * Draws a centre-cropped square of the photo onto a canvas and returns sRGB + * JPEG bytes. Canvas output is always sRGB — exactly what photo labs and home + * inkjets want (see README). Handles any format the browser can decode. + */ +async function rasterizeSquareJpeg( + url: string, + sizePx: number, +): Promise { + const img = await loadImage(url) + if (!img) return null + const canvas = document.createElement('canvas') + canvas.width = sizePx + canvas.height = sizePx + const ctx = canvas.getContext('2d') + if (!ctx) return null + const side = Math.min(img.naturalWidth, img.naturalHeight) + const sx = (img.naturalWidth - side) / 2 + const sy = (img.naturalHeight - side) / 2 + ctx.drawImage(img, sx, sy, side, side, 0, 0, sizePx, sizePx) + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/jpeg', 0.92), + ) + if (!blob) return null + return new Uint8Array(await blob.arrayBuffer()) +} + +/** Builds an A4, sRGB, print-ready PDF of all photos (paginated). */ +export async function buildSheetPdf( + photos: Photo[], + perRow: number, +): Promise { + const doc = await PDFDocument.create() + const font = await doc.embedFont(StandardFonts.Helvetica) + const layout = sheetLayout(perRow) + const pageW = A4_MM.width * PT_PER_MM + const pageH = A4_MM.height * PT_PER_MM + const margin = layout.marginMm * PT_PER_MM + const centerX = (text: string, size: number, cx: number) => + cx - font.widthOfTextAtSize(text, size) / 2 + + for (let start = 0; start < photos.length; start += layout.capacity) { + const page = doc.addPage([pageW, pageH]) + page.drawRectangle({ + x: margin, + y: margin, + width: pageW - margin * 2, + height: pageH - margin * 2, + borderColor: rgb(0.85, 0.85, 0.85), + borderWidth: 0.5, + borderDashArray: [3, 3], + }) + + const slice = photos.slice(start, start + layout.capacity) + for (let i = 0; i < slice.length; i++) { + const photo = slice[i] + const r = layout.rectFor(i) + const x = r.x * PT_PER_MM + const w = r.width * PT_PER_MM + const h = r.height * PT_PER_MM + const yTop = r.y * PT_PER_MM + const pad = w * POLAROID.framePad + const imgSize = w - pad * 2 + + page.drawRectangle({ + x, + y: pageH - yTop - h, + width: w, + height: h, + color: rgb(1, 1, 1), + borderColor: rgb(0.9, 0.9, 0.9), + borderWidth: 0.5, + }) + + const imgPx = Math.round((imgSize / PT_PER_MM) * (IMAGE_DPI / 25.4)) + const jpeg = await rasterizeSquareJpeg(photo.url, imgPx) + if (jpeg) { + const embedded = await doc.embedJpg(jpeg) + page.drawImage(embedded, { + x: x + pad, + y: pageH - yTop - pad - imgSize, + width: imgSize, + height: imgSize, + }) + } + + const cx = x + w / 2 + const topSize = w * POLAROID.captionTopSize + const botSize = w * POLAROID.captionBottomSize + const capY = pageH - yTop - pad - imgSize - pad * 0.6 + if (photo.captionTop) { + page.drawText(photo.captionTop, { + x: centerX(photo.captionTop, topSize, cx), + y: capY - topSize, + size: topSize, + font, + color: rgb(0.15, 0.15, 0.15), + }) + } + if (photo.captionBottom) { + page.drawText(photo.captionBottom, { + x: centerX(photo.captionBottom, botSize, cx), + y: capY - topSize - botSize - 2, + size: botSize, + font, + color: rgb(0.45, 0.45, 0.45), + }) + } + } + } + + return doc.save() +} + +export async function downloadSheetPdf( + photos: Photo[], + perRow: number, + filename = 'polaroids.pdf', +): Promise { + const bytes = await buildSheetPdf(photos, perRow) + const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4269dcc..a5b081b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: offline-geocode-city: specifier: ^1.0.2 version: 1.0.2 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 react: specifier: ^19.2.7 version: 19.2.7 @@ -477,6 +480,12 @@ packages: cpu: [x64] os: [win32] + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@radix-ui/react-compose-refs@1.1.3': resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: @@ -1276,9 +1285,15 @@ packages: vite-plus: optional: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1401,6 +1416,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1855,6 +1873,14 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.71.0': optional: true + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -2492,8 +2518,17 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.71.0 '@oxlint/binding-win32-x64-msvc': 1.71.0 + pako@1.0.11: {} + pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -2610,6 +2645,8 @@ snapshots: dependencies: is-number: 7.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} turbo@2.10.0: