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.
This commit is contained in:
@@ -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('.', ',');
|
||||||
@@ -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<string, boolean>[] = 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<string>[] {
|
||||||
|
const out: Set<string>[] = [];
|
||||||
|
const n = Math.min(ids.length, 8);
|
||||||
|
for (let m = 0; m < (1 << n); m++) {
|
||||||
|
const s = new Set<string>();
|
||||||
|
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<PlaceSpec, 'count'>, 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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user