feat: oversize sheets, image duplication, free labelling and a new layout

Prints larger than the sheet no longer just fail. Each cell can now spill over
the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be
tiled across several sheets with a dashed glue fold and a sheet number. The
maths lives in printoversize.ts, dependency-free, so the browser preview and the
server PDF compute the same thing -- including the note strip that keeps the
sheet number off the picture.

The same image can now appear as several cells, so a portrait can be printed at
two sizes on one sheet without copying and re-uploading the file. The footer
line can be switched off, given custom text and placed at one of six positions;
it is only drawn where that edge is free.

Layout reworked for all three widths: work left, sticky preview right on the
desktop, collapsed setting groups paired in a two-column grid that expands full
width; preview first and a fixed action bar on the phone. Verified at 390, 820,
1440 and 1680 px -- no horizontal overflow, no dead white space.

Also a public presentation page at /vorstellung (no login, nothing operable),
whose sample sheet is computed by the real packer rather than drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
tillandClaude Opus 5 committed 2026-08-20 10:55:11 +00:00
1 parent aeea706eb6
commit 5ebe8026e0
12 files changed
+1759 -499

No files matched your search

+99 -16
View File
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery';
@@ -11,7 +12,7 @@ export const prerender = false;
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
const MAX_COPIES = 200; // Exemplare je Bild
const MAX_PAGES = 20;
const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
interface CellIn {
@@ -25,6 +26,8 @@ interface CellIn {
landscape?: boolean; // Bildformat quer statt hoch
fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen
bg?: string; // Randfarbe beim Einpassen
oversize?: Oversize; // größer als der Bogen: melden, überstehen lassen oder kacheln
overlapMm?: number; // Klebefalz beim Kacheln
}
const json = (b: unknown, s = 200) =>
@@ -46,26 +49,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
const specs: PlaceSpec[] = [];
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
// Zellen, die nicht auf den Bogen passen und dafür einen eigenen Weg haben.
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
for (const [i, c] of cellsIn.entries()) {
const size = cellSize(c);
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
const id = String(c.id || `c${i}`);
specs.push({
id, wMm: size.w, hMm: size.h,
count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES),
allowRotate: c.allowRotate !== false,
});
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
const allowRotate = c.allowRotate !== false;
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
continue;
}
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
}
if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
const totalPieces = specs.reduce((n, s) => n + s.count, 0);
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
const plan = layout(specs, sheet);
if (!plan.pages.length) {
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
if (!plan.pages.length && !big.length) {
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
}
if (plan.pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${plan.pages.length}). Bitte Anzahl reduzieren.` }, 422);
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
// Gedrehte Platzierungen brauchen eine eigene Fassung.
@@ -103,13 +113,69 @@ export const POST: APIRoute = async ({ request, locals }) => {
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
bleedMm: sheet.bleedMm,
}));
const extras: PageExtras[] = plan.pages.map(() => ({}));
const kinds = resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`);
const footer = body?.footer === false ? null
: `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
// --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln ---
const notes: string[] = [];
for (const [bi, b] of big.entries()) {
const buf = await loadSource(b.cell.src, locals.user as any);
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
if (b.mode === 'overflow') {
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
const key = `o${bi}`;
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
images[key] = { bytes: out.buffer, ext: out.ext };
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
marks.push([]); // Marken wären hier sinnlos, die Kanten liegen außerhalb
extras.push({});
}
if (info.lostMm > 0.05)
notes.push(`${labelMm(b.wMm, b.hMm)} ragt über den Bogen: es fehlen ${fmtMm(info.lostLeft + info.lostRight)} in der Breite und ${fmtMm(info.lostTop + info.lostBottom)} in der Höhe.`);
} else {
const noteMm = noteInset(sheet);
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
if (tp.tiles.length * b.count > MAX_PAGES)
return json({ error: `Der Posterdruck bräuchte ${tp.tiles.length * b.count} Blätter — bitte kleineres Maß oder größeres Papier.` }, 422);
const inset = sheet.marginMm || 0;
for (const [ti, t] of tp.tiles.entries()) {
const key = `t${bi}_${ti}`;
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
images[key] = { bytes: out.buffer, ext: out.ext };
// Klebefalz sichtbar machen: gestrichelt dort, wo das Nachbarblatt anschließt.
const dashed: Line[] = [];
const top = inset + noteMm;
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
marks.push([]);
extras.push({
note: `Blatt ${ti + 1}/${tp.tiles.length} · Reihe ${t.row + 1}, Spalte ${t.col + 1}` +
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} Klebefalz (gestrichelt)` : ''),
dashed,
});
}
}
notes.push(`${labelMm(b.wMm, b.hMm)} auf ${tp.cols} × ${tp.rows} Blätter verteilt, ${fmtMm(tp.overlapMm)} Überlappung zum Kleben.`);
}
}
if (pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${pages.length}). Bitte Anzahl reduzieren.` }, 422);
const kinds = [
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (Poster)' : ' (überstehend)'}`),
];
const footer = readFooter(body, sheet, kinds, dpi);
const bytes = await buildSheetPdf({
sheet, pages, marksPerPage: marks, images,
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
title: String(body?.title || 'Klarbild Druckbogen'),
footer,
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
@@ -147,6 +213,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
'X-Klarbild-Pages': String(pages.length),
'X-Klarbild-Per-Sheet': String(plan.perSheet),
'X-Klarbild-Delivered': delivered,
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
},
});
@@ -197,6 +264,22 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
return c.landscape ? { w: s.h, h: s.w } : s;
}
/** Millimeter menschenlesbar: „3 mm", „1,5 mm", „0 mm". */
const fmtMm = (n: number) => `${String(Math.round(n * 10) / 10).replace('.', ',')} mm`;
const FOOTER_POS: FooterPos[] = ['unten-links', 'unten-mitte', 'unten-rechts', 'oben-links', 'oben-mitte', 'oben-rechts'];
/** Fußzeile: aus, automatisch oder eigener Text an frei wählbarer Stelle. */
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
const f = body?.footer;
if (f === false || f === null) return null;
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
if (f === true || f == null) return { text: auto, position: 'unten-links' as FooterPos, sizePt: 6 };
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'unten-links';
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
}
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
/** Randfarbe nur als sicheres Hex zulassen. */
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');