Files
pi-imgen/extensions/imgen/render/preflight.ts
T
mozempk 756dd9994e feat(render): Typst renderer, contrast analysis and print pre-flight
render/typst.ts stages each job as a self-contained bundle (<job>/.typst/
lib.typ + template + resolved.json) and roots Typst there, so artwork living
outside the package is reachable and old jobs stay re-renderable. Paths are
root-relative throughout -- Typst resolves a leading / against --root, not
the filesystem.

render/contrast.ts measures the region behind the text with sharp and forces
a scrim on high variance even when mean luminance passes; verified with a
checkerboard that scores 5.45:1 contrast yet stdev 119.9.

render/preflight.ts checks TrimBox, embedded fonts, image dpi and structure,
degrading to 'non installato' rather than failing when a tool is absent.

E2E verified via jiti: TrimBox 297.0x420.0mm, font axes injected, bundle
staged. 0 type errors.
2026-08-27 09:50:22 +02:00

188 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Print pre-flight — what to check before a PDF goes to a copy shop.
*
* Every tool here is optional. A missing tool is reported as "non installato", never as
* a failure: refusing to hand over a poster because `qpdf` is absent would be absurd.
* The one check that needs no tools at all is the resolution check, and that is the
* failure that actually happens — a 1024px generation is ~62 dpi across A3's long edge.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { FORMATS_GEOMETRY, effectiveDpi, trimMm } from "./formats.ts";
import type { Format } from "../design/spec.ts";
export type CheckStatus = "ok" | "warn" | "fail" | "skipped";
export interface Check {
id: string;
status: CheckStatus;
/** Italian, shown to the user. */
message: string;
detail?: string;
}
export interface PreflightReport {
path: string;
checks: Check[];
ok: boolean;
/** Italian summary, ready to print. */
summary: string;
}
/** Minimum acceptable image resolution for print. 300 is the target; 150 is the floor. */
export const PRINT_DPI_TARGET = 300;
export const PRINT_DPI_FLOOR = 150;
function run(cmd: string, args: string[], timeoutMs = 15000): Promise<{ code: number | null; out: string; missing: boolean }> {
return new Promise((res) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
} catch {
res({ code: null, out: "", missing: true });
return;
}
let out = "";
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.stdout?.on("data", (d) => { out += String(d); });
child.stderr?.on("data", (d) => { out += String(d); });
child.on("error", () => { clearTimeout(timer); res({ code: null, out: "", missing: true }); });
child.on("close", (code) => { clearTimeout(timer); res({ code, out, missing: false }); });
});
}
/**
* Pure, tool-free: is the artwork big enough for this print size?
* This is the check that catches the mistake people actually make.
*/
export function checkArtResolution(
format: Format,
art: { width: number; height: number },
): Check {
const geo = FORMATS_GEOMETRY[format];
if (geo.kind !== "print") {
return { id: "risoluzione", status: "skipped", message: "Formato per schermo: risoluzione non rilevante." };
}
const trim = trimMm(format);
const dpiW = effectiveDpi(art.width, trim.widthMm);
const dpiH = effectiveDpi(art.height, trim.heightMm);
const dpi = Math.min(dpiW, dpiH);
const r = Math.round(dpi);
if (dpi >= PRINT_DPI_TARGET) {
return { id: "risoluzione", status: "ok", message: `Risoluzione immagine ${r} dpi: ottima per la stampa.` };
}
if (dpi >= PRINT_DPI_FLOOR) {
return {
id: "risoluzione",
status: "warn",
message: `Risoluzione immagine ${r} dpi: accettabile solo per formati grandi visti da lontano.`,
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm`,
};
}
return {
id: "risoluzione",
status: "fail",
message: `Risoluzione immagine troppo bassa (${r} dpi): va ingrandita prima di stampare.`,
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm — servono almeno ${PRINT_DPI_FLOOR} dpi`,
};
}
/** Full pre-flight on a produced PDF. Never throws. */
export async function checkPdf(path: string): Promise<PreflightReport> {
const checks: Check[] = [];
if (!existsSync(path)) {
const c: Check = { id: "file", status: "fail", message: "Il file PDF non esiste." };
return { path, checks: [c], ok: false, summary: renderSummary([c]) };
}
// 1. Page geometry + TrimBox. A TrimBox is what tells the printer where to cut.
const info = await run("pdfinfo", ["-box", path]);
if (info.missing) {
checks.push({ id: "geometria", status: "skipped", message: "pdfinfo non installato (brew install poppler): geometria non verificata." });
} else {
const hasTrim = /TrimBox:/i.test(info.out);
const size = /Page size:\s*([0-9.]+)\s*x\s*([0-9.]+)\s*pts/i.exec(info.out);
const mm = size ? `${(parseFloat(size[1]) / 72 * 25.4).toFixed(1)}x${(parseFloat(size[2]) / 72 * 25.4).toFixed(1)} mm` : "sconosciuta";
checks.push(hasTrim
? { id: "geometria", status: "ok", message: `Pagina ${mm} con TrimBox: la tipografia sa dove tagliare.` }
: { id: "geometria", status: "warn", message: `Pagina ${mm} senza TrimBox: senza abbondanza la tipografia taglia a occhio.` });
}
// 2. Fonts must be embedded or the shop's machine substitutes something else.
const fonts = await run("pdffonts", [path]);
if (fonts.missing) {
checks.push({ id: "caratteri", status: "skipped", message: "pdffonts non installato (brew install poppler): caratteri non verificati." });
} else {
// pdffonts columns: name type encoding emb sub uni object ID
// "emb" is the 4th-from-last column; "no" there means the font is NOT embedded.
// ("sub: yes" is normal and desirable — that is subsetting, not a problem.)
const lines = fonts.out.split("\n").slice(2).filter((l) => l.trim());
const notEmbedded = lines.filter((l) => {
const cols = l.trim().split(/\s+/);
return cols.length >= 4 && cols[cols.length - 4] === "no";
});
checks.push(!lines.length
? { id: "caratteri", status: "warn", message: "Nessun carattere incorporato trovato: il PDF potrebbe non contenere testo." }
: notEmbedded.length
? {
id: "caratteri",
status: "fail",
message: `${notEmbedded.length} carattere/i NON incorporato/i: in tipografia il testo cambierebbe aspetto.`,
detail: notEmbedded.map((l) => l.trim().split(/\s+/)[0]).join(", "),
}
: { id: "caratteri", status: "ok", message: `Tutti i caratteri sono incorporati (${lines.length}).` });
}
// 3. Image resolution inside the PDF.
const imgs = await run("pdfimages", ["-list", path]);
if (imgs.missing) {
checks.push({ id: "immagini", status: "skipped", message: "pdfimages non installato (brew install poppler): risoluzione non verificata." });
} else {
const dpis: number[] = [];
for (const line of imgs.out.split("\n").slice(2)) {
const cols = line.trim().split(/\s+/);
if (cols.length > 14) {
const x = parseFloat(cols[12]);
const y = parseFloat(cols[13]);
if (Number.isFinite(x) && Number.isFinite(y)) dpis.push(Math.min(x, y));
}
}
const lowest = dpis.length ? Math.min(...dpis) : null;
checks.push(lowest === null
? { id: "immagini", status: "skipped", message: "Nessuna immagine rasterizzata nel PDF." }
: lowest >= PRINT_DPI_TARGET
? { id: "immagini", status: "ok", message: `Immagini a ${Math.round(lowest)} dpi: ottimo.` }
: lowest >= PRINT_DPI_FLOOR
? { id: "immagini", status: "warn", message: `Immagini a ${Math.round(lowest)} dpi: accettabile solo per formati grandi.` }
: { id: "immagini", status: "fail", message: `Immagini a soli ${Math.round(lowest)} dpi: troppo poco per la stampa.` });
}
// 4. Structural integrity.
const q = await run("qpdf", ["--check", path]);
if (q.missing) {
checks.push({ id: "integrita", status: "skipped", message: "qpdf non installato (brew install qpdf): integrità non verificata." });
} else {
checks.push(q.code === 0
? { id: "integrita", status: "ok", message: "Struttura del PDF valida." }
: { id: "integrita", status: "warn", message: "qpdf segnala qualcosa di anomalo nel PDF.", detail: q.out.split("\n").slice(0, 4).join("\n") });
}
const ok = !checks.some((c) => c.status === "fail");
return { path, checks, ok, summary: renderSummary(checks) };
}
const ICON: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗", skipped: "–" };
function renderSummary(checks: Check[]): string {
const lines = checks.map((c) => ` ${ICON[c.status]} ${c.message}`);
const failed = checks.filter((c) => c.status === "fail").length;
const warned = checks.filter((c) => c.status === "warn").length;
const head = failed
? `Controllo stampa: ${failed} problema/i da risolvere.`
: warned
? `Controllo stampa: pronto, con ${warned} avvertenza/e.`
: "Controllo stampa: tutto a posto.";
return [head, ...lines].join("\n");
}