A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
164 files changed
+27190
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseCrop, clampCrop, centred, coverSize, containSize, type CropRel } from '../src/lib/cropmath.ts';
|
||||
import { coverCrop, containCrop } from '../src/lib/printrender.ts';
|
||||
|
||||
/** Deterministic randomness — on a failure the case can be reproduced. */
|
||||
function rng(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x1_0000_0000);
|
||||
}
|
||||
|
||||
test('Server side and browser side compute the same thing (cover)', () => {
|
||||
const r = rng(20260821);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 8000);
|
||||
const natH = 1 + Math.floor(r() * 8000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
const server = coverCrop(natW, natH, aspect); // printrender.ts → PDF
|
||||
const browser = baseCrop(natW, natH, aspect, 'cover'); // cropmath.ts → preview
|
||||
assert.deepEqual(browser, server, `cover ${natW}x${natH} @ ${aspect}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Server side and browser side compute the same thing (contain)', () => {
|
||||
const r = rng(7);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 8000);
|
||||
const natH = 1 + Math.floor(r() * 8000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
assert.deepEqual(baseCrop(natW, natH, aspect, 'contain'), containCrop(natW, natH, aspect),
|
||||
`contain ${natW}x${natH} @ ${aspect}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cover stays inside the image, contain encloses the whole image', () => {
|
||||
const r = rng(99);
|
||||
const EPS = 1e-9;
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 6000);
|
||||
const natH = 1 + Math.floor(r() * 6000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
|
||||
const c = baseCrop(natW, natH, aspect, 'cover');
|
||||
assert.ok(c.x >= -EPS && c.y >= -EPS && c.x + c.w <= 1 + EPS && c.y + c.h <= 1 + EPS,
|
||||
`cover sticks out: ${JSON.stringify(c)}`);
|
||||
|
||||
const k = baseCrop(natW, natH, aspect, 'contain');
|
||||
assert.ok(k.x <= EPS && k.y <= EPS && k.x + k.w >= 1 - EPS && k.y + k.h >= 1 - EPS,
|
||||
`contain does not enclose: ${JSON.stringify(k)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('The aspect ratio is exact — otherwise the image would be distorted', () => {
|
||||
const r = rng(4242);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 6000);
|
||||
const natH = 1 + Math.floor(r() * 6000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
for (const fit of ['cover', 'contain'] as const) {
|
||||
const c = baseCrop(natW, natH, aspect, fit);
|
||||
// The crop measured in pixels has to have exactly the target ratio.
|
||||
const actual = (c.w * natW) / (c.h * natH);
|
||||
assert.ok(Math.abs(actual - aspect) < 1e-9,
|
||||
`${fit} ${natW}x${natH}: ratio ${actual} instead of ${aspect}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('When the ratio already matches, the crop is the whole image', () => {
|
||||
for (const [w, h] of [[3000, 2000], [1000, 1000], [350, 450]]) {
|
||||
for (const fit of ['cover', 'contain'] as const) {
|
||||
assert.deepEqual(baseCrop(w, h, w / h, fit), { x: 0, y: 0, w: 1, h: 1 }, `${w}x${h} ${fit}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Switching the size keeps the centre of the crop', () => {
|
||||
// The user moved the crop to the top left, then switches from 3:2 to 35x45.
|
||||
const before: CropRel = { x: 0.1, y: 0.05, w: 0.4, h: 0.4 };
|
||||
const after = baseCrop(3000, 2000, 35 / 45, 'cover', before);
|
||||
const centreBefore = before.x + before.w / 2;
|
||||
const centreAfter = after.x + after.w / 2;
|
||||
// On the constrained axis the stop can take hold — but it may only move the
|
||||
// centre towards the image, not beyond it.
|
||||
assert.ok(Math.abs(centreAfter - centreBefore) < 0.5, `centre jumped: ${centreBefore} → ${centreAfter}`);
|
||||
assert.ok(after.x >= 0 && after.x + after.w <= 1);
|
||||
});
|
||||
|
||||
test('clampCrop keeps cover inside the image and lets contain stick out in a controlled way', () => {
|
||||
assert.deepEqual(clampCrop({ x: -5, y: -5, w: 0.5, h: 0.5 }, 'cover'), { x: 0, y: 0, w: 0.5, h: 0.5 });
|
||||
assert.deepEqual(clampCrop({ x: 5, y: 5, w: 0.5, h: 0.5 }, 'cover'), { x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
// contain: at least 5% of the crop has to stay inside the image.
|
||||
const far = clampCrop({ x: -99, y: -99, w: 2, h: 2 }, 'contain');
|
||||
assert.ok(far.x + far.w > 0 && far.y + far.h > 0, 'motif pushed completely out of the frame');
|
||||
});
|
||||
|
||||
test('centred lays the crop around a centre point', () => {
|
||||
assert.deepEqual(centred(0.5, 0.5), { x: 0.25, y: 0.25, w: 0.5, h: 0.5 });
|
||||
assert.deepEqual(centred(0.2, 0.2, { x: 0, y: 0, w: 1, h: 1 }), { x: 0.4, y: 0.4, w: 0.2, h: 0.2 });
|
||||
});
|
||||
|
||||
test('coverSize and containSize are inverse to each other in ratio', () => {
|
||||
const c = coverSize(3000, 2000, 0.5), k = containSize(3000, 2000, 0.5);
|
||||
assert.ok(c.w <= 1 + 1e-9 && c.h <= 1 + 1e-9, 'cover must not go above 1');
|
||||
assert.ok(k.w >= 1 - 1e-9 || k.h >= 1 - 1e-9, 'contain has to fill at least one edge');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isEmail, mailLayout, esc } from '../src/lib/mail.ts';
|
||||
|
||||
test('Valid addresses are recognised', () => {
|
||||
for (const good of ['first.last@example.com', 'a.b+c@sub.example.co.uk', 'x@y.de', 'Gran_1949@example.de']) {
|
||||
assert.ok(isEmail(good), `wrongly rejected: ${good}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Invalid addresses are rejected — including the ones with a line break appended', () => {
|
||||
const bad = [
|
||||
'', ' ', 'not-an-address', 'a@b', '@b.de', 'a@.de', 'a b@c.de', 'a@b .de',
|
||||
'a@b.de\nBcc: victim@example.com', // header injection
|
||||
'a@b.de\r\nSubject: x',
|
||||
'a@b.de\n', // JavaScript's "$" also matches before a line end
|
||||
'a'.repeat(250) + '@b.de', // too long
|
||||
];
|
||||
for (const s of bad) assert.ok(!isEmail(s), `slipped through: ${JSON.stringify(s)}`);
|
||||
});
|
||||
|
||||
test('esc defuses everything that could take the HTML apart', () => {
|
||||
assert.equal(esc('<b>x</b>'), '<b>x</b>');
|
||||
assert.equal(esc('a & b'), 'a & b');
|
||||
assert.equal(esc('said "hello"'), 'said "hello"');
|
||||
// Order: & first, otherwise < would become &lt;
|
||||
assert.equal(esc('<&>'), '<&>');
|
||||
});
|
||||
|
||||
test('The heading of the mail is always escaped', () => {
|
||||
const html = mailLayout('</h1><script>alert(1)</script>', ['Text']);
|
||||
assert.ok(!html.includes('<script>'), 'script tag ended up in the HTML');
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test('The address of the button is escaped', () => {
|
||||
const html = mailLayout('Title', ['Text'], { text: 'View', url: 'https://x.de/"><script>alert(1)</script>' });
|
||||
assert.ok(!html.includes('<script>'), 'script tag smuggled in through the address');
|
||||
});
|
||||
|
||||
test('Paragraphs are deliberately allowed to contain HTML — which is why callers have to escape', () => {
|
||||
// This is on purpose (for <b>), and exactly for that reason it is the caller's
|
||||
// duty to send user input through esc() first. The bug the review found was a
|
||||
// place that had forgotten to.
|
||||
const html = mailLayout('Title', ['A <b>bold</b> word']);
|
||||
assert.match(html, /<b>bold<\/b>/);
|
||||
// And with esc() it is defused:
|
||||
assert.ok(!mailLayout('T', [esc('<b>x</b>')]).includes('<b>x</b>'));
|
||||
});
|
||||
|
||||
test('The mail loads nothing from outside servers', () => {
|
||||
const html = mailLayout('Title', ['Text'], { text: 'Go', url: 'https://klarbild.example/s/abc' });
|
||||
// No images, no fonts, no stylesheets from outside — that would be a tracking
|
||||
// pixel, and many mailboxes would block it anyway.
|
||||
assert.ok(!/<img/i.test(html), 'image in the mail HTML');
|
||||
assert.ok(!/<link/i.test(html), 'external resource in the mail HTML');
|
||||
assert.ok(!/@import|url\(/i.test(html), 'the CSS loads something');
|
||||
// Exactly one destination: the link that was handed in.
|
||||
const targets = [...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual([...new Set(targets)], ['https://klarbild.example/s/abc']);
|
||||
});
|
||||
|
||||
test('Without a button no link comes into being either', () => {
|
||||
const html = mailLayout('Title', ['Text only']);
|
||||
assert.ok(!html.includes('href='), 'unexpected link');
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildResultFilename, timeStamp, shortTag, slugify, KLARBILD_NAME, splitOldName, newNameFor } from '../src/lib/naming.ts';
|
||||
|
||||
const AT = new Date('2026-08-20T12:32:07Z'); // = 14:32:07 in Berlin (summer time)
|
||||
const ID1 = 'a3f9c1de-1111-2222-3333-444455556666';
|
||||
const ID2 = '77b21e00-1111-2222-3333-444455556666';
|
||||
|
||||
test('The reported bug: two badges on the same day no longer overwrite each other', () => {
|
||||
// Same motif, same size, same second — this used to produce the same name twice.
|
||||
const a = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
|
||||
const b = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 });
|
||||
assert.notEqual(a, b, 'two images must never carry the same name');
|
||||
assert.equal(a, '2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg');
|
||||
assert.equal(b, '2026-08-20_143207_portrait-badge_the-frame_77b2.jpg');
|
||||
});
|
||||
|
||||
test('Names sort lexicographically = chronologically', () => {
|
||||
const times = [
|
||||
new Date('2026-08-19T23:59:59Z'),
|
||||
new Date('2026-08-20T00:00:01Z'),
|
||||
new Date('2026-08-20T12:32:07Z'),
|
||||
new Date('2026-12-24T08:00:00Z'), // winter time — a different offset from UTC
|
||||
new Date('2027-01-01T00:00:00Z'),
|
||||
];
|
||||
const names = times.map((at, i) => buildResultFilename('photo', '30x40', 'jpg', { at, id: `${i}${i}${i}${i}0000-0000-0000-0000-000000000000` }));
|
||||
const sorted = [...names].sort();
|
||||
assert.deepEqual(sorted, names, `sort order differs:\n${sorted.join('\n')}`);
|
||||
});
|
||||
|
||||
test('The timestamp is in local time, not in UTC', () => {
|
||||
// 00:30 Berlin summer time is 22:30 UTC on the previous day — the name has to
|
||||
// show the day on which the image was made.
|
||||
assert.equal(timeStamp(new Date('2026-08-19T22:30:00Z')), '2026-08-20_003000');
|
||||
assert.equal(timeStamp(new Date('2026-12-24T07:05:09Z')), '2026-12-24_080509'); // winter time: +1
|
||||
});
|
||||
|
||||
test('Midnight is written as 00, not as 24', () => {
|
||||
assert.match(timeStamp(new Date('2026-08-19T22:00:00Z')), /^2026-08-20_00000/);
|
||||
});
|
||||
|
||||
test('The short id is stable — a second run gives the same name', () => {
|
||||
assert.equal(shortTag(ID1), shortTag(ID1));
|
||||
assert.equal(shortTag(ID1), 'a3f9');
|
||||
const it = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: AT };
|
||||
assert.equal(newNameFor(it), newNameFor(it));
|
||||
});
|
||||
|
||||
test('Processing an image again does not stack up stamps', () => {
|
||||
const once = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
|
||||
const twice = buildResultFilename(once, '30x40', 'jpg', { at: AT, id: ID2 });
|
||||
assert.equal(twice, '2026-08-20_143207_portrait-badge_30x40_77b2.jpg');
|
||||
const thrice = buildResultFilename(twice, 'a4', 'jpg', { at: AT, id: ID1 });
|
||||
assert.equal(thrice, '2026-08-20_143207_portrait-badge_a4_a3f9.jpg');
|
||||
});
|
||||
|
||||
test('Our own pattern recognises our own names and only those', () => {
|
||||
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait-badge_the-frame_a3f9'));
|
||||
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait_30x40_a3f9-2'));
|
||||
assert.ok(!KLARBILD_NAME.test('2026-07-23_portrait_the-frame')); // the old scheme
|
||||
assert.ok(!KLARBILD_NAME.test('holiday-2026_beach')); // somebody else's name
|
||||
});
|
||||
|
||||
test('Old names are split up correctly', () => {
|
||||
assert.deepEqual(splitOldName('2026-07-23_portrait_the-frame.png'), { motif: 'portrait', format: 'the-frame', ext: 'png' });
|
||||
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
|
||||
// A foreign file without our scheme: all of it becomes the motif, format "original".
|
||||
assert.deepEqual(splitOldName('Holiday Crete.JPEG'), { motif: 'holiday-crete', format: 'original', ext: 'jpeg' });
|
||||
// Already in the new scheme: motif and format stay, so that renaming is idempotent.
|
||||
assert.deepEqual(splitOldName('2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'),
|
||||
{ motif: 'portrait-badge', format: 'the-frame', ext: 'jpg' });
|
||||
});
|
||||
|
||||
test('Renaming is idempotent — a second run changes nothing', () => {
|
||||
const old = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') };
|
||||
const fresh = newNameFor(old);
|
||||
assert.equal(fresh, '2026-07-23_111542_portrait_the-frame_a3f9.png');
|
||||
assert.equal(newNameFor({ ...old, filename: fresh }), fresh, 'the second run has to give the same name');
|
||||
});
|
||||
|
||||
test('Renaming takes the creation date, not the date out of the old name', () => {
|
||||
// If a name ever carried a wrong date, created_at from the database wins.
|
||||
const it = { id: ID2, filename: '2020-01-01_portrait_a4.png', created_at: new Date('2026-03-05T06:00:00Z') };
|
||||
assert.match(newNameFor(it), /^2026-03-05_070000_portrait_a4_77b2\.png$/);
|
||||
});
|
||||
|
||||
test('A counter is appended when a name is really taken', () => {
|
||||
const a = buildResultFilename('portrait', 'a4', 'png', { at: AT, id: ID1 });
|
||||
const b = buildResultFilename('portrait', 'a4', 'png', { at: AT, id: ID1, seq: 2 });
|
||||
assert.equal(b, a.replace('.png', '-2.png'));
|
||||
assert.ok(KLARBILD_NAME.test(b.replace('.png', '')));
|
||||
});
|
||||
|
||||
test('Motifs stay readable and usable on a file system', () => {
|
||||
const n = buildResultFilename('Größe & Maß – Übersicht!.png', '13x18', 'jpg', { at: AT, id: ID1 });
|
||||
assert.equal(n, '2026-08-20_143207_groesse-mass-uebersicht_13x18_a3f9.jpg');
|
||||
assert.ok(!/[^a-z0-9._-]/.test(n), `unexpected character in ${n}`);
|
||||
});
|
||||
|
||||
test('Generic names become "klarbild" instead of inheriting nonsense', () => {
|
||||
for (const n of ['IMG.png', 'foto.jpg', 'telegram.jpg', null]) {
|
||||
const out = buildResultFilename(n as any, 'a4', 'png', { at: AT, id: ID1 });
|
||||
assert.match(out, /_klarbild_a4_a3f9\.png$/, `${n} → ${out}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('slugify stays usable unchanged', () => {
|
||||
assert.equal(slugify('Grüße aus Köln'), 'gruesse-aus-koeln');
|
||||
});
|
||||
|
||||
// --- Collision loop (belt and braces) ----------------------------------------
|
||||
// The loop is tested with an injected "is taken" check, so that no database has
|
||||
// to be running for it.
|
||||
import { uniqueFilename } from '../src/lib/renames.ts';
|
||||
|
||||
test('If the wanted name is taken after all, a counter is appended', async () => {
|
||||
const taken = new Set(['2026-08-20_143207_portrait_a4_a3f9.png']);
|
||||
const n = await uniqueFilename(ID1, 'portrait', 'a4', 'png', AT, async (x) => taken.has(x));
|
||||
assert.equal(n, '2026-08-20_143207_portrait_a4_a3f9-2.png');
|
||||
});
|
||||
|
||||
test('A free name is handed out unchanged', async () => {
|
||||
const n = await uniqueFilename(ID1, 'portrait', 'a4', 'png', AT, async () => false);
|
||||
assert.equal(n, '2026-08-20_143207_portrait_a4_a3f9.png');
|
||||
});
|
||||
|
||||
test('Even with permanently taken names the loop returns something', async () => {
|
||||
const n = await uniqueFilename(ID1, 'portrait', 'a4', 'png', AT, async () => true);
|
||||
assert.ok(n.endsWith('.png') && n.length > 10, `unusable fallback: ${n}`);
|
||||
});
|
||||
|
||||
// --- Path escape on delivery -------------------------------------------------
|
||||
// `posixpath.join('/customers/acme','../../../etc/cron.d')` gives `/etc/cron.d`,
|
||||
// and the upload creates that directory beforehand. That is why the check runs
|
||||
// immediately before writing — here is the check itself.
|
||||
import { safeSegment } from '../src/lib/remotetarget.ts';
|
||||
|
||||
test('Folder and file names cannot leave the base folder', () => {
|
||||
const evil = ['..', '../..', '../../../etc/cron.d', 'a/b', 'a\\b', '.', '...',
|
||||
'Gallery A/../..', 'y\ny', 'z\u0000z'];
|
||||
for (const b of evil) {
|
||||
assert.throws(() => safeSegment(b, 'folder'), /Invalid/, `slipped through: ${JSON.stringify(b)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Real customer folders stay allowed — the check is not there to annoy', () => {
|
||||
const good = ['Gallery A', 'TheFrame-Backgrounds', 'Müller & Söhne (2026)',
|
||||
"Wedding O'Brien", 'Daycare 2026_Group 3', 'a.b.c',
|
||||
'2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'];
|
||||
for (const g of good) assert.equal(safeSegment(g, 'folder'), g, `wrongly rejected: ${g}`);
|
||||
});
|
||||
|
||||
test('An empty name is allowed and means "no subfolder"', () => {
|
||||
assert.equal(safeSegment('', 'folder'), '');
|
||||
assert.equal(safeSegment(' ', 'folder'), '');
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
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 understands the shorthand people actually type', () => {
|
||||
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 is width:height — "4:3" is landscape, "3:4" is portrait.
|
||||
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('nonsense'), null);
|
||||
assert.equal(parseSizeMm('9999x1'), null);
|
||||
});
|
||||
|
||||
test('mm → px is exact (proof of the printed size)', () => {
|
||||
assert.equal(mmToPx(100, 300), 1181); // 10 cm
|
||||
assert.equal(mmToPx(150, 300), 1772); // 15 cm
|
||||
assert.equal(mmToPx(300, 300), 3543); // 30 cm → like a 30×40 poster
|
||||
assert.equal(mmToPx(400, 300), 4724);
|
||||
assert.equal(mmToPx(35, 300), 413); // passport photo
|
||||
assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt
|
||||
});
|
||||
|
||||
test('Passport photo sheet: 35×45 mm on A4 gives 20 pieces', () => {
|
||||
assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20);
|
||||
});
|
||||
|
||||
test('Placements do not overlap and stay inside the type area', () => {
|
||||
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, 'left margin');
|
||||
assert.ok(p.y >= sheet.marginMm - 1e-6, 'top margin');
|
||||
assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'right margin');
|
||||
assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'bottom margin');
|
||||
}
|
||||
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, `overlap ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('The gap keeps the bleed of neighbouring cells apart', () => {
|
||||
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('Corner marks lie outside the trimmed size', () => {
|
||||
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 corners × 2 marks');
|
||||
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), 'mark reaches into the image');
|
||||
}
|
||||
});
|
||||
|
||||
test('Continuous lines do not run through other images', () => {
|
||||
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), 'line cuts through a motif');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('An image that is too big lands in unplaced instead of nowhere', () => {
|
||||
const res = layout([{ id: 'big', wMm: 300, hMm: 400, count: 1 }], A4);
|
||||
assert.equal(res.pages.length, 0);
|
||||
assert.equal(res.unplaced[0].specId, 'big');
|
||||
});
|
||||
|
||||
test('More than one sheet when the quantity does not fit', () => {
|
||||
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('Rotating allows more images on 10×15 photo paper', () => {
|
||||
const photo: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||
const without = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, photo);
|
||||
const with_ = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, photo);
|
||||
assert.equal(without, 3);
|
||||
assert.ok(with_ >= 3);
|
||||
});
|
||||
|
||||
test('The recommended gap makes room for the marks', () => {
|
||||
assert.equal(recommendedGap('corner', 0, 4, 3), 14);
|
||||
assert.equal(recommendedGap('none', 3), 6);
|
||||
assert.equal(recommendedGap('grid', 0), 0);
|
||||
});
|
||||
|
||||
test('The dpi warning fires when the source is too small', () => {
|
||||
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('The size tables are consistent', () => {
|
||||
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');
|
||||
// Decimal places must not fall away — the caption stands in the footer and in
|
||||
// the file name.
|
||||
assert.equal(labelMm(112.5, 150), '11.25 × 15 cm');
|
||||
assert.equal(labelMm(105, 148), '10.5 × 14.8 cm');
|
||||
});
|
||||
|
||||
test('The chosen orientation stays when it does not cost extra sheets', () => {
|
||||
// A single passport photo must not be laid on its side just because more of
|
||||
// them would fit on the sheet that way.
|
||||
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 on portrait A4: lying flat it is too wide (250 > 200 mm of usable
|
||||
// width), rotated it fits — so it has to be rotated.
|
||||
const big = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true }], A4);
|
||||
assert.equal(big.pages.length, 1);
|
||||
assert.equal(big.pages[0].placements[0].rotated, true);
|
||||
assert.equal(big.pages[0].placements[0].w, 150);
|
||||
|
||||
// Without permission to rotate it stays flat and is reported as "does not fit".
|
||||
const stubborn = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: false }], A4);
|
||||
assert.equal(stubborn.pages.length, 0);
|
||||
assert.equal(stubborn.unplaced.length, 1);
|
||||
});
|
||||
|
||||
/* --- Regressions from the code review of 18 August 2026 ---------------- */
|
||||
|
||||
test('Without an entry, rotating is allowed (allowRotate undefined)', () => {
|
||||
// Before, the image landed in "unplaced" because !!undefined === false.
|
||||
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('Even with many sizes each one gets a fitting orientation', () => {
|
||||
// The "tooBig" emergency brake (>5 sizes) must not silently drop a size.
|
||||
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'), 'the wide image has to be placed rotated');
|
||||
assert.equal(res.unplaced.length, 0);
|
||||
});
|
||||
|
||||
test('unplaced names the image that is really missing', () => {
|
||||
const specs = [
|
||||
{ id: 'small', wMm: 20, hMm: 30, count: 2, allowRotate: false },
|
||||
{ id: 'huge', 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, 'huge');
|
||||
assert.equal(res.unplaced[0].count, 3);
|
||||
});
|
||||
|
||||
test('Corner marks stay outside the printed area even with bleed', () => {
|
||||
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];
|
||||
// The requested offset of 3 mm is too small for 5 mm of bleed — it has to be
|
||||
// raised.
|
||||
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), 'mark lies inside the printed bleed');
|
||||
}
|
||||
});
|
||||
|
||||
test('capacity no longer caps at 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('Degenerate quantities do not bring the packer to a halt', () => {
|
||||
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, `packing took ${Math.round(ms)} ms — too long for the server process`);
|
||||
});
|
||||
|
||||
/* --- Oversize images (20 × 30 on A4 & poster printing) ----------------- */
|
||||
|
||||
test('20 × 30 cm does not fit on A4 — but only by 3 mm', () => {
|
||||
const borderless: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||
assert.equal(fitsOnSheet(200, 300, borderless, false), false);
|
||||
const info = overflowPlacement('a', 1, 200, 300, borderless, 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, spread evenly
|
||||
assert.equal(info.lostBottom, 1.5);
|
||||
assert.equal(info.lostMm, 3);
|
||||
// centred: x = (210 − 200) / 2
|
||||
assert.equal(info.placement.x, 5);
|
||||
assert.equal(info.placement.y, -1.5);
|
||||
});
|
||||
|
||||
test('An oversize image is rotated when less is lost that way', () => {
|
||||
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||
// 297 × 200 landscape: unrotated it runs 87 mm over the width, rotated it
|
||||
// almost fits.
|
||||
const info = overflowPlacement('a', 1, 297, 200, a4, true);
|
||||
assert.equal(info.rotated, true);
|
||||
assert.equal(info.lostMm, 0);
|
||||
});
|
||||
|
||||
test('Poster print: 30 × 40 cm on A4 gives 2 × 2 sheets with a glue flap', () => {
|
||||
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);
|
||||
// The first tile fills the usable area, the last one carries the remainder.
|
||||
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);
|
||||
// The tiles cover the whole image (the right and bottom edges are reached).
|
||||
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, `something is missing on the right: ${maxX}`);
|
||||
assert.ok(maxY >= 400 - 1e-6, `something is missing at the bottom: ${maxY}`);
|
||||
});
|
||||
|
||||
test('Tiles really do overlap by the glue flap', () => {
|
||||
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||
const tp = tilePlan(300, 400, a4, 10);
|
||||
const left = tp.tiles.find((t) => t.col === 0 && t.row === 0)!;
|
||||
const right = tp.tiles.find((t) => t.col === 1 && t.row === 0)!;
|
||||
const overlap = (left.xMm + left.wMm) - right.xMm;
|
||||
assert.equal(Math.round(overlap * 10) / 10, 10);
|
||||
});
|
||||
|
||||
test('A tile crop stays inside the crop the user chose', () => {
|
||||
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, 'broke out horizontally');
|
||||
assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'broke out vertically');
|
||||
}
|
||||
// The first tile begins exactly where the crop begins.
|
||||
const first = tileCrop(crop, tp.tiles[0], 300, 400);
|
||||
assert.equal(Math.round(first.x * 1e6) / 1e6, crop.x);
|
||||
assert.equal(Math.round(first.y * 1e6) / 1e6, crop.y);
|
||||
});
|
||||
|
||||
test('An image that fits does not count as oversize', () => {
|
||||
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, 'rotated it fits');
|
||||
assert.equal(fitsOnSheet(250, 150, a4, false), false, 'unrotated it does not');
|
||||
});
|
||||
|
||||
test('Poster print leaves room for the sheet number without running off the bottom', () => {
|
||||
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 };
|
||||
// A 5 mm margin is not enough for the 8 pt line — the tile moves down by 1 mm.
|
||||
assert.equal(noteInset(sheet), 1);
|
||||
const ts = tileSheet(sheet);
|
||||
const tp = tilePlan(300, 400, ts, 10);
|
||||
const top = (sheet.marginMm || 0) + noteInset(sheet);
|
||||
for (const t of tp.tiles) {
|
||||
assert.ok(top + t.hMm <= sheet.hMm + 1e-6,
|
||||
`tile ${t.row}/${t.col} runs off the bottom: ${top + t.hMm} > ${sheet.hMm}`);
|
||||
assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6);
|
||||
}
|
||||
// The tiles cover the trimmed size with no gaps.
|
||||
const right = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
|
||||
const bottom = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
|
||||
assert.ok(right >= 300 - 1e-6 && bottom >= 400 - 1e-6, `coverage ${right} × ${bottom}`);
|
||||
});
|
||||
|
||||
test('A generous margin does not move the tile', () => {
|
||||
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 };
|
||||
assert.equal(noteInset(sheet), 0);
|
||||
assert.deepEqual(tileSheet(sheet), sheet);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildSheetFilename, buildSingleFilename, paperToken, filenameFromHeader, KLARBILD_NAME } from '../src/lib/naming.ts';
|
||||
|
||||
/**
|
||||
* Names for print files. The reason for these tests is the same one as in August
|
||||
* for the library images: two sheets from the same day had the same name, and
|
||||
* whoever put both into the same folder lost the first one.
|
||||
*/
|
||||
|
||||
const AT = new Date('2026-09-01T12:32:07Z'); // 14:32:07 Berlin time
|
||||
|
||||
test('A sheet gets a timestamp, its contents, the paper and a short id', () => {
|
||||
const n = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca6ec4-…', at: AT });
|
||||
assert.equal(n, '2026-09-01_143207_print-9-pieces_a4_4eca.pdf');
|
||||
});
|
||||
|
||||
test('Two sheets from the same second cannot overwrite each other', () => {
|
||||
const a = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'aaaa1111', at: AT });
|
||||
const b = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'bbbb2222', at: AT });
|
||||
assert.notEqual(a, b, 'same second, same settings — different all the same');
|
||||
});
|
||||
|
||||
test('One and many — singular, plural, number of sheets', () => {
|
||||
const one = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 1, pages: 1, runId: 'ab12', at: AT });
|
||||
assert.ok(one.includes('print-1-piece_'), one);
|
||||
const many = buildSheetFilename({ wMm: 329, hMm: 483, paperId: 'A3PLUS', pieces: 24, pages: 3, runId: 'ffff', at: AT });
|
||||
assert.equal(many, '2026-09-01_143207_print-24-pieces-3-sheets_a3plus_ffff.pdf');
|
||||
});
|
||||
|
||||
test('A name of your own takes precedence and is made safe', () => {
|
||||
const n = buildSheetFilename({ name: 'Daycare Müller / set 2', wMm: 210, hMm: 297, paperId: 'A4', pieces: 4, pages: 1, runId: 'cccc', at: AT });
|
||||
assert.equal(n, '2026-09-01_143207_daycare-mueller-set-2_a4_cccc.pdf');
|
||||
});
|
||||
|
||||
test('Unknown paper becomes the size', () => {
|
||||
assert.equal(paperToken(null, 100, 150), '100x150');
|
||||
assert.equal(paperToken('', 210, 297), '210x297');
|
||||
assert.equal(paperToken('A4', 210, 297), 'a4');
|
||||
// A very long identifier would bloat the name — then the size is the better choice.
|
||||
assert.equal(paperToken('an-extremely-long-identifier', 210, 297), '210x297');
|
||||
});
|
||||
|
||||
test('Single images inherit the motif of their source', () => {
|
||||
const n = buildSingleFilename({ sourceName: '2026-08-31_221842_kombiniert_30x40_25b3.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b34b55-e816', at: AT });
|
||||
assert.equal(n, '2026-09-01_143207_kombiniert_130x180_25b3.jpg');
|
||||
});
|
||||
|
||||
test('Without a usable motif it says "klarbild", not nothing', () => {
|
||||
assert.equal(buildSingleFilename({ sourceName: null, wMm: 35, hMm: 45, ext: 'jpg', id: 'aaaa', at: AT }),
|
||||
'2026-09-01_143207_klarbild_35x45_aaaa.jpg');
|
||||
assert.equal(buildSingleFilename({ sourceName: 'IMG_1234.HEIC', wMm: 35, hMm: 45, ext: 'jpg', id: 'bbbb', at: AT }),
|
||||
'2026-09-01_143207_img-1234_35x45_bbbb.jpg');
|
||||
});
|
||||
|
||||
test('Sizes with decimals lose the point — otherwise it would look like an extension', () => {
|
||||
const n = buildSingleFilename({ sourceName: 'x.png', wMm: 150, hMm: 112.5, ext: 'png', id: 'dddd', at: AT });
|
||||
assert.equal(n, '2026-09-01_143207_x_150x112-5_dddd.png');
|
||||
assert.equal(n.split('.').length, 2, 'exactly one extension');
|
||||
});
|
||||
|
||||
test('Print files follow the same pattern as library results', () => {
|
||||
for (const n of [
|
||||
buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca', at: AT }),
|
||||
buildSingleFilename({ sourceName: 'portrait.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b3', at: AT }),
|
||||
]) {
|
||||
assert.ok(KLARBILD_NAME.test(n.replace(/\.[^.]+$/, '')), `does not fit the scheme: ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Names are usable on any file system', () => {
|
||||
for (const name of ['Grüße/Köln', 'a\\b:c*d?e"f<g>h|i', '../../..', 'x'.repeat(200)]) {
|
||||
const n = buildSheetFilename({ name, wMm: 210, hMm: 297, paperId: 'A4', pieces: 2, pages: 1, runId: 'eeee', at: AT });
|
||||
assert.ok(/^[a-z0-9_.-]+$/.test(n), `unexpected character: ${n}`);
|
||||
assert.ok(n.endsWith('.pdf'));
|
||||
}
|
||||
});
|
||||
|
||||
test('The name comes from the server, not from the browser', () => {
|
||||
assert.equal(filenameFromHeader('attachment; filename="2026-09-01_143207_print-9-pieces_a4_4eca.pdf"', 'x.pdf'),
|
||||
'2026-09-01_143207_print-9-pieces_a4_4eca.pdf');
|
||||
assert.equal(filenameFromHeader("attachment; filename*=UTF-8''gr%C3%BC%C3%9Fe.pdf", 'x.pdf'), 'grüße.pdf');
|
||||
assert.equal(filenameFromHeader(null, 'x.pdf'), 'x.pdf');
|
||||
assert.equal(filenameFromHeader('attachment', 'x.pdf'), 'x.pdf');
|
||||
});
|
||||
|
||||
test('A name from the header cannot smuggle in a path', () => {
|
||||
for (const evil of ['attachment; filename="../../etc/passwd"', 'attachment; filename="a/b.pdf"',
|
||||
'attachment; filename="a\\\\b.pdf"', 'attachment; filename=".hidden"']) {
|
||||
const n = filenameFromHeader(evil, 'x.pdf');
|
||||
assert.ok(!n.includes('/') && !n.includes('\\') && !n.startsWith('.'), `path survived: ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ZIPs from the library are not all called the same', async () => {
|
||||
const { buildSelectionZipName } = await import('../src/lib/naming.ts');
|
||||
const a = buildSelectionZipName(12, AT);
|
||||
assert.match(a, /^2026-09-01_143207_selection-12-images_[0-9a-f]{4}\.zip$/);
|
||||
assert.equal(buildSelectionZipName(1, AT).includes('selection-1-image_'), true);
|
||||
// Two selections in the same second: the short id tells them apart.
|
||||
const pairs = new Set(Array.from({ length: 50 }, () => buildSelectionZipName(12, AT)));
|
||||
assert.ok(pairs.size > 40, `too many identical names: ${pairs.size}/50`);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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';
|
||||
|
||||
/** A 3:2 landscape graphic with clearly recognisable edges. */
|
||||
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('Cropping gives exactly the target size — with bleed too', 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('Letterboxing gives exactly the target size and lays down the background colour', 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), 'width');
|
||||
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300), 'height');
|
||||
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 top = at(Math.round(info.width / 2), 3);
|
||||
const centre = at(Math.round(info.width / 2), Math.round(info.height / 2));
|
||||
assert.deepEqual(top, [0, 0, 0], 'the top has to be the background colour');
|
||||
assert.ok(centre[2] > 150, 'the image has to stand in the middle');
|
||||
}
|
||||
});
|
||||
|
||||
test('Cropping fills the size completely (no margin)', 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, 'the corners carry image too');
|
||||
});
|
||||
|
||||
test('A landscape target from a portrait source keeps its measurements', 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('The rotated version swaps width and height', 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('The cover and contain crops compute in opposite directions', () => {
|
||||
const a = 35 / 45; // portrait target
|
||||
const cov = coverCrop(1200, 800, a); // landscape image
|
||||
const con = containCrop(1200, 800, a);
|
||||
assert.ok(cov.w < 1 && cov.h === 1, 'cover cuts away at the sides');
|
||||
assert.ok(con.h > 1 && con.w === 1, 'contain sticks out at the top and bottom');
|
||||
assert.ok(con.y < 0, 'the oversize lies outside the image');
|
||||
});
|
||||
|
||||
test('A crop is taken over exactly (no silent re-centring)', async () => {
|
||||
// Colour the top left corner red, then crop exactly that corner.
|
||||
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, 'the chosen crop has to be red');
|
||||
});
|
||||
|
||||
/* --- Regressions from the code review of 18 August 2026 ---------------- */
|
||||
|
||||
test('EXIF rotation is applied — phone photos do not come out sideways', async () => {
|
||||
const landscape = 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 withExif = await sharp(landscape).withMetadata({ orientation: 6 }).jpeg().toBuffer();
|
||||
|
||||
const auto = await sharp(await sharp(withExif).rotate().toBuffer()).metadata();
|
||||
const out = await renderCell(withExif, null, 90, 130, 300, { ext: 'png' });
|
||||
// renderCell has to compute with the pixel grid that the browser shows.
|
||||
assert.deepEqual(out.srcPx, [auto.width, auto.height]);
|
||||
});
|
||||
|
||||
test('Extreme crops do not blow up memory and still give the target size', 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));
|
||||
}
|
||||
// A crop far outside the image: only background colour around it, no huge buffer.
|
||||
const far = await renderCell(panorama, { x: -3.5, y: -3.5, w: 8, h: 8 }, 35, 45, 300, { ext: 'png', fit: 'contain', background: '#000000' });
|
||||
assert.equal(far.width, mmToPx(35, 300));
|
||||
assert.equal(far.height, mmToPx(45, 300));
|
||||
});
|
||||
|
||||
test('The rotated version with bleed keeps its measurements', 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('Bleed enlarges the cell without moving the crop', async () => {
|
||||
// A colour gradient, so that every position has a colour of its own.
|
||||
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 centre = 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 });
|
||||
// Centre of the trim box = centre of the image (the bleed lies symmetrically
|
||||
// around the outside).
|
||||
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 centre(0), b = await centre(5);
|
||||
assert.ok(Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2,
|
||||
`the centre of the trim box moves with bleed: ${a} vs ${b}`);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { summarise } from '../src/lib/printruns.ts';
|
||||
|
||||
/**
|
||||
* The short summary stands in the history list. It arrives cell by cell — two
|
||||
* cells with the same size must not show up there twice.
|
||||
*/
|
||||
|
||||
test('A single size stays as it is', () => {
|
||||
assert.equal(summarise(['13 × 18 cm']), '13 × 18 cm');
|
||||
});
|
||||
|
||||
test('Identical sizes are pulled together', () => {
|
||||
assert.equal(summarise(['10 × 15 cm', '10 × 15 cm']), '10 × 15 cm ×2');
|
||||
});
|
||||
|
||||
test('Existing piece counts are added up, not appended', () => {
|
||||
assert.equal(summarise(['35 × 45 mm ×8', '35 × 45 mm ×4']), '35 × 45 mm ×12');
|
||||
assert.equal(summarise(['10 × 15 cm ×3', '10 × 15 cm']), '10 × 15 cm ×4');
|
||||
});
|
||||
|
||||
test('Different sizes stay side by side, in the order they came in', () => {
|
||||
assert.equal(summarise(['13 × 18 cm', '35 × 45 mm ×8']), '13 × 18 cm, 35 × 45 mm ×8');
|
||||
});
|
||||
|
||||
test('Additions such as (poster) keep the groups apart', () => {
|
||||
assert.equal(summarise(['30 × 40 cm ×2 (poster)', '30 × 40 cm ×1 (poster)']),
|
||||
'30 × 40 cm ×3 (poster)');
|
||||
assert.equal(summarise(['30 × 40 cm (poster)', '30 × 40 cm (oversize)']),
|
||||
'30 × 40 cm (poster), 30 × 40 cm (oversize)');
|
||||
});
|
||||
|
||||
test('An empty list gives something readable', () => {
|
||||
assert.equal(summarise([]), 'Print sheet');
|
||||
});
|
||||
|
||||
test('Very long enumerations are shortened', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => `${i + 10} × ${i + 15} cm`);
|
||||
const s = summarise(many);
|
||||
assert.ok(s.length <= 118, `too long: ${s.length}`);
|
||||
assert.ok(s.endsWith('…'));
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
buildXmp, embedProvenance, readXmp,
|
||||
TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
||||
stripXmp,
|
||||
} from '../src/lib/provenance.ts';
|
||||
|
||||
const image = (w = 240, h = 160) =>
|
||||
sharp({ create: { width: w, height: h, channels: 3, background: '#c8463c' } });
|
||||
|
||||
test('The XMP packet carries the IPTC code and the model', () => {
|
||||
const xmp = buildXmp({ sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'google/gemini-3-pro-image' });
|
||||
assert.match(xmp, /Iptc4xmpExt:DigitalSourceType="http:\/\/cv\.iptc\.org\/newscodes\/digitalsourcetype\/trainedAlgorithmicMedia"/);
|
||||
assert.match(xmp, /xmp:CreatorTool="Klarbild \(google\/gemini-3-pro-image\)"/);
|
||||
assert.match(xmp, /<x:xmpmeta/);
|
||||
});
|
||||
|
||||
test('Special characters in the prompt do not take the XML apart', () => {
|
||||
const xmp = buildXmp({
|
||||
sourceType: TRAINED_ALGORITHMIC_MEDIA,
|
||||
description: 'Ada & Grace <b>"large"</b> — 3 < 5',
|
||||
});
|
||||
assert.ok(!xmp.includes('<b>'), 'unescaped tag in the XMP');
|
||||
assert.match(xmp, /Ada & Grace/);
|
||||
// Exactly as many opening as closing brackets of the elements we know about.
|
||||
assert.equal((xmp.match(/<rdf:Description/g) || []).length, 1);
|
||||
assert.equal((xmp.match(/<\/rdf:Description>/g) || []).length, 1);
|
||||
});
|
||||
|
||||
for (const [name, make] of [
|
||||
['PNG', async () => image().png().toBuffer()],
|
||||
['JPEG', async () => image().jpeg().toBuffer()],
|
||||
['JPEG with dpi metadata (as in a job)', async () => image().withMetadata({ density: 300 }).jpeg({ quality: 92 }).toBuffer()],
|
||||
] as const) {
|
||||
test(`${name}: the marking is written and the image stays intact`, async () => {
|
||||
const before = await make();
|
||||
const after = embedProvenance(before, {
|
||||
sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'test/model', description: 'Test run',
|
||||
});
|
||||
assert.ok(after.length > before.length, 'nothing embedded');
|
||||
|
||||
// 1) The marking can be read back …
|
||||
const xmp = readXmp(after);
|
||||
assert.ok(xmp, 'XMP not found');
|
||||
assert.match(xmp!, /trainedAlgorithmicMedia/);
|
||||
|
||||
// 2) … and sharp still reads the file, with unchanged measurements.
|
||||
const m = await sharp(after).metadata();
|
||||
const m0 = await sharp(before).metadata();
|
||||
assert.equal(m.width, m0.width);
|
||||
assert.equal(m.height, m0.height);
|
||||
assert.equal(m.format, m0.format);
|
||||
if (m0.density) assert.equal(m.density, m0.density, 'dpi lost');
|
||||
|
||||
// 3) The pixels themselves are untouched.
|
||||
const a = await sharp(before).raw().toBuffer();
|
||||
const b = await sharp(after).raw().toBuffer();
|
||||
assert.ok(a.equals(b), 'the image content has changed');
|
||||
});
|
||||
}
|
||||
|
||||
test('Cleaned-up photos get the code for altered material', () => {
|
||||
const xmp = buildXmp({ sourceType: COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA });
|
||||
assert.match(xmp, /compositeWithTrainedAlgorithmicMedia/);
|
||||
});
|
||||
|
||||
test('Unknown formats and junk come back unchanged', () => {
|
||||
for (const b of [Buffer.from('not an image'), Buffer.alloc(0), Buffer.from([0x52, 0x49, 0x46, 0x46])]) {
|
||||
assert.ok(embedProvenance(b, { sourceType: TRAINED_ALGORITHMIC_MEDIA }).equals(b));
|
||||
}
|
||||
});
|
||||
|
||||
test('Marking twice does not break the file', async () => {
|
||||
const png = await image().png().toBuffer();
|
||||
const once = embedProvenance(png, { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const twice = embedProvenance(once, { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const m = await sharp(twice).metadata();
|
||||
assert.equal(m.width, 240);
|
||||
});
|
||||
|
||||
test('The APP1 identifier ends on a real null byte (otherwise no tool recognises it)', async () => {
|
||||
const jpg = embedProvenance(await image().jpeg().toBuffer(), { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const i = jpg.indexOf('http://ns.adobe.com/xap/1.0/');
|
||||
assert.ok(i > 0, 'identifier not found');
|
||||
assert.equal(jpg[i + 28], 0x00, 'the identifier does not end on 0x00');
|
||||
});
|
||||
|
||||
test('Marking twice replaces the old entry instead of appending a second one', async () => {
|
||||
for (const [name, make] of [
|
||||
['PNG', async () => image().png().toBuffer()],
|
||||
['JPEG', async () => image().jpeg().toBuffer()],
|
||||
] as const) {
|
||||
const raw = await make();
|
||||
const old = embedProvenance(raw, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'old/model' });
|
||||
const fresh = embedProvenance(old, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'new/model' });
|
||||
|
||||
// Exactly **one** packet, and it is the new one.
|
||||
const packets = fresh.toString('latin1').split('<x:xmpmeta').length - 1;
|
||||
assert.equal(packets, 1, `${name}: ${packets} XMP packets instead of one`);
|
||||
assert.match(readXmp(fresh)!, /new\/model/, `${name}: wrong model`);
|
||||
assert.ok(!readXmp(fresh)!.includes('old/model'), `${name}: the old model is still there`);
|
||||
|
||||
// And the image is still intact.
|
||||
const m = await sharp(fresh).metadata();
|
||||
assert.equal(m.width, 240, `${name}: measurements changed`);
|
||||
assert.ok((await sharp(raw).raw().toBuffer()).equals(await sharp(fresh).raw().toBuffer()),
|
||||
`${name}: image content changed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('stripXmp leaves files without a marking untouched', async () => {
|
||||
const png = await image().png().toBuffer();
|
||||
assert.ok(stripXmp(png).equals(png));
|
||||
const jpg = await image().jpeg().toBuffer();
|
||||
assert.ok(stripXmp(jpg).equals(jpg));
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { neuerSlug, tageLesen, geraet, gleich, proofBuild, proofValid, proofFromCookie, proofName,
|
||||
MAX_TITEL, MAX_NOTIZ } from '../src/lib/shares.ts';
|
||||
|
||||
process.env.SESSION_SECRET ||= 'test-secret-for-the-tests';
|
||||
|
||||
test('Slugs are long enough, easy to read out and hard to confuse', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const s = neuerSlug();
|
||||
assert.equal(s.length, 12, `wrong length: ${s}`);
|
||||
// No i, l, 0, O, 1 — otherwise they get misheard over the phone.
|
||||
assert.match(s, /^[abcdefghjkmnpqrstuvwxyz23456789]+$/, `forbidden character: ${s}`);
|
||||
assert.ok(!seen.has(s), `slug repeated after ${i} draws: ${s}`);
|
||||
seen.add(s);
|
||||
}
|
||||
});
|
||||
|
||||
test('The characters are evenly distributed — otherwise the search space shrinks', () => {
|
||||
const counts = new Map<string, number>();
|
||||
for (let i = 0; i < 4000; i++) for (const c of neuerSlug()) counts.set(c, (counts.get(c) || 0) + 1);
|
||||
const values = [...counts.values()];
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
for (const [c, n] of counts) {
|
||||
assert.ok(Math.abs(n - mean) / mean < 0.25, `${c} comes up ${n}× instead of ~${Math.round(mean)}×`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Nonsensical expiry values are rejected, not read as "unlimited"', () => {
|
||||
// Exactly the bug the review found: Number('abc') is NaN, NaN > 0 is false —
|
||||
// and the link then held good forever.
|
||||
for (const bad of ['abc', -5, 1e9, Infinity, NaN, {}, [], 'tomorrow']) {
|
||||
assert.throws(() => tageLesen(bad, 30), /Validity/, `slipped through: ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Sensible expiry values come through, the default applies when one is left out', () => {
|
||||
assert.equal(tageLesen(7, 30), 7);
|
||||
assert.equal(tageLesen('30', 30), 30);
|
||||
assert.equal(tageLesen(0, 30), 0); // 0 = deliberately unlimited
|
||||
assert.equal(tageLesen(3650, 30), 3650);
|
||||
assert.equal(tageLesen(undefined, 30), 30);
|
||||
assert.equal(tageLesen(null, 30), 30);
|
||||
assert.equal(tageLesen('', 30), 30);
|
||||
assert.equal(tageLesen(7.9, 30), 7); // truncated, not rounded
|
||||
});
|
||||
|
||||
test('Only the browser and the system are left over from the user agent', () => {
|
||||
const cases = [
|
||||
['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1', 'Safari · iPhone'],
|
||||
['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', 'Chrome · Windows'],
|
||||
['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/121.0', 'Firefox · Mac'],
|
||||
['Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile Safari/537.36', 'Chrome · Android'],
|
||||
] as const;
|
||||
for (const [ua, expected] of cases) assert.equal(geraet(ua), expected, ua.slice(0, 40));
|
||||
assert.equal(geraet(''), null);
|
||||
assert.equal(geraet(null), null);
|
||||
// The fingerprint must not give away more than the browser and the system.
|
||||
for (const [ua] of cases) assert.ok((geraet(ua) || '').length < 25, 'kept too much detail');
|
||||
});
|
||||
|
||||
test('The proof for the passphrase is only valid for its own link', () => {
|
||||
const a = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const b = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
const n = proofBuild(a);
|
||||
assert.ok(proofValid(a, n), 'its own link was rejected');
|
||||
assert.ok(!proofValid(b, n), 'the proof is valid for somebody else\'s link');
|
||||
assert.ok(!proofValid(a, 'nonsense'), 'nonsense accepted');
|
||||
assert.ok(!proofValid(a, ''), 'empty value accepted');
|
||||
assert.ok(!proofValid(a, null), 'null accepted');
|
||||
});
|
||||
|
||||
test('An expired or forged proof is rejected', () => {
|
||||
const id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const [, sig] = proofBuild(id).split('.');
|
||||
const past = Math.floor(Date.now() / 1000) - 10;
|
||||
assert.ok(!proofValid(id, `${past}.${sig}`), 'an expired proof came through');
|
||||
// Push the expiry forward without knowing the signature → has to fail.
|
||||
const future = Math.floor(Date.now() / 1000) + 999999;
|
||||
assert.ok(!proofValid(id, `${future}.${sig}`), 'a tampered lifetime came through');
|
||||
});
|
||||
|
||||
test('The proof is read out of the right cookie', () => {
|
||||
const header = `other=x; ${proofName('abc123def456')}=value.sig; more=y`;
|
||||
assert.equal(proofFromCookie(header, 'abc123def456'), 'value.sig');
|
||||
assert.equal(proofFromCookie(header, 'anotherone12'), null);
|
||||
assert.equal(proofFromCookie(null, 'abc123def456'), null);
|
||||
});
|
||||
|
||||
test('gleich() compares in constant time and does not trip over lengths', () => {
|
||||
assert.ok(gleich('abc', 'abc'));
|
||||
assert.ok(!gleich('abc', 'abd'));
|
||||
assert.ok(!gleich('abc', 'abcd'));
|
||||
assert.ok(!gleich('', 'x'));
|
||||
assert.ok(gleich('', ''));
|
||||
});
|
||||
|
||||
test('The length limits are in place', () => {
|
||||
assert.ok(MAX_TITEL > 0 && MAX_TITEL <= 500);
|
||||
assert.ok(MAX_NOTIZ > MAX_TITEL);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { archiveName, packageName } from '../src/lib/shares.ts';
|
||||
|
||||
/**
|
||||
* Names inside the collected package. Two identical names in one ZIP are not an
|
||||
* error while packing — they only show up when **unpacking**, where the second
|
||||
* one overwrites the first. That is why there is a test for it.
|
||||
*/
|
||||
|
||||
test('Identical file names are numbered through', () => {
|
||||
const seen = new Map<string, number>();
|
||||
assert.equal(archiveName('bild.png', seen), 'bild.png');
|
||||
assert.equal(archiveName('bild.png', seen), 'bild_1.png');
|
||||
assert.equal(archiveName('bild.png', seen), 'bild_2.png');
|
||||
assert.equal(archiveName('anderes.png', seen), 'anderes.png');
|
||||
});
|
||||
|
||||
test('Names cannot break out of the archive', () => {
|
||||
const seen = new Map<string, number>();
|
||||
for (const evil of ['../../etc/passwd', 'a/b/c.png', 'a\\b.png', '/absolute.png']) {
|
||||
const n = archiveName(evil, seen);
|
||||
assert.ok(!n.includes('/') && !n.includes('\\'), `separator survived: ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Control characters are thrown out', () => {
|
||||
const seen = new Map<string, number>();
|
||||
assert.equal(archiveName('ab\u0007c.png', seen), 'abc.png');
|
||||
assert.equal(archiveName('line\nbreak.png', seen), 'linebreak.png');
|
||||
});
|
||||
|
||||
test('Missing names and names without an extension get something usable', () => {
|
||||
const seen = new Map<string, number>();
|
||||
assert.equal(archiveName(null, seen), 'image.jpg');
|
||||
assert.equal(archiveName('', seen), 'image_1.jpg');
|
||||
assert.equal(archiveName(' ', seen), 'image_2.jpg');
|
||||
assert.equal(archiveName('no-extension', seen), 'no-extension.jpg');
|
||||
assert.equal(archiveName('with.png', seen), 'with.png');
|
||||
assert.equal(archiveName('with.PNG', seen), 'with.PNG');
|
||||
});
|
||||
|
||||
test('Real Klarbild names stay unchanged', () => {
|
||||
const seen = new Map<string, number>();
|
||||
const n = '2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg';
|
||||
assert.equal(archiveName(n, seen), n);
|
||||
});
|
||||
|
||||
test('The package is named after the date and the title', () => {
|
||||
const on = new Date('2026-09-01T10:00:00Z');
|
||||
assert.equal(packageName('Daycare August 2026', on), '2026-09-01_daycare-august-2026.zip');
|
||||
assert.equal(packageName('Müller & Söhne', on), '2026-09-01_mueller-soehne.zip');
|
||||
assert.equal(packageName(null, on), '2026-09-01_klarbild.zip');
|
||||
assert.equal(packageName('', on), '2026-09-01_klarbild.zip');
|
||||
assert.equal(packageName('!!!', on), '2026-09-01_klarbild.zip');
|
||||
});
|
||||
|
||||
test('The package name gets along with every file system', () => {
|
||||
const on = new Date('2026-09-01T10:00:00Z');
|
||||
for (const title of ['Grüße aus Köln / part 2', 'a\\b:c*d?e"f<g>h|i', '../../..', 'x'.repeat(200)]) {
|
||||
const n = packageName(title, on);
|
||||
assert.ok(!/[^a-z0-9_.-]/.test(n), `unexpected character in ${n}`);
|
||||
assert.ok(n.length < 60, `too long: ${n.length}`);
|
||||
assert.ok(n.endsWith('.zip'));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user