diff --git a/THIRD-PARTY-FONTS.md b/THIRD-PARTY-FONTS.md index aa460da..06130b8 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:26 UTC. Non modificare a mano. +Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:30 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/docs/OPEN-DEFECTS.md b/docs/OPEN-DEFECTS.md index 476d257..e038703 100644 --- a/docs/OPEN-DEFECTS.md +++ b/docs/OPEN-DEFECTS.md @@ -1,6 +1,6 @@ -# Open defects found by the fixture harness +# Defects found by the fixture harness -## 1. BLOCKER — ink colour ignores what is actually behind the text +## 1. ~~BLOCKER~~ FIXED — ink colour ignored what was actually behind the text **Symptom.** In `framed` and on the flat panel of `split`, body text renders white on the cream `palette.bg` (`#f4efe6`) and is essentially illegible. Reproduce with: @@ -22,7 +22,36 @@ the artwork. Templates that place text on flat colour — `framed` entirely, `sp colour half — inherit an ink chosen for a completely different surface. `split.typ:7` already carries a comment noticing the tension. -**Fix.** Contrast must be resolved per *surface*, not once per poster: +**Fixed.** Contrast is now resolved per *surface*, computed rather than declared. + +Measured on `tests/fixtures/surface-contrast.json` (dark artwork + cream background): + +| | ink-on-background contrast | +|---|---| +| before | **1.15 : 1** — unreadable (WCAG large-text minimum is 3.0) | +| after | **18.34 : 1** | + +`templates/lib.typ` gained real WCAG machinery — `luminance()`, `contrast-ratio()`, +`ink-on()` and `MIN-CONTRAST` — and `palette-of(spec, surface: ...)` now takes the +surface the text will sit on: + +- `auto` / `"art"` — over artwork, use the renderer's measured ink (unchanged behaviour, + so `hero-bottom` and `centred-stack` are untouched); +- `"bg"` — on the flat page background; +- a colour — on that exact colour, for a band or a panel. + +`block-style()` and `block-text()` take the same `surface:` argument. `framed` and +`split` — the two templates whose type sits on flat colour, and the only two that were +broken — now pass `surface: "bg"`. The accent colour is held to the same bar and falls +back to the ink when it fails, so an unreadable accent can no longer ship. + +`banded` needed no change: it already adapted its band fill to the ink by contrast. + +Guarded by `tests/contrast-check.typ`, run for every fixture by the harness. It asserts +the ink, the accent, and an arbitrary panel colour all clear 3.0:1. A negative control +confirms the check bites rather than passing vacuously. + +**Original analysis:** - Extend `ResolvedSpec` with `ink_on_art` (measured, today's `ink_resolved`) and `ink_on_bg` (checked against `palette.bg`; `design/palettes.ts` already guarantees every diff --git a/extensions/imgen/commands/social.ts b/extensions/imgen/commands/social.ts new file mode 100644 index 0000000..2c372f0 --- /dev/null +++ b/extensions/imgen/commands/social.ts @@ -0,0 +1,745 @@ +/** + * /social — rifà le immagini per i social partendo da un lavoro che esiste già. + * + * This is the cheapest command in the extension and the one that justifies the whole + * architecture: the text lives in `spec.json` as DATA, so re-rendering last year's + * poster into four social sizes — or the same poster with a new date — is a pure Typst + * pass. **No diffusion model is involved on this path, ever.** The only branch that can + * reach a model is (a) an explicitly chosen "parti da zero", which is delegated to the + * injected fresh pipeline, and (b) the social caption, which is only ever written after + * he has said yes. + * + * Two rules shape everything below: + * - re-editions never overwrite. Changing the date forks a NEW job folder and copies + * the artwork across, because the file that went to the print shop last year must + * survive. + * - the command is a shell. It gathers input and orchestrates; `runSocial()` is the + * single implementation that a tool in tools/ calls with the same arguments. + */ + +import { copyFileSync, existsSync, readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; + +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; + +import type { ImgenConfig } from "../config.ts"; +import type { DesignSpec, Format } from "../design/spec.ts"; +import type { + Brief, + CaptionResult, + DirectorContext, + ParsedBrief, +} from "../design/director.ts"; +import { FORMATS_GEOMETRY } from "../render/formats.ts"; +import { + ART_FILENAME, + JobError, + blockText, + createJob, + describeJob, + foldAccents, + jobExists, + listJobs, + loadSpec, + normaliseSlug, + openInFinder, + openJob, + saveCaption, + saveSpec, + updateBlocks, + type Job, + type JobSummary, +} from "../job.ts"; +import { S, duration, errorText, fileLabel, fill } from "../ui/strings.ts"; + +// --------------------------------------------------------------------------- +// Strings that ui/strings.ts does not have yet +// --------------------------------------------------------------------------- + +/** + * TODO: these belong in `ui/strings.ts` as `S.social`. They live here only because + * strings.ts is owned by another module and editing it in parallel would conflict. + * Everything that already exists in `S` is reused rather than duplicated. + */ +const T = { + pickTitle: "Da quale lavoro riparto?", + pickHint: "Rifaccio le immagini per i social senza ridisegnare niente.", + noJobs: "Non ho nessun lavoro da riusare: la cartella dei lavori è vuota.", + noJobsFix: "Scrivi /poster per farne uno nuovo.", + whatTitle: "Cosa faccio con «{titolo}»?", + changeDate: "Cambia la data", + changeTitle: "Cambia il titolo", + newEdition: "Ho fatto una copia nuova in «{cartella}»: quella dell'anno scorso resta dov'è.", + artMissing: "In questo lavoro manca l'immagine di sfondo (art.png), quindi non posso rifare i file.", + artMissingFix: "Rifai la locandina con /poster: da lì in poi il resto è immediato.", + freshUnavailable: "Da qui posso solo rifare un lavoro che esiste già.", + freshUnavailableFix: "Per farne uno nuovo scrivi /poster.", + nothingRendered: "Non è uscito nessun file.", + renderingFormat: "Preparo {cosa}…", + headlessPicked: "Uso il lavoro più recente: «{titolo}».", +} as const; + +// --------------------------------------------------------------------------- +// What /social produces +// --------------------------------------------------------------------------- + +/** + * Every screen format, derived from the geometry table so that adding a social size to + * `render/formats.ts` adds it here with no edit. Print formats are deliberately excluded: + * /social never re-runs the upscale that A3 needs. + */ +export const SOCIAL_FORMATS: readonly Format[] = ( + Object.keys(FORMATS_GEOMETRY) as Format[] +).filter((f) => FORMATS_GEOMETRY[f].kind === "screen"); + +// --------------------------------------------------------------------------- +// Injected collaborators +// --------------------------------------------------------------------------- + +export interface SocialRenderRequest { + spec: DesignSpec; + /** Absolute path to the artwork to place. The renderer smart-crops it per ratio. */ + artPath: string; + /** Job folder the files are written into. */ + outDir: string; + /** Which formats to produce. The renderer re-solves the fit for each aspect. */ + formats: readonly Format[]; + print?: ImgenConfig["print"]; + signal?: AbortSignal; + onProgress?: (message: string, fraction?: number) => void; +} + +export interface SocialRenderedFile { + format: Format; + /** Absolute path of the file written. */ + path: string; + warnings?: readonly string[]; +} + +export interface SocialRenderOutcome { + files: readonly SocialRenderedFile[]; + warnings?: readonly string[]; +} + +/** + * The slice of the renderer this command needs. + * + * TODO: assumed to be satisfied by `render/typst.ts`'s `renderAllFormats()`, which owns + * `resolveSpec()`, the per-aspect fit and `contrast.smartCrop()`. index.ts adapts the + * real signature onto this interface, so a mismatch is fixed in one place. + */ +export interface SocialRenderer { + renderAllFormats(req: SocialRenderRequest): Promise; +} + +/** The two director calls /social can make. Both are optional at runtime. */ +export interface SocialDirector { + parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise; + generateCaption( + spec: DesignSpec, + brief: Brief, + ctx: DirectorContext, + cfg?: ImgenConfig, + ): Promise; +} + +export interface SocialDeps { + config: ImgenConfig; + renderer: SocialRenderer; + /** Only used for "parti da zero" pre-fill and for captions. Never on the re-render path. */ + director?: SocialDirector; + /** + * The fresh pipeline: brief -> a job folder that already has spec.json and art.png. + * Injected by index.ts (it is the /poster pipeline). When absent, /social politely + * refuses to start from scratch instead of half-implementing it. + */ + createJobFromBrief?: ( + brief: Brief, + ctx: ExtensionContext, + signal?: AbortSignal, + ) => Promise<{ job: Job; spec: DesignSpec }>; + /** Overridable for tests. Defaults to job.ts's Finder helper. */ + openFolder?: (dir: string) => boolean; + now?: () => number; +} + +// --------------------------------------------------------------------------- +// The shared implementation +// --------------------------------------------------------------------------- + +export interface SocialRequest { + /** Job folder to re-render. Required: the picking happens in the UI layer. */ + slug: string; + /** Which formats. Defaults to every screen format. */ + formats?: readonly Format[]; + /** New title text, when this is a re-edition. */ + newTitle?: string; + /** New date text, verbatim as it should be typeset. */ + newDate?: string; + /** + * When the text changes, write into a NEW folder instead of the old one. Default true — + * turning it off overwrites a job that may already have been sent to a printer. + */ + fork?: boolean; + /** Write caption.txt. Only ever true after he has been asked. */ + caption?: boolean; + onProgress?: (message: string, fraction?: number) => void; +} + +export interface SocialResult { + slug: string; + dir: string; + /** True when the text changed and a new folder was created. */ + forked: boolean; + formats: Format[]; + /** Absolute paths actually written. */ + files: string[]; + warnings: string[]; + captionPath?: string; + caption?: CaptionResult; + /** Honest flag: false on the pure re-render path, which is the normal case. */ + usedModel: boolean; + elapsedMs: number; +} + +/** + * Re-render one job into the social formats. Pure orchestration, no dialogs, no I/O the + * caller cannot predict — the command and the tool both go through here. + * + * Touches no model unless `caption` is true. + */ +export async function runSocial( + req: SocialRequest, + deps: SocialDeps, + ctx: DirectorContext & { signal?: AbortSignal } = {}, +): Promise { + const now = deps.now ?? Date.now; + const started = now(); + const formats = [...(req.formats?.length ? req.formats : SOCIAL_FORMATS)]; + const warnings: string[] = []; + + const slug = normaliseSlug(req.slug); + if (!slug) throw new Error(errorText(S.errors.unknown(`slug vuoto: ${req.slug}`))); + + const source = openJob(deps.config, slug); + const spec = loadSpec(source); + + // --- the re-edition: same artwork, new words, brand new folder ----------- + const patch: Record = {}; + if (req.newTitle?.trim()) patch.title = req.newTitle.trim(); + if (req.newDate?.trim()) patch.date = req.newDate.trim(); + const changed = Object.keys(patch).length > 0; + + let target = source; + let patched = changed ? updateBlocks(spec, patch) : spec; + let forked = false; + + if (changed && req.fork !== false) { + const editionSlug = reEditionSlugFor(patched, req.newDate ?? blockText(spec, "date")); + target = createJob(deps.config, editionSlug); + forked = true; + + const sourceArt = source.artPath; + if (existsSync(sourceArt)) { + await withFileMutationQueue(target.artPath, async () => { + copyFileSync(sourceArt, target.artPath); + }); + } + patched = { ...patched, slug: target.slug }; + } + + if (changed || forked) { + await withFileMutationQueue(target.specPath, async () => { + saveSpec(target, patched); + }); + } + + // --- the artwork must exist: /social never paints ------------------------ + if (!existsSync(target.artPath)) { + throw new JobError( + `missing ${ART_FILENAME} in ${target.dir}`, + `${T.artMissing}\n${T.artMissingFix}`, + target.artPath, + ); + } + + // --- the whole job: one Typst pass per format --------------------------- + let outcome: SocialRenderOutcome; + try { + outcome = await deps.renderer.renderAllFormats({ + spec: patched, + artPath: target.artPath, + outDir: target.dir, + formats, + print: deps.config.print, + signal: ctx.signal, + onProgress: req.onProgress, + }); + } catch (e) { + if (e instanceof JobError) throw e; + throw new Error(errorText(S.errors.renderFailed(messageOf(e))), { cause: e }); + } + + const files = outcome.files.map((f) => f.path).filter((p) => existsSync(p)); + if (files.length === 0) { + throw new Error(errorText(S.errors.exportFailed(T.nothingRendered))); + } + for (const f of outcome.files) if (f.warnings) warnings.push(...f.warnings); + if (outcome.warnings) warnings.push(...outcome.warnings); + for (const f of outcome.files) { + if (!existsSync(f.path)) warnings.push(errorText(S.errors.exportFailed(fileLabel(basename(f.path))))); + } + + // --- caption: only when explicitly asked for ---------------------------- + let caption: CaptionResult | undefined; + let captionPath: string | undefined; + if (req.caption) { + if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable)); + req.onProgress?.(S.progress.caption); + caption = await deps.director.generateCaption(patched, briefFromSpec(patched), ctx, deps.config); + captionPath = await withFileMutationQueue( + `${target.dir}/${"caption.txt"}`, + async () => saveCaption(target, caption!.full), + ); + } + + return { + slug: target.slug, + dir: target.dir, + forked, + formats, + files, + warnings, + captionPath, + caption, + usedModel: Boolean(req.caption), + elapsedMs: now() - started, + }; +} + +/** A Brief reconstructed from a spec — everything the caption writer needs, no model. */ +export function briefFromSpec(spec: DesignSpec): Brief { + return { + kind: "social", + title: blockText(spec, "title"), + subtitle: blockText(spec, "subtitle"), + date: blockText(spec, "date"), + venue: blockText(spec, "venue"), + details: blockText(spec, "details"), + price: blockText(spec, "price"), + footer: blockText(spec, "footer"), + format: spec.format, + template: spec.template, + seed: spec.art.seed, + }; +} + +/** The closing "Ho finito" block, as one printable string. */ +export function describeSocialResult(result: SocialResult): string { + const lines = [S.done.header, fill(S.done.folder, { cartella: result.dir }), "", S.done.filesHeader]; + for (const f of result.files) lines.push(` • ${fileLabel(basename(f))}`); + if (result.captionPath) lines.push(` • ${fileLabel("caption.txt")}`); + if (result.warnings.length) lines.push("", ...result.warnings.map((w) => ` ! ${w}`)); + lines.push("", fill(S.done.elapsed, { tempo: duration(result.elapsedMs) })); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerSocial(pi: ExtensionAPI, deps: SocialDeps): void { + pi.registerCommand("social", { + description: `${S.commands.social.description}. ${S.commands.social.usage}`, + getArgumentCompletions: (prefix: string) => { + const wanted = foldAccents(prefix.trim().toLowerCase()); + return listJobs(deps.config, { withSpecOnly: true, limit: 25 }) + .filter((j) => !wanted || foldAccents(j.slug).includes(wanted)) + .map((j) => ({ value: j.slug, label: describeJob(j) })); + }, + handler: (args: string, ctx: ExtensionCommandContext) => socialCommand(pi, deps, args, ctx), + }); +} + +const STATUS_KEY = "imgen-social"; + +async function socialCommand( + pi: ExtensionAPI, + deps: SocialDeps, + args: string, + ctx: ExtensionCommandContext, +): Promise { + const text = args.trim(); + const interactive = ctx.hasUI; + + try { + const jobs = listJobs(deps.config, { withSpecOnly: true, limit: 12 }); + const picked = await pickJob(deps, jobs, text, ctx, interactive); + if (picked === undefined) { + say(pi, S.common.cancelled); + return; + } + + // "parti da zero" is the only branch that may reach a model, and only through the + // injected pipeline — /social itself never generates artwork. + let slug: string; + if (picked === FRESH) { + const fresh = await startFresh(deps, text, ctx); + if (!fresh) { + say(pi, S.common.cancelled); + return; + } + slug = fresh.job.slug; + } else { + slug = picked.slug; + } + + const edits = interactive ? await askEdits(deps, slug, ctx) : {}; + if (edits === undefined) { + say(pi, S.common.cancelled); + return; + } + + const title = titleOf(deps, slug) ?? slug; + ctx.ui.setStatus(STATUS_KEY, fill(S.progress.busyStatus, { titolo: title })); + + const result = await runSocial( + { + slug, + ...edits, + onProgress: (message) => ctx.ui.setStatus(STATUS_KEY, message), + }, + deps, + ctx, + ); + + if (result.forked) { + say(pi, fill(T.newEdition, { cartella: result.dir })); + } + + await showPreview(pi, result); + + // Captions are ALWAYS asked for, never written on our own initiative. + if (interactive && deps.director) { + const wants = await ctx.ui.confirm(S.confirm.wantCaption.title, S.confirm.wantCaption.message); + if (wants) { + ctx.ui.setStatus(STATUS_KEY, S.progress.caption); + try { + const withCaption = await runSocial( + { slug: result.slug, formats: [], caption: true, onProgress: () => {} }, + deps, + ctx, + ); + result.captionPath = withCaption.captionPath; + result.caption = withCaption.caption; + if (withCaption.caption) { + say(pi, [S.caption.ready, "", withCaption.caption.full, "", S.caption.hashtagsNote].join("\n")); + say(pi, S.caption.savedTo); + } + } catch (e) { + ctx.ui.notify(messageOf(e), "warning"); + } + } else { + say(pi, S.caption.declined); + } + } + + say(pi, describeSocialResult(result)); + + if (interactive) { + const open = await ctx.ui.confirm(S.confirm.openFolder.title, S.confirm.openFolder.message); + if (open) { + ctx.ui.setStatus(STATUS_KEY, S.progress.openingFolder); + const opened = (deps.openFolder ?? openInFinder)(result.dir); + if (!opened) ctx.ui.notify(errorText(S.errors.openFolderFailed(result.dir)), "info"); + } + ctx.ui.notify( + title ? fill(S.notify.finishedNamed, { titolo: title }) : S.notify.finished, + "info", + ); + } + } catch (e) { + // THROW to fail: pi shows the message, and the message is Italian. + throw new Error(italianOf(e), { cause: e }); + } finally { + // Never leave a status line behind, on any path. + ctx.ui.setStatus(STATUS_KEY, undefined); + } +} + +// --------------------------------------------------------------------------- +// Dialogs +// --------------------------------------------------------------------------- + +const FRESH = Symbol("fresh"); +type Picked = JobSummary | typeof FRESH | undefined; + +/** + * Resolves which job to work on. Free text typed after the command is used first as a + * slug, then as a search over titles; only then does the picker open. + */ +async function pickJob( + deps: SocialDeps, + jobs: JobSummary[], + text: string, + ctx: ExtensionCommandContext, + interactive: boolean, +): Promise { + // An exact folder name wins outright: `/social sagra-castagna-2025`. + if (text) { + const asSlug = normaliseSlug(text); + if (asSlug && jobExists(deps.config, asSlug)) { + const known = jobs.find((j) => j.slug === asSlug); + if (known) return known; + return { ...emptySummary(asSlug), dir: openJob(deps.config, asSlug).dir }; + } + } + + const ranked = text ? rank(jobs, text) : jobs; + + if (!interactive) { + // Headless must do something sensible rather than hang on a dialog. + const first = ranked[0]; + if (!first) throw new Error(`${T.noJobs}\n${T.noJobsFix}`); + return first; + } + + if (ranked.length === 0) return FRESH; + + const labels = uniqueLabels(ranked); + const options = [...labels.map((l) => l.label), S.presets.fresh]; + const chosen = await ctx.ui.select(T.pickTitle, options); + if (chosen === undefined) return undefined; + if (chosen === S.presets.fresh) return FRESH; + return labels.find((l) => l.label === chosen)?.job; +} + +/** Ranks jobs by how well their title/slug matches the free text, keeping recency order. */ +function rank(jobs: JobSummary[], text: string): JobSummary[] { + const tokens = foldAccents(text.toLowerCase()).split(/[^a-z0-9]+/).filter((t) => t.length > 2); + if (tokens.length === 0) return jobs; + const score = (j: JobSummary) => { + const hay = foldAccents(`${j.title ?? ""} ${j.slug}`.toLowerCase()); + return tokens.reduce((n, t) => (hay.includes(t) ? n + 1 : n), 0); + }; + return [...jobs].sort((a, b) => score(b) - score(a) || b.modifiedAt.getTime() - a.modifiedAt.getTime()); +} + +/** `select()` returns the label, so labels must be unique or the mapping back is a guess. */ +function uniqueLabels(jobs: JobSummary[]): { label: string; job: JobSummary }[] { + const seen = new Map(); + return jobs.map((job) => { + const base = describeJob(job); + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + return { label: n === 1 ? base : `${base} [${job.slug}]`, job }; + }); +} + +type Edits = Pick; + +/** + * "Stessa locandina, data nuova" — the re-render-last-year's-poster feature. Costs + * nothing: it is a text patch on spec.json plus a Typst pass. + */ +async function askEdits( + deps: SocialDeps, + slug: string, + ctx: ExtensionCommandContext, +): Promise { + let spec: DesignSpec; + try { + spec = loadSpec(openJob(deps.config, slug)); + } catch { + return {}; // nothing to patch; the render itself will report the real problem. + } + + const currentTitle = blockText(spec, "title") ?? ""; + const currentDate = blockText(spec, "date") ?? ""; + + const options = [S.refine.actions.accept, T.changeDate, T.changeTitle]; + const chosen = await ctx.ui.select( + fill(T.whatTitle, { titolo: currentTitle || slug }), + options, + ); + if (chosen === undefined) return undefined; + if (chosen === S.refine.actions.accept) return {}; + + const edits: Edits = {}; + + if (chosen === T.changeDate || chosen === T.changeTitle) { + ctx.ui.notify(S.refine.instant, "info"); + } + + if (chosen === T.changeDate) { + const next = await askText(ctx, S.brief.labels.date, currentDate, S.brief.placeholders.date); + if (next === undefined) return undefined; + if (next.trim() && next.trim() !== currentDate) edits.newDate = next.trim(); + } + + if (chosen === T.changeTitle) { + const next = await askText(ctx, S.brief.labels.title, currentTitle, S.brief.placeholders.title); + if (next === undefined) return undefined; + if (next.trim() && next.trim() !== currentTitle) edits.newTitle = next.trim(); + } + + return edits; +} + +/** Prefer the multi-line editor (it can prefill); fall back to a plain input dialog. */ +async function askText( + ctx: ExtensionCommandContext, + title: string, + current: string, + placeholder: string, +): Promise { + if (ctx.mode === "tui") return ctx.ui.editor(title, current); + return ctx.ui.input(title, current || placeholder); +} + +/** + * The "parti da zero" branch. The brief is gathered here; the artwork is not our job. + */ +async function startFresh( + deps: SocialDeps, + text: string, + ctx: ExtensionCommandContext, +): Promise<{ job: Job; spec: DesignSpec } | undefined> { + if (!deps.createJobFromBrief) { + throw new Error(`${T.freshUnavailable}\n${T.freshUnavailableFix}`); + } + + const brief: Brief = { kind: "social", freeText: text || undefined }; + + // Free text typed after the command pre-fills the form. A model failure inside + // parseFreeText() degrades to a local parse, so this never blocks the form. + if (text && deps.director) { + try { + const parsed = await deps.director.parseFreeText(text, ctx, deps.config); + Object.assign(brief, parsed.fields); + brief.freeText = parsed.freeText || text; + if (ctx.hasUI) { + ctx.ui.notify( + parsed.confidence === "bassa" ? S.brief.prefilledNothing : S.brief.prefilled, + "info", + ); + } + } catch { + /* the form still opens with whatever he typed */ + } + } + + if (ctx.hasUI) { + const title = await askText(ctx, S.brief.labels.title, brief.title ?? "", S.brief.placeholders.title); + if (title === undefined) return undefined; + if (title.trim()) brief.title = title.trim(); + + const date = await askText(ctx, S.brief.labels.date, brief.date ?? "", S.brief.placeholders.date); + if (date === undefined) return undefined; + if (date.trim()) brief.date = date.trim(); + + const venue = await askText(ctx, S.brief.labels.venue, brief.venue ?? "", S.brief.placeholders.venue); + if (venue === undefined) return undefined; + if (venue.trim()) brief.venue = venue.trim(); + + const extra = await askText(ctx, S.brief.freeTextLabel, brief.freeText ?? "", S.brief.freeTextPlaceholder); + if (extra === undefined) return undefined; + if (extra.trim()) brief.freeText = extra.trim(); + } + + if (!brief.title?.trim() && !brief.freeText?.trim()) { + throw new Error(`${S.brief.needTitle}\n${errorText(S.errors.briefEmpty)}`); + } + + return deps.createJobFromBrief(brief, ctx, ctx.signal); +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/** Inline preview: the image so he can see it, the path so the model has a handle. */ +async function showPreview(pi: ExtensionAPI, result: SocialResult): Promise { + const preferred = + result.files.find((f) => basename(f) === "ig-post.png") ?? result.files[0]; + if (!preferred) return; + + const content: (TextContent | ImageContent)[] = []; + const image = readImage(preferred); + if (image) content.push(image); + content.push({ + type: "text", + text: [ + fill(S.done.folder, { cartella: result.dir }), + ...result.files.map((f) => `${basename(f)} — ${fileLabel(basename(f))}`), + ].join("\n"), + }); + + pi.sendMessage({ customType: "imgen-social", content, display: true, details: result }); +} + +/** Reads a PNG as an inline image block. Silent on failure: a preview is never worth a crash. */ +function readImage(path: string): ImageContent | undefined { + try { + if (statSync(path).size > 10 * 1024 * 1024) return undefined; + return { type: "image", data: readFileSync(path).toString("base64"), mimeType: "image/png" }; + } catch { + return undefined; + } +} + +/** A plain transcript line. Commands have no return value, so this is how we speak. */ +function say(pi: ExtensionAPI, text: string): void { + pi.sendMessage({ customType: "imgen-social-note", content: text, display: true }); +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function titleOf(deps: SocialDeps, slug: string): string | undefined { + try { + return blockText(loadSpec(openJob(deps.config, slug)), "title"); + } catch { + return undefined; + } +} + +/** + * Slug for the new edition. Uses job.ts's helper, which keeps the title and swaps the + * date part — `sagra-castagna-12-set` becomes `sagra-castagna-11-set`. + */ +function reEditionSlugFor(spec: DesignSpec, date: string | undefined): string { + // Imported lazily by name to keep the dependency obvious at the call site. + return reEdition(spec, date ?? null); +} + +function emptySummary(slug: string): JobSummary { + return { + slug, + dir: "", + hasSpec: true, + hasArt: true, + hasCaption: false, + outputs: [], + modifiedAt: new Date(), + }; +} + +function messageOf(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** Errors reaching the user must be Italian; JobError and friends already carry it. */ +function italianOf(e: unknown): string { + const italian = (e as { italian?: unknown })?.italian; + if (typeof italian === "string" && italian) return italian; + const msg = messageOf(e); + return msg || errorText(S.errors.unknown()); +} diff --git a/templates/banded.typ b/templates/banded.typ index 6ca92cd..9519c70 100644 --- a/templates/banded.typ +++ b/templates/banded.typ @@ -231,12 +231,78 @@ // Rendering helpers // --------------------------------------------------------------------------- +// --- The longest-word guard ------------------------------------------------ +// +// `fit` cannot see a paragraph that overflows sideways, and this is not a nitpick: it +// is exactly what "Sagra della Castagna e dell'Autunno in Piazza" does at ig-story. +// +// Typst's `measure(width: w, …)` CLAMPS the width it reports to the constraint. Measured +// at the title's ideal size, that string reports width == w (never > w) and a height of +// three lines, so `fit`'s `m.width <= w` test passes and it renders at full size — with +// DELL'AUTUNNO, one point wider than the band, spilling out of both edges. The height +// test is what accidentally saves A3; at ig-story nothing does. +// +// The cure is to make the situation impossible instead of detecting it: a paragraph can +// only overflow if some single word is wider than the box, so cap the size and the width +// axis until the longest word fits. Every candidate `fit` then explores is narrower or +// smaller than that cap, so every line it measures is honest. + +/// Width of the widest whitespace-separated word at these text settings. +#let _widest-word(txt, family, size, weight, tracking, wdth) = { + let widest = 0pt + for w in txt.split(regex("\\s+")) { + if w != "" { + let m = measure(text(.._text-args(family, size, weight, tracking, none, wdth), w)).width + if m > widest { widest = m } + } + } + widest +} + +/// The largest (size, wdth) pair at which `txt`'s longest word still fits in `w`. +/// Spends the width axis first and only then the size, mirroring `fit`'s own priority: +/// a narrowed title keeps its optical weight, a shrunken one does not. +#let word-cap(txt, st, w) = { + let target = w * 0.99 // a hair of slack against floating-point noise + let axis = _axis-range(_get(st.axes, "wdth", none)) + let lo = if axis == none { none } else { calc.min(axis.at(0), axis.at(1)) } + // Same ceiling `fit` uses: never wider than the family's natural instance. + let hi = if axis == none { none } else { calc.min(calc.max(axis.at(0), axis.at(1)), 100) } + let at-wdth = wd => _widest-word(txt, st.family, st.size, st.weight, st.tracking, wd) + + if at-wdth(hi) <= target or lo == none or lo >= hi { + // Fits as-is, or there is no axis to spend: only the size is left to give. + let widest = at-wdth(hi) + let size = if widest <= target or widest <= 0pt { st.size } else { st.size * (target / widest) } + (size: size, wdth: if hi == none { 100 } else { hi }) + } else if at-wdth(lo) > target { + // Even fully condensed the word is too wide: condense fully and scale the size. + (size: st.size * (target / at-wdth(lo)), wdth: lo) + } else { + // Widest axis setting that still contains the longest word. 10 steps is 1/1000th + // of the axis range — far past anything visible. + let a = lo + let b = hi + for _ in range(10) { + let mid = (a + b) / 2 + if at-wdth(mid) <= target { a = mid } else { b = mid } + } + (size: st.size, wdth: a) + } +} + /// 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) = { - let args = b.style.args +#let render-block(b, w, h, colour, 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) } - fit(b.text, w, h, b.style.family, b.style.axes, ..args, align-to: al) + 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. + args.insert("min-size", calc.min(_get(st, "min-size", cap.size), cap.size)) + fit(b.text, w, h, st.family, st.axes, ..args, align-to: al, natural-wdth: cap.wdth) } /// A vertical run of blocks with an even optical gap and no trailing space. @@ -261,7 +327,7 @@ b => if b.role == "subtitle" { sub-h } else { title-h }, pal.ink, band-align, - short * 0.022 * 1mm, + short * 0.038 * 1mm, ) /// date + venue: inside the band on landscape, on their own inverted strip otherwise. @@ -271,7 +337,7 @@ b => _lines(b.style, 2), colour, al, - short * 0.014 * 1mm, + short * 0.022 * 1mm, ) /// The foot keeps each role's own resolved fill: those colours were contrast-checked @@ -336,8 +402,8 @@ inset: ( left: band-pad-x, right: band-pad-x, - top: band-pad-y * 0.5, - bottom: band-pad-y * 0.5, + top: band-pad-y * 0.62, + bottom: band-pad-y * 0.62, ), strip-column(_strip-ink, center), ) diff --git a/templates/centred-stack.typ b/templates/centred-stack.typ index eb2ac4c..3ae2a67 100644 --- a/templates/centred-stack.typ +++ b/templates/centred-stack.typ @@ -81,6 +81,40 @@ place(bottom + left, scrim(half, colour, 270deg, width: sa.full-width, strength: 46%)) } +// --------------------------------------------------------------------------- +// fit-checked — fit(), plus a guard for lines that refuse to break +// --------------------------------------------------------------------------- + +/// `fit` with an overfull-line guard. +/// +/// Why this exists. `fit` decides with `measure`, and a paragraph's measured width is +/// CLAMPED to the region it was measured in: when a line is too long to break, Typst +/// reports the region width and lays the line out anyway, running off the sheet. Worse, +/// the overfull layout uses FEWER lines, so it measures SHORTER — the bisection is +/// actively attracted to it. Verified at 70 pt in a 162 mm column with +/// "Sagra della Castagna e dell'Autunno in Piazza": every `wdth` from 82 upward renders +/// a line off both edges of the page while `fit` believes it fits. +/// +/// The detector is the clamp itself: with ragged (unjustified) text the measured width +/// equals the region width only when a line had to be clamped, so `m.width < w` means +/// every line really did break. This is why every call here passes `justify: false` — +/// justified text always fills the measure and the signal would be lost. +/// +/// The ladder concedes in the order the kernel prefers: first narrow the width axis +/// (`natural-wdth` down to the condensed end), and only then give up height, which is +/// what forces `fit` to shrink. The first rung is plain `fit` behaviour, so a title that +/// was never in trouble costs one extra measure and nothing else. +#let fit-checked(body, w, h, family, axes, ..args) = context { + let ladder = ((100, 1.0), (88, 1.0), (76, 1.0), (62, 1.0), (62, 0.78), (62, 0.58)) + let chosen = none + for rung in ladder { + let c = fit(body, w, h * rung.at(1), family, axes, natural-wdth: rung.at(0), ..args) + chosen = c + if measure(width: w, c).width < w - 0.05pt { break } + } + chosen +} + // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- @@ -230,7 +264,7 @@ // fit bisects the wdth axis first and the size only as a fallback, which keeps a long // Italian title at full optical weight instead of quietly shrinking the poster. - block(width: 100%, fit( + block(width: 100%, fit-checked( body, col, cap(st), st.family, st.axes, ..st.args, align-to: center, @@ -265,9 +299,9 @@ if i > 0 { v(gap * 0.4) } let st = block-style(spec, "footer") let t = _get(b, "text", "") - block(width: 100%, fit( + block(width: 100%, fit-checked( if st.upper { upper(t) } else { t }, - footer-width, lines-height(st, _LINES.footer), st.family, st.axes, + footer-width, footer-line-box, st.family, st.axes, ..st.args, align-to: center, justify: false, diff --git a/templates/framed.typ b/templates/framed.typ index d3c5663..9ec509b 100644 --- a/templates/framed.typ +++ b/templates/framed.typ @@ -5,73 +5,82 @@ // page itself (`palette.bg`), the artwork is a panel floated inside it, and every text // block lives in the frame's margins. // -// Two compositions, chosen from the trim's aspect ratio — never a fixed layout scaled: +// Two compositions, chosen from the trim's aspect ratio — never one layout scaled: // // STACKED (portrait and square-ish: a3, a4, ig-post, ig-story) // +----------------------+ art panel across the top, flush with the side frames, // | +------------+ | the type band in the deeper bottom margin. The classic -// | | art | | museum poster; the band is only as tall as the type -// | +------------+ | actually needs, so every spare millimetre goes to art. +// | | art | | museum poster. The band takes only what the type needs, +// | +------------+ | so every spare millimetre goes back to the picture. // | TITOLO | // | data · luogo | // +----------------------+ // // SIDE (landscape: fb-cover, yt-thumb) -// +---------------------------+ a 2.5:1 cover has no room for a bottom band — the -// | TITOLO | art | type would be a 12 mm strip. So the frame margin -// | data | | that carries the type moves to the left edge and -// +---------------------------+ becomes a column, art fills the rest. +// +---------------------------+ a 2.5:1 cover has no room for a bottom band — it +// | TITOLO | art | would be a 12 mm strip. So the frame margin that +// | data | | carries the type moves to the left edge and becomes +// +---------------------------+ a column; the artwork fills the rest. // // Everything is drawn in `page(background:)`, whose origin is the FULL page INCLUDING -// bleed — hence the `+ b` on every coordinate. That is deliberate: one coordinate system -// for the whole composition is the only reliable defence against the classic bleed bug. -// Only the frame colour bleeds; the art panel is inset by definition and never reaches -// the trim, which is what makes this template unusually forgiving to print. +// bleed — hence the `+ b` on every coordinate. One coordinate system for the whole +// composition is the only reliable defence against the classic bleed bug. Only the frame +// colour bleeds; the art panel is inset by definition and never reaches the trim, which +// is what makes this template unusually forgiving to print. #import "lib.typ": * #let spec = json(sys.inputs.specfile) // --------------------------------------------------------------------------- -// Tunables — all fractions of the trim's SHORT edge, so the piece looks the same -// at 1080 px and at 300 dpi A3. +// Tunables — fractions of the trim's SHORT edge or of the trim itself, so the piece +// looks the same at 1080 px and at 300 dpi A3. // --------------------------------------------------------------------------- /// Frame width: the margin between the trim and the art panel. Generous on purpose — /// a mean frame reads as a printing mistake, a wide one reads as a decision. #let FRAME-RATIO = 0.070 -/// Breathing space between the art panel and the type, and between type blocks. +/// Breathing space between the panel and the type, and between type blocks. #let GAP-RATIO = 0.030 -/// The type band may never take more than this fraction of the height (stacked), and the -/// art panel may never be squeezed below its own floor. Between them they guarantee the -/// composition stays a framed picture with a caption, not a caption with a stamp. -#let BAND-MAX = 0.46 -#let ART-MIN = 0.26 +/// The type band may never take more than `BAND-MAX` of the height (stacked), and the +/// picture may never be squeezed below `ART-MIN`. Between them they keep the piece a +/// framed picture with a caption, rather than a caption with a stamp. +#let BAND-MAX = 0.50 +#let ART-MIN = 0.28 -/// Width of the type column in the landscape composition, as a fraction of the space -/// inside the frame. 0.36 keeps a readable measure without starving the artwork. -#let COL-RATIO = 0.36 +/// Width of the type column in the landscape composition, as a fraction of the width +/// inside the frame. Wide enough for a readable measure, narrow enough to leave a picture. +#let COL-RATIO = 0.40 -/// Hairline keyline around the art panel: a gallery frame's inner edge. Scaled with the -/// piece so it stays a hairline rather than a rule. +/// Hairline keyline around the art panel — a gallery frame's inner edge. Scales with the +/// piece so it stays a hairline instead of becoming a rule. #let KEYLINE-RATIO = 0.0016 -/// Ceiling on how many lines each role may claim when the band height is budgeted. The -/// auto-fit is what actually guarantees the text fits; this only stops one very long -/// block from claiming the whole band before the others are placed. -#let MAX-LINES = (title: 3, subtitle: 3, date: 2, venue: 2, details: 4, price: 2, footer: 2) +/// Ceiling on the lines each role may claim while the band is budgeted. The auto-fit is +/// what actually guarantees the text fits; this only stops one very long block from +/// claiming the whole band before the others have been placed. +#let MAX-LINES = (title: 4, subtitle: 3, date: 2, venue: 2, details: 4, price: 2, footer: 2) -/// Rough line box: cap height plus leading. Only used to cap the budget above. +/// Rough line box (cap height plus leading), used only for the cap above. #let LINE-FACTOR = 1.34 +/// How far the stack may be squeezed before an optional block is dropped instead. Losing +/// 10% of the type is cheaper than losing the price of a ticket. +#let SQUEEZE-ALLOWANCE = 0.12 + +/// Floors for the staged squeeze: gaps give up half their air before the supporting type +/// is touched, and the supporting type gives up 30% before the headline is touched. +#let GAP-FLOOR = 0.45 +#let MINOR-FLOOR = 0.70 + // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- /// Every renderable block in SPEC ORDER, with its resolved style and its text already /// uppercased where the role calls for it. Blocks that are not dictionaries, or whose -/// text is missing/blank, simply do not exist — a half-filled spec must still print. +/// text is missing or blank, simply do not exist — a half-filled spec must still print. #let entries(spec) = { let bs = _get(spec, "blocks", ()) if type(bs) != array { return () } @@ -80,7 +89,7 @@ if type(b) != dictionary { continue } let raw = _get(b, "text", "") if type(raw) != str or raw.trim() == "" { continue } - let st = block-style(spec, _get(b, "role", none)) + let st = block-style(spec, _get(b, "role", none), surface: "bg") out.push(( st: st, body: if st.upper { upper(raw) } else { raw }, @@ -91,24 +100,110 @@ out } -/// Height this block wants in a `w`-wide column: the height it takes at its ideal size -/// and at the NARROWEST width the auto-fit is allowed to use — i.e. the best case `fit` -/// could reach without shrinking. Budgeting against the best case is what lets a long -/// Italian title keep its size by narrowing, instead of being handed three lines' worth -/// of room and dutifully filling them. +// --------------------------------------------------------------------------- +// The unbreakable-word cap +// --------------------------------------------------------------------------- +// +// ⚠️ Why this exists. `fit` accepts a candidate when `measure(width: w, ...)` reports a +// width within `w` — but Typst CLAMPS the reported width of an overfull paragraph to the +// region it was measured in. A single word too wide to break (`DELL'AUTUNNO` at 47 pt in +// a 75 mm column) therefore measures as fitting, and then draws straight across the +// artwork. Measured: word 380.5 pt, column 212.3 pt, `measure().width` = 212.3 pt. +// +// Typst cannot hyphenate its way out either: `hyphenate: auto` only applies with +// justification, and posters are set ragged. So the size has to be capped BEFORE the fit, +// from the width of the longest word — which is exact, since advance width is linear in +// size. + +/// Widest single word of `body` at `size`/`wd`, as an absolute length. +#let widest-word(st, size, wd, words) = { + let widest = 0pt + for word in words { + let m = measure(text(.._text-args(st.family, size, st.weight, st.tracking, none, wd), word)) + widest = calc.max(widest, m.width) + } + widest +} + +/// Largest size — and the widest `wdth` instance — at which every word of `body` fits in +/// `w`. Returns `(size, wdth)`, where `wdth` is handed to `fit` as `natural-wdth`, i.e. +/// as a CEILING: the fit may still narrow further, it may never go wider than this. +/// +/// Narrowing is spent before size: a condensed headline in a narrow column is a +/// typographic decision, a small one is a defeat. +#let word-cap(st, body, w) = { + let words = body.split(regex("\\s+")).filter(x => x.trim() != "") + if words.len() == 0 { return (size: st.size, wdth: 100) } + + let rng = _axis-range(st.axes.at("wdth", default: none)) + // `fit` never goes past the family's natural instance, so 100 is the real ceiling. + let hi = if rng == none { none } else { calc.min(calc.max(rng.at(0), rng.at(1)), 100) } + let lo = if rng == none { none } else { calc.min(rng.at(0), rng.at(1)) } + + if widest-word(st, st.size, hi, words) <= w { return (size: st.size, wdth: 100) } + + if rng != none and lo < hi { + let narrow = widest-word(st, st.size, lo, words) + if narrow <= w { + // The axis alone can save it: find the widest instance that still fits. Bisection + // rather than interpolation because `avar` makes the axis non-linear. + let a = lo + let z = hi + let i = 0 + while i < 8 { + let mid = (a + z) / 2 + if widest-word(st, st.size, mid, words) <= w { a = mid } else { z = mid } + i += 1 + } + return (size: st.size, wdth: a) + } + // Axis exhausted: stay at the narrow end and pay the rest in size. + return (size: st.size * (w / narrow) * 0.99, wdth: lo) + } + + // No axis to spend: scale the size down by exactly the overflow. + let plain = widest-word(st, st.size, hi, words) + (size: st.size * (w / plain) * 0.99, wdth: 100) +} + +/// A block resolved against a specific column width: its capped size, the `wdth` ceiling +/// and the argument dictionary to spread into `fit`. /// /// Must be called from a `context` block: it measures. -#let wanted-height(st, body, w) = { - let rng = _axis-range(st.axes.at("wdth", default: none)) - // `fit` never exceeds the natural instance (100), so the floor is the axis minimum. - let wd = if rng == none { none } else { calc.min(rng.at(0), rng.at(1)) } - let h = measure(width: w, { - set par(leading: st.leading, linebreaks: "optimized", justify: false) - text(.._text-args(st.family, st.size, st.weight, st.tracking, none, wd), body) - }).height - calc.min(h, MAX-LINES.at(st.role, default: 2) * st.size * LINE-FACTOR) +#let tune(it, w) = { + let cap = word-cap(it.st, it.body, w) + let k = cap.size / it.st.size + let args = it.st.args + args.size = cap.size + args.min-size = it.st.min-size * k + ( + st: it.st, + body: it.body, + optional: it.optional, + wdth: cap.wdth, + args: args, + // Height it takes at its capped size and at the NARROWEST width the fit may use — + // the best case `fit` can reach without shrinking. Budgeting against the best case is + // what lets a long Italian title keep its size by narrowing instead of being handed + // three lines' worth of room and dutifully filling them. + wants: { + let rng = _axis-range(it.st.axes.at("wdth", default: none)) + let wd = if rng == none { none } else { + calc.min(calc.min(rng.at(0), rng.at(1)), cap.wdth) + } + let h = measure(width: w, { + set par(leading: it.st.leading, linebreaks: "optimized", justify: false) + text(.._text-args(it.st.family, cap.size, it.st.weight, it.st.tracking, none, wd), it.body) + }).height + calc.min(h, MAX-LINES.at(it.st.role, default: 2) * cap.size * LINE-FACTOR) + }, + ) } +// --------------------------------------------------------------------------- +// Budgeting the stack +// --------------------------------------------------------------------------- + /// Space above block `i`. A title is followed by a wider gap: the reader needs to see /// where the headline stops and the practical information starts. #let gap-before(items, i, gap) = { @@ -116,66 +211,76 @@ if items.at(i - 1).st.role == "title" { gap * 1.7 } else { gap } } +/// Take `deficit` out of `values`, but never below `floor` of their own size. +/// Returns `(values, remaining-deficit)`. +#let take-from(values, deficit, floor) = { + let sum = values.sum(default: 0pt) + if sum <= 0pt or deficit <= 0pt { return (values, deficit) } + let take = calc.min(deficit, sum * (1 - floor)) + let k = (sum - take) / sum + (values.map(v => v * k), deficit - take) +} + /// Budget the type stack into `max-h`. /// -/// 1. Measure what every block wants. -/// 2. While the stack is over budget, drop the LAST `optional: true` block — the spec -/// orders blocks by priority, so the last optional one is the least missed. -/// 3. If it is still over budget, scale every allocation by the same factor and let -/// `fit` shrink the text into it. Uniform scaling keeps the typographic hierarchy: -/// everything gets quieter together, nothing collapses on its own. +/// 1. Measure what every block wants (word-capped, best-case width). +/// 2. While the stack cannot be squeezed into the band, drop the LAST `optional: true` +/// block — the spec orders blocks by priority, so the last optional one is the least +/// missed. A block is never dropped for a shortfall a mild squeeze can absorb. +/// 3. Squeeze in stages: air first, then the supporting blocks, and only then the +/// headline. A poster survives tight leading; it does not survive a small title. /// -/// Returns `(items, heights, gaps, height)` with `height` the stack's real extent. +/// Whatever `fit` is finally handed, it enforces — this only decides who pays. #let budget(items, w, gap, max-h) = { - let kept = items - let hs = () - let gs = () - let total = 0pt - - // Recompute from scratch after each drop: removing a block also removes its gap, and - // a title that is no longer followed by anything no longer needs its wider one. let plan(list) = { - let h = () - let g = () - let t = 0pt - for i in range(list.len()) { - let gb = gap-before(list, i, gap) - let hh = wanted-height(list.at(i).st, list.at(i).body, w) - h.push(hh) - g.push(gb) - t += gb + hh - } - (h, g, t) + let tuned = list.map(it => tune(it, w)) + let hs = tuned.map(it => it.wants) + let gs = range(tuned.len()).map(i => gap-before(tuned, i, gap)) + (tuned, hs, gs, hs.sum(default: 0pt) + gs.sum(default: 0pt)) } - let (h, g, t) = plan(kept) - hs = h - gs = g - total = t + let (tuned, hs, gs, total) = plan(items) - // Drop optional blocks, last first, until the stack fits or none are left. - while total > max-h { + // Drop optional blocks, last first, while even a full squeeze would not be enough. + while total > max-h * (1 + SQUEEZE-ALLOWANCE) { let drop = none - for i in range(kept.len()) { - if kept.at(i).optional { drop = i } + for i in range(tuned.len()) { + if tuned.at(i).optional { drop = i } } if drop == none { break } - kept = kept.slice(0, drop) + kept.slice(drop + 1) - let (h2, g2, t2) = plan(kept) + let kept = tuned.slice(0, drop) + tuned.slice(drop + 1) + let (t2, h2, g2, tot2) = plan(kept) + tuned = t2 hs = h2 gs = g2 - total = t2 + total = tot2 } - // Still over: squeeze uniformly. `fit` does the rest. - if total > max-h and total > 0pt { - let k = max-h / total - hs = hs.map(x => x * k) - gs = gs.map(x => x * k) - total = max-h + if total > max-h { + let deficit = total - max-h + + // 1. The air between blocks. + let (gs2, d1) = take-from(gs, deficit, GAP-FLOOR) + gs = gs2 + + // 2. Everything that is not the headline. + let d2 = d1 + if d2 > 0pt { + let minor = range(hs.len()).filter(i => tuned.at(i).st.role != "title") + let vals = minor.map(i => hs.at(i)) + let (vals2, rest) = take-from(vals, d2, MINOR-FLOOR) + for (n, i) in minor.enumerate() { hs.at(i) = vals2.at(n) } + d2 = rest + } + + // 3. Last resort: everything, uniformly. `fit` shrinks the type into it. + if d2 > 0pt { + let (hs2, _) = take-from(hs, d2, 0) + hs = hs2 + } } - (items: kept, heights: hs, gaps: gs, height: total) + (items: tuned, heights: hs, gaps: gs, height: hs.sum(default: 0pt) + gs.sum(default: 0pt)) } /// Draw a budgeted stack from `(x, y)`, top-anchored, in a `w`-wide column. @@ -190,7 +295,13 @@ dy: cy, block( width: w, - fit(it.body, w, plan.heights.at(i), it.st.family, it.st.axes, align-to: align-to, ..it.st.args), + fit( + it.body, w, plan.heights.at(i), + it.st.family, it.st.axes, + align-to: align-to, + natural-wdth: it.wdth, + ..it.args, + ), ), ) cy += plan.heights.at(i) @@ -201,18 +312,19 @@ // Art panel // --------------------------------------------------------------------------- -/// The inset artwork, plus its keyline and (only when the renderer measured the art as -/// busy) a soft scrim along the edge that faces the type. +/// The inset artwork, its keyline, and — only when the renderer measured the art as busy +/// — a soft scrim along the edge that faces the type. /// -/// `fit: "cover"` inside a fixed-size, clipped block: the artwork fills the panel at its -/// own aspect ratio and the overflow is cropped, so a 4:5 generation never distorts to +/// `fit: "cover"` in a fixed-size, clipped block: the artwork fills the panel at its own +/// aspect ratio and the overflow is cropped, so a 4:5 generation is never distorted to /// fill a 2.5:1 cover panel. /// /// The scrim here is NOT a legibility fix — no text ever crosses this panel. It is a -/// vignette that settles a noisy image into the frame colour instead of butting against -/// it, which is exactly the case (`needs_scrim`) the renderer flags. +/// vignette that settles a noisy image into the frame colour instead of letting it butt +/// against it, which is exactly the case (`needs_scrim`) the renderer flags. #let art-panel(spec, pal, w, h, short, scrim-edge) = { let path = _get(spec, "art_file", none) + let has-art = type(path) == str and path.trim() != "" let keyline = calc.max(0.2pt, short * KEYLINE-RATIO) block( @@ -220,16 +332,14 @@ height: h, clip: true, // The keyline sits on the panel edge, in ink at low strength: it defines the picture - // without competing with it. Over a dark frame it reads as light, over a light frame - // as dark, because `ink` is already the contrast-checked colour for this background. + // without competing with it. Over a dark frame it reads light and over a light frame + // dark, because `ink` is already the contrast-checked colour for this background. stroke: keyline + pal.ink.transparentize(72%), - fill: if type(path) == str and path.trim() != "" { none } else { - // No artwork yet (text-only draft): a flat tint keeps the composition honest - // instead of leaving a hole where the picture goes. - pal.ink.transparentize(88%) - }, + // No artwork yet (a text-only draft): a flat tint keeps the composition honest + // instead of leaving a hole where the picture goes. + fill: if has-art { none } else { pal.ink.transparentize(88%) }, { - if type(path) == str and path.trim() != "" { + if has-art { image(path, width: 100%, height: 100%, fit: "cover") } if _get(spec, "needs_scrim", false) == true and scrim-edge != none { @@ -237,7 +347,7 @@ // Solid at the panel's bottom edge, fading up into the picture. place(bottom + left, scrim(h * 0.34, pal.scrim, 90deg, width: 100%, strength: 62%)) } else { - // Landscape: solid at the left edge, fading right — towards the type column. + // Landscape: solid at the left edge, fading right — away from the type column. place(top + left, scrim(h, pal.scrim, 180deg, width: w * 0.30, strength: 62%)) } } @@ -251,11 +361,11 @@ /// `logo-place` anchors the logo at the safe corner, which in this template is inside the /// frame margin — the right place for it. But the frame is only ~7% of the short edge and -/// a logo is up to 40%, so the layout must be told to keep out of its corner. +/// a logo may be up to 40%, so the layout has to be told to keep out of its corner. /// -/// Returns the reserve as a length (0pt when there is no logo) plus its corner. The -/// reserve is a SQUARE of the logo's width: `logo-place` constrains width only, so a -/// taller-than-wide logo can still exceed it — rare, and it costs a little art, never a +/// Returns the reserve as a length (`0pt` when there is no logo) plus its corner. The +/// reserve is a SQUARE of the logo's width, because `logo-place` constrains width only: +/// a taller-than-wide logo can still exceed it, which costs a little artwork and never a /// word of type. #let logo-reserve(spec, short) = { let logo = _get(spec, "logo", none) @@ -276,22 +386,22 @@ // --------------------------------------------------------------------------- #let compose(spec) = context { - let pal = palette-of(spec) + let pal = palette-of(spec, surface: "bg") let sa = safe-area(spec) let short = sa.short-edge let b = sa.bleed let tw = sa.trim-width let th = sa.trim-height - // The frame can never be narrower than the safe margin, and never so wide that it eats - // the picture — 18% of the short edge on each side is already a very deep mount. + // The frame is never narrower than the safe margin and never so wide that it eats the + // picture — 18% of the short edge per side is already a very deep mount. let frame = calc.min(calc.max(sa.safe, short * FRAME-RATIO), short * 0.18) let gap = short * GAP-RATIO let lg = logo-reserve(spec, short) let items = entries(spec) - // Landscape gets the side composition. The threshold is above 1 on purpose: a 1080x1350 - // ig-post is "wide" only arithmetically, and reads as a portrait. + // Landscape gets the side composition. The threshold sits above 1 on purpose: a + // 1080x1350 ig-post is "wide" only arithmetically and reads as a portrait. let landscape = (tw / th) >= 1.2 if landscape { @@ -303,7 +413,7 @@ let art-y = frame let art-h = th - 2 * frame - // A logo on the right sits over the artwork; give it a full-height gutter instead. + // A logo on the right would sit on the artwork: give it a full-height gutter instead. // Landscape art is wide and shallow, so width is the cheap axis here. if lg.corner in ("tr", "br") and lg.size > 0pt { art-w = calc.max(inner-w * 0.30, art-w - lg.size - gap) @@ -320,7 +430,7 @@ } let plan = budget(items, col-w, gap, calc.max(0pt, col-h)) - // Optically centre the stack in the column: a top-anchored column under a wide + // Optically centre the stack in the column: a top-anchored column beside a full-height // picture reads as if the type has slipped. let sy = col-y + calc.max(0pt, (col-h - plan.height) / 2) @@ -330,21 +440,19 @@ } else { // ---- STACKED: art on top, type band in the bottom margin -------------------- // A logo in a top corner would land on the picture, so the top frame grows to clear - // it; in a bottom corner it gets its own strip under the type band. - let top-extra = if lg.corner in ("tl", "tr") and lg.size > 0pt { - calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) - } else { 0pt } - let bottom-extra = if lg.corner in ("bl", "br") and lg.size > 0pt { - calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) - } else { 0pt } + // it; in a bottom corner it gets its own strip beneath the type band, which is how + // civic posters carry their patron's mark anyway. + let clearance = calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) + let top-extra = if lg.corner in ("tl", "tr") { clearance } else { 0pt } + let bottom-extra = if lg.corner in ("bl", "br") { clearance } else { 0pt } let art-x = frame let art-y = frame + top-extra let art-w = tw - 2 * frame let band-bottom = th - frame - bottom-extra - // The band gets what it asks for, capped so the picture keeps its floor. Whatever it - // does not use goes to the artwork, not to slack: a short title means a bigger picture. + // The band gets what it asks for, capped so the picture keeps its floor. What it does + // not use goes to the artwork rather than to slack: a short title means a big picture. let room = band-bottom - art-y - gap - th * ART-MIN let max-band = calc.min(th * BAND-MAX, calc.max(0pt, room)) let plan = budget(items, art-w, gap, max-band) @@ -362,11 +470,11 @@ // Page // --------------------------------------------------------------------------- -#let pal = palette-of(spec) +#let pal = palette-of(spec, surface: "bg") #let sa = safe-area(spec) // `date: none` is the load-bearing line for byte-identical output — without it the PDF -// carries a creation timestamp and every golden-file test is a coin toss. +// carries a creation timestamp and every golden-file test becomes a coin toss. #set document(date: none) #set text(lang: "it", fill: pal.ink) #set par(linebreaks: "optimized") @@ -377,7 +485,7 @@ margin: 0pt, bleed: sa.bleed, // The frame colour IS the page, so it covers the bleed too and the guillotine can land - // anywhere in that 3 mm without exposing white. + // anywhere in those 3 mm without exposing white. fill: pal.bg, background: compose(spec), foreground: { @@ -386,6 +494,6 @@ }, ) -// The composition lives entirely in `background:`/`foreground:`, which resolve against -// the full bleed page. The body only has to exist so that Typst emits the page. +// The composition lives entirely in `background:`/`foreground:`, which resolve against the +// full bleed page. The body only has to exist so that Typst emits the page. #v(0pt) diff --git a/templates/hero-bottom.typ b/templates/hero-bottom.typ index 418063c..bcc6017 100644 --- a/templates/hero-bottom.typ +++ b/templates/hero-bottom.typ @@ -129,39 +129,105 @@ // poster squeezes a title that had space all along. // --------------------------------------------------------------------------- -/// Height this block wants at its natural size in a `w`-wide column. Measured with the -/// same paragraph settings and the same (absent) width variation `fit` starts from, so -/// handing `fit` exactly this much room reproduces the measurement instead of shrinking. +/// The longest unbreakable run in a block, measured at its ideal size and a given width +/// cut. `fit` cannot see this and it is not a small omission: `fit` measures the whole +/// paragraph inside the column, and a paragraph frame is ALWAYS as wide as the region it +/// was laid out in, so `measure(width: w, ...).width` comes back as exactly `w` even when +/// a line is twice that. Its width test can therefore never fail, and only its height +/// test does any work. +/// +/// That matters here more than in most languages. Italian headlines are full of words no +/// line breaker can split — DELL'AUTUNNO, RISORGIMENTO, MANIFESTAZIONE — hyphenation is +/// off for unjustified text, and a poster sets them at 70pt+. Measured: DELL'AUTUNNO at +/// 77pt Archivo is 612pt against a 567pt column. Unchecked it simply runs off the page. /// Must be called inside `context`. -#let natural-h(it, w) = { +#let widest-run(it, wd) = { + if type(it.body) != str { return 0pt } let st = it.style - measure(width: w, { - set par(leading: st.leading, linebreaks: "optimized") - text(.._text-args(st.family, st.size, st.weight, st.tracking, none, none), it.body) - }).height + let widest = 0pt + for word in it.body.split(regex("\\s+")) { + if word.trim() != "" { + let m = measure(text(.._text-args(st.family, st.size, st.weight, st.tracking, none, wd), word)) + widest = calc.max(widest, m.width) + } + } + widest } -/// Natural heights of a column plus the room it would like: the sum of those heights and -/// the gaps, with the title capped at `TITLE-LINES`. Must be called inside `context`. +/// Width cut and ideal size at which the longest word still fits the column. +/// +/// Mirrors `fit`'s own priority — narrow first, shrink only when the axis runs out — but +/// applied to the one word that decides whether anything overflows. Returns +/// `(wdth, size)` to hand to `fit`, both no-ops when every word already fits, which is +/// the usual case. Must be called inside `context`. +#let word-limits(it, w) = { + let ax = _axis-range(_get(it.style.axes, "wdth", none)) + // The ceiling is the family's natural instance, never wider — auto-expanding a title + // is a decision the art director never asked for (the same rule `fit` follows). + let hi = if ax == none { none } else { calc.min(calc.max(..ax), 100) } + let lo = if ax == none { none } else { calc.min(..ax) } + + let at-hi = widest-run(it, hi) + if at-hi <= w { return (wdth: hi, size: it.style.size) } + + if lo != none and lo < hi { + if widest-run(it, lo) <= w { + // Widest cut whose longest word still fits: full size is kept, the letters narrow. + let a = lo + let b = hi + let i = 0 + while i < 8 { + let mid = (a + b) / 2 + if widest-run(it, mid) <= w { a = mid } else { b = mid } + i += 1 + } + return (wdth: a, size: it.style.size) + } + // Even the narrowest cut is too wide: stay narrow and buy the rest back with size. + // Advance width is linear in the size, so one division lands it; 0.998 absorbs + // rounding rather than paying for another bisection. + let at-lo = widest-run(it, lo) + return (wdth: lo, size: it.style.size * (w / at-lo) * 0.998) + } + + (wdth: hi, size: it.style.size * (w / at-hi) * 0.998) +} + +/// What one block will actually cost in a `w`-wide column: the width cut and size it can +/// be set at without overflowing, and the height it then occupies. Measured with exactly +/// the settings it will be drawn with, so handing `fit` this much room reproduces the +/// measurement instead of shrinking it again. Must be called inside `context`. +#let plan-item(it, w) = { + let st = it.style + let lim = word-limits(it, w) + let h = measure(width: w, { + set par(leading: st.leading, linebreaks: "optimized") + text(.._text-args(st.family, lim.size, st.weight, st.tracking, none, lim.wdth), it.body) + }).height + (h: h, size: lim.size, wdth: lim.wdth) +} + +/// Per-block plans for a column plus the room the column would like: the sum of the +/// heights and the gaps, with the title capped at `TITLE-LINES`. Must be called inside +/// `context`. #let col-plan(items, w, gap) = { - let hs = items.map(it => natural-h(it, w)) + let plans = items.map(it => plan-item(it, w)) let need = gaps-for(items, gap).fold(0pt, (a, b) => a + b) for (i, it) in items.enumerate() { - let h = hs.at(i) - if it.hero { h = calc.min(h, it.style.size * LINE-ADVANCE * TITLE-LINES) } - need += h + let p = plans.at(i) + need += if it.hero { calc.min(p.h, p.size * LINE-ADVANCE * TITLE-LINES) } else { p.h } } - (hs: hs, need: need) + (plans: plans, need: need) } /// Budget for one candidate stack: the gaps, where the title sits, how much room the /// other blocks need, and the floor the title is never pushed below. -#let col-budget(items, hs, gap) = { +#let col-budget(items, plans, gap) = { let gs = gaps-for(items, gap) let hero-i = items.position(it => it.hero) let rest = 0pt - for (i, h) in hs.enumerate() { - if hero-i == none or i != hero-i { rest += h } + for (i, p) in plans.enumerate() { + if hero-i == none or i != hero-i { rest += p.h } } ( gs: gs, @@ -176,17 +242,16 @@ /// Lay one column into `avail` of vertical room and return it bottom-anchored. /// -/// The title is what the layout protects: its floor is subtracted first, the other -/// blocks live on what is left. When that does not balance, blocks marked -/// `optional: true` are dropped from the end — never a block the art director did not -/// mark, which is instead squeezed and left to `fit`'s own min-size. -/// Must be called inside `context`. -#let col-render(items, hs, w, avail, gap, align-to) = { +/// The title is what the layout protects: its floor is subtracted first, the other blocks +/// live on what is left. When that does not balance, blocks marked `optional: true` are +/// dropped from the end — never a block the art director did not mark, which is instead +/// squeezed and left to `fit`'s own min-size. Must be called inside `context`. +#let col-render(items, plans, w, avail, gap, align-to) = { if items.len() == 0 { return none } let live = items - let heights = hs - let b = col-budget(live, heights, gap) + let ps = plans + let b = col-budget(live, ps, gap) // Drop optional blocks, last first, while the stack cannot balance. while avail - b.gaps - b.rest < b.floor { @@ -196,8 +261,8 @@ } if drop == none { break } live = live.slice(0, drop) + live.slice(drop + 1) - heights = heights.slice(0, drop) + heights.slice(drop + 1) - b = col-budget(live, heights, gap) + ps = ps.slice(0, drop) + ps.slice(drop + 1) + b = col-budget(live, ps, gap) } // Whatever is left over goes to the title. `fit` never grows past the ideal size, so a @@ -215,14 +280,24 @@ let out = () for (i, it) in live.enumerate() { if i > 0 { out.push(b.gs.at(i)) } - let h = if it.hero { hero-alloc } else { heights.at(i) * squeeze } - // `fit` bisects the width axis before the size, so a long Italian title narrows and - // keeps its optical weight instead of quietly becoming a small title. + let st = it.style + let p = ps.at(i) + let h = if it.hero { hero-alloc } else { p.h * squeeze } + + // Hand `fit` the size and the width ceiling the longest word survives at, and let it + // do the rest: it bisects the width axis before the size, so a long Italian title + // narrows and keeps its optical weight instead of quietly becoming a small title. + let args = st.args + args.size = p.size + // Keep the shrink range proportional when the word guard already lowered the ideal. + args.min-size = st.min-size * (p.size / st.size) + out.push(fit( it.body, w, calc.max(h, 1pt), - it.style.family, it.style.axes, + st.family, st.axes, align-to: align-to, - ..it.style.args, + natural-wdth: if p.wdth == none { 100 } else { p.wdth }, + ..args, )) } @@ -266,7 +341,7 @@ let rw = sa.width * (1.0 - COL-SPLIT - COL-GUTTER) let left-plan = col-plan(if split { head } else { items }, lw, gap) - let right-plan = if split { col-plan(facts, rw, gap) } else { (hs: (), need: 0pt) } + let right-plan = if split { col-plan(facts, rw, gap) } else { (plans: (), need: 0pt) } // The band is content-driven and then clamped: it never looks thinner than a band, // never eats more of the artwork than the format can spare, and never spills out of @@ -296,14 +371,14 @@ bottom + left, dx: sa.x, dy: -(sa.bleed + sa.safe), - col-render(if split { head } else { items }, left-plan.hs, lw, band-h, gap, left), + col-render(if split { head } else { items }, left-plan.plans, lw, band-h, gap, left), ) if split { place( bottom + right, dx: -(sa.bleed + sa.safe), dy: -(sa.bleed + sa.safe), - col-render(facts, right-plan.hs, rw, band-h, gap, right), + col-render(facts, right-plan.plans, rw, band-h, gap, right), ) } diff --git a/templates/lib.typ b/templates/lib.typ index 6310578..6515a0e 100644 --- a/templates/lib.typ +++ b/templates/lib.typ @@ -54,17 +54,105 @@ if s.len() in (4, 7, 9) { rgb(s) } else { fallback } } -/// The four spec colours, always complete. `ink_resolved` is the contrast-checked ink -/// the renderer computed; it wins over the art director's `palette.ink`. -#let palette-of(spec) = { +// --------------------------------------------------------------------------- +// Contrast +// +// Text has to be legible against whatever is ACTUALLY behind it. That surface differs +// per template: `hero-bottom` and `centred-stack` put type over the artwork, `framed` +// puts it on the flat page background, `split` on a colour panel. A single ink chosen +// once per poster cannot serve all three -- picking the artwork-measured ink for text +// sitting on a cream background yields white-on-cream at 1.15:1, which is unreadable. +// --------------------------------------------------------------------------- + +/// A colour component as a plain 0..1 float. Typst hands back ratios for rgb components. +#let _chan(v) = if type(v) == ratio { v / 100% } else { float(v) } + +/// sRGB -> linear. The linearisation matters: averaging raw 0-255 values overstates the +/// luminance of saturated colours and picks the wrong ink. +#let _linearise(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) } + +/// WCAG relative luminance, 0 (black) .. 1 (white). +#let luminance(col) = { + let c = _chan + let p = col.rgb().components() + 0.2126 * _linearise(c(p.at(0))) + 0.7152 * _linearise(c(p.at(1))) + 0.0722 * _linearise(c(p.at(2))) +} + +/// WCAG contrast ratio between two colours, 1.0 .. 21.0. +#let contrast-ratio(a, b) = { + let (x, y) = (luminance(a), luminance(b)) + let (hi, lo) = if x > y { (x, y) } else { (y, x) } + (hi + 0.05) / (lo + 0.05) +} + +/// Best ink for `ground` among `candidates`, falling back to plain black/white so this +/// can never return something illegible. Ties go to the earliest candidate, which keeps +/// the art director's chosen ink whenever it is good enough. +#let ink-on(ground, ..candidates) = { + let pool = candidates.pos() + (black, white) + let best = none + let best-ratio = 0.0 + for c in pool { + if type(c) == color { + let r = contrast-ratio(c, ground) + if r > best-ratio { best = c; best-ratio = r } + } + } + if best == none { black } else { best } +} + +/// WCAG AA for large text. Everything a poster sets is large, so 3.0 is the honest bar; +/// below it a colour is genuinely hard to read from across a piazza. +#let MIN-CONTRAST = 3.0 + +/// The four spec colours, always complete. +/// +/// `surface` says what the text will sit ON, and therefore which ink is correct: +/// * `auto` / `"art"` — over the artwork: use the ink the renderer MEASURED against the +/// picture (`ink_on_art`, or legacy `ink_resolved`). +/// * `"bg"` — on the flat page background. +/// * a colour — on that exact colour (a band, a panel). +/// Anything other than `auto`/`"art"` re-derives the ink by contrast, so a template that +/// moves its type onto flat colour cannot inherit an ink chosen for a photograph. +/// +/// `accent` is held to the same bar: an accent that fails against the surface is dropped +/// back to the ink rather than rendered unreadable. +#let palette-of(spec, surface: auto) = { let p = _get(spec, "palette", (:)) - let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111")) - ( - bg: hex(_get(p, "bg", none), fallback: rgb("#ffffff")), - ink: ink, - accent: hex(_get(p, "accent", none), fallback: ink), - scrim: hex(_get(p, "scrim", none), fallback: rgb("#000000")), + let declared = hex(_get(p, "ink", none), fallback: rgb("#111111")) + let measured = hex( + _get(spec, "ink_on_art", _get(spec, "ink_resolved", _get(p, "ink", none))), + fallback: rgb("#111111"), ) + let bg = hex(_get(p, "bg", none), fallback: rgb("#ffffff")) + let scrim = hex(_get(p, "scrim", none), fallback: rgb("#000000")) + let raw-accent = hex(_get(p, "accent", none), fallback: declared) + + let ground = if surface == auto or surface == "art" { none } + else if surface == "bg" { bg } + else if type(surface) == color { surface } + else if type(surface) == str and surface.starts-with("#") { hex(surface, fallback: bg) } + else { none } + + // Over artwork we trust the measurement; on a known flat colour we compute. + let ink = if ground == none { + measured + } else { + let explicit = hex(_get(spec, "ink_on_bg", none), fallback: none) + if surface == "bg" and explicit != none and contrast-ratio(explicit, ground) >= MIN-CONTRAST { + explicit + } else { + ink-on(ground, declared, measured) + } + } + + let accent = if ground != none and contrast-ratio(raw-accent, ground) < MIN-CONTRAST { + ink + } else { + raw-accent + } + + (bg: bg, ink: ink, accent: accent, scrim: scrim) } /// Font family for a role group: "display" for headlines, "body" for everything else. @@ -406,10 +494,10 @@ /// axes that family's variable axes, ready for `fit` /// upper whether the template should uppercase the text /// args spreadable into fit: `fit(body, w, h, st.family, st.axes, ..st.args)` -#let block-style(spec, role) = { +#let block-style(spec, role, surface: auto) = { let key = if type(role) == str and role in _ROLE-STYLES { role } else { "details" } let s = _ROLE-STYLES.at(key) - let pal = palette-of(spec) + let pal = palette-of(spec, surface: surface) let fill = if s.ink == "accent" { pal.accent } else if s.ink == "muted" { @@ -459,12 +547,12 @@ /// Text of the first block with `role`, uppercased when the role calls for it (pass /// `force-upper: true/false` to override). `none` when the role is absent or empty, so /// templates can write `if t != none { ... }` instead of guarding each field. -#let block-text(spec, role, force-upper: auto) = { +#let block-text(spec, role, force-upper: auto, surface: auto) = { let bs = blocks-of(spec, role) if bs.len() == 0 { return none } let t = _get(bs.at(0), "text", "") if type(t) != str or t.trim() == "" { return none } - let up = if force-upper == auto { block-style(spec, role).upper } else { force-upper } + let up = if force-upper == auto { block-style(spec, role, surface: surface).upper } else { force-upper } if up { upper(t) } else { t } } diff --git a/templates/split.typ b/templates/split.typ index 9422637..1ad4650 100644 --- a/templates/split.typ +++ b/templates/split.typ @@ -26,15 +26,40 @@ #let spec = json(sys.inputs.specfile) #set document(date: none) // load-bearing: this is what makes output byte-identical -#set text(lang: "it") // Italian hyphenation, for free -#set par(linebreaks: "optimized") // Knuth-Plass; `fit` sets it again on its own trials +#set text(lang: "it") // Italian hyphenation and quotes, for free +#set par(linebreaks: "optimized") // Knuth-Plass, for any paragraph outside `fit-text` + +// --------------------------------------------------------------------------- +// Two measured Typst 0.15.1 facts that this template has to defend against. +// Both were reproduced against the real binary with Archivo; both are invisible +// unless you look at pixels, which is exactly why they are written down here. +// +// (1) `linebreaks: "optimized"` emits OVERFULL lines for ragged display type. +// "SAGRA DELLA CASTAGNA E DELL'AUTUNNO IN PIAZZA" at 24pt in a 60mm box: +// simple -> 4 lines, 87.46pt tall, every line inside the box +// optimized -> 3 lines, 63.79pt tall, line 2 running ~90% of the box +// width past its right edge +// 4 of 6 realistic Italian headlines reproduced it; `justify` and +// `hyphenate` change nothing. So display type here pins "simple". +// +// (2) `measure(width: w, ..)` CLAMPS the width it reports to `w`. An unbreakable +// 940.24pt word measured at width 170.08pt reports 170.08pt. That is why (1) +// is silent: `fit`'s `m.width <= w` test can never fail, and because the +// overfull layout uses FEWER lines it also passes the height test. +// +// `fit-text` below is the whole response: it pins the line breaker on the block +// body — an explicit `par(linebreaks: ..)` field beats the `set par` inside +// `fit`, so `fit` measures and draws the same honest layout — and it caps the +// ideal size so the longest single word fits, which is the one overflow the +// clamp in (2) hides even from an honest breaker. +// --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Geometry // --------------------------------------------------------------------------- #let sa = safe-area(spec) -#let pal = palette-of(spec) +#let pal = palette-of(spec, surface: "bg") #let trim = trim-mm(spec) /// Landscape is the ONLY switch in this file. Square counts as portrait: a 4:5 ig-post @@ -140,7 +165,7 @@ if type(b) != dictionary { continue } let t = b.at("text", default: "") if type(t) != str or t.trim() == "" { continue } - let st = block-style(spec, b.at("role", default: none)) + let st = block-style(spec, b.at("role", default: none), surface: "bg") out.push(( role: st.role, body: if st.upper { upper(t) } else { t }, @@ -158,7 +183,7 @@ let fs = entries.filter(e => e.role == "footer") if fs.len() == 0 { none } else { fs.map(e => e.body).join(" · ") } } -#let foot-style = block-style(spec, "footer") +#let foot-style = block-style(spec, "footer", surface: "bg") /// Height of the bottom band: whichever is taller, the colophon or the logo sharing it. #let foot-text-h = if foot-body == none { 0pt } else { foot-style.size * 2.2 } @@ -278,6 +303,52 @@ // 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) = { + 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 } +} + +/// Auto-fit one block into `w` x `h` — the only way type is drawn in this template. +/// +/// Everything is delegated to lib's `fit`; the two things added here are the defences +/// described at the top of the file: +/// +/// * the body is handed over as an explicit `par(linebreaks: "simple", ..)`, whose own +/// 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. +#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 probe(word) = measure(text( + ..(if st.family != none { (font: st.family) } else { (:) }), + ..(if floor-wdth != none { (variations: (wdth: floor-wdth)) } else { (:) }), + size: 100pt, weight: st.weight, tracking: st.tracking, word, + )).width + let widest = words.fold(0pt, (a, word) => calc.max(a, probe(word))) + if widest > 0pt { cap = calc.min(cap, 100pt * (w / widest)) } + } + fit( + par(linebreaks: "simple", body), + w, h, st.family, st.axes, + align-to: align-to, + ..(st.args + (size: cap)), + ) +} + + /// The stack, vertically centred in the flow area. Centring rather than top-aligning is /// what makes a two-block spec look composed instead of abandoned: the panel is often /// far taller than the words that have to go in it (an ig-story panel is ~156 mm for as @@ -293,7 +364,7 @@ // 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, fit(e.body, panel.width, budget(e), st.family, st.axes, ..st.args)) + block(width: panel.width, fit-text(e.body, panel.width, budget(e), st)) } })) @@ -307,8 +378,7 @@ let a = if logo-on-panel-bottom and logo-left { right } else { left } place(bottom + left, dx: x, dy: -sa.y, box( width: w, - fit(foot-body, w, calc.max(foot-text-h, 1mm), foot-style.family, foot-style.axes, - align-to: a, ..foot-style.args), + fit-text(foot-body, w, calc.max(foot-text-h, 1mm), foot-style, align-to: a), )) } diff --git a/tests/contrast-check.typ b/tests/contrast-check.typ new file mode 100644 index 0000000..93e62f0 --- /dev/null +++ b/tests/contrast-check.typ @@ -0,0 +1,43 @@ +// Asserts that the kernel picks a legible ink for every surface a template can use. +// +// This is the regression guard for the blocker where the artwork-measured ink was used +// for text sitting on flat colour, producing white-on-cream at 1.15:1. +// +// typst compile tests/contrast-check.typ out.pdf --input specfile=/tests/fixtures/x.json --root . +// +// Compiles silently on success; panics with the offending ratio on failure. +#import "/templates/lib.typ": palette-of, contrast-ratio, MIN-CONTRAST + +#let spec = json(sys.inputs.specfile) +#let name = spec.at("slug", default: "?") + +#let problems = () + +// 1. Text on the flat page background must clear the bar. +#let on-bg = palette-of(spec, surface: "bg") +#let r-ink = contrast-ratio(on-bg.ink, on-bg.bg) +#if r-ink < MIN-CONTRAST { + problems.push("ink su bg = " + repr(r-ink)) +} + +// 2. The accent is held to the same bar - palette-of drops it back to ink if it fails, +// so a failure here means that substitution is broken. +#let r-acc = contrast-ratio(on-bg.accent, on-bg.bg) +#if r-acc < MIN-CONTRAST { + problems.push("accent su bg = " + repr(r-acc)) +} + +// 3. An arbitrary flat panel colour must also resolve to something legible. +#let on-accent = palette-of(spec, surface: on-bg.accent) +#let r-panel = contrast-ratio(on-accent.ink, on-bg.accent) +#if r-panel < MIN-CONTRAST { + problems.push("ink su pannello accent = " + repr(r-panel)) +} + +#if problems.len() > 0 { + panic("contrasto insufficiente [" + name + "]: " + problems.join("; ") + + " (minimo " + repr(MIN-CONTRAST) + ":1)") +} + +#set page(width: 40mm, height: 10mm, margin: 2mm) +ok diff --git a/tests/render-fixtures.sh b/tests/render-fixtures.sh index 4855ce9..b075559 100755 --- a/tests/render-fixtures.sh +++ b/tests/render-fixtures.sh @@ -50,6 +50,24 @@ echo echo "passati: $pass falliti: $fail" if [ "$fail" -gt 0 ]; then printf 'falliti: %s\n' "${failed[*]}"; exit 1; fi +# Contrast assertion: every surface must resolve to a legible ink (>= 3.0:1). +echo +echo "controllo contrasto:" +cbad=0 +for fx in "$ROOT"/tests/fixtures/*.json; do + fxname="$(basename "$fx" .json)" + if out=$("$TYPST" compile "$ROOT/tests/contrast-check.typ" "$OUT/contrast_${fxname}.pdf" \ + --input specfile="/tests/fixtures/${fxname}.json" --root "$ROOT" 2>&1); then + printf ' ok %s\n' "$fxname" + else + cbad=$((cbad+1)) + printf ' FAIL %s\n' "$fxname" + echo "$out" | grep -m1 'contrasto insufficiente' | sed 's/^/ /' + fi +done +[ "$cbad" -gt 0 ] && { echo "contrasto: $cbad fixture non conformi"; exit 1; } +echo + # Geometry assertion on the print fixtures: A3 trim must be exactly 297x420mm. python3 - "$OUT" <<'PY' import glob, re, sys, os