Files
pi-imgen/templates/lib.typ
T
mozempk 5d27417c54 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.
2026-08-27 09:33:08 +02:00

596 lines
24 KiB
Typst

// lib.typ — shared kernel imported by every template in templates/.
//
// Templates are fixed and reviewed; the LLM never writes Typst. Everything here reads
// the ResolvedSpec (see extensions/imgen/design/spec.ts) as DATA, loaded once by the
// template with:
//
// #import "lib.typ": *
// #let spec = json(sys.inputs.specfile)
//
// Two invariants this file must never break:
// 1. Determinism. No randomness, no dates, no system fonts, no wall clock. Combined
// with --ignore-system-fonts + SOURCE_DATE_EPOCH + `#set document(date: none)`
// the output is byte-identical across runs, which is what makes the golden-file
// tests meaningful.
// 2. Resolution independence. Every size is a fraction of the TRIM, so the same
// template is correct at 1080 px and at 300 dpi A3.
//
// Colour note: this is an RGB pipeline on purpose. Never introduce cmyk() — its
// gradients render wrong (typst#4422) and CMYK ICC images produce PDFs Acrobat
// refuses to open (typst#3781).
// ---------------------------------------------------------------------------
// Internal helpers — tolerant readers for a spec whose optional fields may be
// missing entirely, or present but JSON `null` (which reaches Typst as `none`).
// ---------------------------------------------------------------------------
/// Read `key` from dictionary `d`, falling back to `default` when `d` is not a
/// dictionary, the key is absent, or the value is `none`.
#let _get(d, key, default) = {
if type(d) != dictionary { return default }
let v = d.at(key, default: default)
if v == none { default } else { v }
}
/// Read a nested path, e.g. `_dig(spec, ("page", "bleedMm"), 0)`. Same tolerance as
/// `_get` at every level, so a missing `page` object degrades instead of panicking.
#let _dig(d, path, default) = {
let cur = d
for key in path {
if type(cur) != dictionary { return default }
cur = cur.at(key, default: none)
if cur == none { return default }
}
cur
}
/// Parse a spec hex string into a colour. Anything unparseable falls back rather than
/// killing the render: a wrong colour is recoverable, a failed poster is not.
#let hex(v, fallback: black) = {
if type(v) == color { return v }
if type(v) != str { return fallback }
let s = v.trim()
if not s.starts-with("#") { return fallback }
if s.len() in (4, 7, 9) { rgb(s) } else { fallback }
}
// ---------------------------------------------------------------------------
// 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 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.
/// Returns `none` when the spec omits it — callers pass that straight through, so Typst
/// keeps the inherited family instead of erroring on `font: none`.
#let font-of(spec, kind) = {
let v = _dig(spec, ("fonts", kind), none)
if type(v) == str and v.trim() != "" { v } else { none }
}
/// Variable-font axes usable for `family`, e.g. `(wdth: (62, 125), wght: (100, 900))`.
/// Optional: the renderer MAY inject `spec.font_axes` (family -> axis ranges) from
/// design/fonts.ts. Absent, this returns `(:)` and `fit` simply bisects on size.
#let font-axes(spec, family) = {
if type(family) != str { return (:) }
let table = _get(spec, "font_axes", (:))
if type(table) != dictionary { return (:) }
let a = table.at(family, default: (:))
if type(a) == dictionary { a } else { (:) }
}
/// Trim size in mm, as plain numbers. Bleed is deliberately excluded: every ratio here
/// is a fraction of the TRIM, so turning bleed on never changes how big the title is.
#let trim-mm(spec) = (
width: _dig(spec, ("page", "widthMm"), 210),
height: _dig(spec, ("page", "heightMm"), 297),
)
/// Short edge of the trim, in mm — the scale reference for every type ratio and for the
/// logo, so portrait and landscape get proportionally the same headline.
#let short-edge-mm(spec) = {
let t = trim-mm(spec)
calc.min(t.width, t.height)
}
/// Normalise an axis range out of the spec: `(62, 125)`, `(min: 62, max: 125)` or a
/// bare number all become a `(lo, hi)` pair; anything else becomes `none`.
#let _axis-range(a) = {
if a == none { return none }
if type(a) == array and a.len() >= 2 { return (a.at(0), a.at(1)) }
if type(a) == dictionary {
let lo = a.at("min", default: a.at("lo", default: none))
let hi = a.at("max", default: a.at("hi", default: none))
if lo != none and hi != none { return (lo, hi) }
}
if type(a) in (int, float) { return (a, a) }
none
}
/// Build the `text()` argument dictionary, skipping every key the caller left `none` so
/// Typst is never handed `font: none` or an empty `variations`.
#let _text-args(family, size, weight, tracking, fill, wdth) = {
let args = (size: size)
if family != none { args.insert("font", family) }
if weight != none { args.insert("weight", weight) }
if tracking != none { args.insert("tracking", tracking) }
if fill != none { args.insert("fill", fill) }
if wdth != none { args.insert("variations", (wdth: wdth)) }
args
}
// ---------------------------------------------------------------------------
// fit — auto-fit text into a box
// ---------------------------------------------------------------------------
/// Fit `body` into a `w` x `h` box and return the styled content.
///
/// `w` and `h` MUST be absolute lengths (mm/pt). Inside `page(background:)` or a
/// `layout()` block use the mm keys of `safe-area(spec)`; a ratio cannot be measured.
///
/// Strategy, and the whole reason this helper exists: when `family` exposes a `wdth`
/// axis we bisect on WIDTH FIRST, holding the requested size and narrowing the letters
/// until the line fits. Narrowing preserves a poster's optical weight — the headline
/// still fills its band and still reads from across the piazza. Shrinking destroys it:
/// a 40% smaller title is a 40% quieter poster. Only once the axis bottoms out at its
/// minimum do we fall back to bisecting the size.
///
/// - body: content to typeset
/// - w, h: absolute box size
/// - family: font family string, or `none` to inherit
/// - axes: axis ranges for that family, e.g. `(wdth: (62, 125))`; `none`/`(:)` is fine
/// - size: the ideal (maximum) size — the fit never grows past it
/// - min-size: hard floor; defaults to 28% of `size`, never below 6pt
/// - natural-wdth: the width the fit starts from and never exceeds. 100 is the default
/// instance of every variable family in the bundled set, and it is a CEILING, not a
/// target: Archivo runs to wdth 125, but auto-EXPANDING a short title is a decision
/// the art director never asked for. Raise it only in a template that wants it.
/// - min-wdth: optional floor tighter than the axis minimum, for families whose extreme
/// condensed end is unreadable at small sizes.
/// - steps: bisection iterations (12 is well past visual convergence: 1/4096 of range)
#let fit(
body,
w,
h,
family,
axes,
size: 72pt,
min-size: none,
weight: none,
tracking: none,
fill: none,
leading: 0.34em,
justify: false,
align-to: left,
natural-wdth: 100,
min-wdth: none,
steps: 12,
) = context {
let ax = if type(axes) == dictionary { axes } else { (:) }
let wdth = _axis-range(ax.at("wdth", default: none))
let raw-floor = if min-size != none { min-size } else { calc.max(6pt, size * 0.28) }
let floor = calc.min(raw-floor, size)
// One trial layout. `measure` is given the real box width so the paragraph wraps
// exactly as it will on the page — multi-line titles are handled, not guessed at.
// Only the width is constrained: a height-constrained region could report a clipped
// height and make every candidate look like it fits.
let trial(sz, wd) = {
set par(leading: leading, linebreaks: "optimized", justify: justify)
set align(align-to)
text(.._text-args(family, sz, weight, tracking, fill, wd), body)
}
let fits(sz, wd) = {
let m = measure(width: w, trial(sz, wd))
// 0.01pt of slack absorbs floating-point noise in the bisection.
m.width <= w + 0.01pt and m.height <= h + 0.01pt
}
let final-size = size
let final-wdth = none
// Clamp the usable axis range: never wider than the family's natural instance, never
// narrower than the caller's floor. `hi` is where we would rather stay, `lo` is the
// concession we are willing to make before touching the size.
let axis-lo = if wdth == none { none } else { calc.min(wdth.at(0), wdth.at(1)) }
let axis-hi = if wdth == none { none } else { calc.max(wdth.at(0), wdth.at(1)) }
let lo = if wdth == none { none } else if min-wdth == none { axis-lo } else {
calc.max(axis-lo, min-wdth)
}
let hi = if wdth == none { none } else {
calc.max(lo, calc.min(axis-hi, natural-wdth))
}
if wdth != none and lo < hi {
if fits(size, hi) {
// Fits at full width and full size — nothing to concede.
final-wdth = hi
} else if not fits(size, lo) {
// The axis bottomed out: stay narrow and buy the rest back by shrinking.
final-wdth = lo
let slo = floor
let shi = size
let i = 0
while i < steps {
let mid = (slo + shi) / 2
if fits(mid, lo) { slo = mid } else { shi = mid }
i += 1
}
final-size = slo
} else {
// Widest width that still fits at the full requested size.
let i = 0
while i < steps {
let mid = (lo + hi) / 2
if fits(size, mid) { lo = mid } else { hi = mid }
i += 1
}
final-wdth = lo
}
} else {
// No usable wdth axis, or one the clamps collapsed to a point: bisect on size alone,
// pinning the axis to that point so the family still renders at its natural width.
if wdth != none { final-wdth = hi }
if not fits(size, final-wdth) {
let slo = floor
let shi = size
let i = 0
while i < steps {
let mid = (slo + shi) / 2
if fits(mid, final-wdth) { slo = mid } else { shi = mid }
i += 1
}
final-size = slo
}
}
trial(final-size, final-wdth)
}
// ---------------------------------------------------------------------------
// scrim — the gradient wash that buys legibility over busy artwork
// ---------------------------------------------------------------------------
/// A gradient rectangle drawn BEHIND a text band: opaque at the band's outer edge,
/// fading to nothing over the artwork. Draw the scrim, then the text, then stop.
///
/// - height: band height (absolute length, or a ratio of the container)
/// - colour: the wash — normally `palette.scrim`; `none` falls back to black
/// - angle: gradient direction. `none` -> 90deg, i.e. transparent at the top and solid
/// at the bottom, which is what a bottom-anchored band wants. 270deg for a top band.
///
/// The stops are deliberately non-linear: a straight ramp reads as a grey haze over the
/// whole image, whereas holding the fade back until ~45% keeps the artwork clean and
/// still lands full density under the type.
#let scrim(height, colour, angle, width: 100%, strength: 100%) = {
let c = if colour == none { black } else { hex(colour, fallback: black) }
let a = if angle == none { 90deg } else { angle }
let solid = if strength >= 100% { c } else { c.transparentize(100% - strength) }
rect(
width: width,
height: height,
stroke: none,
fill: gradient.linear(
(c.transparentize(100%), 0%),
(c.transparentize(78%), 45%),
(c.transparentize(30%), 75%),
(solid, 100%),
angle: a,
),
)
}
// ---------------------------------------------------------------------------
// safe-area — the usable rect
// ---------------------------------------------------------------------------
/// The rect content may occupy: the trim, inset by the safe margin.
///
/// Returns absolute mm lengths AND fractions, because the two live in different
/// coordinate systems and confusing them is the classic bleed bug:
/// * `x` / `y` / `width` / `height` are offsets from the FULL page origin (the
/// top-left of the bleed), which is exactly what `page(background:)` and
/// `page(foreground:)` resolve against.
/// * `fx` / `fy` / `fw` / `fh` are that same rect as fractions of the FULL page, for
/// percentage-based placement.
/// * `tw` / `th` are the safe rect as fractions of the TRIM — the resolution-
/// independent numbers to build layout ratios from.
#let safe-area(spec) = {
let t = trim-mm(spec)
let raw-bleed = _dig(spec, ("page", "bleedMm"), 0)
let raw-safe = _dig(spec, ("page", "safeMm"), 0)
let bleed = calc.max(0, raw-bleed)
// A safe margin can never eat more than 40% of an edge, whatever the config says.
let safe = calc.max(0, calc.min(raw-safe, calc.min(t.width, t.height) * 0.4))
let full-w = t.width + 2 * bleed
let full-h = t.height + 2 * bleed
let inset = bleed + safe
let usable-w = t.width - 2 * safe
let usable-h = t.height - 2 * safe
(
// absolute, measured from the full-page origin
x: inset * 1mm,
y: inset * 1mm,
width: usable-w * 1mm,
height: usable-h * 1mm,
// the boxes this rect sits inside
trim-width: t.width * 1mm,
trim-height: t.height * 1mm,
full-width: full-w * 1mm,
full-height: full-h * 1mm,
bleed: bleed * 1mm,
safe: safe * 1mm,
short-edge: calc.min(t.width, t.height) * 1mm,
// fractions of the full page (bleed included)
fx: inset / full-w * 100%,
fy: inset / full-h * 100%,
fw: usable-w / full-w * 100%,
fh: usable-h / full-h * 100%,
// fractions of the trim — the stable numbers for layout ratios
tw: usable-w / t.width * 100%,
th: usable-h / t.height * 100%,
)
}
// ---------------------------------------------------------------------------
// crop-marks
// ---------------------------------------------------------------------------
/// Eight short rules marking the trim corners, for `page(foreground: ...)`.
///
/// Off by default (`spec.page.cropMarks`), because web-to-print services reject them
/// while offset houses want them. Each mark runs from a trim corner outward to the page
/// edge, so it lives entirely inside the bleed and never touches the trimmed piece.
/// With no bleed there is nowhere to put them and nothing is drawn.
///
/// Plain black on purpose: this is an RGB pipeline, and cmyk() registration black is
/// exactly the kind of colour that breaks the PDF (see the header note).
#let crop-marks(spec) = {
if not _dig(spec, ("page", "cropMarks"), false) { return none }
let bleed = _dig(spec, ("page", "bleedMm"), 0)
if bleed <= 0 { return none }
let t = trim-mm(spec)
let b = bleed * 1mm
let tw = t.width * 1mm
let th = t.height * 1mm
let s = 0.25pt + black
let hmark(x, y) = place(top + left, dx: x, dy: y, line(length: b, angle: 0deg, stroke: s))
let vmark(x, y) = place(top + left, dx: x, dy: y, line(length: b, angle: 90deg, stroke: s))
hmark(0pt, b) // top-left, horizontal
vmark(b, 0pt) // top-left, vertical
hmark(b + tw, b) // top-right, horizontal
vmark(b + tw, 0pt) // top-right, vertical
hmark(0pt, b + th) // bottom-left, horizontal
vmark(b, b + th) // bottom-left, vertical
hmark(b + tw, b + th) // bottom-right, horizontal
vmark(b + tw, b + th) // bottom-right, vertical
}
// ---------------------------------------------------------------------------
// block-style — role to typography
// ---------------------------------------------------------------------------
// Ratios are fractions of the TRIM'S SHORT EDGE, so a headline is the same physical
// fraction of the piece at any format or dpi. The title dominates the footer by roughly
// 8x, which is what makes a poster readable from across the street and legal up close.
#let _ROLE-STYLES = (
title: (ratio: 0.130, kind: "display", weight: 800, tracking: -0.015em, leading: 0.30em, upper: true, ink: "ink"),
subtitle: (ratio: 0.052, kind: "display", weight: 500, tracking: 0em, leading: 0.42em, upper: false, ink: "ink"),
date: (ratio: 0.046, kind: "body", weight: 700, tracking: 0.02em, leading: 0.40em, upper: true, ink: "accent"),
venue: (ratio: 0.036, kind: "body", weight: 500, tracking: 0.03em, leading: 0.45em, upper: true, ink: "ink"),
details: (ratio: 0.024, kind: "body", weight: 400, tracking: 0.01em, leading: 0.60em, upper: false, ink: "ink"),
price: (ratio: 0.032, kind: "body", weight: 700, tracking: 0.02em, leading: 0.45em, upper: false, ink: "accent"),
footer: (ratio: 0.016, kind: "body", weight: 400, tracking: 0.06em, leading: 0.70em, upper: true, ink: "muted"),
)
/// Typography for a block role. Unknown roles degrade to `details` rather than failing.
///
/// Returns:
/// role the role actually used (after the unknown-role fallback)
/// ratio fraction of the short edge behind `size`
/// size absolute ideal size — the maximum `fit` will use
/// min-size floor for the auto-fit
/// family resolved family name, or `none` to inherit
/// 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, 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, surface: surface)
let fill = if s.ink == "accent" {
pal.accent
} else if s.ink == "muted" {
pal.ink.transparentize(30%)
} else {
pal.ink
}
let family = font-of(spec, s.kind)
let size = short-edge-mm(spec) * s.ratio * 1mm
// A title may shrink hard when the auto-fit runs out of width axis; the small roles
// must stay legible, so their floor is deliberately tight.
let shrink = if key == "title" { 0.32 } else if key == "subtitle" { 0.55 } else { 0.8 }
let min-size = calc.max(5pt, size * shrink)
(
role: key,
ratio: s.ratio,
size: size,
min-size: min-size,
family: family,
axes: font-axes(spec, family),
weight: s.weight,
tracking: s.tracking,
leading: s.leading,
fill: fill,
upper: s.upper,
args: (
size: size,
min-size: min-size,
weight: s.weight,
tracking: s.tracking,
leading: s.leading,
fill: fill,
),
)
}
/// Every block with the given role, in spec order. Returns `()` when there are none —
/// an absent role means "draw nothing", never an error.
#let blocks-of(spec, role) = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { return () }
bs.filter(b => type(b) == dictionary and _get(b, "role", none) == role)
}
/// 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, 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, surface: surface).upper } else { force-upper }
if up { upper(t) } else { t }
}
// ---------------------------------------------------------------------------
// logo-place
// ---------------------------------------------------------------------------
/// Place `spec.logo` in its corner, sized to `spec.logo.scale` of the trim's short
/// edge, inset to the safe area. Meant for `page(foreground: ...)`, whose origin is the
/// FULL page including bleed — which is why the inset is bleed + safe, not just safe.
///
/// Optional throughout: no `logo`, no `path`, or an empty path all draw nothing.
/// NOTE: `path` must be root-relative (leading "/"). Typst resolves image paths against
/// `--root`, not against the filesystem root, so the renderer rewrites the spec path.
#let logo-place(spec, margin: none) = {
let logo = _get(spec, "logo", none)
if type(logo) != dictionary { return none }
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { return none }
let sa = safe-area(spec)
let corner = _get(logo, "corner", "br")
let raw-scale = _get(logo, "scale", 0.12)
let scale = if type(raw-scale) in (int, float) {
calc.max(0.02, calc.min(0.4, raw-scale))
} else { 0.12 }
let w = sa.short-edge * scale
let dx = if margin != none { margin } else { sa.x }
let dy = if margin != none { margin } else { sa.y }
let spot = (
tl: (top + left, dx, dy),
tr: (top + right, -dx, dy),
bl: (bottom + left, dx, -dy),
br: (bottom + right, -dx, -dy),
).at(corner, default: (bottom + right, -dx, -dy))
place(spot.at(0), dx: spot.at(1), dy: spot.at(2), image(path, width: w, fit: "contain"))
}