465 lines
20 KiB
Typst
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),
|
|
),
|
|
)
|
|
}
|