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');
|
||||
});
|
||||
Reference in new issue
Block a user