e87c61435c
Security and robustness: - EXIF orientation is now applied before any geometry. Phone photos carry the rotation only as metadata; sharp was cropping the unrotated raster, so a portrait shot came out of the printer sideways and wrongly framed. - renderCell no longer materialises the padded image at source resolution. It is one extract-resize-extend chain now, which is also sharp's internal order. A panorama into a narrow contain target used to build a ~960 MB intermediate and then fail; it is 90 ms and a few MB now. - Target size is capped (300 Mpx) and bleedMm is clamped in /api/print/single, which had no bound at all. - The delivery gallery is validated before use - posixpath.join let a crafted name escape the target's base folder and create directories there. - Sheet requests are capped at 500 pieces and the packer has a step budget, so a degenerate request cannot block the single-threaded server. - Print presets: delete only your own (admins all), config size limit, count limit, and by_name honours anonymous_generations. - Telegram callbacks require an active pairing, like every other path. - Error responses no longer leak storage paths or delivery hostnames. Correctness: - allowRotate:undefined now means allowed, consistently with the packer. - The many-formats shortcut no longer drops a format that only fits rotated. - unplaced names the format that is actually missing, not the first one. - Crop marks never sit inside the printed bleed - the offset is raised. - capacity() computes the grid instead of probing with 200 copies. - Image keys in the sheet cannot collide with a cell literally named x::rot. - labelMm keeps real decimals; parseSizeMm reads a:b as width:height, so 3:4/15 is portrait and 4:3/15 is landscape. - The footer is skipped when there is no free space at the bottom. - The UI warns when corner marks do not fit the margin, and when continuous guides are used with mixed sizes. Tests: 21 -> 31, each finding has a regression test.
133 lines
7.6 KiB
TypeScript
133 lines
7.6 KiB
TypeScript
// 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: 'Passbild 35 × 45 mm (biometrisch)', w: 35, h: 45, group: 'Passbild' },
|
||
{ id: 'P50x50', label: 'Visum USA 51 × 51 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: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
|
||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
|
||
// Klassische Fotoformate — deckungsgleich mit den KI-Formaten aus src/lib/format.ts
|
||
{ 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: 'S11x15', label: '11 × 15 cm', w: 110, 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: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Foto' },
|
||
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
|
||
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
|
||
// Poster- und Rahmenformate (Bilderrahmen im Handel)
|
||
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
|
||
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
|
||
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & Rahmen' },
|
||
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & Rahmen' },
|
||
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & Rahmen' },
|
||
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & Rahmen' },
|
||
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & Rahmen' },
|
||
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & Rahmen' },
|
||
// 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' },
|
||
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
|
||
// Bildschirm-Seitenverhältnisse als Druckmaß (The Frame & Co.)
|
||
{ id: 'W16x9', label: '16:9 „The Frame" (30 cm breit)', w: 300, h: 168.75, group: 'Seitenverhältnis' },
|
||
{ id: 'W9x16', label: '9:16 Hochformat (30 cm hoch)', w: 168.75, h: 300, group: 'Seitenverhältnis' },
|
||
{ id: 'W3x2', label: '3:2 (auf 30 cm)', w: 300, h: 200, group: 'Seitenverhältnis' },
|
||
{ id: 'W4x3', label: '4:3 (auf 30 cm)', w: 300, h: 225, group: 'Seitenverhältnis' },
|
||
{ id: 'W5x4', label: '5:4 (auf 30 cm)', w: 300, h: 240, group: 'Seitenverhältnis' },
|
||
];
|
||
|
||
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]) {
|
||
// a:b wird als Breite:Höhe gelesen — „3:4/15" ist also hochkant, „4:3/15" quer.
|
||
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;
|
||
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), 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 — ohne die echten Nachkommastellen zu verlieren. */
|
||
export function labelMm(w: number, h: number): string {
|
||
const mm = (n: number) => de(Math.round(n * 10) / 10);
|
||
const cm = (n: number) => de(Math.round(n * 100) / 1000);
|
||
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
|
||
}
|
||
const de = (n: number) => String(n).replace('.', ',');
|