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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
11 files changed
+370
-64
No files matched your search
@@ -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);}
|
||||
|
||||
+14
-42
@@ -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<CropRel>(cell.crop);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(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
|
||||
</div>
|
||||
<div className="reihe">
|
||||
<button className="pbtn" onClick={() => setCrop(base)}>{cell.fit === 'contain' ? 'Ganzes Bild' : 'Bild füllen'}</button>
|
||||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }, cell.fit))}>Mitte</button>
|
||||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }, cell.fit, grenzen))}>Mitte</button>
|
||||
<button className="knopf schmal" onClick={schliessen}>Übernehmen</button>
|
||||
</div>
|
||||
<div className="fein">Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit.
|
||||
|
||||
Reference in new issue
Block a user