From a651025422e5808f1ba369f19e6fc85f6a41a894 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 18 Aug 2026 06:20:45 +0000 Subject: [PATCH] feat: mm-based print layout math and paper/photo size tables Pure geometry, no I/O: DIN A6-A2 incl. A3+ (329x483), photo-paper cuts, US Letter/Legal, biometric and small photo formats, plus a forgiving free size parser (12x15 cm, 35x45mm, 5, 4:3/15). Packing: exact grid for uniform sizes, MaxRects with one fixed orientation per format for mixed sets, so small prints fill the space next to a large one. Crop marks as line segments (corner marks outside the trim box; continuous guides that never cross a motif). Covered by tests/printlayout.test.ts. --- src/lib/paper.ts | 116 ++++++++++++ src/lib/printlayout.ts | 360 ++++++++++++++++++++++++++++++++++++++ tests/printlayout.test.ts | 134 ++++++++++++++ 3 files changed, 610 insertions(+) create mode 100644 src/lib/paper.ts create mode 100644 src/lib/printlayout.ts create mode 100644 tests/printlayout.test.ts diff --git a/src/lib/paper.ts b/src/lib/paper.ts new file mode 100644 index 0000000..dff400f --- /dev/null +++ b/src/lib/paper.ts @@ -0,0 +1,116 @@ +// Papier- und Bildformate für den Druckbogen (Modul „Druck", ohne KI). +// Alle Maße in Millimetern — mm ist die Leitwährung dieses Moduls, weil ein +// Druckbogen physisch ist. Pixel entstehen erst beim Rendern (mm → dpi). + +export interface PaperSize { id: string; label: string; w: number; h: number; group: string } + +/** Bogenformate (Hochformat-Konvention: kurze Seite × lange Seite). */ +export const PAPERS: PaperSize[] = [ + // DIN + { id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' }, + { id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' }, + { id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' }, + { id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' }, + { id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' }, + { id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' }, + // Fotopapier (Zuschnitte, wie sie im Fachhandel liegen) + { id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' }, + { id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' }, + { id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' }, + { id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' }, + { id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' }, + // US + { id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' }, + { id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' }, +]; + +/** Bildformate (Endformat eines einzelnen Bildes auf dem Bogen). */ +export interface PhotoSize { id: string; label: string; w: number; h: number; group: string } + +export const PHOTO_SIZES: PhotoSize[] = [ + // Passbild / Ausweis + { id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' }, + { id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' }, + // Kleinformate (Kita, Schule, Portemonnaie) + { id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' }, + { id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' }, + { id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' }, + { id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' }, + { id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' }, + // Klassische Fotoformate + { id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' }, + { id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' }, + { id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' }, + { id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' }, + { id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' }, + { id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' }, + { id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' }, + { id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' }, + { id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' }, + { id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' }, + { id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' }, + { id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' }, + // Quadrate + { id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' }, + { id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' }, + { id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' }, + { id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' }, + // DIN als Bildformat + { id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' }, + { id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' }, + { id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' }, + { id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' }, +]; + +export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null; +export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null; + +/** + * Freitext-Maß → mm. Bewusst großzügig, weil Till Maße so tippt, wie er sie sagt: + * „12x15" → 120 × 150 mm (cm ist die Standardeinheit) + * „12 × 15 cm" → 120 × 150 mm + * „35x45mm" → 35 × 45 mm + * „5" → 50 × 50 mm (quadratisch) + * „4:3 / 15" → Seitenverhältnis 4:3, längere Seite 15 cm → 150 × 112,5 mm + */ +export function parseSizeMm(input: string): { w: number; h: number } | null { + const t = (input || '').trim().toLowerCase().replace(/,/g, '.'); + if (!t) return null; + + // Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm" + const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t); + if (ratio && ratio[3]) { + const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]); + const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10; + if (!a || !b || !long) return null; + const [lo, hi] = a >= b ? [b, a] : [a, b]; + return norm(long * (lo / hi), long); + } + + const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm + const body = t.replace(/\s*(mm|cm)\s*$/, '').trim(); + + const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body); + if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit); + + const sq = /^(\d+(?:\.\d+)?)$/.exec(body); + if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); } + + return null; +} + +function norm(w: number, h: number): { w: number; h: number } | null { + const r = (n: number) => Math.round(n * 100) / 100; + if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null; + return { w: r(w), h: r(h) }; +} + +export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi); +export const mmToPt = (mm: number) => (mm / 25.4) * 72; + +/** Hübsche Beschriftung eines Maßes für die Oberfläche. */ +export function labelMm(w: number, h: number): string { + const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ',')); + return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`; +} +const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ','); diff --git a/src/lib/printlayout.ts b/src/lib/printlayout.ts new file mode 100644 index 0000000..74293ae --- /dev/null +++ b/src/lib/printlayout.ts @@ -0,0 +1,360 @@ +// Reine Layout-Mathematik für den Druckbogen — mm, kein I/O, keine Abhängigkeiten. +// Wird identisch von der Vorschau im Browser und vom PDF-Renderer auf dem Server +// benutzt, damit „was ich sehe" und „was gedruckt wird" dieselbe Rechnung sind. +// +// Koordinaten: Ursprung oben links, x nach rechts, y nach unten (wie Bildschirm/SVG). +// Der PDF-Renderer spiegelt y, weil PDF von unten links zählt. + +export interface PlaceSpec { + id: string; // verweist auf ein Bild + wMm: number; // Endformat (Trimmbox) Breite + hMm: number; // Endformat Höhe + count: number; // wie oft aufs Papier + allowRotate?: boolean; +} + +export interface SheetSpec { + wMm: number; + hMm: number; + marginMm: number; // nicht bedruckbarer Rand des Druckers (Sicherheitsrand) + gapMm: number; // Abstand zwischen zwei Endformaten + bleedMm?: number; // Beschnittzugabe je Seite (Bild ragt darüber hinaus) + center?: boolean; // Block auf dem Bogen zentrieren (Default: true) +} + +export interface Placement { + specId: string; + copy: number; // 1-basiert + x: number; y: number; // linke obere Ecke der Trimmbox + w: number; h: number; // Trimmbox + rotated: boolean; // Bild um 90° gedreht platziert +} + +export interface Line { x1: number; y1: number; x2: number; y2: number } + +export interface Page { placements: Placement[] } + +export interface LayoutResult { + pages: Page[]; + /** Was auf keinen Bogen passt (z. B. Bild größer als das Papier). */ + unplaced: { specId: string; count: number; reason: string }[]; + perSheet: number; // Stück auf dem ersten Bogen (Kennzahl für die UI) +} + +export type MarkMode = 'none' | 'corner' | 'grid'; + +const EPS = 1e-6; + +/** Effektiver Abstand: Beschnittzugaben zweier Nachbarn dürfen sich nicht überlappen. */ +export function effectiveGap(sheet: SheetSpec): number { + return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0)); +} + +/** + * Packt die Endformate auf so wenige Bogen wie möglich. + * + * Zwei Wege, bewusst getrennt: + * 1. Alle Bilder gleich groß → exaktes Raster (Spalten × Zeilen). Perfekt + * ausgerichtet, damit durchgehende Schnittlinien wirklich durchgehen. + * 2. Gemischte Größen → MaxRects (Best-Short-Side-Fit) mit optionaler + * 90°-Drehung. Das ist der Fall „ein großes Bild plus acht Passbilder": + * die Kleinen wandern in die Restfläche neben dem Großen. + * + * Der Abstand wird als „aufgeblasenes" Rechteck mitgeführt (Bild + Abstand), + * deshalb stimmen die Zwischenräume überall, auch am Rand. + */ +export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult { + const gap = effectiveGap(sheet); + const bleed = sheet.bleedMm || 0; + const inset = (sheet.marginMm || 0) + bleed; + const availW = sheet.wMm - 2 * inset; + const availH = sheet.hMm - 2 * inset; + + const unplaced: LayoutResult['unplaced'] = []; + const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = []; + + for (const s of specs) { + const n = Math.max(0, Math.floor(s.count || 0)); + if (!n) continue; + const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS; + const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS; + if (!fitsPlain && !fitsRot) { + unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' }); + continue; + } + for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false }); + } + if (!units.length) return { pages: [], unplaced, perSheet: 0 }; + + const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS); + let pages: Page[]; + + if (uniform) { + pages = gridPack(units, specs, availW, availH, gap); + } else { + // Ausrichtung je Bildformat festlegen (nicht je Exemplar) — sonst liegen + // gleiche Bilder wild durcheinander auf dem Bogen. Alle sinnvollen + // Kombinationen durchprobieren und die beste nehmen. + const ids = [...new Set(units.map((u) => u.specId))]; + const rotatable = ids.filter((id) => { + const sp = specs.find((s) => s.id === id)!; + return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS; + }); + const tooBig = ids.length > 5 || units.length > 150; + const variants: Record[] = tooBig + ? [Object.fromEntries(ids.map((id) => [id, false]))] + : combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)]))); + + let best: Page[] | null = null, bestScore = -Infinity; + for (const [vi, rotMap] of variants.entries()) { + const oriented = units.map((u) => { + const r = !!rotMap[u.specId]; + const sp = specs.find((s) => s.id === u.specId)!; + // Nur Ausrichtungen zulassen, die überhaupt auf den Bogen passen. + const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm; + return { ...u, w, h, rot: r }; + }); + if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue; + const cand = maxRectsPack(oriented, availW, availH, gap); + const placed = cand.reduce((n, p) => n + p.placements.length, 0); + if (placed < units.length) continue; + // Wenige Bogen zählt am meisten, dann volle erste Seite, dann „so wenig + // gedreht wie möglich" (Tills Wunschausrichtung bleibt eher erhalten). + const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi + - 0.1 * Object.values(rotMap).filter(Boolean).length; + if (score > bestScore) { bestScore = score; best = cand; } + } + pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap); + const placed = pages.reduce((n, p) => n + p.placements.length, 0); + if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' }); + } + + // Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke. + for (const pg of pages) { + let offX = inset, offY = inset; + if (sheet.center !== false && pg.placements.length) { + const minX = Math.min(...pg.placements.map((p) => p.x)); + const maxX = Math.max(...pg.placements.map((p) => p.x + p.w)); + const minY = Math.min(...pg.placements.map((p) => p.y)); + const maxY = Math.max(...pg.placements.map((p) => p.y + p.h)); + offX = inset + (availW - (maxX - minX)) / 2 - minX; + offY = inset + (availH - (maxY - minY)) / 2 - minY; + } + for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); } + } + + return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 }; +} + +/** Gleich große Bilder: sauberes Raster, Zeile für Zeile. */ +function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[], + availW: number, availH: number, gap: number): Page[] { + const spec = specs.find((s) => s.id === units[0].specId); + let { w, h } = units[0]; + let rot = false; + const fit = (a: number, b: number) => ({ + cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))), + rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))), + }); + let best = fit(w, h); + if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) { + const alt = fit(h, w); + if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; } + } + const perPage = best.cols * best.rows; + if (!perPage) return []; + + const pages: Page[] = []; + for (let i = 0; i < units.length; i += perPage) { + const chunk = units.slice(i, i + perPage); + const placements: Placement[] = chunk.map((u, k) => { + const c = k % best.cols, r = Math.floor(k / best.cols); + return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot }; + }); + pages.push({ placements }); + } + return pages; +} + +interface FreeRect { x: number; y: number; w: number; h: number } + +/** Gemischte Größen: MaxRects mit Best-Short-Side-Fit, Ausrichtung schon festgelegt. */ +function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[], + availW: number, availH: number, gap: number): Page[] { + // Groß zuerst — sonst blockieren Kleinteile die guten Flächen. + const todo = [...units].sort((a, b) => + (b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) || + a.specId.localeCompare(b.specId) || a.copy - b.copy); + + const W = availW + gap, H = availH + gap; // aufgeblasene Fläche + const pages: Page[] = []; + + while (todo.length) { + let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }]; + const placements: Placement[] = []; + + for (;;) { + let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity; + let bestRect: FreeRect | null = null; + + for (let i = 0; i < todo.length; i++) { + const u = todo[i]; + const cw = u.w + gap, ch = u.h + gap; + for (const r of free) { + if (cw > r.w + EPS || ch > r.h + EPS) continue; + const short = Math.min(r.w - cw, r.h - ch); + // Bei Gleichstand die am weitesten oben/links liegende Fläche nehmen — + // das ergibt ein ruhiges, lesbares Bogenbild statt verstreuter Motive. + const pos = r.y * 10000 + r.x; + if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) { + bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r; + } + } + } + if (bestIdx < 0 || !bestRect) break; + + const u = todo.splice(bestIdx, 1)[0]; + placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot }); + free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap }); + } + + if (!placements.length) break; // nichts platzierbar → Abbruch + placements.sort((a, b) => a.y - b.y || a.x - b.x); + pages.push({ placements }); + if (pages.length > 200) break; // Notbremse + } + return pages; +} + +/** Alle Teilmengen (Ausrichtungs-Kombinationen), stabil sortiert. */ +function combos(ids: string[]): Set[] { + const out: Set[] = []; + const n = Math.min(ids.length, 8); + for (let m = 0; m < (1 << n); m++) { + const s = new Set(); + for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]); + out.push(s); + } + // Erst „nichts gedreht", dann aufsteigend nach Anzahl Drehungen. + return out.sort((a, b) => a.size - b.size); +} + +function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] { + const out: FreeRect[] = []; + for (const r of free) { + const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS + && used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS; + if (!overlap) { out.push(r); continue; } + if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h }); + if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h }); + if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y }); + if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) }); + } + // In anderen enthaltene Rechtecke wegwerfen (sonst wächst die Liste ins Unendliche). + return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) => + j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS && + b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS && + (b.w > a.w + EPS || b.h > a.h + EPS || j < i))); +} + +/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */ +export function capacity(spec: Omit, sheet: SheetSpec): number { + const probe = layout([{ ...spec, count: 200 }], sheet); + return probe.pages[0]?.placements.length || 0; +} + +export interface MarkOptions { + mode: MarkMode; + lengthMm?: number; // Länge einer Marke (Norm: 4–10 mm) + offsetMm?: number; // Abstand vom Endformat nach außen + bleedMm?: number; +} + +/** + * Schnittmarken als Liniensegmente. + * - `corner`: vier Eckenpaare je Bild, außerhalb des Endformats (Adobe-/Druckerei-Konvention). + * - `grid`: durchgehende Linien über den ganzen Bogen an jeder Schnittkante — für + * das Schneidelineal/die Schlagschere, wenn viele gleiche Bilder drauf sind. + */ +export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] { + if (opt.mode === 'none') return []; + const len = opt.lengthMm ?? 4; + const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1); + const lines: Line[] = []; + + if (opt.mode === 'corner') { + for (const p of page.placements) { + const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h; + // je Ecke: eine waagerechte und eine senkrechte Marke, nach außen weisend + for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) { + lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y }); + lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) }); + } + } + return clip(lines, sheet); + } + + // grid: Schnittkanten über den ganzen Bogen ziehen — aber nur dort, wo kein + // anderes Bild im Weg ist. Sonst liefe die Linie quer durchs Nachbarmotiv. + const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w])); + const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h])); + for (const x of xs) { + const blocked = page.placements + .filter((p) => p.x + EPS < x && x < p.x + p.w - EPS) + .map((p) => [p.y, p.y + p.h] as [number, number]); + for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b }); + } + for (const y of ys) { + const blocked = page.placements + .filter((p) => p.y + EPS < y && y < p.y + p.h - EPS) + .map((p) => [p.x, p.x + p.w] as [number, number]); + for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y }); + } + return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2); +} + +const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b); + +/** Freie Abschnitte in [from,to] nach Abzug der belegten Intervalle. */ +function complement(blocked: [number, number][], from: number, to: number): [number, number][] { + const sorted = [...blocked].sort((a, b) => a[0] - b[0]); + const out: [number, number][] = []; + let cur = from; + for (const [a, b] of sorted) { + if (a > cur) out.push([cur, Math.min(a, to)]); + cur = Math.max(cur, b); + if (cur >= to) break; + } + if (cur < to) out.push([cur, to]); + return out.filter(([a, b]) => b - a > 0.2); +} + +/** + * Kleinster Abstand, bei dem die gewählten Marken noch sinnvoll sind: + * Eckmarken zweier Nachbarn brauchen je (Versatz + Länge) Platz, Beschnittzugaben + * dürfen sich nicht überlappen. + */ +export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number { + const off = offsetMm ?? Math.max(2, bleedMm + 1); + const base = 2 * bleedMm; + if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm)); + if (mode === 'grid') return Math.max(base, 0); + return base; +} + +const round = (n: number) => Math.round(n * 1000) / 1000; + +/** Marken, die über den Bogenrand hinausragen würden, abschneiden bzw. verwerfen. */ +function clip(lines: Line[], sheet: SheetSpec): Line[] { + const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v)); + const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v)); + return lines + .map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) })) + .filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2); +} + +/** Reicht die Quellauflösung für das Endformat bei gewünschter dpi? */ +export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } { + const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0; + return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 }; +} diff --git a/tests/printlayout.test.ts b/tests/printlayout.test.ts new file mode 100644 index 0000000..2150354 --- /dev/null +++ b/tests/printlayout.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout'; +import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper'; + +const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true }; + +test('parseSizeMm versteht Tills Schreibweisen', () => { + assert.deepEqual(parseSizeMm('12x15'), { w: 120, h: 150 }); + assert.deepEqual(parseSizeMm('12 × 15 cm'), { w: 120, h: 150 }); + assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 }); + assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 }); + assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 }); + assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 }); + assert.equal(parseSizeMm('quatsch'), null); + assert.equal(parseSizeMm('9999x1'), null); +}); + +test('mm → px ist exakt (Druckmaß-Nachweis)', () => { + assert.equal(mmToPx(100, 300), 1181); // 10 cm + assert.equal(mmToPx(150, 300), 1772); // 15 cm + assert.equal(mmToPx(300, 300), 3543); // 30 cm → wie Poster 30×40 + assert.equal(mmToPx(400, 300), 4724); + assert.equal(mmToPx(35, 300), 413); // Passbild + assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt +}); + +test('Passbild-Bogen: 35×45 mm auf A4 ergibt 20 Stück', () => { + assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20); +}); + +test('Platzierungen überlappen nicht und bleiben im Satzspiegel', () => { + const sheet: SheetSpec = { ...A4, gapMm: 6 }; + const res = layout([ + { id: 'a', wMm: 90, hMm: 130, count: 2 }, + { id: 'b', wMm: 35, hMm: 45, count: 6 }, + ], sheet); + assert.ok(res.pages.length >= 1); + for (const pg of res.pages) { + for (const p of pg.placements) { + assert.ok(p.x >= sheet.marginMm - 1e-6, 'linker Rand'); + assert.ok(p.y >= sheet.marginMm - 1e-6, 'oberer Rand'); + assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'rechter Rand'); + assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'unterer Rand'); + } + for (let i = 0; i < pg.placements.length; i++) + for (let j = i + 1; j < pg.placements.length; j++) { + const a = pg.placements[i], b = pg.placements[j]; + const overlap = a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6 + && a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6; + assert.ok(!overlap, `Überlappung ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`); + } + } +}); + +test('Abstand hält die Beschnittzugabe der Nachbarn auseinander', () => { + const sheet: SheetSpec = { ...A4, gapMm: 1, bleedMm: 3 }; + assert.equal(effectiveGap(sheet), 6); + const res = layout([{ id: 'a', wMm: 50, hMm: 50, count: 4 }], { ...sheet, gapMm: effectiveGap(sheet) }); + const row = res.pages[0].placements.filter((p) => Math.abs(p.y - res.pages[0].placements[0].y) < 1e-6) + .sort((a, b) => a.x - b.x); + assert.ok(row.length >= 2); + assert.ok(row[1].x - (row[0].x + row[0].w) >= 6 - 1e-6); +}); + +test('Eckmarken liegen außerhalb des Endformats', () => { + const res = layout([{ id: 'a', wMm: 90, hMm: 130, count: 1 }], A4); + const pg = res.pages[0]; + const lines = cutMarks(pg, A4, { mode: 'corner', lengthMm: 4, offsetMm: 3 }); + assert.equal(lines.length, 8, '4 Ecken × 2 Marken'); + const p = pg.placements[0]; + for (const l of lines) { + const inside = (x: number, y: number) => + x > p.x + 1e-6 && x < p.x + p.w - 1e-6 && y > p.y + 1e-6 && y < p.y + p.h - 1e-6; + assert.ok(!inside(l.x1, l.y1) && !inside(l.x2, l.y2), 'Marke ragt ins Bild'); + } +}); + +test('Durchgehende Linien laufen nicht durch andere Bilder', () => { + const sheet: SheetSpec = { ...A4, gapMm: 4 }; + const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 6 }], sheet); + const pg = res.pages[0]; + const lines = cutMarks(pg, sheet, { mode: 'grid' }); + assert.ok(lines.length > 0); + for (const l of lines) { + for (const p of pg.placements) { + const hitsX = Math.min(l.x1, l.x2) < p.x + p.w - 1e-3 && Math.max(l.x1, l.x2) > p.x + 1e-3; + const hitsY = Math.min(l.y1, l.y2) < p.y + p.h - 1e-3 && Math.max(l.y1, l.y2) > p.y + 1e-3; + assert.ok(!(hitsX && hitsY), 'Linie schneidet ein Motiv'); + } + } +}); + +test('Zu großes Bild landet in unplaced statt im Nirwana', () => { + const res = layout([{ id: 'gross', wMm: 300, hMm: 400, count: 1 }], A4); + assert.equal(res.pages.length, 0); + assert.equal(res.unplaced[0].specId, 'gross'); +}); + +test('Mehr als ein Bogen, wenn die Menge nicht passt', () => { + const res = layout([{ id: 'a', wMm: 35, hMm: 45, count: 25 }], A4); + assert.equal(res.pages.length, 2); + assert.equal(res.pages[0].placements.length, 20); + assert.equal(res.pages[1].placements.length, 5); +}); + +test('Drehen erlaubt mehr Bilder auf 10×15-Fotopapier', () => { + const foto: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; + const ohne = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, foto); + const mit = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, foto); + assert.equal(ohne, 3); + assert.ok(mit >= 3); +}); + +test('Empfohlener Abstand macht Platz für Marken', () => { + assert.equal(recommendedGap('corner', 0, 4, 3), 14); + assert.equal(recommendedGap('none', 3), 6); + assert.equal(recommendedGap('grid', 0), 0); +}); + +test('dpi-Warnung greift bei zu kleiner Quelle', () => { + assert.equal(dpiCheck(1181, 100, 300).ok, true); + assert.equal(dpiCheck(600, 100, 300).ok, false); + assert.equal(dpiCheck(1181, 100, 300).dpi, 300); +}); + +test('Formattabellen sind konsistent', () => { + assert.equal(paperById('A4')!.w, 210); + assert.equal(paperById('A3plus')!.h, 483); + assert.equal(photoById('P35x45')!.w, 35); + assert.equal(photoById('S12x15')!.h, 150); + assert.equal(labelMm(35, 45), '35 × 45 mm'); + assert.equal(labelMm(120, 150), '12 × 15 cm'); +});