From 6ae6e0348cdb797a22d5857d1e6bc54262719ac6 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 1 Sep 2026 02:44:45 +0000 Subject: [PATCH] feat: CSP and security headers, memory limit, AI provenance, one crop core Five things off the open list in the wiki. CSP with real hashes for every inline script (Astro's experimental.csp), so no 'unsafe-inline' for scripts. style-src is deliberately reopened in middleware: React sets styles as attributes and attributes cannot be hashed -- measured, the hash-only policy blocks them and the print preview loses its layout. Plus nosniff, referrer-policy, X-Frame-Options and a permissions-policy. Verified across all pages: zero violations. mem_limit 3g in compose. sharp can spike; without a limit an outlier takes the whole container down instead of just the process, and the healthcheck can't help. AI results now carry IPTC DigitalSourceType (trainedAlgorithmicMedia, or compositeWithTrainedAlgorithmicMedia for edited photos) plus the model, embedded as XMP -- written by hand into PNG iTXt and JPEG APP1 rather than pulling in a sharp upgrade with a native postinstall. exiftool reads it back from both formats; pixels, dimensions and dpi are provably untouched. Crop maths lived twice, in the browser preview and the server renderer, with nothing holding them together. Now one dependency-free module, and 60000 randomised cases assert both entry points agree. That test immediately found a real bug: the zoom limits (0.02 / 8x) also clamped the *initial* crop, so an extreme format silently broke the aspect ratio -- "Rand lassen" would have cropped after all. Limits are now widened to whatever the format needs. Also: the colour flag sat exactly on the selection circle in the library and covered it. Moved to the bottom left. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- astro.config.mjs | 17 ++++++ docker-compose.yaml | 5 ++ src/components/LibraryApp.tsx | 6 +- src/components/PrintApp.tsx | 56 +++++------------- src/lib/cropmath.ts | 90 +++++++++++++++++++++++++++++ src/lib/printrender.ts | 31 ++++------ src/lib/process.ts | 10 ++++ src/lib/provenance.ts | Bin 0 -> 6374 bytes src/middleware.ts | 33 ++++++++++- tests/cropmath.test.ts | 106 ++++++++++++++++++++++++++++++++++ tests/provenance.test.ts | 80 +++++++++++++++++++++++++ 11 files changed, 370 insertions(+), 64 deletions(-) create mode 100644 src/lib/cropmath.ts create mode 100644 src/lib/provenance.ts create mode 100644 tests/cropmath.test.ts create mode 100644 tests/provenance.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index 6e2067f..0286e5c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,6 +10,23 @@ export default defineConfig({ // CSRF: eigener Schutz über SameSite=Lax-Session-Cookie. Astros checkOrigin // bricht multipart-Uploads hinter dem Reverse-Proxy (http↔https-Mismatch). security: { checkOrigin: false }, + // Content-Security-Policy mit Hashes statt 'unsafe-inline': Astro rechnet die + // Hashes seiner eigenen inline-Skripte und -Styles beim Bauen aus. Die übrigen + // Direktiven setzt die Middleware (src/middleware.ts). + experimental: { + csp: { + directives: [ + "default-src 'self'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ], + }, + }, server: { host: true, port: Number(process.env.PORT) || 4321 }, vite: { ssr: { diff --git a/docker-compose.yaml b/docker-compose.yaml index cfaaa13..587d0e6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,6 +26,11 @@ services: depends_on: - db - minio + # Speichergrenze: sharp kann bei großen Bildern kurzzeitig viel Speicher + # ziehen. Ohne Grenze reißt ein Ausreißer den ganzen Container mit; mit + # Grenze stirbt nur der Node-Prozess, und der Healthcheck startet ihn neu. + mem_limit: 3g + mem_reservation: 512m expose: - "4321" networks: diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index a9471bb..6878a45 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -302,7 +302,11 @@ function LibStyles() { .haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;} .kachel.gewaehlt .haken{background:var(--accent);} .modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;} - .flagbadge{position:absolute;top:5px;left:6px;font-size:19px;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.35);} + /* Unten links — oben links sitzt der Auswahlkreis (.haken), oben rechts die + Modus-Marke, unten rechts die Fassungsnummer. Vorher lag die Flagge exakt + auf dem Auswahlkreis und verdeckte ihn. */ + .flagbadge{position:absolute;bottom:5px;left:6px;font-size:19px;line-height:1; + text-shadow:0 1px 3px rgba(0,0,0,.55);pointer-events:none;} .tagbar{margin:0 0 12px;} .flagchip .flag{font-size:13px;vertical-align:-1px;} .flag.none{color:var(--soft);} diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index 2a77a9e..ff1200d 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -5,6 +5,7 @@ import { type SheetSpec, type PlaceSpec, type MarkMode, } from '../lib/printlayout'; import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize'; +import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath'; /* -------------------------------------------------------------------------- Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit @@ -16,8 +17,7 @@ import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } fr -------------------------------------------------------------------------- */ type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string }; -interface CropRel { x: number; y: number; w: number; h: number } -type FitMode = 'cover' | 'contain'; +// CropRel und FitMode kommen aus cropmath.ts — eine Definition für Browser und Server. interface Cell { id: string; @@ -49,40 +49,11 @@ const safeColor = (v: unknown): string => const zahl = (n: number, ein: string, mehr: string) => `${n} ${n === 1 ? ein : mehr}`; const mm = (n: number) => String(Math.round(n * 10) / 10).replace('.', ','); -/* ---------------- Zuschnitt-Mathematik (spiegelt printrender.ts) ---------- */ - -function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel { - const imgA = natW / natH; - const w = imgA > aspect ? aspect / imgA : 1; - const h = imgA > aspect ? 1 : imgA / aspect; - return clampCrop(centred(w, h, around), 'cover'); -} -function containCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel { - const imgA = natW / natH; - const w = imgA > aspect ? 1 : aspect / imgA; - const h = imgA > aspect ? imgA / aspect : 1; - return clampCrop(centred(w, h, around), 'contain'); -} -const centred = (w: number, h: number, around?: CropRel): CropRel => { - const cx = around ? around.x + around.w / 2 : 0.5; - const cy = around ? around.y + around.h / 2 : 0.5; - return { x: cx - w / 2, y: cy - h / 2, w, h }; -}; -function baseCrop(natW: number, natH: number, aspect: number, fit: FitMode, around?: CropRel): CropRel { - return fit === 'contain' ? containCrop(natW, natH, aspect, around) : coverCrop(natW, natH, aspect, around); -} -function clampCrop(c: CropRel, fit: FitMode = 'cover'): CropRel { - if (fit === 'contain') { - const w = Math.min(8, Math.max(0.02, c.w)); - const h = Math.min(8, Math.max(0.02, c.h)); - return { w, h, - x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)), - y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)) }; - } - const w = Math.min(1, Math.max(0.02, c.w)); - const h = Math.min(1, Math.max(0.02, c.h)); - return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) }; -} +/* ---------------- Zuschnitt-Mathematik ------------------------------------ + Kommt aus `src/lib/cropmath.ts` — genau dieselbe Datei, die der Server fürs + PDF benutzt. Vorher stand die Rechnung hier ein zweites Mal; zwei Kopien + derselben Formel driften früher oder später auseinander, und dann zeigt die + Vorschau etwas anderes, als gedruckt wird. */ const sizeOfCell = (c: Cell): { w: number; h: number } | null => { const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId); @@ -1030,6 +1001,9 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop const s = sizeOfCell(cell); const aspect = s ? s.w / s.h : 1; const base = baseCrop(cell.natW, cell.natH, aspect, cell.fit); + // Dieselben Grenzen wie für den Start-Ausschnitt — sonst würde der Rahmen bei + // Zoomstufe 1 auf einen anderen Wert springen als beim Öffnen. + const grenzen = limitsFor(base); const [crop, setCrop] = useState(cell.crop); const boxRef = useRef(null); const modalRef = useRef(null); @@ -1039,9 +1013,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop const zoom = Math.min(6, Math.max(1, base.w / crop.w)); const setZoom = (z: number) => { const zz = Math.min(6, Math.max(1, z)); - const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2; - const w = base.w / zz, h = base.h / zz; - setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }, cell.fit)); + setCrop(clampCrop(centred(base.w / zz, base.h / zz, crop), cell.fit, grenzen)); }; const onDown = (e: React.PointerEvent) => { @@ -1054,7 +1026,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop const dx = (e.clientX - drag.current.x) / r.width; const dy = (e.clientY - drag.current.y) / r.height; const c = drag.current.crop; - setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }, cell.fit)); + setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }, cell.fit, grenzen)); }; const onUp = () => { drag.current = null; }; @@ -1092,7 +1064,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop const step = e.shiftKey ? 0.05 : 0.01; const move = (dx: number, dy: number) => { e.preventDefault(); - setCrop((c) => clampCrop({ ...c, x: c.x + dx * c.w, y: c.y + dy * c.h }, cell.fit)); + setCrop((c) => clampCrop({ ...c, x: c.x + dx * c.w, y: c.y + dy * c.h }, cell.fit, grenzen)); }; if (e.key === 'ArrowLeft') move(-step, 0); else if (e.key === 'ArrowRight') move(step, 0); @@ -1131,7 +1103,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
- +
Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit. diff --git a/src/lib/cropmath.ts b/src/lib/cropmath.ts new file mode 100644 index 0000000..5a173ad --- /dev/null +++ b/src/lib/cropmath.ts @@ -0,0 +1,90 @@ +// Zuschnitt-Mathematik — **ohne jede Abhängigkeit**, wie printlayout.ts. +// +// Diese Datei existiert, weil dieselbe Rechnung vorher zweimal im Projekt stand: +// einmal im Browser (Vorschau und Zuschnitt-Fenster in PrintApp.tsx) und einmal +// auf dem Server (printrender.ts, für das PDF). Beide rechneten gleich — aber +// nichts hielt sie gegeneinander. Zwei Kopien derselben Formel driften früher +// oder später auseinander, und dann zeigt die Vorschau etwas anderes, als +// gedruckt wird. Jetzt gibt es nur noch eine Quelle, und ein Test hält den +// Server-Einstieg gegen den Browser-Einstieg. +// +// Alle Ausschnitte sind **relativ** (0…1 bezogen auf das Quellbild) und damit +// unabhängig von der Auflösung: derselbe Zuschnitt gilt für das kleine +// Vorschaubild wie für das Original. + +export interface CropRel { x: number; y: number; w: number; h: number } +export type FitMode = 'cover' | 'contain'; + +/** Größter Ausschnitt im Zielverhältnis, der **innerhalb** des Bildes liegt. */ +export function coverSize(natW: number, natH: number, aspect: number): { w: number; h: number } { + const imgA = natW / natH; + return imgA > aspect ? { w: aspect / imgA, h: 1 } : { w: 1, h: imgA / aspect }; +} + +/** + * Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält. + * Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand. + */ +export function containSize(natW: number, natH: number, aspect: number): { w: number; h: number } { + const imgA = natW / natH; + return imgA > aspect ? { w: 1, h: imgA / aspect } : { w: aspect / imgA, h: 1 }; +} + +/** Um denselben Mittelpunkt legen wie ein vorhandener Ausschnitt (sonst mittig). */ +export function centred(w: number, h: number, around?: CropRel | null): CropRel { + const cx = around ? around.x + around.w / 2 : 0.5; + const cy = around ? around.y + around.h / 2 : 0.5; + return { x: cx - w / 2, y: cy - h / 2, w, h }; +} + +/** Grenzen fürs Herein- und Herauszoomen. */ +export interface CropLimits { min?: number; max?: number } + +/** + * Hält den Ausschnitt in sinnvollen Grenzen. + * Bei `cover` bleibt er im Bild. Bei `contain` darf er hinausragen — aber nicht + * so weit, dass vom Motiv nichts mehr zu sehen wäre (mindestens 5 % müssen im + * Rahmen bleiben). + * + * `min`/`max` begrenzen, wie weit der Nutzer herein- und herauszoomen darf. + * Der **Start**-Ausschnitt muss davon ausgenommen sein: Ein sehr hohes Bild in + * einem sehr breiten Format braucht rechnerisch mehr als das Achtfache — und + * ein sehr breites Format aus einem sehr hohen Bild weniger als 2 %. Griffe die + * Grenze dort, stimmte das Seitenverhältnis nicht mehr, und das Bild würde doch + * beschnitten, obwohl „Rand lassen" gewählt ist. `baseCrop` weitet die Grenzen + * deshalb auf das, was das Format tatsächlich braucht. + */ +export function clampCrop(c: CropRel, fit: FitMode = 'cover', limits: CropLimits = {}): CropRel { + const lo = limits.min ?? 0.02; + const hi = limits.max ?? 8; + if (fit === 'contain') { + const w = Math.min(hi, Math.max(lo, c.w)); + const h = Math.min(hi, Math.max(lo, c.h)); + return { + w, h, + x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)), + y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)), + }; + } + const w = Math.min(Math.min(1, hi), Math.max(lo, c.w)); + const h = Math.min(Math.min(1, hi), Math.max(lo, c.h)); + return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) }; +} + +/** Grenzen, die einen bestimmten Start-Ausschnitt garantiert durchlassen. */ +export const limitsFor = (c: { w: number; h: number }): CropLimits => + ({ min: Math.min(0.02, c.w, c.h), max: Math.max(8, c.w, c.h) }); + +/** + * Der Ausschnitt, mit dem ein Bild in ein Zielformat startet. + * `around` erhält den Bildmittelpunkt, wenn der Nutzer schon verschoben hatte + * und danach das Format wechselt. + */ +export function baseCrop( + natW: number, natH: number, aspect: number, fit: FitMode = 'cover', around?: CropRel | null, +): CropRel { + const { w, h } = fit === 'contain' ? containSize(natW, natH, aspect) : coverSize(natW, natH, aspect); + // Die Zoom-Grenzen dürfen den Start-Ausschnitt nie verformen — sonst wäre das + // Seitenverhältnis dahin und es würde doch beschnitten (siehe clampCrop). + return clampCrop(centred(w, h, around), fit, limitsFor({ w, h })); +} diff --git a/src/lib/printrender.ts b/src/lib/printrender.ts index 700d58a..cac37ab 100644 --- a/src/lib/printrender.ts +++ b/src/lib/printrender.ts @@ -5,38 +5,29 @@ import sharp from 'sharp'; import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; import { mmToPt, mmToPx } from './paper.ts'; import type { Line, Page, SheetSpec } from './printlayout.ts'; +import { baseCrop, type CropRel, type FitMode } from './cropmath.ts'; /** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */ -export interface CropRel { x: number; y: number; w: number; h: number } +export type { CropRel, FitMode }; export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 }; -/** - * Was passiert, wenn das Bild nicht dem Zielverhältnis entspricht? - * - `cover` — zuschneiden, bis das Format ausgefüllt ist (kein Rand, es fehlt etwas). - * - `contain` — das ganze Bild einpassen, der Rest wird zur Randfarbe (nichts fehlt). - * Verzerrt wird nie. - */ -export type FitMode = 'cover' | 'contain'; +/* `FitMode`: `cover` = zuschneiden bis ausgefüllt (es fehlt etwas außen), + `contain` = ganzes Bild einpassen (außen bleibt die Randfarbe). Nie verzerren. */ /** Größter mittiger Ausschnitt im Zielverhältnis — füllt aus, schneidet ab. */ -export function coverCrop(natW: number, natH: number, aspect: number): CropRel { - const imgA = natW / natH; - const w = imgA > aspect ? aspect / imgA : 1; - const h = imgA > aspect ? 1 : imgA / aspect; - return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }; -} +// Beide Einstiegspunkte rechnen über `src/lib/cropmath.ts` — dieselbe Datei, die +// auch die Browser-Vorschau benutzt. `tests/cropmath.test.ts` hält beide Wege +// gegeneinander, damit Vorschau und PDF nicht auseinanderlaufen können. +export const coverCrop = (natW: number, natH: number, aspect: number): CropRel => + baseCrop(natW, natH, aspect, 'cover'); /** * Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält. * Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand. */ -export function containCrop(natW: number, natH: number, aspect: number): CropRel { - const imgA = natW / natH; - const w = imgA > aspect ? 1 : aspect / imgA; - const h = imgA > aspect ? imgA / aspect : 1; - return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }; -} +export const containCrop = (natW: number, natH: number, aspect: number): CropRel => + baseCrop(natW, natH, aspect, 'contain'); /** * Bringt einen Bildausschnitt exakt auf ein physisches Maß. diff --git a/src/lib/process.ts b/src/lib/process.ts index 5ddeb12..dff0f69 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -6,6 +6,7 @@ import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { finalizeToFormat, stickerContour, type CropMode } from './pipeline'; import { formatToken } from './naming'; import { uniqueFilename } from './renames'; +import { embedProvenance, TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA } from './provenance'; import sharp from 'sharp'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; @@ -161,6 +162,15 @@ export async function processItem(itemId: string): Promise { contentType = 'image/jpeg'; } + // KI-Herkunft maschinenlesbar in die Datei schreiben (IPTC DigitalSourceType). + // „generate" ist vollständig erzeugt, alles andere ist ein verändertes Foto — + // dafür sieht die IPTC-Liste einen eigenen Code vor. + outBuf = embedProvenance(outBuf, { + sourceType: mode === 'generate' ? TRAINED_ALGORITHMIC_MEDIA : COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA, + model: modelUsed, + description: isPrivate ? null : prompt.slice(0, 300), + }); + const key = resultKey(item.id); await putObject(key, outBuf, contentType); diff --git a/src/lib/provenance.ts b/src/lib/provenance.ts new file mode 100644 index 0000000000000000000000000000000000000000..77f13be59a6da722c62f757caa17a6df3e0661da GIT binary patch literal 6374 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 OXBzoHBhU0)bp8(m?g1nK literal 0 HcmV?d00001 diff --git a/src/middleware.ts b/src/middleware.ts index 8664817..4f0d808 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -38,5 +38,36 @@ export const onRequest = defineMiddleware(async (ctx, next) => { return ctx.redirect('/'); } } - return next(); + return withSecurityHeaders(await next()); }); + +/** + * Sicherheits-Header. Die Content-Security-Policy selbst baut Astro beim Bauen + * (`experimental.csp` in `astro.config.mjs`) und hängt echte **Hashes** für jedes + * eigene inline-Skript an — also kein `'unsafe-inline'` für Skripte, das ist der + * wichtige Teil gegen eingeschleusten Code. + * + * `style-src` wird hier bewusst wieder auf `'unsafe-inline'` gesetzt: React setzt + * Stile als Attribut am Element (`style={{…}}`), und Attribute lassen sich nicht + * hashen. Gemessen: Mit reiner Hash-Policy blockt der Browser sie, und die + * Vorschau im Druckmodul verliert ihr Layout. Der verbleibende Rest — CSS-basierte + * Exfiltration — ist eine deutlich schmalere Klasse als Skript-Injektion, und die + * bleibt zu. + */ +function withSecurityHeaders(res: Response): Response { + const csp = res.headers.get('content-security-policy'); + if (csp) { + res.headers.set('content-security-policy', + csp.replace(/style-src[^;]*/i, "style-src 'self' 'unsafe-inline'")); + } + // Kein MIME-Sniffing: Ergebnisdateien werden mit erkanntem Typ ausgeliefert, + // der Browser soll nicht selbst raten und etwas als Skript ausführen. + res.headers.set('x-content-type-options', 'nosniff'); + // Beim Wechsel auf fremde Seiten keinen Pfad mitschicken (Galerie-Namen!). + res.headers.set('referrer-policy', 'strict-origin-when-cross-origin'); + // Ältere Browser ohne CSP-frame-ancestors. + res.headers.set('x-frame-options', 'DENY'); + // Keine dieser Schnittstellen wird gebraucht. + res.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'); + return res; +} diff --git a/tests/cropmath.test.ts b/tests/cropmath.test.ts new file mode 100644 index 0000000..0b67291 --- /dev/null +++ b/tests/cropmath.test.ts @@ -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'; + +/** Deterministischer Zufall — bei einem Fehlschlag ist der Fall reproduzierbar. */ +function rng(seed: number) { + let s = seed >>> 0; + return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x1_0000_0000); +} + +test('Server-Einstieg und Browser-Einstieg rechnen identisch (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 → Vorschau + assert.deepEqual(browser, server, `cover ${natW}x${natH} @ ${aspect}`); + } +}); + +test('Server-Einstieg und Browser-Einstieg rechnen identisch (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 bleibt im Bild, contain umschließt das ganze Bild', () => { + 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 ragt heraus: ${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 umschließt nicht: ${JSON.stringify(k)}`); + } +}); + +test('Das Seitenverhältnis stimmt — sonst würde verzerrt', () => { + 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); + // Der Ausschnitt in Pixeln muss exakt das Zielverhältnis haben. + const ist = (c.w * natW) / (c.h * natH); + assert.ok(Math.abs(ist - aspect) < 1e-9, + `${fit} ${natW}x${natH}: Verhältnis ${ist} statt ${aspect}`); + } + } +}); + +test('Bei passendem Verhältnis ist der Ausschnitt das ganze Bild', () => { + 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('Formatwechsel behält den Bildmittelpunkt', () => { + // Nutzer hat nach links oben verschoben, wechselt dann von 3:2 auf 35x45. + const vorher: CropRel = { x: 0.1, y: 0.05, w: 0.4, h: 0.4 }; + const nachher = baseCrop(3000, 2000, 35 / 45, 'cover', vorher); + const mitteVorher = vorher.x + vorher.w / 2; + const mitteNachher = nachher.x + nachher.w / 2; + // In der eingeschränkten Achse kann der Anschlag greifen — er darf den + // Mittelpunkt aber nur zum Bild hin verschieben, nicht darüber hinaus. + assert.ok(Math.abs(mitteNachher - mitteVorher) < 0.5, `Mitte sprang: ${mitteVorher} → ${mitteNachher}`); + assert.ok(nachher.x >= 0 && nachher.x + nachher.w <= 1); +}); + +test('clampCrop hält cover im Bild und lässt contain kontrolliert hinausragen', () => { + 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: mindestens 5 % des Ausschnitts müssen im Bild bleiben. + const weit = clampCrop({ x: -99, y: -99, w: 2, h: 2 }, 'contain'); + assert.ok(weit.x + weit.w > 0 && weit.y + weit.h > 0, 'Motiv komplett aus dem Rahmen geschoben'); +}); + +test('centred legt den Ausschnitt um einen Mittelpunkt', () => { + 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 und containSize sind zueinander invers im Verhältnis', () => { + 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 darf nicht über 1'); + assert.ok(k.w >= 1 - 1e-9 || k.h >= 1 - 1e-9, 'contain muss mindestens eine Kante füllen'); +}); diff --git a/tests/provenance.test.ts b/tests/provenance.test.ts new file mode 100644 index 0000000..f37b731 --- /dev/null +++ b/tests/provenance.test.ts @@ -0,0 +1,80 @@ +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, +} from '../src/lib/provenance.ts'; + +const bild = (w = 240, h = 160) => + sharp({ create: { width: w, height: h, channels: 3, background: '#c8463c' } }); + +test('Das XMP-Paket trägt den IPTC-Code und das Modell', () => { + 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, / { + const xmp = buildXmp({ + sourceType: TRAINED_ALGORITHMIC_MEDIA, + description: 'Lea & Phoenix "groß" — 3 < 5', + }); + assert.ok(!xmp.includes(''), 'unmaskiertes Tag im XMP'); + assert.match(xmp, /Lea & Phoenix/); + // Genau so viele öffnende wie schließende Klammern der bekannten Elemente. + assert.equal((xmp.match(//g) || []).length, 1); +}); + +for (const [name, machen] of [ + ['PNG', async () => bild().png().toBuffer()], + ['JPEG', async () => bild().jpeg().toBuffer()], + ['JPEG mit dpi-Metadaten (wie im Auftrag)', async () => bild().withMetadata({ density: 300 }).jpeg({ quality: 92 }).toBuffer()], +] as const) { + test(`${name}: Kennzeichnung wird geschrieben und das Bild bleibt heil`, async () => { + const vorher = await machen(); + const nachher = embedProvenance(vorher, { + sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'test/modell', description: 'Prüflauf', + }); + assert.ok(nachher.length > vorher.length, 'nichts eingebettet'); + + // 1) Kennzeichnung ist wieder auslesbar … + const xmp = readXmp(nachher); + assert.ok(xmp, 'XMP nicht gefunden'); + assert.match(xmp!, /trainedAlgorithmicMedia/); + + // 2) … und sharp liest die Datei weiterhin, mit unveränderten Maßen. + const m = await sharp(nachher).metadata(); + const m0 = await sharp(vorher).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 verloren'); + + // 3) Die Bildpunkte selbst sind unangetastet. + const a = await sharp(vorher).raw().toBuffer(); + const b = await sharp(nachher).raw().toBuffer(); + assert.ok(a.equals(b), 'Bildinhalt hat sich verändert'); + }); +} + +test('Bereinigte Fotos bekommen den Code für verändertes Material', () => { + const xmp = buildXmp({ sourceType: COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA }); + assert.match(xmp, /compositeWithTrainedAlgorithmicMedia/); +}); + +test('Unbekannte Formate und Schrott kommen unverändert zurück', () => { + for (const b of [Buffer.from('kein bild'), Buffer.alloc(0), Buffer.from([0x52, 0x49, 0x46, 0x46])]) { + assert.ok(embedProvenance(b, { sourceType: TRAINED_ALGORITHMIC_MEDIA }).equals(b)); + } +}); + +test('Zweimal kennzeichnen bricht die Datei nicht', async () => { + const png = await bild().png().toBuffer(); + const einmal = embedProvenance(png, { sourceType: TRAINED_ALGORITHMIC_MEDIA }); + const zweimal = embedProvenance(einmal, { sourceType: TRAINED_ALGORITHMIC_MEDIA }); + const m = await sharp(zweimal).metadata(); + assert.equal(m.width, 240); +});