/** * The sheet playground. * * This is not a mock-up of the print studio: it imports `engine/printlayout.ts` * and `engine/paper.ts` verbatim from the Klarbild repository and runs them in * your browser. The numbers you see here are the numbers the application * computes, and the PDF you download is laid out by the same arithmetic — it is * drawn with pdf-lib on this page rather than with sharp on a server, which is * why it carries outlines instead of photographs. * * Nothing is uploaded and there is no server behind this page. */ import { layout, cutMarks, effectiveGap, recommendedGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../engine/printlayout'; import { PAPERS, PHOTO_SIZES, labelMm } from '../engine/paper'; /** German writes 29,7 cm where English writes 29.7 cm. Only the separator differs. */ function localiseNumbers(text: string, lang: string): string { return lang === 'de' ? text.replace(/(\d),(?=\d)/g, '$1\u2009').replace(/(\d)\.(?=\d)/g, '$1,') : text; } const MM_PT = 72 / 25.4; type State = { paperId: string; landscape: boolean; sizeId: string; count: number; marks: MarkMode; bleedMm: number; marginMm: number; allowRotate: boolean; }; const state: State = { paperId: 'A4', landscape: false, sizeId: 'P35x45', count: 8, marks: 'corner', bleedMm: 0, marginMm: 5, allowRotate: true, }; /* ------------------------------------------------------------------ helpers */ function paper() { const p = PAPERS.find((x) => x.id === state.paperId) ?? PAPERS[2]; return state.landscape ? { ...p, w: p.h, h: p.w } : p; } function size() { return PHOTO_SIZES.find((x) => x.id === state.sizeId) ?? PHOTO_SIZES[0]; } function sheetSpec(): SheetSpec { const p = paper(); return { wMm: p.w, hMm: p.h, marginMm: state.marginMm, gapMm: recommendedGap(state.marks, state.bleedMm), bleedMm: state.bleedMm, center: true, }; } function placeSpec(): PlaceSpec { const s = size(); return { id: 'cell', wMm: s.w, hMm: s.h, count: state.count, allowRotate: state.allowRotate }; } function compute() { const sheet = sheetSpec(); const result = layout([placeSpec()], sheet); return { sheet, result }; } /* ------------------------------------------------------------------- render */ const NS = 'http://www.w3.org/2000/svg'; function el(name: string, attrs: Record) { const n = document.createElementNS(NS, name); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v)); return n; } function drawSheet(host: SVGSVGElement, pageIndex = 0) { const { sheet, result } = compute(); const page = result.pages[pageIndex]; host.setAttribute('viewBox', `0 0 ${sheet.wMm} ${sheet.hMm}`); host.replaceChildren(); host.appendChild(el('rect', { x: 0, y: 0, width: sheet.wMm, height: sheet.hMm, fill: '#fff' })); // The printer's non-printable margin, as a hairline so you can see what is lost. host.appendChild(el('rect', { x: sheet.marginMm, y: sheet.marginMm, width: Math.max(0, sheet.wMm - 2 * sheet.marginMm), height: Math.max(0, sheet.hMm - 2 * sheet.marginMm), fill: 'none', stroke: 'var(--line)', 'stroke-width': 0.2, 'stroke-dasharray': '2 2', })); if (!page) return { sheet, result }; page.placements.forEach((pl, i) => { const g = el('g', { class: 'cell', style: `--i:${i}` }); if (sheet.bleedMm) { g.appendChild(el('rect', { x: pl.x - sheet.bleedMm, y: pl.y - sheet.bleedMm, width: pl.w + 2 * sheet.bleedMm, height: pl.h + 2 * sheet.bleedMm, fill: 'var(--bleed)', })); } g.appendChild(el('rect', { x: pl.x, y: pl.y, width: pl.w, height: pl.h, fill: 'var(--cellfill)' })); // A stand-in for the photograph: a horizon and a disc, so the cell reads as // a picture without pretending to be one. g.appendChild(el('circle', { cx: pl.x + pl.w / 2, cy: pl.y + pl.h * 0.38, r: Math.min(pl.w, pl.h) * 0.19, fill: 'var(--cellink)', })); g.appendChild(el('path', { d: `M${pl.x} ${pl.y + pl.h} C ${pl.x + pl.w * 0.12} ${pl.y + pl.h * 0.62}, ${pl.x + pl.w * 0.88} ${pl.y + pl.h * 0.62}, ${pl.x + pl.w} ${pl.y + pl.h} Z`, fill: 'var(--cellink)', })); host.appendChild(g); }); for (const line of cutMarks(page, sheet, { mode: state.marks, lengthMm: 4, offsetMm: 3 })) { host.appendChild(el('line', { x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: 'var(--mark)', 'stroke-width': 0.25, })); } return { sheet, result }; } /* ---------------------------------------------------------------------- PDF */ async function buildPdf(): Promise { const { PDFDocument, rgb } = await import('pdf-lib'); const { sheet, result } = compute(); const doc = await PDFDocument.create(); doc.setTitle('Klarbild sheet (demo)'); doc.setCreator('Klarbild demo — klarbild-demo.heidrich-digital.de'); for (const page of result.pages) { const pg = doc.addPage([sheet.wMm * MM_PT, sheet.hMm * MM_PT]); // PDF counts from the bottom left; the layout counts from the top left. const flip = (yMm: number, hMm: number) => (sheet.hMm - yMm - hMm) * MM_PT; for (const pl of page.placements) { if (sheet.bleedMm) { pg.drawRectangle({ x: (pl.x - sheet.bleedMm) * MM_PT, y: flip(pl.y - sheet.bleedMm, pl.h + 2 * sheet.bleedMm), width: (pl.w + 2 * sheet.bleedMm) * MM_PT, height: (pl.h + 2 * sheet.bleedMm) * MM_PT, color: rgb(0.925, 0.933, 0.925), }); } pg.drawRectangle({ x: pl.x * MM_PT, y: flip(pl.y, pl.h), width: pl.w * MM_PT, height: pl.h * MM_PT, borderColor: rgb(0.72, 0.72, 0.70), borderWidth: 0.4, }); } for (const line of cutMarks(page, sheet, { mode: state.marks, lengthMm: 4, offsetMm: 3 })) { pg.drawLine({ start: { x: line.x1 * MM_PT, y: (sheet.hMm - line.y1) * MM_PT }, end: { x: line.x2 * MM_PT, y: (sheet.hMm - line.y2) * MM_PT }, thickness: 0.25, color: rgb(0.4, 0.4, 0.38), }); } } return new Blob([await doc.save()], { type: 'application/pdf' }); } /* ---------------------------------------------------------------------- wire */ interface PgLabels { downloadBusy: string; noteAllFit: string; noteAllFitMulti: string; notePartial: string; noteNone: string; } export function mountPlayground(root: HTMLElement, labels: PgLabels) { const lang = document.documentElement.lang || 'en'; const mm = (w: number, h: number) => localiseNumbers(labelMm(w, h), lang); const svg = root.querySelector('#sheet')!; const out = { fits: root.querySelector('[data-out=fits]')!, pages: root.querySelector('[data-out=pages]')!, cell: root.querySelector('[data-out=cell]')!, paper: root.querySelector('[data-out=paper]')!, gap: root.querySelector('[data-out=gap]')!, note: root.querySelector('[data-out=note]')!, }; // Paper and size menus come from the same tables the application uses. const paperSel = root.querySelector('#paper')!; for (const group of [...new Set(PAPERS.map((p) => p.group))]) { const og = document.createElement('optgroup'); og.label = group; for (const p of PAPERS.filter((x) => x.group === group)) { const o = document.createElement('option'); o.value = p.id; o.textContent = `${p.label} · ${mm(p.w, p.h)}`; og.appendChild(o); } paperSel.appendChild(og); } paperSel.value = state.paperId; const sizeSel = root.querySelector('#size')!; for (const group of [...new Set(PHOTO_SIZES.map((s) => s.group))]) { const og = document.createElement('optgroup'); og.label = group; for (const s of PHOTO_SIZES.filter((x) => x.group === group)) { const o = document.createElement('option'); o.value = s.id; o.textContent = s.label; og.appendChild(o); } sizeSel.appendChild(og); } sizeSel.value = state.sizeId; function refresh() { const { sheet, result } = drawSheet(svg); const s = size(); const wanted = state.count; const placed = result.pages.reduce((n, p) => n + p.placements.length, 0); out.fits.textContent = String(placed); out.pages.textContent = String(result.pages.length); out.cell.textContent = mm(s.w, s.h); out.paper.textContent = mm(sheet.wMm, sheet.hMm); out.gap.textContent = localiseNumbers(`${effectiveGap(sheet).toFixed(1)} mm`, lang); const fill = (t: string) => t .replace('{n}', String(wanted)) .replace('{placed}', String(placed)) .replace('{pages}', String(result.pages.length)) .replace('{size}', s.label); if (placed === 0) { out.note.textContent = fill(labels.noteNone); out.note.dataset.tone = 'warn'; } else if (placed < wanted) { out.note.textContent = fill(labels.notePartial); out.note.dataset.tone = 'warn'; } else { out.note.textContent = fill(result.pages.length > 1 ? labels.noteAllFitMulti : labels.noteAllFit); out.note.dataset.tone = 'ok'; } } root.addEventListener('input', (e) => { const t = e.target as HTMLInputElement | HTMLSelectElement; switch (t.id) { case 'paper': state.paperId = t.value; break; case 'size': state.sizeId = t.value; break; case 'count': state.count = Math.max(1, Math.min(200, Number(t.value) || 1)); break; case 'bleed': state.bleedMm = Number(t.value); root.querySelector('[data-val=bleed]')!.textContent = `${state.bleedMm} mm`; break; case 'margin': state.marginMm = Number(t.value); root.querySelector('[data-val=margin]')!.textContent = `${state.marginMm} mm`; break; default: return; } refresh(); }); root.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('button[data-set]'); if (!b) return; const [key, value] = b.dataset.set!.split(':'); if (key === 'marks') state.marks = value as MarkMode; if (key === 'orientation') state.landscape = value === 'landscape'; if (key === 'rotate') state.allowRotate = !state.allowRotate; for (const sib of root.querySelectorAll(`button[data-set^="${key}:"]`)) { sib.setAttribute('aria-pressed', String(sib.dataset.set === b.dataset.set)); } if (key === 'rotate') b.setAttribute('aria-pressed', String(state.allowRotate)); refresh(); }); const dl = root.querySelector('#download')!; dl.addEventListener('click', async () => { const label = dl.textContent; dl.disabled = true; dl.textContent = labels.downloadBusy; try { const blob = await buildPdf(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `klarbild-demo-${state.sizeId}-${state.count}-on-${state.paperId}.pdf`; a.click(); // Revoke late: Safari cancels the download if the URL dies too early. setTimeout(() => URL.revokeObjectURL(url), 10_000); } finally { dl.disabled = false; dl.textContent = label; } }); refresh(); }