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'; import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.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`); }); /* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */ test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => { const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; assert.equal(fitsOnSheet(200, 300, randlos, false), false); const info = overflowPlacement('a', 1, 200, 300, randlos, false); assert.equal(info.rotated, false); assert.equal(info.lostLeft, 0); assert.equal(info.lostRight, 0); assert.equal(info.lostTop, 1.5); // 300 − 297 = 3 mm, mittig verteilt assert.equal(info.lostBottom, 1.5); assert.equal(info.lostMm, 3); // mittig: x = (210 − 200) / 2 assert.equal(info.placement.x, 5); assert.equal(info.placement.y, -1.5); }); test('Überstehen dreht, wenn dabei weniger verloren geht', () => { const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; // 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast. const info = overflowPlacement('a', 1, 297, 200, a4, true); assert.equal(info.rotated, true); assert.equal(info.lostMm, 0); }); test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => { const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; const tp = tilePlan(300, 400, a4, 10); assert.equal(tp.cols, 2); assert.equal(tp.rows, 2); assert.equal(tp.tiles.length, 4); assert.equal(tp.overlapMm, 10); // Erste Kachel füllt die Nutzfläche, letzte trägt den Rest. assert.equal(tp.tiles[0].wMm, 200); assert.equal(tp.tiles[0].hMm, 287); assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom); assert.equal(tp.tiles[3].glueRight, false); assert.equal(tp.tiles[3].glueBottom, false); // Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht). const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm)); const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm)); assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`); assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`); }); test('Kacheln überlappen sich wirklich um den Klebefalz', () => { const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; const tp = tilePlan(300, 400, a4, 10); const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!; const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!; const ueberlappung = (links.xMm + links.wMm) - rechts.xMm; assert.equal(Math.round(ueberlappung * 10) / 10, 10); }); test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => { const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; const tp = tilePlan(300, 400, a4, 10); const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 }; for (const t of tp.tiles) { const c = tileCrop(crop, t, 300, 400); assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen'); assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen'); } // Die erste Kachel beginnt exakt am Zuschnitt-Anfang. const erste = tileCrop(crop, tp.tiles[0], 300, 400); assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x); assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y); }); test('Ein Bild, das passt, gilt nicht als übergroß', () => { const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; assert.equal(fitsOnSheet(130, 180, a4, false), true); assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es'); assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht'); }); test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => { const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 }; // 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten. assert.equal(noteInset(sheet), 1); const ts = tileSheet(sheet); const tp = tilePlan(300, 400, ts, 10); const oben = (sheet.marginMm || 0) + noteInset(sheet); for (const t of tp.tiles) { assert.ok(oben + t.hMm <= sheet.hMm + 1e-6, `Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`); assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6); } // Die Kacheln decken das Endmaß lückenlos ab. const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm)); const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm)); assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`); }); test('Großzügiger Rand verschiebt die Kachel nicht', () => { const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 }; assert.equal(noteInset(sheet), 0); assert.deepEqual(tileSheet(sheet), sheet); });