diff --git a/CHANGELOG.md b/CHANGELOG.md index fa807d8..38b1734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to Klarbild are documented here. Newest first. +## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI) + +### Added +- **Print module (`/druck`)** — a lab-style, completely AI-free path: crop, scale and place + images at exact physical sizes. No model call, no cost, no queue. +- **Interactive crop editor** — pan and zoom on a fixed target aspect ratio (rule-of-thirds + overlay); the crop is stored relative (0..1) so it survives any source resolution. +- **Size presets & free input** — biometric passport 35 × 45 mm, 2 × 3 / 3 × 4 / 4 × 5 / 4,5 × 6 cm, + 9 × 13 … 50 × 70 cm, squares, DIN. Free input understands `12x15` (cm), `35x45mm`, `5` + (= 5 × 5 cm) and `4:3/15` (ratio + long edge). +- **Sheet layout** — paper presets DIN A6–A2 incl. **A3+ (329 × 483 mm)**, photo-paper cuts + (9 × 13 … 20 × 30), US Letter/Legal, plus any custom paper size. Portrait/landscape, + printer margin, centering, per-image copy count. +- **Automatic nesting** — identical sizes produce an exact grid; mixed sizes go through a + MaxRects packer that fixes one orientation per format and tries every combination, + so eight passport photos land in the free space next to the 13 × 18. +- **Crop marks** — `corner` (fine 0.25 pt marks outside the trim box, Photoshop/InDesign + convention, 4 mm long / 3 mm offset by default) or `grid` (continuous guides across the + sheet that never cross another image). Gap between images adapts to the chosen marks. +- **Bleed** (0–10 mm) — the image extends past the trim box, edge pixels are copied rather + than stretched; the gap is always ≥ 2 × bleed so neighbouring bleeds cannot overlap. +- **PDF output at 1:1** — the PDF page is exactly the sheet size (pdf-lib), with a footer + stating the sizes, dpi and the "print at 100 %, not fit-to-page" reminder. +- **Single export** — one image at an exact size as PNG/JPG with dpi metadata, plus a + resolution warning when the source cannot hold the requested dpi. +- **Sheet templates** — save the whole sheet setup (paper, marks, formats, counts) as a + reusable preset (`print_presets`, migration `013`). +- **All three entrances** — `/druck` in the web app, `📐 Druckbogen` in Telegram (fully + button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`. +- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator), + `POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`. +- **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)" + and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image + clones that image into every format, so one click produces the whole set. +- **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons. +- **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any + extra delivery target (`deliver` in the sheet request). +- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px, + non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging. + ## 2026-07-23 — Compose, generate & second backup ### Added diff --git a/README.md b/README.md index eebd366..f0d97bc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ für den Cricut freistellen, und automatisch nach Picdrop ausliefern. ## Stack Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss · -sharp · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. Deploy: Gitea → Coolify → Hetzner. +sharp · pdf-lib (Druckbogen) · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. +Deploy: Gitea → Coolify → Hetzner. ## Entwicklung ```bash @@ -31,12 +32,26 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter - **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern". - **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate. - **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`. +- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit + Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen — + Passbildsatz, Kita-Satz, Sticker. Papier DIN A6–A2 inkl. **A3+**, Fotopapier 9×13–20×30, Letter/Legal + oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien. + Beschnittzugabe 0–10 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten. + Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer. + Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet), + `src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP + (`exact_size`, `print_sheet`). - **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose. ## Datenmodell / Migrationen Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichtige Spalten: `items.has_alpha`, `jobs.status` (inkl. `failed`), `jobs.finished_at`, -`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`. +`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`, +`print_presets` (Bogen-Vorlagen, `013`). + +## Tests +`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing, +mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit). ## Stolpersteine & Lösungen (Learnings) - **„Bild bei Picdrop ✓, aber nicht in der Galerie":** Fehldiagnose vermeiden — die Dateien lagen die ganze diff --git a/mcp/klarbild-mcp.mjs b/mcp/klarbild-mcp.mjs index c90ed06..fa9f821 100644 --- a/mcp/klarbild-mcp.mjs +++ b/mcp/klarbild-mcp.mjs @@ -12,8 +12,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { readFile } from 'node:fs/promises'; -import { basename } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, ''); const TOKEN = process.env.KLARBILD_TOKEN || ''; @@ -64,6 +64,38 @@ const TOOLS = [ }, required: ['files'] } }, { name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).', inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } }, + + { name: 'exact_size', + description: 'OHNE KI: ein Bild exakt auf ein physisches Maß bringen (mittiger Ausschnitt im Zielverhältnis, dpi-Metadaten). Ergebnis wird lokal gespeichert.', + inputSchema: { type: 'object', properties: { + file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' }, + size: { type: 'string', description: 'Maß: "12x15" (cm), "35x45mm", "5" (=5×5 cm) oder "4:3/15"' }, + landscape: { type: 'boolean', description: 'Querformat statt Hochformat' }, + dpi: { type: 'number', description: 'Standard 300' }, + ext: { type: 'string', enum: ['jpg', 'png'] }, + out: { type: 'string', description: 'Zielpfad für die Datei (Standard: ./klarbild-.)' }, + }, required: ['file', 'size'] } }, + + { name: 'print_sheet', + description: 'OHNE KI: mehrere Bilder in 100 %-Größe mit Schnittmarken auf einen Druckbogen setzen und als PDF speichern. Für Passbildsätze, Kita-Bilder, Sticker.', + inputSchema: { type: 'object', properties: { + images: { type: 'array', description: 'Je Eintrag: Datei + Endmaß + Stückzahl', + items: { type: 'object', properties: { + file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' }, + size: { type: 'string', description: 'Endmaß, z. B. "35x45mm", "9x13", "12x15"' }, + count: { type: 'number', description: 'Wie oft auf den Bogen (Standard 1)' }, + landscape: { type: 'boolean' }, + allowRotate: { type: 'boolean', description: 'Darf gedreht platziert werden (Standard true)' }, + }, required: ['file', 'size'] } }, + paper: { type: 'string', description: 'A4 (Standard), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — oder ein freies Maß wie "32,9x48,3"' }, + landscape: { type: 'boolean', description: 'Bogen quer' }, + marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Schnitthilfen (Standard corner)' }, + marginMm: { type: 'number', description: 'Rand zum Papier, Standard 5' }, + gapMm: { type: 'number', description: 'Abstand zwischen den Bildern, Standard passend zu den Marken' }, + bleedMm: { type: 'number', description: 'Beschnittzugabe je Seite, Standard 0' }, + dpi: { type: 'number', description: 'Standard 300' }, + out: { type: 'string', description: 'Zielpfad des PDFs (Standard: ./klarbild-druckbogen.pdf)' }, + }, required: ['images'] } }, ]; server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS })); @@ -100,6 +132,44 @@ server.setRequestHandler({ method: 'tools/call' }, async (req) => { if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen'); return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] }; } + if (name === 'exact_size') { + const up = await uploadFile(a.file); + const r = await fetch(api('/api/print/single'), { + method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size, + landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`); + const ext = a.ext === 'png' ? 'png' : 'jpg'; + const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`); + await writeFile(out, Buffer.from(await r.arrayBuffer())); + const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi'); + const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Quelle reicht nur für ca. ${real} dpi.` : ''; + return { content: [{ type: 'text', text: `Gespeichert: ${out} (${px} px).${warn}` }] }; + } + if (name === 'print_sheet') { + const imgs = a.images || []; + if (!imgs.length) throw new Error('Keine Bilder angegeben.'); + const cells = []; + for (const [i, im] of imgs.entries()) { + const up = await uploadFile(im.file); + cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size, + count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false }); + } + const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' }; + const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5, + bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true, + marks: { mode: a.marks || 'corner' }, cells }; + if (a.gapMm != null) body.gapMm = a.gapMm; + const r = await fetch(api('/api/print/sheet'), { + method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`); + const out = resolve(a.out || './klarbild-druckbogen.pdf'); + await writeFile(out, Buffer.from(await r.arrayBuffer())); + return { content: [{ type: 'text', text: + `Gespeichert: ${out} — ${r.headers.get('x-klarbild-pages')} Bogen, ${r.headers.get('x-klarbild-per-sheet')} Bilder auf Bogen 1. ` + + 'Beim Drucken „Tatsächliche Größe / 100 %" wählen.' }] }; + } if (name === 'job_status') { const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json(); const job = j.job || j; diff --git a/migrations/013_print_presets.sql b/migrations/013_print_presets.sql new file mode 100644 index 0000000..64c4c21 --- /dev/null +++ b/migrations/013_print_presets.sql @@ -0,0 +1,14 @@ +-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") ------------------- +CREATE TABLE IF NOT EXISTS print_presets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + config jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name); + +-- Telegram: Zustand „wartet auf Druck-Auswahl" ----------------------------- +ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check; +ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check + CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded')); diff --git a/package-lock.json b/package-lock.json index 468131e..ac0215e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", "react": "^19.0.0", @@ -31,6 +32,7 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tsx": "^4.23.12", "typescript": "^5.7.2" } }, @@ -1763,6 +1765,24 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -6135,6 +6155,12 @@ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -6196,6 +6222,24 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -7430,6 +7474,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 682e650..5a5414d 100644 --- a/package.json +++ b/package.json @@ -8,21 +8,23 @@ "build": "astro build", "start": "node ./dist/server/entry.mjs", "migrate": "node --loader tsx ./scripts/migrate.mjs", - "astro": "astro" + "astro": "astro", + "test": "node --import tsx --test tests/*.test.ts" }, "dependencies": { "@astrojs/node": "^9.1.3", "@astrojs/react": "^4.2.1", - "@fontsource/space-grotesk": "^5.1.0", - "@fontsource/space-mono": "^5.1.0", "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", + "@fontsource/space-grotesk": "^5.1.0", + "@fontsource/space-mono": "^5.1.0", "archiver": "^7.0.1", "argon2": "^0.41.1", "astro": "^5.5.0", "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", "react": "^19.0.0", @@ -34,6 +36,7 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tsx": "^4.23.12", "typescript": "^5.7.2" } } diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx new file mode 100644 index 0000000..d1bfd94 --- /dev/null +++ b/src/components/PrintApp.tsx @@ -0,0 +1,808 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } 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'; + +/* -------------------------------------------------------------------------- + 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. + -------------------------------------------------------------------------- */ + +type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string }; +interface CropRel { x: number; y: number; w: number; h: number } + +interface Cell { + id: string; + name: string; + preview: string; // Data-URL (Upload) oder /api/items/…/file + 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' + customSize: string; + landscape: boolean; + count: number; + allowRotate: boolean; + crop: CropRel; +} + +const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); + +/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */ +function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel { + const imgA = natW / natH; + let w = 1, h = 1; + if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); } + const cx = around ? around.x + around.w / 2 : 0.5; + const cy = around ? around.y + around.h / 2 : 0.5; + return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }); +} +function clampCrop(c: CropRel): CropRel { + const w = Math.min(1, Math.max(0.02, c.w)); + const h = Math.min(1, Math.max(0.02, c.h)); + return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) }; +} + +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; +}; + +export default function PrintApp() { + const [cells, setCells] = useState([]); + const [paperId, setPaperId] = useState('A4'); + const [paperCustom, setPaperCustom] = useState(''); + const [paperLandscape, setPaperLandscape] = useState(false); + const [marginMm, setMarginMm] = useState(5); + const [autoGap, setAutoGap] = useState(true); + const [gapMm, setGapMm] = useState(12); + const [bleedMm, setBleedMm] = useState(0); + const [marks, setMarks] = useState('corner'); + const [markLen, setMarkLen] = useState(4); + const [markOff, setMarkOff] = useState(3); + 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 [gallery, setGallery] = 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 [dragging, setDragging] = useState(false); + const fileRef = useRef(null); + + const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); }; + + /* ---------------- Bogen ---------------- */ + const paper = useMemo(() => { + const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId); + if (!p) return null; + return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h }; + }, [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 specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => { + const s = sizeOfCell(c); + if (!s || c.error || !c.src) return []; + return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }]; + }), [cells]); + + 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 ---------------- */ + 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 id = uid(); + 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: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) } + : x)); + probe.src = url; + }; + 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()); + const info = j.files?.[0]; + setCells((p) => p.map((x) => x.id === id ? { + ...x, uploading: false, error: info?.error, + src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined, + natW: info?.width || x.natW, natH: info?.height || x.natH, + } : x)); + } catch { + setCells((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x)); + } + } + }, []); + + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + 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]); + + useEffect(() => { + loadPresets(); + fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {}); + }, []); + const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) + .then((j) => setPresets(j.presets || [])).catch(() => {}); + + const addFromLibrary = (it: any) => { + const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0); + const id = uid(); + const c: Cell = { + ...newCell(id, it.filename || 'Bild'), + uploading: false, + src: { kind: 'item', id: it.id }, + preview: `/api/items/${it.id}/file?preview=1`, + natW: w || 2000, natH: h || 3000, + }; + c.crop = coverCrop(c.natW, c.natH, aspectOf(c)); + setCells((p) => [...p, c]); + }; + + 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) { + const a = aspectOf(next); + if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop); + } + 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 } } : {}), + marks: { mode: marks, lengthMm: markLen, offsetMm: markOff }, + cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).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 }; + }), + }); + + const makePdf = async () => { + if (!plan?.pages.length) 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; } + 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); } + }; + + const makeSingle = async (c: Cell) => { + const s = sizeOfCell(c); if (!s || !c.src) return; + setBusy('single'); + try { + const res = await fetch('/api/print/single', { + 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 }), + }); + 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}`); + 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); } + }; + + const savePreset = async () => { + const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")'); + if (!name?.trim()) return; + const config = { + paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, + marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery, + formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })), + }; + await fetch('/api/print/presets', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), config }), + }); + notify('Vorlage gespeichert.'); loadPresets(); + }; + + const applyPreset = (p: any) => { + const c = p?.config; if (!c) 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); + 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 || '', + landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false }; + const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop); + return next; + }; + if (prev.length === 1 && c.formats.length > 1) + return c.formats.map((f: any, i: number) => apply({ ...prev[0], id: i === 0 ? prev[0].id : uid() }, f)); + return prev.map((cell, i) => apply(cell, c.formats[Math.min(i, c.formats.length - 1)])); + }); + } + notify(Array.isArray(c.formats) && c.formats.length > 1 + ? 'Vorlage geladen — das Bild wurde für jedes Format übernommen.' + : 'Vorlage geladen.'); + }; + + const deletePreset = async (id: string) => { + if (!id || !confirm('Vorlage löschen?')) return; + await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' }); + 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); + + 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" /> + )} +
+ + +
+ {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' && ( +
+ + +
+ )} +
+ +
+ +
+ + + +
+
+ + +
+ + {!autoGap && ( + setGapMm(Number(e.target.value))} /> + )} + +
Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.
+
+ +
+ +
+ + +
+ +
+ +
+ + + {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".
+
+
+ + {/* ---------- 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}
} + +
+ ); +} + +/* ---------------- 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, + crop: { x: 0, y: 0, w: 1, h: 1 }, + }; +} +function aspectOf(c: Cell): number { + const s = sizeOfCell(c); + return s ? s.w / s.h : 1; +} +function cropStyle(c: CropRel): React.CSSProperties { + return { + position: 'absolute', + width: `${100 / c.w}%`, height: `${100 / c.h}%`, + left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`, + objectFit: 'fill', + }; +} +function groupBy(list: T[], _k: 'group'): [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()]; +} +function download(blob: Blob, name: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); + 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; +}) { + const s = sizeOfCell(cell); + const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null; + return ( +
+ +
!cell.uploading && onEdit()}> + {cell.preview + ? + : } + {!cell.uploading && Zuschnitt} +
+
+ + {cell.sizeId === 'custom' && ( + onPatch({ customSize: e.target.value })} + placeholder="12x15 · 35x45mm · 4:3/15" /> + )} +
+
+ + +
+
+ + onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} /> + +
+
+ + {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`)} +
+ )} + +
+
+ ); +} + +/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */ +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 = coverCrop(cell.natW, cell.natH, aspect); + const [crop, setCrop] = useState(cell.crop); + const boxRef = useRef(null); + const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null); + + const zoom = Math.min(20, Math.max(1, base.w / crop.w)); + const setZoom = (z: number) => { + const zz = Math.min(20, 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 })); + }; + + const onDown = (e: React.PointerEvent) => { + (e.target as Element).setPointerCapture?.(e.pointerId); + drag.current = { x: e.clientX, y: e.clientY, crop }; + }; + const onMove = (e: React.PointerEvent) => { + if (!drag.current || !boxRef.current) return; + const r = boxRef.current.getBoundingClientRect(); + const dx = (e.clientX - drag.current.x) / r.width; + const dy = (e.clientY - drag.current.y) / r.height; + const c = drag.current.crop; + setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h })); + }; + const onUp = () => { drag.current = null; }; + + useEffect(() => { + const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', esc); + return () => window.removeEventListener('keydown', esc); + }, [onClose]); + + const apply = () => { onChange(crop); onClose(); }; + + return ( +
+
e.stopPropagation()}> +
+ Zuschnitt · {s ? labelMm(s.w, s.h) : ''} + +
+
+
{ setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}> + +
+
+
+
+ +
+ + + +
+
Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.
+
+
+
+ ); +} + +function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + fetch('/api/items').then((r) => r.json()) + .then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false)); + }, []); + return ( +
+
e.stopPropagation()}> +
+ Aus der Bibliothek wählen + +
+
+ {loading ?

Wird geladen …

+ : !items.length ?

Noch keine Bilder in der Bibliothek.

+ : items.map((it) => ( + + ))} +
+
+
+ ); +} + +function PrintStyles() { + return ; +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 92e0324..45c66f6 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -14,6 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h const nav = [ { href: '/', label: 'Studio', icon: 'studio' }, { href: '/bibliothek', label: 'Bibliothek', icon: 'library' }, + { href: '/druck', label: 'Druck', icon: 'print' }, { href: '/warteschlange', label: 'Warteschlange', icon: 'queue' }, { href: '/anleitung', label: 'Hilfe', icon: 'help' }, ...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []), @@ -25,6 +26,7 @@ const ICONS: Record = { library: ``, queue: ``, admin: ``, + print: ``, help: ``, }; --- diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index b1e723d..086ca84 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -17,7 +17,7 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P return s?.picdrop_default_gallery || DEFAULT_GALLERY; } -async function cfgForKey(key: string | null | undefined): Promise { +export async function cfgForKey(key: string | null | undefined): Promise { if (!key) return null; if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; } if (key === 'picdrop') return loadConfig(); diff --git a/src/lib/paper.ts b/src/lib/paper.ts new file mode 100644 index 0000000..dff400f --- /dev/null +++ b/src/lib/paper.ts @@ -0,0 +1,116 @@ +// Papier- und Bildformate für den Druckbogen (Modul „Druck", ohne KI). +// Alle Maße in Millimetern — mm ist die Leitwährung dieses Moduls, weil ein +// Druckbogen physisch ist. Pixel entstehen erst beim Rendern (mm → dpi). + +export interface PaperSize { id: string; label: string; w: number; h: number; group: string } + +/** Bogenformate (Hochformat-Konvention: kurze Seite × lange Seite). */ +export const PAPERS: PaperSize[] = [ + // DIN + { id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' }, + { id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' }, + { id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' }, + { id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' }, + { id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' }, + { id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' }, + // Fotopapier (Zuschnitte, wie sie im Fachhandel liegen) + { id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' }, + { id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' }, + { id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' }, + { id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' }, + { id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' }, + // US + { id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' }, + { id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' }, +]; + +/** Bildformate (Endformat eines einzelnen Bildes auf dem Bogen). */ +export interface PhotoSize { id: string; label: string; w: number; h: number; group: string } + +export const PHOTO_SIZES: PhotoSize[] = [ + // Passbild / Ausweis + { id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' }, + { id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' }, + // Kleinformate (Kita, Schule, Portemonnaie) + { id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' }, + { id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' }, + { id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' }, + { id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' }, + { id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' }, + // Klassische Fotoformate + { id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' }, + { id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' }, + { id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' }, + { id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' }, + { id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' }, + { id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' }, + { id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' }, + { id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' }, + { id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' }, + { id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' }, + { id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' }, + { id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' }, + // Quadrate + { id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' }, + { id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' }, + { id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' }, + { id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' }, + // DIN als Bildformat + { id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' }, + { id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' }, + { id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' }, + { id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' }, +]; + +export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null; +export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null; + +/** + * Freitext-Maß → mm. Bewusst großzügig, weil Till Maße so tippt, wie er sie sagt: + * „12x15" → 120 × 150 mm (cm ist die Standardeinheit) + * „12 × 15 cm" → 120 × 150 mm + * „35x45mm" → 35 × 45 mm + * „5" → 50 × 50 mm (quadratisch) + * „4:3 / 15" → Seitenverhältnis 4:3, längere Seite 15 cm → 150 × 112,5 mm + */ +export function parseSizeMm(input: string): { w: number; h: number } | null { + const t = (input || '').trim().toLowerCase().replace(/,/g, '.'); + if (!t) return null; + + // Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm" + const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t); + if (ratio && ratio[3]) { + const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]); + const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10; + if (!a || !b || !long) return null; + const [lo, hi] = a >= b ? [b, a] : [a, b]; + return norm(long * (lo / hi), long); + } + + const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm + const body = t.replace(/\s*(mm|cm)\s*$/, '').trim(); + + const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body); + if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit); + + const sq = /^(\d+(?:\.\d+)?)$/.exec(body); + if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); } + + return null; +} + +function norm(w: number, h: number): { w: number; h: number } | null { + const r = (n: number) => Math.round(n * 100) / 100; + if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null; + return { w: r(w), h: r(h) }; +} + +export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi); +export const mmToPt = (mm: number) => (mm / 25.4) * 72; + +/** Hübsche Beschriftung eines Maßes für die Oberfläche. */ +export function labelMm(w: number, h: number): string { + const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ',')); + return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`; +} +const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ','); diff --git a/src/lib/printlayout.ts b/src/lib/printlayout.ts new file mode 100644 index 0000000..74293ae --- /dev/null +++ b/src/lib/printlayout.ts @@ -0,0 +1,360 @@ +// Reine Layout-Mathematik für den Druckbogen — mm, kein I/O, keine Abhängigkeiten. +// Wird identisch von der Vorschau im Browser und vom PDF-Renderer auf dem Server +// benutzt, damit „was ich sehe" und „was gedruckt wird" dieselbe Rechnung sind. +// +// Koordinaten: Ursprung oben links, x nach rechts, y nach unten (wie Bildschirm/SVG). +// Der PDF-Renderer spiegelt y, weil PDF von unten links zählt. + +export interface PlaceSpec { + id: string; // verweist auf ein Bild + wMm: number; // Endformat (Trimmbox) Breite + hMm: number; // Endformat Höhe + count: number; // wie oft aufs Papier + allowRotate?: boolean; +} + +export interface SheetSpec { + wMm: number; + hMm: number; + marginMm: number; // nicht bedruckbarer Rand des Druckers (Sicherheitsrand) + gapMm: number; // Abstand zwischen zwei Endformaten + bleedMm?: number; // Beschnittzugabe je Seite (Bild ragt darüber hinaus) + center?: boolean; // Block auf dem Bogen zentrieren (Default: true) +} + +export interface Placement { + specId: string; + copy: number; // 1-basiert + x: number; y: number; // linke obere Ecke der Trimmbox + w: number; h: number; // Trimmbox + rotated: boolean; // Bild um 90° gedreht platziert +} + +export interface Line { x1: number; y1: number; x2: number; y2: number } + +export interface Page { placements: Placement[] } + +export interface LayoutResult { + pages: Page[]; + /** Was auf keinen Bogen passt (z. B. Bild größer als das Papier). */ + unplaced: { specId: string; count: number; reason: string }[]; + perSheet: number; // Stück auf dem ersten Bogen (Kennzahl für die UI) +} + +export type MarkMode = 'none' | 'corner' | 'grid'; + +const EPS = 1e-6; + +/** Effektiver Abstand: Beschnittzugaben zweier Nachbarn dürfen sich nicht überlappen. */ +export function effectiveGap(sheet: SheetSpec): number { + return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0)); +} + +/** + * Packt die Endformate auf so wenige Bogen wie möglich. + * + * Zwei Wege, bewusst getrennt: + * 1. Alle Bilder gleich groß → exaktes Raster (Spalten × Zeilen). Perfekt + * ausgerichtet, damit durchgehende Schnittlinien wirklich durchgehen. + * 2. Gemischte Größen → MaxRects (Best-Short-Side-Fit) mit optionaler + * 90°-Drehung. Das ist der Fall „ein großes Bild plus acht Passbilder": + * die Kleinen wandern in die Restfläche neben dem Großen. + * + * Der Abstand wird als „aufgeblasenes" Rechteck mitgeführt (Bild + Abstand), + * deshalb stimmen die Zwischenräume überall, auch am Rand. + */ +export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult { + const gap = effectiveGap(sheet); + const bleed = sheet.bleedMm || 0; + const inset = (sheet.marginMm || 0) + bleed; + const availW = sheet.wMm - 2 * inset; + const availH = sheet.hMm - 2 * inset; + + const unplaced: LayoutResult['unplaced'] = []; + const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = []; + + for (const s of specs) { + const n = Math.max(0, Math.floor(s.count || 0)); + if (!n) continue; + const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS; + const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS; + if (!fitsPlain && !fitsRot) { + unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' }); + continue; + } + for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false }); + } + if (!units.length) return { pages: [], unplaced, perSheet: 0 }; + + const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS); + let pages: Page[]; + + if (uniform) { + pages = gridPack(units, specs, availW, availH, gap); + } else { + // Ausrichtung je Bildformat festlegen (nicht je Exemplar) — sonst liegen + // gleiche Bilder wild durcheinander auf dem Bogen. Alle sinnvollen + // Kombinationen durchprobieren und die beste nehmen. + const ids = [...new Set(units.map((u) => u.specId))]; + const rotatable = ids.filter((id) => { + const sp = specs.find((s) => s.id === id)!; + return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS; + }); + const tooBig = ids.length > 5 || units.length > 150; + const variants: Record[] = tooBig + ? [Object.fromEntries(ids.map((id) => [id, false]))] + : combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)]))); + + let best: Page[] | null = null, bestScore = -Infinity; + for (const [vi, rotMap] of variants.entries()) { + const oriented = units.map((u) => { + const r = !!rotMap[u.specId]; + const sp = specs.find((s) => s.id === u.specId)!; + // Nur Ausrichtungen zulassen, die überhaupt auf den Bogen passen. + const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm; + return { ...u, w, h, rot: r }; + }); + if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue; + const cand = maxRectsPack(oriented, availW, availH, gap); + const placed = cand.reduce((n, p) => n + p.placements.length, 0); + if (placed < units.length) continue; + // Wenige Bogen zählt am meisten, dann volle erste Seite, dann „so wenig + // gedreht wie möglich" (Tills Wunschausrichtung bleibt eher erhalten). + const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi + - 0.1 * Object.values(rotMap).filter(Boolean).length; + if (score > bestScore) { bestScore = score; best = cand; } + } + pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap); + const placed = pages.reduce((n, p) => n + p.placements.length, 0); + if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' }); + } + + // Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke. + for (const pg of pages) { + let offX = inset, offY = inset; + if (sheet.center !== false && pg.placements.length) { + const minX = Math.min(...pg.placements.map((p) => p.x)); + const maxX = Math.max(...pg.placements.map((p) => p.x + p.w)); + const minY = Math.min(...pg.placements.map((p) => p.y)); + const maxY = Math.max(...pg.placements.map((p) => p.y + p.h)); + offX = inset + (availW - (maxX - minX)) / 2 - minX; + offY = inset + (availH - (maxY - minY)) / 2 - minY; + } + for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); } + } + + return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 }; +} + +/** Gleich große Bilder: sauberes Raster, Zeile für Zeile. */ +function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[], + availW: number, availH: number, gap: number): Page[] { + const spec = specs.find((s) => s.id === units[0].specId); + let { w, h } = units[0]; + let rot = false; + const fit = (a: number, b: number) => ({ + cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))), + rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))), + }); + let best = fit(w, h); + if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) { + const alt = fit(h, w); + if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; } + } + const perPage = best.cols * best.rows; + if (!perPage) return []; + + const pages: Page[] = []; + for (let i = 0; i < units.length; i += perPage) { + const chunk = units.slice(i, i + perPage); + const placements: Placement[] = chunk.map((u, k) => { + const c = k % best.cols, r = Math.floor(k / best.cols); + return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot }; + }); + pages.push({ placements }); + } + return pages; +} + +interface FreeRect { x: number; y: number; w: number; h: number } + +/** Gemischte Größen: MaxRects mit Best-Short-Side-Fit, Ausrichtung schon festgelegt. */ +function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[], + availW: number, availH: number, gap: number): Page[] { + // Groß zuerst — sonst blockieren Kleinteile die guten Flächen. + const todo = [...units].sort((a, b) => + (b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) || + a.specId.localeCompare(b.specId) || a.copy - b.copy); + + const W = availW + gap, H = availH + gap; // aufgeblasene Fläche + const pages: Page[] = []; + + while (todo.length) { + let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }]; + const placements: Placement[] = []; + + for (;;) { + let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity; + let bestRect: FreeRect | null = null; + + for (let i = 0; i < todo.length; i++) { + const u = todo[i]; + const cw = u.w + gap, ch = u.h + gap; + for (const r of free) { + if (cw > r.w + EPS || ch > r.h + EPS) continue; + const short = Math.min(r.w - cw, r.h - ch); + // Bei Gleichstand die am weitesten oben/links liegende Fläche nehmen — + // das ergibt ein ruhiges, lesbares Bogenbild statt verstreuter Motive. + const pos = r.y * 10000 + r.x; + if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) { + bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r; + } + } + } + if (bestIdx < 0 || !bestRect) break; + + const u = todo.splice(bestIdx, 1)[0]; + placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot }); + free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap }); + } + + if (!placements.length) break; // nichts platzierbar → Abbruch + placements.sort((a, b) => a.y - b.y || a.x - b.x); + pages.push({ placements }); + if (pages.length > 200) break; // Notbremse + } + return pages; +} + +/** Alle Teilmengen (Ausrichtungs-Kombinationen), stabil sortiert. */ +function combos(ids: string[]): Set[] { + const out: Set[] = []; + const n = Math.min(ids.length, 8); + for (let m = 0; m < (1 << n); m++) { + const s = new Set(); + for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]); + out.push(s); + } + // Erst „nichts gedreht", dann aufsteigend nach Anzahl Drehungen. + return out.sort((a, b) => a.size - b.size); +} + +function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] { + const out: FreeRect[] = []; + for (const r of free) { + const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS + && used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS; + if (!overlap) { out.push(r); continue; } + if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h }); + if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h }); + if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y }); + if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) }); + } + // In anderen enthaltene Rechtecke wegwerfen (sonst wächst die Liste ins Unendliche). + return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) => + j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS && + b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS && + (b.w > a.w + EPS || b.h > a.h + EPS || j < i))); +} + +/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */ +export function capacity(spec: Omit, sheet: SheetSpec): number { + const probe = layout([{ ...spec, count: 200 }], sheet); + return probe.pages[0]?.placements.length || 0; +} + +export interface MarkOptions { + mode: MarkMode; + lengthMm?: number; // Länge einer Marke (Norm: 4–10 mm) + offsetMm?: number; // Abstand vom Endformat nach außen + bleedMm?: number; +} + +/** + * Schnittmarken als Liniensegmente. + * - `corner`: vier Eckenpaare je Bild, außerhalb des Endformats (Adobe-/Druckerei-Konvention). + * - `grid`: durchgehende Linien über den ganzen Bogen an jeder Schnittkante — für + * das Schneidelineal/die Schlagschere, wenn viele gleiche Bilder drauf sind. + */ +export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] { + if (opt.mode === 'none') return []; + const len = opt.lengthMm ?? 4; + const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1); + const lines: Line[] = []; + + if (opt.mode === 'corner') { + for (const p of page.placements) { + const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h; + // je Ecke: eine waagerechte und eine senkrechte Marke, nach außen weisend + for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) { + lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y }); + lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) }); + } + } + return clip(lines, sheet); + } + + // grid: Schnittkanten über den ganzen Bogen ziehen — aber nur dort, wo kein + // anderes Bild im Weg ist. Sonst liefe die Linie quer durchs Nachbarmotiv. + const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w])); + const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h])); + for (const x of xs) { + const blocked = page.placements + .filter((p) => p.x + EPS < x && x < p.x + p.w - EPS) + .map((p) => [p.y, p.y + p.h] as [number, number]); + for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b }); + } + for (const y of ys) { + const blocked = page.placements + .filter((p) => p.y + EPS < y && y < p.y + p.h - EPS) + .map((p) => [p.x, p.x + p.w] as [number, number]); + for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y }); + } + return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2); +} + +const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b); + +/** Freie Abschnitte in [from,to] nach Abzug der belegten Intervalle. */ +function complement(blocked: [number, number][], from: number, to: number): [number, number][] { + const sorted = [...blocked].sort((a, b) => a[0] - b[0]); + const out: [number, number][] = []; + let cur = from; + for (const [a, b] of sorted) { + if (a > cur) out.push([cur, Math.min(a, to)]); + cur = Math.max(cur, b); + if (cur >= to) break; + } + if (cur < to) out.push([cur, to]); + return out.filter(([a, b]) => b - a > 0.2); +} + +/** + * Kleinster Abstand, bei dem die gewählten Marken noch sinnvoll sind: + * Eckmarken zweier Nachbarn brauchen je (Versatz + Länge) Platz, Beschnittzugaben + * dürfen sich nicht überlappen. + */ +export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number { + const off = offsetMm ?? Math.max(2, bleedMm + 1); + const base = 2 * bleedMm; + if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm)); + if (mode === 'grid') return Math.max(base, 0); + return base; +} + +const round = (n: number) => Math.round(n * 1000) / 1000; + +/** Marken, die über den Bogenrand hinausragen würden, abschneiden bzw. verwerfen. */ +function clip(lines: Line[], sheet: SheetSpec): Line[] { + const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v)); + const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v)); + return lines + .map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) })) + .filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2); +} + +/** Reicht die Quellauflösung für das Endformat bei gewünschter dpi? */ +export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } { + const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0; + return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 }; +} diff --git a/src/lib/printrender.ts b/src/lib/printrender.ts new file mode 100644 index 0000000..242c960 --- /dev/null +++ b/src/lib/printrender.ts @@ -0,0 +1,158 @@ +// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib). +// Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis +// vorhersagbar und wiederholbar ist. +import sharp from 'sharp'; +import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; +import { mmToPt, mmToPx } from './paper'; +import type { Line, Page, SheetSpec } from './printlayout'; + +/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */ +export interface CropRel { x: number; y: number; w: number; h: number } + +export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 }; + +/** + * Bringt einen Bildausschnitt exakt auf ein physisches Maß. + * `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt + * exakt der gewählte Ausschnitt. Fehlende Randpixel werden aus der Kante + * fortgeschrieben (extendWith 'copy'), statt das Motiv zu stauchen. + */ +export async function renderCell( + input: Buffer, + crop: CropRel | null, + wMm: number, + hMm: number, + dpi: number, + opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {}, +): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> { + const bleed = Math.max(0, opt.bleedMm || 0); + const meta = await sharp(input, { failOn: 'none' }).metadata(); + const nw = meta.width || 0, nh = meta.height || 0; + if (!nw || !nh) throw new Error('Bildmaße unbekannt.'); + + // Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis + // („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle. + const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm); + // Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox + // exakt das bleibt, was in der Vorschau gerahmt wurde. + const fx = (wMm + 2 * bleed) / wMm; + const fy = (hMm + 2 * bleed) / hMm; + const cw = c.w * fx, ch = c.h * fy; + const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2; + + const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh }; + const left = Math.round(want.left), top = Math.round(want.top); + const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height)); + + const ex = { + left: Math.min(Math.max(0, left), nw - 1), + top: Math.min(Math.max(0, top), nh - 1), + }; + const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left)); + const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top)); + const padLeft = Math.max(0, ex.left - left); + const padTop = Math.max(0, ex.top - top); + const padRight = Math.max(0, width - exW - padLeft); + const padBottom = Math.max(0, height - exH - padTop); + + const targetW = mmToPx(wMm + 2 * bleed, dpi); + const targetH = mmToPx(hMm + 2 * bleed, dpi); + + let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH }); + if (padLeft || padTop || padRight || padBottom) { + img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' }); + } + img = img.resize(targetW, targetH, { fit: 'fill' }); + if (opt.rotate) img = img.rotate(90); + + const hasAlpha = !!meta.hasAlpha; + const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; + const flat = hasAlpha && ext === 'jpg'; + if (flat) img = img.flatten({ background: opt.background || '#ffffff' }); + + const out = await (ext === 'png' + ? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) + : img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }) + ).toBuffer({ resolveWithObject: true }); + + return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] }; +} + +/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */ +export function coverCrop(natW: number, natH: number, aspect: number): CropRel { + const imgA = natW / natH; + const w = imgA > aspect ? aspect / imgA : 1; + const h = imgA > aspect ? 1 : imgA / aspect; + return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }; +} + +function normCrop(c: CropRel | null): CropRel { + if (!c) return FULL_CROP; + const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0)); + let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1); + let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h); + return { x, y, w, h }; +} + +export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' } + +export interface SheetPdfInput { + sheet: SheetSpec; + pages: Page[]; + marksPerPage: Line[][]; + /** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */ + images: Record; + title?: string; + footer?: string | null; + markWidthPt?: number; +} + +/** + * Baut das Druck-PDF. Seitengröße = Bogengröße in mm, 1:1 — beim Drucken + * unbedingt „Tatsächliche Größe / 100 %" wählen, nicht „an Seite anpassen". + */ +export async function buildSheetPdf(input: SheetPdfInput): Promise { + const { sheet, pages, marksPerPage, images } = input; + const pdf = await PDFDocument.create(); + pdf.setTitle(input.title || 'Klarbild Druckbogen'); + pdf.setProducer('Klarbild'); + pdf.setCreator('Klarbild — Druckbogen'); + + const embedded: Record = {}; + 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 bleed = sheet.bleedMm || 0; + const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm); + + pages.forEach((pg, i) => { + const page = pdf.addPage([W, H]); + for (const p of pg.placements) { + const im = embedded[p.specId]; + if (!im) continue; + // Bild inkl. Beschnittzugabe: ragt je Seite um `bleed` über die Trimmbox. + const x = mmToPt(p.x - bleed); + const yTop = p.y - bleed; // von oben gezählt + const w = mmToPt(p.w + 2 * bleed); + const h = mmToPt(p.h + 2 * bleed); + page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h }); + } + for (const l of marksPerPage[i] || []) { + page.drawLine({ + start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) }, + end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) }, + thickness: input.markWidthPt ?? 0.25, + color: rgb(0, 0, 0), + }); + } + if (font && input.footer) { + page.drawText(input.footer, { + x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42), + rotate: degrees(0), + }); + } + }); + + return pdf.save(); +} diff --git a/src/lib/printsource.ts b/src/lib/printsource.ts new file mode 100644 index 0000000..288080f --- /dev/null +++ b/src/lib/printsource.ts @@ -0,0 +1,44 @@ +// Auflösen der Bildquellen für den Druckbogen: frisch hochgeladen oder aus der Bibliothek. +import { one } from './db'; +import { getObject } from './storage'; + +export type PrintSource = + | { kind: 'upload'; path: string } + | { kind: 'item'; id: string }; + +export interface SessionUser { uid: string | null; role: string } + +/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */ +export async function loadSource(src: PrintSource, user: SessionUser): Promise { + if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.'); + + if (src.kind === 'upload') { + // Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc. + const p = String(src.path || ''); + if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.'); + return getObject(p); + } + + if (src.kind === 'item') { + const item = await one( + `SELECT i.result_path, i.filename, j.created_by, j.private + FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]); + if (!item?.result_path) throw new Error('Bild nicht gefunden.'); + const isAdmin = user.role === 'admin'; + const own = item.created_by === user.uid; + if (!isAdmin && !own) { + const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1'); + if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.'); + } + return getObject(item.result_path); + } + + throw new Error('Unbekannte Quellenart.'); +} + +/** Sprechender Dateiname für den Download. */ +export function sheetFilename(prefix: string, ext: string): string { + const date = new Date().toISOString().slice(0, 10); + const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + return `${date}_${slug || 'druckbogen'}.${ext}`; +} diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 07715af..72912e7 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -54,6 +54,37 @@ export async function seed(): Promise { { contour_mm: 3 }); } + // Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen. + { + const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`); + const by = admin?.id ?? null; + const P = async (name: string, config: Record) => { + const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]); + if (exists) return; + await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`, + [name, by, JSON.stringify(config)]); + }; + const sheet = { + paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5, + autoGap: true, gapMm: 14, bleedMm: 0, + marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true, + }; + const F = (sizeId: string, count: number, landscape = false) => + ({ sizeId, customSize: '', landscape, count, allowRotate: true }); + + // Der klassische Kita-/Schulfoto-Satz aus einem Bild. + await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] }); + // Nur die kleinen Abzüge, wenn der große schon gedruckt ist. + await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] }); + // Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist. + await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] }); + // Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier. + await P('2 × 10×7,5 auf Fotopapier 10×15', { + ...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0, + formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }], + }); + } + // The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen). await query( `UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both' diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts index 8742fb7..fa99927 100644 --- a/src/lib/telegram.ts +++ b/src/lib/telegram.ts @@ -6,6 +6,9 @@ import { one, query } from './db'; import { decrypt } from './crypto'; import { getObject, putObject, sourceKey } from './storage'; import { enqueue } from './queue'; +import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout'; +import { renderCell, buildSheetPdf } from './printrender'; +import { photoById, labelMm, paperById } from './paper'; const QUIET_MS = 4000; // Ruhefenster für die Bündelung const BASE = process.env.PUBLIC_BASE_URL || ''; @@ -22,12 +25,18 @@ const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP function mainKeyboard() { return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent(); } +// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig. +const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18']; +const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen" + const HELP_TEXT = 'So funktioniert’s — alles per Knopf, ohne Befehle:\n\n' + '🖼 *Bearbeiten:* Bild(er) hierher schicken (am besten als *Datei*), dann unten das Rezept antippen.\n' + '🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' + '✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' + - '♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n\n' + + '♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' + + '📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' + + 'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' + 'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.'; export async function getToken(): Promise { @@ -126,6 +135,7 @@ export async function sweepDrafts(): Promise { const n = (d.file_refs || []).length; // Ab 2 Bildern zusätzlich „Kombinieren" anbieten. if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); } + kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu'); const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : ''; try { @@ -254,6 +264,63 @@ async function dispatchGenerate(chatId: number, description: string, b: Bot) { await enqueue({ itemId: it!.id, jobId: job!.id }); } +/** + * Druckbogen ohne KI: Bilder des Drafts auf ein Maß bringen, auf A4 setzen, + * Schnittmarken dazu, als PDF zurückschicken. Läuft direkt (keine Warteschlange, + * kein Modell, keine Kosten). + */ +async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) { + const draft = await one('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]); + if (!draft) { await b.api.sendMessage(chatId, 'Kein offener Bild-Stapel gefunden.'); return; } + const size = photoById(sizeId); + if (!size) { await b.api.sendMessage(chatId, 'Unbekanntes Maß.'); return; } + + await b.api.sendMessage(chatId, '📐 Baue den Druckbogen …'); + const sources = await refsToSources(draft.file_refs || [], b); + if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; } + + const a4 = paperById('A4')!; + const sheet: SheetSpec = { + wMm: a4.w, hMm: a4.h, marginMm: 5, + gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true, + }; + // „Bogen füllen": so oft wie möglich, gleichmäßig auf die Bilder verteilt. + let per = count; + if (!per) { + const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet); + per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length)); + } + const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true })); + const plan = layout(specs, sheet); + if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ Das Maß passt nicht auf A4.'); return; } + + const images: Record = {}; + for (const [i, src] of sources.entries()) { + const buf = await getObject(src.source_path); + const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated))); + for (const rot of rots) { + const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot }); + images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext }; + } + } + const pages = plan.pages.map((pg) => ({ + placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })), + })); + const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 })); + const label = labelMm(size.w, size.h); + const bytes = await buildSheetPdf({ + sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`, + footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`, + }); + + const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`); + await b.api.sendDocument(chatId, file, { + caption: `📐 ${plan.pages.length} Bogen · ${plan.pages[0].placements.length} Bild(er) auf Bogen 1 · ${label}\n` + + 'Beim Drucken „Tatsächliche Größe / 100 %" wählen — sonst stimmt das Maß nicht.', + }); + await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]); +} + /** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */ export async function notifyJobDone(chatId: number, jobId: string): Promise { const b = await getBot(); if (!b) return; @@ -369,8 +436,37 @@ function register(b: Bot) { }); b.on('callback_query:data', async (ctx) => { - const [kind, recipeId] = ctx.callbackQuery.data.split(':'); + const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':'); await ctx.answerCallbackQuery(); + if (kind === 'p') { // Druckbogen ohne KI + const draft = await one<{ id: string }>( + `SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print') + ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]); + if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.'); + if (recipeId === 'menu') { + await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]); + const kb = new InlineKeyboard(); + PRINT_SIZES.forEach((id, i) => { + const s = photoById(id); if (!s) return; + kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row(); + }); + return ctx.editMessageText('📐 Welches Endmaß soll das Bild haben?', { reply_markup: kb }); + } + if (extra === undefined) { + const s = photoById(recipeId); + if (!s) return ctx.editMessageText('Unbekanntes Maß.'); + const kb = new InlineKeyboard(); + PRINT_COUNTS.forEach((n, i) => { + kb.text(n ? `${n}×` : 'Bogen füllen', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row(); + }); + return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — wie oft auf den A4-Bogen?`, { reply_markup: kb }); + } + await ctx.editMessageText('Alles klar — Bogen wird gebaut.'); + const b2 = await getBot(); + if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2) + .catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Druckbogen fehlgeschlagen: ${e?.message || e}`)); + return; + } if (kind === 'd') { // Standard-Rezept setzen await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]); return ctx.editMessageText('Standard-Rezept gesetzt.'); diff --git a/src/pages/anleitung.astro b/src/pages/anleitung.astro index 2abe83d..1c6234a 100644 --- a/src/pages/anleitung.astro +++ b/src/pages/anleitung.astro @@ -59,7 +59,50 @@ import Base from '../layouts/Base.astro';
-

5 · Alles per Telegram — ohne Befehle

+

5 · Druck — exakte Maße und Schnittmarken (ohne KI)

+

Der Reiter Druck ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten, + keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.

+
    +
  1. Bilder hochladen (ziehen, ⌘/Strg + V, oder Aus Bibliothek ein fertiges Ergebnis holen).
  2. +
  3. Je Bild das Endmaß wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15, + 12 × 15, 13 × 18 … oder Eigenes Maß: 12x15 (cm), 35x45mm, + 5 (= 5 × 5 cm) oder 4:3/15 (Verhältnis 4:3, längere Kante 15 cm).
  4. +
  5. Auf das Vorschaubild tippen → Zuschnitt: schieben und zoomen. Das Seitenverhältnis bleibt + fest am Zielformat, verzerrt wird nie.
  6. +
  7. Anzahl je Bild setzen — so entsteht der Passbild- oder Kita-Satz.
  8. +
  9. 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ß.
  10. +
  11. 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.
  12. +
  13. Druck-PDF erzeugen. Die PDF-Seite hat exakt das Bogenmaß.
  14. +
+

Wichtig beim Drucken: im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen — + nicht „an Seitengröße anpassen". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann, + setzt den Rand auf 0; sonst 3–5 mm, sonst schneidet der Drucker die Marken weg.

+
    +
  • 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.
  • +
  • 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), + 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, + fertiger Satz.
  • +
  • Randprofile: Randlos (0 mm), Standard (5 mm) oder Sicher (10 mm) + per Knopf, der genaue Wert bleibt frei eintragbar.
  • +
  • Wohin?: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein + Zusatzziel gelegt werden — wie die Bilder aus dem Studio.
  • +
  • Automatische Anordnung: gleich große Bilder ergeben ein sauberes Raster; bei gemischten + Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.
  • +
