Files
klarbild/tests/printlayout.test.ts
T
till 7d42782c41 feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.

Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.

Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
2026-08-18 07:04:08 +00:00

155 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 });
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');
});
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);
});