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