Files
pi-imgen/templates/hero-bottom.typ
T

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