From 5ebe8026e0a5ddc9edb9c4cea010c8263044e664 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 20 Aug 2026 10:55:11 +0000 Subject: [PATCH] feat: oversize sheets, image duplication, free labelling and a new layout Prints larger than the sheet no longer just fail. Each cell can now spill over the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be tiled across several sheets with a dashed glue fold and a sheet number. The maths lives in printoversize.ts, dependency-free, so the browser preview and the server PDF compute the same thing -- including the note strip that keeps the sheet number off the picture. The same image can now appear as several cells, so a portrait can be printed at two sizes on one sheet without copying and re-uploading the file. The footer line can be switched off, given custom text and placed at one of six positions; it is only drawn where that edge is free. Layout reworked for all three widths: work left, sticky preview right on the desktop, collapsed setting groups paired in a two-column grid that expands full width; preview first and a fixed action bar on the phone. Verified at 390, 820, 1440 and 1680 px -- no horizontal overflow, no dead white space. Also a public presentation page at /vorstellung (no login, nothing operable), whose sample sheet is computed by the real packer rather than drawn. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- CHANGELOG.md | 41 + README.md | 18 +- src/components/PrintApp.tsx | 1401 +++++++++++++++++++++++----------- src/lib/printoversize.ts | 124 +++ src/lib/printrender.ts | 59 +- src/middleware.ts | 2 +- src/pages/anleitung.astro | 26 +- src/pages/api/print/sheet.ts | 115 ++- src/pages/changelog.astro | 36 + src/pages/llms.txt.ts | 20 +- src/pages/vorstellung.astro | 315 ++++++++ tests/printlayout.test.ts | 101 +++ 12 files changed, 1759 insertions(+), 499 deletions(-) create mode 100644 src/lib/printoversize.ts create mode 100644 src/pages/vorstellung.astro diff --git a/CHANGELOG.md b/CHANGELOG.md index 46eca48..8d4c261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to Klarbild are documented here. Newest first. +## 2026-08-20 — Oversize sheets, duplicate images, free labelling, new layout + +### Added +- **Prints larger than the sheet.** The everyday case is 20 × 30 cm on A4 photo paper, where + exactly 3 mm are missing. Instead of refusing, each cell now offers two ways out + (`src/lib/printoversize.ts`, dependency-free like `printlayout.ts` so browser and server compute + the same thing): + - `overflow` centres the piece on the **whole sheet**, not the printable area — otherwise you + throw away the margin a borderless printer can actually use. How much is lost per side is + computed up front and shown before printing. + - `tile` splits the piece across several sheets. Tiles overlap by a glue fold (10 mm by default), + printed on both neighbours and marked with a dashed line. Each sheet carries its number, row + and column. 30 × 40 cm on A4 → 2 × 2 sheets. + Verified by rasterising the PDFs at 254 dpi: the overflow case measures 200,1 mm wide at x = 5 mm + and bleeds off both long edges; the tiles measure 200 × 286 and 110 × 124 mm, overlapping by 10. +- **Duplicate an image.** The same picture can now appear as several cells and be printed in two + different sizes on one sheet. Previously you had to copy the file, rename it and upload it again. +- **Configurable label.** The footer line can be switched off, given custom text, and placed at one + of six positions. It is only drawn if that edge is actually free — it never lands on a picture. +- **Public presentation page** at `/vorstellung` (no login, nothing operable). The sample sheet on it + is not a drawing: it runs the real packer at render time, so it follows the algorithm. + +### Fixed +- **The tile number overlapped the picture.** The sheet number is printed at 8 pt from 3 mm off the + top edge, which needs about 6 mm — with a 5 mm paper margin it ran 0,8 mm into the motif. + `noteInset()`/`tileSheet()` now shift the tile down by the shortfall and shorten the sheet by the + same amount, so nothing sticks out at the bottom. Both the browser preview and the PDF call the + same helper, so the previewed sheet count cannot drift from the printed one. + +### Changed +- **Desktop layout.** Work on the left, preview on the right, and the preview column is sticky so it + follows along — no more endless column next to an empty one. Setting groups sit collapsed side by + side in a two-column grid and expand across the full width, so three-way switches keep their + labels instead of being squeezed to 50 px. +- **Mobile layout.** Preview first, "Druck-PDF erzeugen" as a fixed bar above the tab navigation, + each image row showing thumbnail and controls side by side, touch targets at 38–40 px, and the + remove button moved onto the thumbnail so it stops covering the format dropdown. The step numbers + ("1 ·", "2 ·") are hidden below 1080 px, where the preview comes first and the numbers would lie. +- Advanced setting groups (margins, quality, labelling, delivery) start collapsed — their summary + line already says what is set. + ## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed ### Fixed diff --git a/README.md b/README.md index e5bc69c..5e50321 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,18 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer. Alle Formate der KI-Generierung sind auch hier wählbar; passt das Bild nicht zum Format, entscheidet man je Bild zwischen **Zuschneiden** und **Rand lassen** (mit Randfarbe) — nie verzerren. + 86 Bildmaße in 10 Gruppen (Passbild bis 100 × 140 cm), 19 Papierformate. + **Größer als der Bogen** (20 × 30 cm auf A4): je Zelle `oversize` = `fit` (melden) · + `overflow` (mittig aufs ganze Blatt, Verlust wird vorab beziffert — hier genau 3 mm) · + `tile` (Posterdruck auf mehrere Blätter mit gestricheltem Klebefalz und Blattnummer). + **Beschriftung** frei: aus, automatisch oder eigener Text an einer von sechs Stellen; + sie entfällt automatisch, wenn an der Kante kein Platz frei ist. + Dasselbe Bild darf mehrfach als Zelle vorkommen („Duplizieren"), um es in zwei Maßen zu drucken. Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet), - `src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („🖨 Drucken") und MCP - (`exact_size`, `print_sheet`). + `src/lib/printoversize.ts` (Überstand & Kachelung, getestet), `src/lib/printrender.ts` + (sharp + pdf-lib). Browser-Vorschau und Server-PDF benutzen denselben Rechenkern. + Auch per Telegram („🖨 Drucken") und MCP (`exact_size`, `print_sheet`). + Öffentliche Vorstellungsseite ohne Login: `/vorstellung`. - **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose. ## Datenmodell / Migrationen @@ -52,7 +61,10 @@ Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichti `print_presets` (Bogen-Vorlagen, `013`). ## Tests -`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing, +`npm test` — Node-Testrunner mit nativem Type-Stripping (`node --experimental-strip-types`). +**Kein `tsx`, kein `playwright` in `package.json`** — der Docker-Build installiert devDependencies, +und deren native postinstall-Schritte haben den Deploy schon zweimal zerlegt. Testwerkzeug gehört +außerhalb des Projekts installiert. Deckt die Druck-Layoutmathematik ab (Maß-Parsing, mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit). ## Stolpersteine & Lösungen (Learnings) diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index 83981df..2a77a9e 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -1,14 +1,18 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'; import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper'; import { layout, cutMarks, recommendedGap, dpiCheck, type SheetSpec, type PlaceSpec, type MarkMode, } from '../lib/printlayout'; +import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize'; /* -------------------------------------------------------------------------- - Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit - Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern; - Pixel entstehen erst beim Rendern auf dem Server. + Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit + Schnittmarken auf einen Bogen setzen. + + Aufbau: links gearbeitet, rechts gesehen. Am Rechner steht die Vorschau + mitlaufend (sticky) neben der Arbeit, am Handy direkt unter den Bildern und + vor den Einstellungen — sie ist die einzige Rückmeldung, ob der Bogen stimmt. -------------------------------------------------------------------------- */ type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string }; @@ -18,59 +22,59 @@ type FitMode = 'cover' | 'contain'; interface Cell { id: string; name: string; - preview: string; // Data-URL (Upload) oder /api/items/…/file + preview: string; // verkleinerte Data-URL bzw. /api/items/… src?: SrcRef; uploading?: boolean; error?: string; - natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis) - sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom' + natW: number; natH: number; + sizeId: string; customSize: string; landscape: boolean; count: number; allowRotate: boolean; - fit: FitMode; // passt das Bild nicht: zuschneiden oder einpassen - bg: string; // Randfarbe beim Einpassen + fit: FitMode; + bg: string; + oversize: Oversize; + overlapMm: number; crop: CropRel; } -const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); +const SPEICHER = 'klarbild.drucken.v2'; +const MAX_BILDER = 30; -/** Farbwerte aus fremden Vorlagen landen in style — nur echte Hex-Werte zulassen. */ +const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); const safeColor = (v: unknown): string => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v : '#ffffff'; +/** „1 Bogen", „2 Bogen", „1 Bild", „5 Bilder" — Einzahl ist keine Kleinigkeit. */ +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) ---------- */ -/** Größter Ausschnitt im Zielverhältnis — füllt das Format aus, schneidet ab. */ 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'); } - -/** Kleinster Ausschnitt im Zielverhältnis, der das ganze Bild enthält — es bleibt Rand. */ 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 }; }; - -/** Basisausschnitt (Zoomstufe 1) für die gewählte Regel. */ 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') { - // Beim Einpassen darf der Ausschnitt über das Bild hinausragen — dort ist der Rand. - const w = Math.min(40, Math.max(0.02, c.w)); - const h = Math.min(40, Math.max(0.02, c.h)); + 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)) }; @@ -83,9 +87,50 @@ function clampCrop(c: CropRel, fit: FitMode = 'cover'): CropRel { const sizeOfCell = (c: Cell): { w: number; h: number } | null => { const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId); if (!base) return null; - const s = { w: base.w, h: base.h }; - return c.landscape ? { w: s.h, h: s.w } : s; + return c.landscape ? { w: base.h, h: base.w } : { w: base.w, h: base.h }; }; +const aspectOf = (c: Cell): number => { const s = sizeOfCell(c); return s ? s.w / s.h : 1; }; + +/** + * Platziert das Bild so im Rahmen, dass genau `c` sichtbar ist. Die Höhe kommt + * aus dem echten Seitenverhältnis des Bildes — dadurch kann nie etwas verzerren. + */ +function cropStyle(c: CropRel, natW?: number, natH?: number): React.CSSProperties { + return { + position: 'absolute', + width: `${100 / c.w}%`, + ...(natW && natH ? { aspectRatio: `${natW} / ${natH}`, height: 'auto' as const } : { height: `${100 / c.h}%` }), + left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`, + }; +} + +function newCell(id: string, name: string): Cell { + return { + id, name, preview: '', uploading: true, natW: 1000, natH: 1500, + sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true, + fit: 'cover', bg: '#ffffff', oversize: 'fit', overlapMm: 10, + crop: { x: 0, y: 0, w: 1, h: 1 }, + }; +} + +/** Vorschau klein rechnen — 30 Handyfotos als volle Data-URLs sprengen den Tab. */ +function shrink(dataUrl: string, maxKante = 700): Promise<{ url: string; w: number; h: number }> { + return new Promise((res) => { + const img = new Image(); + img.onload = () => { + const k = Math.min(1, maxKante / Math.max(img.naturalWidth, img.naturalHeight)); + if (k >= 1) return res({ url: dataUrl, w: img.naturalWidth, h: img.naturalHeight }); + const cv = document.createElement('canvas'); + cv.width = Math.round(img.naturalWidth * k); cv.height = Math.round(img.naturalHeight * k); + cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height); + res({ url: cv.toDataURL('image/jpeg', 0.72), w: img.naturalWidth, h: img.naturalHeight }); + }; + img.onerror = () => res({ url: dataUrl, w: 0, h: 0 }); + img.src = dataUrl; + }); +} + +/* ========================================================================== */ export default function PrintApp() { const [cells, setCells] = useState([]); @@ -94,29 +139,36 @@ export default function PrintApp() { const [paperLandscape, setPaperLandscape] = useState(false); const [marginMm, setMarginMm] = useState(5); const [autoGap, setAutoGap] = useState(true); - const [gapMm, setGapMm] = useState(12); + const [gapMm, setGapMm] = useState(8); const [bleedMm, setBleedMm] = useState(0); const [marks, setMarks] = useState('corner'); - const [markLen, setMarkLen] = useState(4); - const [markOff, setMarkOff] = useState(3); + const [markLen, setMarkLen] = useState(3); + const [markOff, setMarkOff] = useState(1); const [dpi, setDpi] = useState(300); const [ext, setExt] = useState<'jpg' | 'png'>('jpg'); const [center, setCenter] = useState(true); - const [footer, setFooter] = useState(true); - const [deliverTo, setDeliverTo] = useState(''); // '' = nur herunterladen + const [footerOn, setFooterOn] = useState(true); + const [footerText, setFooterText] = useState(''); + const [footerPos, setFooterPos] = useState('unten-links'); + const [deliverTo, setDeliverTo] = useState(''); const [gallery, setGallery] = useState(''); - const [targets, setTargets] = useState([]); + const [targets, setTargets] = useState([]); const [editing, setEditing] = useState(null); const [picker, setPicker] = useState(false); const [presets, setPresets] = useState([]); const [presetSel, setPresetSel] = useState(''); const [busy, setBusy] = useState(null); - const [toast, setToast] = useState(null); + const [toast, setToast] = useState<{ text: string; bleibt?: boolean } | null>(null); const [dragging, setDragging] = useState(false); + const [bogen, setBogen] = useState(0); const fileRef = useRef(null); + const geladen = useRef(false); - const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); }; + const notify = (text: string, bleibt = false) => { + setToast({ text, bleibt }); + if (!bleibt) setTimeout(() => setToast(null), 3200); + }; /* ---------------- Bogen ---------------- */ const paper = useMemo(() => { @@ -126,41 +178,117 @@ export default function PrintApp() { }, [paperId, paperCustom, paperLandscape]); const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm; - const sheet: SheetSpec | null = paper ? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center } : null; + const nutzflaeche = sheet + ? { w: sheet.wMm - 2 * (marginMm + bleedMm), h: sheet.hMm - 2 * (marginMm + bleedMm) } + : null; + + /** Passt die Zelle auf den Bogen? Liefert auch, was die Auswege kosten. */ + const passt = useCallback((c: Cell) => { + const s = sizeOfCell(c); + if (!s || !sheet) return { ok: true } as const; + if (fitsOnSheet(s.w, s.h, sheet, c.allowRotate)) return { ok: true } as const; + const of = overflowPlacement(c.id, 1, s.w, s.h, sheet, c.allowRotate); + const tp = tilePlan(s.w, s.h, tileSheet(sheet), c.overlapMm); + return { ok: false, size: s, verlust: of.lostMm, blaetter: tp.tiles.length, cols: tp.cols, rows: tp.rows } as const; + }, [sheet]); const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => { const s = sizeOfCell(c); if (!s || c.error || !c.src) return []; + if (sheet && !fitsOnSheet(s.w, s.h, sheet, c.allowRotate) && c.oversize !== 'fit') return []; return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }]; - }), [cells]); + }), [cells, sheet]); const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]); const marksPerPage = useMemo(() => (plan && sheet) ? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm })) : [], [plan, sheet, marks, markLen, markOff, bleedMm]); - /* ---------------- Upload ---------------- */ + /** Zusatzbogen aus übergroßen Bildern — nur zur Anzeige der Gesamtzahl. */ + const extraBogen = useMemo(() => cells.reduce((n, c) => { + const p = passt(c); + if (p.ok || c.oversize === 'fit') return n; + return n + (c.oversize === 'tile' ? p.blaetter * c.count : c.count); + }, 0), [cells, passt]); + + const bogenGesamt = (plan?.pages.length || 0) + extraBogen; + const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c)); + const stueckGesamt = ready.reduce((n, c) => n + c.count, 0); + const gedreht = plan?.pages.some((pg) => pg.placements.some((p) => p.rotated)); + const kannDrucken = !!(plan?.pages.length || extraBogen); + + useEffect(() => { if (bogen >= Math.max(1, plan?.pages.length || 0)) setBogen(0); }, [plan, bogen]); + + /* ---------------- Zwischenspeicher ---------------- */ + useEffect(() => { + try { + const roh = localStorage.getItem(SPEICHER); + if (roh) { + const s = JSON.parse(roh); + if (s.paperId) setPaperId(s.paperId); + if (s.paperCustom != null) setPaperCustom(s.paperCustom); + setPaperLandscape(!!s.paperLandscape); + if (s.marginMm != null) setMarginMm(s.marginMm); + setAutoGap(s.autoGap !== false); + if (s.gapMm != null) setGapMm(s.gapMm); + if (s.bleedMm != null) setBleedMm(s.bleedMm); + if (s.marks) setMarks(s.marks); + if (s.markLen != null) setMarkLen(s.markLen); + if (s.markOff != null) setMarkOff(s.markOff); + if (s.dpi) setDpi(s.dpi); + if (s.ext) setExt(s.ext); + setCenter(s.center !== false); + setFooterOn(s.footerOn !== false); + if (s.footerText != null) setFooterText(s.footerText); + if (s.footerPos) setFooterPos(s.footerPos); + } + } catch { /* egal */ } + geladen.current = true; + }, []); + + useEffect(() => { + if (!geladen.current) return; + const t = setTimeout(() => { + try { + localStorage.setItem(SPEICHER, JSON.stringify({ + paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, + marks, markLen, markOff, dpi, ext, center, footerOn, footerText, footerPos, + })); + } catch { /* Speicher voll — nicht schlimm */ } + }, 400); + return () => clearTimeout(t); + }, [paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, + marks, markLen, markOff, dpi, ext, center, footerOn, footerText, footerPos]); + + /* ---------------- Bilder ---------------- */ const addFiles = useCallback(async (files: FileList | File[]) => { - const arr = Array.from(files) - .filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)) - .slice(0, 30); - for (const f of arr) { + const alle = Array.from(files); + const bilder = alle.filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)); + if (alle.length > bilder.length) + notify(`${zahl(alle.length - bilder.length, 'Datei', 'Dateien')} übersprungen — nur Bilder (JPG, PNG, WEBP, HEIC).`); + if (bilder.length > MAX_BILDER) + notify(`Nur die ersten ${MAX_BILDER} Bilder übernommen (${bilder.length} ausgewählt).`); + + for (const f of bilder.slice(0, MAX_BILDER)) { const id = uid(); + setCells((p) => [...p, newCell(id, f.name)]); const reader = new FileReader(); - reader.onload = () => { - const url = reader.result as string; - const probe = new Image(); - probe.onload = () => setCells((p) => p.map((x) => x.id === id - ? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight, - crop: baseCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x), x.fit) } - : x)); - probe.src = url; + reader.onload = async () => { + const { url, w, h } = await shrink(reader.result as string); + setCells((p) => p.map((x) => { + if (x.id !== id) return x; + // Querformat-Foto auch quer anlegen — sonst schneidet „Hoch" Kopf und Füße ab. + const landscape = w > 0 && h > 0 ? w > h : x.landscape; + const next = { ...x, preview: url, natW: w || x.natW, natH: h || x.natH, landscape }; + next.crop = baseCrop(next.natW, next.natH, aspectOf(next), next.fit); + return next; + })); }; reader.readAsDataURL(f); - setCells((p) => [...p, newCell(id, f.name)]); + const fd = new FormData(); fd.append('files', f); try { const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json()); @@ -178,12 +306,13 @@ export default function PrintApp() { useEffect(() => { const onPaste = (e: ClipboardEvent) => { + if (editing || picker) return; const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); if (imgs.length) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean)); }; window.addEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste); - }, [addFiles]); + }, [addFiles, editing, picker]); useEffect(() => { loadPresets(); @@ -199,8 +328,9 @@ export default function PrintApp() { ...newCell(id, it.filename || 'Bild'), uploading: false, src: { kind: 'item', id: it.id }, - preview: `/api/items/${it.id}/file?preview=1`, + preview: `/api/items/${it.id}/file?thumb=1`, natW: w || 2000, natH: h || 3000, + landscape: !!(w && h && w > h), }; c.crop = baseCrop(c.natW, c.natH, aspectOf(c), c.fit); setCells((p) => [...p, c]); @@ -209,7 +339,6 @@ export default function PrintApp() { const patch = (id: string, up: Partial) => setCells((p) => p.map((c) => { if (c.id !== id) return c; const next = { ...c, ...up }; - // Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen. if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined || up.fit !== undefined) { const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, c.crop); @@ -217,34 +346,50 @@ export default function PrintApp() { return next; })); + /** Dasselbe Bild ein zweites Mal — für zwei Formate ohne erneutes Hochladen. */ + const duplicate = (id: string) => setCells((p) => { + const i = p.findIndex((c) => c.id === id); + if (i < 0) return p; + const kopie: Cell = { ...p[i], id: uid() }; + const next = [...p]; next.splice(i + 1, 0, kopie); + return next; + }); + /* ---------------- Ausgabe ---------------- */ const body = () => ({ paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId }, landscape: paperLandscape, - marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer, - ...(deliverTo ? { deliver: { target: deliverTo === 'picdrop' ? '' : deliverTo, gallery: gallery.trim() || undefined } } : {}), + marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, + footer: footerOn ? { text: footerText.trim() || undefined, position: footerPos } : false, marks: { mode: marks, lengthMm: markLen, offsetMm: markOff }, - cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => { + ...(deliverTo ? { deliver: { target: deliverTo === 'picdrop' ? '' : deliverTo, gallery: gallery.trim() || undefined } } : {}), + cells: ready.map((c) => { const s = sizeOfCell(c)!; - return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, - allowRotate: c.allowRotate, fit: c.fit, bg: c.bg }; + return { + id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, + allowRotate: c.allowRotate, fit: c.fit, bg: c.bg, + oversize: c.oversize, overlapMm: c.overlapMm, + }; }), }); const makePdf = async () => { - if (!plan?.pages.length) return; + if (!kannDrucken) return; setBusy('pdf'); try { const res = await fetch('/api/print/sheet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body()), }); - if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; } + if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; } const blob = await res.blob(); - download(blob, `klarbild-druckbogen-${new Date().toISOString().slice(0, 10)}.pdf`); - const dmsgRaw = res.headers.get('X-Klarbild-Delivery-Msg'); - const dmsg = dmsgRaw ? ` ${decodeURIComponent(dmsgRaw)}` : ''; - notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.${dmsg}`); - } catch { notify('Netzwerkfehler.'); } finally { setBusy(null); } + download(blob, `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`); + const n = Number(res.headers.get('X-Klarbild-Pages') || 0); + const hinweis = res.headers.get('X-Klarbild-Notes'); + const liefer = res.headers.get('X-Klarbild-Delivery-Msg'); + notify(`PDF fertig · ${zahl(n, 'Bogen', 'Bogen')}. Beim Drucken „Tatsächliche Größe / 100 %" wählen.` + + (hinweis ? ` ${decodeURIComponent(hinweis)}` : '') + + (liefer ? ` ${decodeURIComponent(liefer)}` : ''), !!hinweis); + } catch { notify('Netzwerkfehler.', true); } finally { setBusy(null); } }; const makeSingle = async (c: Cell) => { @@ -255,24 +400,25 @@ export default function PrintApp() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }), }); - if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; } - const blob = await res.blob(); - download(blob, `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`); + if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; } + download(await res.blob(), `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`); const real = res.headers.get('X-Klarbild-Real-Dpi'); notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0' ? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.` - : `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`); - } catch { notify('Netzwerkfehler.'); } finally { setBusy(null); } + : `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`, + res.headers.get('X-Klarbild-Dpi-Ok') === '0'); + } catch { notify('Netzwerkfehler.', true); } finally { setBusy(null); } }; + /* ---------------- Vorlagen ---------------- */ const savePreset = async () => { - const name = prompt('Name der Vorlage? (z. B. „Kita-Satz" oder „8 × Passbild")'); + const name = prompt('Name der Vorlage? (z. B. „Kita-Satz")'); if (!name?.trim()) return; const config = { paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, - marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery, + marks, markLen, markOff, dpi, ext, center, footerOn, footerText, footerPos, deliverTo, gallery, formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, - count: c.count, allowRotate: c.allowRotate, fit: c.fit, bg: c.bg })), + count: c.count, allowRotate: c.allowRotate, fit: c.fit, bg: c.bg, oversize: c.oversize })), }; await fetch('/api/print/presets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -281,22 +427,27 @@ export default function PrintApp() { notify('Vorlage gespeichert.'); loadPresets(); }; - const applyPreset = (p: any) => { + const applyPreset = (p: any, fragen = true) => { const c = p?.config; if (!c) return; + if (fragen && cells.length && + !confirm(`„${p.name}" übernehmen? Deine jetzigen Format- und Bogen-Einstellungen werden ersetzt.`)) { + setPresetSel(''); return; + } setPaperId(c.paperId ?? 'A4'); setPaperCustom(c.paperCustom ?? ''); setPaperLandscape(!!c.paperLandscape); - setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 12); - setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 4); setMarkOff(c.markOff ?? 3); - setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); setFooter(c.footer !== false); + setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 8); + setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 3); setMarkOff(c.markOff ?? 1); + setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); + setFooterOn(c.footerOn !== false); setFooterText(c.footerText ?? ''); setFooterPos(c.footerPos ?? 'unten-links'); setDeliverTo(c.deliverTo ?? ''); setGallery(c.gallery ?? ''); - // Formate anwenden. Der typische Fall ist ein Bild und mehrere Größen - // („Kita-Satz": 1× 13×18, 2× 9×13, 8× Passbild) — dann wird das Bild geklont. + if (Array.isArray(c.formats) && c.formats.length) { setCells((prev) => { if (!prev.length) return prev; const apply = (cell: Cell, f: any): Cell => { - const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '', + const next: Cell = { ...cell, sizeId: f.sizeId, customSize: f.customSize || '', landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false, - fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: safeColor(f.bg) }; + fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: safeColor(f.bg), + oversize: (f.oversize === 'overflow' || f.oversize === 'tile') ? f.oversize : 'fit' }; const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, cell.crop); return next; }; @@ -312,285 +463,357 @@ export default function PrintApp() { const deletePreset = async (id: string) => { if (!id || !confirm('Vorlage löschen?')) return; - await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' }); + const r = await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' }); + if (!r.ok) notify((await r.json().catch(() => ({}))).error || 'Konnte nicht gelöscht werden.', true); setPresetSel(''); loadPresets(); }; const editCell = cells.find((c) => c.id === editing) || null; - const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c)); - const totalPieces = ready.reduce((n, c) => n + c.count, 0); + const grundFuerGrau = !cells.length ? 'Noch kein Bild ausgewählt.' + : !sheet ? 'Papiermaß fehlt.' + : !ready.length ? 'Kein Bild ist fertig — bitte Maß prüfen.' + : !kannDrucken ? 'Kein Bild passt auf den Bogen.' : null; + /* ================================ Ansicht =============================== */ return (
- {/* ---------- 1 · Bilder ---------- */} -
-
- 1 · Bilder -
- - {cells.length > 0 && } -
-
-
- {cells.length === 0 ? ( -
fileRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setDragging(true); }} - onDragLeave={() => setDragging(false)} - onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}> - -

