a593dea6c5
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.
159 lines
6.4 KiB
TypeScript
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();
|
|
}
|