Files
tillandClaude Opus 5 e2481139a1
CI / check (push) Has been cancelled
Klarbild 1.0.0 — first public release
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
2026-09-15 14:52:41 +00:00

119 lines
5.2 KiB
TypeScript

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 &amp; 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));
});