Files
klarbild/src/pages/api/print/sheet.ts
T
till 4fb34cdab8 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.
2026-08-18 06:40:31 +00:00

182 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};