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:
2026-08-18 06:20:45 +00:00
parent ed22a76fda
commit a651025422
3 changed files with 610 additions and 0 deletions
+116
View File
@@ -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('.', ',');
+360
View File
@@ -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: 410 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 };
}