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.
596 lines
24 KiB
Typst
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"))
|
|
}
|