353 lines
15 KiB
Typst
353 lines
15 KiB
Typst
// hero-bottom.typ — the default poster layout.
|
|
//
|
|
// Artwork full-bleed; every block of type in one band across the bottom, over a
|
|
// gradient scrim. Title dominant, then date/venue, details small at the foot. It is the
|
|
// safest and most reusable of the five layouts, which is why it is the default: it never
|
|
// fights the artwork for the middle of the page and it survives any aspect ratio.
|
|
//
|
|
// COORDINATES (verified against typst 0.15.1, see docs/typst-verified.md):
|
|
// * `page(background:)` and `page(foreground:)` resolve their offsets against the FULL
|
|
// page INCLUDING bleed — the same origin every mm key of `safe-area()` is measured
|
|
// from, so the whole composition lives there;
|
|
// * the page BODY with `margin: 0pt` starts at the TRIM instead, which is off by the
|
|
// bleed on a print job. The body is therefore left empty. Typst still emits the page.
|
|
// * PNG export renders the trim, so the bleed and the crop marks are PDF-only. Nothing
|
|
// load-bearing may live outside the safe area.
|
|
//
|
|
// Layering, bottom to top: page fill -> artwork -> scrim -> type -> crop marks.
|
|
//
|
|
// The LLM never edits this file. It reads the ResolvedSpec as data and the fixed rules
|
|
// below decide the composition.
|
|
|
|
#import "lib.typ": *
|
|
#let spec = json(sys.inputs.specfile)
|
|
|
|
#let pal = palette-of(spec)
|
|
#let sa = safe-area(spec)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tuning
|
|
//
|
|
// Every constant is a ratio, never a size. The same file has to be right on A3 at
|
|
// 300 dpi and on a 1280x720 thumbnail, so nothing here may be expressed in points.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Trim width/height above which the band splits into two columns. fb-cover (2.47) and
|
|
/// yt-thumb (1.78) land above it; a3/a4 (0.71), ig-post (0.80) and ig-story (0.56) below.
|
|
#let LANDSCAPE-AT = 1.25
|
|
|
|
/// The band grows to fit its content, but only between these fractions of the trim
|
|
/// height. The floor keeps it reading as a band when there are only two blocks; the
|
|
/// ceiling stops a seven-block spec from swallowing the artwork. Wide formats get a
|
|
/// higher ceiling because a third of 664 px is not a band, it is a caption.
|
|
#let BAND-MIN = 0.30
|
|
#let BAND-MAX-TALL = 0.62
|
|
#let BAND-MAX-WIDE = 0.80
|
|
|
|
/// Vertical gap between blocks, as a fraction of the trim's short edge, and the wider
|
|
/// step that separates the title group from the informative blocks. That single larger
|
|
/// gap is what makes the band read as "headline, then facts" instead of one grey stack.
|
|
#let GAP-RATIO = 0.020
|
|
#let GROUP-GAP = 1.9
|
|
|
|
/// Lines the title may claim before the band stops growing for it. Past this the title
|
|
/// is narrowed on the `wdth` axis (and only then shrunk) by `fit`, which is the whole
|
|
/// point of the auto-fit: a four-line headline is not a headline.
|
|
#let TITLE-LINES = 3
|
|
|
|
/// Rough line advance in multiples of the font size. Used only to cap how much room the
|
|
/// title may claim — never to lay anything out, which is always done from real metrics.
|
|
#let LINE-ADVANCE = 1.32
|
|
|
|
/// Scrim height as a multiple of the band. The kernel's gradient holds its fade back
|
|
/// until ~45% so the artwork stays clean, which means the scrim has to start well above
|
|
/// the type for the small blocks at the foot to sit on full density.
|
|
#let SCRIM-TALL = 1.50
|
|
#let SCRIM-WIDE = 1.30
|
|
|
|
/// Two-column split for landscape formats: title column, gutter, and the rest.
|
|
#let COL-SPLIT = 0.60
|
|
#let COL-GUTTER = 0.045
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Blocks -> laid-out items
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Every block that has real text, IN SPEC ORDER, paired with its resolved typography.
|
|
///
|
|
/// `hero` marks the first `title` block — the one the band is sized around. A second
|
|
/// title block (rare, but the schema allows it) is treated as an ordinary line rather
|
|
/// than competing for the same room. Empty and malformed blocks are dropped silently:
|
|
/// a missing subtitle means "draw nothing", never a failed render.
|
|
#let prepare(spec) = {
|
|
let bs = _get(spec, "blocks", ())
|
|
if type(bs) != array { return () }
|
|
let items = ()
|
|
let hero-taken = false
|
|
for b in bs {
|
|
if type(b) != dictionary { continue }
|
|
let raw = _get(b, "text", "")
|
|
if type(raw) != str or raw.trim() == "" { continue }
|
|
let st = block-style(spec, _get(b, "role", none))
|
|
let hero = st.role == "title" and not hero-taken
|
|
if hero { hero-taken = true }
|
|
items.push((
|
|
style: st,
|
|
// Uppercasing happens here, not in `fit`, so the measured text and the drawn text
|
|
// are the same string. CAPS-ONLY families (Bebas Neue, Bungee) are unaffected:
|
|
// they draw capitals either way.
|
|
body: if st.upper { upper(raw) } else { raw },
|
|
hero: hero,
|
|
optional: _get(b, "optional", false) == true,
|
|
))
|
|
}
|
|
items
|
|
}
|
|
|
|
/// Roles that belong to the headline group — the left column on a landscape format.
|
|
#let HEAD-ROLES = ("title", "subtitle")
|
|
|
|
/// Leading gap for each item; the first has none.
|
|
#let gaps-for(items, gap) = {
|
|
let gs = ()
|
|
for (i, it) in items.enumerate() {
|
|
if i == 0 {
|
|
gs.push(0pt)
|
|
} else {
|
|
let step = items.at(i - 1).style.role in HEAD-ROLES and not (it.style.role in HEAD-ROLES)
|
|
gs.push(if step { gap * GROUP-GAP } else { gap })
|
|
}
|
|
}
|
|
gs
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Measuring and fitting a column
|
|
//
|
|
// The band is sized from REAL metrics, not from a guess at how many lines each block
|
|
// takes. A one-line date must not reserve two lines' worth of room, or a seven-block
|
|
// poster squeezes a title that had space all along.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Height this block wants at its natural size in a `w`-wide column. Measured with the
|
|
/// same paragraph settings and the same (absent) width variation `fit` starts from, so
|
|
/// handing `fit` exactly this much room reproduces the measurement instead of shrinking.
|
|
/// Must be called inside `context`.
|
|
#let natural-h(it, w) = {
|
|
let st = it.style
|
|
measure(width: w, {
|
|
set par(leading: st.leading, linebreaks: "optimized")
|
|
text(.._text-args(st.family, st.size, st.weight, st.tracking, none, none), it.body)
|
|
}).height
|
|
}
|
|
|
|
/// Natural heights of a column plus the room it would like: the sum of those heights and
|
|
/// the gaps, with the title capped at `TITLE-LINES`. Must be called inside `context`.
|
|
#let col-plan(items, w, gap) = {
|
|
let hs = items.map(it => natural-h(it, w))
|
|
let need = gaps-for(items, gap).fold(0pt, (a, b) => a + b)
|
|
for (i, it) in items.enumerate() {
|
|
let h = hs.at(i)
|
|
if it.hero { h = calc.min(h, it.style.size * LINE-ADVANCE * TITLE-LINES) }
|
|
need += h
|
|
}
|
|
(hs: hs, need: need)
|
|
}
|
|
|
|
/// Budget for one candidate stack: the gaps, where the title sits, how much room the
|
|
/// other blocks need, and the floor the title is never pushed below.
|
|
#let col-budget(items, hs, gap) = {
|
|
let gs = gaps-for(items, gap)
|
|
let hero-i = items.position(it => it.hero)
|
|
let rest = 0pt
|
|
for (i, h) in hs.enumerate() {
|
|
if hero-i == none or i != hero-i { rest += h }
|
|
}
|
|
(
|
|
gs: gs,
|
|
gaps: gs.fold(0pt, (a, b) => a + b),
|
|
hero-i: hero-i,
|
|
rest: rest,
|
|
// A shade above `fit`'s own floor, so the title reaches its hard minimum only after
|
|
// the layout has already given up everything else it could.
|
|
floor: if hero-i == none { 0pt } else { items.at(hero-i).style.min-size * 1.15 },
|
|
)
|
|
}
|
|
|
|
/// Lay one column into `avail` of vertical room and return it bottom-anchored.
|
|
///
|
|
/// The title is what the layout protects: its floor is subtracted first, the other
|
|
/// blocks live on what is left. When that does not balance, blocks marked
|
|
/// `optional: true` are dropped from the end — never a block the art director did not
|
|
/// mark, which is instead squeezed and left to `fit`'s own min-size.
|
|
/// Must be called inside `context`.
|
|
#let col-render(items, hs, w, avail, gap, align-to) = {
|
|
if items.len() == 0 { return none }
|
|
|
|
let live = items
|
|
let heights = hs
|
|
let b = col-budget(live, heights, gap)
|
|
|
|
// Drop optional blocks, last first, while the stack cannot balance.
|
|
while avail - b.gaps - b.rest < b.floor {
|
|
let drop = none
|
|
for (i, it) in live.enumerate() {
|
|
if it.optional and not it.hero { drop = i }
|
|
}
|
|
if drop == none { break }
|
|
live = live.slice(0, drop) + live.slice(drop + 1)
|
|
heights = heights.slice(0, drop) + heights.slice(drop + 1)
|
|
b = col-budget(live, heights, gap)
|
|
}
|
|
|
|
// Whatever is left over goes to the title. `fit` never grows past the ideal size, so a
|
|
// generous allocation simply means the title stays full size on fewer lines.
|
|
let hero-alloc = avail - b.gaps - b.rest
|
|
let squeeze = 1.0
|
|
if b.hero-i != none and hero-alloc < b.floor {
|
|
hero-alloc = b.floor
|
|
let room = avail - b.gaps - b.floor
|
|
squeeze = if b.rest > 0pt { calc.max(0.35, calc.min(1.0, room / b.rest)) } else { 1.0 }
|
|
} else if b.hero-i == none and b.rest + b.gaps > avail {
|
|
squeeze = calc.max(0.35, (avail - b.gaps) / b.rest)
|
|
}
|
|
|
|
let out = ()
|
|
for (i, it) in live.enumerate() {
|
|
if i > 0 { out.push(b.gs.at(i)) }
|
|
let h = if it.hero { hero-alloc } else { heights.at(i) * squeeze }
|
|
// `fit` bisects the width axis before the size, so a long Italian title narrows and
|
|
// keeps its optical weight instead of quietly becoming a small title.
|
|
out.push(fit(
|
|
it.body, w, calc.max(h, 1pt),
|
|
it.style.family, it.style.axes,
|
|
align-to: align-to,
|
|
..it.style.args,
|
|
))
|
|
}
|
|
|
|
// Each fitted block occupies its own natural height, so the stack collapses any slack
|
|
// and hugs the bottom of the band — the foot of the poster stays a straight edge.
|
|
block(width: w, stack(dir: ttb, ..out))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The composition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Full-bleed artwork, drawn from the full-page origin so it covers the bleed too.
|
|
/// `fit: "cover"` crops rather than distorts — a stretched face is worse than a lost
|
|
/// corner. NOTE: `art_file` must be root-relative; Typst resolves image paths against
|
|
/// `--root`, so the renderer rewrites it (same rule as `logo.path`).
|
|
#let artwork(spec) = {
|
|
let p = _get(spec, "art_file", none)
|
|
if type(p) != str or p.trim() == "" { return none }
|
|
place(top + left, image(p, width: sa.full-width, height: sa.full-height, fit: "cover"))
|
|
}
|
|
|
|
/// Scrim + type. One `context` for the whole thing, because the scrim's height is
|
|
/// derived from the band, the band's height from the measured content, and the logo's
|
|
/// ceiling from the band: they must all come out of a single measuring pass.
|
|
#let composition(spec) = context {
|
|
let items = prepare(spec)
|
|
if items.len() > 0 {
|
|
let gap = sa.short-edge * GAP-RATIO
|
|
let landscape = sa.trim-width / sa.trim-height >= LANDSCAPE-AT
|
|
|
|
// On a wide format a bottom third is a caption, not a band: the title would have to
|
|
// shrink to nothing to leave room for six more lines under it. So the band splits —
|
|
// headline left, the facts right-aligned against the safe edge — and both columns
|
|
// sit on the same baseline. Portrait and square formats keep the single stack.
|
|
let head = items.filter(it => it.style.role in HEAD-ROLES)
|
|
let facts = items.filter(it => not (it.style.role in HEAD-ROLES))
|
|
let split = landscape and head.len() > 0 and facts.len() > 0
|
|
|
|
let lw = if split { sa.width * COL-SPLIT } else { sa.width }
|
|
let rw = sa.width * (1.0 - COL-SPLIT - COL-GUTTER)
|
|
|
|
let left-plan = col-plan(if split { head } else { items }, lw, gap)
|
|
let right-plan = if split { col-plan(facts, rw, gap) } else { (hs: (), need: 0pt) }
|
|
|
|
// The band is content-driven and then clamped: it never looks thinner than a band,
|
|
// never eats more of the artwork than the format can spare, and never spills out of
|
|
// the safe area.
|
|
let want = calc.max(left-plan.need, right-plan.need)
|
|
let band-h = calc.min(
|
|
calc.max(want, sa.trim-height * BAND-MIN),
|
|
sa.trim-height * (if landscape { BAND-MAX-WIDE } else { BAND-MAX-TALL }),
|
|
sa.height,
|
|
)
|
|
|
|
// Scrim first, type second: within `foreground` the drawing order is the content
|
|
// order. It runs to the physical bottom edge so the wash does not stop at the trim
|
|
// on a bled job. Skipped entirely when the renderer measured the artwork behind the
|
|
// band as calm and light enough — `needs_scrim` is not ours to second-guess.
|
|
if _get(spec, "needs_scrim", false) == true {
|
|
let scrim-h = calc.min(
|
|
sa.full-height,
|
|
band-h * (if landscape { SCRIM-WIDE } else { SCRIM-TALL }) + sa.bleed + sa.safe,
|
|
)
|
|
place(bottom + left, scrim(scrim-h, pal.scrim, none))
|
|
}
|
|
|
|
// Both columns are bottom-anchored at the safe inset, so the foot of the type is a
|
|
// straight line whatever each column ended up containing.
|
|
place(
|
|
bottom + left,
|
|
dx: sa.x,
|
|
dy: -(sa.bleed + sa.safe),
|
|
col-render(if split { head } else { items }, left-plan.hs, lw, band-h, gap, left),
|
|
)
|
|
if split {
|
|
place(
|
|
bottom + right,
|
|
dx: -(sa.bleed + sa.safe),
|
|
dy: -(sa.bleed + sa.safe),
|
|
col-render(facts, right-plan.hs, rw, band-h, gap, right),
|
|
)
|
|
}
|
|
|
|
// The logo keeps the corner the art director chose, but the band owns the bottom of
|
|
// the page, so a `bl`/`br` logo is lifted to rest just above it. `logo-place` still
|
|
// does the sizing and the inset: it is handed a shortened container that ends at the
|
|
// band, and its own bottom alignment does the rest. Top corners are unaffected.
|
|
let ceiling = calc.max(sa.full-height - band-h - gap, sa.full-height * 0.30)
|
|
place(top + left, block(width: sa.full-width, height: ceiling, logo-place(spec)))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Byte-reproducible output: this is the load-bearing directive, not SOURCE_DATE_EPOCH.
|
|
// Without it the golden-file tests compare a timestamp.
|
|
#set document(date: none)
|
|
|
|
// `lang: "it"` buys Italian hyphenation and quotation conventions for free. The body
|
|
// family is only set when the spec names one — `text(font: none)` is an error, and an
|
|
// inherited family is a better failure than a dead render.
|
|
#let body-font = font-of(spec, "body")
|
|
#set text(
|
|
lang: "it",
|
|
fill: pal.ink,
|
|
..if body-font == none { (:) } else { (font: body-font) },
|
|
)
|
|
#set par(linebreaks: "optimized")
|
|
|
|
// `bleed:` is what makes Typst write a real PDF TrimBox; `margin: 0pt` because nothing
|
|
// flows — every element is placed. `fill` shows through only where the artwork is
|
|
// missing or does not cover, which is the one case where the palette background matters.
|
|
#set page(
|
|
width: sa.trim-width,
|
|
height: sa.trim-height,
|
|
bleed: sa.bleed,
|
|
margin: 0pt,
|
|
fill: pal.bg,
|
|
background: artwork(spec),
|
|
foreground: {
|
|
composition(spec)
|
|
crop-marks(spec)
|
|
},
|
|
)
|