diff --git a/extensions/imgen/index.ts b/extensions/imgen/index.ts index 7197a78..b833dd4 100644 --- a/extensions/imgen/index.ts +++ b/extensions/imgen/index.ts @@ -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; - renderAllFormats(req: RenderRequest): Promise; - } + // 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 | null = null; - const typst = (): Promise => { - typstApi ??= import("./render/typst.ts") - .then((mod) => { - const api = mod as unknown as Partial; - 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(); - 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 => { + 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 => { + 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 { + return adapt(await typstRender(req)); }, - async renderAllFormats(req) { - return toRenderOutcome(await (await typst()).renderAllFormats(req)); + async renderAllFormats(req: RenderRequest): Promise { + return adapt(await typstRenderAll(req)); }, }; + /** basename -> format, so a produced path can be labelled without asking the renderer. */ + const FORMAT_BY_FILE = new Map(); + 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 { - 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 => { + await savePreset(presetsDeps, key, preset); + }, }; const socialDeps: SocialDeps = { diff --git a/extensions/imgen/render/contrast.ts b/extensions/imgen/render/contrast.ts index ca20ce2..99dcc67 100644 --- a/extensions/imgen/render/contrast.ts +++ b/extensions/imgen/render/contrast.ts @@ -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 { - 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. */ diff --git a/extensions/imgen/render/typst.ts b/extensions/imgen/render/typst.ts index 90e1f0d..3a41fdd 100644 --- a/extensions/imgen/render/typst.ts +++ b/extensions/imgen/render/typst.ts @@ -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 { + 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 { + 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 { + 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)]; +} diff --git a/extensions/imgen/tools/index.ts b/extensions/imgen/tools/index.ts index f9f41a5..35499df 100644 --- a/extensions/imgen/tools/index.ts +++ b/extensions/imgen/tools/index.ts @@ -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 " + diff --git a/templates/split.typ b/templates/split.typ index 255aabd..7fcf84f 100644 --- a/templates/split.typ +++ b/templates/split.typ @@ -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)), )) } diff --git a/tests/e2e/render.mjs b/tests/e2e/render.mjs index 7584e8f..a2b5b22 100644 --- a/tests/e2e/render.mjs +++ b/tests/e2e/render.mjs @@ -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");