Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0442a617c | |||
| 4fb34cdab8 | |||
| 21a0a68972 | |||
| 32f3b91779 | |||
| cd9081823d | |||
| e8b3fde652 | |||
| a593dea6c5 | |||
| a651025422 |
@@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to Klarbild are documented here. Newest first.
|
||||
|
||||
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
|
||||
|
||||
### Added
|
||||
- **Print module (`/druck`)** — a lab-style, completely AI-free path: crop, scale and place
|
||||
images at exact physical sizes. No model call, no cost, no queue.
|
||||
- **Interactive crop editor** — pan and zoom on a fixed target aspect ratio (rule-of-thirds
|
||||
overlay); the crop is stored relative (0..1) so it survives any source resolution.
|
||||
- **Size presets & free input** — biometric passport 35 × 45 mm, 2 × 3 / 3 × 4 / 4 × 5 / 4,5 × 6 cm,
|
||||
9 × 13 … 50 × 70 cm, squares, DIN. Free input understands `12x15` (cm), `35x45mm`, `5`
|
||||
(= 5 × 5 cm) and `4:3/15` (ratio + long edge).
|
||||
- **Sheet layout** — paper presets DIN A6–A2 incl. **A3+ (329 × 483 mm)**, photo-paper cuts
|
||||
(9 × 13 … 20 × 30), US Letter/Legal, plus any custom paper size. Portrait/landscape,
|
||||
printer margin, centering, per-image copy count.
|
||||
- **Automatic nesting** — identical sizes produce an exact grid; mixed sizes go through a
|
||||
MaxRects packer that fixes one orientation per format and tries every combination,
|
||||
so eight passport photos land in the free space next to the 13 × 18.
|
||||
- **Crop marks** — `corner` (fine 0.25 pt marks outside the trim box, Photoshop/InDesign
|
||||
convention, 4 mm long / 3 mm offset by default) or `grid` (continuous guides across the
|
||||
sheet that never cross another image). Gap between images adapts to the chosen marks.
|
||||
- **Bleed** (0–10 mm) — the image extends past the trim box, edge pixels are copied rather
|
||||
than stretched; the gap is always ≥ 2 × bleed so neighbouring bleeds cannot overlap.
|
||||
- **PDF output at 1:1** — the PDF page is exactly the sheet size (pdf-lib), with a footer
|
||||
stating the sizes, dpi and the "print at 100 %, not fit-to-page" reminder.
|
||||
- **Single export** — one image at an exact size as PNG/JPG with dpi metadata, plus a
|
||||
resolution warning when the source cannot hold the requested dpi.
|
||||
- **Sheet templates** — save the whole sheet setup (paper, marks, formats, counts) as a
|
||||
reusable preset (`print_presets`, migration `013`).
|
||||
- **All three entrances** — `/druck` in the web app, `📐 Druckbogen` in Telegram (fully
|
||||
button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`.
|
||||
- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator),
|
||||
`POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`.
|
||||
- **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)"
|
||||
and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image
|
||||
clones that image into every format, so one click produces the whole set.
|
||||
- **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons.
|
||||
- **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any
|
||||
extra delivery target (`deliver` in the sheet request).
|
||||
- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px,
|
||||
non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging.
|
||||
|
||||
## 2026-07-23 — Compose, generate & second backup
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,7 +8,8 @@ für den Cricut freistellen, und automatisch nach Picdrop ausliefern.
|
||||
|
||||
## Stack
|
||||
Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss ·
|
||||
sharp · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. Deploy: Gitea → Coolify → Hetzner.
|
||||
sharp · pdf-lib (Druckbogen) · grammY (Telegram) · OKLCH-Tokens, kein Tailwind.
|
||||
Deploy: Gitea → Coolify → Hetzner.
|
||||
|
||||
## Entwicklung
|
||||
```bash
|
||||
@@ -31,12 +32,26 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
|
||||
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
|
||||
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
|
||||
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
|
||||
- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
|
||||
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
|
||||
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6–A2 inkl. **A3+**, Fotopapier 9×13–20×30, Letter/Legal
|
||||
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
|
||||
Beschnittzugabe 0–10 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
|
||||
Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer.
|
||||
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
|
||||
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP
|
||||
(`exact_size`, `print_sheet`).
|
||||
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
||||
|
||||
## Datenmodell / Migrationen
|
||||
Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichtige Spalten:
|
||||
`items.has_alpha`, `jobs.status` (inkl. `failed`), `jobs.finished_at`,
|
||||
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`.
|
||||
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`,
|
||||
`print_presets` (Bogen-Vorlagen, `013`).
|
||||
|
||||
## Tests
|
||||
`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
|
||||
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
|
||||
|
||||
## Stolpersteine & Lösungen (Learnings)
|
||||
- **„Bild bei Picdrop ✓, aber nicht in der Galerie":** Fehldiagnose vermeiden — die Dateien lagen die ganze
|
||||
|
||||
+72
-2
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
Generated
+547
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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 3–5 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 & 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>;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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('.', ',');
|
||||
@@ -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: 4–10 mm)
|
||||
offsetMm?: number; // Abstand vom Endformat nach außen
|
||||
bleedMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnittmarken als Liniensegmente.
|
||||
* - `corner`: vier Eckenpaare je Bild, außerhalb des Endformats (Adobe-/Druckerei-Konvention).
|
||||
* - `grid`: durchgehende Linien über den ganzen Bogen an jeder Schnittkante — für
|
||||
* das Schneidelineal/die Schlagschere, wenn viele gleiche Bilder drauf sind.
|
||||
*/
|
||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||
if (opt.mode === 'none') return [];
|
||||
const len = opt.lengthMm ?? 4;
|
||||
const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1);
|
||||
const lines: Line[] = [];
|
||||
|
||||
if (opt.mode === 'corner') {
|
||||
for (const p of page.placements) {
|
||||
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
|
||||
// je Ecke: eine waagerechte und eine senkrechte Marke, nach außen weisend
|
||||
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
|
||||
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
|
||||
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
|
||||
}
|
||||
}
|
||||
return clip(lines, sheet);
|
||||
}
|
||||
|
||||
// grid: Schnittkanten über den ganzen Bogen ziehen — aber nur dort, wo kein
|
||||
// anderes Bild im Weg ist. Sonst liefe die Linie quer durchs Nachbarmotiv.
|
||||
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
|
||||
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
|
||||
for (const x of xs) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
|
||||
.map((p) => [p.y, p.y + p.h] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
|
||||
}
|
||||
for (const y of ys) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
|
||||
.map((p) => [p.x, p.x + p.w] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
|
||||
}
|
||||
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
|
||||
|
||||
/** Freie Abschnitte in [from,to] nach Abzug der belegten Intervalle. */
|
||||
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
|
||||
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
let cur = from;
|
||||
for (const [a, b] of sorted) {
|
||||
if (a > cur) out.push([cur, Math.min(a, to)]);
|
||||
cur = Math.max(cur, b);
|
||||
if (cur >= to) break;
|
||||
}
|
||||
if (cur < to) out.push([cur, to]);
|
||||
return out.filter(([a, b]) => b - a > 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleinster Abstand, bei dem die gewählten Marken noch sinnvoll sind:
|
||||
* Eckmarken zweier Nachbarn brauchen je (Versatz + Länge) Platz, Beschnittzugaben
|
||||
* dürfen sich nicht überlappen.
|
||||
*/
|
||||
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
|
||||
const off = offsetMm ?? Math.max(2, bleedMm + 1);
|
||||
const base = 2 * bleedMm;
|
||||
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
|
||||
if (mode === 'grid') return Math.max(base, 0);
|
||||
return base;
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/** Marken, die über den Bogenrand hinausragen würden, abschneiden bzw. verwerfen. */
|
||||
function clip(lines: Line[], sheet: SheetSpec): Line[] {
|
||||
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
|
||||
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
|
||||
return lines
|
||||
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
|
||||
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
/** Reicht die Quellauflösung für das Endformat bei gewünschter dpi? */
|
||||
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
|
||||
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
|
||||
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -6,6 +6,9 @@ import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject, putObject, sourceKey } from './storage';
|
||||
import { enqueue } from './queue';
|
||||
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
|
||||
import { renderCell, buildSheetPdf } from './printrender';
|
||||
import { photoById, labelMm, paperById } from './paper';
|
||||
|
||||
const QUIET_MS = 4000; // Ruhefenster für die Bündelung
|
||||
const BASE = process.env.PUBLIC_BASE_URL || '';
|
||||
@@ -22,12 +25,18 @@ const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP
|
||||
function mainKeyboard() {
|
||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||
}
|
||||
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
|
||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
||||
|
||||
const HELP_TEXT =
|
||||
'So funktioniert’s — alles per Knopf, ohne Befehle:\n\n' +
|
||||
'🖼 *Bearbeiten:* Bild(er) hierher schicken (am besten als *Datei*), dann unten das Rezept antippen.\n' +
|
||||
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
|
||||
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
|
||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n\n' +
|
||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
||||
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' +
|
||||
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
||||
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
||||
|
||||
export async function getToken(): Promise<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.');
|
||||
|
||||
@@ -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 A6–A2 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 3–5 mm, sonst schneidet der Drucker die Marken weg.</p>
|
||||
<ul>
|
||||
<li><b>Beschnittzugabe</b> (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
|
||||
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</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 & 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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) });
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user