// 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 } // `strength` scales EVERY stop, not just the terminal one. Scaling only the last stop // leaves the intermediate stops at their full-strength alpha, so below ~70% strength // the 75% stop ends up denser than the "solid" end and the wash peaks a quarter of the // way in from the anchored edge instead of at it — two dark bands bracketing the type // instead of one clean ramp. `t` is the stop's share of full density; the shape of the // ramp (0 / 22 / 70 / 100) is unchanged, only its scale. let stop(t) = { let alpha = strength * t if alpha >= 100% { c } else { c.transparentize(100% - alpha) } } rect( width: width, height: height, stroke: none, fill: gradient.linear( (stop(0.0), 0%), (stop(0.22), 45%), (stop(0.70), 75%), (stop(1.0), 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")) }