// split.typ — hard geometric split: artwork owns one half, flat colour the other, // and every word is typeset on the flat side. // // The idea is editorial rather than pictorial. There is no soft blend, no text over a // photograph, no drop shadow doing the work a scrim should: a single straight seam cuts // the piece in two, the artwork bleeds off three edges of its half, and the type sits on // clean `palette.bg` where `ink_resolved` was contrast-checked against a flat colour and // is therefore actually true. // // The split axis follows the aspect, which is the only thing that changes between an A3 // and a YouTube thumbnail: // // portrait / square landscape // +-------------------+ +---------+---------+ // | ARTWORK | | | TITLE | // | | | ARTWORK | date | // +===================+ seam | | venue | seam is the vertical rule // | TITLE | | | | // | date, venue, ... | | | footer | // +-------------------+ +---------+---------+ // // Everything below is a fraction of the TRIM, so the same file is correct at 1080 px and // at 300 dpi A3, and turning bleed on never changes how big the headline is. #import "lib.typ": * #let spec = json(sys.inputs.specfile) #set document(date: none) // load-bearing: this is what makes output byte-identical #set text(lang: "it") // Italian hyphenation, for free #set par(linebreaks: "optimized") // Knuth-Plass; `fit` sets it again on its own trials // --------------------------------------------------------------------------- // Geometry // --------------------------------------------------------------------------- #let sa = safe-area(spec) #let pal = palette-of(spec) #let trim = trim-mm(spec) /// Landscape is the ONLY switch in this file. Square counts as portrait: a 4:5 ig-post /// reads as an upright piece, so it gets the horizontal seam like A3 does. #let landscape = trim.width > trim.height /// A hard half. This constant is deliberately not spec-driven — "one half" IS the /// layout, and an art director who wants 60/40 wants `banded`, not `split`. #let SPLIT = 0.5 /// Breathing room between the seam and the first word. Measured off the short edge so /// the optical distance is the same on every format. #let gutter = sa.short-edge * 0.045 /// Vertical rhythm between stacked blocks, and the spacing unit for the footer line. #let gap = sa.short-edge * 0.016 /// The seam, in FULL-PAGE coordinates (bleed included) — the origin `page(background:)` /// and `page(foreground:)` resolve against. Along the split axis the artwork runs from /// the page edge to here, so it covers its own bleed; the panel starts here. #let seam = sa.bleed + (if landscape { trim.width } else { trim.height }) * SPLIT * 1mm /// The panel: the flat half, inset to the safe area, pushed off the seam by the gutter. /// `calc.max` guards the pathological case of a safe margin wide enough to swallow half /// the piece — `safe-area` already caps it at 40% of the short edge, but a zero-height /// box would be a hard error rather than an ugly poster. #let panel = if landscape { ( x: seam + gutter, y: sa.y, width: calc.max(1mm, sa.x + sa.width - seam - gutter), height: sa.height, ) } else { ( x: sa.x, y: seam + gutter, width: sa.width, height: calc.max(1mm, sa.y + sa.height - seam - gutter), ) } // --------------------------------------------------------------------------- // Logo — placed by lib's `logo-place`, but its footprint has to be reserved here // --------------------------------------------------------------------------- // // `logo-place` honours `spec.logo.corner` and works off the same safe rect, so a corner // that lands on the panel lands exactly on the panel's own edge. We do not move it — // the art director's corner is respected — we only make room for it, and let the scrim // cover the case where it was asked for on top of the artwork. #let logo-raw = if type(spec) == dictionary { spec.at("logo", default: none) } else { none } #let logo-path = if type(logo-raw) == dictionary { logo-raw.at("path", default: none) } else { none } #let has-logo = type(logo-path) == str and logo-path.trim() != "" /// Reserved logo width. This mirrors `logo-place`'s own sizing (short edge x clamped /// scale) because the helper places the image but cannot tell us how much room it took. #let logo-w = if not has-logo { 0pt } else { let s = logo-raw.at("scale", default: 0.12) let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 } sa.short-edge * s } #let logo-corner = if has-logo { logo-raw.at("corner", default: "br") } else { none } #let logo-left = logo-corner in ("tl", "bl") /// Which side of the seam the logo fell on. In portrait the panel is the bottom half, so /// only bl/br touch it; in landscape the panel is the right half, so tr/br do. #let logo-on-panel-bottom = has-logo and ( if landscape { logo-corner == "br" } else { logo-corner in ("bl", "br") } ) #let logo-on-panel-top = has-logo and landscape and logo-corner == "tr" /// Vertical reservation. The image's height is unknown at layout time — `logo-place` /// sizes by width and lets the aspect decide — so we reserve a square. Wider-than-tall /// wordmarks, which is nearly all of them, simply leave a little air. #let logo-h = logo-w // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- /// How many lines a role is expected to run to. Multiplied by the role's type size this /// becomes its share of the panel: a title is budgeted three lines of very large type, a /// price one line of small type, and the ratio between them is what makes the hierarchy /// survive at every format instead of only at the one it was drawn on. #let _LINES = ( title: 3.0, subtitle: 2.0, date: 1.5, venue: 1.5, details: 3.0, price: 1.0, footer: 1.5, ) /// Every block, IN SPEC ORDER, with its text resolved. This generalises lib's /// `block-text` — which returns the first block of a role — to a spec that legitimately /// repeats a role (two `details` lines, say). Blank and malformed entries are dropped /// silently: a missing venue means "draw no venue", never an error. /// /// Casing comes from the role via `block-style`, never from the glyphs. Bebas Neue and /// Bungee draw capitals at lowercase codepoints, so a mixed-case subtitle in one of them /// simply arrives in caps; nothing here reads lowercase shapes as a signal. #let entries = { let out = () let raw = if type(spec) == dictionary { spec.at("blocks", default: ()) } else { () } if type(raw) != array { raw = () } for b in raw { if type(b) != dictionary { continue } let t = b.at("text", default: "") if type(t) != str or t.trim() == "" { continue } let st = block-style(spec, b.at("role", default: none)) out.push(( role: st.role, body: if st.upper { upper(t) } else { t }, style: st, optional: b.at("optional", default: false) == true, weight: st.ratio * _LINES.at(st.role, default: 1.5), )) } out } /// The colophon line. Several footer blocks collapse into one rule-of-thumb line rather /// than stacking, because the foot of the panel is a single band shared with the logo. #let foot-body = { let fs = entries.filter(e => e.role == "footer") if fs.len() == 0 { none } else { fs.map(e => e.body).join(" · ") } } #let foot-style = block-style(spec, "footer") /// Height of the bottom band: whichever is taller, the colophon or the logo sharing it. #let foot-text-h = if foot-body == none { 0pt } else { foot-style.size * 2.2 } #let foot-h = calc.max(foot-text-h, if logo-on-panel-bottom { logo-h } else { 0pt }) /// Reservation above the stack, for the one corner (landscape + `tr`) where the logo /// would otherwise sit on the headline. #let head-h = if logo-on-panel-top { logo-h + gap } else { 0pt } /// What is left for the type stack. #let flow-h = calc.max( 1mm, panel.height - head-h - foot-h - (if foot-h > 0pt { gap } else { 0pt }), ) // --------------------------------------------------------------------------- // Dropping optional blocks // --------------------------------------------------------------------------- /// The least space a block can occupy and still be worth printing: one line at the floor /// size `block-style` computed for its role, plus its leading. #let _min-need(st) = st.min-size * 1.55 /// Blocks that go in the stack, after dropping `optional: true` entries that genuinely /// do not fit. Spec order is priority order (title first, footer last), so the LAST /// optional block is always the cheapest thing to lose. Note this only fires when the /// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is /// handled by `fit`, which narrows the type on the wdth axis long before anything is /// thrown away. #let flow = { let list = entries.filter(e => e.role != "footer") let needed(l) = { let s = 0pt for e in l { s += _min-need(e.style) } s + gap * calc.max(0, l.len() - 1) } while needed(list) > flow-h and list.any(e => e.optional) { let idx = 0 for (i, e) in list.enumerate() { if e.optional { idx = i } } list = list.slice(0, idx) + list.slice(idx + 1) } list } /// Split the flow height between the survivors by weight, so the panel is always exactly /// filled: one lone title gets the whole panel (and `fit` still refuses to grow past its /// ideal size), while a seven-block spec divides it proportionally and the title keeps /// roughly half. Because every budget is honoured by `fit`, the stack can never be taller /// than the panel — the layout has no overflow case. #let _wsum = flow.fold(0.0, (a, e) => a + e.weight) #let _inner = calc.max(1mm, flow-h - gap * calc.max(0, flow.len() - 1)) #let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) } // --------------------------------------------------------------------------- // The three pieces of the composition // --------------------------------------------------------------------------- /// The artwork half, bleeding off the three page edges it touches and stopping dead on /// the seam. `fit: "cover"` fills the box and crops the overflow, so the art is never /// distorted whatever aspect the backend produced; `clip` makes the seam a hard edge /// rather than an overhang. /// /// NOTE: `spec.art_file` must reach us ROOT-RELATIVE (leading "/"). Typst resolves image /// paths against `--root`, not the filesystem root — the renderer rewrites it, exactly as /// it does for `spec.logo.path`. With no artwork at all we fall back to a flat scrim- /// coloured half: a poster missing its picture is recoverable, a failed compile is not. #let art-file = if type(spec) == dictionary { spec.at("art_file", default: none) } else { none } #let art-w = if landscape { seam } else { sa.full-width } #let art-h = if landscape { sa.full-height } else { seam } #let artwork = place(top + left, dx: 0pt, dy: 0pt, box( width: art-w, height: art-h, clip: true, if type(art-file) == str and art-file.trim() != "" { image(art-file, width: 100%, height: 100%, fit: "cover") } else { rect(width: 100%, height: 100%, fill: pal.scrim, stroke: none) }, )) /// The scrim, and the one place it is honest in this template. /// /// No text ever sits on the artwork here, so this is not carrying the headline. It is /// anchored on the OUTER edge of the art half — the top in portrait, the left in /// landscape — and fades toward the seam, which is precisely where the two art-side /// corners are. That covers the case the art director asked for: a logo placed `tl`/`tr` /// (portrait) or `tl`/`bl` (landscape) sits on the picture, and when the renderer has /// measured that picture as bright or busy (`needs_scrim`) it needs something under it. /// It also keeps loud artwork from shouting across the seam at the flat panel. #let needs-scrim = if type(spec) == dictionary { spec.at("needs_scrim", default: false) == true } else { false } #let art-scrim = if not needs-scrim { none } else if landscape { place(top + left, dx: 0pt, dy: 0pt, scrim(sa.full-height, pal.scrim, 180deg, width: art-w * 0.45, strength: 80%)) } else { place(top + left, dx: 0pt, dy: 0pt, scrim(art-h * 0.45, pal.scrim, 270deg, width: sa.full-width, strength: 80%)) } /// The seam itself: an accent hairline drawn on the PANEL side of the cut, so it always /// reads against flat `bg` instead of disappearing into whatever the artwork does at its /// edge. It runs the full page, bleed included — a rule that stopped at the trim would /// leave a notch after cutting. #let seam-rule = { let t = calc.max(0.6pt, sa.short-edge * 0.0025) if landscape { place(top + left, dx: seam, dy: 0pt, rect(width: t, height: sa.full-height, fill: pal.accent, stroke: none)) } else { place(top + left, dx: 0pt, dy: seam, rect(width: sa.full-width, height: t, fill: pal.accent, stroke: none)) } } // --------------------------------------------------------------------------- // Type // --------------------------------------------------------------------------- /// The stack, vertically centred in the flow area. Centring rather than top-aligning is /// what makes a two-block spec look composed instead of abandoned: the panel is often /// far taller than the words that have to go in it (an ig-story panel is ~156 mm for as /// little as a title and a date), and a heading pinned to the top of that void reads as /// a mistake. #let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, { let first = true for e in flow { if not first { v(gap) } first = false let st = e.style // One line per block, and the whole reason `fit` exists: a long Italian title // narrows on the wdth axis before it is allowed to shrink, so it keeps filling its // band. "Sagra della Castagna e dell'Autunno in Piazza" lands on three lines at // ig-story and stays a headline instead of becoming a caption. block(width: panel.width, fit(e.body, panel.width, budget(e), st.family, st.axes, ..st.args)) } })) /// The colophon, pinned to the foot of the panel and pushed clear of the logo when they /// share that edge. It is aligned AWAY from the logo, so the two read as one line with a /// hole in the middle rather than as a collision. #let foot-line = if foot-body == none { none } else { let reserve = if logo-on-panel-bottom { logo-w + gap } else { 0pt } let w = calc.max(1mm, panel.width - reserve) let x = panel.x + (if logo-on-panel-bottom and logo-left { reserve } else { 0pt }) let a = if logo-on-panel-bottom and logo-left { right } else { left } place(bottom + left, dx: x, dy: -sa.y, box( width: w, fit(foot-body, w, calc.max(foot-text-h, 1mm), foot-style.family, foot-style.axes, align-to: a, ..foot-style.args), )) } // --------------------------------------------------------------------------- // The page // --------------------------------------------------------------------------- // // `width`/`height` are the TRIM and `bleed` is separate, which is what makes Typst write // a real PDF TrimBox for the copy shop. Everything is drawn in `background`/`foreground` // because those two resolve their offsets against the FULL page including bleed — the // same origin `safe-area` reports — while the body would not. The body stays empty. // // PNG export renders the trim page, so the bleed art and the crop marks are a PDF-only // concern; the composition is identical either way because the seam is measured off the // trim, not off the page. #set page( width: sa.trim-width, height: sa.trim-height, margin: 0pt, bleed: sa.bleed, fill: pal.bg, background: { set text(lang: "it") set par(linebreaks: "optimized") artwork art-scrim seam-rule place(top + left, dx: panel.x, dy: panel.y + head-h, type-stack) foot-line }, foreground: { crop-marks(spec) logo-place(spec) }, )