fix(render): resolve ink contrast per surface, not once per poster

templates/lib.typ gains WCAG luminance/contrast-ratio/ink-on and a
surface-aware palette-of(). framed and split place type on flat colour and
were inheriting an ink measured against the artwork, rendering white on
cream at 1.15:1. Now computed per surface: 18.34:1 on the regression
fixture. Accent held to the same bar with fallback to ink.

Adds tests/contrast-check.typ, run per fixture by the harness.
35/35 render combos and 7/7 contrast assertions pass.
This commit is contained in:
mozempk
2026-08-27 09:33:08 +02:00
parent aad8a7fbdf
commit 5d27417c54
11 changed files with 1476 additions and 200 deletions
+1 -1
View File
@@ -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/<id>/` e **non** sono versionati.
Ogni cartella contiene il file `LICENSE` originale.
+32 -3
View File
@@ -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
+745
View File
@@ -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<SocialRenderOutcome>;
}
/** The two director calls /social can make. Both are optional at runtime. */
export interface SocialDirector {
parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<ParsedBrief>;
generateCaption(
spec: DesignSpec,
brief: Brief,
ctx: DirectorContext,
cfg?: ImgenConfig,
): Promise<CaptionResult>;
}
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<SocialResult> {
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<string, string> = {};
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<void> {
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<Picked> {
// 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<string, number>();
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<SocialRequest, "newTitle" | "newDate">;
/**
* "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<Edits | undefined> {
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<string | undefined> {
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<void> {
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());
}
+73 -7
View File
@@ -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),
)
+37 -3
View File
@@ -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,
+237 -129
View File
@@ -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)
+111 -36
View File
@@ -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),
)
}
+101 -13
View File
@@ -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 }
}
+78 -8
View File
@@ -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),
))
}
+43
View File
@@ -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
+18
View File
@@ -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