Demo and product page for Klarbild

A static Astro page. The sheet playground imports the print engine
(printlayout.ts, paper.ts) verbatim from the Klarbild repository and runs it in
the browser, so the layout on screen is the application's own arithmetic rather
than a mock-up. The PDF it offers is drawn from the same placements with pdf-lib.

No backend, nothing uploaded, no third-party requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 14:59:59 +00:00
commit 181060c32d
19 files changed
+7309

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
/**
* 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';
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<string, string | number>) {
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<Blob> {
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 */
export function mountPlayground(root: HTMLElement) {
const svg = root.querySelector<SVGSVGElement>('#sheet')!;
const out = {
fits: root.querySelector<HTMLElement>('[data-out=fits]')!,
pages: root.querySelector<HTMLElement>('[data-out=pages]')!,
cell: root.querySelector<HTMLElement>('[data-out=cell]')!,
paper: root.querySelector<HTMLElement>('[data-out=paper]')!,
gap: root.querySelector<HTMLElement>('[data-out=gap]')!,
note: root.querySelector<HTMLElement>('[data-out=note]')!,
};
// Paper and size menus come from the same tables the application uses.
const paperSel = root.querySelector<HTMLSelectElement>('#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} · ${labelMm(p.w, p.h)}`;
og.appendChild(o);
}
paperSel.appendChild(og);
}
paperSel.value = state.paperId;
const sizeSel = root.querySelector<HTMLSelectElement>('#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 = labelMm(s.w, s.h);
out.paper.textContent = labelMm(sheet.wMm, sheet.hMm);
out.gap.textContent = `${effectiveGap(sheet).toFixed(1)} mm`;
if (placed === 0) {
out.note.textContent = `${s.label} does not fit on this sheet at 100 %. In the application this is where you choose overflow or tiling.`;
out.note.dataset.tone = 'warn';
} else if (placed < wanted) {
out.note.textContent = `${placed} of ${wanted} fit on ${result.pages.length} sheet${result.pages.length > 1 ? 's' : ''}.`;
out.note.dataset.tone = 'warn';
} else {
out.note.textContent = `All ${placed} fit${result.pages.length > 1 ? ` across ${result.pages.length} sheets` : ' on one sheet'}. Nothing is scaled.`;
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<HTMLButtonElement>('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<HTMLButtonElement>(`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<HTMLButtonElement>('#download')!;
dl.addEventListener('click', async () => {
const label = dl.textContent;
dl.disabled = true;
dl.textContent = 'Building the PDF …';
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();
}