334 lines
13 KiB
Typst
334 lines
13 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%))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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-band = if footers.len() == 0 { 0pt } else {
|
|
lines-height(footer-style, _LINES.footer) + gap * 1.2
|
|
}
|
|
|
|
#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(
|
|
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(
|
|
if st.upper { upper(t) } else { t },
|
|
footer-width, lines-height(st, _LINES.footer), 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,
|
|
align(center + bottom, footer-stack),
|
|
),
|
|
)
|
|
}
|