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.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { renderCell, coverCrop, containCrop } from '../src/lib/printrender.ts';
|
||||
import { mmToPx } from '../src/lib/paper.ts';
|
||||
|
||||
/** Quergrafik 3:2 mit klar erkennbaren Rändern. */
|
||||
async function testImage(w = 1200, h = 800): Promise<Buffer> {
|
||||
return sharp({ create: { width: w, height: h, channels: 3, background: { r: 30, g: 90, b: 220 } } })
|
||||
.png().toBuffer();
|
||||
}
|
||||
|
||||
test('Zuschneiden liefert exakt das Zielmaß — auch mit Beschnittzugabe', async () => {
|
||||
const img = await testImage();
|
||||
for (const bleed of [0, 3]) {
|
||||
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', bleedMm: bleed });
|
||||
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300));
|
||||
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300));
|
||||
}
|
||||
});
|
||||
|
||||
test('Einpassen liefert exakt das Zielmaß und legt die Randfarbe an', async () => {
|
||||
const img = await testImage();
|
||||
for (const bleed of [0, 3]) {
|
||||
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'contain', bleedMm: bleed, background: '#000000' });
|
||||
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300), 'Breite');
|
||||
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300), 'Höhe');
|
||||
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
|
||||
const at = (x: number, y: number) => {
|
||||
const i = (y * info.width + x) * info.channels;
|
||||
return [data[i], data[i + 1], data[i + 2]];
|
||||
};
|
||||
const oben = at(Math.round(info.width / 2), 3);
|
||||
const mitte = at(Math.round(info.width / 2), Math.round(info.height / 2));
|
||||
assert.deepEqual(oben, [0, 0, 0], 'oben muss Randfarbe sein');
|
||||
assert.ok(mitte[2] > 150, 'in der Mitte muss das Bild stehen');
|
||||
}
|
||||
});
|
||||
|
||||
test('Zuschneiden füllt das Format vollständig aus (kein Rand)', async () => {
|
||||
const img = await testImage();
|
||||
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', background: '#000000' });
|
||||
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
|
||||
const at = (x: number, y: number) => {
|
||||
const i = (y * info.width + x) * info.channels;
|
||||
return [data[i], data[i + 1], data[i + 2]];
|
||||
};
|
||||
for (const [x, y] of [[3, 3], [info.width - 4, 3], [3, info.height - 4], [info.width - 4, info.height - 4]])
|
||||
assert.ok(at(x, y)[2] > 150, 'auch die Ecken tragen Bild');
|
||||
});
|
||||
|
||||
test('Querformat-Ziel aus Hochformat-Quelle bleibt maßhaltig', async () => {
|
||||
const img = await testImage(800, 1200);
|
||||
const out = await renderCell(img, null, 150, 100, 300, { ext: 'jpg', fit: 'contain', background: '#ffffff' });
|
||||
assert.equal(out.width, mmToPx(150, 300));
|
||||
assert.equal(out.height, mmToPx(100, 300));
|
||||
});
|
||||
|
||||
test('Gedrehte Fassung tauscht Breite und Höhe', async () => {
|
||||
const img = await testImage();
|
||||
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true });
|
||||
assert.equal(out.width, mmToPx(130, 300));
|
||||
assert.equal(out.height, mmToPx(90, 300));
|
||||
});
|
||||
|
||||
test('cover- und contain-Ausschnitt rechnen gegensätzlich', () => {
|
||||
const a = 35 / 45; // Hochformat-Ziel
|
||||
const cov = coverCrop(1200, 800, a); // Querbild
|
||||
const con = containCrop(1200, 800, a);
|
||||
assert.ok(cov.w < 1 && cov.h === 1, 'cover schneidet seitlich ab');
|
||||
assert.ok(con.h > 1 && con.w === 1, 'contain ragt oben/unten hinaus');
|
||||
assert.ok(con.y < 0, 'der Überstand liegt außerhalb des Bildes');
|
||||
});
|
||||
|
||||
test('Ein Ausschnitt wird exakt übernommen (kein stilles Nachzentrieren)', async () => {
|
||||
// Linke obere Ecke rot einfärben, dann genau diese Ecke zuschneiden.
|
||||
const base = await sharp({ create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 255 } } })
|
||||
.composite([{ input: await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
|
||||
.png().toBuffer();
|
||||
const out = await renderCell(base, { x: 0, y: 0, w: 0.2, h: 0.2 }, 50, 50, 300, { ext: 'png' });
|
||||
const { data } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
|
||||
assert.ok(data[0] > 200 && data[2] < 60, 'der gewählte Ausschnitt muss rot sein');
|
||||
});
|
||||
Reference in New Issue
Block a user