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 0000000..77f13be Binary files /dev/null and b/src/lib/provenance.ts differ 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); +});