feat: print API - exact single image, sheet PDF, sheet presets

POST /api/print/single (image at an exact size, dpi headers incl. a real-dpi
warning), GET /api/print/single as a mm-to-px calculator, POST /api/print/sheet
(multi-image sheet as PDF, limits on cells/copies/pages) and print_presets for
reusable sheet setups.
This commit is contained in:
2026-08-18 06:20:46 +00:00
parent a593dea6c5
commit e8b3fde652
4 changed files with 267 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query(
`SELECT p.id, p.name, p.config, p.created_at, u.display_name AS by_name
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
ORDER BY p.name ASC`);
return json({ presets: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const body = await request.json().catch(() => null) as any;
const name = String(body?.name || '').trim();
if (!name) return json({ error: 'Name fehlt.' }, 400);
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
const row = await one(
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
[name.slice(0, 80), locals.user.uid, JSON.stringify(body.config)]);
return json({ preset: row });
};
export const DELETE: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id');
if (!id) return json({ error: 'id fehlt.' }, 400);
await query('DELETE FROM print_presets WHERE id=$1', [id]);
return json({ ok: true });
};
+156
View File
@@ -0,0 +1,156 @@
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';
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),
});
return new Response(Buffer.from(bytes), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || 'druckbogen', 'pdf')}"`,
'X-Klarbild-Pages': String(pages.length),
'X-Klarbild-Per-Sheet': String(plan.perSheet),
},
});
} 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;
};
+61
View File
@@ -0,0 +1,61 @@
import type { APIRoute } from 'astro';
import { renderCell, type CropRel } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
import { dpiCheck } from '../../../lib/printlayout';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Ein einzelnes Bild exakt auf ein Maß bringen — ohne KI, ohne Bogen.
* Antwort: die fertige Bilddatei (PNG/JPG) mit dpi-Metadaten.
*/
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 src: PrintSource = body?.src;
const crop: CropRel | null = body?.crop ?? null;
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
? { w: Number(body.wMm), h: Number(body.hMm) }
: parseSizeMm(String(body?.size || ''));
if (!size) return json({ error: 'Maß fehlt oder ist ungültig.' }, 400);
if (body?.landscape) size = { w: size.h, h: size.w };
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Maß außerhalb des zulässigen Bereichs.' }, 400);
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const buf = await loadSource(src, locals.user as any);
const out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 });
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
const cropW = (crop?.w ?? 1) * out.srcPx[0];
const check = dpiCheck(cropW, size.w, dpi);
return new Response(out.buffer, {
headers: {
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`,
'X-Klarbild-Px': `${out.width}x${out.height}`,
'X-Klarbild-Real-Dpi': String(check.dpi),
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
},
});
} catch (e: any) {
return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400);
}
};
/** Kleine Hilfe für Skripte/MCP: Maß → Pixel bei dpi. */
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const size = parseSizeMm(url.searchParams.get('size') || '');
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
if (!size) return json({ error: 'Parameter size fehlt (z. B. 12x15 oder 35x45mm).' }, 400);
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
};