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.
This commit is contained in:
2026-08-18 06:20:46 +00:00
parent a651025422
commit a593dea6c5
4 changed files with 755 additions and 3 deletions
+158
View File
@@ -0,0 +1,158 @@
// 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();
}
+44
View File
@@ -0,0 +1,44 @@
// Auflösen der Bildquellen für den Druckbogen: frisch hochgeladen oder aus der Bibliothek.
import { one } from './db';
import { getObject } from './storage';
export type PrintSource =
| { kind: 'upload'; path: string }
| { kind: 'item'; id: string };
export interface SessionUser { uid: string | null; role: string }
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
if (src.kind === 'upload') {
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
const p = String(src.path || '');
if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.');
return getObject(p);
}
if (src.kind === 'item') {
const item = await one<any>(
`SELECT i.result_path, i.filename, j.created_by, j.private
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
if (!item?.result_path) throw new Error('Bild nicht gefunden.');
const isAdmin = user.role === 'admin';
const own = item.created_by === user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
}
return getObject(item.result_path);
}
throw new Error('Unbekannte Quellenart.');
}
/** Sprechender Dateiname für den Download. */
export function sheetFilename(prefix: string, ext: string): string {
const date = new Date().toISOString().slice(0, 10);
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `${date}_${slug || 'druckbogen'}.${ext}`;
}