Klarbild 1.0.0 — first public release
CI / check (push) Has been cancelled

A self-hosted print studio: images at exact physical sizes, sheet layout with
crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery,
share links, mail, Telegram — is an optional module that is off by default.

This is the neutral public twin of a private tool. Compared to that original:

- optional modules behind KLARBILD_MODULES; the default install is print-only
- English interface with German as a full second language (1,038 strings);
  the English sentence is the translation key
- no built-in accounts — first boot creates one admin from ADMIN_USERNAME /
  ADMIN_PASSWORD, or generates a password and logs it once
- delivery and backup targets are generic SFTP/FTPS endpoints, off by default
- English routes, English identifiers, English comments throughout
- two compose files: print-only by default, everything in the full one
- AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY

124 tests green, type check clean, verified end to end against a live instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 14:52:41 +00:00
commit e2481139a1
164 files changed
+27190

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
// Deliberately without AI — plain geometry and scaling, so that the result is
// predictable and repeatable.
import sharp from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout.ts';
import { baseCrop, type CropRel, type FitMode } from './cropmath.ts';
/** Crop relative to the source (0..1) — storable independently of resolution. */
export type { CropRel, FitMode };
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
/* `FitMode`: `cover` = crop until filled (something is missing at the outside),
`contain` = fit the whole image in (the letterbox colour stays at the outside).
Never distort. */
/** Largest centred crop in the target ratio — fills it out, cuts something off. */
// Both entry points calculate via `src/lib/cropmath.ts` — the same file the
// browser preview uses. `tests/cropmath.test.ts` holds the two routes against
// each other, so that preview and PDF cannot drift apart.
export const coverCrop = (natW: number, natH: number, aspect: number): CropRel =>
baseCrop(natW, natH, aspect, 'cover');
/**
* Smallest crop in the target ratio that contains the **whole** image.
* It deliberately reaches beyond the edge of the image (w or h > 1) — that is
* where the letterbox area appears.
*/
export const containCrop = (natW: number, natH: number, aspect: number): CropRel =>
baseCrop(natW, natH, aspect, 'contain');
/**
* Brings a section of an image to an exact physical size.
* `bleedMm` enlarges the rendered field outwards; the trim box stays exactly the
* chosen section. Missing edge pixels are continued from the edge
* (extendWith 'copy') instead of squashing the subject.
*/
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; fit?: FitMode } = {},
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
const bleed = Math.max(0, opt.bleedMm || 0);
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
// Apply the EXIF orientation BEFORE anything is calculated. Phone photos carry
// the rotation only as metadata; browsers and the iPhone show them rotated,
// while sharp without this step calculates on the unrotated raster — and then
// the image comes out of the printer sideways and with the wrong crop.
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
const src = (meta0.orientation ?? 1) > 1
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
: input;
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
const nw = meta.width || 0, nh = meta.height || 0;
if (!nw || !nh) throw new Error('Image dimensions unknown.');
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
? containCrop(nw, nh, wMm / hMm)
: coverCrop(nw, nh, wMm / hMm));
// Bleed: widen the crop from the centre, so that the trim box stays exactly
// the framed area.
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 targetW = mmToPx(wMm + 2 * bleed, dpi);
const targetH = mmToPx(hMm + 2 * bleed, dpi);
if (targetW * targetH > MAX_TARGET_PX)
throw new Error(`Target image too large (${targetW}×${targetH} px). Please reduce the size or the resolution.`);
// Visible part of the crop (the rest is filled, not rendered).
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Crop lies outside the image.');
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
// Mapping crop → target image (scale in px per fraction of the crop).
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
const right = targetW - dx - dw, bottom = targetH - dy - dh;
// A single sharp chain: extract → resize → extend. That order is sharp's own
// internal order, which is why the numbers add up. Important: only pad after
// scaling down, otherwise the intermediate image grows beyond all measure.
let img = sharp(src, { failOn: 'none' })
.extract({ left: L, top: T, width: W, height: H })
.resize(dw, dh, { fit: 'fill' });
if (dx || dy || right || bottom) {
img = img.extend(fit === 'contain'
? { top: dy, left: dx, bottom, right, background: bg }
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
}
const hasAlpha = !!meta.hasAlpha;
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const encode = (pipe: sharp.Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
// Rotate only in the second pass: otherwise sharp would apply a rotation
// before the padding and put the margins on the wrong sides.
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
.toBuffer({ resolveWithObject: true });
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
.toBuffer({ resolveWithObject: true });
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
}
/** Upper bound for the target image — guards against a memory explosion from extreme size/dpi combinations. */
const MAX_TARGET_PX = 300_000_000;
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
if (!c) return FULL_CROP;
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
// When letterboxing, the crop may be larger than the image (that is where the
// letterbox area appears); when cropping it has to lie inside the image.
const maxSide = fit === 'contain' ? 8 : 1;
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
if (fit === 'contain') {
// Only prevent the image from being pushed completely out of the crop.
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
return { x, y, w, h };
}
const x = Math.min(1 - w, Math.max(0, num(c.x)));
const y = Math.min(1 - h, Math.max(0, num(c.y)));
return { x, y, w, h };
}
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
/** Where the footer sits. The edge first, the alignment second.
* The values stay as they are — they cross the wire in the API request body. */
export type FooterPos =
| 'bottom-left' | 'bottom-center' | 'bottom-right'
| 'top-left' | 'top-center' | 'top-rechts';
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
/** Extras per page — for the poster print: sheet number and glue flap. */
export interface PageExtras { note?: string | null; dashed?: Line[] }
export interface SheetPdfInput {
sheet: SheetSpec;
pages: Page[];
marksPerPage: Line[][];
/** specId → finished rendered image (bleed included, rotated if applicable). */
images: Record<string, SheetCellImage>;
title?: string;
footer?: FooterSpec | null;
extrasPerPage?: PageExtras[];
markWidthPt?: number;
}
/**
* Builds the print PDF. Page size = sheet size in mm, 1:1 — when printing, be
* sure to choose "Actual size / 100 %", not "fit to page".
*/
export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
const { sheet, pages, marksPerPage, images } = input;
const pdf = await PDFDocument.create();
pdf.setTitle(input.title || 'Klarbild print sheet');
pdf.setProducer('Klarbild');
pdf.setCreator('Klarbild — print sheet');
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 needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
const font = needsFont ? 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;
// Image including bleed: reaches beyond the trim box by `bleed` per side.
const x = mmToPt(p.x - bleed);
const yTop = p.y - bleed; // counted from the top
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),
});
}
// Glue flaps in the poster print: dashed, so that nobody mistakes them for a
// cut edge.
const extras = input.extrasPerPage?.[i];
for (const l of extras?.dashed || []) {
page.drawLine({
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
});
}
if (font) {
const put = (text: string, pos: FooterPos, size: number) => {
const w = font.widthOfTextAtSize(text, size);
const top = pos.startsWith('top');
const y = top ? H - mmToPt(3) - size : mmToPt(3);
const x = pos.endsWith('center') ? (W - w) / 2
: pos.endsWith('rechts') ? W - mmToPt(4) - w
: mmToPt(4);
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
};
// The footer only if there really is room at the chosen edge —
// otherwise it would sit in the middle of the subject.
if (input.footer?.text) {
const pos = input.footer.position || 'bottom-left';
const size = input.footer.sizePt ?? 6;
const top = pos.startsWith('top');
const free = pg.placements.length
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
: sheet.hMm;
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
}
// The sheet number in a poster print is always there — it is the assembly instruction.
if (extras?.note) put(extras.note, 'top-left', 8);
}
});
return pdf.save();
}