Files
klarbild/src/lib/printrender.ts
T
till a593dea6c5 feat: exact-size rendering and 1:1 sheet PDF (sharp + pdf-lib)
renderCell brings a relative crop onto an exact physical size; missing bleed
pixels are copied from the edge instead of stretching the motif. Without a
crop it falls back to a centred cover crop, never a distorted fill.
buildSheetPdf writes a PDF page that is exactly the sheet size in mm with
hairline crop marks and an optional footer. Source resolution guards uploads
to the sources/ prefix and reuses the library visibility rules.
2026-08-18 06:20:46 +00:00

159 lines
6.4 KiB
TypeScript

// 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<string, SheetCellImage>;
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<Uint8Array> {
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<string, any> = {};
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();
}