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.
188 lines
8.1 KiB
TypeScript
188 lines
8.1 KiB
TypeScript
/**
|
||
* 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");
|
||
}
|