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 ; }