Files
pi-imgen/templates/lib.typ
T
mozempk aee92d284c cleanup: clear stale TODOs, tighten types, translate art-prompt hints
Every 'being written in parallel' / 'ASSUMED SIGNATURE' TODO is gone: each
referenced module now exists and each assumption was checked against it
rather than the comment simply deleted.

- logo/presets/retouch: renderer typed as the real Renderer contract instead
  of unknown / (...args: never[]).
- director: the ModelRegistry assumption is VERIFIED against the installed
  @earendil-works types (find() on ModelRegistry, getModel() on ModelRuntime).
- strings: the img2imgBug TODO had it backwards -- drawthings raises a graded
  message a static string cannot express, and this is the fallback.
- poster: an Italian hint was being spliced into an English art prompt, which
  degrades these models. New director.refineArtPrompt() folds the note in via
  the LLM and falls back to the old splice on any failure, so it can only
  improve on the previous behaviour. scrubArtPrompt still runs either way.

Remaining TODOs are one category only (Italian strings living in per-file
tables rather than ui/strings.ts) and are accurate, not stale.
2026-08-27 10:30:54 +02:00

605 lines
25 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 }
// `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"))
}