// 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), ), ) }