Compare commits

..

8 Commits

Author SHA1 Message Date
till f0442a617c docs: seeded templates and sheet delivery in llms.txt 2026-08-18 06:40:51 +00:00
till 4fb34cdab8 feat: sheet templates, margin profiles and delivering the sheet
Seeds the four sheet setups Till actually prints (Kita set, small school set,
full passport sheet, two 10x7,5 on 10x15 paper). A multi-format template
applied to a single image clones it into every format, so one click yields the
whole set. Margin gets one-click borderless / 5 mm / 10 mm profiles, and the
finished PDF can be pushed to Picdrop, the NAS or any extra target.
2026-08-18 06:40:31 +00:00
till 21a0a68972 docs: print module in help page, llms.txt, README and changelog 2026-08-18 06:20:46 +00:00
till 32f3b91779 feat: print sheets via Telegram and MCP
Telegram gains a fully button-driven print flow (photo -> size -> count ->
A4 PDF with corner marks), running inline without the queue or a model. MCP
gains exact_size and print_sheet so an assistant can produce print-ready files
from a local folder. Keeps the rule that every feature works on all three ways in.
2026-08-18 06:20:46 +00:00
till cd9081823d feat: print page with crop editor and live sheet preview
New Druck tab: upload or pick from the library, choose a target size and copy
count per image, pan/zoom crop on a locked aspect ratio, and a live preview
that renders the same layout the PDF will contain. Sheet controls for paper,
margin, gap, bleed, crop marks, dpi and templates.
2026-08-18 06:20:46 +00:00
till e8b3fde652 feat: print API - exact single image, sheet PDF, sheet presets
POST /api/print/single (image at an exact size, dpi headers incl. a real-dpi
warning), GET /api/print/single as a mm-to-px calculator, POST /api/print/sheet
(multi-image sheet as PDF, limits on cells/copies/pages) and print_presets for
reusable sheet setups.
2026-08-18 06:20:46 +00:00
till a593dea6c5 feat: exact-size rendering and 1:1 sheet PDF (sharp + pdf-lib)
renderCell brings a relative crop onto an exact physical size; missing bleed
pixels are copied from the edge instead of stretching the motif. Without a
crop it falls back to a centred cover crop, never a distorted fill.
buildSheetPdf writes a PDF page that is exactly the sheet size in mm with
hairline crop marks and an optional footer. Source resolution guards uploads
to the sources/ prefix and reuses the library visibility rules.
2026-08-18 06:20:46 +00:00
till a651025422 feat: mm-based print layout math and paper/photo size tables
Pure geometry, no I/O: DIN A6-A2 incl. A3+ (329x483), photo-paper cuts,
US Letter/Legal, biometric and small photo formats, plus a forgiving free
size parser (12x15 cm, 35x45mm, 5, 4:3/15). Packing: exact grid for uniform
sizes, MaxRects with one fixed orientation per format for mixed sets, so
small prints fill the space next to a large one. Crop marks as line segments
(corner marks outside the trim box; continuous guides that never cross a
motif). Covered by tests/printlayout.test.ts.
2026-08-18 06:20:45 +00:00
22 changed files with 2828 additions and 14 deletions
+40
View File
@@ -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 A6A2 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** (010 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
+17 -2
View File
@@ -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 A6A2 inkl. **A3+**, Fotopapier 9×1320×30, Letter/Legal
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
Beschnittzugabe 010 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
+72 -2
View File
@@ -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-<maß>.<ext>)' },
}, 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;
+14
View File
@@ -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'));
+547
View File
@@ -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",
+6 -3
View File
@@ -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"
}
}
+808
View File
@@ -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<Cell[]>([]);
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<MarkMode>('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<string>(''); // '' = nur herunterladen
const [gallery, setGallery] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [picker, setPicker] = useState(false);
const [presets, setPresets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
const [toast, setToast] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileRef = useRef<HTMLInputElement>(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<Cell>) => 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 (
<div className="druck">
{/* ---------- 1 · Bilder ---------- */}
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">1 · Bilder</span>
<div className="kopf-tools">
<button className="link" onClick={() => setPicker(true)}>Aus Bibliothek</button>
{cells.length > 0 && <button className="link" onClick={() => setCells([])}>Alle entfernen</button>}
</div>
</div>
<div className="buehne">
{cells.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen, <span className="kbd">V</span> einfügen oder klicken.<br />
Kein KI-Schritt nur zuschneiden, skalieren, setzen.</p>
</div>
) : (
<div className="zellen">
{cells.map((c) => <CellCard key={c.id} cell={c} dpi={dpi}
onPatch={(u) => patch(c.id, u)}
onEdit={() => setEditing(c.id)}
onSingle={() => makeSingle(c)}
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)}
<button className="zelle-add" onClick={() => fileRef.current?.click()}> Bild</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && addFiles(e.target.files)} />
</div>
</section>
<div className="raster">
{/* ---------- 2 · Bogen ---------- */}
<section className="karte">
<div className="kopfzeile"><span className="mono-label">2 · Bogen &amp; Schnitt</span>
<button className="link" onClick={savePreset}>Als Vorlage sichern</button>
</div>
<div className="steuer">
<div className="feld">
<label>Vorlagen</label>
<div className="reihe">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyPreset(presets.find((p) => p.id === e.target.value)); }}>
<option value="">Vorlage wählen </option>
{presets.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{presetSel && <button className="pbtn" onClick={() => deletePreset(presetSel)}></button>}
</div>
</div>
<div className="feld">
<label>Papierformat</label>
<select className="select" value={paperId} onChange={(e) => setPaperId(e.target.value)}>
{groupBy(PAPERS, 'group').map(([g, list]) => (
<optgroup key={g} label={g}>
{list.map((p) => <option key={p.id} value={p.id}>{p.label} · {p.w} × {p.h} mm</option>)}
</optgroup>
))}
<option value="custom">Eigenes Papiermaß </option>
</select>
{paperId === 'custom' && (
<input className="input" value={paperCustom} onChange={(e) => setPaperCustom(e.target.value)}
placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" />
)}
<div className="schalter">
<button className={!paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(false)}>Hoch</button>
<button className={paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(true)}>Quer</button>
</div>
{paper && <div className="fein">Bogen {labelMm(paper.w, paper.h)} die PDF-Seite hat exakt dieses Maß.</div>}
</div>
<div className="feld">
<label>Schnitthilfen</label>
<div className="schalter">
<button className={marks === 'none' ? 'an' : ''} onClick={() => setMarks('none')}>Keine</button>
<button className={marks === 'corner' ? 'an' : ''} onClick={() => setMarks('corner')}>Eckmarken</button>
<button className={marks === 'grid' ? 'an' : ''} onClick={() => setMarks('grid')}>Durchgehend</button>
</div>
<div className="fein">
{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.'}
</div>
{marks === 'corner' && (
<div className="reihe zwei">
<label className="mini">Länge (mm)
<input className="input" type="number" min={1} max={20} step={0.5} value={markLen}
onChange={(e) => setMarkLen(Number(e.target.value))} /></label>
<label className="mini">Abstand (mm)
<input className="input" type="number" min={0} max={20} step={0.5} value={markOff}
onChange={(e) => setMarkOff(Number(e.target.value))} /></label>
</div>
)}
</div>
<div className="feld">
<label>Ränder &amp; Abstände</label>
<div className="schalter zart">
<button className={marginMm === 0 ? 'an' : ''} onClick={() => setMarginMm(0)}>Randlos</button>
<button className={marginMm === 5 ? 'an' : ''} onClick={() => setMarginMm(5)}>Standard 5 mm</button>
<button className={marginMm === 10 ? 'an' : ''} onClick={() => setMarginMm(10)}>Sicher 10 mm</button>
</div>
<div className="reihe zwei">
<label className="mini">Rand zum Papier (mm)
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
onChange={(e) => setMarginMm(Number(e.target.value))} /></label>
<label className="mini">Beschnittzugabe (mm)
<input className="input" type="number" min={0} max={10} step={0.5} value={bleedMm}
onChange={(e) => setBleedMm(Number(e.target.value))} /></label>
</div>
<label className="check">
<input type="checkbox" checked={autoGap} onChange={(e) => setAutoGap(e.target.checked)} />
<span>Abstand automatisch ({gapEff.toFixed(1)} mm passend zu den Marken)</span>
</label>
{!autoGap && (
<input className="input" type="number" min={0} max={60} step={0.5} value={gapMm}
onChange={(e) => setGapMm(Number(e.target.value))} />
)}
<label className="check">
<input type="checkbox" checked={center} onChange={(e) => setCenter(e.target.checked)} />
<span>Auf dem Bogen zentrieren</span>
</label>
<div className="fein">Randlos druckbare Drucker: Rand 0 setzen. Sonst 35 mm sonst schneidet der Drucker Marken weg.</div>
</div>
<div className="feld">
<label>Qualität</label>
<div className="reihe zwei">
<label className="mini">Auflösung (dpi)
<select className="select" value={dpi} onChange={(e) => setDpi(Number(e.target.value))}>
<option value={150}>150 Entwurf</option>
<option value={300}>300 Fotodruck (Standard)</option>
<option value={600}>600 sehr fein</option>
</select></label>
<label className="mini">Dateiformat im PDF
<select className="select" value={ext} onChange={(e) => setExt(e.target.value as any)}>
<option value="jpg">JPG kleiner</option>
<option value="png">PNG verlustfrei</option>
</select></label>
</div>
<label className="check">
<input type="checkbox" checked={footer} onChange={(e) => setFooter(e.target.checked)} />
<span>Fußzeile mit Maßen &amp; Druckhinweis</span>
</label>
</div>
<div className="feld">
<label>Wohin?</label>
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}>
<option value="">Nur herunterladen</option>
<option value="picdrop">Zusätzlich an Picdrop (Standard)</option>
<option value="nas">Zusätzlich aufs NAS</option>
{targets.map((t) => <option key={t.id} value={t.id}>Zusätzlich an {t.name}"</option>)}
</select>
{deliverTo && (
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
)}
<div className="fein">Der fertige Bogen wird als PDF geladen — auf Wunsch zusätzlich ins gewohnte Ziel gelegt.</div>
</div>
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut …</> : 'Druck-PDF erzeugen'}
</button>
<div className="fein mitte">Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen nicht an Seitengröße anpassen".</div>
</div>
</section>
{/* ---------- 3 · Vorschau ---------- */}
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">3 · Vorschau</span>
{plan && <span className="fein nopad">{plan.pages.length} Bogen · {totalPieces} Bilder</span>}
</div>
<div className="vorschau">
{!sheet ? <p className="leer">Bitte ein gültiges Papiermaß angeben.</p>
: !plan || !plan.pages.length ? <p className="leer">Noch nichts zu zeigen — Bilder hinzufügen und Formate wählen.</p>
: plan.pages.map((pg, i) => (
<div key={i} className="bogen-halter">
<div className="bogen" style={{ aspectRatio: `${sheet.wMm} / ${sheet.hMm}` }}>
{pg.placements.map((p, k) => {
const c = cells.find((x) => x.id === p.specId);
if (!c) return null;
return (
<div key={k} className="slot" style={{
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
}}>
<div className="rotor" style={p.rotated ? {
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
transform: 'translate(-50%, -50%) rotate(90deg)',
} : undefined}>
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
</div>
</div>
);
})}
<svg className="marken" viewBox={`0 0 ${sheet.wMm} ${sheet.hMm}`} preserveAspectRatio="none">
{(marksPerPage[i] || []).map((l, k) => (
<line key={k} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
stroke="#16150F" strokeWidth={0.25} vectorEffect="non-scaling-stroke" />
))}
</svg>
</div>
<div className="bogen-fuss">Bogen {i + 1} · {pg.placements.length} Bilder</div>
</div>
))}
{plan?.unplaced?.length ? (
<p className="warnung">Nicht platzierbar: {plan.unplaced.map((u) => {
const c = cells.find((x) => x.id === u.specId);
return `${c?.name || u.specId} (${u.reason})`;
}).join(', ')}</p>
) : null}
</div>
</section>
</div>
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
onChange={(crop) => patch(editCell.id, { crop })} />}
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />}
{toast && <div className="toast">{toast}</div>}
<PrintStyles />
</div>
);
}
/* ---------------- 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<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
const m = new Map<string, T[]>();
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<Cell>) => 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 (
<div className={`zelle ${cell.error ? 'err' : ''}`}>
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button>
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }}
onClick={() => !cell.uploading && onEdit()}>
{cell.preview
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
: <span className="lade" />}
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
</div>
<div className="zelle-steuer">
<select className="select mini-select" value={cell.sizeId} onChange={(e) => onPatch({ sizeId: e.target.value })}>
{groupBy(PHOTO_SIZES, 'group').map(([g, list]) => (
<optgroup key={g} label={g}>
{list.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
<option value="custom">Eigenes Maß …</option>
</select>
{cell.sizeId === 'custom' && (
<input className="input mini-input" value={cell.customSize} onChange={(e) => onPatch({ customSize: e.target.value })}
placeholder="12x15 · 35x45mm · 4:3/15" />
)}
<div className="zelle-reihe">
<div className="schalter winzig">
<button className={!cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: false })}>Hoch</button>
<button className={cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: true })}>Quer</button>
</div>
<div className="anzahl">
<button onClick={() => onPatch({ count: Math.max(1, cell.count - 1) })}></button>
<input type="number" min={1} max={200} value={cell.count}
onChange={(e) => onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} />
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
</div>
</div>
<label className="check winzig">
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
<span>darf gedreht platziert werden</span>
</label>
{cell.error ? <div className="fein warn">{cell.error}</div> : s && (
<div className={`fein ${check && !check.ok ? 'warn' : ''}`}>
{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`)}
</div>
)}
<button className="pbtn breit" disabled={!cell.src} onClick={onSingle}>Einzeln herunterladen</button>
</div>
</div>
);
}
/** 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<CropRel>(cell.crop);
const boxRef = useRef<HTMLDivElement>(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 (
<div className="modal" onClick={onClose}>
<div className="modal-inhalt" onClick={(e) => e.stopPropagation()}>
<div className="kopfzeile">
<span className="mono-label">Zuschnitt · {s ? labelMm(s.w, s.h) : ''}</span>
<button className="link" onClick={onClose}>Schließen</button>
</div>
<div className="crop-buehne">
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
<div className="crop-gitter" />
</div>
</div>
<div className="crop-steuer">
<label className="mini breit">Zoom
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
onChange={(e) => setZoom(Number(e.target.value))} />
</label>
<div className="reihe">
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button>
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button>
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
</div>
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div>
</div>
</div>
</div>
);
}
function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/items').then((r) => r.json())
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="modal" onClick={onClose}>
<div className="modal-inhalt breit" onClick={(e) => e.stopPropagation()}>
<div className="kopfzeile">
<span className="mono-label">Aus der Bibliothek wählen</span>
<button className="link" onClick={onClose}>Schließen</button>
</div>
<div className="picker">
{loading ? <p className="leer">Wird geladen …</p>
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
: items.map((it) => (
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
<span>{it.filename}</span>
</button>
))}
</div>
</div>
</div>
);
}
function PrintStyles() {
return <style>{`
.druck{display:flex;flex-direction:column;gap:16px;}
.raster{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;align-items:start;}
@media(max-width:900px){.raster{grid-template-columns:1fr;}}
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);}
.kopf-tools{display:flex;gap:12px;}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:0;}
.buehne{padding:16px;}
.drop{width:100%;min-height:180px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
.zellen{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;}
.zelle{position:relative;border:1px solid var(--line);border-radius:var(--radius-sm);background:#fff;padding:9px;display:flex;flex-direction:column;gap:8px;}
.zelle.err{border-color:var(--err);}
.zelle-x{position:absolute;top:5px;right:5px;z-index:3;width:22px;height:22px;border:none;border-radius:50%;background:rgba(22,21,15,.72);color:#fff;cursor:pointer;font-size:13px;line-height:1;}
.zelle-bild{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:3px;cursor:zoom-in;
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
.zelle-bild img{display:block;}
.zelle-lupe{position:absolute;left:0;right:0;bottom:0;background:rgba(22,21,15,.62);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;text-align:center;padding:3px 0;}
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.zelle-steuer{display:flex;flex-direction:column;gap:6px;}
.zelle-reihe{display:flex;gap:6px;align-items:center;}
.zelle-add{border:1.5px dashed var(--line);background:none;border-radius:var(--radius-sm);cursor:pointer;font-size:14px;color:var(--soft);min-height:120px;font-family:inherit;}
.anzahl{display:flex;align-items:center;border:1px solid var(--line);border-radius:3px;overflow:hidden;background:#fff;}
.anzahl button{width:28px;height:30px;border:none;background:none;cursor:pointer;font-size:15px;color:var(--ink);}
.anzahl input{width:44px;height:30px;border:none;text-align:center;font-family:var(--font-mono);font-size:13px;outline:none;-moz-appearance:textfield;}
.anzahl input::-webkit-outer-spin-button,.anzahl input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
.input,.select{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
.mini-select,.mini-input{padding:6px 8px;font-size:12.5px;}
.input:focus,.select:focus{border-color:var(--accent);}
.reihe{display:flex;gap:6px;align-items:center;margin-top:6px;}
.reihe .select{flex:1;}
.reihe.zwei{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.mini{display:block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);}
.mini .input,.mini .select{margin-top:4px;}
.mini.breit{width:100%;}
.mini input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.fein.nopad{margin:0;}
.fein.mitte{text-align:center;}
.fein.warn{color:var(--err);}
.check{display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12.5px;color:var(--ink);text-transform:none;letter-spacing:0;font-family:inherit;}
.check input{width:15px;height:15px;accent-color:var(--accent);margin-top:2px;flex:0 0 auto;}
.check.winzig{font-size:11px;color:var(--soft);}
.schalter{display:flex;gap:6px;margin-top:8px;}
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12px;padding:6px 4px;}
.schalter.winzig{margin:0;flex:1;}
.schalter.winzig button{padding:5px 4px;font-size:11.5px;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.pbtn.breit{width:100%;}
.pbtn:disabled{opacity:.5;cursor:not-allowed;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.knopf.schmal{width:auto;padding:9px 16px;font-size:13.5px;}
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
@keyframes sp{to{transform:rotate(360deg);}}
.vorschau{padding:16px;display:flex;flex-direction:column;gap:16px;}
.leer{color:var(--soft);font-size:13px;margin:0;text-align:center;padding:26px 0;}
.bogen-halter{display:flex;flex-direction:column;gap:6px;}
.bogen{position:relative;width:100%;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;}
.slot{position:absolute;overflow:hidden;}
.rotor{position:absolute;left:50%;top:50%;width:100%;height:100%;transform:translate(-50%,-50%);overflow:hidden;}
.rotor img{position:absolute;}
.marken{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;}
.bogen-fuss{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);}
.warnung{color:var(--err);font-size:12px;margin:0;}
.modal{position:fixed;inset:0;background:rgba(22,21,15,.55);display:flex;align-items:center;justify-content:center;z-index:70;padding:16px;}
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
.modal-inhalt.breit{width:min(880px,100%);}
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
.crop-box:active{cursor:grabbing;}
.crop-box img{position:absolute;pointer-events:none;}
.crop-gitter{position:absolute;inset:0;pointer-events:none;
background:linear-gradient(to right,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px)),
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;}
.pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pick:hover{border-color:var(--accent);}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
`}</style>;
}
+2
View File
@@ -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<string, string> = {
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
};
---
+1 -1
View File
@@ -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<PicdropCfg | null> {
export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
if (!key) return null;
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
if (key === 'picdrop') return loadConfig();
+116
View File
@@ -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('.', ',');
+360
View File
@@ -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<string, boolean>[] = 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<string>[] {
const out: Set<string>[] = [];
const n = Math.min(ids.length, 8);
for (let m = 0; m < (1 << n); m++) {
const s = new Set<string>();
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<PlaceSpec, 'count'>, 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: 410 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 };
}
+158
View File
@@ -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<string, SheetCellImage>;
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<Uint8Array> {
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<string, any> = {};
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();
}
+44
View File
@@ -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<Buffer> {
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<any>(
`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}`;
}
+31
View File
@@ -54,6 +54,37 @@ export async function seed(): Promise<void> {
{ 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<string, unknown>) => {
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'
+98 -2
View File
@@ -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 funktionierts — 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<string> {
@@ -126,6 +135,7 @@ export async function sweepDrafts(): Promise<void> {
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<any>('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<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
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<void> {
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.');
+49 -2
View File
@@ -59,7 +59,50 @@ import Base from '../layouts/Base.astro';
</section>
<section>
<h2>5 · Alles per Telegram — ohne Befehle</h2>
<h2>5 · Druck — exakte Maße und Schnittmarken (ohne KI)</h2>
<p class="lead">Der Reiter <b>Druck</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p>
<ol>
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
<li>Je Bild das <b>Endmaß</b> wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15,
12 × 15, 13 × 18 … oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>,
<code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
fest am Zielformat, verzerrt wird nie.</li>
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
<li><b>Papierformat</b> wählen: DIN A6A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
</ol>
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
<b>nicht</b> „an Seitengröße anpassen". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann,
setzt den Rand auf 0; sonst 35 mm, sonst schneidet der Drucker die Marken weg.</p>
<ul>
<li><b>Beschnittzugabe</b> (010 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.</li>
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild),
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
fertiger Satz.</li>
<li><b>Randprofile</b>: <b>Randlos</b> (0 mm), <b>Standard</b> (5 mm) oder <b>Sicher</b> (10 mm)
per Knopf, der genaue Wert bleibt frei eintragbar.</li>
<li><b>Wohin?</b>: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein
Zusatzziel gelegt werden — wie die Bilder aus dem Studio.</li>
<li><b>Automatische Anordnung</b>: 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.</li>
</ul>
<p class="mini">Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.</p>
</section>
<section>
<h2>6 · Alles per Telegram — ohne Befehle</h2>
<p>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.</p>
<ul>
@@ -69,13 +112,15 @@ import Base from '../layouts/Base.astro';
„mit unserer Hündin Frieda" — dann „🔀 Kombinieren" antippen. (Ohne Beschreibung fragt der Bot nach.)</li>
<li><b>Neu erzeugen:</b> unten auf „✨ Neues Bild" tippen und beschreiben, was entstehen soll.</li>
<li><b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.</li>
<li><b>Druckbogen (ohne KI):</b> Bild schicken → „📐 Druckbogen" → Endmaß und Anzahl antippen.
Zurück kommt ein A4-PDF mit Schnittmarken in 100-%-Größe.</li>
<li>Über „❓ Hilfe" gibt es die Kurzanleitung, über „📁 Rezept wählen" das Standard-Rezept.</li>
</ul>
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
</section>
<section>
<h2>6 · Sicherung & sauber neu starten (Admin)</h2>
<h2>7 · Sicherung &amp; sauber neu starten (Admin)</h2>
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
@@ -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;}}
</style>
</Base>
+36
View File
@@ -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 });
};
+181
View File
@@ -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<string, SheetCellImage> = {};
const needRotated = new Set<string>();
const needPlain = new Set<string>();
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;
};
+61
View File
@@ -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) });
};
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
---
<Base title="Druck · Klarbild">
<h1 class="seiten-titel">Druck</h1>
<p class="seiten-lead">
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.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
</style>
</Base>
+37 -2
View File
@@ -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'|<uuid>, 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
+134
View File
@@ -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');
});