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