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.
220 lines
10 KiB
TypeScript
220 lines
10 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts';
|
||
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
|
||
|
||
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
|
||
|
||
test('parseSizeMm versteht Tills Schreibweisen', () => {
|
||
assert.deepEqual(parseSizeMm('12x15'), { w: 120, h: 150 });
|
||
assert.deepEqual(parseSizeMm('12 × 15 cm'), { w: 120, h: 150 });
|
||
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 });
|
||
// 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);
|
||
});
|
||
|
||
test('mm → px ist exakt (Druckmaß-Nachweis)', () => {
|
||
assert.equal(mmToPx(100, 300), 1181); // 10 cm
|
||
assert.equal(mmToPx(150, 300), 1772); // 15 cm
|
||
assert.equal(mmToPx(300, 300), 3543); // 30 cm → wie Poster 30×40
|
||
assert.equal(mmToPx(400, 300), 4724);
|
||
assert.equal(mmToPx(35, 300), 413); // Passbild
|
||
assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt
|
||
});
|
||
|
||
test('Passbild-Bogen: 35×45 mm auf A4 ergibt 20 Stück', () => {
|
||
assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20);
|
||
});
|
||
|
||
test('Platzierungen überlappen nicht und bleiben im Satzspiegel', () => {
|
||
const sheet: SheetSpec = { ...A4, gapMm: 6 };
|
||
const res = layout([
|
||
{ id: 'a', wMm: 90, hMm: 130, count: 2 },
|
||
{ id: 'b', wMm: 35, hMm: 45, count: 6 },
|
||
], sheet);
|
||
assert.ok(res.pages.length >= 1);
|
||
for (const pg of res.pages) {
|
||
for (const p of pg.placements) {
|
||
assert.ok(p.x >= sheet.marginMm - 1e-6, 'linker Rand');
|
||
assert.ok(p.y >= sheet.marginMm - 1e-6, 'oberer Rand');
|
||
assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'rechter Rand');
|
||
assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'unterer Rand');
|
||
}
|
||
for (let i = 0; i < pg.placements.length; i++)
|
||
for (let j = i + 1; j < pg.placements.length; j++) {
|
||
const a = pg.placements[i], b = pg.placements[j];
|
||
const overlap = a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6
|
||
&& a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
|
||
assert.ok(!overlap, `Überlappung ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('Abstand hält die Beschnittzugabe der Nachbarn auseinander', () => {
|
||
const sheet: SheetSpec = { ...A4, gapMm: 1, bleedMm: 3 };
|
||
assert.equal(effectiveGap(sheet), 6);
|
||
const res = layout([{ id: 'a', wMm: 50, hMm: 50, count: 4 }], { ...sheet, gapMm: effectiveGap(sheet) });
|
||
const row = res.pages[0].placements.filter((p) => Math.abs(p.y - res.pages[0].placements[0].y) < 1e-6)
|
||
.sort((a, b) => a.x - b.x);
|
||
assert.ok(row.length >= 2);
|
||
assert.ok(row[1].x - (row[0].x + row[0].w) >= 6 - 1e-6);
|
||
});
|
||
|
||
test('Eckmarken liegen außerhalb des Endformats', () => {
|
||
const res = layout([{ id: 'a', wMm: 90, hMm: 130, count: 1 }], A4);
|
||
const pg = res.pages[0];
|
||
const lines = cutMarks(pg, A4, { mode: 'corner', lengthMm: 4, offsetMm: 3 });
|
||
assert.equal(lines.length, 8, '4 Ecken × 2 Marken');
|
||
const p = pg.placements[0];
|
||
for (const l of lines) {
|
||
const inside = (x: number, y: number) =>
|
||
x > p.x + 1e-6 && x < p.x + p.w - 1e-6 && y > p.y + 1e-6 && y < p.y + p.h - 1e-6;
|
||
assert.ok(!inside(l.x1, l.y1) && !inside(l.x2, l.y2), 'Marke ragt ins Bild');
|
||
}
|
||
});
|
||
|
||
test('Durchgehende Linien laufen nicht durch andere Bilder', () => {
|
||
const sheet: SheetSpec = { ...A4, gapMm: 4 };
|
||
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 6 }], sheet);
|
||
const pg = res.pages[0];
|
||
const lines = cutMarks(pg, sheet, { mode: 'grid' });
|
||
assert.ok(lines.length > 0);
|
||
for (const l of lines) {
|
||
for (const p of pg.placements) {
|
||
const hitsX = Math.min(l.x1, l.x2) < p.x + p.w - 1e-3 && Math.max(l.x1, l.x2) > p.x + 1e-3;
|
||
const hitsY = Math.min(l.y1, l.y2) < p.y + p.h - 1e-3 && Math.max(l.y1, l.y2) > p.y + 1e-3;
|
||
assert.ok(!(hitsX && hitsY), 'Linie schneidet ein Motiv');
|
||
}
|
||
}
|
||
});
|
||
|
||
test('Zu großes Bild landet in unplaced statt im Nirwana', () => {
|
||
const res = layout([{ id: 'gross', wMm: 300, hMm: 400, count: 1 }], A4);
|
||
assert.equal(res.pages.length, 0);
|
||
assert.equal(res.unplaced[0].specId, 'gross');
|
||
});
|
||
|
||
test('Mehr als ein Bogen, wenn die Menge nicht passt', () => {
|
||
const res = layout([{ id: 'a', wMm: 35, hMm: 45, count: 25 }], A4);
|
||
assert.equal(res.pages.length, 2);
|
||
assert.equal(res.pages[0].placements.length, 20);
|
||
assert.equal(res.pages[1].placements.length, 5);
|
||
});
|
||
|
||
test('Drehen erlaubt mehr Bilder auf 10×15-Fotopapier', () => {
|
||
const foto: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||
const ohne = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, foto);
|
||
const mit = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, foto);
|
||
assert.equal(ohne, 3);
|
||
assert.ok(mit >= 3);
|
||
});
|
||
|
||
test('Empfohlener Abstand macht Platz für Marken', () => {
|
||
assert.equal(recommendedGap('corner', 0, 4, 3), 14);
|
||
assert.equal(recommendedGap('none', 3), 6);
|
||
assert.equal(recommendedGap('grid', 0), 0);
|
||
});
|
||
|
||
test('dpi-Warnung greift bei zu kleiner Quelle', () => {
|
||
assert.equal(dpiCheck(1181, 100, 300).ok, true);
|
||
assert.equal(dpiCheck(600, 100, 300).ok, false);
|
||
assert.equal(dpiCheck(1181, 100, 300).dpi, 300);
|
||
});
|
||
|
||
test('Formattabellen sind konsistent', () => {
|
||
assert.equal(paperById('A4')!.w, 210);
|
||
assert.equal(paperById('A3plus')!.h, 483);
|
||
assert.equal(photoById('P35x45')!.w, 35);
|
||
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', () => {
|
||
// Ein einzelnes Passbild darf nicht quer gelegt werden, nur weil hochkant
|
||
// mehr auf den Bogen passen würden.
|
||
const res = layout([{ id: 'p', wMm: 35, hMm: 45, count: 1, allowRotate: true }], A4);
|
||
assert.equal(res.pages[0].placements[0].rotated, false);
|
||
assert.equal(res.pages[0].placements[0].w, 35);
|
||
|
||
// 25 × 15 cm auf A4 hoch: quer ist es zu breit (250 > 200 mm Nutzbreite),
|
||
// gedreht passt es — dann muss gedreht werden.
|
||
const gross = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true }], A4);
|
||
assert.equal(gross.pages.length, 1);
|
||
assert.equal(gross.pages[0].placements[0].rotated, true);
|
||
assert.equal(gross.pages[0].placements[0].w, 150);
|
||
|
||
// Ohne Dreherlaubnis bleibt es liegen und wird als „passt nicht" gemeldet.
|
||
const stur = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: false }], A4);
|
||
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`);
|
||
});
|