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
+66 -1
View File
@@ -11,7 +11,10 @@ test('parseSizeMm versteht Tills Schreibweisen', () => {
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 });
// a:b ist Breite:Höhe — „4:3" ist quer, „3:4" hochkant.
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 150, h: 112.5 });
assert.deepEqual(parseSizeMm('3:4 / 15'), { w: 112.5, h: 150 });
assert.deepEqual(parseSizeMm('16:9/30'), { w: 300, h: 168.75 });
assert.equal(parseSizeMm('quatsch'), null);
assert.equal(parseSizeMm('9999x1'), null);
});
@@ -131,6 +134,9 @@ test('Formattabellen sind konsistent', () => {
assert.equal(photoById('S12x15')!.h, 150);
assert.equal(labelMm(35, 45), '35 × 45 mm');
assert.equal(labelMm(120, 150), '12 × 15 cm');
// Nachkommastellen dürfen nicht wegfallen — das Label steht in Fußzeile und Dateiname.
assert.equal(labelMm(112.5, 150), '11,25 × 15 cm');
assert.equal(labelMm(105, 148), '10,5 × 14,8 cm');
});
test('Die gewählte Ausrichtung bleibt, wenn sie nicht mehr Bogen kostet', () => {
@@ -152,3 +158,62 @@ test('Die gewählte Ausrichtung bleibt, wenn sie nicht mehr Bogen kostet', () =>
assert.equal(stur.pages.length, 0);
assert.equal(stur.unplaced.length, 1);
});
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
test('Ohne Angabe ist Drehen erlaubt (allowRotate undefined)', () => {
// Vorher landete das Bild in „unplaced", weil !!undefined === false war.
const res = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1 }], A4);
assert.equal(res.unplaced.length, 0);
assert.equal(res.pages[0].placements[0].rotated, true);
});
test('Auch bei vielen Formaten bekommt jedes eine passende Ausrichtung', () => {
// Notbremse „tooBig" (>5 Formate) darf kein Format still fallen lassen.
const specs = Array.from({ length: 5 }, (_, i) => ({ id: `k${i}`, wMm: 20, hMm: 30, count: 1, allowRotate: true }));
specs.push({ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true });
const res = layout(specs, { ...A4, gapMm: 4 });
const placed = res.pages.flatMap((p) => p.placements).map((p) => p.specId);
assert.ok(placed.includes('g'), 'das breite Bild muss gedreht platziert werden');
assert.equal(res.unplaced.length, 0);
});
test('unplaced nennt das Bild, das wirklich fehlt', () => {
const specs = [
{ id: 'klein', wMm: 20, hMm: 30, count: 2, allowRotate: false },
{ id: 'riesig', wMm: 250, hMm: 250, count: 3, allowRotate: false },
];
const res = layout(specs, A4);
assert.equal(res.unplaced.length, 1);
assert.equal(res.unplaced[0].specId, 'riesig');
assert.equal(res.unplaced[0].count, 3);
});
test('Eckmarken bleiben auch mit Beschnittzugabe außerhalb des gedruckten Bereichs', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, gapMm: 20, bleedMm: 5, center: true };
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 2, allowRotate: false }], sheet);
const pg = res.pages[0];
// Wunschversatz 3 mm ist zu klein für 5 mm Beschnitt — muss angehoben werden.
const lines = cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3, bleedMm: 5 });
assert.ok(lines.length > 0);
for (const l of lines) for (const p of pg.placements) {
const inBleed = (x: number, y: number) =>
x > p.x - 5 + 1e-6 && x < p.x + p.w + 5 - 1e-6 && y > p.y - 5 + 1e-6 && y < p.y + p.h + 5 - 1e-6;
assert.ok(!inBleed(l.x1, l.y1) && !inBleed(l.x2, l.y2), 'Marke liegt im gedruckten Beschnitt');
}
});
test('capacity deckelt nicht mehr bei 200', () => {
const a2: SheetSpec = { wMm: 420, hMm: 594, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const n = capacity({ id: 'x', wMm: 20, hMm: 30, allowRotate: false }, a2);
assert.equal(n, 20 * 19);
});
test('Entartete Mengen bringen den Packer nicht zum Stehen', () => {
const t0 = process.hrtime.bigint();
const specs = Array.from({ length: 40 }, (_, i) => ({ id: `s${i}`, wMm: 5 + (i % 5), hMm: 6 + (i % 4), count: 12, allowRotate: true }));
const res = layout(specs, { wMm: 2000, hMm: 2000, marginMm: 0, gapMm: 0, bleedMm: 0, center: true });
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
assert.ok(res.pages.length >= 1);
assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`);
});