docs: README (Italian) and MIT licence with font carve-out

This commit is contained in:
mozempk
2026-08-27 09:06:58 +02:00
parent 2f7db0e4fd
commit d2f3d9fda2
6 changed files with 2549 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
// 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:
//
// #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. Sizes are expressed as fractions 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 the dictionary 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 }
}
/// The four spec colours, always complete. `ink_resolved` is the contrast-checked ink
/// the renderer computed; it wins over the art director's `palette.ink`.
#let palette-of(spec) = {
let p = _get(spec, "palette", (:))
let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111"))
(
bg: hex(_get(p, "bg", none), fallback: rgb("#ffffff")),
ink: ink,
accent: hex(_get(p, "accent", none), fallback: ink),
scrim: hex(_get(p, "scrim", none), fallback: rgb("#000000")),
)
}
/// Font family for a role group: "display" for headlines, "body" for everything else.
/// Returns `none` when the spec omits it, which callers pass straight through (Typst
/// then 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`, as `(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 deliberately excluded: every ratio in this
/// file is a fraction of the TRIM, so adding bleed 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 a portrait and a landscape poster 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 from 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()` arguments dictionary, skipping every key the caller left `none`
/// so we never hand Typst `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, returning the styled content.
///
/// `w` and `h` MUST be absolute lengths (mm/pt). Inside `page(background:)` or a
/// `layout()` block use `safe-area(spec)`'s mm keys; percentages cannot be measured.
///
/// Strategy, and the whole reason this helper exists: when `family` exposes a `wdth`
/// axis we bisect on WIDTH FIRST, keeping 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
/// - steps: bisection iterations (~12 is well past visual convergence)
#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,
steps: 12,
) = context {
let axes = if type(axes) == dictionary { axes } else { (:) }
let wdth = _axis-range(axes.at("wdth", default: none))
let floor = if min-size != none { min-size } else {
calc.max(6pt, size * 0.28)
}
let floor = calc.min(floor, size)
// One trial layout, measured inside the real box. `measure` with a region makes the
// paragraph wrap exactly as it will on the page, so multi-line titles are handled.
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, height: h, trial(sz, wd))
// 0.01pt slack absorbs the last bit of 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
if wdth != none and wdth.at(0) < wdth.at(1) {
let lo = calc.min(wdth.at(0), wdth.at(1)) // narrowest, most likely to fit
let hi = calc.max(wdth.at(0), wdth.at(1)) // widest, what we would rather keep
if fits(size, hi) {
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 that still fits at full 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 {
if wdth != none { final-wdth = wdth.at(0) }
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 it, then the text, then nothing else.
///
/// - height: band height (absolute length or a ratio of the container)
/// - colour: the wash colour — normally `palette.scrim`; `none` falls back to black
/// - angle: gradient direction. `none` -> 90deg, i.e. transparent at top, solid at the
/// bottom, which is what a bottom-anchored text band wants. Use 270deg for a top band.
///
/// The mid stop is deliberately not linear: a straight ramp reads as a grey haze across
/// the whole image, whereas holding the fade back until ~45 % keeps the artwork clean.
#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 that 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 mixing them is the classic bleed bug:
/// * `x`/`y`/`width`/`height` are offsets from the FULL page origin (top-left of the
/// bleed), which is exactly what `page(background:)` and `page(foreground:)`
/// resolve against.
/// * `fx`/`fy`/`fw`/`fh` are the 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 bleed = _dig(spec, ("page", "bleedMm"), 0)
let safe = _dig(spec, ("page", "safeMm"), 0)
// A safe margin can never eat more than 40 % of an edge, whatever the config says.
let safe = calc.max(0, calc.min(safe, calc.min(t.width, t.height) * 0.4))
let bleed = calc.max(0, bleed)
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, 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;
/// offset houses want them. Each mark runs from a trim corner outward to the page edge,
/// so it lives entirely in the bleed and never touches the trimmed piece. With no bleed
/// there is nowhere to put them and nothing is drawn.
///
/// Drawn in plain black: 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(dx: x, dy: y, line(length: b, angle: 0deg, stroke: s))
let vmark(x, y) = place(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 poster at any format or dpi. The title dominates by roughly 8x the
// footer, which is what makes a poster readable at distance 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: 0.000em, leading: 0.42em, upper: false, ink: "ink"),
date: (ratio: 0.046, kind: "body", weight: 700, tracking: 0.020em, leading: 0.40em, upper: true, ink: "accent"),
venue: (ratio: 0.036, kind: "body", weight: 500, tracking: 0.030em, leading: 0.45em, upper: true, ink: "ink"),
details: (ratio: 0.024, kind: "body", weight: 400, tracking: 0.010em, leading: 0.60em, upper: false, ink: "ink"),
price: (ratio: 0.032, kind: "body", weight: 700, tracking: 0.020em, leading: 0.45em, upper: false, ink: "accent"),
footer: (ratio: 0.016, kind: "body", weight: 400, tracking: 0.060em, leading: 0.70em, upper: true, ink: "muted"),
)
/// Typography for a block role. Unknown roles degrade to `details` rather than failing.
///
/// Returns:
/// ratio fraction of the short edge used for `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 straight into `fit`: `fit(body, w, h, st.family, st.axes, ..st.args)`
#let block-style(spec, role) = {
let key = if type(role) == str and role in _ROLE-STYLES { role } else { "details" }
let s = _ROLE-STYLES.at(key)
let pal = palette-of(spec)
let 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
// Titles may shrink hard; small roles must stay legible, so their floor is tight.
let min-size = if key == "title" { size * 0.32 } else if key == "subtitle" {
size * 0.55
} else { size * 0.8 }
(
role: key,
ratio: s.ratio,
size: size,
min-size: calc.max(5pt, 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: calc.max(5pt, 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 —
/// templates should treat an absent role as "draw nothing", not as 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)
}
/// The text of the first block with `role`, uppercased when the role calls for it.
/// `none` when the role is absent.
#let block-text(spec, role, upper: 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 upper == auto { block-style(spec, role).upper } else { 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. 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 it.
#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 scale = _get(logo, "scale", 0.12)
if type(scale) not in (int, float) { scale = 0.12 }
let scale = calc.max(0.02, calc.min(0.4, scale))
let w = sa.short-edge * scale
let inset = if margin != none { margin } else { sa.x }
let insety = if margin != none { margin } else { sa.y }
let spot = (
tl: (top + left, inset, insety),
tr: (top + right, -inset, insety),
bl: (bottom + left, inset, -insety),
br: (bottom + right, -inset, -insety),
).at(corner, default: (bottom + right, -inset, -insety))
place(
spot.at(0),
dx: spot.at(1),
dy: spot.at(2),
image(path, width: w, fit: "contain"),
)
}