From e8b3fde6523038441e47f4d17dea38705fa97d47 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 18 Aug 2026 06:20:46 +0000 Subject: [PATCH] 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. --- migrations/013_print_presets.sql | 14 +++ src/pages/api/print/presets.ts | 36 +++++++ src/pages/api/print/sheet.ts | 156 +++++++++++++++++++++++++++++++ src/pages/api/print/single.ts | 61 ++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 migrations/013_print_presets.sql create mode 100644 src/pages/api/print/presets.ts create mode 100644 src/pages/api/print/sheet.ts create mode 100644 src/pages/api/print/single.ts diff --git a/migrations/013_print_presets.sql b/migrations/013_print_presets.sql new file mode 100644 index 0000000..64c4c21 --- /dev/null +++ b/migrations/013_print_presets.sql @@ -0,0 +1,14 @@ +-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") ------------------- +CREATE TABLE IF NOT EXISTS print_presets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + config jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name); + +-- Telegram: Zustand „wartet auf Druck-Auswahl" ----------------------------- +ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check; +ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check + CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded')); diff --git a/src/pages/api/print/presets.ts b/src/pages/api/print/presets.ts new file mode 100644 index 0000000..ca784b2 --- /dev/null +++ b/src/pages/api/print/presets.ts @@ -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 }); +}; diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts new file mode 100644 index 0000000..967b04f --- /dev/null +++ b/src/pages/api/print/sheet.ts @@ -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 = {}; + const needRotated = new Set(); + const needPlain = new Set(); + 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; +}; diff --git a/src/pages/api/print/single.ts b/src/pages/api/print/single.ts new file mode 100644 index 0000000..98b3006 --- /dev/null +++ b/src/pages/api/print/single.ts @@ -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) }); +};