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:
2026-08-20 10:55:11 +00:00
parent aeea706eb6
commit 5ebe8026e0
12 changed files with 1759 additions and 499 deletions
+101
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts';
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.ts';
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
@@ -217,3 +218,103 @@ test('Entartete Mengen bringen den Packer nicht zum Stehen', () => {
assert.ok(res.pages.length >= 1);
assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`);
});
/* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */
test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => {
const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(200, 300, randlos, false), false);
const info = overflowPlacement('a', 1, 200, 300, randlos, false);
assert.equal(info.rotated, false);
assert.equal(info.lostLeft, 0);
assert.equal(info.lostRight, 0);
assert.equal(info.lostTop, 1.5); // 300 297 = 3 mm, mittig verteilt
assert.equal(info.lostBottom, 1.5);
assert.equal(info.lostMm, 3);
// mittig: x = (210 200) / 2
assert.equal(info.placement.x, 5);
assert.equal(info.placement.y, -1.5);
});
test('Überstehen dreht, wenn dabei weniger verloren geht', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
// 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast.
const info = overflowPlacement('a', 1, 297, 200, a4, true);
assert.equal(info.rotated, true);
assert.equal(info.lostMm, 0);
});
test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
assert.equal(tp.cols, 2);
assert.equal(tp.rows, 2);
assert.equal(tp.tiles.length, 4);
assert.equal(tp.overlapMm, 10);
// Erste Kachel füllt die Nutzfläche, letzte trägt den Rest.
assert.equal(tp.tiles[0].wMm, 200);
assert.equal(tp.tiles[0].hMm, 287);
assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom);
assert.equal(tp.tiles[3].glueRight, false);
assert.equal(tp.tiles[3].glueBottom, false);
// Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht).
const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`);
assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`);
});
test('Kacheln überlappen sich wirklich um den Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!;
const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!;
const ueberlappung = (links.xMm + links.wMm) - rechts.xMm;
assert.equal(Math.round(ueberlappung * 10) / 10, 10);
});
test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 };
for (const t of tp.tiles) {
const c = tileCrop(crop, t, 300, 400);
assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen');
assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen');
}
// Die erste Kachel beginnt exakt am Zuschnitt-Anfang.
const erste = tileCrop(crop, tp.tiles[0], 300, 400);
assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x);
assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y);
});
test('Ein Bild, das passt, gilt nicht als übergroß', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(130, 180, a4, false), true);
assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es');
assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht');
});
test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 };
// 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten.
assert.equal(noteInset(sheet), 1);
const ts = tileSheet(sheet);
const tp = tilePlan(300, 400, ts, 10);
const oben = (sheet.marginMm || 0) + noteInset(sheet);
for (const t of tp.tiles) {
assert.ok(oben + t.hMm <= sheet.hMm + 1e-6,
`Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`);
assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6);
}
// Die Kacheln decken das Endmaß lückenlos ab.
const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`);
});
test('Großzügiger Rand verschiebt die Kachel nicht', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 };
assert.equal(noteInset(sheet), 0);
assert.deepEqual(tileSheet(sheet), sheet);
});