fix: code-review findings in the print module

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.
This commit is contained in:
2026-08-18 07:50:11 +00:00
parent 7d42782c41
commit e87c61435c
12 changed files with 303 additions and 76 deletions
+7 -6
View File
@@ -95,11 +95,11 @@ export function parseSizeMm(input: string): { w: number; h: number } | 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;
const [lo, hi] = a >= b ? [b, a] : [a, b];
return norm(long * (lo / hi), long);
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
}
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
@@ -123,9 +123,10 @@ function norm(w: number, h: number): { w: number; h: number } | null {
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. */
/** Hübsche Beschriftung eines Maßes — ohne die echten Nachkommastellen zu verlieren. */
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 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 fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ',');
const de = (n: number) => String(n).replace('.', ',');