Files
klarbild-demo/src/lib/playground.ts
T
tillandClaude Opus 5 71ffedfaa4 German page, and a section for the image module
/ and /de/ are now two pages from one shared component and one copy file, with
hreflang between them and a language button in the header. Separate URLs rather
than a runtime switch: each language is linkable and indexable, and the page
still works without JavaScript. The sheet playground takes its labels from the
page, so its readout says "29,7 cm" in German and "29.7 cm" in English.

New section for the `ai` module, because the page so far read as if Klarbild
were print-only. It says plainly what the module costs and that the endpoint is
configurable, and it carries a preset builder: pick tasks and a target size,
see the exact pixels and the recipe that gets saved. It composes; it does not
generate. A generator here would need a key on a public page and would show a
picture from a model rather than anything about Klarbild — the part worth
showing is the recipe.

The target pixels come from engine/format.ts, copied verbatim from the
application like the layout files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 15:42:22 +00:00

311 lines
11 KiB
TypeScript

/**
* 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<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 */
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<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} · ${mm(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 = 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<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 = 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();
}