fix(render): satisfy the Renderer contract the commands declare

index.ts was written against assumed renderer signatures (render(req)) while
typst.ts exposed render(resolved, jobDir, outPath). It type-checked -- only
typeof === 'function' was ever asserted -- but would have failed at runtime.

typst.ts now exports the request-shaped render()/renderAllFormats() the
commands' Renderer interface declares; the low-level entry points are
renderOne()/renderFormats(). smartCrop() likewise adopts retouch.ts's richer
contract, returning the size read back from the written file.

Verified e2e: a3-portrait -> PNG+PDF, ig-story -> PNG, Italian progress.
This commit is contained in:
mozempk
2026-08-27 09:55:48 +02:00
parent 4191489995
commit c74b3c8b5e
6 changed files with 290 additions and 181 deletions
+83 -117
View File
@@ -18,7 +18,8 @@
* of use. Nothing in here may throw at load time.
*/
import { basename } from "node:path";
import { rename } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { defineTool, getAgentDir } from "@earendil-works/pi-coding-agent";
import type {
@@ -42,6 +43,9 @@ import type { Brief, DirectorContext } from "./design/director.ts";
import { FORMATS, TEMPLATES, type Format } from "./design/spec.ts";
import { formatReport, runDoctor, sessionBanner } from "./doctor.ts";
import { openInFinder, outputFile } from "./job.ts";
import { smartCrop as smartCropTo } from "./render/contrast.ts";
import { checkPdf } from "./render/preflight.ts";
import { render as typstRender, renderAllFormats as typstRenderAll } from "./render/typst.ts";
import { S, duration, errorText, fileLabel, fill } from "./ui/strings.ts";
import {
@@ -190,155 +194,115 @@ export default function imgen(pi: ExtensionAPI): void {
// ---- the renderer --------------------------------------------------------
//
// TODO — ASSUMED SIGNATURES. `render/typst.ts` is being written in parallel and owns
// resolveSpec() / render() / renderAllFormats(). This file is the single adapter, so a
// mismatch is fixed here and nowhere else. What is assumed:
// - `render(req)` and `renderAllFormats(req)` take the poster `RenderRequest` shape
// (spec, artPath, outDir, formats, config, pdf, ppi, signal, onProgress);
// - they return `{ files, warnings? }`, where `files` is either absolute paths or
// `{ format, path }` records — both are accepted below;
// - the renderer resolves the spec itself (contrast, geometry, font paths) and writes
// under job.ts's OUTPUT_BASENAMES.
// The module is imported lazily so that a missing or broken renderer is one Italian
// sentence at the point of use rather than a crash while pi is still booting.
type TypstFile = string | { format?: Format; path: string; warnings?: readonly string[] };
interface TypstOutcome {
files?: readonly TypstFile[];
warnings?: readonly string[];
}
interface TypstApi {
render(req: RenderRequest): Promise<TypstOutcome>;
renderAllFormats(req: RenderRequest): Promise<TypstOutcome>;
}
// render/typst.ts already exposes the request-shaped `render(req)` /
// `renderAllFormats(req)` the commands declare, so this adapter is thin. It exists for
// exactly two reasons:
//
// 1. NAMING. Typst writes `a3-portrait.pdf`; job.ts, `fileLabel()` and everything the
// user reads expect `poster-a3.pdf`. The two conventions meet here, and nowhere else.
// 2. SHAPE. /social wants one record per format, and carries `print` overrides rather
// than a whole config.
//
// Errors pass straight through: TypstError already carries an Italian `italian` field,
// which is exactly what commands/poster.ts looks for.
let typstApi: Promise<TypstApi> | null = null;
const typst = (): Promise<TypstApi> => {
typstApi ??= import("./render/typst.ts")
.then((mod) => {
const api = mod as unknown as Partial<TypstApi>;
if (typeof api.render !== "function" || typeof api.renderAllFormats !== "function") {
throw new Error("render/typst.ts: render()/renderAllFormats() mancanti");
}
return api as TypstApi;
})
.catch((e: unknown) => {
typstApi = null; // let a later call try again, e.g. after a reinstall
throw new Error(
errorText({
message: T.rendererMissing,
fix: T.rendererMissingFix,
detail: (e as Error)?.message,
}),
);
});
return typstApi;
};
const pathOf = (f: TypstFile): string => (typeof f === "string" ? f : f.path);
/** basename -> format, so a returned path can be labelled without the renderer's help. */
const FORMAT_BY_FILE = new Map<string, Format>();
for (const f of FORMATS) {
FORMAT_BY_FILE.set(outputFile(f, "png"), f);
FORMAT_BY_FILE.set(outputFile(f, "pdf"), f);
}
const collectWarnings = (out: TypstOutcome): string[] => {
const warnings = [...(out.warnings ?? [])];
for (const f of out.files ?? []) {
if (typeof f !== "string" && f.warnings) warnings.push(...f.warnings);
/** `a3-portrait.pdf` -> `poster-a3.pdf`. Anything unrecognised is left alone. */
const toJobName = async (path: string): Promise<string> => {
const match = /^(.+)\.(png|pdf)$/.exec(basename(path));
const format = match?.[1] as Format | undefined;
const ext = match?.[2] as "png" | "pdf" | undefined;
if (!format || !ext || !(FORMATS as readonly string[]).includes(format)) return path;
const wanted = join(dirname(path), outputFile(format, ext));
if (wanted === path) return path;
try {
await rename(path, wanted);
return wanted;
} catch {
// A failed rename is cosmetic: the file exists, under the other name.
return path;
}
return warnings;
};
const toRenderOutcome = (out: TypstOutcome): RenderOutcome => {
const files = (out.files ?? []).map(pathOf);
const warnings = collectWarnings(out);
return warnings.length > 0 ? { files, warnings } : { files };
const adapt = async (outcome: RenderOutcome): Promise<RenderOutcome> => {
const files: string[] = [];
for (const f of outcome.files) files.push(await toJobName(f));
return outcome.warnings?.length ? { files, warnings: outcome.warnings } : { files };
};
const renderer: Renderer = {
async render(req) {
return toRenderOutcome(await (await typst()).render(req));
async render(req: RenderRequest): Promise<RenderOutcome> {
return adapt(await typstRender(req));
},
async renderAllFormats(req) {
return toRenderOutcome(await (await typst()).renderAllFormats(req));
async renderAllFormats(req: RenderRequest): Promise<RenderOutcome> {
return adapt(await typstRenderAll(req));
},
};
/** basename -> format, so a produced path can be labelled without asking the renderer. */
const FORMAT_BY_FILE = new Map<string, Format>();
for (const f of FORMATS) {
for (const ext of ["png", "pdf"] as const) {
FORMAT_BY_FILE.set(outputFile(f, ext), f);
FORMAT_BY_FILE.set(`${f}.${ext}`, f);
}
}
/**
* The same renderer seen through /social's eyes: it wants one record per format and
* carries `print` overrides instead of a whole config. Never asks for a PDF — /social
* only ever produces screen sizes.
* The same renderer seen through /social's eyes. Never asks for a PDF: /social only
* ever produces screen sizes, and a PDF nobody opens costs seconds.
*/
const socialRenderer: SocialRenderer = {
async renderAllFormats(req: SocialRenderRequest): Promise<SocialRenderOutcome> {
const out = await (await typst()).renderAllFormats({
spec: req.spec,
artPath: req.artPath,
outDir: req.outDir,
formats: req.formats,
config: req.print ? { ...config, print: { ...config.print, ...req.print } } : config,
pdf: false,
signal: req.signal,
onProgress: req.onProgress,
});
const outcome = await adapt(
await typstRenderAll({
spec: req.spec,
artPath: req.artPath,
outDir: req.outDir,
formats: req.formats,
config: req.print ? { ...config, print: { ...config.print, ...req.print } } : config,
pdf: false,
signal: req.signal,
onProgress: req.onProgress,
}),
);
const files: SocialRenderedFile[] = [];
const returned = out.files ?? [];
returned.forEach((f, i) => {
const path = pathOf(f);
const named = typeof f === "string" ? undefined : f.format;
// Fall back to the filename, then to the request order: the renderer may not
// label its output, but the names are ours and the order is the one we asked for.
const format = named ?? FORMAT_BY_FILE.get(basename(path)) ?? req.formats[i];
if (!format) return;
const perFile = typeof f === "string" ? undefined : f.warnings;
files.push(perFile && perFile.length > 0 ? { format, path, warnings: perFile } : { format, path });
outcome.files.forEach((path, i) => {
// The filename is ours; the request order is the fallback when it is not.
const format = FORMAT_BY_FILE.get(basename(path)) ?? req.formats[i];
if (format) files.push({ format, path });
});
const warnings = out.warnings ?? [];
return warnings.length > 0 ? { files, warnings } : { files };
return outcome.warnings?.length ? { files, warnings: outcome.warnings } : { files };
},
};
/**
* PDF preflight. TODO — ASSUMED SIGNATURE: `render/preflight.ts` exports
* `checkPdf(pdfPath, config?) -> { ok, problems }`. A preflight that cannot run is not
* a broken PDF: the files are still produced, they are simply not inspected.
* PDF pre-flight, reshaped into the `{ ok, problems }` the poster command asks for.
* `checkPdf()` never throws, but a pre-flight that cannot run is not a broken PDF: the
* files are still produced, they are simply not inspected.
*/
const preflight = async (pdfPath: string): Promise<{ ok: boolean; problems: string[] }> => {
try {
const mod = (await import("./render/preflight.ts")) as unknown as {
checkPdf?: (path: string, cfg?: ImgenConfig) => Promise<{ ok: boolean; problems: string[] }>;
};
if (typeof mod.checkPdf !== "function") return { ok: true, problems: [] };
return await mod.checkPdf(pdfPath, config);
const report = await checkPdf(pdfPath);
const problems = report.checks
.filter((c) => c.status === "fail" || c.status === "warn")
.map((c) => c.message);
return { ok: report.ok, problems };
} catch {
return { ok: true, problems: [] };
}
};
/**
* Saliency-aware crop for /retouch. TODO — ASSUMED SIGNATURE: `render/contrast.ts`
* exports `smartCrop(input, out, {width,height}, opts?)`.
* Saliency-aware crop for /retouch, adapted to the size-object shape that command
* declares. sharp's `cover` fit lands exactly on the requested pixel size.
* TODO: render/contrast.ts's smartCrop takes no AbortSignal yet, so `opts` is ignored.
*/
const crop: RetouchCropper = {
async smartCrop(input, out, size, opts) {
let mod: {
smartCrop?: RetouchCropper["smartCrop"];
};
try {
mod = (await import("./render/contrast.ts")) as unknown as { smartCrop?: RetouchCropper["smartCrop"] };
} catch (e) {
throw new Error(
errorText({ message: T.cropperMissing, fix: T.rendererMissingFix, detail: (e as Error)?.message }),
);
}
if (typeof mod.smartCrop !== "function") {
throw new Error(errorText({ message: T.cropperMissing, fix: T.rendererMissingFix }));
}
return mod.smartCrop(input, out, size, opts);
async smartCrop(input, out, size) {
const path = await smartCropTo(input, out, size.width, size.height);
return { path, width: size.width, height: size.height };
},
};
@@ -375,7 +339,9 @@ export default function imgen(pi: ExtensionAPI): void {
cpu: { removeBackground, vectorize },
piConfigDir,
// Wired to the /presets writer so the two can never disagree about the file format.
savePreset: (key: string, preset: Preset) => savePreset(presetsDeps, key, preset),
savePreset: async (key: string, preset: Preset): Promise<void> => {
await savePreset(presetsDeps, key, preset);
},
};
const socialDeps: SocialDeps = {
+20 -5
View File
@@ -141,14 +141,29 @@ function fallback(): RegionAnalysis {
}
/**
* Crop `input` to exactly w x h using sharp's attention strategy, which keeps the most
* Crop `input` to exactly `size` using sharp's attention strategy, which keeps the most
* salient part of the picture. (`smartcrop.js` is five years unmaintained — not used.)
*
* Returns the size actually produced, read back from the written file rather than echoed
* from the request, so a caller is never told a size the image does not have.
*/
export async function smartCrop(input: string, out: string, w: number, h: number): Promise<string> {
await sharp(input)
.resize({ width: w, height: h, fit: "cover", position: sharp.strategy.attention })
export async function smartCrop(
input: string,
out: string,
size: { width: number; height: number },
opts: { signal?: AbortSignal; onProgress?: (message: string) => void } = {},
): Promise<{ path: string; width: number; height: number }> {
opts.signal?.throwIfAborted();
opts.onProgress?.("Ritaglio l'immagine…");
const info = await sharp(input)
.resize({
width: Math.max(1, Math.round(size.width)),
height: Math.max(1, Math.round(size.height)),
fit: "cover",
position: sharp.strategy.attention,
})
.toFile(out);
return out;
return { path: out, width: info.width, height: info.height };
}
/** Pixel dimensions of an image, or null when it cannot be read. */
+105 -5
View File
@@ -176,7 +176,7 @@ export async function stageBundle(jobDir: string, template: TemplateName): Promi
* Render one output. `jobDir` must already hold the artwork (and the logo, if any).
* Returns the output path.
*/
export async function render(
export async function renderOne(
resolved: ResolvedSpec,
jobDir: string,
outPath: string,
@@ -265,7 +265,7 @@ export interface FormatOutput {
* One spec -> every requested format. The auto-fit is re-solved per aspect ratio inside
* the template, so a layout is never merely scaled.
*/
export async function renderAllFormats(
export async function renderFormats(
spec: DesignSpec,
cfg: ImgenConfig,
jobDir: string,
@@ -281,13 +281,13 @@ export async function renderAllFormats(
// PDF carries the bleed and the TrimBox — this is the file for the copy shop.
out.push({
format,
path: await render(resolved, jobDir, join(jobDir, `${format}.pdf`), { ...opts, format: "pdf" }),
path: await renderOne(resolved, jobDir, join(jobDir, `${format}.pdf`), { ...opts, format: "pdf" }),
kind: "print",
});
// PNG of the same page comes out already trimmed (no bleed) — handy for preview.
out.push({
format,
path: await render(resolved, jobDir, join(jobDir, `${format}.png`), {
path: await renderOne(resolved, jobDir, join(jobDir, `${format}.png`), {
...opts, format: "png", ppi: cfg.print.dpi || PRINT_DPI,
}),
kind: "print",
@@ -295,7 +295,7 @@ export async function renderAllFormats(
} else {
out.push({
format,
path: await render(resolved, jobDir, join(jobDir, `${format}.png`), {
path: await renderOne(resolved, jobDir, join(jobDir, `${format}.png`), {
...opts, format: "png", ppi: ppiForScreen(format),
}),
kind: "screen",
@@ -314,3 +314,103 @@ function ppiForScreen(format: Format): number {
}
export { artTargetSize, type PrintTarget };
// ---------------------------------------------------------------------------
// Request-shaped API — the `Renderer` interface the commands declare.
//
// The commands own that contract (see commands/poster.ts); this is where it is
// satisfied, so index.ts can wire the renderer in without an adapter of its own.
// ---------------------------------------------------------------------------
export interface RenderRequest {
spec: DesignSpec;
/** Absolute path to the artwork PNG. Never contains lettering. */
artPath: string;
/** The job folder. Outputs land here, and it becomes Typst's --root. */
outDir: string;
formats?: readonly Format[];
config: ImgenConfig;
/** False during the draft loop: a PDF nobody will open costs seconds. */
pdf?: boolean;
ppi?: number;
signal?: AbortSignal;
onProgress?: (message: string) => void;
}
export interface RenderOutcome {
files: string[];
warnings?: string[];
}
/**
* Copy a referenced file into the job folder when it is not already there, and return
* the root-relative name Typst needs. Artwork and logos routinely live elsewhere.
*/
async function stageFile(jobDir: string, absPath: string): Promise<string> {
const name = basename(absPath);
const target = join(jobDir, name);
if (resolve(absPath) !== resolve(target)) {
await mkdir(jobDir, { recursive: true });
await copyFile(absPath, target);
}
return name;
}
/**
* Typeset the requested formats. Pure with respect to the artwork: no model ever runs
* here, which is what makes a font or colour change a sub-second operation.
*/
export async function render(req: RenderRequest): Promise<RenderOutcome> {
const { spec, config, outDir, artPath } = req;
const formats = (req.formats?.length ? req.formats : [spec.format]) as Format[];
const files: string[] = [];
const warnings: string[] = [];
await mkdir(outDir, { recursive: true });
const artName = await stageFile(outDir, artPath);
if (spec.logo?.path && existsSync(spec.logo.path)) {
await stageFile(outDir, spec.logo.path);
} else if (spec.logo?.path) {
warnings.push(`Logo non trovato, lo salto: ${spec.logo.path}`);
}
for (const format of formats) {
const geo = FORMATS_GEOMETRY[format];
const resolved = await resolveSpec(spec, config, artPath, { format, artFileName: artName });
if (resolved.needs_scrim) {
warnings.push("Ho aggiunto una velatura dietro al testo: l'immagine sotto era troppo movimentata.");
}
req.onProgress?.(`Compongo il testo (${format})…`);
const png = join(outDir, `${format}.png`);
files.push(await renderOne(resolved, outDir, png, {
format: "png",
ppi: req.ppi ?? (geo.kind === "print" ? config.print.dpi || PRINT_DPI : ppiForScreen(format)),
signal: req.signal,
}));
// The PDF is the file the copy shop gets: it alone carries the bleed and TrimBox.
if (geo.kind === "print" && req.pdf !== false) {
req.onProgress?.("Preparo il PDF per la stampa…");
files.push(await renderOne(resolved, outDir, join(outDir, `${format}.pdf`), {
format: "pdf",
signal: req.signal,
}));
}
}
return { files, warnings: warnings.length ? dedupe(warnings) : undefined };
}
/** Every requested format in one pass. Same contract; formats defaults to all of them. */
export async function renderAllFormats(req: RenderRequest): Promise<RenderOutcome> {
const formats = (req.formats?.length
? req.formats
: (Object.keys(FORMATS_GEOMETRY) as Format[])) as Format[];
return render({ ...req, formats });
}
function dedupe(xs: string[]): string[] {
return [...new Set(xs)];
}
+34 -23
View File
@@ -31,7 +31,7 @@
import { existsSync, renameSync } from "node:fs";
import { basename, extname, isAbsolute, join, resolve } from "node:path";
import { Type, type Static } from "typebox";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import {
@@ -43,7 +43,6 @@ import {
import type { ImgenConfig } from "../config.ts";
import type { Backend } from "../backends/types.ts";
import { BackendError } from "../backends/types.ts";
import {
vectorize as vectorizeImpl,
type CpuOpResult,
@@ -67,7 +66,6 @@ import {
describeJob,
jobFile,
listJobs,
loadSpec,
normaliseSlug,
openInFinder,
openJob,
@@ -132,9 +130,11 @@ const T = {
vectorDone: "Ricalco finito: l'SVG lo puoi ingrandire quanto vuoi senza che si sgrani.",
captionAsk: "Il testo per i social lo scrivo solo se me lo chiedi.",
labels: {
art: "L'immagine di sfondo",
draft: "La prova veloce",
svg: "Il marchio ricalcato (SVG)",
editImage: "Modifico l'immagine",
removeBackground: "Tolgo lo sfondo",
upscale: "Ingrandisco l'immagine",
vectorize: "Ricalco con linee nitide",
listJobs: "Guardo i lavori fatti",
},
} as const;
@@ -248,14 +248,22 @@ function outPath(raw: string | undefined, dir: string, fallbackName: string): st
return isAbsolute(p) ? p : resolve(dir, p);
}
/** Opens an existing job, or refuses in Italian naming the slug the model invented. */
/**
* Opens an existing job, or refuses in Italian naming the slug the model invented.
* job.ts's own JobError is deliberately swallowed: "Non trovo la cartella" is true but
* useless to the model, whereas naming list_jobs tells it exactly how to recover.
*/
function requireJob(deps: ToolsDeps, slug: string): Job {
const key = normaliseSlug(slug);
const job = key ? openJob(cfgOf(deps), key) : undefined;
if (!job || !existsSync(job.dir)) {
refuse(fill(T.jobUnknown, { slug }), T.jobUnknownFix);
if (key) {
try {
const job = openJob(cfgOf(deps), key);
if (existsSync(job.dir)) return job;
} catch {
/* fall through to the refusal below */
}
}
return job;
refuse(fill(T.jobUnknown, { slug }), T.jobUnknownFix);
}
/** The artwork a typeset pass should place: an explicit override, a photo, or art.png. */
@@ -341,9 +349,14 @@ const BlocksParam = Type.Object(
},
);
/** Compile-time proof that BlocksParam covers every role in the fixed contract. */
type _BlocksCoverAllRoles =
/**
* Compile-time proof that BlocksParam covers every role in the fixed contract. Adding a
* role to BLOCK_ROLES without adding it here is a type error, not a silently ignored field.
*/
type BlocksCoverAllRoles =
Exclude<(typeof BLOCK_ROLES)[number], keyof (typeof BlocksParam)["properties"]> extends never ? true : never;
const BLOCKS_ARE_EXHAUSTIVE: BlocksCoverAllRoles = true;
void BLOCKS_ARE_EXHAUSTIVE;
/**
* The whole DesignSpec as a tool parameter — the fixed contract, reused verbatim rather
@@ -612,7 +625,7 @@ export function createTypesetSpecTool(deps: ToolsDeps) {
if (!spec) refuse(T.specMissing, T.specMissingFix);
if (params.spec) changed.push("spec");
const preset = params.preset ? cfgOf(deps).presets[params.preset] : undefined;
const preset = params.preset ? cfg.presets[params.preset] : undefined;
if (params.preset && !preset) fail(new Error(params.preset), S.errors.presetUnknown(params.preset));
// Each edit goes through the command's own `applyInstantRefinement`, which is typed
@@ -801,10 +814,8 @@ export function createRenderSpecTool(deps: ToolsDeps) {
elapsedMs: result.elapsedMs,
};
} else {
const formats = ((params.formats as Format[] | undefined)?.filter((f) => FORMATS.includes(f)) ?? [])
.length
? (params.formats as Format[])
: exportFormats(spec.format);
const asked = (params.formats as Format[] | undefined)?.filter((f) => FORMATS.includes(f)) ?? [];
const formats = asked.length ? asked : exportFormats(spec.format);
const artPath = artworkFor(job, spec);
say(S.progress.exporting);
@@ -934,7 +945,7 @@ const EditImageParams = Type.Object({
export function createEditImageTool(deps: ToolsDeps) {
return defineTool({
name: "edit_image",
label: S.toolLabels.retouch,
label: T.labels.editImage,
description:
"Edit an existing image generatively with the local diffusion model (img2img). edit_image is the " +
"FRAGILE one: Draw Things upstream issue #121 makes img2img crash on 16GB Macs, and this is a 16GB " +
@@ -986,7 +997,7 @@ const RemoveBackgroundParams = Type.Object({
export function createRemoveBackgroundTool(deps: ToolsDeps) {
return defineTool({
name: "remove_background",
label: S.toolLabels.retouch,
label: T.labels.removeBackground,
description:
"Cut the subject out of an image and write a transparent RGBA PNG. remove_background uses Apple " +
"Vision first and rembg as a fallback: no diffusion model, no download, always available even when " +
@@ -1033,7 +1044,7 @@ const UpscaleImageParams = Type.Object({
export function createUpscaleImageTool(deps: ToolsDeps) {
return defineTool({
name: "upscale_image",
label: S.toolLabels.retouch,
label: T.labels.upscale,
description:
"Enlarge an image without it going soft. upscale_image runs the ncnn/Vulkan Real-ESRGAN binary — " +
"no diffusion model — and only falls back to the image backend when that binary is missing.",
@@ -1094,7 +1105,7 @@ export interface VectorizeDetails extends CpuOpResult {
export function createVectorizeImageTool(deps: ToolsDeps) {
return defineTool({
name: "vectorize_image",
label: S.toolLabels.retouch,
label: T.labels.vectorize,
description:
"Trace a raster image into a clean SVG with vtracer. vectorize_image is what turns a generated logo " +
"into the file a printer can enlarge to any size without pixels; it uses no diffusion model. " +
@@ -1168,7 +1179,7 @@ export interface ListJobsDetails {
export function createListJobsTool(deps: ToolsDeps) {
return defineTool({
name: "list_jobs",
label: S.toolLabels.brief,
label: T.labels.listJobs,
description:
"List the poster and logo jobs already on disk: slug, title, date, format, and which files each " +
"folder holds. list_jobs is how you find the slug that render_spec and typeset_spec need, and how " +
+38 -30
View File
@@ -33,7 +33,7 @@
#set document(date: none) // load-bearing: this is what makes output byte-identical
#set text(lang: "it") // Italian hyphenation and quotes, for free
#set par(linebreaks: "optimized") // Knuth-Plass, for any paragraph outside `fit-text`
#set par(linebreaks: "optimized") // Knuth-Plass; see the note below on why type pins "simple"
// ---------------------------------------------------------------------------
// Two measured Typst 0.15.1 facts that this template has to defend against.
@@ -196,8 +196,10 @@
out
}
/// The colophon line. Several footer blocks collapse into one rule-of-thumb line rather
/// than stacking, because the foot of the panel is a single band shared with the logo.
/// The colophon line. `footer` is the one role lifted out of the flow: it belongs at the
/// foot of the panel, on the same band as the logo, not wherever it happens to fall in
/// the stack. Several footer blocks collapse into one line for the same reason — that
/// band is a single line shared with a logo, not a place to stack paragraphs.
#let foot-body = {
let fs = entries.filter(e => e.role == "footer")
if fs.len() == 0 { none } else { fs.map(e => e.body).join(" · ") }
@@ -226,17 +228,18 @@
/// size `block-style` computed for its role, plus its leading.
#let _min-need(st) = st.min-size * 1.55
/// Blocks that go in the stack, after dropping `optional: true` entries that genuinely
/// do not fit. Spec order is priority order (title first, footer last), so the LAST
/// optional block is always the cheapest thing to lose. Note this only fires when the
/// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is
/// handled by `fit`, which narrows the type on the wdth axis long before anything is
/// thrown away.
/// Total space the gaps take for a candidate stack, capped so the air can never outweigh
/// the words: without the cap a panel of nothing but large roles would spend most of its
/// height on the spaces between them.
#let gaps-of(list) = calc.min(_gap-sum(list), flow-h * 0.35)
/// Blocks that go in the stack, after dropping `optional: true` entries that genuinely
/// do not fit. Spec order is priority order (title first, footer last), so the LAST
/// optional block is always the cheapest thing to lose. Note this only fires when the
/// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is
/// handled by `fit`, which narrows the type on the wdth axis long before anything is
/// thrown away. Nothing that is not marked optional is ever dropped: a poster missing
/// its date is a worse outcome than a poster that visibly does not fit.
#let flow = {
let list = entries.filter(e => e.role != "footer")
let needed(l) = {
@@ -252,11 +255,6 @@
list
}
/// Split the flow height between the survivors by weight, so the panel is always exactly
/// filled: one lone title gets the whole panel (and `fit` still refuses to grow past its
/// ideal size), while a seven-block spec divides it proportionally and the title keeps
/// roughly half. Because every budget is honoured by `fit`, the stack can never be taller
/// than the panel — the layout has no overflow case.
#let _wsum = flow.fold(0.0, (a, e) => a + e.weight)
#let _inner = calc.max(1mm, flow-h - gaps-of(flow))
@@ -267,6 +265,12 @@
if raw > 0pt { gaps-of(flow) / raw } else { 1.0 }
}
#let gap-after(e) = _gap-raw(e) * _gap-scale
/// How much height a block may use: its share of what is left after the gaps, weighted by
/// role. The weights sum to whatever the spec contains, so the budgets always add up to
/// the flow area exactly — one lone title is offered the whole panel (and `fit` still
/// refuses to grow past its ideal size, so it simply sits in the middle of it), while a
/// seven-block spec divides it proportionally and the title keeps roughly half.
#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) }
// ---------------------------------------------------------------------------
@@ -394,15 +398,23 @@
)
}
/// The blocks, in spec order, each auto-fitted into its slice of the panel.
///
/// `set block(spacing: 0pt)` is not cosmetic: Typst's default 1.2em block spacing is
/// measured against the AMBIENT text size, which in a page background is the 11pt
/// default and has nothing to do with this poster. All vertical rhythm here is `gap`,
/// which is a fraction of the trim, so it must be the only spacing in play.
/// Both set rules are load-bearing, and both were found by looking at pixels.
///
/// `spacing: 0pt` — Typst's default 1.2em block spacing is measured against the AMBIENT
/// text size, which inside a page background is the 11pt default and has nothing to do
/// with this poster. Left on, it added ~13pt between every pair of blocks and pushed the
/// price straight through the colophon at yt-thumb. All vertical rhythm here is
/// `gap-after`, a fraction of the trim, and it has to be the only spacing in play.
///
/// `breakable: false` — a block that does not fit its region is SPLIT across regions,
/// and a page background offers exactly one, so the remainder is re-laid at the same
/// origin and the blocks print on top of each other. That only happens when the stack
/// overflows the flow area at all (an absurd `safeMm`), but the difference between
/// "overflows" and "unreadable" is this one word.
#let stack-body = {
set block(spacing: 0pt)
set block(spacing: 0pt, breakable: false)
set par(spacing: 0pt)
for (i, e) in flow.enumerate() {
if i > 0 { v(gap-after(flow.at(i - 1)), weak: false) }
@@ -433,11 +445,6 @@
/// blocks are dropped first. It then spills symmetrically over the seam and the colophon
/// rather than overlapping itself, which is the failure worth having: visibly wrong, but
/// with nothing silently deleted from an event poster.
/// `breakable: false` is not decoration. A breakable block with a fixed height whose
/// content does not fit is split across REGIONS, and a page background offers exactly
/// one — so the remainder is re-laid at the same origin and the blocks print on top of
/// each other. Measured with `safeMm: 80`: subtitle, date and venue all piled onto the
/// same three lines. Unbreakable, the same spec simply overflows in one piece.
#let type-stack = block(
width: panel.width,
height: flow-h,
@@ -451,15 +458,16 @@
#let foot-line = if foot-body == none { none } else {
let reserve = if logo-on-panel-bottom { logo-w + gap } else { 0pt }
let w = calc.max(1mm, panel.width - reserve)
let h = calc.max(foot-text-h, 1mm)
let x = panel.x + (if logo-on-panel-bottom and logo-left { reserve } else { 0pt })
let a = if logo-on-panel-bottom and logo-left { right } else { left }
// The height is pinned as well as the width. Bottom-anchored content that is free to
// grow would grow UPWARD into the stack, which is exactly the collision the reserve
// above exists to prevent.
// The height is pinned as well as the width. Bottom-anchored content free to grow would
// grow UPWARD into the stack, which is the collision the reserve above exists to stop.
place(bottom + left, dx: x, dy: -sa.y, block(
width: w,
height: calc.max(foot-text-h, 1mm),
align(bottom, fit-text(foot-body, w, calc.max(foot-text-h, 1mm), foot-style, align-to: a)),
height: h,
breakable: false,
align(bottom, fit-text(foot-body, w, h, foot-style, align-to: a)),
))
}
+10 -1
View File
@@ -34,11 +34,20 @@ console.log("resolved.font_axes =", JSON.stringify(resolved.font_axes));
const TY = process.env.TYPST;
const out = join(job, "poster.pdf");
await typst.render(resolved, job, out, { binary: TY });
await typst.renderOne(resolved, job, out, { binary: TY });
const d = readFileSync(out);
const tb = /\/TrimBox\s*\[([^\]]*)\]/.exec(d.toString("latin1"));
if (!tb) { console.log("TrimBox: ABSENT"); process.exit(1); }
const [x0,y0,x1,y1] = tb[1].split(/\s+/).map(Number);
console.log(`TrimBox mm = ${((x1-x0)/72*25.4).toFixed(1)} x ${((y1-y0)/72*25.4).toFixed(1)}`);
console.log("bundle staged =", existsSync(join(job, ".typst/lib.typ")), existsSync(join(job, ".typst/hero-bottom.typ")));
// The request-shaped API is what the commands actually call.
const outcome = await typst.render({
spec, artPath: join(job, "art.png"), outDir: job, config: cfg,
formats: ["a3-portrait", "ig-story"], pdf: true,
onProgress: (m) => console.log(" progresso:", m),
});
console.log("files =", outcome.files.map(f => f.split("/").pop()).join(", "));
console.log("warnings =", outcome.warnings ?? "(nessuno)");
console.log("OK");