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:
@@ -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'));
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) });
|
||||
};
|
||||
Reference in New Issue
Block a user