4fb34cdab8
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.
182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
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;
|
||
};
|