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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/a4-preview.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,6 +16,16 @@ export function A4Preview() {

const containerRef = useRef<HTMLDivElement>(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
Expand All @@ -35,7 +47,16 @@ export function A4Preview() {

return (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Print sheet (A4)</h2>
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">Print sheet (A4)</h2>
<Button
size="sm"
disabled={isExporting}
onClick={() => void handleExport()}
>
{isExporting ? 'Preparing…' : 'Export PDF'}
</Button>
</div>
<div ref={containerRef} className="mx-auto w-full max-w-xl">
<div
className="relative bg-white shadow-md ring-1 ring-black/10"
Expand Down
144 changes: 144 additions & 0 deletions apps/web/src/lib/pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'

import { A4_MM, POLAROID, PT_PER_MM, sheetLayout } from '@/lib/layout'
import { type Photo } from '@/lib/photos'

const IMAGE_DPI = 300

function loadImage(url: string): Promise<HTMLImageElement | null> {
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<Uint8Array | null> {
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<Blob | null>((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<Uint8Array> {
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<void> {
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)
}
37 changes: 37 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading