diff --git a/THIRD-PARTY-FONTS.md b/THIRD-PARTY-FONTS.md index 06130b8..82bff9e 100644 --- a/THIRD-PARTY-FONTS.md +++ b/THIRD-PARTY-FONTS.md @@ -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//` e **non** sono versionati. Ogni cartella contiene il file `LICENSE` originale. diff --git a/extensions/imgen/backends/cpu.ts b/extensions/imgen/backends/cpu.ts index 375139f..315fd40 100644 --- a/extensions/imgen/backends/cpu.ts +++ b/extensions/imgen/backends/cpu.ts @@ -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 { + // 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 { const started = Date.now(); const notes: string[] = []; diff --git a/extensions/imgen/backends/drawthings.ts b/extensions/imgen/backends/drawthings.ts index 64b7d99..1c15d7f 100644 --- a/extensions/imgen/backends/drawthings.ts +++ b/extensions/imgen/backends/drawthings.ts @@ -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 { return new Promise((resolve, reject) => { if (signal?.aborted) { - reject(abortedError()); + reject(abortedError(true)); return; } @@ -639,9 +651,10 @@ function runCli(input: RunInput): Promise { 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 = { ...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 = { ...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,66 +1088,105 @@ export class DrawThingsBackend implements Backend { config.upscalerScaleFactor = Math.round(factor); } - const args = [ - "generate", - "--model", model, - // An upscaler needs no creative direction; an empty prompt keeps it faithful. - "--prompt", "", - "--negative-prompt", "", - "--width", String(targetW), - "--height", String(targetH), - "--steps", String(this.upscaleSteps), - "--seed", String(UPSCALE_SEED), - "--models-dir", this.cfg.modelsPath, - "--config-json", JSON.stringify(config), - "--image", input, - "--output", outPath, - "--disable-preview", - ]; - if (this.offline) args.push("--offline"); - args.push(...this.remoteArgs()); + // Fail fast, before queueing behind another job. + const cliPath = await this.requireCliPath(); - const started = Date.now(); - try { - const outcome = await runCli({ - binary: this.cli, - args, - onProgress, - signal, - timeoutMs: this.timeoutMs, - }); - await this.assertSuccess(outcome, outPath, true); - const real = await readImageSize(outPath); - return { - path: outPath, - width: real?.width ?? targetW, - height: real?.height ?? targetH, - seed: UPSCALE_SEED, - model, - elapsedMs: outcome.elapsedMs, - }; - } catch (e) { - // An abort is the user's decision, not a failure to work around. - if (signal?.aborted) throw e; - const isKnownBug = e instanceof BackendError && e.message.includes("issue #121"); - if (!isKnownBug) throw e; + 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); - onProgress?.( - "Ingrandimento generativo non riuscito (bug noto #121 sui Mac da 16GB): " + - "procedo con un ridimensionamento Lanczos di alta qualità. " + - "Il dettaglio non viene ricostruito, ma la locandina resta stampabile.", - ); - await resampleWithSharp(input, outPath, targetW, targetH); - const fallbackSize = await readImageSize(outPath); - return { - path: outPath, - width: fallbackSize?.width ?? targetW, - height: fallbackSize?.height ?? targetH, - seed: UPSCALE_SEED, - model: "lanczos(sharp)", - elapsedMs: Date.now() - started, - }; - } + const args = [ + "generate", + "--model", model, + // An upscaler needs no creative direction; an empty prompt keeps it faithful. + "--prompt", "", + "--negative-prompt", "", + "--width", String(targetW), + "--height", String(targetH), + "--steps", String(this.upscaleSteps), + "--seed", String(UPSCALE_SEED), + "--models-dir", this.cfg.modelsPath, + "--config-json", JSON.stringify(config), + "--image", input, + "--output", tmpOut, + "--disable-preview", + ]; + if (this.offline) args.push("--offline"); + args.push(...remote.args); + + try { + try { + const outcome = await runCli({ + binary: cliPath, + args, + onProgress, + signal, + timeoutMs: this.timeoutMs, + }); + 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, + width: real?.width ?? targetW, + height: real?.height ?? targetH, + seed: UPSCALE_SEED, + model, + elapsedMs: outcome.elapsedMs, + }; + } catch (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; + + onProgress?.( + "Ingrandimento generativo non riuscito (bug noto #121 sui Mac da 16GB): " + + "procedo con un ridimensionamento Lanczos di alta qualità. " + + "Il dettaglio non viene ricostruito, ma la locandina resta stampabile.", + ); + // 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, + width: fallbackSize?.width ?? targetW, + height: fallbackSize?.height ?? targetH, + seed: UPSCALE_SEED, + model: "lanczos(sharp)", + 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 { + private tilingPayload(force = false): Record { 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 [ - "--remote", - "--remote-url", REMOTE_HOST, - "--remote-port", String(this.cfg.server.port), - "--no-remote-tls", - ]; + /** + * 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(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 { + 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 { + private async assertSuccess(o: { + outcome: RunOutcome; + filePath: string; + reportPath: string; + usesImage: boolean; + remoteUsed: boolean; + }): Promise { + 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 kind = classifyRunFailure(outcome); - if (kind !== "other") throw issue121Error(kind === "crash", detail); + const detail = `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`; + const kind = classifyRunFailure(outcome); + + // 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,28 +1339,23 @@ 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); - } - throw new BackendError( - `no output produced at ${outPath}`, - `Nessuna immagine prodotta in «${outPath}», nonostante il comando sia terminato senza errori.`, - tail(outcome.stderr || outcome.stdout, 2_000), - ); - } + // 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 ${filePath}`, + `Nessuna immagine prodotta in «${reportPath}», nonostante il comando sia terminato senza errori.`, + tail(outcome.stderr || outcome.stdout, 2_000), + ); } } diff --git a/extensions/imgen/commands/presets.ts b/extensions/imgen/commands/presets.ts index 9e7e408..a0a67de 100644 --- a/extensions/imgen/commands/presets.ts +++ b/extensions/imgen/commands/presets.ts @@ -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. */ diff --git a/extensions/imgen/design/spec.ts b/extensions/imgen/design/spec.ts index e809cf5..322b816 100644 --- a/extensions/imgen/design/spec.ts +++ b/extensions/imgen/design/spec.ts @@ -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; // 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 + * "/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; + /** family name -> variable axis ranges, e.g. { Archivo: { wdth: [62,125] } }. */ + font_axes: Record>; } diff --git a/extensions/imgen/render/contrast.ts b/extensions/imgen/render/contrast.ts new file mode 100644 index 0000000..ca20ce2 --- /dev/null +++ b/extensions/imgen/render/contrast.ts @@ -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> = { + "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 { + 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 { + 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; + } +} diff --git a/extensions/imgen/render/preflight.ts b/extensions/imgen/render/preflight.ts new file mode 100644 index 0000000..3444a43 --- /dev/null +++ b/extensions/imgen/render/preflight.ts @@ -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; + 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 { + 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 = { 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"); +} diff --git a/extensions/imgen/render/typst.ts b/extensions/imgen/render/typst.ts new file mode 100644 index 0000000..90e1f0d --- /dev/null +++ b/extensions/imgen/render/typst.ts @@ -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: + * + * / + * spec.json the DesignSpec, human-editable + * art.png the artwork + * .typst/lib.typ copied kernel + * .typst/.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 | null { + const entry = byFamily(family); + if (!entry?.axes) return null; + const out: Record = {}; + 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 { + 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 = {}; + const fontAxes: Record> = {}; + 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 /.typst so the job is self-contained. */ +export async function stageBundle(jobDir: string, template: TemplateName): Promise { + 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 { + 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 { + return new Promise((resolvePromise, reject) => { + let child: ReturnType; + 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 { + 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 }; diff --git a/package.json b/package.json index b52afa9..4987473 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/fetch-fonts.sh b/scripts/fetch-fonts.sh index a339d46..0145166 100755 --- a/scripts/fetch-fonts.sh +++ b/scripts/fetch-fonts.sh @@ -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=] @@ -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=" exit 1 diff --git a/templates/banded.typ b/templates/banded.typ index a407bfb..4c2bf67 100644 --- a/templates/banded.typ +++ b/templates/banded.typ @@ -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() diff --git a/templates/centred-stack.typ b/templates/centred-stack.typ index a27a1c3..b5722b3 100644 --- a/templates/centred-stack.typ +++ b/templates/centred-stack.typ @@ -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), ), ) diff --git a/templates/split.typ b/templates/split.typ index f5e4b83..70fe689 100644 --- a/templates/split.typ +++ b/templates/split.typ @@ -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 } - else { none } + 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 diff --git a/tests/art-busy.png b/tests/art-busy.png new file mode 100644 index 0000000..83034e0 Binary files /dev/null and b/tests/art-busy.png differ diff --git a/tests/e2e/contrast.mjs b/tests/e2e/contrast.mjs new file mode 100644 index 0000000..9623734 --- /dev/null +++ b/tests/e2e/contrast.mjs @@ -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}`); +} diff --git a/tests/e2e/render.mjs b/tests/e2e/render.mjs new file mode 100644 index 0000000..7584e8f --- /dev/null +++ b/tests/e2e/render.mjs @@ -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");