Files
pi-imgen/templates/centred-stack.typ
T

374 lines
15 KiB
Typst

// centred-stack.typ — a centred, symmetrical type stack over full-bleed art.
//
// Voice: formal and classical. Teatro, opera, concerto, conferenza — the kind of piece
// where symmetry IS the design and any deliberate asymmetry would read as a mistake.
//
// Composition, top to bottom, all on one centred axis:
//
// ┌──────────────────────────┐
// │ full-bleed art │ art: fit "cover", never distorted
// │ ····· centre veil ···· │ scrim only when spec.needs_scrim
// │ T I T O L O │ display face, auto-fit
// │ ────────── │ short accent rule
// │ sottotitolo │ body face from here down
// │ DATA E ORARIO │
// │ LUOGO │
// │ dettagli, prezzo │
// │ │
// │ footer · logo │ footer pinned to the foot of the sheet
// └──────────────────────────┘
//
// Three rules this file obeys, in order of importance:
// 1. Nothing ever leaves the safe area. The stack is budgeted BEFORE it is typeset
// (see `cap` below), and `fit` only ever shrinks, so overflow is impossible rather
// than unlikely. That matters because a poster is checked once, at the printer.
// 2. Every measurement is a fraction of the trim's short edge, so the composition is
// re-solved per format instead of being a fixed layout that gets scaled.
// 3. Determinism: no dates, no randomness, no system fonts. See lib.typ's header.
//
// Coordinates: with `page(bleed:)` the BODY's origin is the trim's top-left, while
// `background:`/`foreground:` resolve against the full bleed page. Verified, not assumed.
// So body placement insets by `sa.safe`, and background art uses `sa.full-*`.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
// ---------------------------------------------------------------------------
// Geometry, palette, and the two knobs the aspect ratio turns
// ---------------------------------------------------------------------------
#let sa = safe-area(spec)
#let pal = palette-of(spec)
#let trim = trim-mm(spec)
#let se = short-edge-mm(spec) * 1mm // the scale reference for everything here
#let aspect = trim.width / trim.height
// fb-cover (2.47:1) and yt-thumb (1.78:1) have almost no vertical room, so the same
// centred stack has to be re-solved rather than scaled: a shorter measure, fewer lines
// per block, tighter gaps. Portrait and 4:5 keep the classical airy setting.
#let wide = aspect > 1.05
#let ultra-wide = aspect > 1.8
// Measure. A full-width line on a 289 mm fb-cover would be unreadable; a centred stack
// wants a column, not the whole sheet.
#let col = sa.width * (if ultra-wide { 0.70 } else if wide { 0.82 } else { 0.90 })
#let gap = se * (if wide { 0.020 } else { 0.030 })
#let rule-gap = gap * 1.30
#let rule-len = calc.min(col * 0.32, se * 0.20)
#let rule-w = calc.max(0.5pt, se * 0.0018)
// ---------------------------------------------------------------------------
// The centre veil — the "full-page scrim" this layout needs
// ---------------------------------------------------------------------------
/// A wash over the WHOLE page that is densest along the horizontal centre line, which is
/// exactly where a centred stack puts its type. Built from lib's `scrim` twice (each
/// half-page gradient runs solid-at-the-middle, transparent-at-its-edge) over a light
/// flat base, so the artwork stays visible at the top and bottom edges and the type
/// still sits on enough density to hold contrast.
///
/// Edge-anchored bands (the hero-bottom kind) are wrong here: they would darken exactly
/// the two strips this layout leaves empty and leave the headline unsupported.
#let centre-veil(sa, colour) = {
let half = sa.full-height / 2
place(top + left, rect(
width: sa.full-width, height: sa.full-height,
stroke: none, fill: colour.transparentize(66%),
))
place(top + left, scrim(half, colour, 90deg, width: sa.full-width, strength: 46%))
place(bottom + left, scrim(half, colour, 270deg, width: sa.full-width, strength: 46%))
}
// ---------------------------------------------------------------------------
// fit-checked — fit(), plus a guard for lines that refuse to break
// ---------------------------------------------------------------------------
/// `fit` with an overfull-line guard.
///
/// Why this exists. `fit` decides with `measure`, and a paragraph's measured width is
/// CLAMPED to the region it was measured in: when a line is too long to break, Typst
/// reports the region width and lays the line out anyway, running off the sheet. Worse,
/// the overfull layout uses FEWER lines, so it measures SHORTER — the bisection is
/// actively attracted to it. Verified at 70 pt in a 162 mm column with
/// "Sagra della Castagna e dell'Autunno in Piazza": every `wdth` from 82 upward renders
/// a line off both edges of the page while `fit` believes it fits.
///
/// The detector is the clamp itself: with ragged (unjustified) text the measured width
/// equals the region width only when a line had to be clamped, so `m.width < w` means
/// every line really did break. This is why every call here passes `justify: false` —
/// justified text always fills the measure and the signal would be lost.
///
/// The ladder concedes in the order the kernel prefers: first narrow the width axis
/// (`natural-wdth` down to the condensed end), and only then give up height, which is
/// what forces `fit` to shrink. The first rung is plain `fit` behaviour, so a title that
/// was never in trouble costs one extra measure and nothing else.
#let fit-checked(body, w, h, family, axes, ..args) = context {
let ladder = ((100, 1.0), (88, 1.0), (76, 1.0), (62, 1.0), (62, 0.78), (62, 0.58))
let chosen = none
for rung in ladder {
let c = fit(body, w, h * rung.at(1), family, axes, natural-wdth: rung.at(0), ..args)
chosen = c
if measure(width: w, c).width < w - 0.05pt { break }
}
chosen
}
// ---------------------------------------------------------------------------
// Blocks
// ---------------------------------------------------------------------------
/// Every block with usable text, in spec order. Blocks are rendered in the order the art
/// director wrote them; only the role decides the typography.
#let all-blocks = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { () } else {
bs.filter(b => {
if type(b) != dictionary { return false }
let t = _get(b, "text", none)
type(t) == str and t.trim() != ""
})
}
}
#let role-of(b) = {
let r = _get(b, "role", "details")
if type(r) == str { r } else { "details" }
}
/// The footer is lifted out of the stack and pinned to the foot of the sheet — the
/// classical position for a patrocinio/credit line, and it keeps the tiny type from
/// hanging off the bottom of an otherwise centred group.
#let footers = all-blocks.filter(b => role-of(b) == "footer")
#let stack-blocks = all-blocks.filter(b => role-of(b) != "footer")
// Relative appetite for vertical space. The title gets the lion's share; everything else
// is proportioned against it. Only used when space is short — see `cap`.
#let _WEIGHT = (
title: 5.0, subtitle: 2.0, date: 1.7, venue: 1.4, details: 2.2, price: 1.2, footer: 1.0,
)
// Hard ceiling on how many lines a role may occupy. Without it a spacious A3 would hand
// the title a 200 mm box and cheerfully set it on six lines.
#let _LINES = if wide {
(title: 2, subtitle: 2, date: 1, venue: 1, details: 2, price: 1, footer: 1)
} else {
(title: 3, subtitle: 2, date: 2, venue: 2, details: 4, price: 1, footer: 2)
}
#let weight-of(role) = _WEIGHT.at(role, default: 1.2)
/// Height of `lines` lines at this role's ideal size. `leading` is an em-relative length,
/// so it is resolved against the role's own size instead of the ambient text size.
#let lines-height(st, lines) = {
let lead = st.leading.em * st.size + st.leading.abs
st.size * 1.2 * lines + lead * calc.max(0, lines - 1)
}
/// The floor a block needs to still be worth setting: one line at its minimum size — two
/// for a title, because a headline broken to a single shrunken line is not a headline.
#let need-of(st) = {
st.min-size * (if st.role == "title" { 2.6 } else { 1.25 })
}
// ---------------------------------------------------------------------------
// Vertical budget
// ---------------------------------------------------------------------------
// Optical centring: the eye reads the centre of a sheet as slightly above the geometric
// centre, so the whole group is lifted. Implemented by shortening the centring box from
// the bottom, which also makes the lift self-clamping — the stack is budgeted against the
// SHORTENED box, so it can never be pushed past the safe edge at either end.
#let lift = sa.height * (if wide { 0.015 } else { 0.030 })
// The footer band, reserved out of the stack's height before anything is measured.
#let footer-style = block-style(spec, "footer")
#let footer-line-box = lines-height(footer-style, _LINES.footer)
// Glyphs paint a little past their line box — «@», an accented capital, a descender in a
// mixed-case credit line — so the footer sits one small allowance ABOVE the safe line
// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink
// below the safe edge on A3.
#let footer-descender = footer-style.size * 0.20
#let footer-band = if footers.len() == 0 { 0pt } else {
footer-line-box + gap * 1.2 + footer-descender
}
#let avail = sa.height - 2 * lift - footer-band
// A rule is drawn only where it means something: between the title and whatever follows.
#let has-rule = {
let roles = stack-blocks.map(role-of)
roles.contains("title") and roles.filter(r => r != "title").len() > 0
}
#let rule-extra = if has-rule { 2 * rule-gap + rule-w - gap } else { 0pt }
/// Drop `optional: true` blocks, last (least important) first, until what is left can be
/// set above its minimum sizes. In practice this almost never fires — the auto-fit
/// absorbs a crowded spec by narrowing and shrinking — which is the point: dropping a
/// block the user asked for is the last resort, not the first.
#let keep-blocks = {
let kept = stack-blocks
while kept.len() > 1 {
let n = kept.len()
let gaps = (n - 1) * gap + rule-extra
let pool = avail - gaps
let need = kept.map(b => need-of(block-style(spec, role-of(b)))).sum(default: 0pt)
if pool >= need { break }
// Least important droppable block = the last one flagged optional.
let idx = none
for (i, b) in kept.enumerate() {
if _get(b, "optional", false) == true { idx = i }
}
if idx == none { break }
kept = kept.slice(0, idx) + kept.slice(idx + 1)
}
kept
}
#let n-kept = keep-blocks.len()
#let gaps-total = if n-kept <= 1 { 0pt } else { (n-kept - 1) * gap + rule-extra }
#let pool = calc.max(0pt, avail - gaps-total)
#let weight-total = keep-blocks.map(b => weight-of(role-of(b))).sum(default: 1.0)
/// The box a block may fill: its share of the leftover height, never more than its line
/// ceiling. Because every block is capped and `fit` never grows past its box, the summed
/// stack cannot exceed `avail` — no post-hoc overflow check is needed anywhere below.
#let cap(st) = {
let share = pool * (weight-of(st.role) / weight-total)
calc.min(lines-height(st, _LINES.at(st.role, default: 2)), share)
}
// ---------------------------------------------------------------------------
// The stack
// ---------------------------------------------------------------------------
#let type-stack = {
let prev = none
for b in keep-blocks {
let role = role-of(b)
let st = block-style(spec, role)
// Spacing. The rule replaces the gap after the title band ends.
if prev != none {
if prev == "title" and role != "title" and has-rule {
v(rule-gap)
block(width: 100%, align(center, line(
length: rule-len,
stroke: rule-w + pal.accent,
)))
v(rule-gap)
} else {
v(gap)
}
}
// Uppercasing is the role's decision (lib's _ROLE-STYLES). Caps-only families such as
// Bebas Neue or Bungee draw capitals at lowercase codepoints, so a non-uppercased
// role still renders correctly with them — we just never rely on lowercase shapes.
let t = _get(b, "text", "")
let body = if st.upper { upper(t) } else { t }
// fit bisects the wdth axis first and the size only as a fallback, which keeps a long
// Italian title at full optical weight instead of quietly shrinking the poster.
block(width: 100%, fit-checked(
body, col, cap(st), st.family, st.axes,
..st.args,
align-to: center,
justify: false,
))
prev = role
}
}
// A bottom-corner logo shares the footer band, so the footer's measure is inset by the
// logo's width on BOTH sides — symmetric, because a centred footer nudged off-axis to
// dodge a logo is exactly the kind of near-miss this layout cannot afford.
#let footer-width = {
let logo = _get(spec, "logo", none)
let inset = if type(logo) != dictionary { 0pt } else {
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { 0pt } else {
let corner = _get(logo, "corner", "br")
if corner in ("bl", "br") {
let s = _get(logo, "scale", 0.12)
let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 }
sa.short-edge * s + gap
} else { 0pt }
}
}
calc.max(sa.width * 0.4, sa.width - 2 * inset)
}
#let footer-stack = {
for (i, b) in footers.enumerate() {
if i > 0 { v(gap * 0.4) }
let st = block-style(spec, "footer")
let t = _get(b, "text", "")
block(width: 100%, fit-checked(
if st.upper { upper(t) } else { t },
footer-width, footer-line-box, st.family, st.axes,
..st.args,
align-to: center,
justify: false,
))
}
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
#set document(date: none)
#set text(lang: "it", fill: pal.ink)
#set par(linebreaks: "optimized", justify: false)
#set page(
width: sa.trim-width,
height: sa.trim-height,
margin: 0pt,
bleed: sa.bleed,
fill: pal.bg,
background: {
// Full-bleed artwork. "cover" crops rather than distorts; the crop is centred, so a
// symmetrical layout keeps a symmetrical background.
let art = _get(spec, "art_file", none)
if type(art) == str and art.trim() != "" {
place(top + left, image(
art,
width: sa.full-width,
height: sa.full-height,
fit: "cover",
))
}
// ink_resolved was already contrast-checked upstream; needs_scrim is that check's
// verdict. Never second-guess either one here.
if _get(spec, "needs_scrim", false) == true {
centre-veil(sa, pal.scrim)
}
},
foreground: {
crop-marks(spec)
logo-place(spec)
},
)
// Body origin = trim top-left, so the safe inset is `sa.safe` alone.
#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe, box(
width: col,
height: avail,
align(center + horizon, type-stack),
))
#if footers.len() > 0 {
place(top + left,
dx: sa.safe + (sa.width - footer-width) / 2,
dy: sa.safe + sa.height - footer-band,
box(
width: footer-width,
height: footer-band - footer-descender,
align(center + bottom, footer-stack),
),
)
}