diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fa40b..38b1734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ All notable changes to Klarbild are documented here. Newest first. 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. diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index dfe1597..d1bfd94 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -70,6 +70,9 @@ export default function PrintApp() { const [ext, setExt] = useState<'jpg' | 'png'>('jpg'); const [center, setCenter] = useState(true); const [footer, setFooter] = useState(true); + const [deliverTo, setDeliverTo] = useState(''); // '' = nur herunterladen + const [gallery, setGallery] = useState(''); + const [targets, setTargets] = useState([]); const [editing, setEditing] = useState(null); const [picker, setPicker] = useState(false); @@ -149,7 +152,10 @@ export default function PrintApp() { return () => window.removeEventListener('paste', onPaste); }, [addFiles]); - useEffect(() => { loadPresets(); }, []); + 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(() => {}); @@ -183,6 +189,7 @@ export default function PrintApp() { 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)!; @@ -200,7 +207,9 @@ export default function PrintApp() { 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`); - notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.`); + 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); } }; @@ -227,7 +236,7 @@ export default function PrintApp() { if (!name?.trim()) return; const config = { paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, - marks, markLen, markOff, dpi, ext, center, footer, + 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', { @@ -243,16 +252,26 @@ export default function PrintApp() { 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); - // Formate auf die vorhandenen Bilder anwenden (der Reihe nach). + 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) => prev.map((cell, i) => { - const f = c.formats[Math.min(i, c.formats.length - 1)]; - const next = { ...cell, 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; - })); + 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('Vorlage geladen.'); + 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) => { @@ -368,6 +387,11 @@ export default function PrintApp() {
+
+ + + +
+
+ + + {deliverTo && ( + setGallery(e.target.value)} + placeholder="Galerie/Ordner (leer = Standard)" /> + )} +
Der fertige Bogen wird als PDF geladen — auf Wunsch zusätzlich ins gewohnte Ziel gelegt.
+
+ @@ -722,6 +761,7 @@ function PrintStyles() { .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;} diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index b1e723d..086ca84 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -17,7 +17,7 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P return s?.picdrop_default_gallery || DEFAULT_GALLERY; } -async function cfgForKey(key: string | null | undefined): Promise { +export async function cfgForKey(key: string | null | undefined): Promise { if (!key) return null; if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; } if (key === 'picdrop') return loadConfig(); diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 07715af..72912e7 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -54,6 +54,37 @@ export async function seed(): Promise { { contour_mm: 3 }); } + // Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen. + { + const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`); + const by = admin?.id ?? null; + const P = async (name: string, config: Record) => { + const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]); + if (exists) return; + await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`, + [name, by, JSON.stringify(config)]); + }; + const sheet = { + paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5, + autoGap: true, gapMm: 14, bleedMm: 0, + marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true, + }; + const F = (sizeId: string, count: number, landscape = false) => + ({ sizeId, customSize: '', landscape, count, allowRotate: true }); + + // Der klassische Kita-/Schulfoto-Satz aus einem Bild. + await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] }); + // Nur die kleinen Abzüge, wenn der große schon gedruckt ist. + await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] }); + // Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist. + await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] }); + // Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier. + await P('2 × 10×7,5 auf Fotopapier 10×15', { + ...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0, + formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }], + }); + } + // The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen). await query( `UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both' diff --git a/src/pages/anleitung.astro b/src/pages/anleitung.astro index 4c8b4d9..1c6234a 100644 --- a/src/pages/anleitung.astro +++ b/src/pages/anleitung.astro @@ -85,8 +85,16 @@ import Base from '../layouts/Base.astro'; schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.
  • Einzeln herunterladen: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.
  • Auflösungswarnung: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.
  • -
  • Vorlagen: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern — - z. B. „Kita-Satz Felix" oder „8 × Passbild".
  • +
  • Vorlagen: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern. + Mitgeliefert sind Kita-Satz (A4) (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild), + Schulsatz klein (A4), Passbildbogen 35 × 45 (A4) und + 2 × 10 × 7,5 auf Fotopapier 10 × 15. Lädst du eine mehrformatige Vorlage bei nur + einem Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick, + fertiger Satz.
  • +
  • Randprofile: Randlos (0 mm), Standard (5 mm) oder Sicher (10 mm) + per Knopf, der genaue Wert bleibt frei eintragbar.
  • +
  • Wohin?: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein + Zusatzziel gelegt werden — wie die Bilder aus dem Studio.
  • Automatische Anordnung: gleich große Bilder ergeben ein sauberes Raster; bei gemischten Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.
  • diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts index 967b04f..264558c 100644 --- a/src/pages/api/print/sheet.ts +++ b/src/pages/api/print/sheet.ts @@ -3,6 +3,9 @@ import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type Ma 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; @@ -103,12 +106,34 @@ export const POST: APIRoute = async ({ request, locals }) => { 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="${sheetFilename(body?.name || 'druckbogen', '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) {