feat(render): Typst renderer, contrast analysis and print pre-flight
render/typst.ts stages each job as a self-contained bundle (<job>/.typst/ lib.typ + template + resolved.json) and roots Typst there, so artwork living outside the package is reachable and old jobs stay re-renderable. Paths are root-relative throughout -- Typst resolves a leading / against --root, not the filesystem. render/contrast.ts measures the region behind the text with sharp and forces a scrim on high variance even when mean luminance passes; verified with a checkerboard that scores 5.45:1 contrast yet stdev 119.9. render/preflight.ts checks TrimBox, embedded fonts, image dpi and structure, degrading to 'non installato' rather than failing when a tool is absent. E2E verified via jiti: TrimBox 297.0x420.0mm, font axes injected, bundle staged. 0 type errors.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Caratteri di terze parti inclusi in pi-imgen
|
||||
|
||||
Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:30 UTC. Non modificare a mano.
|
||||
Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:43 UTC. Non modificare a mano.
|
||||
|
||||
I file dei caratteri stanno in `vendor/fonts/<id>/` e **non** sono versionati.
|
||||
Ogni cartella contiene il file `LICENSE` originale.
|
||||
|
||||
@@ -28,6 +28,7 @@ import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { BackendError, type ProgressFn } from "./types.ts";
|
||||
import { withHeavyLock } from "./serialize.ts";
|
||||
import type { ImgenConfig } from "../config.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -529,11 +530,25 @@ async function findUpscaleModelsDir(binPath: string, explicit?: string): Promise
|
||||
* the native x4 (twice, when more than 4x is asked) and resample down to the requested size
|
||||
* with sharp/Lanczos — sharper than a fake native 2x and never a silent wrong-model load.
|
||||
*/
|
||||
export async function upscaleRealesrgan(
|
||||
export function upscaleRealesrgan(
|
||||
input: string,
|
||||
out: string,
|
||||
factor: number,
|
||||
opts: UpscaleOptions = {},
|
||||
): Promise<CpuOpResult> {
|
||||
// ncnn/Vulkan is a second heavy process with its own large footprint: it must never
|
||||
// run beside a Draw Things diffusion job on a 16GB machine. Same lock, one queue.
|
||||
return withHeavyLock(
|
||||
() => upscaleRealesrganInner(input, out, factor, opts),
|
||||
() => opts.onProgress?.("Un'altra elaborazione è in corso: l'ingrandimento parte appena si libera la memoria."),
|
||||
);
|
||||
}
|
||||
|
||||
async function upscaleRealesrganInner(
|
||||
input: string,
|
||||
out: string,
|
||||
factor: number,
|
||||
opts: UpscaleOptions,
|
||||
): Promise<CpuOpResult> {
|
||||
const started = Date.now();
|
||||
const notes: string[] = [];
|
||||
|
||||
@@ -8,15 +8,22 @@
|
||||
* - Raw gRPC carries `configuration` as a FlatBuffer (config.fbs) with no Node SDK.
|
||||
* - The in-app JS scripting engine cannot be triggered from outside.
|
||||
*
|
||||
* Two invariants this file exists to protect:
|
||||
* Invariants this file exists to protect:
|
||||
* 1. `--output` is MANDATORY. Without it the CLI paints an inline terminal preview and
|
||||
* INTENTIONALLY WRITES NO FILE — the classic silently-empty result. We always pass
|
||||
* it, and we verify afterwards that a non-empty file really appeared.
|
||||
* 2. `--disable-preview` is always passed, otherwise every run draws into the terminal.
|
||||
* 2. That verification is only worth anything against a file THIS run created, so every
|
||||
* run renders to a fresh temp sibling and is `rename()`d onto the destination only
|
||||
* once it has been checked. Otherwise a stale poster from a previous prompt/seed
|
||||
* passes the check and is handed back with the new seed and model attached.
|
||||
* 3. `--disable-preview` is always passed, otherwise every run draws into the terminal.
|
||||
* 4. `--remote` is added ONLY when the daemon is really listening: it has no in-process
|
||||
* fallback, so aiming it at a closed port fails every single run.
|
||||
* 5. One heavy job at a time, process-wide (see serialize.ts).
|
||||
*
|
||||
* Memory: peak RAM at VAE DECODE — not weight size — is what kills a 16GB machine
|
||||
* (FLUX.2 klein @1024²: 14.03GB untiled vs 7.18GB tiled). Tiling therefore travels in
|
||||
* every `--config-json` payload we build.
|
||||
* every `--config-json` payload we build — flag AND geometry, never the flag alone.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
@@ -100,8 +107,13 @@ const DEFAULT_TILE_OVERLAP_PX = 64;
|
||||
*/
|
||||
const REMOTE_ABORT_SETTLE_MS = 8_000;
|
||||
|
||||
/** Stable `Error.message` values, matched in a few places; keep them in sync. */
|
||||
/**
|
||||
* Stable `Error.message` values, matched in a few places; keep them in sync. The
|
||||
* before-start variant is distinct on purpose: nothing was ever spawned, so there is no
|
||||
* orphaned server-side job to wait out.
|
||||
*/
|
||||
const ABORTED_MSG = "generation aborted";
|
||||
const ABORTED_BEFORE_START_MSG = "generation aborted before start";
|
||||
const TIMED_OUT_MSG = "generation timed out";
|
||||
|
||||
/** Upscale is deterministic in intent; there is no user-facing seed for it. */
|
||||
@@ -532,9 +544,9 @@ function issue121Error(confident: boolean, detail: string): BackendError {
|
||||
);
|
||||
}
|
||||
|
||||
function abortedError(): BackendError {
|
||||
function abortedError(beforeStart = false): BackendError {
|
||||
return new BackendError(
|
||||
ABORTED_MSG,
|
||||
beforeStart ? ABORTED_BEFORE_START_MSG : ABORTED_MSG,
|
||||
"Operazione annullata: la generazione è stata interrotta prima di produrre un'immagine.",
|
||||
);
|
||||
}
|
||||
@@ -544,7 +556,7 @@ function runCli(input: RunInput): Promise<RunOutcome> {
|
||||
|
||||
return new Promise<RunOutcome>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortedError());
|
||||
reject(abortedError(true));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -639,9 +651,10 @@ function runCli(input: RunInput): Promise<RunOutcome> {
|
||||
if (e.code === "ENOENT") {
|
||||
reject(
|
||||
new BackendError(
|
||||
`${binary} not found on PATH`,
|
||||
`Comando «${binary}» non trovato. Installalo con: brew install draw-things-cli ` +
|
||||
"(formula Homebrew CORE, non un tap personalizzato e non --HEAD).",
|
||||
`${binary} not found`,
|
||||
`Comando «${binary}» non più eseguibile. Reinstallalo con: brew install draw-things-cli ` +
|
||||
"(formula Homebrew CORE, non un tap personalizzato e non --HEAD), " +
|
||||
"oppure indica il percorso completo in IMGEN_DT_CLI.",
|
||||
e.message,
|
||||
),
|
||||
);
|
||||
@@ -997,8 +1010,10 @@ export class DrawThingsBackend implements Backend {
|
||||
* harmlessly onto the model's recommended settings.
|
||||
*
|
||||
* Because this run uses `--image`, it is exposed to upstream issue #121. Print must
|
||||
* not die on that bug, so a crash falls back to a high-quality Lanczos resample via
|
||||
* sharp — announced in Italian through onProgress, never silently.
|
||||
* not die on that bug, so a CRASH falls back to a high-quality Lanczos resample via
|
||||
* sharp — announced in Italian through onProgress, never silently. Nothing else takes
|
||||
* that fallback: a stopped daemon is reported as a stopped daemon, because silently
|
||||
* shipping a non-generative upscale in its name is a wrong result with a wrong reason.
|
||||
*/
|
||||
async upscale(
|
||||
input: string,
|
||||
@@ -1055,12 +1070,17 @@ export class DrawThingsBackend implements Backend {
|
||||
0,
|
||||
);
|
||||
|
||||
const config: Record<string, unknown> = { ...this.tilingPayload() };
|
||||
// Memory safety: at 4× the VAE decode is what OOMs a 16GB machine, so tiling is
|
||||
// forced on for large targets regardless of configuration.
|
||||
if (longEdge > FORCE_TILING_ABOVE_PX && config.tiledDecoding !== true) {
|
||||
config.tiledDecoding = true;
|
||||
onProgress?.("Decodifica a tasselli attivata automaticamente per non saturare la memoria.");
|
||||
// forced on for large targets regardless of configuration — WITH its geometry, or
|
||||
// the flag alone would leave the tile size up to the model's recommended settings.
|
||||
const forceTiling = longEdge > FORCE_TILING_ABOVE_PX;
|
||||
const config: Record<string, unknown> = { ...this.tilingPayload(forceTiling) };
|
||||
if (forceTiling && !this.cfg.tiling?.tiledDecoding) {
|
||||
onProgress?.(
|
||||
"Decodifica a tasselli attivata automaticamente " +
|
||||
`(${config.decodingTileWidth}×${config.decodingTileHeight}px, sovrapposizione ` +
|
||||
`${config.decodingTileOverlap}px) per non saturare la memoria.`,
|
||||
);
|
||||
}
|
||||
config.strength = this.upscaleStrength;
|
||||
if (/(esrgan|ultrasharp|remacri|swinir|\b[248]x\b)/i.test(model)) {
|
||||
@@ -1068,6 +1088,16 @@ export class DrawThingsBackend implements Backend {
|
||||
config.upscalerScaleFactor = Math.round(factor);
|
||||
}
|
||||
|
||||
// Fail fast, before queueing behind another job.
|
||||
const cliPath = await this.requireCliPath();
|
||||
|
||||
return withHeavyLock(
|
||||
async () => {
|
||||
// Timed from inside the queue: waiting for the lock is not upscaling time.
|
||||
const started = Date.now();
|
||||
const remote = await this.remoteArgs(onProgress);
|
||||
const tmpOut = tempOutputPath(outPath);
|
||||
|
||||
const args = [
|
||||
"generate",
|
||||
"--model", model,
|
||||
@@ -1081,22 +1111,29 @@ export class DrawThingsBackend implements Backend {
|
||||
"--models-dir", this.cfg.modelsPath,
|
||||
"--config-json", JSON.stringify(config),
|
||||
"--image", input,
|
||||
"--output", outPath,
|
||||
"--output", tmpOut,
|
||||
"--disable-preview",
|
||||
];
|
||||
if (this.offline) args.push("--offline");
|
||||
args.push(...this.remoteArgs());
|
||||
args.push(...remote.args);
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
try {
|
||||
const outcome = await runCli({
|
||||
binary: this.cli,
|
||||
binary: cliPath,
|
||||
args,
|
||||
onProgress,
|
||||
signal,
|
||||
timeoutMs: this.timeoutMs,
|
||||
});
|
||||
await this.assertSuccess(outcome, outPath, true);
|
||||
await this.assertSuccess({
|
||||
outcome,
|
||||
filePath: tmpOut,
|
||||
reportPath: outPath,
|
||||
usesImage: true,
|
||||
remoteUsed: remote.used,
|
||||
});
|
||||
await rename(tmpOut, outPath);
|
||||
const real = await readImageSize(outPath);
|
||||
return {
|
||||
path: outPath,
|
||||
@@ -1107,8 +1144,15 @@ export class DrawThingsBackend implements Backend {
|
||||
elapsedMs: outcome.elapsedMs,
|
||||
};
|
||||
} catch (e) {
|
||||
// An abort is the user's decision, not a failure to work around.
|
||||
if (signal?.aborted) throw e;
|
||||
// An abort (or a timeout) is a decision already taken, not a failure to work
|
||||
// around: never resample instead.
|
||||
if (signal?.aborted || wasKilledByUs(e)) {
|
||||
if (remote.used && wasKilledByUs(e)) await this.settleAfterRemoteKill(onProgress);
|
||||
throw e;
|
||||
}
|
||||
// ONLY the real upstream bug earns the fallback. A dead server raises
|
||||
// serverUnreachableError instead, so "start gRPCServerCLI" can no longer be
|
||||
// silently answered with a non-generative resample.
|
||||
const isKnownBug = e instanceof BackendError && e.message.includes("issue #121");
|
||||
if (!isKnownBug) throw e;
|
||||
|
||||
@@ -1117,7 +1161,16 @@ export class DrawThingsBackend implements Backend {
|
||||
"procedo con un ridimensionamento Lanczos di alta qualità. " +
|
||||
"Il dettaglio non viene ricostruito, ma la locandina resta stampabile.",
|
||||
);
|
||||
await resampleWithSharp(input, outPath, targetW, targetH);
|
||||
// Same temp-then-rename discipline: a half-written fallback must not land
|
||||
// on the destination either.
|
||||
await resampleWithSharp(input, tmpOut, targetW, targetH);
|
||||
if (!(await isNonEmptyFile(tmpOut))) {
|
||||
throw new BackendError(
|
||||
"sharp fallback produced no file",
|
||||
"Ingrandimento di riserva non riuscito: nessuna immagine prodotta.",
|
||||
);
|
||||
}
|
||||
await rename(tmpOut, outPath);
|
||||
const fallbackSize = await readImageSize(outPath);
|
||||
return {
|
||||
path: outPath,
|
||||
@@ -1128,6 +1181,12 @@ export class DrawThingsBackend implements Backend {
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
await discardQuietly(tmpOut);
|
||||
}
|
||||
},
|
||||
() => onProgress?.("Un'altra elaborazione è in corso: l'ingrandimento parte appena si libera la memoria."),
|
||||
);
|
||||
}
|
||||
|
||||
// -- internals -----------------------------------------------------------
|
||||
@@ -1161,48 +1220,115 @@ export class DrawThingsBackend implements Backend {
|
||||
* The tiling lever: a PARTIAL JSGenerationConfiguration merged onto the model's
|
||||
* recommended settings — never a complete config.
|
||||
*
|
||||
* `force` is the upscale safeguard: it turns tiling on even when the config says off,
|
||||
* and it MUST carry the geometry with it. `{ tiledDecoding: true }` on its own leaves
|
||||
* the tile size to whatever the model recommends (possibly the whole frame), i.e. no
|
||||
* memory saving at all — while the user has already been told tiling is on.
|
||||
*
|
||||
* UNVERIFIED: the units of decodingTileWidth / decodingTileHeight. Pixels are assumed
|
||||
* here (the values in config default to 512/512 with 64 overlap, which reads as px);
|
||||
* if they turn out to be latent units, these would each cover 8× the area.
|
||||
*/
|
||||
private tilingPayload(): Record<string, unknown> {
|
||||
private tilingPayload(force = false): Record<string, unknown> {
|
||||
const t = this.cfg.tiling;
|
||||
if (!t?.tiledDecoding) return { tiledDecoding: false };
|
||||
if (!force && !t?.tiledDecoding) return { tiledDecoding: false };
|
||||
const px = (value: number | undefined, fallback: number): number =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
|
||||
return {
|
||||
tiledDecoding: true,
|
||||
decodingTileWidth: t.decodingTileWidth,
|
||||
decodingTileHeight: t.decodingTileHeight,
|
||||
decodingTileOverlap: t.decodingTileOverlap,
|
||||
decodingTileWidth: px(t?.decodingTileWidth, DEFAULT_TILE_PX),
|
||||
decodingTileHeight: px(t?.decodingTileHeight, DEFAULT_TILE_PX),
|
||||
decodingTileOverlap: px(t?.decodingTileOverlap, DEFAULT_TILE_OVERLAP_PX),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flags that route the run through the resident, already-warm daemon. */
|
||||
private remoteArgs(): string[] {
|
||||
if (!this.cfg.server?.enabled) return [];
|
||||
return [
|
||||
/**
|
||||
* Flags that route the run through the resident, already-warm daemon — but ONLY when
|
||||
* something is actually listening there.
|
||||
*
|
||||
* `--remote` has no fallback: pointed at a closed port the CLI cannot run in-process,
|
||||
* so passing it on `server.enabled` alone (the default) made EVERY generation fail
|
||||
* whenever the daemon was down. Checking the port costs one TCP connect and turns that
|
||||
* hard failure into a slow but working local run.
|
||||
*/
|
||||
private async remoteArgs(onProgress?: ProgressFn): Promise<{ args: string[]; used: boolean }> {
|
||||
const server = this.cfg.server;
|
||||
if (!server?.enabled) return { args: [], used: false };
|
||||
|
||||
if (!(await probePort(server.port))) {
|
||||
onProgress?.(
|
||||
`Server residente non in ascolto su ${REMOTE_HOST}:${server.port}: procedo comunque, ` +
|
||||
"ma il modello va ricaricato da zero (più lento). " +
|
||||
`Per riaverlo caldo: ${serverStartHint(this.cfg)}`,
|
||||
);
|
||||
return { args: [], used: false };
|
||||
}
|
||||
|
||||
return {
|
||||
args: [
|
||||
"--remote",
|
||||
"--remote-url", REMOTE_HOST,
|
||||
"--remote-port", String(this.cfg.server.port),
|
||||
"--remote-port", String(server.port),
|
||||
"--no-remote-tls",
|
||||
];
|
||||
],
|
||||
used: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Killing the CLI does not cancel the job inside gRPCServerCLI. Hold the heavy-work
|
||||
* lock a little longer so the caller's immediate retry cannot start a second decode
|
||||
* while the orphaned one is still holding its peak.
|
||||
*/
|
||||
private async settleAfterRemoteKill(onProgress?: ProgressFn): Promise<void> {
|
||||
onProgress?.(
|
||||
"Interruzione inviata: il server residente sta però ancora completando l'immagine " +
|
||||
"(da qui non è possibile annullarla). Attendo " +
|
||||
`${Math.round(REMOTE_ABORT_SETTLE_MS / 1000)} secondi prima di avviare altro, ` +
|
||||
"per non tenere due elaborazioni in memoria insieme.",
|
||||
);
|
||||
await delay(REMOTE_ABORT_SETTLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a finished child into either silence or a good Italian error. Order matters:
|
||||
* the crash check comes first, because a crashed img2img run also has no output file
|
||||
* and would otherwise be reported as the generic "no image produced".
|
||||
* 1. a broken link to the daemon is diagnosed FIRST, by probing the port — it is the
|
||||
* one failure whose cure ("start the server") the user can act on, and it must
|
||||
* never be dressed up as issue #121;
|
||||
* 2. then the crash checks, because a crashed img2img run also has no output file and
|
||||
* would otherwise be reported as the generic "no image produced";
|
||||
* 3. then, always, "did a non-empty file really appear?".
|
||||
*
|
||||
* `filePath` is the temp file the run was told to write; `reportPath` is the
|
||||
* destination the user knows about.
|
||||
*/
|
||||
private async assertSuccess(
|
||||
outcome: RunOutcome,
|
||||
outPath: string,
|
||||
usesImage: boolean,
|
||||
): Promise<void> {
|
||||
private async assertSuccess(o: {
|
||||
outcome: RunOutcome;
|
||||
filePath: string;
|
||||
reportPath: string;
|
||||
usesImage: boolean;
|
||||
remoteUsed: boolean;
|
||||
}): Promise<void> {
|
||||
const { outcome, filePath, reportPath, usesImage, remoteUsed } = o;
|
||||
const failed = outcome.code !== 0 || outcome.signal !== null;
|
||||
const detail = `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`;
|
||||
const produced = await isNonEmptyFile(filePath);
|
||||
if (!failed && produced) return;
|
||||
|
||||
if (failed && usesImage) {
|
||||
const detail = `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`;
|
||||
const kind = classifyRunFailure(outcome);
|
||||
if (kind !== "other") throw issue121Error(kind === "crash", detail);
|
||||
|
||||
// 1. The daemon. It was listening when we assembled the args (remoteArgs probed it),
|
||||
// so if it is gone now, or the run died on the transport, the run never reached a
|
||||
// model — no #121 diagnosis, and no Lanczos fallback in upscale().
|
||||
if (remoteUsed && (kind === "transport" || !(await probePort(this.cfg.server.port)))) {
|
||||
throw serverUnreachableError(this.cfg, detail);
|
||||
}
|
||||
|
||||
// 2. issue #121. Only for runs that passed --image, and only for genuine crash
|
||||
// fingerprints; a transport error on a LOCAL run counts, since there is no daemon
|
||||
// involved for it to be blamed on.
|
||||
if (usesImage && (kind === "crash" || kind === "suspect" || (!remoteUsed && kind === "transport"))) {
|
||||
throw issue121Error(kind === "crash", detail);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
@@ -1213,30 +1339,25 @@ export class DrawThingsBackend implements Backend {
|
||||
throw new BackendError(
|
||||
`draw-things-cli failed (exit=${outcome.code} signal=${outcome.signal})`,
|
||||
`draw-things-cli è ${how} senza completare la generazione.` +
|
||||
(this.cfg.server?.enabled
|
||||
? ` Verifica che il server residente sia attivo su ${REMOTE_HOST}:${this.cfg.server.port}.`
|
||||
(remoteUsed
|
||||
? ` Il server residente su ${REMOTE_HOST}:${this.cfg.server.port} risponde ancora,`
|
||||
+ " quindi il problema è nella generazione stessa."
|
||||
: ""),
|
||||
tail(outcome.stderr || outcome.stdout, 2_000),
|
||||
);
|
||||
}
|
||||
|
||||
// A zero exit code is not proof of a file: this is exactly the failure mode that a
|
||||
// missing --output produces, so it is always checked.
|
||||
if (!(await isNonEmptyFile(outPath))) {
|
||||
if (usesImage) {
|
||||
// A clean exit with no file, on a run that used --image, is the crash seen from
|
||||
// the outside: the process was reaped before it could write anything.
|
||||
const kind = classifyRunFailure(outcome);
|
||||
if (kind !== "other") throw issue121Error(kind === "crash", detail);
|
||||
}
|
||||
// 3. A zero exit code is not proof of a file: this is exactly the failure mode that a
|
||||
// missing --output produces, so it is always checked — against the temp file this
|
||||
// run was told to write, never against a destination a previous run may have left
|
||||
// behind.
|
||||
throw new BackendError(
|
||||
`no output produced at ${outPath}`,
|
||||
`Nessuna immagine prodotta in «${outPath}», nonostante il comando sia terminato senza errori.`,
|
||||
`no output produced at ${filePath}`,
|
||||
`Nessuna immagine prodotta in «${reportPath}», nonostante il comando sia terminato senza errori.`,
|
||||
tail(outcome.stderr || outcome.stdout, 2_000),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-generative fallback resize. Lanczos3 is the best of the cheap options. */
|
||||
async function resampleWithSharp(
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import type { Backend } from "../backends/types.ts";
|
||||
import type { DirectorContext, ParsedBrief } from "../design/director.ts";
|
||||
import { foldAccents, normaliseSlug } from "../job.ts";
|
||||
import S, { bullets, errorText, fill, list, menu } from "../ui/strings.ts";
|
||||
import { S, bullets, errorText, fill, list, menu } from "../ui/strings.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dependencies — injected so index.ts owns construction and tests own the rest
|
||||
@@ -102,7 +102,7 @@ function agentDir(deps: PresetsDeps): string {
|
||||
|
||||
/**
|
||||
* Reads the file as it is on disk — NOT the DEFAULTS-merged view. Anything we do not
|
||||
* understand (a future key, a hand-written comment-free tweak) survives the round trip.
|
||||
* understand (a key from a newer version, something he added by hand) survives the trip.
|
||||
* A broken file throws instead of being overwritten: losing his settings is worse than
|
||||
* refusing to save.
|
||||
*/
|
||||
|
||||
@@ -105,8 +105,25 @@ export interface ResolvedSpec extends DesignSpec {
|
||||
dpi: number;
|
||||
cropMarks: boolean;
|
||||
};
|
||||
art_file: string; // absolute path to the artwork actually placed
|
||||
ink_resolved: string; // contrast-checked, may override palette.ink
|
||||
needs_scrim: boolean; // from luminance AND variance of the region behind text
|
||||
font_files: Record<string, string>; // family name -> absolute .ttf path
|
||||
/**
|
||||
* ROOT-RELATIVE path to the artwork ("/art.png"). Typst resolves a leading "/" against
|
||||
* --root, NOT the filesystem: handing it an absolute path silently yields
|
||||
* "<root>/home/you/..." and a file-not-found. Same rule for logo.path.
|
||||
*/
|
||||
art_file: string;
|
||||
/**
|
||||
* Ink measured against the ARTWORK, for templates that set type over the picture.
|
||||
* `ink_resolved` is the legacy alias and is kept in sync for older templates.
|
||||
*/
|
||||
ink_on_art: string;
|
||||
/** Ink for type sitting on the flat page background. */
|
||||
ink_on_bg: string;
|
||||
/** @deprecated use ink_on_art; retained so nothing breaks mid-migration. */
|
||||
ink_resolved: string;
|
||||
/** From luminance AND variance behind the text — variance alone can require a scrim. */
|
||||
needs_scrim: boolean;
|
||||
/** family name -> absolute .ttf path (font paths are NOT root-relative). */
|
||||
font_files: Record<string, string>;
|
||||
/** family name -> variable axis ranges, e.g. { Archivo: { wdth: [62,125] } }. */
|
||||
font_axes: Record<string, Record<string, [number, number]>>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Contrast analysis — decides the ink colour and whether a scrim is needed, BEFORE
|
||||
* Typst runs. Keeping this in TypeScript keeps the templates pure functions of the spec.
|
||||
*
|
||||
* The rule that matters: text must be legible against what is ACTUALLY behind it. A mean
|
||||
* luminance check alone is not enough — white text over busy artwork can have a perfectly
|
||||
* comfortable mean and still be unreadable, so variance forces a scrim independently.
|
||||
*/
|
||||
import sharp from "sharp";
|
||||
import type { TemplateName } from "../design/spec.ts";
|
||||
|
||||
/** WCAG AA for large text. Everything a poster sets is large, so this is the honest bar. */
|
||||
export const MIN_CONTRAST = 3.0;
|
||||
|
||||
/** Above this per-channel stdev the region is "busy" and needs a scrim regardless of mean. */
|
||||
export const BUSY_STDEV = 48;
|
||||
|
||||
export interface Region {
|
||||
/** All fractions of the image, 0..1. */
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RegionAnalysis {
|
||||
luminance: number;
|
||||
stdev: number;
|
||||
busy: boolean;
|
||||
ink: string;
|
||||
contrast: number;
|
||||
needsScrim: boolean;
|
||||
}
|
||||
|
||||
/** sRGB channel (0..255) -> linear 0..1. */
|
||||
function linearise(v: number): number {
|
||||
const u = v / 255;
|
||||
return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function relativeLuminance(r: number, g: number, b: number): number {
|
||||
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
|
||||
}
|
||||
|
||||
export function contrastRatio(l1: number, l2: number): number {
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
export function hexLuminance(hex: string): number {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return 0;
|
||||
const n = parseInt(m[1], 16);
|
||||
return relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each template puts its type, as a fraction of the artwork.
|
||||
*
|
||||
* These mirror the layouts in templates/*.typ. `framed` and `split` are deliberately
|
||||
* absent: neither places text over the picture at all (split says so in its own source),
|
||||
* so there is nothing to measure and no scrim to draw.
|
||||
*/
|
||||
export const TEXT_REGIONS: Partial<Record<TemplateName, Region>> = {
|
||||
"hero-bottom": { left: 0, top: 0.55, width: 1, height: 0.45 },
|
||||
banded: { left: 0, top: 0.34, width: 1, height: 0.32 },
|
||||
"centred-stack": { left: 0.06, top: 0.22, width: 0.88, height: 0.56 },
|
||||
};
|
||||
|
||||
/** True when this template sets type over the artwork. */
|
||||
export function typeSitsOnArt(template: TemplateName): boolean {
|
||||
return template in TEXT_REGIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the region of `imagePath` that will sit behind the text.
|
||||
*
|
||||
* Returns the ink to use over the ARTWORK plus whether a scrim is required. Never throws
|
||||
* on a missing or unreadable image: a poster that renders with a safe default beats a
|
||||
* crash, so the fallback is white ink with a scrim.
|
||||
*/
|
||||
export async function analyseRegion(imagePath: string, region: Region): Promise<RegionAnalysis> {
|
||||
try {
|
||||
const img = sharp(imagePath);
|
||||
const meta = await img.metadata();
|
||||
const W = meta.width ?? 0;
|
||||
const H = meta.height ?? 0;
|
||||
if (!W || !H) return fallback();
|
||||
|
||||
// Clamp: a region partly outside the image must still yield a valid extract box.
|
||||
const left = Math.max(0, Math.min(W - 1, Math.round(region.left * W)));
|
||||
const top = Math.max(0, Math.min(H - 1, Math.round(region.top * H)));
|
||||
const width = Math.max(1, Math.min(W - left, Math.round(region.width * W)));
|
||||
const height = Math.max(1, Math.min(H - top, Math.round(region.height * H)));
|
||||
|
||||
// Flatten onto mid-grey first: an alpha channel would otherwise skew the means, and
|
||||
// .stats() reports premultiplied values that misrepresent what a viewer sees.
|
||||
const stats = await sharp(imagePath)
|
||||
.extract({ left, top, width, height })
|
||||
.flatten({ background: { r: 128, g: 128, b: 128 } })
|
||||
.toColourspace("srgb")
|
||||
.stats();
|
||||
|
||||
const ch = stats.channels.slice(0, 3);
|
||||
if (ch.length < 3) return fallback();
|
||||
|
||||
const luminance = relativeLuminance(ch[0].mean, ch[1].mean, ch[2].mean);
|
||||
const stdev = Math.max(...ch.map((c) => c.stdev));
|
||||
const busy = stdev > BUSY_STDEV;
|
||||
|
||||
const white = 1.0;
|
||||
const black = 0.0;
|
||||
const rWhite = contrastRatio(white, luminance);
|
||||
const rBlack = contrastRatio(black, luminance);
|
||||
const useWhite = rWhite >= rBlack;
|
||||
|
||||
return {
|
||||
luminance,
|
||||
stdev,
|
||||
busy,
|
||||
ink: useWhite ? "#ffffff" : "#111111",
|
||||
contrast: Math.max(rWhite, rBlack),
|
||||
// A scrim goes on when contrast is short OR the region is busy. The busy case is
|
||||
// the one a mean-only check silently gets wrong.
|
||||
needsScrim: Math.max(rWhite, rBlack) < MIN_CONTRAST || busy,
|
||||
};
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
function fallback(): RegionAnalysis {
|
||||
return {
|
||||
luminance: 0.5,
|
||||
stdev: 0,
|
||||
busy: false,
|
||||
ink: "#ffffff",
|
||||
contrast: 1,
|
||||
needsScrim: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop `input` to exactly w x h using sharp's attention strategy, which keeps the most
|
||||
* salient part of the picture. (`smartcrop.js` is five years unmaintained — not used.)
|
||||
*/
|
||||
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 })
|
||||
.toFile(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pixel dimensions of an image, or null when it cannot be read. */
|
||||
export async function imageSize(path: string): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
const m = await sharp(path).metadata();
|
||||
return m.width && m.height ? { width: m.width, height: m.height } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Print pre-flight — what to check before a PDF goes to a copy shop.
|
||||
*
|
||||
* Every tool here is optional. A missing tool is reported as "non installato", never as
|
||||
* a failure: refusing to hand over a poster because `qpdf` is absent would be absurd.
|
||||
* The one check that needs no tools at all is the resolution check, and that is the
|
||||
* failure that actually happens — a 1024px generation is ~62 dpi across A3's long edge.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { FORMATS_GEOMETRY, effectiveDpi, trimMm } from "./formats.ts";
|
||||
import type { Format } from "../design/spec.ts";
|
||||
|
||||
export type CheckStatus = "ok" | "warn" | "fail" | "skipped";
|
||||
|
||||
export interface Check {
|
||||
id: string;
|
||||
status: CheckStatus;
|
||||
/** Italian, shown to the user. */
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface PreflightReport {
|
||||
path: string;
|
||||
checks: Check[];
|
||||
ok: boolean;
|
||||
/** Italian summary, ready to print. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Minimum acceptable image resolution for print. 300 is the target; 150 is the floor. */
|
||||
export const PRINT_DPI_TARGET = 300;
|
||||
export const PRINT_DPI_FLOOR = 150;
|
||||
|
||||
function run(cmd: string, args: string[], timeoutMs = 15000): Promise<{ code: number | null; out: string; missing: boolean }> {
|
||||
return new Promise((res) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch {
|
||||
res({ code: null, out: "", missing: true });
|
||||
return;
|
||||
}
|
||||
let out = "";
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
||||
child.stdout?.on("data", (d) => { out += String(d); });
|
||||
child.stderr?.on("data", (d) => { out += String(d); });
|
||||
child.on("error", () => { clearTimeout(timer); res({ code: null, out: "", missing: true }); });
|
||||
child.on("close", (code) => { clearTimeout(timer); res({ code, out, missing: false }); });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, tool-free: is the artwork big enough for this print size?
|
||||
* This is the check that catches the mistake people actually make.
|
||||
*/
|
||||
export function checkArtResolution(
|
||||
format: Format,
|
||||
art: { width: number; height: number },
|
||||
): Check {
|
||||
const geo = FORMATS_GEOMETRY[format];
|
||||
if (geo.kind !== "print") {
|
||||
return { id: "risoluzione", status: "skipped", message: "Formato per schermo: risoluzione non rilevante." };
|
||||
}
|
||||
const trim = trimMm(format);
|
||||
const dpiW = effectiveDpi(art.width, trim.widthMm);
|
||||
const dpiH = effectiveDpi(art.height, trim.heightMm);
|
||||
const dpi = Math.min(dpiW, dpiH);
|
||||
const r = Math.round(dpi);
|
||||
if (dpi >= PRINT_DPI_TARGET) {
|
||||
return { id: "risoluzione", status: "ok", message: `Risoluzione immagine ${r} dpi: ottima per la stampa.` };
|
||||
}
|
||||
if (dpi >= PRINT_DPI_FLOOR) {
|
||||
return {
|
||||
id: "risoluzione",
|
||||
status: "warn",
|
||||
message: `Risoluzione immagine ${r} dpi: accettabile solo per formati grandi visti da lontano.`,
|
||||
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "risoluzione",
|
||||
status: "fail",
|
||||
message: `Risoluzione immagine troppo bassa (${r} dpi): va ingrandita prima di stampare.`,
|
||||
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm — servono almeno ${PRINT_DPI_FLOOR} dpi`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full pre-flight on a produced PDF. Never throws. */
|
||||
export async function checkPdf(path: string): Promise<PreflightReport> {
|
||||
const checks: Check[] = [];
|
||||
|
||||
if (!existsSync(path)) {
|
||||
const c: Check = { id: "file", status: "fail", message: "Il file PDF non esiste." };
|
||||
return { path, checks: [c], ok: false, summary: renderSummary([c]) };
|
||||
}
|
||||
|
||||
// 1. Page geometry + TrimBox. A TrimBox is what tells the printer where to cut.
|
||||
const info = await run("pdfinfo", ["-box", path]);
|
||||
if (info.missing) {
|
||||
checks.push({ id: "geometria", status: "skipped", message: "pdfinfo non installato (brew install poppler): geometria non verificata." });
|
||||
} else {
|
||||
const hasTrim = /TrimBox:/i.test(info.out);
|
||||
const size = /Page size:\s*([0-9.]+)\s*x\s*([0-9.]+)\s*pts/i.exec(info.out);
|
||||
const mm = size ? `${(parseFloat(size[1]) / 72 * 25.4).toFixed(1)}x${(parseFloat(size[2]) / 72 * 25.4).toFixed(1)} mm` : "sconosciuta";
|
||||
checks.push(hasTrim
|
||||
? { id: "geometria", status: "ok", message: `Pagina ${mm} con TrimBox: la tipografia sa dove tagliare.` }
|
||||
: { id: "geometria", status: "warn", message: `Pagina ${mm} senza TrimBox: senza abbondanza la tipografia taglia a occhio.` });
|
||||
}
|
||||
|
||||
// 2. Fonts must be embedded or the shop's machine substitutes something else.
|
||||
const fonts = await run("pdffonts", [path]);
|
||||
if (fonts.missing) {
|
||||
checks.push({ id: "caratteri", status: "skipped", message: "pdffonts non installato (brew install poppler): caratteri non verificati." });
|
||||
} else {
|
||||
// pdffonts columns: name type encoding emb sub uni object ID
|
||||
// "emb" is the 4th-from-last column; "no" there means the font is NOT embedded.
|
||||
// ("sub: yes" is normal and desirable — that is subsetting, not a problem.)
|
||||
const lines = fonts.out.split("\n").slice(2).filter((l) => l.trim());
|
||||
const notEmbedded = lines.filter((l) => {
|
||||
const cols = l.trim().split(/\s+/);
|
||||
return cols.length >= 4 && cols[cols.length - 4] === "no";
|
||||
});
|
||||
checks.push(!lines.length
|
||||
? { id: "caratteri", status: "warn", message: "Nessun carattere incorporato trovato: il PDF potrebbe non contenere testo." }
|
||||
: notEmbedded.length
|
||||
? {
|
||||
id: "caratteri",
|
||||
status: "fail",
|
||||
message: `${notEmbedded.length} carattere/i NON incorporato/i: in tipografia il testo cambierebbe aspetto.`,
|
||||
detail: notEmbedded.map((l) => l.trim().split(/\s+/)[0]).join(", "),
|
||||
}
|
||||
: { id: "caratteri", status: "ok", message: `Tutti i caratteri sono incorporati (${lines.length}).` });
|
||||
}
|
||||
|
||||
// 3. Image resolution inside the PDF.
|
||||
const imgs = await run("pdfimages", ["-list", path]);
|
||||
if (imgs.missing) {
|
||||
checks.push({ id: "immagini", status: "skipped", message: "pdfimages non installato (brew install poppler): risoluzione non verificata." });
|
||||
} else {
|
||||
const dpis: number[] = [];
|
||||
for (const line of imgs.out.split("\n").slice(2)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
if (cols.length > 14) {
|
||||
const x = parseFloat(cols[12]);
|
||||
const y = parseFloat(cols[13]);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) dpis.push(Math.min(x, y));
|
||||
}
|
||||
}
|
||||
const lowest = dpis.length ? Math.min(...dpis) : null;
|
||||
checks.push(lowest === null
|
||||
? { id: "immagini", status: "skipped", message: "Nessuna immagine rasterizzata nel PDF." }
|
||||
: lowest >= PRINT_DPI_TARGET
|
||||
? { id: "immagini", status: "ok", message: `Immagini a ${Math.round(lowest)} dpi: ottimo.` }
|
||||
: lowest >= PRINT_DPI_FLOOR
|
||||
? { id: "immagini", status: "warn", message: `Immagini a ${Math.round(lowest)} dpi: accettabile solo per formati grandi.` }
|
||||
: { id: "immagini", status: "fail", message: `Immagini a soli ${Math.round(lowest)} dpi: troppo poco per la stampa.` });
|
||||
}
|
||||
|
||||
// 4. Structural integrity.
|
||||
const q = await run("qpdf", ["--check", path]);
|
||||
if (q.missing) {
|
||||
checks.push({ id: "integrita", status: "skipped", message: "qpdf non installato (brew install qpdf): integrità non verificata." });
|
||||
} else {
|
||||
checks.push(q.code === 0
|
||||
? { id: "integrita", status: "ok", message: "Struttura del PDF valida." }
|
||||
: { id: "integrita", status: "warn", message: "qpdf segnala qualcosa di anomalo nel PDF.", detail: q.out.split("\n").slice(0, 4).join("\n") });
|
||||
}
|
||||
|
||||
const ok = !checks.some((c) => c.status === "fail");
|
||||
return { path, checks, ok, summary: renderSummary(checks) };
|
||||
}
|
||||
|
||||
const ICON: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗", skipped: "–" };
|
||||
|
||||
function renderSummary(checks: Check[]): string {
|
||||
const lines = checks.map((c) => ` ${ICON[c.status]} ${c.message}`);
|
||||
const failed = checks.filter((c) => c.status === "fail").length;
|
||||
const warned = checks.filter((c) => c.status === "warn").length;
|
||||
const head = failed
|
||||
? `Controllo stampa: ${failed} problema/i da risolvere.`
|
||||
: warned
|
||||
? `Controllo stampa: pronto, con ${warned} avvertenza/e.`
|
||||
: "Controllo stampa: tutto a posto.";
|
||||
return [head, ...lines].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* The Typst renderer — a pure `ResolvedSpec -> files` function. It never calls a model,
|
||||
* which is exactly why changing a colour, a font or a date is a sub-second re-render
|
||||
* instead of a two-minute regeneration.
|
||||
*
|
||||
* ## Why each job is a self-contained bundle
|
||||
*
|
||||
* Typst can only read files under `--root`. Templates live in the package; artwork and
|
||||
* output live in the user's pictures folder — two trees with no useful common ancestor
|
||||
* (and rooting at "/" would be indefensible). So each job gets its own copy:
|
||||
*
|
||||
* <job>/
|
||||
* spec.json the DesignSpec, human-editable
|
||||
* art.png the artwork
|
||||
* .typst/lib.typ copied kernel
|
||||
* .typst/<tpl>.typ copied template
|
||||
* .typst/resolved.json what the template actually reads
|
||||
*
|
||||
* `--root` is the job directory. That makes every job hermetic and re-renderable years
|
||||
* later even if the package moved or changed — which is what makes "re-do last year's
|
||||
* poster with the new date" a no-model operation.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, copyFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname, basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { DesignSpec, ResolvedSpec, Format, TemplateName } from "../design/spec.ts";
|
||||
import type { ImgenConfig } from "../config.ts";
|
||||
import { FONTS, byFamily } from "../design/fonts.ts";
|
||||
import {
|
||||
FORMATS_GEOMETRY,
|
||||
PRINT_DPI,
|
||||
SCREEN_DPI,
|
||||
trimMm,
|
||||
artTargetSize,
|
||||
type PrintTarget,
|
||||
} from "./formats.ts";
|
||||
import {
|
||||
analyseRegion,
|
||||
typeSitsOnArt,
|
||||
TEXT_REGIONS,
|
||||
hexLuminance,
|
||||
contrastRatio,
|
||||
MIN_CONTRAST,
|
||||
} from "./contrast.ts";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
/** extensions/imgen/render -> package root */
|
||||
export const PACKAGE_ROOT = resolve(HERE, "..", "..", "..");
|
||||
export const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
|
||||
export const FONTS_DIR = join(PACKAGE_ROOT, "vendor", "fonts");
|
||||
|
||||
export class TypstError extends Error {
|
||||
constructor(readonly italian: string, readonly diagnostic: string) {
|
||||
super(`typst failed: ${diagnostic.split("\n")[0] ?? "unknown"}`);
|
||||
this.name = "TypstError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
/** "pdf" carries the bleed and a TrimBox; "png" comes out already TRIMMED. */
|
||||
format?: "pdf" | "png" | "svg";
|
||||
ppi?: number;
|
||||
/** typst binary; overridable for tests. */
|
||||
binary?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Locate a bundled family's .ttf. Absent families simply fall through to Typst's own. */
|
||||
function fontFileFor(family: string): string | null {
|
||||
const entry = byFamily(family);
|
||||
if (!entry) return null;
|
||||
const dir = join(FONTS_DIR, entry.id);
|
||||
if (!existsSync(dir)) return null;
|
||||
return dir;
|
||||
}
|
||||
|
||||
function axesFor(family: string): Record<string, [number, number]> | null {
|
||||
const entry = byFamily(family);
|
||||
if (!entry?.axes) return null;
|
||||
const out: Record<string, [number, number]> = {};
|
||||
for (const [axis, range] of Object.entries(entry.axes)) {
|
||||
if (Array.isArray(range)) out[axis] = [range[0], range[1]];
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a DesignSpec into everything the template needs, measuring contrast against the
|
||||
* artwork rather than trusting the art director's guess.
|
||||
*/
|
||||
export async function resolveSpec(
|
||||
spec: DesignSpec,
|
||||
cfg: ImgenConfig,
|
||||
artPath: string,
|
||||
opts: { format?: Format; artFileName?: string } = {},
|
||||
): Promise<ResolvedSpec> {
|
||||
const format = opts.format ?? spec.format;
|
||||
const geo = FORMATS_GEOMETRY[format];
|
||||
const isPrint = geo.kind === "print";
|
||||
const trim = trimMm(format);
|
||||
|
||||
// Only measure when the template actually sets type over the picture. `framed` and
|
||||
// `split` do not, so there is nothing to measure and no scrim to draw.
|
||||
const region = TEXT_REGIONS[spec.template as TemplateName];
|
||||
const analysis = region
|
||||
? await analyseRegion(artPath, region)
|
||||
: null;
|
||||
|
||||
const inkOnArt = analysis?.ink ?? spec.palette.ink;
|
||||
|
||||
// Ink for flat background: keep the art director's choice when it is legible, else
|
||||
// flip to whichever of black/white actually reads against the page colour.
|
||||
const bgLum = hexLuminance(spec.palette.bg);
|
||||
const declaredLum = hexLuminance(spec.palette.ink);
|
||||
const inkOnBg =
|
||||
contrastRatio(declaredLum, bgLum) >= MIN_CONTRAST
|
||||
? spec.palette.ink
|
||||
: bgLum > 0.5
|
||||
? "#111111"
|
||||
: "#ffffff";
|
||||
|
||||
const fontFiles: Record<string, string> = {};
|
||||
const fontAxes: Record<string, Record<string, [number, number]>> = {};
|
||||
for (const family of [spec.fonts.display, spec.fonts.body]) {
|
||||
const dir = fontFileFor(family);
|
||||
if (dir) fontFiles[family] = dir;
|
||||
const axes = axesFor(family);
|
||||
if (axes) fontAxes[family] = axes;
|
||||
}
|
||||
|
||||
const artName = opts.artFileName ?? basename(artPath);
|
||||
|
||||
return {
|
||||
...spec,
|
||||
format,
|
||||
page: {
|
||||
widthMm: trim.widthMm,
|
||||
heightMm: trim.heightMm,
|
||||
bleedMm: isPrint ? cfg.print.bleedMm : 0,
|
||||
safeMm: cfg.print.safeMm,
|
||||
dpi: isPrint ? (cfg.print.dpi || PRINT_DPI) : SCREEN_DPI,
|
||||
cropMarks: isPrint ? cfg.print.cropMarks : false,
|
||||
},
|
||||
art_file: `/${artName}`, // root-relative: see the module comment
|
||||
ink_on_art: inkOnArt,
|
||||
ink_on_bg: inkOnBg,
|
||||
ink_resolved: inkOnArt, // legacy alias
|
||||
needs_scrim: analysis?.needsScrim ?? false,
|
||||
font_files: fontFiles,
|
||||
font_axes: fontAxes,
|
||||
// A logo path must also be root-relative; the caller stages the file into the job.
|
||||
logo: spec.logo ? { ...spec.logo, path: `/${basename(spec.logo.path)}` } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Copy the kernel and one template into <job>/.typst so the job is self-contained. */
|
||||
export async function stageBundle(jobDir: string, template: TemplateName): Promise<string> {
|
||||
const dir = join(jobDir, ".typst");
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tpl = join(TEMPLATES_DIR, `${template}.typ`);
|
||||
if (!existsSync(tpl)) {
|
||||
throw new TypstError(
|
||||
`Impaginazione sconosciuta: "${template}".`,
|
||||
`template not found: ${tpl}`,
|
||||
);
|
||||
}
|
||||
await copyFile(join(TEMPLATES_DIR, "lib.typ"), join(dir, "lib.typ"));
|
||||
await copyFile(tpl, join(dir, `${template}.typ`));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one output. `jobDir` must already hold the artwork (and the logo, if any).
|
||||
* Returns the output path.
|
||||
*/
|
||||
export async function render(
|
||||
resolved: ResolvedSpec,
|
||||
jobDir: string,
|
||||
outPath: string,
|
||||
opts: RenderOptions = {},
|
||||
): Promise<string> {
|
||||
const template = resolved.template as TemplateName;
|
||||
const stage = await stageBundle(jobDir, template);
|
||||
const specPath = join(stage, "resolved.json");
|
||||
await writeFile(specPath, JSON.stringify(resolved, null, 2), "utf8");
|
||||
await mkdir(dirname(outPath), { recursive: true });
|
||||
|
||||
const fmt = opts.format ?? (outPath.endsWith(".png") ? "png" : outPath.endsWith(".svg") ? "svg" : "pdf");
|
||||
const args = [
|
||||
"compile",
|
||||
join(stage, `${template}.typ`),
|
||||
outPath,
|
||||
"--root", jobDir,
|
||||
// Root-relative, NOT an absolute filesystem path.
|
||||
"--input", `specfile=/.typst/resolved.json`,
|
||||
"--format", fmt,
|
||||
// Only our bundled fonts are visible, so output is identical on any machine. This is
|
||||
// what makes the golden-file tests meaningful.
|
||||
"--ignore-system-fonts",
|
||||
];
|
||||
if (existsSync(FONTS_DIR)) args.push("--font-path", FONTS_DIR);
|
||||
if (fmt === "png") args.push("--ppi", String(opts.ppi ?? resolved.page.dpi ?? SCREEN_DPI));
|
||||
|
||||
await runTypst(opts.binary ?? "typst", args, opts.signal);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function runTypst(binary: string, args: string[], signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// No shell: paths under /Volumes may contain spaces.
|
||||
child = spawn(binary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Belt and braces for reproducibility; `#set document(date: none)` already
|
||||
// removes the only clock-dependent field.
|
||||
env: { ...process.env, SOURCE_DATE_EPOCH: "0" },
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new TypstError(
|
||||
"Typst non è installato. Esegui install.sh oppure: brew install typst",
|
||||
String(e),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let err = "";
|
||||
child.stderr?.on("data", (d) => { err += String(d); });
|
||||
const onAbort = () => child.kill("SIGTERM");
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.on("error", (e) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new TypstError(
|
||||
"Typst non è installato o non è eseguibile. Prova: brew install typst",
|
||||
String(e),
|
||||
));
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) {
|
||||
reject(new TypstError("Composizione annullata.", "aborted"));
|
||||
} else if (code === 0) {
|
||||
resolvePromise();
|
||||
} else {
|
||||
reject(new TypstError(
|
||||
"Non sono riuscito a comporre la locandina. Dettaglio tecnico qui sotto.",
|
||||
err.trim() || `typst exited ${code}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface FormatOutput {
|
||||
format: Format;
|
||||
path: string;
|
||||
kind: "print" | "screen";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
spec: DesignSpec,
|
||||
cfg: ImgenConfig,
|
||||
jobDir: string,
|
||||
artPath: string,
|
||||
formats: Format[],
|
||||
opts: RenderOptions = {},
|
||||
): Promise<FormatOutput[]> {
|
||||
const out: FormatOutput[] = [];
|
||||
for (const format of formats) {
|
||||
const geo = FORMATS_GEOMETRY[format];
|
||||
const resolved = await resolveSpec(spec, cfg, artPath, { format });
|
||||
if (geo.kind === "print") {
|
||||
// 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" }),
|
||||
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`), {
|
||||
...opts, format: "png", ppi: cfg.print.dpi || PRINT_DPI,
|
||||
}),
|
||||
kind: "print",
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
format,
|
||||
path: await render(resolved, jobDir, join(jobDir, `${format}.png`), {
|
||||
...opts, format: "png", ppi: ppiForScreen(format),
|
||||
}),
|
||||
kind: "screen",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ppi that lands a screen format on its exact pixel size. */
|
||||
function ppiForScreen(format: Format): number {
|
||||
const geo = FORMATS_GEOMETRY[format];
|
||||
const trim = trimMm(format);
|
||||
if (!trim.widthMm) return SCREEN_DPI;
|
||||
return Math.round((geo.pxWidth / trim.widthMm) * 25.4);
|
||||
}
|
||||
|
||||
export { artTargetSize, type PrintTarget };
|
||||
+2
-1
@@ -36,7 +36,8 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "tests/render-fixtures.sh",
|
||||
"fonts": "scripts/fetch-fonts.sh"
|
||||
"fonts": "scripts/fetch-fonts.sh",
|
||||
"test:e2e": "node tests/e2e/render.mjs && node tests/e2e/contrast.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
|
||||
+12
-3
@@ -59,7 +59,9 @@
|
||||
# The family list is PARSED from extensions/imgen/design/fonts.ts. It is never
|
||||
# duplicated here — adding a family there is the only edit needed.
|
||||
#
|
||||
# Idempotent: a family whose manifest is complete is skipped unless --force.
|
||||
# Idempotent: a family is skipped only when every file its manifest lists is
|
||||
# still on disk (see manifest_complete), so a partial install is repaired
|
||||
# instead of being skipped for ever. --force re-fetches regardless.
|
||||
#
|
||||
# Uso:
|
||||
# scripts/fetch-fonts.sh [--force] [--only=<id|famiglia>]
|
||||
@@ -305,7 +307,10 @@ mkdir -p "$DEST"
|
||||
|
||||
# --- per-source fetchers ----------------------------------------------------
|
||||
# Each prints the names of the files it installed into $stage, one per line, on
|
||||
# fd 3 (stdout is reserved for progress), and returns non-zero if it got nothing.
|
||||
# fd 3 (stdout is reserved for progress). ALL OR NOTHING: a fetcher returns
|
||||
# non-zero unless every file it expected arrived intact, so a half-family is a
|
||||
# failure the next tier gets a shot at, never something we install. Each also
|
||||
# sets FETCH_EXPECTED to the number of files it expected.
|
||||
|
||||
# Tier 1 — google/fonts raw. Variable TTFs, full character set, exact names
|
||||
# taken from METADATA.pb.
|
||||
@@ -746,7 +751,11 @@ if [ -n "$MISSING" ] || [ -n "$FAILED" ]; then
|
||||
fi
|
||||
if [ -n "$MISSING" ]; then
|
||||
fail "Incomplete su disco:$MISSING"
|
||||
fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build."
|
||||
# The licence warning only when a LICENSE is what is actually missing:
|
||||
# «(incompleta)» means faces are missing, which is a different problem.
|
||||
case "$MISSING" in
|
||||
*"(LICENSE)"*) fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build." ;;
|
||||
esac
|
||||
fi
|
||||
fail "Riprova con: scripts/fetch-fonts.sh --force --only=<id>"
|
||||
exit 1
|
||||
|
||||
+99
-100
@@ -42,7 +42,7 @@
|
||||
// Spec-derived constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#let pal = palette-of(spec)
|
||||
#let pal = palette-of(spec) // surface: auto == the ink measured over the artwork
|
||||
#let sa = safe-area(spec)
|
||||
#let t = trim-mm(spec)
|
||||
#let short = short-edge-mm(spec)
|
||||
@@ -54,65 +54,23 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Band colour
|
||||
//
|
||||
// `ink_resolved` was contrast-checked by the renderer against the ARTWORK, and this
|
||||
// template must not second-guess it — but inside the band the type is not over the
|
||||
// artwork, it is over flat accent, a pair nobody has checked. So the ink stays exactly
|
||||
// as resolved and the BAND moves instead: keep the accent when it separates, otherwise
|
||||
// push its lightness away from the ink while holding its hue. A slightly shifted red is
|
||||
// a small art-direction concession; a title nobody can read is not recoverable.
|
||||
// The band IS the accent, unshifted — that colour is the art director's decision and
|
||||
// this template has no business second-guessing it. What changes instead is the INK on
|
||||
// top of it, because the ink the renderer measured was measured against the ARTWORK,
|
||||
// and inside the band the type is not over the artwork at all. `palette-of(spec,
|
||||
// surface: …)` is the kernel's answer to exactly that: hand it the flat colour the type
|
||||
// will sit on and it re-derives a legible ink (falling back to plain black or white
|
||||
// before it will hand back something unreadable). `block-style(…, surface: …)` threads
|
||||
// the same choice through every role's fill.
|
||||
//
|
||||
// So this template works with three surfaces:
|
||||
// band — the accent
|
||||
// strip — the poster's ink, used as a ground with the band's colour read back off it
|
||||
// foot — the artwork itself, where the renderer's measured ink is the right one
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// sRGB -> linear, per WCAG 2.x relative luminance.
|
||||
#let _channel(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) }
|
||||
|
||||
#let _luma(c) = {
|
||||
let k = rgb(c).components().slice(0, 3).map(v => _channel(v / 100%))
|
||||
0.2126 * k.at(0) + 0.7152 * k.at(1) + 0.0722 * k.at(2)
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio, 1 (identical) to 21 (black on white).
|
||||
#let _contrast(a, b) = {
|
||||
let la = _luma(a)
|
||||
let lb = _luma(b)
|
||||
(calc.max(la, lb) + 0.05) / (calc.min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
// 3.0 is the WCAG threshold for LARGE text, which is all this band ever carries: the
|
||||
// title is 13% of the short edge (38 mm on A3) and even the date is over 13 mm.
|
||||
#let _KEEP = 3.0 // good enough to leave the art director's accent alone
|
||||
#let _AIM = 3.5 // what a shifted accent must reach, with a little headroom
|
||||
|
||||
#let _band-fill = {
|
||||
let ink = pal.ink
|
||||
let base = pal.accent
|
||||
if _contrast(base, ink) >= _KEEP {
|
||||
base
|
||||
} else {
|
||||
// Which way to run: away from the ink, so a light ink darkens the band.
|
||||
let lighter = _luma(ink) < 0.5
|
||||
let found = none
|
||||
for i in range(1, 13) {
|
||||
if found == none {
|
||||
let c = if lighter { base.lighten(i * 8%) } else { base.darken(i * 8%) }
|
||||
if _contrast(c, ink) >= _AIM { found = c }
|
||||
}
|
||||
}
|
||||
if found != none {
|
||||
found
|
||||
} else if _contrast(pal.bg, ink) >= _AIM {
|
||||
pal.bg // the palette's own background is the next most considered choice
|
||||
} else if lighter {
|
||||
white // last resort: a colour that cannot fail
|
||||
} else {
|
||||
black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The strip under the band inverts the pair: ink ground, band-coloured type. That is
|
||||
// exactly the contrast we just guaranteed, read the other way round.
|
||||
#let _strip-fill = pal.ink
|
||||
#let _strip-ink = _band-fill
|
||||
#let band-fill = pal.accent
|
||||
#let strip-fill = pal.ink
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocks -> zones
|
||||
@@ -125,7 +83,11 @@
|
||||
|
||||
#let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip")
|
||||
|
||||
#let _prepared = {
|
||||
/// Pass one: role and zone only. `block-style` is the only thing that knows how to
|
||||
/// normalise a role, so it is called here for its `role` field alone; the fills it
|
||||
/// returns now would be the wrong ones, because which surface a block sits on is not
|
||||
/// known until every block has been filed.
|
||||
#let _zoned = {
|
||||
let out = ()
|
||||
let bs = _get(spec, "blocks", ())
|
||||
if type(bs) == array {
|
||||
@@ -133,16 +95,12 @@
|
||||
if type(b) == dictionary {
|
||||
let raw = _get(b, "text", "")
|
||||
if type(raw) == str and raw.trim() != "" {
|
||||
// `block-style` normalises the role, so `st.role` is never an unknown one.
|
||||
let st = block-style(spec, _get(b, "role", none))
|
||||
let role = block-style(spec, _get(b, "role", none)).role
|
||||
out.push((
|
||||
role: st.role,
|
||||
zone: _ZONES.at(st.role, default: "lower"),
|
||||
// CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase
|
||||
// codepoints; upper() is harmless for them and correct for everyone else.
|
||||
text: if st.upper { upper(raw) } else { raw },
|
||||
role: role,
|
||||
zone: _ZONES.at(role, default: "lower"),
|
||||
raw: raw,
|
||||
optional: _get(b, "optional", false) == true,
|
||||
style: st,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -151,14 +109,56 @@
|
||||
out
|
||||
}
|
||||
|
||||
// On landscape the strip has nowhere to go, so it rides inside the band as a second
|
||||
// column instead of a second bar — which also moves date and venue onto the band's
|
||||
// surface, and therefore changes which ink they need.
|
||||
#let split-band = landscape and _zoned.any(b => b.zone == "strip") and _zoned.any(b => b.zone == "band")
|
||||
|
||||
/// The ink the art director asked for, when it is legible on `surface`.
|
||||
///
|
||||
/// `palette-of(surface: …)` picks the MAXIMUM-contrast ink, which is the right default
|
||||
/// and the wrong call for the band: cream on this template's red scores 3.7:1 and black
|
||||
/// scores 5.0, so the kernel would swap a cream title for a black one and break the
|
||||
/// palette the rest of the poster is built from. The kernel's own docstring says the
|
||||
/// art director's ink should survive "whenever it is good enough", and on a headline
|
||||
/// this size good enough is `MIN-CONTRAST`. So: keep it when it clears the bar, and
|
||||
/// hand back `none` — meaning "use the kernel's choice" — when it does not.
|
||||
#let _preferred-ink(surface) = {
|
||||
if surface == auto { return none }
|
||||
let declared = hex(_dig(spec, ("palette", "ink"), none), fallback: none)
|
||||
if declared != none and contrast-ratio(declared, surface) >= MIN-CONTRAST { declared } else { none }
|
||||
}
|
||||
|
||||
#let _SURFACE = (
|
||||
band: band-fill,
|
||||
strip: if split-band { band-fill } else { strip-fill },
|
||||
lower: auto, // over the artwork: keep the renderer's measurement
|
||||
)
|
||||
|
||||
/// Pass two: resolve each block's typography against the surface it actually lands on.
|
||||
#let _prepared = _zoned.map(b => {
|
||||
let surface = _SURFACE.at(b.zone)
|
||||
let st = block-style(spec, b.role, surface: surface)
|
||||
// Only roles whose fill IS the ink are re-considered; `date`'s accent and `footer`'s
|
||||
// muted ink are deliberate colour decisions and stay exactly as the kernel resolved
|
||||
// them against this surface.
|
||||
let keep = if st.fill == palette-of(spec, surface: surface).ink { _preferred-ink(surface) } else { none }
|
||||
(
|
||||
role: b.role,
|
||||
zone: b.zone,
|
||||
ink: keep,
|
||||
// CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase codepoints;
|
||||
// upper() is harmless for them and correct for everyone else.
|
||||
text: if st.upper { upper(b.raw) } else { b.raw },
|
||||
optional: b.optional,
|
||||
style: st,
|
||||
)
|
||||
})
|
||||
|
||||
#let band-blocks = _prepared.filter(b => b.zone == "band")
|
||||
#let strip-blocks = _prepared.filter(b => b.zone == "strip")
|
||||
#let lower-blocks = _prepared.filter(b => b.zone == "lower")
|
||||
|
||||
// On landscape the strip has nowhere to go, so it rides inside the band as a second
|
||||
// column instead of a second bar.
|
||||
#let split-band = landscape and strip-blocks.len() > 0 and band-blocks.len() > 0
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type boxes
|
||||
//
|
||||
@@ -291,13 +291,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-fit one prepared block into `w` x `h`, overriding only the colour: on the band
|
||||
/// the kernel's art-checked fill would be the wrong one, everywhere else it is right.
|
||||
#let render-block(b, w, h, colour, al) = context {
|
||||
/// Auto-fit one prepared block into `w` x `h`. The colour needs no override here: the
|
||||
/// block's style was already resolved against the surface its zone lands on.
|
||||
#let render-block(b, w, h, al) = context {
|
||||
let st = b.style
|
||||
let cap = word-cap(b.text, st, w)
|
||||
let args = st.args
|
||||
if colour != none { args.insert("fill", colour) }
|
||||
if _get(b, "ink", none) != none { args.insert("fill", b.ink) }
|
||||
args.insert("size", cap.size)
|
||||
// Keep the floor at or below the (possibly capped) size, or `fit` would be asked to
|
||||
// bisect an empty range.
|
||||
@@ -311,12 +311,12 @@
|
||||
/// is relative to the paragraph's OWN text size, so between a 109 pt title and its
|
||||
/// subtitle it silently inserts 114 pt — a hole bigger than the subtitle. The vertical
|
||||
/// rhythm of a poster is a design decision, so every gap here is an explicit `v()`.
|
||||
#let stack-blocks(blocks, w, h-of, colour, al, gap) = {
|
||||
#let stack-blocks(blocks, w, h-of, al, gap) = {
|
||||
set par(spacing: 0pt)
|
||||
let first = true
|
||||
for b in blocks {
|
||||
if not first { v(gap, weak: false) }
|
||||
render-block(b, w, h-of(b), colour, al)
|
||||
render-block(b, w, h-of(b), al)
|
||||
first = false
|
||||
}
|
||||
}
|
||||
@@ -325,23 +325,23 @@
|
||||
band-blocks,
|
||||
title-w,
|
||||
b => if b.role == "subtitle" { sub-h } else { title-h },
|
||||
pal.ink,
|
||||
band-align,
|
||||
short * 0.038 * 1mm,
|
||||
)
|
||||
|
||||
/// date + venue: inside the band on landscape, on their own inverted strip otherwise.
|
||||
#let strip-column(colour, al) = stack-blocks(
|
||||
/// Either way their fills came from `_SURFACE.strip`, which already knows which.
|
||||
#let strip-column(al) = stack-blocks(
|
||||
strip-blocks,
|
||||
if split-band { info-w } else { sa.width },
|
||||
b => _lines(b.style, 2),
|
||||
colour,
|
||||
al,
|
||||
short * 0.022 * 1mm,
|
||||
)
|
||||
|
||||
/// The foot keeps each role's own resolved fill: those colours were contrast-checked
|
||||
/// against the artwork, which is exactly what they sit on here.
|
||||
/// The foot keeps each role's own resolved fill: `_SURFACE.lower` is `auto`, so those
|
||||
/// are the colours the renderer measured against the artwork — which is exactly what
|
||||
/// this column sits on.
|
||||
#let foot-column(blocks) = {
|
||||
// Deliberately NOT hyphenated: this column is centred and ragged, where a hyphen
|
||||
// reads as an error, and Italian patterns break loanwords badly ("wee-kend"). The
|
||||
@@ -350,7 +350,6 @@
|
||||
blocks,
|
||||
foot-w,
|
||||
b => _lines(b.style, if b.role == "details" { 3 } else { 2 }),
|
||||
none,
|
||||
foot-align,
|
||||
short * 0.018 * 1mm,
|
||||
)
|
||||
@@ -371,12 +370,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
#let band-block = if band-blocks.len() == 0 and not split-band {
|
||||
#let band-block = if band-blocks.len() == 0 {
|
||||
none
|
||||
} else {
|
||||
block(
|
||||
width: sa.full-width,
|
||||
fill: _band-fill,
|
||||
fill: band-fill,
|
||||
inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y),
|
||||
if split-band {
|
||||
// Bottom-aligned info hangs off the same optical line as the last title line.
|
||||
@@ -385,7 +384,7 @@
|
||||
column-gutter: gutter-w,
|
||||
align: (left + horizon, right + bottom),
|
||||
band-column,
|
||||
strip-column(pal.ink, right),
|
||||
strip-column(right),
|
||||
)
|
||||
} else {
|
||||
band-column
|
||||
@@ -398,14 +397,14 @@
|
||||
} else {
|
||||
block(
|
||||
width: sa.full-width,
|
||||
fill: _strip-fill,
|
||||
fill: strip-fill,
|
||||
inset: (
|
||||
left: band-pad-x,
|
||||
right: band-pad-x,
|
||||
top: band-pad-y * 0.62,
|
||||
bottom: band-pad-y * 0.62,
|
||||
),
|
||||
strip-column(_strip-ink, center),
|
||||
strip-column(center),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -465,16 +464,15 @@
|
||||
// Scrim only over the artwork, only under the foot, only when the renderer measured
|
||||
// the region behind it as busy. The band never needs one — it is flat colour.
|
||||
//
|
||||
// It has to be a good deal TALLER than the text it protects: the kernel's gradient
|
||||
// holds its fade back until 45% of the height so the artwork stays clean, so a scrim
|
||||
// merely as tall as the foot leaves the foot's first line sitting on nothing. At
|
||||
// 3.2x, that first line lands at about 70% of the ramp, where the wash has real body.
|
||||
// Three rules, in order of who wins:
|
||||
// want — 3.2x the foot, with a floor so a single line still gets a wash, not a smear;
|
||||
// room — capped at 70% of the gap below the band, so a clean strip of artwork always
|
||||
// survives between band and scrim. That strip is the layout, not decoration;
|
||||
// need — but a foot too big for that strip is still covered: whatever it takes to
|
||||
// hold the text plus a fade above it beats the 70% rule.
|
||||
// It must be a good deal TALLER than the text it protects: the kernel's gradient
|
||||
// holds its fade back until 45% of its height so the artwork stays clean, so a scrim
|
||||
// merely as tall as the foot leaves the foot's first line sitting on nothing.
|
||||
// want — 3.2x the foot, which lands that first line at ~70% of the ramp where the
|
||||
// wash has real body; the floor keeps a one-line foot from getting a smear;
|
||||
// room — but never past 70% of the gap below the band, so a clean strip of artwork
|
||||
// always survives between band and scrim. That strip IS the layout;
|
||||
// need — unless the foot is itself too big for that strip, in which case covering
|
||||
// the text wins: it takes whatever holds the foot plus a fade above it.
|
||||
if foot-h > 0pt and _get(spec, "needs_scrim", false) == true {
|
||||
// recomputed: `group-top` may have moved since
|
||||
let room = sa.full-height - (group-top + group-h)
|
||||
@@ -517,6 +515,7 @@
|
||||
},
|
||||
)
|
||||
|
||||
// The body stays empty on purpose: every element is placed in the foreground, whose
|
||||
// origin is the full page, so nothing depends on where a margin box would have started.
|
||||
// An empty box is the whole body. Every element is placed in the foreground instead,
|
||||
// whose origin is the full page including bleed — the one coordinate system `safe-area`
|
||||
// reports in — so nothing depends on where a margin box would have started.
|
||||
#box()
|
||||
|
||||
+50
-29
@@ -20,8 +20,10 @@
|
||||
//
|
||||
// Three rules this file obeys, in order of importance:
|
||||
// 1. Nothing ever leaves the safe area. The stack is budgeted BEFORE it is typeset
|
||||
// (see `cap` below), and `fit` only ever shrinks, so overflow is impossible rather
|
||||
// than unlikely. That matters because a poster is checked once, at the printer.
|
||||
// (see `cap`), each block is auto-fitted into its share and checked for lines that
|
||||
// refused to break (see `fit-checked`), and the bands a logo needs are reserved out
|
||||
// of the height first. Overflow is made impossible, not unlikely — a poster gets
|
||||
// checked once, at the printer.
|
||||
// 2. Every measurement is a fraction of the trim's short edge, so the composition is
|
||||
// re-solved per format instead of being a fixed layout that gets scaled.
|
||||
// 3. Determinism: no dates, no randomness, no system fonts. See lib.typ's header.
|
||||
@@ -227,7 +229,38 @@
|
||||
// SHORTENED box, so it can never be pushed past the safe edge at either end.
|
||||
#let lift = sa.height * (if wide { 0.015 } else { 0.030 })
|
||||
|
||||
// The footer band, reserved out of the stack's height before anything is measured.
|
||||
// Room for the logo. `logo-place` sizes it to a fraction of the trim's short edge and
|
||||
// insets it to the safe area, so its box is predictable even though its aspect ratio is
|
||||
// not — the square case is assumed, which is the worst one. The stack then gives up a
|
||||
// band at that end of the sheet, because a centred column is wide enough to run straight
|
||||
// through a corner mark.
|
||||
#let logo-side = {
|
||||
let logo = _get(spec, "logo", none)
|
||||
if type(logo) != dictionary { 0pt } else {
|
||||
let path = _get(logo, "path", none)
|
||||
if type(path) != str or path.trim() == "" { 0pt } else {
|
||||
let raw = _get(logo, "scale", 0.12)
|
||||
let scale = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 }
|
||||
sa.short-edge * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
#let logo-corner = {
|
||||
let c = _dig(spec, ("logo", "corner"), "br")
|
||||
if type(c) == str and c in ("tl", "tr", "bl", "br") { c } else { "br" }
|
||||
}
|
||||
#let logo-at-top = logo-side > 0pt and logo-corner in ("tl", "tr")
|
||||
#let logo-at-foot = logo-side > 0pt and logo-corner in ("bl", "br")
|
||||
|
||||
#let top-reserve = if logo-at-top { logo-side + gap-base } else { 0pt }
|
||||
#let logo-reserve = if logo-at-foot { logo-side + gap-base } else { 0pt }
|
||||
|
||||
// The footer band. A foot-corner logo shares it when there is measure enough left beside
|
||||
// it: the footer's column is inset by the logo width on BOTH sides — symmetric, because a
|
||||
// centred footer nudged off-axis to dodge a logo is exactly the near-miss this layout
|
||||
// cannot afford. When the logo is big enough to leave no usable measure (the schema
|
||||
// allows 0.4 of the short edge) the footer gives up the corner instead and sits above it.
|
||||
// A smaller stack is a compromise; a credit line printed across a logo is a defect.
|
||||
#let footer-style = block-style(spec, "footer")
|
||||
#let footer-line-box = lines-height(footer-style, _LINES.footer)
|
||||
// Glyphs paint a little past their line box — «@», an accented capital, a descender in a
|
||||
@@ -235,11 +268,18 @@
|
||||
// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink
|
||||
// below the safe edge on A3.
|
||||
#let footer-descender = footer-style.size * 0.20
|
||||
#let footer-band = if footers.len() == 0 { 0pt } else {
|
||||
footer-line-box + gap-base * 1.2 + footer-descender
|
||||
#let footer-clear = if logo-at-foot { sa.width - 2 * (logo-side + gap-base) } else { sa.width }
|
||||
#let footer-shares = not logo-at-foot or footer-clear >= sa.width * 0.45
|
||||
#let footer-width = if footer-shares { footer-clear } else { sa.width }
|
||||
#let footer-content = if footers.len() == 0 { 0pt } else {
|
||||
footer-line-box + footer-descender + gap-base * 1.2
|
||||
}
|
||||
#let footer-lift = if footers.len() == 0 or footer-shares { 0pt } else { logo-reserve }
|
||||
// The band is whichever is taller: the footer's own needs, or the clearance the logo
|
||||
// wants. With no footer and no logo it is nothing at all.
|
||||
#let footer-band = calc.max(footer-content + footer-lift, logo-reserve)
|
||||
|
||||
#let avail = sa.height - 2 * lift - footer-band
|
||||
#let avail = sa.height - 2 * lift - footer-band - top-reserve
|
||||
|
||||
// A rule is drawn only where it means something: between the title and whatever follows.
|
||||
#let has-rule = {
|
||||
@@ -346,29 +386,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A bottom-corner logo shares the footer band, so the footer's measure is inset by the
|
||||
// logo's width on BOTH sides — symmetric, because a centred footer nudged off-axis to
|
||||
// dodge a logo is exactly the kind of near-miss this layout cannot afford.
|
||||
#let footer-width = {
|
||||
let logo = _get(spec, "logo", none)
|
||||
let inset = if type(logo) != dictionary { 0pt } else {
|
||||
let path = _get(logo, "path", none)
|
||||
if type(path) != str or path.trim() == "" { 0pt } else {
|
||||
let corner = _get(logo, "corner", "br")
|
||||
if corner in ("bl", "br") {
|
||||
let s = _get(logo, "scale", 0.12)
|
||||
let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 }
|
||||
sa.short-edge * s + gap
|
||||
} else { 0pt }
|
||||
}
|
||||
}
|
||||
calc.max(sa.width * 0.4, sa.width - 2 * inset)
|
||||
}
|
||||
|
||||
#let footer-stack = {
|
||||
for (i, b) in footers.enumerate() {
|
||||
if i > 0 { v(gap * 0.4) }
|
||||
let st = block-style(spec, "footer")
|
||||
let st = footer-style
|
||||
let t = _get(b, "text", "")
|
||||
block(width: 100%, fit-checked(
|
||||
if st.upper { upper(t) } else { t },
|
||||
@@ -411,7 +432,7 @@
|
||||
if _get(spec, "needs_scrim", false) == true {
|
||||
// Background coordinates include the bleed; the stack's centre is a body (trim)
|
||||
// coordinate, so it is offset by one bleed to line the two up.
|
||||
centre-veil(sa, pal.scrim, sa.bleed + sa.safe + avail / 2)
|
||||
centre-veil(sa, pal.scrim, sa.bleed + sa.safe + top-reserve + avail / 2)
|
||||
}
|
||||
},
|
||||
foreground: {
|
||||
@@ -421,7 +442,7 @@
|
||||
)
|
||||
|
||||
// Body origin = trim top-left, so the safe inset is `sa.safe` alone.
|
||||
#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe, box(
|
||||
#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe + top-reserve, box(
|
||||
width: col,
|
||||
height: avail,
|
||||
align(center + horizon, type-stack),
|
||||
@@ -433,7 +454,7 @@
|
||||
dy: sa.safe + sa.height - footer-band,
|
||||
box(
|
||||
width: footer-width,
|
||||
height: footer-band - footer-descender,
|
||||
height: footer-band - footer-descender - footer-lift,
|
||||
align(center + bottom, footer-stack),
|
||||
),
|
||||
)
|
||||
|
||||
+70
-24
@@ -80,9 +80,22 @@
|
||||
/// the optical distance is the same on every format.
|
||||
#let gutter = sa.short-edge * 0.045
|
||||
|
||||
/// Vertical rhythm between stacked blocks, and the spacing unit for the footer line.
|
||||
/// The base vertical rhythm, and the spacing unit for the footer line.
|
||||
#let gap = sa.short-edge * 0.016
|
||||
|
||||
/// Space after a block, before the next one. A flat gap makes a poster read as a list:
|
||||
/// the break after a 38 mm headline has to be bigger than the break after a 7 mm detail
|
||||
/// line or the title runs straight into the subtitle. So the gap is the base rhythm or a
|
||||
/// third of the block's own type size, whichever is larger — which in practice means the
|
||||
/// title (and only the title) buys itself real air.
|
||||
#let _gap-raw(e) = calc.max(gap, e.style.size * 0.32)
|
||||
#let _gap-sum(list) = {
|
||||
if list.len() < 2 { return 0pt }
|
||||
let s = 0pt
|
||||
for e in list.slice(0, list.len() - 1) { s += _gap-raw(e) }
|
||||
s
|
||||
}
|
||||
|
||||
/// The seam, in FULL-PAGE coordinates (bleed included) — the origin `page(background:)`
|
||||
/// and `page(foreground:)` resolve against. Along the split axis the artwork runs from
|
||||
/// the page edge to here, so it covers its own bleed; the panel starts here.
|
||||
@@ -219,12 +232,17 @@
|
||||
/// 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)
|
||||
|
||||
#let flow = {
|
||||
let list = entries.filter(e => e.role != "footer")
|
||||
let needed(l) = {
|
||||
let s = 0pt
|
||||
for e in l { s += _min-need(e.style) }
|
||||
s + gap * calc.max(0, l.len() - 1)
|
||||
s + gaps-of(l)
|
||||
}
|
||||
while needed(list) > flow-h and list.any(e => e.optional) {
|
||||
let idx = 0
|
||||
@@ -240,7 +258,15 @@
|
||||
/// 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 - gap * calc.max(0, flow.len() - 1))
|
||||
#let _inner = calc.max(1mm, flow-h - gaps-of(flow))
|
||||
|
||||
/// The factor the cap in `gaps-of` implies for each individual gap, so the drawn spacing
|
||||
/// and the space the budgets were computed against are always the same number.
|
||||
#let _gap-scale = {
|
||||
let raw = _gap-sum(flow)
|
||||
if raw > 0pt { gaps-of(flow) / raw } else { 1.0 }
|
||||
}
|
||||
#let gap-after(e) = _gap-raw(e) * _gap-scale
|
||||
#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -309,15 +335,30 @@
|
||||
// Type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The narrow end of a family's `wdth` axis, tolerant of every shape the spec may use
|
||||
/// for a range. `none` when the family has no width axis and `fit` can only shrink.
|
||||
#let _wdth-floor(axes) = {
|
||||
/// The `wdth` value `fit` will START from for this family: the natural instance (100),
|
||||
/// or the axis extreme if the axis does not reach it. `none` when the family has no
|
||||
/// width axis, in which case Typst draws the default instance and no variation is set.
|
||||
///
|
||||
/// This has to be the WIDEST width `fit` can use, not the narrowest. `fit` spends the
|
||||
/// axis only when the paragraph is too TALL for its box; a block with a generous height
|
||||
/// budget — a two-block spec where the title owns most of the panel — never narrows at
|
||||
/// all, so a cap computed at the condensed end would not bind and the word would run off
|
||||
/// the page. Measured at ig-story: "DELL'AUTUNNO" left the panel by 4.8 mm that way.
|
||||
#let _wdth-start(axes) = {
|
||||
if type(axes) != dictionary { return none }
|
||||
let a = axes.at("wdth", default: none)
|
||||
if type(a) == array and a.len() >= 2 { calc.min(a.at(0), a.at(1)) }
|
||||
else if type(a) == dictionary { a.at("min", default: a.at("lo", default: none)) }
|
||||
else if type(a) in (int, float) { a }
|
||||
let pair = if type(a) == array and a.len() >= 2 { (a.at(0), a.at(1)) }
|
||||
else if type(a) == dictionary {
|
||||
let lo = a.at("min", default: a.at("lo", default: none))
|
||||
let hi = a.at("max", default: a.at("hi", default: none))
|
||||
if lo != none and hi != none { (lo, hi) } else { none }
|
||||
}
|
||||
else if type(a) in (int, float) { (a, a) }
|
||||
else { none }
|
||||
if pair == none { return none }
|
||||
let lo = calc.min(..pair)
|
||||
let hi = calc.max(..pair)
|
||||
calc.max(lo, calc.min(hi, 100))
|
||||
}
|
||||
|
||||
/// Auto-fit one block into `w` x `h` — the only way type is drawn in this template.
|
||||
@@ -329,18 +370,17 @@
|
||||
/// field outranks the `set par` inside `fit` and so governs BOTH the trial measures
|
||||
/// and the final draw. Without it a long Italian title silently overflows its column.
|
||||
/// * the ideal size is capped so the longest unbreakable word fits `w`. The word is
|
||||
/// measured unconstrained (where `measure` reports the true width) at the narrowest
|
||||
/// width the axis allows, because `fit` spends the axis before it spends size — so
|
||||
/// this only bites when even fully condensed the word would not fit, and then it
|
||||
/// hands `fit` a size at which it does.
|
||||
/// measured UNCONSTRAINED, which is the only way to get its true width past the clamp
|
||||
/// in (2), at the width `fit` starts from. `fit` may then narrow further and gain
|
||||
/// room back; it can never need more.
|
||||
#let fit-text(body, w, h, st, align-to: left) = context {
|
||||
let words = body.split(regex("\\s+")).filter(x => x != "")
|
||||
let cap = st.size
|
||||
if words.len() > 0 and w > 0pt {
|
||||
let floor-wdth = _wdth-floor(st.axes)
|
||||
let start-wdth = _wdth-start(st.axes)
|
||||
let probe(word) = measure(text(
|
||||
..(if st.family != none { (font: st.family) } else { (:) }),
|
||||
..(if floor-wdth != none { (variations: (wdth: floor-wdth)) } else { (:) }),
|
||||
..(if start-wdth != none { (variations: (wdth: start-wdth)) } else { (:) }),
|
||||
size: 100pt, weight: st.weight, tracking: st.tracking, word,
|
||||
)).width
|
||||
let widest = words.fold(0pt, (a, word) => calc.max(a, probe(word)))
|
||||
@@ -364,16 +404,14 @@
|
||||
#let stack-body = {
|
||||
set block(spacing: 0pt)
|
||||
set par(spacing: 0pt)
|
||||
let first = true
|
||||
for e in flow {
|
||||
if not first { v(gap, weak: false) }
|
||||
first = false
|
||||
for (i, e) in flow.enumerate() {
|
||||
if i > 0 { v(gap-after(flow.at(i - 1)), weak: false) }
|
||||
let st = e.style
|
||||
// One line per block, and the whole reason `fit` exists: a long Italian title
|
||||
// narrows on the wdth axis before it is allowed to shrink, so it keeps filling its
|
||||
// band. "Sagra della Castagna e dell'Autunno in Piazza" lands on three lines at
|
||||
// ig-story and stays a headline instead of becoming a caption.
|
||||
block(width: panel.width, height: budget(e), fit-text(e.body, panel.width, budget(e), st))
|
||||
block(width: panel.width, fit-text(e.body, panel.width, budget(e), st))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +421,18 @@
|
||||
/// little as a title and a date), and a heading pinned to the top of that void reads as
|
||||
/// a mistake.
|
||||
///
|
||||
/// Every child block is pinned to its exact budget, and the budgets plus the gaps were
|
||||
/// normalised to `flow-h`, so the stack is EXACTLY `flow-h` tall whatever the spec
|
||||
/// contains. That is what keeps it off the colophon: there is no overflow case to
|
||||
/// handle, only unused space inside a block when its text fitted with room to spare.
|
||||
/// The blocks are NOT pinned to their budgets, only fitted inside them: each takes its
|
||||
/// natural height, so the slack a short block leaves collapses into one pool of air that
|
||||
/// the centring then splits evenly, instead of being scattered as trailing gaps. Since
|
||||
/// every fitted height is at most its budget and the budgets plus gaps were normalised
|
||||
/// to `flow-h`, the stack cannot be taller than the flow area. Measured, with the full
|
||||
/// seven-block spec: 256/313 pt at yt-thumb, 352/500 at A3, 320/414 at ig-story.
|
||||
///
|
||||
/// The one way past that bound is a spec whose blocks cannot fit even at the floor sizes
|
||||
/// `block-style` allows — reachable only with an absurd `safeMm`, since the optional
|
||||
/// 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.
|
||||
#let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, stack-body))
|
||||
|
||||
/// The colophon, pinned to the foot of the panel and pushed clear of the logo when they
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -0,0 +1,13 @@
|
||||
import { createJiti } from "jiti";
|
||||
import { join } from "node:path";
|
||||
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
||||
const R = "/home/moze/Sorgenti/pi-imgen";
|
||||
const c = await jiti.import(join(R, "extensions/imgen/render/contrast.ts"));
|
||||
|
||||
const region = c.TEXT_REGIONS["hero-bottom"];
|
||||
for (const [label, file] of [["calmo (gradiente)", "tests/art.png"], ["movimentato (scacchiera)", "tests/art-busy.png"], ["scuro", "tests/art-dark.png"]]) {
|
||||
const a = await c.analyseRegion(join(R, file), region);
|
||||
console.log(
|
||||
`${label.padEnd(26)} lum=${a.luminance.toFixed(3)} stdev=${a.stdev.toFixed(1).padStart(6)} ` +
|
||||
`busy=${String(a.busy).padEnd(5)} ink=${a.ink} contrasto=${a.contrast.toFixed(2).padStart(5)} scrim=${a.needsScrim}`);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createJiti } from "jiti";
|
||||
import { mkdtempSync, copyFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
||||
const R = "/home/moze/Sorgenti/pi-imgen";
|
||||
const typst = await jiti.import(join(R, "extensions/imgen/render/typst.ts"));
|
||||
const cfgMod = await jiti.import(join(R, "extensions/imgen/config.ts"));
|
||||
|
||||
const job = mkdtempSync(join(tmpdir(), "imgen-e2e-"));
|
||||
copyFileSync(join(R, "tests/art.png"), join(job, "art.png"));
|
||||
|
||||
const cfg = { ...cfgMod.DEFAULTS, outputDir: job };
|
||||
const spec = {
|
||||
slug: "sagra-castagna", format: "a3-portrait", template: "hero-bottom",
|
||||
palette: { bg: "#f4efe6", ink: "#101014", accent: "#b03a24", scrim: "#000000" },
|
||||
fonts: { display: "Archivo", body: "Archivo" },
|
||||
art: { prompt: "autumn chestnuts", negative: "text, letters", seed: 7, source: "generate" },
|
||||
blocks: [
|
||||
{ role: "title", text: "Sagra della Castagna" },
|
||||
{ role: "date", text: "Sabato 12 settembre, ore 21:00" },
|
||||
{ role: "venue", text: "Chiostro di San Domenico, Città Alta" },
|
||||
],
|
||||
};
|
||||
|
||||
const resolved = await typst.resolveSpec(spec, cfg, join(job, "art.png"), { format: "a3-portrait" });
|
||||
console.log("resolved.art_file =", resolved.art_file);
|
||||
console.log("resolved.ink_on_art =", resolved.ink_on_art);
|
||||
console.log("resolved.ink_on_bg =", resolved.ink_on_bg);
|
||||
console.log("resolved.needs_scrim =", resolved.needs_scrim);
|
||||
console.log("resolved.page =", JSON.stringify(resolved.page));
|
||||
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 });
|
||||
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")));
|
||||
console.log("OK");
|
||||
Reference in New Issue
Block a user