diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx new file mode 100644 index 0000000..dfe1597 --- /dev/null +++ b/src/components/PrintApp.tsx @@ -0,0 +1,768 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper'; +import { + layout, cutMarks, recommendedGap, dpiCheck, + type SheetSpec, type PlaceSpec, type MarkMode, +} from '../lib/printlayout'; + +/* -------------------------------------------------------------------------- + Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit + Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern; + Pixel entstehen erst beim Rendern auf dem Server. + -------------------------------------------------------------------------- */ + +type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string }; +interface CropRel { x: number; y: number; w: number; h: number } + +interface Cell { + id: string; + name: string; + preview: string; // Data-URL (Upload) oder /api/items/…/file + src?: SrcRef; + uploading?: boolean; + error?: string; + natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis) + sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom' + customSize: string; + landscape: boolean; + count: number; + allowRotate: boolean; + crop: CropRel; +} + +const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); + +/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */ +function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel { + const imgA = natW / natH; + let w = 1, h = 1; + if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); } + const cx = around ? around.x + around.w / 2 : 0.5; + const cy = around ? around.y + around.h / 2 : 0.5; + return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }); +} +function clampCrop(c: CropRel): CropRel { + const w = Math.min(1, Math.max(0.02, c.w)); + const h = Math.min(1, Math.max(0.02, c.h)); + return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) }; +} + +const sizeOfCell = (c: Cell): { w: number; h: number } | null => { + const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId); + if (!base) return null; + const s = { w: base.w, h: base.h }; + return c.landscape ? { w: s.h, h: s.w } : s; +}; + +export default function PrintApp() { + const [cells, setCells] = useState([]); + const [paperId, setPaperId] = useState('A4'); + const [paperCustom, setPaperCustom] = useState(''); + const [paperLandscape, setPaperLandscape] = useState(false); + const [marginMm, setMarginMm] = useState(5); + const [autoGap, setAutoGap] = useState(true); + const [gapMm, setGapMm] = useState(12); + const [bleedMm, setBleedMm] = useState(0); + const [marks, setMarks] = useState('corner'); + const [markLen, setMarkLen] = useState(4); + const [markOff, setMarkOff] = useState(3); + const [dpi, setDpi] = useState(300); + const [ext, setExt] = useState<'jpg' | 'png'>('jpg'); + const [center, setCenter] = useState(true); + const [footer, setFooter] = useState(true); + + const [editing, setEditing] = useState(null); + const [picker, setPicker] = useState(false); + const [presets, setPresets] = useState([]); + const [presetSel, setPresetSel] = useState(''); + const [busy, setBusy] = useState(null); + const [toast, setToast] = useState(null); + const [dragging, setDragging] = useState(false); + const fileRef = useRef(null); + + const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); }; + + /* ---------------- Bogen ---------------- */ + const paper = useMemo(() => { + const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId); + if (!p) return null; + return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h }; + }, [paperId, paperCustom, paperLandscape]); + + const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm; + + const sheet: SheetSpec | null = paper + ? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center } + : null; + + const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => { + const s = sizeOfCell(c); + if (!s || c.error || !c.src) return []; + return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }]; + }), [cells]); + + const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]); + const marksPerPage = useMemo(() => (plan && sheet) + ? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm })) + : [], [plan, sheet, marks, markLen, markOff, bleedMm]); + + /* ---------------- Upload ---------------- */ + const addFiles = useCallback(async (files: FileList | File[]) => { + const arr = Array.from(files) + .filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)) + .slice(0, 30); + for (const f of arr) { + const id = uid(); + const reader = new FileReader(); + reader.onload = () => { + const url = reader.result as string; + const probe = new Image(); + probe.onload = () => setCells((p) => p.map((x) => x.id === id + ? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight, + crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) } + : x)); + probe.src = url; + }; + reader.readAsDataURL(f); + setCells((p) => [...p, newCell(id, f.name)]); + const fd = new FormData(); fd.append('files', f); + try { + const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json()); + const info = j.files?.[0]; + setCells((p) => p.map((x) => x.id === id ? { + ...x, uploading: false, error: info?.error, + src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined, + natW: info?.width || x.natW, natH: info?.height || x.natH, + } : x)); + } catch { + setCells((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x)); + } + } + }, []); + + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); + if (imgs.length) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean)); + }; + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }, [addFiles]); + + useEffect(() => { loadPresets(); }, []); + const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) + .then((j) => setPresets(j.presets || [])).catch(() => {}); + + const addFromLibrary = (it: any) => { + const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0); + const id = uid(); + const c: Cell = { + ...newCell(id, it.filename || 'Bild'), + uploading: false, + src: { kind: 'item', id: it.id }, + preview: `/api/items/${it.id}/file?preview=1`, + natW: w || 2000, natH: h || 3000, + }; + c.crop = coverCrop(c.natW, c.natH, aspectOf(c)); + setCells((p) => [...p, c]); + }; + + const patch = (id: string, up: Partial) => setCells((p) => p.map((c) => { + if (c.id !== id) return c; + const next = { ...c, ...up }; + // Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen. + if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) { + const a = aspectOf(next); + if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop); + } + return next; + })); + + /* ---------------- Ausgabe ---------------- */ + const body = () => ({ + paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId }, + landscape: paperLandscape, + marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer, + 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`); + notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.`); + } 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, + 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); + // Formate auf die vorhandenen Bilder anwenden (der Reihe nach). + 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; + })); + } + notify('Vorlage geladen.'); + }; + + const deletePreset = async (id: string) => { + if (!id || !confirm('Vorlage löschen?')) return; + await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' }); + setPresetSel(''); loadPresets(); + }; + + const editCell = cells.find((c) => c.id === editing) || null; + const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c)); + const totalPieces = ready.reduce((n, c) => n + c.count, 0); + + return ( +
+ {/* ---------- 1 · Bilder ---------- */} +
+
+ 1 · Bilder +
+ + {cells.length > 0 && } +
+
+
+ {cells.length === 0 ? ( +
fileRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}> + +

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

+
+ ) : ( +
+ {cells.map((c) => patch(c.id, u)} + onEdit={() => setEditing(c.id)} + onSingle={() => makeSingle(c)} + onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)} + +
+ )} + e.target.files && addFiles(e.target.files)} /> +
+
+ +
+ {/* ---------- 2 · Bogen ---------- */} +
+
2 · Bogen & Schnitt + +
+
+
+ +
+ + {presetSel && } +
+
+ +
+ + + {paperId === 'custom' && ( + setPaperCustom(e.target.value)} + placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" /> + )} +
+ + +
+ {paper &&
Bogen {labelMm(paper.w, paper.h)} — die PDF-Seite hat exakt dieses Maß.
} +
+ +
+ +
+ + + +
+
+ {marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.' + : marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.' + : 'Keine Linien — Kanten selbst anlegen.'} +
+ {marks === 'corner' && ( +
+ + +
+ )} +
+ +
+ +
+ + +
+ + {!autoGap && ( + setGapMm(Number(e.target.value))} /> + )} + +
Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.
+
+ +
+ +
+ + +
+ +
+ + +
Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".
+
+
+ + {/* ---------- 3 · Vorschau ---------- */} +
+
+ 3 · Vorschau + {plan && {plan.pages.length} Bogen · {totalPieces} Bilder} +
+
+ {!sheet ?

Bitte ein gültiges Papiermaß angeben.

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

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

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

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

+ ) : null} +
+
+
+ + {editCell && setEditing(null)} + onChange={(crop) => patch(editCell.id, { crop })} />} + {picker && setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />} + {toast &&
{toast}
} + +
+ ); +} + +/* ---------------- Bausteine ---------------- */ + +function newCell(id: string, name: string): Cell { + return { + id, name, preview: '', uploading: true, natW: 1000, natH: 1500, + sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true, + crop: { x: 0, y: 0, w: 1, h: 1 }, + }; +} +function aspectOf(c: Cell): number { + const s = sizeOfCell(c); + return s ? s.w / s.h : 1; +} +function cropStyle(c: CropRel): React.CSSProperties { + return { + position: 'absolute', + width: `${100 / c.w}%`, height: `${100 / c.h}%`, + left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`, + objectFit: 'fill', + }; +} +function groupBy(list: T[], _k: 'group'): [string, T[]][] { + const m = new Map(); + for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); } + return [...m.entries()]; +} +function download(blob: Blob, name: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: { + cell: Cell; dpi: number; + onPatch: (u: Partial) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void; +}) { + const s = sizeOfCell(cell); + const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null; + return ( +
+ +
!cell.uploading && onEdit()}> + {cell.preview + ? + : } + {!cell.uploading && Zuschnitt} +
+
+ + {cell.sizeId === 'custom' && ( + onPatch({ customSize: e.target.value })} + placeholder="12x15 · 35x45mm · 4:3/15" /> + )} +
+
+ + +
+
+ + onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} /> + +
+
+ + {cell.error ?
{cell.error}
: s && ( +
+ {labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px + {check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)} +
+ )} + +
+
+ ); +} + +/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */ +function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) { + const s = sizeOfCell(cell); + const aspect = s ? s.w / s.h : 1; + const base = coverCrop(cell.natW, cell.natH, aspect); + const [crop, setCrop] = useState(cell.crop); + const boxRef = useRef(null); + const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null); + + const zoom = Math.min(20, Math.max(1, base.w / crop.w)); + const setZoom = (z: number) => { + const zz = Math.min(20, Math.max(1, z)); + const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2; + const w = base.w / zz, h = base.h / zz; + setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h })); + }; + + const onDown = (e: React.PointerEvent) => { + (e.target as Element).setPointerCapture?.(e.pointerId); + drag.current = { x: e.clientX, y: e.clientY, crop }; + }; + const onMove = (e: React.PointerEvent) => { + if (!drag.current || !boxRef.current) return; + const r = boxRef.current.getBoundingClientRect(); + const dx = (e.clientX - drag.current.x) / r.width; + const dy = (e.clientY - drag.current.y) / r.height; + const c = drag.current.crop; + setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h })); + }; + const onUp = () => { drag.current = null; }; + + useEffect(() => { + const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', esc); + return () => window.removeEventListener('keydown', esc); + }, [onClose]); + + const apply = () => { onChange(crop); onClose(); }; + + return ( +
+
e.stopPropagation()}> +
+ Zuschnitt · {s ? labelMm(s.w, s.h) : ''} + +
+
+
{ setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}> + +
+
+
+
+ +
+ + + +
+
Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.
+
+
+
+ ); +} + +function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + fetch('/api/items').then((r) => r.json()) + .then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false)); + }, []); + return ( +
+
e.stopPropagation()}> +
+ Aus der Bibliothek wählen + +
+
+ {loading ?

Wird geladen …

+ : !items.length ?

Noch keine Bilder in der Bibliothek.

+ : items.map((it) => ( + + ))} +
+
+
+ ); +} + +function PrintStyles() { + return ; +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 92e0324..45c66f6 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -14,6 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h const nav = [ { href: '/', label: 'Studio', icon: 'studio' }, { href: '/bibliothek', label: 'Bibliothek', icon: 'library' }, + { href: '/druck', label: 'Druck', icon: 'print' }, { href: '/warteschlange', label: 'Warteschlange', icon: 'queue' }, { href: '/anleitung', label: 'Hilfe', icon: 'help' }, ...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []), @@ -25,6 +26,7 @@ const ICONS: Record = { library: ``, queue: ``, admin: ``, + print: ``, help: ``, }; --- diff --git a/src/pages/druck.astro b/src/pages/druck.astro new file mode 100644 index 0000000..382d8b5 --- /dev/null +++ b/src/pages/druck.astro @@ -0,0 +1,16 @@ +--- +import Base from '../layouts/Base.astro'; +import PrintApp from '../components/PrintApp.tsx'; +--- + +

Druck

+

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

+ + +