// banded.typ — "a fascia": a solid colour band across the middle carries the title, // with the artwork left visible above and below it. // // Loud and graphic; built for sagre, feste patronali and summer festivals. The title // sits on FLAT colour, never on the picture, so it needs no scrim and stays legible // whatever the diffusion model painted. Only the secondary lines at the foot of the // page sit over the artwork, and those are the ones `spec.needs_scrim` protects. // // Composition, portrait / square (a3, a4, ig-post, ig-story): // // ┌───────────────────┐ // │ artwork │ // ├═══════════════════┤ band : accent, full width, bleeds off both edges // │ TITOLO sottot. │ title (+ subtitle), centred // ├───────────────────┤ strip : ink, the same width, inverted colours // │ data · luogo │ date, venue // ├───────────────────┤ // │ artwork │ // │ dettagli/prezzo │ foot : over the artwork, scrimmed when needed // └───────────────────┘ // // Landscape (fb-cover, yt-thumb) is NOT the same layout scaled down. A wide canvas has // no vertical room for a band plus a strip, and a title set across 1640 px is an // unreadable measure. So on landscape the band swallows the strip and becomes two // columns — title left, date/venue right — and the whole thing is proportionally // taller. See `landscape` below. // // Everything is a fraction of the TRIM's short edge or of the trim height, so the same // file is correct at 1080 px and at 300 dpi A3. Nothing here reads the clock, the // filesystem or a random source: the output is byte-identical across runs. #import "lib.typ": * #let spec = json(sys.inputs.specfile) // Byte-reproducible output — the load-bearing directive, see docs/typst-verified.md. #set document(date: none) // Italian hyphenation and Knuth-Plass line breaking for free. #set text(lang: "it") #set par(linebreaks: "optimized") // --------------------------------------------------------------------------- // Spec-derived constants // --------------------------------------------------------------------------- #let pal = palette-of(spec) #let sa = safe-area(spec) #let t = trim-mm(spec) #let short = short-edge-mm(spec) /// True for fb-cover and yt-thumb, false for everything portrait or square-ish. /// 1.25 sits well clear of ig-post's 0.8 and of a square, and well below fb-cover's 2.47. #let landscape = t.width >= t.height * 1.25 // --------------------------------------------------------------------------- // Band colour // // `ink_resolved` was contrast-checked by the renderer against the ARTWORK, and this // template must not second-guess it — but inside the band the type is not over the // artwork, it is over flat accent, a pair nobody has checked. So the ink stays exactly // as resolved and the BAND moves instead: keep the accent when it separates, otherwise // push its lightness away from the ink while holding its hue. A slightly shifted red is // a small art-direction concession; a title nobody can read is not recoverable. // --------------------------------------------------------------------------- /// sRGB -> linear, per WCAG 2.x relative luminance. #let _channel(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) } #let _luma(c) = { let k = rgb(c).components().slice(0, 3).map(v => _channel(v / 100%)) 0.2126 * k.at(0) + 0.7152 * k.at(1) + 0.0722 * k.at(2) } /// WCAG contrast ratio, 1 (identical) to 21 (black on white). #let _contrast(a, b) = { let la = _luma(a) let lb = _luma(b) (calc.max(la, lb) + 0.05) / (calc.min(la, lb) + 0.05) } // 3.0 is the WCAG threshold for LARGE text, which is all this band ever carries: the // title is 13% of the short edge (38 mm on A3) and even the date is over 13 mm. #let _KEEP = 3.0 // good enough to leave the art director's accent alone #let _AIM = 3.5 // what a shifted accent must reach, with a little headroom #let _band-fill = { let ink = pal.ink let base = pal.accent if _contrast(base, ink) >= _KEEP { base } else { // Which way to run: away from the ink, so a light ink darkens the band. let lighter = _luma(ink) < 0.5 let found = none for i in range(1, 13) { if found == none { let c = if lighter { base.lighten(i * 8%) } else { base.darken(i * 8%) } if _contrast(c, ink) >= _AIM { found = c } } } if found != none { found } else if _contrast(pal.bg, ink) >= _AIM { pal.bg // the palette's own background is the next most considered choice } else if lighter { white // last resort: a colour that cannot fail } else { black } } } // The strip under the band inverts the pair: ink ground, band-coloured type. That is // exactly the contrast we just guaranteed, read the other way round. #let _strip-fill = pal.ink #let _strip-ink = _band-fill // --------------------------------------------------------------------------- // Blocks -> zones // // `spec.blocks` is walked ONCE, in order, and each block is filed into the zone its // role belongs to. Order inside a zone is spec order, and the zones themselves run down // the page in role order, so the spec's sequence is what the reader's eye follows. // Roles the kernel does not know fall back to `details`, which lands in the foot. // --------------------------------------------------------------------------- #let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip") #let _prepared = { let out = () let bs = _get(spec, "blocks", ()) if type(bs) == array { for b in bs { if type(b) == dictionary { let raw = _get(b, "text", "") if type(raw) == str and raw.trim() != "" { // `block-style` normalises the role, so `st.role` is never an unknown one. let st = block-style(spec, _get(b, "role", none)) out.push(( role: st.role, zone: _ZONES.at(st.role, default: "lower"), // CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase // codepoints; upper() is harmless for them and correct for everyone else. text: if st.upper { upper(raw) } else { raw }, optional: _get(b, "optional", false) == true, style: st, )) } } } } out } #let band-blocks = _prepared.filter(b => b.zone == "band") #let strip-blocks = _prepared.filter(b => b.zone == "strip") #let lower-blocks = _prepared.filter(b => b.zone == "lower") // On landscape the strip has nowhere to go, so it rides inside the band as a second // column instead of a second bar. #let split-band = landscape and strip-blocks.len() > 0 and band-blocks.len() > 0 // --------------------------------------------------------------------------- // Type boxes // // These are the boxes handed to `fit`, i.e. CEILINGS, not targets: `fit` never grows // text past `block-style`'s ideal size, so a two-word title yields a slim band and the // artwork gets the rest. Only a long title spends the whole budget — and it spends it // on the wdth axis first, staying big and simply narrowing. // --------------------------------------------------------------------------- /// Height allowance for `n` lines at this role's ideal size (1.45 covers em box + /// leading for every role in the kernel's table). #let _lines(st, n) = st.size * n * 1.45 #let band-pad-y = short * 0.05 * 1mm #let band-pad-x = sa.x // bleed + safe: content lands in the safe area // The band may take at most this much of the page. Landscape gets more because it has // no strip below it and the artwork strips above/below stay legible at a smaller share. #let band-max = t.height * (if landscape { 0.50 } else { 0.42 }) * 1mm #let band-inner = calc.max(short * 0.12 * 1mm, band-max - 2 * band-pad-y) #let n-title = band-blocks.filter(b => b.role != "subtitle").len() #let has-sub = band-blocks.any(b => b.role == "subtitle") // Two and a half lines of title at every format: short * 0.42 against a title set at // short * 0.13 with 1.3 line advance. The band budget wins when it is the tighter one. #let title-h = calc.min( if has-sub { band-inner * 0.66 } else { band-inner }, short * 0.42 * 1mm, ) / calc.max(1, n-title) #let sub-h = if has-sub { band-inner * 0.26 } else { 0pt } // Column split inside a landscape band. The three add up to exactly the inner width. #let title-w = if split-band { sa.width * 0.66 } else { sa.width } #let info-w = if split-band { sa.width * 0.28 } else { sa.width } #let gutter-w = sa.width * 0.06 // Centred is the sagra-banner reading of this layout; landscape needs a left edge to // hang the title on, because its right-hand column is the date. #let band-align = if landscape { left } else { center } #let foot-align = if landscape { left } else { center } // --------------------------------------------------------------------------- // Logo reservation // // `logo-place` puts the logo in its corner regardless of what is there. When that // corner is at the bottom it shares the foot with the smaller blocks, so the foot gives // up the width instead of colliding with it. // --------------------------------------------------------------------------- #let _logo-w = { let lg = _get(spec, "logo", none) let path = if type(lg) == dictionary { _get(lg, "path", none) } else { none } if type(path) != str or path.trim() == "" { 0pt } else { let raw = _get(lg, "scale", 0.12) let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 } sa.short-edge * s } } #let _logo-corner = _dig(spec, ("logo", "corner"), "br") #let _logo-at-foot = _logo-w > 0pt and _logo-corner in ("bl", "br") #let foot-w = if _logo-at-foot { sa.width - _logo-w - short * 0.03 * 1mm } else { sa.width } // Slide the foot away from the logo rather than under it. #let foot-dx = if _logo-at-foot and _logo-corner == "bl" { sa.x + _logo-w + short * 0.03 * 1mm } else { sa.x } // --------------------------------------------------------------------------- // Rendering helpers // --------------------------------------------------------------------------- /// Auto-fit one prepared block into `w` x `h`, overriding only the colour: on the band /// the kernel's art-checked fill would be the wrong one, everywhere else it is right. #let render-block(b, w, h, colour, al) = { let args = b.style.args if colour != none { args.insert("fill", colour) } fit(b.text, w, h, b.style.family, b.style.axes, ..args, align-to: al) } /// A vertical run of blocks with an even optical gap and no trailing space. #let stack-blocks(blocks, w, h-of, colour, al, gap) = { let first = true for b in blocks { if not first { v(gap, weak: false) } render-block(b, w, h-of(b), colour, al) first = false } } #let band-column = stack-blocks( band-blocks, title-w, b => if b.role == "subtitle" { sub-h } else { title-h }, pal.ink, band-align, short * 0.022 * 1mm, ) /// date + venue: inside the band on landscape, on their own inverted strip otherwise. #let strip-column(colour, al) = stack-blocks( strip-blocks, if split-band { info-w } else { sa.width }, b => _lines(b.style, 2), colour, al, short * 0.014 * 1mm, ) /// The foot keeps each role's own resolved fill: those colours were contrast-checked /// against the artwork, which is exactly what they sit on here. #let foot-column(blocks) = { // Hyphenation earns its keep in a narrow column of small text, and can never reach // the title from here. set text(hyphenate: true) stack-blocks( blocks, foot-w, b => _lines(b.style, if b.role == "details" { 3 } else { 2 }), none, foot-align, short * 0.018 * 1mm, ) } // --------------------------------------------------------------------------- // The three painted pieces // --------------------------------------------------------------------------- /// Full-bleed artwork. Explicit mm rather than 100% so it cannot be misread as a /// fraction of the trim: this must cover the whole page, bleed included. /// NOTE: `art_file` must be root-relative — Typst resolves image paths against --root, /// so the renderer rewrites it, exactly as it does for the logo. #let art-layer = { let p = _get(spec, "art_file", none) if type(p) == str and p.trim() != "" { image(p, width: sa.full-width, height: sa.full-height, fit: "cover") } } #let band-block = if band-blocks.len() == 0 and not split-band { none } else { block( width: sa.full-width, fill: _band-fill, inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y), if split-band { // Bottom-aligned info hangs off the same optical line as the last title line. grid( columns: (title-w, info-w), column-gutter: gutter-w, align: (left + horizon, right + bottom), band-column, strip-column(pal.ink, right), ) } else { band-column }, ) } #let strip-block = if landscape or strip-blocks.len() == 0 { none } else { block( width: sa.full-width, fill: _strip-fill, inset: ( left: band-pad-x, right: band-pad-x, top: band-pad-y * 0.5, bottom: band-pad-y * 0.5, ), strip-column(_strip-ink, center), ) } // --------------------------------------------------------------------------- // Composition // // One `context` block solves the whole vertical layout, because every decision depends // on a measurement: how tall the band came out, whether the foot still fits under it, // and how much scrim that foot needs. Drawn into `page(foreground:)`, whose origin is // the FULL page including bleed — the same coordinate system `safe-area` reports. // --------------------------------------------------------------------------- #let composition = context { let band-h = if band-block == none { 0pt } else { measure(width: sa.full-width, band-block).height } let strip-h = if strip-block == none { 0pt } else { measure(width: sa.full-width, strip-block).height } let group-h = band-h + strip-h // Optically centred: a hair above the middle, because the foot below reads as weight. let nudge = -t.height * (if landscape { 0.02 } else { 0.045 }) * 1mm let min-top = sa.y + t.height * 0.08 * 1mm // always leave a real strip of artwork let group-top = calc.max(min-top, (sa.full-height - group-h) / 2 + nudge) // How much room is left between the band group and the bottom safe edge. let foot-gap = short * 0.06 * 1mm let avail = sa.full-height - sa.y - (group-top + group-h) - foot-gap let measure-foot(blocks) = { if blocks.len() == 0 { 0pt } else { measure(width: foot-w, block(width: foot-w, foot-column(blocks))).height } } let shown = lower-blocks let foot-h = measure-foot(shown) // `optional: true` means "drop me before you break the page". Only then, and only // when dropping actually buys the space. if foot-h > avail and shown.any(b => b.optional) { shown = shown.filter(b => not b.optional) foot-h = measure-foot(shown) } // Still tight: slide the band up into the top artwork, as far as `min-top` allows. if foot-h > avail { group-top = calc.max(min-top, group-top - (foot-h - avail)) } // Scrim only over the artwork, only under the foot, only when the renderer measured // the region behind it as busy. The band never needs one — it is flat colour. if foot-h > 0pt and _get(spec, "needs_scrim", false) == true { let h = calc.min(foot-h + short * 0.20 * 1mm, sa.full-height * 0.45) place(bottom + left, scrim(h, pal.scrim, none)) } if band-block != none { place(top + left, dy: group-top, band-block) } if strip-block != none { place(top + left, dy: group-top + band-h, strip-block) } if foot-h > 0pt { place(bottom + left, dx: foot-dx, dy: -sa.y, block(width: foot-w, foot-column(shown))) } } // --------------------------------------------------------------------------- // Page // // `width`/`height` are the TRIM and `bleed` extends outward, which is what makes Typst // emit a real TrimBox for the copy shop. PNG export renders the trim page, so the // bleed and the crop marks are a PDF concern only — by design. // --------------------------------------------------------------------------- #set page( width: sa.trim-width, height: sa.trim-height, margin: 0pt, bleed: sa.bleed, fill: pal.bg, // shows only if the artwork is missing or transparent background: art-layer, foreground: { composition logo-place(spec) crop-marks(spec) }, ) // The body stays empty on purpose: every element is placed in the foreground, whose // origin is the full page, so nothing depends on where a margin box would have started. #box()