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:
till committed 2026-08-18 07:50:11 +00:00
1 parent 7d42782c41
commit e87c61435c
12 files changed
+303 -76

No files matched your search

+13 -1
View File
@@ -35,6 +35,10 @@ interface Cell {
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
/** Farbwerte aus fremden Vorlagen landen in style — nur echte Hex-Werte zulassen. */
const safeColor = (v: unknown): string =>
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v : '#ffffff';
/** Größter Ausschnitt im Zielverhältnis — füllt das Format aus, schneidet ab. */
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
const imgA = natW / natH;
@@ -292,7 +296,7 @@ export default function PrintApp() {
const apply = (cell: Cell, f: any): Cell => {
const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '',
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false,
fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: f.bg || '#ffffff' };
fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: safeColor(f.bg) };
const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, cell.crop);
return next;
};
@@ -405,6 +409,14 @@ export default function PrintApp() {
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
: 'Keine Linien — Kanten selbst anlegen.'}
</div>
{marks === 'corner' && marginMm < markOff + markLen && (
<div className="fein warn">Bei {marginMm} mm Rand ist außerhalb der Bilder kein Platz für die Marken —
sie werden abgeschnitten. Rand auf mindestens {Math.ceil(markOff + markLen)} mm setzen.</div>
)}
{marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && (
<div className="fein warn">Durchgehende Linien passen nur zu <b>einem</b> Bildmaß. Bei gemischten Größen
werden sie unterbrochen — ein Schnitt über das ganze Blatt würde andere Bilder treffen. Besser Eckmarken.</div>
)}
{marks === 'corner' && (
<div className="reihe zwei">
<label className="mini">Länge (mm)