Files
pi-imgen/templates/banded.typ
T

430 lines
17 KiB
Typst

// 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()