// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib). // Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis // vorhersagbar und wiederholbar ist. import sharp from 'sharp'; import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; import { mmToPt, mmToPx } from './paper'; import type { Line, Page, SheetSpec } from './printlayout'; /** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */ export interface CropRel { x: number; y: number; w: number; h: number } export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 }; /** * Bringt einen Bildausschnitt exakt auf ein physisches Maß. * `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt * exakt der gewählte Ausschnitt. Fehlende Randpixel werden aus der Kante * fortgeschrieben (extendWith 'copy'), statt das Motiv zu stauchen. */ export async function renderCell( input: Buffer, crop: CropRel | null, wMm: number, hMm: number, dpi: number, opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {}, ): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> { const bleed = Math.max(0, opt.bleedMm || 0); const meta = await sharp(input, { failOn: 'none' }).metadata(); const nw = meta.width || 0, nh = meta.height || 0; if (!nw || !nh) throw new Error('Bildmaße unbekannt.'); // Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis // („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle. const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm); // Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox // exakt das bleibt, was in der Vorschau gerahmt wurde. const fx = (wMm + 2 * bleed) / wMm; const fy = (hMm + 2 * bleed) / hMm; const cw = c.w * fx, ch = c.h * fy; const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2; const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh }; const left = Math.round(want.left), top = Math.round(want.top); const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height)); const ex = { left: Math.min(Math.max(0, left), nw - 1), top: Math.min(Math.max(0, top), nh - 1), }; const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left)); const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top)); const padLeft = Math.max(0, ex.left - left); const padTop = Math.max(0, ex.top - top); const padRight = Math.max(0, width - exW - padLeft); const padBottom = Math.max(0, height - exH - padTop); const targetW = mmToPx(wMm + 2 * bleed, dpi); const targetH = mmToPx(hMm + 2 * bleed, dpi); let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH }); if (padLeft || padTop || padRight || padBottom) { img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' }); } img = img.resize(targetW, targetH, { fit: 'fill' }); if (opt.rotate) img = img.rotate(90); const hasAlpha = !!meta.hasAlpha; const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; const flat = hasAlpha && ext === 'jpg'; if (flat) img = img.flatten({ background: opt.background || '#ffffff' }); const out = await (ext === 'png' ? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) : img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }) ).toBuffer({ resolveWithObject: true }); return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] }; } /** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */ export function coverCrop(natW: number, natH: number, aspect: number): CropRel { const imgA = natW / natH; const w = imgA > aspect ? aspect / imgA : 1; const h = imgA > aspect ? 1 : imgA / aspect; return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }; } function normCrop(c: CropRel | null): CropRel { if (!c) return FULL_CROP; const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0)); let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1); let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h); return { x, y, w, h }; } export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' } export interface SheetPdfInput { sheet: SheetSpec; pages: Page[]; marksPerPage: Line[][]; /** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */ images: Record; title?: string; footer?: string | null; markWidthPt?: number; } /** * Baut das Druck-PDF. Seitengröße = Bogengröße in mm, 1:1 — beim Drucken * unbedingt „Tatsächliche Größe / 100 %" wählen, nicht „an Seite anpassen". */ export async function buildSheetPdf(input: SheetPdfInput): Promise { const { sheet, pages, marksPerPage, images } = input; const pdf = await PDFDocument.create(); pdf.setTitle(input.title || 'Klarbild Druckbogen'); pdf.setProducer('Klarbild'); pdf.setCreator('Klarbild — Druckbogen'); const embedded: Record = {}; for (const [id, img] of Object.entries(images)) { embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes); } const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null; const bleed = sheet.bleedMm || 0; const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm); pages.forEach((pg, i) => { const page = pdf.addPage([W, H]); for (const p of pg.placements) { const im = embedded[p.specId]; if (!im) continue; // Bild inkl. Beschnittzugabe: ragt je Seite um `bleed` über die Trimmbox. const x = mmToPt(p.x - bleed); const yTop = p.y - bleed; // von oben gezählt const w = mmToPt(p.w + 2 * bleed); const h = mmToPt(p.h + 2 * bleed); page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h }); } for (const l of marksPerPage[i] || []) { page.drawLine({ start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) }, end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) }, thickness: input.markWidthPt ?? 0.25, color: rgb(0, 0, 0), }); } if (font && input.footer) { page.drawText(input.footer, { x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0), }); } }); return pdf.save(); }