// 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 every word sits // on flat `palette.bg`. // // That last point decides the colour handling. The ink the renderer measured // (`ink_on_art` / legacy `ink_resolved`) was chosen against the PICTURE, and no type // here ever touches the picture — so every call passes `surface: "bg"` and lets // `palette-of` / `block-style` re-derive the ink and the accent by contrast against the // flat half. Inheriting an ink picked for a photograph is the one colour bug this layout // is uniquely exposed to. // // 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 and quotes, for free #set par(linebreaks: "optimized") // Knuth-Plass, for any paragraph outside `fit-text` // --------------------------------------------------------------------------- // Two measured Typst 0.15.1 facts that this template has to defend against. // Both were reproduced against the real binary with Archivo; both are invisible // unless you look at pixels, which is exactly why they are written down here. // // (1) `linebreaks: "optimized"` emits OVERFULL lines for ragged display type. // "SAGRA DELLA CASTAGNA E DELL'AUTUNNO IN PIAZZA" at 24pt in a 60mm box: // simple -> 4 lines, 87.46pt tall, every line inside the box // optimized -> 3 lines, 63.79pt tall, line 2 running ~90% of the box // width past its right edge // 4 of 6 realistic Italian headlines reproduced it; `justify` and // `hyphenate` change nothing. So display type here pins "simple". // // (2) `measure(width: w, ..)` CLAMPS the width it reports to `w`. An unbreakable // 940.24pt word measured at width 170.08pt reports 170.08pt. That is why (1) // is silent: `fit`'s `m.width <= w` test can never fail, and because the // overfull layout uses FEWER lines it also passes the height test. // // `fit-text` below is the whole response: it pins the line breaker on the block // body — an explicit `par(linebreaks: ..)` field beats the `set par` inside // `fit`, so `fit` measures and draws the same honest layout — and it caps the // ideal size so the longest single word fits, which is the one overflow the // clamp in (2) hides even from an honest breaker. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Geometry // --------------------------------------------------------------------------- #let sa = safe-area(spec) #let pal = palette-of(spec, surface: "bg") #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), surface: "bg") 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", surface: "bg") /// 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 narrow end of a family's `wdth` axis, tolerant of every shape the spec may use /// for a range. `none` when the family has no width axis and `fit` can only shrink. #let _wdth-floor(axes) = { if type(axes) != dictionary { return none } let a = axes.at("wdth", default: none) if type(a) == array and a.len() >= 2 { calc.min(a.at(0), a.at(1)) } else if type(a) == dictionary { a.at("min", default: a.at("lo", default: none)) } else if type(a) in (int, float) { a } else { none } } /// Auto-fit one block into `w` x `h` — the only way type is drawn in this template. /// /// Everything is delegated to lib's `fit`; the two things added here are the defences /// described at the top of the file: /// /// * the body is handed over as an explicit `par(linebreaks: "simple", ..)`, whose own /// field outranks the `set par` inside `fit` and so governs BOTH the trial measures /// and the final draw. Without it a long Italian title silently overflows its column. /// * the ideal size is capped so the longest unbreakable word fits `w`. The word is /// measured unconstrained (where `measure` reports the true width) at the narrowest /// width the axis allows, because `fit` spends the axis before it spends size — so /// this only bites when even fully condensed the word would not fit, and then it /// hands `fit` a size at which it does. #let fit-text(body, w, h, st, align-to: left) = context { let words = body.split(regex("\\s+")).filter(x => x != "") let cap = st.size if words.len() > 0 and w > 0pt { let floor-wdth = _wdth-floor(st.axes) let probe(word) = measure(text( ..(if st.family != none { (font: st.family) } else { (:) }), ..(if floor-wdth != none { (variations: (wdth: floor-wdth)) } else { (:) }), size: 100pt, weight: st.weight, tracking: st.tracking, word, )).width let widest = words.fold(0pt, (a, word) => calc.max(a, probe(word))) if widest > 0pt { cap = calc.min(cap, 100pt * (w / widest)) } } fit( par(linebreaks: "simple", body), w, h, st.family, st.axes, align-to: align-to, ..(st.args + (size: cap)), ) } /// 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-text(e.body, panel.width, budget(e), st)) } })) /// 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-text(foot-body, w, calc.max(foot-text-h, 1mm), foot-style, align-to: a), )) } // --------------------------------------------------------------------------- // 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) }, )