Bilder hierher ziehen, ⌘V einfügen oder klicken.
- Kein KI-Schritt — nur zuschneiden, skalieren, setzen.

-
- ) : ( -
- {cells.map((c) => patch(c.id, u)} - onEdit={() => setEditing(c.id)} - onSingle={() => makeSingle(c)} - onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)} - -
- )} - e.target.files && addFiles(e.target.files)} /> -
-
- -
- {/* ---------- 2 · Bogen ---------- */} -
-
2 · Bogen & Schnitt - -
-
-
- -
- - {presetSel && } -
-
- -
- - - {paperId === 'custom' && ( - setPaperCustom(e.target.value)} - placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" /> +
+ {/* ---------- links: Bilder + Einstellungen ---------- */} +
+ + + {cells.length > 0 && ( + )} -
- - -
- {paper &&
Bogen {labelMm(paper.w, paper.h)} — die PDF-Seite hat exakt dieses Maß.
} -
- -
- -
- - - -
-
- {marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.' - : marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.' - : 'Keine Linien — Kanten selbst anlegen.'} -
- {marks === 'corner' && marginMm < markOff + markLen && ( -
Bei {marginMm} mm Rand ist außerhalb der Bilder kein Platz für die Marken — - sie werden abgeschnitten. Rand auf mindestens {Math.ceil(markOff + markLen)} mm setzen.
- )} - {marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && ( -
Durchgehende Linien passen nur zu einem Bildmaß. Bei gemischten Größen - werden sie unterbrochen — ein Schnitt über das ganze Blatt würde andere Bilder treffen. Besser Eckmarken.
- )} - {marks === 'corner' && ( -
- - + + }> +
+ {cells.length === 0 ? ( + <> + + {presets.length > 0 && ( +
+ Oder mit einer Vorlage beginnen +
+ {presets.slice(0, 4).map((p) => ( + + ))} +
+
Vorlage wählen, dann ein Bild hinzufügen — fertig.
+
+ )} + + ) : ( +
+ {cells.map((c) => ( + patch(c.id, u)} + onEdit={() => setEditing(c.id)} + onSingle={() => makeSingle(c)} + onDuplicate={() => duplicate(c.id)} + onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} /> + ))} +
)} + { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} />
+ -
- -
- - - + +
+ +
+ + + {presetSel && } +
+
+ + + + {paperId === 'custom' && ( + <> + setPaperCustom(e.target.value)} + placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" aria-label="Eigenes Papiermaß" /> + {paperCustom && !paper &&
Maß nicht erkannt. Beispiele: „21x29,7", „329x483mm".
} + + )} +
+ + +
+ {paper && nutzflaeche && ( +
Bogen {labelMm(paper.w, paper.h)} · nutzbar {labelMm(nutzflaeche.w, nutzflaeche.h)} — + die PDF-Seite hat exakt das Bogenmaß.
+ )} +
+ +
+ +
+ + + +
+
+ {marks === 'corner' ? 'Feine Marken außerhalb jedes Bildes — du schneidest zwischen den Marken entlang.' + : marks === 'grid' ? 'Durchgehende Linien über den ganzen Bogen — fürs Schneidelineal.' + : 'Keine Linien — Kanten selbst anlegen.'} +
+ {marks === 'corner' && ( + <> +
+ + setMarkLen(Number(e.target.value))} /> + + + setMarkOff(Number(e.target.value))} /> + +
+ {marginMm < markOff + markLen && ( +
Bei {mm(marginMm)} mm Rand ist außen kein Platz für die Marken — + sie werden abgeschnitten. Rand auf mindestens {mm(Math.ceil(markOff + markLen))} mm setzen.
+ )} + + )} + {marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && ( +
Durchgehende Linien passen nur zu einem Bildmaß. Bei gemischten + Größen werden sie unterbrochen — besser Eckmarken.
+ )} +
+ + +
+ + + +
+
+ + setMarginMm(Number(e.target.value))} /> + + + setBleedMm(Number(e.target.value))} /> + +
+
Der Rand ist die Zone, die dein Drucker nicht bedrucken kann — randlos: 0, sonst 3–5 mm. + Die Beschnittzugabe ist ein Zusatzrand, der über das Endmaß hinaus mitgedruckt und dann weggeschnitten wird; + zu Hause meist 0.
+ + Abstand automatisch ({mm(gapEff)} mm — passend zu den Marken) + + {!autoGap && ( + setGapMm(Number(e.target.value))} aria-label="Abstand zwischen den Bildern in mm" /> + )} + Auf dem Bogen zentrieren +
+ + +
+ + + + + + +
+
+ + + Kleine Zeile mit Maßen und Druckhinweis + {footerOn && ( + <> + setFooterText(e.target.value)} + placeholder="Eigener Text (leer = Maße, dpi und Druckhinweis)" aria-label="Text der Beschriftung" /> + + + +
Die Zeile entfällt automatisch, wenn an der gewählten Kante kein Platz frei ist — + sie wird nie über ein Bild gedruckt.
+ + )} +
+ + + + {deliverTo && ( + setGallery(e.target.value)} + placeholder="Galerie/Ordner (leer = Standard)" aria-label="Galerie oder Ordner" /> + )} +
-
- - -
- - {!autoGap && ( - setGapMm(Number(e.target.value))} /> - )} - -
Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.
+
+
-
- -
- - -
- + {/* ---------- rechts: Vorschau (läuft am Rechner mit) ---------- */} +
+ {zahl(bogenGesamt, 'Bogen', 'Bogen')} · {zahl(stueckGesamt, 'Bild', 'Bilder')} : null + }> +
+ {!sheet ?

Bitte ein gültiges Papiermaß angeben.

+ : !cells.length ?

Noch nichts zu zeigen — links ein Bild hinzufügen.

+ : !plan?.pages.length ? ( + + ) : ( + <> + {plan.pages.length > 1 && ( +
+ + Bogen {bogen + 1} / {plan.pages.length} + +
+ )} + +
+ {zahl(plan.pages[Math.min(bogen, plan.pages.length - 1)].placements.length, 'Bild', 'Bilder')} auf diesem Bogen + {extraBogen > 0 && ` · dazu ${zahl(extraBogen, 'Extrabogen', 'Extrabogen')} für übergroße Bilder`} +
+ {gedreht && ( +

Einige Bilder liegen quer, damit mehr auf den Bogen passt — nach dem Schneiden + stehen sie richtig herum. Abschaltbar mit „Zum Platzsparen drehen".

+ )} + + )} + {plan?.unplaced?.length ? ( +

Nicht platzierbar: {plan.unplaced.map((u) => { + const c = cells.find((x) => x.id === u.specId); + return `${c?.name || u.specId} (${u.reason})`; + }).join(', ')}

+ ) : null}
+
-
- - - {deliverTo && ( - setGallery(e.target.value)} - placeholder="Galerie/Ordner (leer = Standard)" /> - )} -
Der fertige Bogen wird als PDF geladen — auf Wunsch zusätzlich ins gewohnte Ziel gelegt.
-
- - -
Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".
+
+ {grundFuerGrau + ? grundFuerGrau + : <>Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".} +
-
- - {/* ---------- 3 · Vorschau ---------- */} -
-
- 3 · Vorschau - {plan && {plan.pages.length} Bogen · {totalPieces} Bilder} -
-
- {!sheet ?

Bitte ein gültiges Papiermaß angeben.

- : !plan || !plan.pages.length ?

Noch nichts zu zeigen — Bilder hinzufügen und Formate wählen.

- : plan.pages.map((pg, i) => ( -
-
- {pg.placements.map((p, k) => { - const c = cells.find((x) => x.id === p.specId); - if (!c) return null; - return ( -
-
- -
-
- ); - })} - - {(marksPerPage[i] || []).map((l, k) => ( - - ))} - -
-
Bogen {i + 1} · {pg.placements.length} Bilder
-
- ))} - {plan?.unplaced?.length ? ( -

Nicht platzierbar: {plan.unplaced.map((u) => { - const c = cells.find((x) => x.id === u.specId); - return `${c?.name || u.specId} (${u.reason})`; - }).join(', ')}

- ) : null} -
-
+
{editCell && setEditing(null)} onChange={(crop) => patch(editCell.id, { crop })} />} {picker && setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />} - {toast &&
{toast}
} + {toast && ( +
+ {toast.text} + {toast.bleibt && } +
+ )} ); } -/* ---------------- Bausteine ---------------- */ +/* ---------------- kleine Bausteine ---------------- */ -function newCell(id: string, name: string): Cell { - return { - id, name, preview: '', uploading: true, natW: 1000, natH: 1500, - sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true, - fit: 'cover', bg: '#ffffff', crop: { x: 0, y: 0, w: 1, h: 1 }, - }; +function Karte({ nr, titel, aktionen, children }: + { nr: string; titel: string; aktionen?: React.ReactNode; children: React.ReactNode }) { + const id = useId(); + return ( +
+
+

{nr} ·

+ {aktionen &&
{aktionen}
} +
+ {children} +
+ ); } -function aspectOf(c: Cell): number { - const s = sizeOfCell(c); - return s ? s.w / s.h : 1; + +const Feld = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
{children}
+); + +const Mini = ({ label, children }: { label: string; children: React.ReactNode }) => ( + +); + +const Check = ({ checked, onChange, children }: + { checked: boolean; onChange: (v: boolean) => void; children: React.ReactNode }) => ( + +); + +/** Einstellungsgruppe — am Handy zugeklappt, am Rechner offen. */ +function Gruppe({ titel, kurz, zu, children }: + { titel: string; kurz?: string; zu?: boolean; children: React.ReactNode }) { + return ( +
+ {kurz && {kurz}} +
{children}
+
+ ); } -/** - * Platziert das Bild so im Rahmen, dass genau `c` sichtbar ist. - * Die Höhe kommt aus dem **echten** Seitenverhältnis des Bildes (`aspect-ratio`), - * nicht aus einem zweiten Prozentwert — dadurch kann das Bild nie verzerren, - * auch wenn der Rahmen einmal nicht exakt das Zielverhältnis hat. - */ -function cropStyle(c: CropRel, natW?: number, natH?: number): React.CSSProperties { - return { - position: 'absolute', - width: `${100 / c.w}%`, - ...(natW && natH - ? { aspectRatio: `${natW} / ${natH}`, height: 'auto' as const } - : { height: `${100 / c.h}%` }), - left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`, - }; -} -function groupBy(list: T[], _k: 'group'): [string, T[]][] { + +function groupBy(list: T[]): [string, T[]][] { const m = new Map(); for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); } return [...m.entries()]; @@ -602,26 +825,103 @@ function download(blob: Blob, name: string) { setTimeout(() => URL.revokeObjectURL(url), 4000); } -function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: { - cell: Cell; dpi: number; - onPatch: (u: Partial) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void; +/* ---------------- Bogen-Vorschau ---------------- */ + +function BogenBild({ sheet, page, marks, cells }: + { sheet: SheetSpec; page: { placements: any[] }; marks: any[]; cells: Cell[] }) { + return ( +
+
+ {page.placements.map((p, k) => { + const c = cells.find((x) => x.id === p.specId); + if (!c) return null; + return ( +
+
+ +
+ {p.w >= 28 && p.h >= 18 && {labelMm(p.w, p.h)}} +
+ ); + })} + +
+
+ ); +} + +/** Wenn nichts passt: den Grund in Zentimetern nennen und einen Ausweg anbieten. */ +function NichtsPasst({ cells, nutzflaeche, paperId, extraBogen, onPaper }: { + cells: Cell[]; nutzflaeche: { w: number; h: number } | null; paperId: string; + extraBogen: number; onPaper: (id: string) => void; +}) { + if (extraBogen > 0) return ( +

Alle Bilder sind übergroß und bekommen eigene Blätter — + {' '}{zahl(extraBogen, 'Blatt', 'Blätter')}. Das PDF kannst du trotzdem erzeugen.

+ ); + const zuGross = cells.map((c) => ({ c, s: sizeOfCell(c) })).filter((x) => x.s); + if (!zuGross.length) return

Noch kein gültiges Bildmaß gewählt.

; + + const groesser = PAPERS.filter((p) => nutzflaeche && p.w * p.h > (nutzflaeche.w + 10) * (nutzflaeche.h + 10)) + .sort((a, b) => a.w * a.h - b.w * b.h)[0]; + return ( +
+

Passt nicht auf den Bogen.{' '} + {zuGross.map((x) => labelMm(x.s!.w, x.s!.h)).join(', ')} bei nutzbaren{' '} + {nutzflaeche ? labelMm(nutzflaeche.w, nutzflaeche.h) : '—'}.

+

Drei Wege: größeres Papier, kleineres Bildmaß — oder beim Bild links + „Überstehen lassen" bzw. „Auf mehrere Blätter" wählen.

+ {groesser && paperId !== groesser.id && ( + + )} +
+ ); +} + +/* ---------------- Bildkarte ---------------- */ + +function CellCard({ cell, dpi, sheet, passt, paperLabel, onPatch, onEdit, onSingle, onDuplicate, onRemove }: { + cell: Cell; dpi: number; sheet: SheetSpec | null; paperLabel: string; + passt: { ok: boolean; size?: { w: number; h: number }; verlust?: number; blaetter?: number; cols?: number; rows?: number }; + onPatch: (u: Partial) => void; onEdit: () => void; onSingle: () => void; + onDuplicate: () => void; onRemove: () => void; }) { const s = sizeOfCell(cell); - const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null; + const quelle = s ? cell.crop.w * cell.natW : 0; + const echt = s && s.w > 0 ? Math.round(quelle / (s.w / 25.4)) : 0; + const stufe = !s ? null : echt >= dpi * 0.9 ? 'ok' : echt >= dpi * 0.6 ? 'knapp' : 'schlecht'; + const bearbeitbar = !cell.uploading && !cell.error; + return (
- -
!cell.uploading && onEdit()}> + +
+ {bearbeitbar && Zuschnitt} + +
- onPatch({ sizeId: e.target.value })}> + {groupBy(PHOTO_SIZES).map(([g, list]) => ( {list.map((p) => )} @@ -629,21 +929,26 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: { {cell.sizeId === 'custom' && ( - onPatch({ customSize: e.target.value })} - placeholder="12x15 · 35x45mm · 4:3/15" /> + <> + onPatch({ customSize: e.target.value })} placeholder="13x18 · 35x45mm · 4:3/15" /> + {!s &&
Maß nicht erkannt. Beispiele: „13x18" (cm), „35x45mm", „4:3/15".
} + )} +
- - onPatch({ count: Math.max(1, cell.count - 1) })} aria-label="Eins weniger">− + onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} /> - +
+
@@ -655,46 +960,92 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: { Randfarbe {['#ffffff', '#000000', '#EAEAE3'].map((col) => (
)} + - {cell.error ?
{cell.error}
: s && ( -
- {labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px - {check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)} + + {/* Übergroß: den Ausweg direkt am Problem anbieten */} + {!passt.ok && s && ( +
+ {labelMm(s.w, s.h)} passt nicht auf {paperLabel}. +
+ + +
+
+ {cell.oversize === 'overflow' + ? `Das Bild liegt mittig auf dem Blatt; ${mm(passt.verlust || 0)} mm ragen hinaus und fehlen hinterher.` + : cell.oversize === 'tile' + ? `Poster aus ${passt.blaetter} Blättern mit ${mm(cell.overlapMm)} mm Klebefalz — die gestrichelte Linie zeigt, wo überlappt wird.` + : 'Wähle einen Weg, sonst bleibt das Bild außen vor.'} +
+ {cell.oversize === 'tile' && ( + + onPatch({ overlapMm: Math.min(40, Math.max(0, Number(e.target.value) || 0)) })} /> + + )}
)} - + + {cell.error ? ( + <> +
{cell.error}
+ + + ) : s && ( +
+ {labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px + {stufe === 'ok' ? ' · Auflösung reicht' + : stufe === 'knapp' ? ` · nur ${echt} dpi — wird leicht weich` + : ` · nur ${echt} dpi — wird sichtbar unscharf, kleineres Format oder größeres Bild nehmen`} +
+ )} + +
+ + +
); } -/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */ +/* ---------------- Zuschnitt ---------------- */ + function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) { const s = sizeOfCell(cell); const aspect = s ? s.w / s.h : 1; const base = baseCrop(cell.natW, cell.natH, aspect, cell.fit); const [crop, setCrop] = useState(cell.crop); const boxRef = useRef(null); + const modalRef = useRef(null); const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null); + const titelId = useId(); - const zoom = Math.min(20, Math.max(1, base.w / crop.w)); + const zoom = Math.min(6, Math.max(1, base.w / crop.w)); const setZoom = (z: number) => { - const zz = Math.min(20, Math.max(1, z)); + 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)); }; const onDown = (e: React.PointerEvent) => { - (e.target as Element).setPointerCapture?.(e.pointerId); + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, crop }; }; const onMove = (e: React.PointerEvent) => { @@ -707,57 +1058,71 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop }; const onUp = () => { drag.current = null; }; - // Wohin lässt sich das Bild überhaupt schieben? Bei „Bild füllen" liegt es in - // einer Achse exakt am Anschlag — dort passiert beim Ziehen nichts, und das - // sieht wie ein Fehler aus, wenn man es nicht dazuschreibt. const freiX = crop.w < 1 - 1e-4 || cell.fit === 'contain'; const freiY = crop.h < 1 - 1e-4 || cell.fit === 'contain'; const richtung = freiX && freiY ? 'in alle Richtungen verschiebbar' - : freiX ? 'nach links und rechts verschiebbar — zum Verschieben nach oben/unten erst hineinzoomen' - : freiY ? 'nach oben und unten verschiebbar — zum Verschieben nach links/rechts erst hineinzoomen' + : freiX ? 'nach links und rechts verschiebbar — für oben/unten erst hineinzoomen' + : freiY ? 'nach oben und unten verschiebbar — für links/rechts erst hineinzoomen' : 'genau passend — zum Verschieben erst hineinzoomen'; - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') return onClose(); - 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)); - }; - if (e.key === 'ArrowLeft') move(-step, 0); - else if (e.key === 'ArrowRight') move(step, 0); - else if (e.key === 'ArrowUp') move(0, -step); - else if (e.key === 'ArrowDown') move(0, step); - else if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(zoom * 1.15); } - else if (e.key === '-') { e.preventDefault(); setZoom(zoom / 1.15); } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose, cell.fit, zoom]); + // Übernehmen statt verwerfen: der Zuschnitt ist verlustfrei und jederzeit korrigierbar. + const schliessen = () => { onChange(crop); onClose(); }; - const apply = () => { onChange(crop); onClose(); }; + // Fokus einfangen, Hintergrund festhalten, Tasten nur hier drin. + useEffect(() => { + const y = window.scrollY; + const vorher = document.activeElement as HTMLElement | null; + document.body.style.overflow = 'hidden'; + modalRef.current?.querySelector('button, input')?.focus(); + return () => { document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); }; + }, []); + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); return schliessen(); } + if (e.key === 'Tab') { + const f = modalRef.current?.querySelectorAll('button, input, [href], select, textarea'); + if (!f?.length) return; + const erste = f[0], letzte = f[f.length - 1]; + if (e.shiftKey && document.activeElement === erste) { e.preventDefault(); letzte.focus(); } + else if (!e.shiftKey && document.activeElement === letzte) { e.preventDefault(); erste.focus(); } + return; + } + const ziel = e.target as HTMLElement; + if (ziel.tagName === 'INPUT' && (ziel as HTMLInputElement).type !== 'range') return; + 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)); + }; + if (e.key === 'ArrowLeft') move(-step, 0); + else if (e.key === 'ArrowRight') move(step, 0); + else if (e.key === 'ArrowUp') move(0, -step); + else if (e.key === 'ArrowDown') move(0, step); + else if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(zoom * 1.15); } + else if (e.key === '-') { e.preventDefault(); setZoom(zoom / 1.15); } + }; return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()} onKeyDown={onKey}>
- Zuschnitt · {s ? labelMm(s.w, s.h) : ''} - +

Zuschnitt · {s ? labelMm(s.w, s.h) : ''}

+
setZoom(zoom >= 2.5 ? 1 : zoom * 1.8)} - onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}> + onWheel={(e) => setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08))}>
@@ -766,11 +1131,10 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
- - + +
-
Bild anfassen und ziehen · Doppeltipp oder Mausrad zoomt · Pfeiltasten für Feinarbeit - (mit Umschalt größere Schritte). Das Seitenverhältnis bleibt exakt am Zielformat — verzerrt wird nie. +
Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit. {cell.fit === 'contain' && ' Beim Einpassen bleibt außen die Randfarbe stehen.'}
@@ -778,18 +1142,32 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop ); } +/* ---------------- Bibliothek ---------------- */ + function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const modalRef = useRef(null); + const titelId = useId(); + useEffect(() => { fetch('/api/items').then((r) => r.json()) .then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false)); }, []); + useEffect(() => { + const y = window.scrollY; + const vorher = document.activeElement as HTMLElement | null; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); }; + }, []); + return (
-
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
- Aus der Bibliothek wählen +

Aus der Bibliothek wählen

@@ -807,97 +1185,171 @@ function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose ); } +/* ---------------- Stile ---------------- */ + function PrintStyles() { return ; } diff --git a/src/lib/printoversize.ts b/src/lib/printoversize.ts new file mode 100644 index 0000000..acc0abc --- /dev/null +++ b/src/lib/printoversize.ts @@ -0,0 +1,124 @@ +// Übergroße Bilder: was tun, wenn das Endmaß größer ist als der Bogen? +// 20 × 30 cm auf DIN-A4-Fotopapier ist der Alltagsfall — es fehlen genau 3 mm. +// Reine Mathematik, keine Abhängigkeiten (wie printlayout.ts), damit Browser und +// Server dieselbe Rechnung benutzen. +import type { Placement, SheetSpec } from './printlayout.ts'; + +const EPS = 1e-6; +const round = (n: number) => Math.round(n * 1000) / 1000; + +export type Oversize = + | 'fit' // nichts tun, als „passt nicht" melden (Standard) + | 'overflow' // mittig aufs Blatt legen, der Überstand wird beim Drucken abgeschnitten + | 'tile'; // auf mehrere Blätter verteilen (Posterdruck zum Zusammenkleben) + +/** Passt das Endmaß auf die Nutzfläche des Bogens — notfalls gedreht? */ +export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean { + const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0); + const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset; + const plain = wMm <= availW + EPS && hMm <= availH + EPS; + const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS; + return plain || rot; +} + +export interface OverflowInfo { + /** Platzierung auf dem Bogen — darf negativ sein, das Bild ragt dann hinaus. */ + placement: Placement; + rotated: boolean; + /** Wie viel je Seite verloren geht (mm), nach dem Drehen gerechnet. */ + lostLeft: number; lostRight: number; lostTop: number; lostBottom: number; + lostMm: number; +} + +/** + * Ein zu großes Bild mittig aufs **ganze Blatt** legen — bewusst nicht auf die + * Nutzfläche, sonst verschenkt man den Rand, den ein randloser Drucker sehr wohl + * bedrucken kann. Was über den Bogen ragt, fehlt hinterher; wie viel, steht in `lost*`. + */ +export function overflowPlacement( + specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true, +): OverflowInfo { + const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm); + const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS; + const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm; + + const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2); + const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y)); + const lostRight = round(Math.max(0, x + w - sheet.wMm)); + const lostBottom = round(Math.max(0, y + h - sheet.hMm)); + return { + placement: { specId, copy, x, y, w, h, rotated }, + rotated, lostLeft, lostRight, lostTop, lostBottom, + lostMm: round(lostLeft + lostRight + lostTop + lostBottom), + }; +} + +/** + * Beim Posterdruck steht auf jedem Blatt „Blatt 2/4 · Reihe 1, Spalte 2" — das ist + * die Montageanleitung und wird deshalb immer gedruckt (8 pt ab 3 mm Oberkante, + * braucht rund 6 mm). Ist der Papierrand knapper, muss die Kachel so weit nach + * unten rücken; damit sie unten nicht herausragt, wird der Bogen entsprechend + * niedriger gerechnet. Browser und Server benutzen dieselbe Funktion, sonst zeigte + * die Vorschau eine andere Blattzahl als das fertige PDF. + */ +export const NOTE_MM = 6; +export function noteInset(sheet: SheetSpec): number { + return Math.max(0, NOTE_MM - (sheet.marginMm || 0)); +} +export function tileSheet(sheet: SheetSpec): SheetSpec { + const n = noteInset(sheet); + return n ? { ...sheet, hMm: sheet.hMm - n } : sheet; +} + +export interface Tile { + col: number; row: number; // 0-basiert + xMm: number; yMm: number; // Ausschnitt im Zielbild, ab linker oberer Ecke + wMm: number; hMm: number; + glueRight: boolean; glueBottom: boolean; // dort schließt eine Nachbarkachel an +} + +export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number } + +/** + * Posterdruck: ein Bild auf mehrere Blätter verteilen. + * Die Kacheln überlappen sich um `overlapMm` — dieser Streifen ist der Klebefalz. + * Er wird auf beiden Nachbarblättern gedruckt; beim Zusammensetzen legt man das + * eine Blatt entlang der gestrichelten Linie auf das andere. + */ +export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan { + const inset = sheet.marginMm || 0; + const availW = Math.max(10, sheet.wMm - 2 * inset); + const availH = Math.max(10, sheet.hMm - 2 * inset); + const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3)); + + const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o); + const cols = Math.max(1, Math.ceil((wMm - o) / stepX)); + const rows = Math.max(1, Math.ceil((hMm - o) / stepY)); + + const tiles: Tile[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = c * stepX, y = r * stepY; + const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y); + if (w <= 0.5 || h <= 0.5) continue; + tiles.push({ + col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h), + glueRight: c < cols - 1, glueBottom: r < rows - 1, + }); + } + } + return { cols, rows, tiles, overlapMm: round(o) }; +} + +/** Ausschnitt einer Kachel, verschachtelt in den Zuschnitt des Nutzers. */ +export function tileCrop( + crop: { x: number; y: number; w: number; h: number }, + tile: Tile, wMm: number, hMm: number, +): { x: number; y: number; w: number; h: number } { + return { + x: crop.x + (tile.xMm / wMm) * crop.w, + y: crop.y + (tile.yMm / hMm) * crop.h, + w: (tile.wMm / wMm) * crop.w, + h: (tile.hMm / hMm) * crop.h, + }; +} diff --git a/src/lib/printrender.ts b/src/lib/printrender.ts index 0405252..700d58a 100644 --- a/src/lib/printrender.ts +++ b/src/lib/printrender.ts @@ -159,6 +159,16 @@ function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel { export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' } +/** Wo die Fußzeile steht. Vorne die Kante, hinten die Ausrichtung. */ +export type FooterPos = + | 'unten-links' | 'unten-mitte' | 'unten-rechts' + | 'oben-links' | 'oben-mitte' | 'oben-rechts'; + +export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number } + +/** Zusätzliches je Seite — für den Posterdruck: Blattnummer und Klebefalz. */ +export interface PageExtras { note?: string | null; dashed?: Line[] } + export interface SheetPdfInput { sheet: SheetSpec; pages: Page[]; @@ -166,7 +176,8 @@ export interface SheetPdfInput { /** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */ images: Record; title?: string; - footer?: string | null; + footer?: FooterSpec | null; + extrasPerPage?: PageExtras[]; markWidthPt?: number; } @@ -185,7 +196,8 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise { for (const [id, img] of Object.entries(images)) { embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes); } - const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null; + const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note); + const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null; const bleed = sheet.bleedMm || 0; const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm); @@ -209,16 +221,43 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise { color: rgb(0, 0, 0), }); } - // Fußzeile nur, wenn unten wirklich Platz ist — sonst stünde sie im Motiv. - const freeBottom = pg.placements.length - ? sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)) - : sheet.hMm; - if (font && input.footer && freeBottom >= 6) { - page.drawText(input.footer, { - x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42), - rotate: degrees(0), + // Klebefalz beim Posterdruck: gestrichelt, damit man sie nicht für eine + // Schnittkante hält. + const extras = input.extrasPerPage?.[i]; + for (const l of extras?.dashed || []) { + page.drawLine({ + start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) }, + end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) }, + thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3], }); } + + if (font) { + const put = (text: string, pos: FooterPos, size: number) => { + const w = font.widthOfTextAtSize(text, size); + const top = pos.startsWith('oben'); + const y = top ? H - mmToPt(3) - size : mmToPt(3); + const x = pos.endsWith('mitte') ? (W - w) / 2 + : pos.endsWith('rechts') ? W - mmToPt(4) - w + : mmToPt(4); + page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) }); + }; + + // Fußzeile nur, wenn an der gewählten Kante wirklich Platz ist — + // sonst stünde sie mitten im Motiv. + if (input.footer?.text) { + const pos = input.footer.position || 'unten-links'; + const size = input.footer.sizePt ?? 6; + const top = pos.startsWith('oben'); + const free = pg.placements.length + ? (top ? Math.min(...pg.placements.map((p) => p.y - bleed)) + : sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed))) + : sheet.hMm; + if (free >= size * 0.353 + 4) put(input.footer.text, pos, size); + } + // Blattnummer beim Posterdruck steht immer — sie ist die Montageanleitung. + if (extras?.note) put(extras.note, 'oben-links', 8); + } }); return pdf.save(); diff --git a/src/middleware.ts b/src/middleware.ts index 8ac4c58..8664817 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { ensureInit } from './lib/init'; import { readSession } from './lib/auth'; import { one } from './lib/db'; -const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/]; +const PUBLIC_PATHS = [/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/]; /** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */ async function tokenUser(header: string | null): Promise { diff --git a/src/pages/anleitung.astro b/src/pages/anleitung.astro index c524e1e..f286e6b 100644 --- a/src/pages/anleitung.astro +++ b/src/pages/anleitung.astro @@ -78,11 +78,19 @@ import Base from '../layouts/Base.astro';
  • Auf das Vorschaubild tippen → Zuschnitt: schieben und zoomen. Das Seitenverhältnis bleibt fest am Zielformat, verzerrt wird nie.
  • Anzahl je Bild setzen — so entsteht der Passbild- oder Kita-Satz.
  • +
  • Dasselbe Bild in zwei Maßen? Duplizieren antippen — das Bild erscheint ein + zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes + Hochladen mehr.
  • Papierformat wählen: DIN A6–A2 inklusive A3+ (329 × 483 mm), Fotopapier-Zuschnitte 9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.
  • Schnitthilfen: Eckmarken (feine Marken außerhalb des Endformats, wie in Photoshop und InDesign) oder durchgehende Linien über den ganzen Bogen fürs Schneidelineal. Die durchgehenden Linien laufen nie durch ein anderes Motiv.
  • +
  • Ist das Endmaß größer als der Bogen — etwa 20 × 30 cm auf DIN-A4-Fotopapier — + erscheint direkt am Bild die Frage, wie es weitergehen soll: + Überstehen legt das Bild mittig aufs ganze Blatt, der Überstand wird abgeschnitten + (bei 20 × 30 auf A4 fehlen genau 3 mm in der Höhe, und genau das steht auch dabei), oder + Posterdruck verteilt es auf mehrere Blätter zum Zusammenkleben.
  • Druck-PDF erzeugen. Die PDF-Seite hat exakt das Bogenmaß.
  • Wichtig beim Drucken: im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen — @@ -91,10 +99,21 @@ import Base from '../layouts/Base.astro';

    • Beschnittzugabe (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.
    • +
    • Posterdruck: Beim Verteilen auf mehrere Blätter überlappen sich die Kacheln um einen + Klebefalz (Standard 10 mm, frei einstellbar). Der Falz wird auf beiden Nachbarblättern + gedruckt und mit einer gestrichelten Linie markiert — beim Zusammensetzen legt man das eine + Blatt entlang dieser Linie aufs andere. Jedes Blatt trägt oben seine Nummer samt Reihe und + Spalte. 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.
    • +
    • Beschriftung: Unter dem Bogen steht standardmäßig eine kleine Zeile mit Maßen, dpi und + dem Druckhinweis. Sie lässt sich ausschalten, mit eigenem Text versehen (z. B. + „Phoenix · August 2026") und an eine von sechs Stellen legen — oben oder unten, jeweils links, + mittig oder rechts. Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch; + über ein Bild wird sie nie gedruckt.
    • Einzeln herunterladen: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.
    • Auflösungswarnung: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.
    • Vorlagen: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern. - Mitgeliefert sind Kita-Satz (A4) (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild), + Mitgeliefert sind Kita-Satz (A4) (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild — das ist + bewusst mehr, als auf ein Blatt geht; die Vorschau sagt dir, wie viele Bogen es werden), Schulsatz klein (A4), Passbildbogen 35 × 45 (A4) und 2 × 10 × 7,5 auf Fotopapier 10 × 15. Lädst du eine mehrformatige Vorlage bei nur einem Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick, @@ -107,7 +126,10 @@ import Base from '../layouts/Base.astro'; Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.

    Auch per Telegram: Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen → PDF zurück. - Und am Handy funktioniert die Seite genauso wie am Rechner.

    + Am Handy steht die Vorschau oben und „Druck-PDF erzeugen" liegt als fester Balken griffbereit + über der Navigation; am Rechner arbeitest du links und siehst rechts, was dabei herauskommt. + Wer das Werkzeug jemandem zeigen will, ohne dass etwas hochgeladen werden kann, schickt den + Link auf die Vorstellungsseite.

    diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts index 3086091..35f345f 100644 --- a/src/pages/api/print/sheet.ts +++ b/src/pages/api/print/sheet.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; -import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout'; -import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender'; +import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout'; +import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize'; +import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender'; import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource'; import { paperById, parseSizeMm, labelMm } from '../../../lib/paper'; import { cfgForKey } from '../../../lib/delivery'; @@ -11,7 +12,7 @@ export const prerender = false; const MAX_CELLS = 40; // verschiedene Bilder je Bogen const MAX_COPIES = 200; // Exemplare je Bild -const MAX_PAGES = 20; +const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung interface CellIn { @@ -25,6 +26,8 @@ interface CellIn { landscape?: boolean; // Bildformat quer statt hoch fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen bg?: string; // Randfarbe beim Einpassen + oversize?: Oversize; // größer als der Bogen: melden, überstehen lassen oder kacheln + overlapMm?: number; // Klebefalz beim Kacheln } const json = (b: unknown, s = 200) => @@ -46,26 +49,33 @@ export const POST: APIRoute = async ({ request, locals }) => { const specs: PlaceSpec[] = []; const resolved: { cell: CellIn; wMm: number; hMm: number }[] = []; + // Zellen, die nicht auf den Bogen passen und dafür einen eigenen Weg haben. + const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = []; + for (const [i, c] of cellsIn.entries()) { const size = cellSize(c); if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400); const id = String(c.id || `c${i}`); - specs.push({ - id, wMm: size.w, hMm: size.h, - count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES), - allowRotate: c.allowRotate !== false, - }); + const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES); + const allowRotate = c.allowRotate !== false; + const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit'; + + if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) { + big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode }); + continue; + } + specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate }); resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h }); } + if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400); - const totalPieces = specs.reduce((n, s) => n + s.count, 0); + const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0); if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422); - const plan = layout(specs, sheet); - if (!plan.pages.length) { + const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 }; + if (!plan.pages.length && !big.length) { return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422); } - if (plan.pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${plan.pages.length}). Bitte Anzahl reduzieren.` }, 422); // Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren. // Gedrehte Platzierungen brauchen eine eigene Fassung. @@ -103,13 +113,69 @@ export const POST: APIRoute = async ({ request, locals }) => { offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined, bleedMm: sheet.bleedMm, })); + const extras: PageExtras[] = plan.pages.map(() => ({})); - const kinds = resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`); - const footer = body?.footer === false ? null - : `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`; + // --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln --- + const notes: string[] = []; + for (const [bi, b] of big.entries()) { + const buf = await loadSource(b.cell.src, locals.user as any); + const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) }; + + if (b.mode === 'overflow') { + const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false); + const key = `o${bi}`; + const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated }); + images[key] = { bytes: out.buffer, ext: out.ext }; + for (let c = 0; c < b.count; c++) { + pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] }); + marks.push([]); // Marken wären hier sinnlos, die Kanten liegen außerhalb + extras.push({}); + } + if (info.lostMm > 0.05) + notes.push(`${labelMm(b.wMm, b.hMm)} ragt über den Bogen: es fehlen ${fmtMm(info.lostLeft + info.lostRight)} in der Breite und ${fmtMm(info.lostTop + info.lostBottom)} in der Höhe.`); + } else { + const noteMm = noteInset(sheet); + const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40)); + if (tp.tiles.length * b.count > MAX_PAGES) + return json({ error: `Der Posterdruck bräuchte ${tp.tiles.length * b.count} Blätter — bitte kleineres Maß oder größeres Papier.` }, 422); + + const inset = sheet.marginMm || 0; + for (const [ti, t] of tp.tiles.entries()) { + const key = `t${bi}_${ti}`; + const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm); + const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false }); + images[key] = { bytes: out.buffer, ext: out.ext }; + + // Klebefalz sichtbar machen: gestrichelt dort, wo das Nachbarblatt anschließt. + const dashed: Line[] = []; + const top = inset + noteMm; + if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); } + if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); } + + for (let c = 0; c < b.count; c++) { + pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] }); + marks.push([]); + extras.push({ + note: `Blatt ${ti + 1}/${tp.tiles.length} · Reihe ${t.row + 1}, Spalte ${t.col + 1}` + + (tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} Klebefalz (gestrichelt)` : ''), + dashed, + }); + } + } + notes.push(`${labelMm(b.wMm, b.hMm)} auf ${tp.cols} × ${tp.rows} Blätter verteilt, ${fmtMm(tp.overlapMm)} Überlappung zum Kleben.`); + } + } + + if (pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${pages.length}). Bitte Anzahl reduzieren.` }, 422); + + const kinds = [ + ...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`), + ...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (Poster)' : ' (überstehend)'}`), + ]; + const footer = readFooter(body, sheet, kinds, dpi); const bytes = await buildSheetPdf({ - sheet, pages, marksPerPage: marks, images, + sheet, pages, marksPerPage: marks, images, extrasPerPage: extras, title: String(body?.title || 'Klarbild Druckbogen'), footer, markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2), @@ -147,6 +213,7 @@ export const POST: APIRoute = async ({ request, locals }) => { 'X-Klarbild-Pages': String(pages.length), 'X-Klarbild-Per-Sheet': String(plan.perSheet), 'X-Klarbild-Delivered': delivered, + ...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}), ...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}), }, }); @@ -197,6 +264,22 @@ function cellSize(c: CellIn): { w: number; h: number } | null { return c.landscape ? { w: s.h, h: s.w } : s; } +/** Millimeter menschenlesbar: „3 mm", „1,5 mm", „0 mm". */ +const fmtMm = (n: number) => `${String(Math.round(n * 10) / 10).replace('.', ',')} mm`; + +const FOOTER_POS: FooterPos[] = ['unten-links', 'unten-mitte', 'unten-rechts', 'oben-links', 'oben-mitte', 'oben-rechts']; + +/** Fußzeile: aus, automatisch oder eigener Text an frei wählbarer Stelle. */ +function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) { + const f = body?.footer; + if (f === false || f === null) return null; + const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`; + if (f === true || f == null) return { text: auto, position: 'unten-links' as FooterPos, sizePt: 6 }; + const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto; + const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'unten-links'; + return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) }; +} + const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover'); /** Randfarbe nur als sicheres Hex zulassen. */ const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff'); diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro index c5ed875..838f5d6 100644 --- a/src/pages/changelog.astro +++ b/src/pages/changelog.astro @@ -9,6 +9,42 @@ import Base from '../layouts/Base.astro';

    Was in Klarbild neu ist. Neueste Änderungen oben.

    +
    +
    20.08.2026

    Übergroße Bilder, Bild duplizieren, freie Beschriftung & neuer Aufbau

    +
      +
    • 20 × 30 auf DIN-A4-Papier — jetzt geht das. Wenn das Wunschmaß größer ist als der Bogen, sagt das Werkzeug nicht mehr nur „passt nicht", sondern bietet zwei Wege an: Überstehen legt das Bild mittig aufs ganze Blatt — was hinausragt, fehlt hinterher, und wie viel das ist, steht vorher dabei (bei 20 × 30 cm auf A4 sind es genau 3 mm in der Höhe). Posterdruck verteilt das Bild auf mehrere Blätter; die Kacheln überlappen sich um einen Klebefalz, der gestrichelt markiert wird, und jedes Blatt trägt seine Nummer mit Reihe und Spalte. 30 × 40 cm auf A4 ergibt so 2 × 2 Blätter.
    • +
    • Bild duplizieren. Dasselbe Bild lässt sich mit einem Knopf kopieren und dann in einem zweiten Maß auf denselben Bogen setzen — etwa ein Porträt einmal in 10 × 15 und einmal als Passbild auf ein Blatt Fotopapier. Kein Umbenennen und erneutes Hochladen mehr.
    • +
    • Beschriftung selbst bestimmen. Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.
    • +
    • Neuer Aufbau am Rechner: links arbeiten, rechts die Vorschau, die beim Scrollen mitläuft. Die Einstellungsgruppen liegen zugeklappt nebeneinander und klappen über die volle Breite auf. Keine endlose Spalte neben einer leeren mehr.
    • +
    • Am Handy wie eine App: Die Vorschau steht oben, „Druck-PDF erzeugen" liegt als fester Balken über der Navigation, jede Bildzeile zeigt Vorschau und Regler nebeneinander, und alle Knöpfe sind daumengroß. Tablet-Breiten sind eigens geprüft.
    • +
    • Öffentliche Vorstellungsseite unter /vorstellung — beschreibt das Druckmodul ohne Login und ohne Bedienung, gedacht zum Herzeigen und für eine mögliche Veröffentlichung als Open Source.
    • +
    +
    + +
    +
    19.08.2026

    Aus „Passbilder" wird „Drucken" — 86 Bildmaße, Zuschnitt repariert

    +
      +
    • Der Bereich heißt jetzt „Drucken" (vorher „Passbilder") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.
    • +
    • 86 Bildmaße in 10 Gruppen: Passbild, Klein, Foto, Zoll (US), Sofortbild, Poster & Rahmen, Quadrat, DIN, Karten und reine Seitenverhältnisse — dazu 19 Papierformate und jederzeit ein freies Maß.
    • +
    • Behoben: Der Zuschnitt zeigte das Bild verzerrt. Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.
    • +
    • Behoben: „Ich kann das Bild nicht verschieben." Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.
    • +
    +
    + +
    +
    18.08.2026

    Neu: Drucken — Bilder auf exakte Maße bringen, ganz ohne KI

    +
      +
    • Exakte Maße statt „ungefähr". Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seitengröße anpassen".
    • +
    • Mehrere Bilder auf einen Bogen. Gleich große Bilder kommen in ein exaktes Raster, gemischte Formate ordnet ein Packer so an, dass möglichst wenige Bogen nötig sind. Ein 13 × 18, ein 9 × 13 und sechs Passbilder passen zusammen auf ein DIN-A4-Blatt.
    • +
    • Schnittmarken wie in der Druckvorstufe: feine Eckmarken außerhalb jedes Bildes oder durchgehende Linien über den ganzen Bogen — unterbrochen, wo ein anderes Motiv liegt. Länge, Abstand und Beschnittzugabe sind einstellbar.
    • +
    • Zuschneiden oder Rand lassen. Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden bis alles ausgefüllt ist, oder das ganze Bild einpassen und außen eine Randfarbe stehen lassen. Verzerrt wird nie. Der Ausschnitt lässt sich im Zuschnitt-Fenster verschieben und zoomen; die Drehung aus Handyfotos (EXIF) wird vorab eingerechnet.
    • +
    • Papier frei wählbar: DIN A7 bis A0, Fotopapier 9 × 13 bis 20 × 30, US-Maße — oder ein eigenes Papiermaß. Randlos, 5 mm oder 10 mm Rand.
    • +
    • Vorlagen für wiederkehrende Bogen (Kita-Satz, Schulsatz, Passbildbogen, 2 × 10 × 7,5 auf Fotopapier) — eigene lassen sich sichern.
    • +
    • Auch über Telegram und den MCP-Server erreichbar, und der fertige Bogen kann direkt an Picdrop oder ein Sicherungsziel ausgeliefert werden.
    • +
    • Ohne KI, ohne Kosten: reine Geometrie im Haus. Kein Modell, kein Schlüssel, keine Bilder, die den Server verlassen.
    • +
    +
    +
    24.07.2026

    JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku

      diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index 62cc8af..c80dff7 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -18,6 +18,7 @@ const BODY = `# Klarbild > Bedienung über Web, Telegram-Bot und HTTP-API/MCP. Basis-URL: https://klarbild.heidrich-digital.de +Öffentliche Vorstellung des Druckmoduls (ohne Login, reine Beschreibung): /vorstellung Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss. ## Authentifizierung (für Agenten) @@ -63,7 +64,9 @@ Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · p Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten. - Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm. - Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß. -- Bildformate (id): P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 · +- Bildformate: 86 Vorgaben in 10 Gruppen (Passbild, Klein, Foto, Zoll (US), Sofortbild, + Poster & Rahmen, Quadrat, DIN, Karten, Seitenverhältnis) — oder freies Maß. Auswahl der ids: + P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 · S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 · R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (Poster & Rahmen) · Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 · @@ -79,6 +82,17 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos - Platzierung: gleich große Bilder → exaktes Raster; gemischte Größen → MaxRects-Packer mit fester Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen). - Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen". +- **Größer als der Bogen** (z. B. 20 × 30 cm auf DIN-A4-Fotopapier) — je Zelle über \`oversize\`: + \`fit\` = melden, dass es nicht passt (Standard) · \`overflow\` = mittig aufs ganze Blatt legen, + der Überstand wird beim Drucken abgeschnitten (20 × 30 auf A4: genau 3 mm in der Höhe) · + \`tile\` = Posterdruck über mehrere Blätter mit Klebefalz (\`overlapMm\`, Standard 10 mm, gestrichelt markiert; + jedes Blatt trägt Nummer, Reihe und Spalte). 30 × 40 cm auf A4 ergibt 2 × 2 Blätter. +- **Beschriftung** (\`footer\`): \`false\` = aus · \`true\` = automatisch (Maße, dpi, Druckhinweis) · + \`{ text, position, sizePt }\` = eigener Text. Position: \`unten-links|unten-mitte|unten-rechts| + oben-links|oben-mitte|oben-rechts\`. Die Zeile entfällt automatisch, wenn an der gewählten + Kante kein Platz frei ist — sie wird nie über ein Bild gedruckt. +- Dasselbe Bild darf mehrfach im \`cells\`-Feld stehen (verschiedene \`id\`, gleiche \`src\`), um es + in mehreren Maßen auf einen Bogen zu setzen. In der Weboberfläche macht das der Knopf „Duplizieren". - POST /api/print/single — ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id}, crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }. @@ -86,8 +100,10 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos - GET /api/print/single?size=12x15&dpi=300 — nur rechnen: mm → Pixel. - POST /api/print/sheet — Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?, gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?}, - cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate? }] }. + cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate?, fit?, bg?, + oversize?(fit|overflow|tile), overlapMm? }] }. Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet. + Grenzen: 40 Zellen, 200 Exemplare je Zelle, 500 Einzelbilder und 60 Bogen je Auftrag. - GET/POST/DELETE /api/print/presets — Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen). Mitgeseedet: „Kita-Satz (A4)", „Schulsatz klein (A4)", „Passbildbogen 35×45 (A4)", „2 × 10×7,5 auf Fotopapier 10×15". Eine Vorlage mit mehreren Formaten und nur einem Bild diff --git a/src/pages/vorstellung.astro b/src/pages/vorstellung.astro new file mode 100644 index 0000000..ef7c75b --- /dev/null +++ b/src/pages/vorstellung.astro @@ -0,0 +1,315 @@ +--- +// Öffentliche Vorstellungsseite für das Druckmodul. +// Bewusst ohne Login und ohne Bedienelemente: Sie zeigt, was das Werkzeug kann, +// man kann es hier aber nicht benutzen. Gedacht als Landeseite für ein +// Open-Source-Release (Gitea/GitHub) und zum Herzeigen. +import Base from '../layouts/Base.astro'; +import { PHOTO_SIZES, PAPERS } from '../lib/paper'; +import { layout, cutMarks, type SheetSpec } from '../lib/printlayout'; + +const anzahlFormate = PHOTO_SIZES.length; +const anzahlPapiere = PAPERS.length; +const anzahlGruppen = new Set(PHOTO_SIZES.map((p) => p.group)).size; + +const koennen = [ + { + kicker: 'Maßhaltig', + titel: 'Millimeter statt „ungefähr“', + text: 'Jedes Endmaß wird in Millimetern gerechnet und erst beim Rendern in Pixel umgesetzt. ' + + 'Das PDF hat exakt das Bogenmaß, jedes Bild darin exakt sein Zielmaß. Nachgemessen wird nicht geschätzt, ' + + 'sondern gemessen: Wir rastern die fertigen PDFs mit 254 dpi und zählen die Pixelkanten nach.', + beleg: '13 × 18 cm → gemessen 130,1 × 180,1 mm', + }, + { + kicker: 'Platz', + titel: 'Mehrere Bilder auf einen Bogen', + text: 'Gleich große Bilder kommen in ein exaktes Raster. Bei gemischten Formaten sucht ein Packer ' + + '(MaxRects, „Best Short Side Fit“) die Anordnung mit den wenigsten Bogen — und probiert dafür jede ' + + 'Kombination aus Hoch- und Querlage durch. Ein Passbild-Satz und ein 13 × 18 passen so auf dasselbe Blatt.', + beleg: 'Kita-Satz: 9 Teile statt 3 auf einem A4-Bogen', + }, + { + kicker: 'Schneiden', + titel: 'Schnittmarken wie in der Druckvorstufe', + text: 'Feine Eckmarken außerhalb jedes Bildes oder durchgehende Rasterlinien über den ganzen Bogen. ' + + 'Die Linien werden dort unterbrochen, wo ein anderes Motiv liegt — es wird also nie quer über ein ' + + 'Bild gezeichnet. Länge und Abstand sind einstellbar, Beschnittzugabe ebenso.', + beleg: 'Eckmarken · durchgehendes Raster · Beschnittzugabe 0–10 mm', + }, + { + kicker: 'Zu groß', + titel: '20 × 30 auf A4 — trotzdem', + text: 'Der Alltagsfall: Es soll ein 20 × 30 werden, im Haus liegt aber nur A4-Fotopapier. ' + + 'Statt „geht nicht“ gibt es zwei Wege. Überstehen legt das Bild mittig aufs ganze Blatt; ' + + 'was hinausragt, fehlt hinterher — und wie viel das ist, steht vorher da.', + beleg: '20 × 30 cm auf A4: es fehlen genau 3 mm in der Höhe', + }, + { + kicker: 'Poster', + titel: 'Ein Bild, mehrere Blätter', + text: 'Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln ' + + 'überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt ' + + 'markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.', + beleg: '30 × 40 cm auf A4 → 2 × 2 Blätter, 10 mm Klebefalz', + }, + { + kicker: 'Zuschnitt', + titel: 'Der Ausschnitt bleibt deiner', + text: 'Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. ' + + 'Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, ' + + 'Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.', + beleg: 'EXIF-Drehung wird vorab eingerechnet — Handyfotos stehen richtig', + }, +]; + +const daten = [ + { zahl: String(anzahlFormate), label: 'Bildformate', fein: `in ${anzahlGruppen} Gruppen — Passbild bis 100 × 140 cm` }, + { zahl: String(anzahlPapiere), label: 'Papierformate', fein: 'DIN A7–A0, Fotopapier, US-Maße, freies Maß' }, + { zahl: '0', label: 'KI im Spiel', fein: 'reine Geometrie, kein Modell, kein Upload nach außen' }, + { zahl: '1:1', label: 'Ausgabe', fein: 'PDF in Originalgröße, 72–1200 dpi' }, +]; + +// Der Beispielbogen unten wird nicht gezeichnet, sondern gerechnet — mit genau +// demselben Packer, den das Werkzeug benutzt. Ändert sich der Algorithmus, +// ändert sich das Bild auf dieser Seite mit. +const musterBogen: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true }; +const musterPlan = layout([ + { id: 'gross', wMm: 130, hMm: 180, count: 1, allowRotate: true }, + { id: 'klein', wMm: 90, hMm: 130, count: 1, allowRotate: true }, + { id: 'pass', wMm: 35, hMm: 45, count: 6, allowRotate: true }, +], musterBogen); +const musterSeite = musterPlan.pages[0]; +const musterMarken = musterSeite ? cutMarks(musterSeite, musterBogen, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : []; +const musterFarbe: Record = { + gross: 'oklch(45% 0.23 267 / 0.09)', klein: 'oklch(45% 0.23 267 / 0.20)', pass: 'oklch(45% 0.23 267 / 0.34)', +}; +const musterAnzahl = musterSeite ? musterSeite.placements.length : 0; +/** Kompaktes Maß fürs Kästchen: unter 10 cm in mm, sonst in cm. */ +const kurzMass = (w: number, h: number) => w < 100 && h < 100 + ? `${Math.round(w)}×${Math.round(h)}` + : `${Math.round(w / 10)}×${Math.round(h / 10)} cm`; + +const schritte = [ + { n: '01', t: 'Bilder wählen', d: 'Hochladen, aus der Bibliothek holen oder ein vorhandenes Bild duplizieren, um es in zwei Maßen zu drucken.' }, + { n: '02', t: 'Maße setzen', d: 'Je Bild ein Zielmaß, Anzahl, Hoch oder Quer, zuschneiden oder Rand lassen.' }, + { n: '03', t: 'Bogen einstellen', d: 'Papier, Schnittmarken, Ränder, Auflösung. Die Vorschau zeigt sofort, wie viele Bogen es werden.' }, + { n: '04', t: 'Drucken', d: 'PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.' }, +]; +--- + +
      + +
      +

      Klarbild · Druckmodul

      +

      Bilder auf exakte Maße bringen.
      Ohne KI.

      +

      + Ein Passbild ist 35 × 45 Millimeter. Ein Rahmen ist 13 × 18 Zentimeter. Beides sind Maße, + keine Meinungen — und trotzdem ist es erstaunlich mühsam, ein Foto genau so aus dem Drucker + zu bekommen. Dieses Werkzeug macht daraus einen Handgriff: Zielmaß eintragen, Ausschnitt + wählen, PDF drucken. Millimetergenau, mit Schnittmarken, mehrere Bilder auf einem Blatt. +

      + +
      + +
      + {daten.map((d) => ( +
      + {d.zahl} + {d.label} +

      {d.fein}

      +
      + ))} +
      + +
      +

      Das Problem

      +

      + Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen + es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein + Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein + 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in + 100 % Größe auf die Seite passt, ist schlicht Verschwendung. +

      +

      + Klarbild Drucken rechnet stattdessen in Millimetern, packt so viele Bilder auf einen Bogen, + wie hineingehen, und legt Schnittmarken dorthin, wo geschnitten wird. Was aus dem Drucker + kommt, hat das Maß, das du eingetragen hast. +

      +
      + +
      +

      Ein Bogen, gerechnet

      +

      + Ein 13 × 18, ein 9 × 13 und sechs Passbilder — auf einem einzigen DIN-A4-Bogen, + alle in 100 % Größe, mit Eckmarken zum Schneiden. Diese Zeichnung ist kein Entwurf: + Sie stammt aus demselben Packer, der auch das PDF erzeugt. +

      +
      + + + {musterSeite?.placements.map((pl) => ( + + + + {kurzMass(pl.w, pl.h)} + + + ))} + {musterMarken.map((l) => ( + + ))} + +
        +
      • 1 × 13 × 18 cm
      • +
      • 1 × 9 × 13 cm
      • +
      • 6 × 35 × 45 mm
      • +
      • {musterAnzahl} Teile auf {musterPlan.pages.length} Bogen · 5 mm Rand · 8 mm Abstand
      • +
      +
      +
      + +
      +

      Was es kann

      +
      + {koennen.map((k) => ( +
      +

      {k.kicker}

      +

      {k.titel}

      +

      {k.text}

      +

      {k.beleg}

      +
      + ))} +
      +
      + +
      +

      In vier Schritten

      +
        + {schritte.map((s) => ( +
      1. {s.n}{s.t}

        {s.d}

      2. + ))} +
      +
      + +
      +

      Unter der Haube

      +
      +
      Einheit
      Millimeter. Pixel entstehen erst beim Rendern (px = mm / 25,4 × dpi), PDF-Punkte über mm / 25,4 × 72.
      +
      Rechenkern
      Abhängigkeitsfreies TypeScript. Browser-Vorschau und Server-PDF benutzen buchstäblich denselben Code — was du siehst, wird gedruckt.
      +
      Packen
      Exaktes Raster bei gleichen Maßen, MaxRects (Best Short Side Fit) bei gemischten. Bewertet nach wenigsten Bogen, vollster erster Seite, wenigsten Drehungen.
      +
      Bild
      sharp in einer einzigen Kette: EXIF-Drehung, Ausschnitt, Skalierung, Rand. Der Zuschnitt wird relativ (0…1) gespeichert und ist damit auflösungsunabhängig.
      +
      PDF
      pdf-lib. Seitengröße exakt das Bogenmaß, Bilder 1 : 1 platziert, Schnittmarken als Vektorlinien, Klebefalze gestrichelt.
      +
      Nachweis
      Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.
      +
      +
      + +
      +

      Selbst betreiben

      +

      + Klarbild läuft auf einem eigenen Server: Astro mit Node, PostgreSQL, eine Warteschlange im + selben Prozess, Dateien lokal oder auf S3. Das Druckmodul kommt ohne jeden externen Dienst + aus — kein Modell, kein Schlüssel, keine Bilder, die das Haus verlassen. Wer nur drucken + will, braucht die KI-Teile gar nicht erst zu konfigurieren. +

      +

      + Diese Seite ist eine Vorstellung, keine Bedienoberfläche — hier lässt sich nichts hochladen + und nichts erzeugen. +

      +
      + +
      + + Klarbild · Druckmodul +
      +
      + + + diff --git a/tests/printlayout.test.ts b/tests/printlayout.test.ts index efdc71c..a408df8 100644 --- a/tests/printlayout.test.ts +++ b/tests/printlayout.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts'; import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts'; +import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.ts'; const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true }; @@ -217,3 +218,103 @@ test('Entartete Mengen bringen den Packer nicht zum Stehen', () => { assert.ok(res.pages.length >= 1); assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`); }); + +/* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */ + +test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => { + const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; + assert.equal(fitsOnSheet(200, 300, randlos, false), false); + const info = overflowPlacement('a', 1, 200, 300, randlos, false); + assert.equal(info.rotated, false); + assert.equal(info.lostLeft, 0); + assert.equal(info.lostRight, 0); + assert.equal(info.lostTop, 1.5); // 300 − 297 = 3 mm, mittig verteilt + assert.equal(info.lostBottom, 1.5); + assert.equal(info.lostMm, 3); + // mittig: x = (210 − 200) / 2 + assert.equal(info.placement.x, 5); + assert.equal(info.placement.y, -1.5); +}); + +test('Überstehen dreht, wenn dabei weniger verloren geht', () => { + const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; + // 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast. + const info = overflowPlacement('a', 1, 297, 200, a4, true); + assert.equal(info.rotated, true); + assert.equal(info.lostMm, 0); +}); + +test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => { + const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; + const tp = tilePlan(300, 400, a4, 10); + assert.equal(tp.cols, 2); + assert.equal(tp.rows, 2); + assert.equal(tp.tiles.length, 4); + assert.equal(tp.overlapMm, 10); + // Erste Kachel füllt die Nutzfläche, letzte trägt den Rest. + assert.equal(tp.tiles[0].wMm, 200); + assert.equal(tp.tiles[0].hMm, 287); + assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom); + assert.equal(tp.tiles[3].glueRight, false); + assert.equal(tp.tiles[3].glueBottom, false); + // Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht). + const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm)); + const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm)); + assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`); + assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`); +}); + +test('Kacheln überlappen sich wirklich um den Klebefalz', () => { + const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; + const tp = tilePlan(300, 400, a4, 10); + const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!; + const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!; + const ueberlappung = (links.xMm + links.wMm) - rechts.xMm; + assert.equal(Math.round(ueberlappung * 10) / 10, 10); +}); + +test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => { + const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; + const tp = tilePlan(300, 400, a4, 10); + const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 }; + for (const t of tp.tiles) { + const c = tileCrop(crop, t, 300, 400); + assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen'); + assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen'); + } + // Die erste Kachel beginnt exakt am Zuschnitt-Anfang. + const erste = tileCrop(crop, tp.tiles[0], 300, 400); + assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x); + assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y); +}); + +test('Ein Bild, das passt, gilt nicht als übergroß', () => { + const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true }; + assert.equal(fitsOnSheet(130, 180, a4, false), true); + assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es'); + assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht'); +}); + +test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => { + const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 }; + // 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten. + assert.equal(noteInset(sheet), 1); + const ts = tileSheet(sheet); + const tp = tilePlan(300, 400, ts, 10); + const oben = (sheet.marginMm || 0) + noteInset(sheet); + for (const t of tp.tiles) { + assert.ok(oben + t.hMm <= sheet.hMm + 1e-6, + `Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`); + assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6); + } + // Die Kacheln decken das Endmaß lückenlos ab. + const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm)); + const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm)); + assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`); +}); + +test('Großzügiger Rand verschiebt die Kachel nicht', () => { + const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 }; + assert.equal(noteInset(sheet), 0); + assert.deepEqual(tileSheet(sheet), sheet); +});