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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user