feat: sheet templates, margin profiles and delivering the sheet

Seeds the four sheet setups Till actually prints (Kita set, small school set,
full passport sheet, two 10x7,5 on 10x15 paper). A multi-format template
applied to a single image clones it into every format, so one click yields the
whole set. Margin gets one-click borderless / 5 mm / 10 mm profiles, and the
finished PDF can be pushed to Picdrop, the NAS or any extra target.
This commit is contained in:
2026-08-18 06:40:31 +00:00
parent 21a0a68972
commit 4fb34cdab8
6 changed files with 125 additions and 15 deletions
+6
View File
@@ -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.
+51 -11
View File
@@ -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<string>(''); // '' = nur herunterladen
const [gallery, setGallery] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [editing, setEditing] = useState<string | null>(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() {
<div className="feld">
<label>Ränder &amp; Abstände</label>
<div className="schalter zart">
<button className={marginMm === 0 ? 'an' : ''} onClick={() => setMarginMm(0)}>Randlos</button>
<button className={marginMm === 5 ? 'an' : ''} onClick={() => setMarginMm(5)}>Standard 5 mm</button>
<button className={marginMm === 10 ? 'an' : ''} onClick={() => setMarginMm(10)}>Sicher 10 mm</button>
</div>
<div className="reihe zwei">
<label className="mini">Rand zum Papier (mm)
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
@@ -412,6 +436,21 @@ export default function PrintApp() {
</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>
@@ -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;}
+1 -1
View File
@@ -17,7 +17,7 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
}
async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
if (!key) return null;
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
if (key === 'picdrop') return loadConfig();
+31
View File
@@ -54,6 +54,37 @@ export async function seed(): Promise<void> {
{ contour_mm: 3 });
}
// Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen.
{
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
const by = admin?.id ?? null;
const P = async (name: string, config: Record<string, unknown>) => {
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
if (exists) return;
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
[name, by, JSON.stringify(config)]);
};
const sheet = {
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
autoGap: true, gapMm: 14, bleedMm: 0,
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
};
const F = (sizeId: string, count: number, landscape = false) =>
({ sizeId, customSize: '', landscape, count, allowRotate: true });
// Der klassische Kita-/Schulfoto-Satz aus einem Bild.
await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
// Nur die kleinen Abzüge, wenn der große schon gedruckt ist.
await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
// Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist.
await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
// Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier.
await P('2 × 10×7,5 auf Fotopapier 10×15', {
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
});
}
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
await query(
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
+10 -2
View File
@@ -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.</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
z. B. „Kita-Satz Felix" oder „8 × Passbild".</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>
+26 -1
View File
@@ -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) {