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:
+83
-117
@@ -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 = {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user