+

Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.

+
+ +
+

6 · Alles per Telegram — ohne Befehle

Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben. Danach läuft alles über die Knöpfe unten im Chat — kein Tippen von Befehlen nötig.

    @@ -69,13 +112,15 @@ import Base from '../layouts/Base.astro'; „mit unserer Hündin Frieda" — dann „🔀 Kombinieren" antippen. (Ohne Beschreibung fragt der Bot nach.)
  • Neu erzeugen: unten auf „✨ Neues Bild" tippen und beschreiben, was entstehen soll.
  • Weiterbearbeiten: ein fertiges Bild vom Bot einfach wieder zurückschicken.
  • +
  • Druckbogen (ohne KI): Bild schicken → „📐 Druckbogen" → Endmaß und Anzahl antippen. + Zurück kommt ein A4-PDF mit Schnittmarken in 100-%-Größe.
  • Über „❓ Hilfe" gibt es die Kurzanleitung, über „📁 Rezept wählen" das Standard-Rezept.

Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.

-

6 · Sicherung & sauber neu starten (Admin)

+

7 · Sicherung & sauber neu starten (Admin)

Im Admin-Bereich lässt sich eine zweite Sicherung aufs NAS einrichten (SFTP/FTPS, auch über Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit „NAS ✓" markiert. Mit „Alle vorhandenen Bilder aufs NAS sichern" lassen sich auch ältere Bilder nachsichern.

@@ -107,6 +152,8 @@ import Base from '../layouts/Base.astro'; .howto li{margin-bottom:6px;} .howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;} .howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;} + .howto .hinweis{background:var(--accent-bg);border-left:3px solid var(--accent);padding:10px 13px;margin:12px 0;font-size:.92rem;line-height:1.55;} + .howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;} @media print{.topbar,.tabbar,.druck{display:none !important;}.howto section{break-inside:avoid;}} diff --git a/src/pages/api/print/presets.ts b/src/pages/api/print/presets.ts new file mode 100644 index 0000000..ca784b2 --- /dev/null +++ b/src/pages/api/print/presets.ts @@ -0,0 +1,36 @@ +import type { APIRoute } from 'astro'; +import { query, one } from '../../../lib/db'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */ +export const GET: APIRoute = async ({ locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const rows = await query( + `SELECT p.id, p.name, p.config, p.created_at, u.display_name AS by_name + FROM print_presets p LEFT JOIN users u ON u.id = p.created_by + ORDER BY p.name ASC`); + return json({ presets: rows }); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const body = await request.json().catch(() => null) as any; + const name = String(body?.name || '').trim(); + if (!name) return json({ error: 'Name fehlt.' }, 400); + if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400); + const row = await one( + `INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`, + [name.slice(0, 80), locals.user.uid, JSON.stringify(body.config)]); + return json({ preset: row }); +}; + +export const DELETE: APIRoute = async ({ url, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const id = url.searchParams.get('id'); + if (!id) return json({ error: 'id fehlt.' }, 400); + await query('DELETE FROM print_presets WHERE id=$1', [id]); + return json({ ok: true }); +}; diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts new file mode 100644 index 0000000..264558c --- /dev/null +++ b/src/pages/api/print/sheet.ts @@ -0,0 +1,181 @@ +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 { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource'; +import { paperById, parseSizeMm, labelMm } from '../../../lib/paper'; +import { cfgForKey } from '../../../lib/delivery'; +import { uploadBuffer } from '../../../lib/picdrop'; +import { one } from '../../../lib/db'; + +export const prerender = false; + +const MAX_CELLS = 40; // verschiedene Bilder je Bogen +const MAX_COPIES = 200; // Exemplare je Bild +const MAX_PAGES = 20; + +interface CellIn { + id: string; + src: PrintSource; + crop?: CropRel | null; + wMm?: number; hMm?: number; + size?: string; // Freitext („12x15", „35x45mm") als Alternative zu wMm/hMm + count?: number; + allowRotate?: boolean; + landscape?: boolean; // Bildformat quer statt hoch +} + +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + let body: any; + try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); } + + try { + const sheet = readSheet(body); + const marksMode: MarkMode = ['none', 'corner', 'grid'].includes(body?.marks?.mode) ? body.marks.mode : 'corner'; + const dpi = clamp(Number(body?.dpi) || 300, 72, 1200); + const wantExt: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg'; + + const cellsIn: CellIn[] = Array.isArray(body?.cells) ? body.cells.slice(0, MAX_CELLS) : []; + if (!cellsIn.length) return json({ error: 'Keine Bilder angegeben.' }, 400); + + const specs: PlaceSpec[] = []; + const resolved: { cell: CellIn; wMm: number; hMm: number }[] = []; + 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, + }); + resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h }); + } + + const plan = layout(specs, sheet); + if (!plan.pages.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. + const images: Record = {}; + const needRotated = new Set(); + const needPlain = new Set(); + for (const pg of plan.pages) for (const p of pg.placements) (p.rotated ? needRotated : needPlain).add(p.specId); + + for (const r of resolved) { + const buf = await loadSource(r.cell.src, locals.user as any); + if (needPlain.has(r.cell.id)) { + const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi, + { ext: wantExt, bleedMm: sheet.bleedMm, rotate: false }); + images[r.cell.id] = { bytes: out.buffer, ext: out.ext }; + } + if (needRotated.has(r.cell.id)) { + const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi, + { ext: wantExt, bleedMm: sheet.bleedMm, rotate: true }); + images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext }; + } + } + + // Platzierungen auf die passende Bildfassung umbiegen. + const pages = plan.pages.map((pg) => ({ + placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })), + })); + + const marks = plan.pages.map((pg) => + cutMarks(pg, sheet, { + mode: marksMode, + lengthMm: numOr(body?.marks?.lengthMm, 4, 1, 20), + offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined, + bleedMm: sheet.bleedMm, + })); + + 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")`; + + const bytes = await buildSheetPdf({ + sheet, pages, marksPerPage: marks, images, + title: String(body?.title || 'Klarbild Druckbogen'), + footer, + markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2), + }); + + const filename = sheetFilename(body?.name || 'druckbogen', 'pdf'); + + // Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben + // (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern. + let delivered = '0', deliveryMsg = ''; + if (body?.deliver) { + const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target); + try { + const cfg = await cfgForKey(key); + if (!cfg) throw new Error('Ziel nicht konfiguriert.'); + const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1'); + const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA'; + await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes)); + delivered = '1'; + deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`; + } catch (e: any) { + deliveryMsg = `Auslieferung fehlgeschlagen: ${e?.message || e}`; + } + } + + return new Response(Buffer.from(bytes), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'X-Klarbild-Pages': String(pages.length), + 'X-Klarbild-Per-Sheet': String(plan.perSheet), + 'X-Klarbild-Delivered': delivered, + ...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}), + }, + }); + } catch (e: any) { + return json({ error: e?.message || 'Druckbogen konnte nicht erzeugt werden.' }, 400); + } +}; + +function readSheet(body: any): SheetSpec { + let w: number | null = null, h: number | null = null; + const p = body?.paper; + if (typeof p === 'string') { const pp = paperById(p); if (pp) { w = pp.w; h = pp.h; } } + else if (p && typeof p === 'object') { + if (p.id) { const pp = paperById(String(p.id)); if (pp) { w = pp.w; h = pp.h; } } + if (p.wMm && p.hMm) { w = Number(p.wMm); h = Number(p.hMm); } + if (!w && p.size) { const s = parseSizeMm(String(p.size)); if (s) { w = s.w; h = s.h; } } + } + if (!w || !h) throw new Error('Papierformat fehlt oder ist unbekannt.'); + if (body?.landscape) [w, h] = [h, w]; + if (w < 20 || h < 20 || w > 2000 || h > 2000) throw new Error('Papierformat außerhalb des zulässigen Bereichs.'); + + const bleedMm = numOr(body?.bleedMm, 0, 0, 10); + const sheet: SheetSpec = { + wMm: w, hMm: h, + marginMm: numOr(body?.marginMm, 5, 0, 50), + gapMm: numOr(body?.gapMm, 4, 0, 100), + bleedMm, + center: body?.center !== false, + }; + sheet.gapMm = effectiveGap(sheet); + return sheet; +} + +function cellSize(c: CellIn): { w: number; h: number } | null { + let s: { w: number; h: number } | null = null; + if (Number(c.wMm) > 0 && Number(c.hMm) > 0) s = { w: Number(c.wMm), h: Number(c.hMm) }; + else if (c.size) s = parseSizeMm(String(c.size)); + if (!s || s.w < 5 || s.h < 5 || s.w > 2000 || s.h > 2000) return null; + return c.landscape ? { w: s.h, h: s.w } : s; +} + +const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); +const numOr = (v: any, def: number, lo: number, hi: number) => { + const n = Number(v); + return Number.isFinite(n) ? clamp(n, lo, hi) : def; +}; diff --git a/src/pages/api/print/single.ts b/src/pages/api/print/single.ts new file mode 100644 index 0000000..98b3006 --- /dev/null +++ b/src/pages/api/print/single.ts @@ -0,0 +1,61 @@ +import type { APIRoute } from 'astro'; +import { renderCell, type CropRel } from '../../../lib/printrender'; +import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource'; +import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper'; +import { dpiCheck } from '../../../lib/printlayout'; + +export const prerender = false; + +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +/** + * Ein einzelnes Bild exakt auf ein Maß bringen — ohne KI, ohne Bogen. + * Antwort: die fertige Bilddatei (PNG/JPG) mit dpi-Metadaten. + */ +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + let body: any; + try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); } + + try { + const src: PrintSource = body?.src; + const crop: CropRel | null = body?.crop ?? null; + let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0) + ? { w: Number(body.wMm), h: Number(body.hMm) } + : parseSizeMm(String(body?.size || '')); + if (!size) return json({ error: 'Maß fehlt oder ist ungültig.' }, 400); + if (body?.landscape) size = { w: size.h, h: size.w }; + if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Maß außerhalb des zulässigen Bereichs.' }, 400); + + const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300)); + const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg'; + const buf = await loadSource(src, locals.user as any); + const out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 }); + + // Hinweis, falls die Quelle für echte 300 dpi zu klein ist. + const cropW = (crop?.w ?? 1) * out.srcPx[0]; + const check = dpiCheck(cropW, size.w, dpi); + + return new Response(out.buffer, { + headers: { + 'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg', + 'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`, + 'X-Klarbild-Px': `${out.width}x${out.height}`, + 'X-Klarbild-Real-Dpi': String(check.dpi), + 'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0', + }, + }); + } catch (e: any) { + return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400); + } +}; + +/** Kleine Hilfe für Skripte/MCP: Maß → Pixel bei dpi. */ +export const GET: APIRoute = async ({ url, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const size = parseSizeMm(url.searchParams.get('size') || ''); + const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300)); + if (!size) return json({ error: 'Parameter size fehlt (z. B. 12x15 oder 35x45mm).' }, 400); + return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) }); +}; diff --git a/src/pages/druck.astro b/src/pages/druck.astro new file mode 100644 index 0000000..382d8b5 --- /dev/null +++ b/src/pages/druck.astro @@ -0,0 +1,16 @@ +--- +import Base from '../layouts/Base.astro'; +import PrintApp from '../components/PrintApp.tsx'; +--- + +

