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

465 lines
20 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`), each block is auto-fitted into its share and checked for lines that
// refused to break (see `fit-checked`), and the bands a logo needs are reserved out
// of the height first. Overflow is made impossible, not unlikely — a poster gets
// 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 })
// Base rhythm. The gap actually used is this plus whatever genuine slack the format
// leaves over (see `gap` below) — an A3 is not just a big Instagram post, it has room to
// breathe and a classical setting should use it.
#let gap-base = se * (if wide { 0.020 } else { 0.030 })
#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 whose density peaks on `focus` — the horizontal line the
/// type stack is centred on, measured from the top of the FULL (bleed) page.
///
/// Built from a flat base plus lib's `scrim` twice, each gradient running solid at
/// `focus` and transparent at its own end of the sheet. The peak follows the type rather
/// than sitting at the geometric centre, because this layout lifts its stack for optical
/// centring and reserves a footer band — put the peak at 50% and the title, the one
/// element that must survive a glance from across the foyer, gets the thinnest cover.
///
/// 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, focus) = {
let f = calc.max(1mm, calc.min(focus, sa.full-height - 1mm))
place(top + left, rect(
width: sa.full-width, height: sa.full-height,
stroke: none, fill: colour.transparentize(58%),
))
place(top + left, scrim(f, colour, 90deg, width: sa.full-width, strength: 42%))
place(top + left, dy: f, scrim(
sa.full-height - f, colour, 270deg, width: sa.full-width, strength: 42%,
))
}
// ---------------------------------------------------------------------------
// 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 exactly the region width and lays the line out anyway, running off the sheet.
/// Two ways that bites, both verified against typst 0.15.1 and Archivo:
///
/// * "Sagra della Castagna e dell'Autunno in Piazza", 70 pt in a 162 mm column: every
/// `wdth` from 82 upward puts "CASTAGNA E DELL'AUTUNNO" on one unbroken line, off
/// both edges of the page — and that layout uses FEWER lines, so it measures
/// SHORTER than the correct one and the bisection is actively drawn to it.
/// * A single word wider than the column ("SANT'AMBROGIOCASTELFRANCHIMONTECATINI"):
/// the clamped width is exactly the column at EVERY size, so `fit` never sees a
/// reason to shrink and returns the headline at full size, three times too wide.
///
/// The detector is the clamp itself: with ragged (unjustified) text the measured width
/// reaches 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 concessions are made in the kernel's own order. First narrow the width axis
/// (`natural-wdth` walked down towards the condensed end), which costs the poster
/// nothing. Only if that fails do we measure the longest word ourselves and cap the
/// size so it fits — the one case the width axis cannot rescue. The first rung is plain
/// `fit` behaviour, so a block that was never in trouble costs one extra measure.
#let fit-checked(body, w, h, family, axes, ..args) = context {
let named = args.named()
let size = named.at("size", default: 72pt)
let ax = if type(axes) == dictionary { axes } else { (:) }
let range = _axis-range(ax.at("wdth", default: none))
let narrowest = if range == none { 100 } else { calc.min(range.at(0), range.at(1)) }
let chosen = none
let clear = false
for nw in (100, 88, 76, narrowest) {
let c = fit(body, w, h, family, axes, natural-wdth: nw, ..args)
chosen = c
if measure(width: w, c).width < w - 0.05pt {
clear = true
break
}
}
if not clear {
// Widest unbreakable run, measured at the narrowest width the fit may use: `fit`
// can only break at spaces, so this is the real floor on the column.
let words = if type(body) == str { body.split(" ") } else { () }
let widest = 0pt
for word in words {
if word.trim() == "" { continue }
let m = measure(text(
.._text-args(family, size, named.at("weight", default: none),
named.at("tracking", default: none), none, narrowest),
word,
))
if m.width > widest { widest = m.width }
}
if widest > w {
let capped = named
// 0.98 leaves room for the rounding in the bisection; without it the fit lands
// exactly ON the column edge, which is where hairline overflows live.
capped.insert("size", size * (w / widest) * 0.98)
chosen = fit(body, w, h, family, axes, natural-wdth: narrowest, ..capped)
}
}
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 })
// Room for the logo. `logo-place` sizes it to a fraction of the trim's short edge and
// insets it to the safe area, so its box is predictable even though its aspect ratio is
// not — the square case is assumed, which is the worst one. The stack then gives up a
// band at that end of the sheet, because a centred column is wide enough to run straight
// through a corner mark.
#let logo-side = {
let logo = _get(spec, "logo", none)
if type(logo) != dictionary { 0pt } else {
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { 0pt } else {
let raw = _get(logo, "scale", 0.12)
let scale = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 }
sa.short-edge * scale
}
}
}
#let logo-corner = {
let c = _dig(spec, ("logo", "corner"), "br")
if type(c) == str and c in ("tl", "tr", "bl", "br") { c } else { "br" }
}
#let logo-at-top = logo-side > 0pt and logo-corner in ("tl", "tr")
#let logo-at-foot = logo-side > 0pt and logo-corner in ("bl", "br")
#let top-reserve = if logo-at-top { logo-side + gap-base } else { 0pt }
#let logo-reserve = if logo-at-foot { logo-side + gap-base } else { 0pt }
// The footer band. A foot-corner logo shares it when there is measure enough left beside
// it: the footer's column is inset by the logo width on BOTH sides — symmetric, because a
// centred footer nudged off-axis to dodge a logo is exactly the near-miss this layout
// cannot afford. When the logo is big enough to leave no usable measure (the schema
// allows 0.4 of the short edge) the footer gives up the corner instead and sits above it.
// A smaller stack is a compromise; a credit line printed across a logo is a defect.
#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-clear = if logo-at-foot { sa.width - 2 * (logo-side + gap-base) } else { sa.width }
#let footer-shares = not logo-at-foot or footer-clear >= sa.width * 0.45
#let footer-width = if footer-shares { footer-clear } else { sa.width }
#let footer-content = if footers.len() == 0 { 0pt } else {
footer-line-box + footer-descender + gap-base * 1.2
}
#let footer-lift = if footers.len() == 0 or footer-shares { 0pt } else { logo-reserve }
// The band is whichever is taller: the footer's own needs, or the clearance the logo
// wants. With no footer and no logo it is nothing at all.
#let footer-band = calc.max(footer-content + footer-lift, logo-reserve)
// What is left for the stack. Clamped: a logo at the schema's maximum scale on the
// shortest format could otherwise claim more than the sheet has, and a negative box
// height is a broken render rather than a cramped one.
#let avail = calc.max(sa.height * 0.25, sa.height - 2 * lift - footer-band - top-reserve)
// 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-base = if has-rule { 2 * gap-base * 1.30 + rule-w - gap-base } 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-base + rule-extra-base
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()
/// The tallest this block will ever be: its line ceiling at its ideal size.
#let line-cap(st) = lines-height(st, _LINES.at(st.role, default: 2))
// Slack = the height the format offers that the type cannot use, because every block is
// already at its line ceiling. Rather than leave it as one dead area below the group,
// part of it goes back into the gaps: the same stack, set more openly. Only PART, and
// never more than doubling a gap, so the stack stays one group instead of drifting into
// unrelated fragments — and the fraction is small enough that the widened gaps still
// cannot push the budget past `avail`.
#let gaps-base-total = if n-kept <= 1 { 0pt } else { (n-kept - 1) * gap-base + rule-extra-base }
#let slack = calc.max(0pt, avail - gaps-base-total
- keep-blocks.map(b => line-cap(block-style(spec, role-of(b)))).sum(default: 0pt))
#let gap = if n-kept <= 1 { gap-base } else {
gap-base + calc.min(gap-base * 1.2, slack * 0.45 / (n-kept + 1))
}
#let rule-gap = gap * 1.30
#let rule-extra = if has-rule { 2 * rule-gap + rule-w - gap } else { 0pt }
#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(line-cap(st), 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
}
}
#let footer-stack = {
for (i, b) in footers.enumerate() {
if i > 0 { v(gap * 0.4) }
let st = footer-style
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 {
// Background coordinates include the bleed; the stack's centre is a body (trim)
// coordinate, so it is offset by one bleed to line the two up.
centre-veil(sa, pal.scrim, sa.bleed + sa.safe + top-reserve + avail / 2)
}
},
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 + top-reserve, 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 - footer-lift,
align(center + bottom, footer-stack),
),
)
}