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.
141 lines
7.3 KiB
TypeScript
141 lines
7.3 KiB
TypeScript
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');
|
|
});
|
|
|
|
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
|
|
|
|
test('EXIF-Drehung wird angewendet — Handyfotos kommen nicht quer heraus', async () => {
|
|
const quer = await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 0, g: 0, b: 255 } } })
|
|
.composite([{ input: await sharp({ create: { width: 80, height: 60, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
|
|
.jpeg().toBuffer();
|
|
const mitExif = await sharp(quer).withMetadata({ orientation: 6 }).jpeg().toBuffer();
|
|
|
|
const auto = await sharp(await sharp(mitExif).rotate().toBuffer()).metadata();
|
|
const out = await renderCell(mitExif, null, 90, 130, 300, { ext: 'png' });
|
|
// renderCell muss mit dem Raster rechnen, das der Browser zeigt.
|
|
assert.deepEqual(out.srcPx, [auto.width, auto.height]);
|
|
});
|
|
|
|
test('Extreme Ausschnitte sprengen den Speicher nicht und liefern das Zielmaß', async () => {
|
|
const panorama = await sharp({ create: { width: 6000, height: 1500, channels: 3, background: { r: 20, g: 80, b: 200 } } }).jpeg().toBuffer();
|
|
for (const [w, h] of [[105, 148], [5, 400]] as [number, number][]) {
|
|
const out = await renderCell(panorama, null, w, h, 300, { ext: 'jpg', fit: 'contain', background: '#000000' });
|
|
assert.equal(out.width, mmToPx(w, 300));
|
|
assert.equal(out.height, mmToPx(h, 300));
|
|
}
|
|
// Ausschnitt weit außerhalb des Bildes: nur Randfarbe drumherum, kein Riesenpuffer.
|
|
const weit = await renderCell(panorama, { x: -3.5, y: -3.5, w: 8, h: 8 }, 35, 45, 300, { ext: 'png', fit: 'contain', background: '#000000' });
|
|
assert.equal(weit.width, mmToPx(35, 300));
|
|
assert.equal(weit.height, mmToPx(45, 300));
|
|
});
|
|
|
|
test('Gedrehte Fassung mit Beschnittzugabe bleibt maßhaltig', async () => {
|
|
const img = await testImage();
|
|
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true, bleedMm: 3 });
|
|
assert.equal(out.width, mmToPx(130 + 6, 300));
|
|
assert.equal(out.height, mmToPx(90 + 6, 300));
|
|
});
|
|
|
|
test('Beschnittzugabe vergrößert das Feld, ohne den Ausschnitt zu verschieben', async () => {
|
|
// Farbverlauf, damit jede Position eine eigene Farbe hat.
|
|
const w = 1000, h = 1000;
|
|
const px = Buffer.alloc(w * h * 3);
|
|
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
const i = (y * w + x) * 3;
|
|
px[i] = Math.round((x / w) * 255); px[i + 1] = Math.round((y / h) * 255); px[i + 2] = 128;
|
|
}
|
|
const img = await sharp(px, { raw: { width: w, height: h, channels: 3 } }).png().toBuffer();
|
|
const crop = { x: 0.2, y: 0.3, w: 0.5, h: 0.4 };
|
|
|
|
const mitte = async (bleed: number) => {
|
|
const out = await renderCell(img, crop, 50, 40, 300, { ext: 'png', bleedMm: bleed });
|
|
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
|
|
// Mitte der Trimmbox = Mitte des Bildes (der Beschnitt liegt symmetrisch außen).
|
|
const i = (Math.round(info.height / 2) * info.width + Math.round(info.width / 2)) * info.channels;
|
|
return [data[i], data[i + 1]];
|
|
};
|
|
const a = await mitte(0), b = await mitte(5);
|
|
assert.ok(Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2,
|
|
`Trimmbox-Mitte wandert mit Beschnitt: ${a} vs ${b}`);
|
|
});
|