Druck

+

+ Bilder ohne KI auf exakte Maße bringen und mehrere davon in 100 %-Größe mit + Schnittmarken auf einen Bogen setzen — Passbildsatz, Kita-Bilder, Sticker. +

+ + + diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index fcb4845..9329c4c 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -12,8 +12,10 @@ const BODY = `# Klarbild > Selbst gehostetes KI-Bildwerkzeug: verwandelt Screenshots und Fundstücke in > saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate, > The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS) -> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Bedienung über Web, -> Telegram-Bot und HTTP-API/MCP. +> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein +> KI-freies Druckmodul: Bilder exakt auf physische Maße bringen und mehrere +> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF). +> Bedienung über Web, Telegram-Bot und HTTP-API/MCP. Basis-URL: https://klarbild.heidrich-digital.de Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss. @@ -57,11 +59,44 @@ Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · p - GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild), ?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP). +## Druckmodul (ohne KI, /druck) +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,K60x90, + S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70, + Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3. +- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang, + 3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv). +- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt. + Der Abstand zwischen zwei Bildern ist immer ≥ 2 × Beschnittzugabe. +- 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". + +- 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? }. + Antwort: die Bilddatei. Header: X-Klarbild-Px, X-Klarbild-Real-Dpi, X-Klarbild-Dpi-Ok. +- 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? }] }. + Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet. +- 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 + klont das Bild in alle Formate. +- Bogen ausliefern: \`deliver: { target: ''|'nas'|, gallery? }\` in /api/print/sheet legt das PDF + zusätzlich ins Ziel. Antwort bleibt das PDF; Header X-Klarbild-Delivered (0|1) und + X-Klarbild-Delivery-Msg (URI-kodiert). + ## MCP-Server (für Assistenten) Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token: - list_recipes — verfügbare Rezepte. - process_images — Bilder (lokaler Ordner / URLs) mit einem Rezept verarbeiten lassen. - job_status — Fortschritt/Ergebnis eines Auftrags abfragen. +- exact_size — OHNE KI: ein Bild exakt auf ein Maß bringen, Datei lokal speichern. +- print_sheet — OHNE KI: Druckbogen (mehrere Bilder × Stückzahl, Schnittmarken) als PDF lokal speichern. ## Auslieferung - Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie diff --git a/tests/printlayout.test.ts b/tests/printlayout.test.ts new file mode 100644 index 0000000..2150354 --- /dev/null +++ b/tests/printlayout.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout'; +import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper'; + +const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true }; + +test('parseSizeMm versteht Tills Schreibweisen', () => { + assert.deepEqual(parseSizeMm('12x15'), { w: 120, h: 150 }); + assert.deepEqual(parseSizeMm('12 × 15 cm'), { w: 120, h: 150 }); + assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 }); + assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 }); + assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 }); + assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 }); + assert.equal(parseSizeMm('quatsch'), null); + assert.equal(parseSizeMm('9999x1'), null); +}); + +test('mm → px ist exakt (Druckmaß-Nachweis)', () => { + assert.equal(mmToPx(100, 300), 1181); // 10 cm + assert.equal(mmToPx(150, 300), 1772); // 15 cm + assert.equal(mmToPx(300, 300), 3543); // 30 cm → wie Poster 30×40 + assert.equal(mmToPx(400, 300), 4724); + assert.equal(mmToPx(35, 300), 413); // Passbild + assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt +}); + +test('Passbild-Bogen: 35×45 mm auf A4 ergibt 20 Stück', () => { + assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20); +}); + +test('Platzierungen überlappen nicht und bleiben im Satzspiegel', () => { + const sheet: SheetSpec = { ...A4, gapMm: 6 }; + const res = layout([ + { id: 'a', wMm: 90, hMm: 130, count: 2 }, + { id: 'b', wMm: 35, hMm: 45, count: 6 }, + ], sheet); + assert.ok(res.pages.length >= 1); + for (const pg of res.pages) { + for (const p of pg.placements) { + assert.ok(p.x >= sheet.marginMm - 1e-6, 'linker Rand'); + assert.ok(p.y >= sheet.marginMm - 1e-6, 'oberer Rand'); + assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'rechter Rand'); + assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'unterer Rand'); + } + for (let i = 0; i < pg.placements.length; i++) + for (let j = i + 1; j < pg.placements.length; j++) { + const a = pg.placements[i], b = pg.placements[j]; + const overlap = a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6 + && a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6; + assert.ok(!overlap, `Überlappung ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`); + } + } +}); + +test('Abstand hält die Beschnittzugabe der Nachbarn auseinander', () => { + const sheet: SheetSpec = { ...A4, gapMm: 1, bleedMm: 3 }; + assert.equal(effectiveGap(sheet), 6); + const res = layout([{ id: 'a', wMm: 50, hMm: 50, count: 4 }], { ...sheet, gapMm: effectiveGap(sheet) }); + const row = res.pages[0].placements.filter((p) => Math.abs(p.y - res.pages[0].placements[0].y) < 1e-6) + .sort((a, b) => a.x - b.x); + assert.ok(row.length >= 2); + assert.ok(row[1].x - (row[0].x + row[0].w) >= 6 - 1e-6); +}); + +test('Eckmarken liegen außerhalb des Endformats', () => { + const res = layout([{ id: 'a', wMm: 90, hMm: 130, count: 1 }], A4); + const pg = res.pages[0]; + const lines = cutMarks(pg, A4, { mode: 'corner', lengthMm: 4, offsetMm: 3 }); + assert.equal(lines.length, 8, '4 Ecken × 2 Marken'); + const p = pg.placements[0]; + for (const l of lines) { + const inside = (x: number, y: number) => + x > p.x + 1e-6 && x < p.x + p.w - 1e-6 && y > p.y + 1e-6 && y < p.y + p.h - 1e-6; + assert.ok(!inside(l.x1, l.y1) && !inside(l.x2, l.y2), 'Marke ragt ins Bild'); + } +}); + +test('Durchgehende Linien laufen nicht durch andere Bilder', () => { + const sheet: SheetSpec = { ...A4, gapMm: 4 }; + const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 6 }], sheet); + const pg = res.pages[0]; + const lines = cutMarks(pg, sheet, { mode: 'grid' }); + assert.ok(lines.length > 0); + for (const l of lines) { + for (const p of pg.placements) { + const hitsX = Math.min(l.x1, l.x2) < p.x + p.w - 1e-3 && Math.max(l.x1, l.x2) > p.x + 1e-3; + const hitsY = Math.min(l.y1, l.y2) < p.y + p.h - 1e-3 && Math.max(l.y1, l.y2) > p.y + 1e-3; + assert.ok(!(hitsX && hitsY), 'Linie schneidet ein Motiv'); + } + } +}); + +test('Zu großes Bild landet in unplaced statt im Nirwana', () => { + const res = layout([{ id: 'gross', wMm: 300, hMm: 400, count: 1 }], A4); + assert.equal(res.pages.length, 0); + assert.equal(res.unplaced[0].specId, 'gross'); +}); + +test('Mehr als ein Bogen, wenn die Menge nicht passt', () => { + const res = layout([{ id: 'a', wMm: 35, hMm: 45, count: 25 }], A4); + assert.equal(res.pages.length, 2); + assert.equal(res.pages[0].placements.length, 20); + assert.equal(res.pages[1].placements.length, 5); +}); + +test('Drehen erlaubt mehr Bilder auf 10×15-Fotopapier', () => { + const foto: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true }; + const ohne = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, foto); + const mit = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, foto); + assert.equal(ohne, 3); + assert.ok(mit >= 3); +}); + +test('Empfohlener Abstand macht Platz für Marken', () => { + assert.equal(recommendedGap('corner', 0, 4, 3), 14); + assert.equal(recommendedGap('none', 3), 6); + assert.equal(recommendedGap('grid', 0), 0); +}); + +test('dpi-Warnung greift bei zu kleiner Quelle', () => { + assert.equal(dpiCheck(1181, 100, 300).ok, true); + assert.equal(dpiCheck(600, 100, 300).ok, false); + assert.equal(dpiCheck(1181, 100, 300).dpi, 300); +}); + +test('Formattabellen sind konsistent', () => { + assert.equal(paperById('A4')!.w, 210); + assert.equal(paperById('A3plus')!.h, 483); + assert.equal(photoById('P35x45')!.w, 35); + assert.equal(photoById('S12x15')!.h, 150); + assert.equal(labelMm(35, 45), '35 × 45 mm'); + assert.equal(labelMm(120, 150), '12 × 15 cm'); +});