430 lines
17 KiB
Typst
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()
|