// framed.typ — artwork inset inside a generous coloured frame. // // The gallery layout, and the safest of the five: the type NEVER sits on the artwork, // so a busy, high-variance image can never eat a word of it. The coloured frame is the // page itself (`palette.bg`), the artwork is a panel floated inside it, and every text // block lives in the frame's margins. // // Two compositions, chosen from the trim's aspect ratio — never one layout scaled: // // STACKED (portrait and square-ish: a3, a4, ig-post, ig-story) // +----------------------+ art panel across the top, flush with the side frames, // | +------------+ | the type band in the deeper bottom margin. The classic // | | art | | museum poster. The band takes only what the type needs, // | +------------+ | so every spare millimetre goes back to the picture. // | TITOLO | // | data · luogo | // +----------------------+ // // SIDE (landscape: fb-cover, yt-thumb) // +---------------------------+ a 2.5:1 cover has no room for a bottom band — it // | TITOLO | art | would be a 12 mm strip. So the frame margin that // | data | | carries the type moves to the left edge and becomes // +---------------------------+ a column; the artwork fills the rest. // // Everything is drawn in `page(background:)`, whose origin is the FULL page INCLUDING // bleed — hence the `+ b` on every coordinate. One coordinate system for the whole // composition is the only reliable defence against the classic bleed bug. Only the frame // colour bleeds; the art panel is inset by definition and never reaches the trim, which // is what makes this template unusually forgiving to print. #import "lib.typ": * #let spec = json(sys.inputs.specfile) // --------------------------------------------------------------------------- // Tunables — fractions of the trim's SHORT edge or of the trim itself, so the piece // looks the same at 1080 px and at 300 dpi A3. // --------------------------------------------------------------------------- /// Frame width: the margin between the trim and the art panel. Generous on purpose — /// a mean frame reads as a printing mistake, a wide one reads as a decision. #let FRAME-RATIO = 0.070 /// Breathing space between the panel and the type, and between type blocks. #let GAP-RATIO = 0.030 /// The type band may never take more than `BAND-MAX` of the height (stacked), and the /// picture may never be squeezed below `ART-MIN`. Between them they keep the piece a /// framed picture with a caption, rather than a caption with a stamp. #let BAND-MAX = 0.50 #let ART-MIN = 0.28 /// Width of the type column in the landscape composition, as a fraction of the width /// inside the frame. Wide enough for a readable measure, narrow enough to leave a picture. #let COL-RATIO = 0.40 /// Hairline keyline around the art panel — a gallery frame's inner edge. Scales with the /// piece so it stays a hairline instead of becoming a rule. #let KEYLINE-RATIO = 0.0016 /// Ceiling on the lines each role may claim while the band is budgeted. The auto-fit is /// what actually guarantees the text fits; this only stops one very long block from /// claiming the whole band before the others have been placed. #let MAX-LINES = (title: 4, subtitle: 3, date: 2, venue: 2, details: 4, price: 2, footer: 2) /// Rough line box (cap height plus leading), used only for the cap above. #let LINE-FACTOR = 1.34 /// How far the stack may be squeezed before an optional block is dropped instead. Losing /// 10% of the type is cheaper than losing the price of a ticket. #let SQUEEZE-ALLOWANCE = 0.12 /// Floors for the staged squeeze: gaps give up half their air before the supporting type /// is touched, and the supporting type gives up 30% before the headline is touched. #let GAP-FLOOR = 0.45 #let MINOR-FLOOR = 0.70 /// Width of the coloured frame: the margin between the trim and everything inside it. /// Never narrower than the safe margin, never so wide that it eats the picture — 18% of /// the short edge per side is already a very deep mount. The logo is aligned to this /// same edge, which is why it is a function rather than a local. #let frame-of(spec) = { let sa = safe-area(spec) calc.min(calc.max(sa.safe, sa.short-edge * FRAME-RATIO), sa.short-edge * 0.18) } // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- /// Every renderable block in SPEC ORDER, with its resolved style and its text already /// uppercased where the role calls for it. Blocks that are not dictionaries, or whose /// text is missing or blank, simply do not exist — a half-filled spec must still print. #let entries(spec) = { let bs = _get(spec, "blocks", ()) if type(bs) != array { return () } let out = () for b in bs { if type(b) != dictionary { continue } let raw = _get(b, "text", "") if type(raw) != str or raw.trim() == "" { continue } let st = block-style(spec, _get(b, "role", none), surface: "bg") out.push(( st: st, body: if st.upper { upper(raw) } else { raw }, // Only an explicit `true` makes a block droppable; anything else is mandatory. optional: _get(b, "optional", false) == true, )) } out } // --------------------------------------------------------------------------- // The unbreakable-word cap // --------------------------------------------------------------------------- // // ⚠️ Why this exists. `fit` accepts a candidate when `measure(width: w, ...)` reports a // width within `w` — but Typst CLAMPS the reported width of an overfull paragraph to the // region it was measured in. A single word too wide to break (`DELL'AUTUNNO` at 47 pt in // a 75 mm column) therefore measures as fitting, and then draws straight across the // artwork. Measured: word 380.5 pt, column 212.3 pt, `measure().width` = 212.3 pt. // // Typst cannot hyphenate its way out either: `hyphenate: auto` only applies with // justification, and posters are set ragged. So the size has to be capped BEFORE the fit, // from the width of the longest word — which is exact, since advance width is linear in // size. /// Widest single word of `body` at `size`/`wd`, as an absolute length. #let widest-word(st, size, wd, words) = { let widest = 0pt for word in words { let m = measure(text(.._text-args(st.family, size, st.weight, st.tracking, none, wd), word)) widest = calc.max(widest, m.width) } widest } /// Largest size — and the widest `wdth` instance — at which every word of `body` fits in /// `w`. Returns `(size, wdth)`, where `wdth` is handed to `fit` as `natural-wdth`, i.e. /// as a CEILING: the fit may still narrow further, it may never go wider than this. /// /// Narrowing is spent before size: a condensed headline in a narrow column is a /// typographic decision, a small one is a defeat. #let word-cap(st, body, w) = { let words = body.split(regex("\\s+")).filter(x => x.trim() != "") if words.len() == 0 { return (size: st.size, wdth: 100) } let rng = _axis-range(st.axes.at("wdth", default: none)) // `fit` never goes past the family's natural instance, so 100 is the real ceiling. let hi = if rng == none { none } else { calc.min(calc.max(rng.at(0), rng.at(1)), 100) } let lo = if rng == none { none } else { calc.min(rng.at(0), rng.at(1)) } if widest-word(st, st.size, hi, words) <= w { return (size: st.size, wdth: 100) } if rng != none and lo < hi { let narrow = widest-word(st, st.size, lo, words) if narrow <= w { // The axis alone can save it: find the widest instance that still fits. Bisection // rather than interpolation because `avar` makes the axis non-linear. let a = lo let z = hi let i = 0 while i < 8 { let mid = (a + z) / 2 if widest-word(st, st.size, mid, words) <= w { a = mid } else { z = mid } i += 1 } return (size: st.size, wdth: a) } // Axis exhausted: stay at the narrow end and pay the rest in size. return (size: st.size * (w / narrow) * 0.99, wdth: lo) } // No axis to spend: scale the size down by exactly the overflow. let plain = widest-word(st, st.size, hi, words) (size: st.size * (w / plain) * 0.99, wdth: 100) } /// A block resolved against a specific column width: its capped size, the `wdth` ceiling /// and the argument dictionary to spread into `fit`. /// /// Must be called from a `context` block: it measures. #let tune(it, w) = { let cap = word-cap(it.st, it.body, w) let k = cap.size / it.st.size let args = it.st.args args.size = cap.size args.min-size = it.st.min-size * k ( st: it.st, body: it.body, optional: it.optional, wdth: cap.wdth, args: args, // Height it takes at its capped size and at the NARROWEST width the fit may use — // the best case `fit` can reach without shrinking. Budgeting against the best case is // what lets a long Italian title keep its size by narrowing instead of being handed // three lines' worth of room and dutifully filling them. wants: { let rng = _axis-range(it.st.axes.at("wdth", default: none)) let wd = if rng == none { none } else { calc.min(calc.min(rng.at(0), rng.at(1)), cap.wdth) } let h = measure(width: w, { set par(leading: it.st.leading, linebreaks: "optimized", justify: false) text(.._text-args(it.st.family, cap.size, it.st.weight, it.st.tracking, none, wd), it.body) }).height calc.min(h, MAX-LINES.at(it.st.role, default: 2) * cap.size * LINE-FACTOR) }, ) } // --------------------------------------------------------------------------- // Budgeting the stack // --------------------------------------------------------------------------- /// Space above block `i`. A title is followed by a wider gap: the reader needs to see /// where the headline stops and the practical information starts. #let gap-before(items, i, gap) = { if i == 0 { return 0pt } if items.at(i - 1).st.role == "title" { gap * 1.7 } else { gap } } /// Take `deficit` out of `values`, but never below `floor` of their own size. /// Returns `(values, remaining-deficit)`. #let take-from(values, deficit, floor) = { let sum = values.sum(default: 0pt) if sum <= 0pt or deficit <= 0pt { return (values, deficit) } let take = calc.min(deficit, sum * (1 - floor)) let k = (sum - take) / sum (values.map(v => v * k), deficit - take) } /// Budget the type stack into `max-h`. /// /// 1. Measure what every block wants (word-capped, best-case width). /// 2. While the stack cannot be squeezed into the band, drop the LAST `optional: true` /// block — the spec orders blocks by priority, so the last optional one is the least /// missed. A block is never dropped for a shortfall a mild squeeze can absorb. /// 3. Squeeze in stages: air first, then the supporting blocks, and only then the /// headline. A poster survives tight leading; it does not survive a small title. /// /// Whatever `fit` is finally handed, it enforces — this only decides who pays. #let budget(items, w, gap, max-h) = { let plan(list) = { let tuned = list.map(it => tune(it, w)) let hs = tuned.map(it => it.wants) let gs = range(tuned.len()).map(i => gap-before(tuned, i, gap)) (tuned, hs, gs, hs.sum(default: 0pt) + gs.sum(default: 0pt)) } let (tuned, hs, gs, total) = plan(items) // Drop optional blocks, last first, while even a full squeeze would not be enough. while total > max-h * (1 + SQUEEZE-ALLOWANCE) { let drop = none for i in range(tuned.len()) { if tuned.at(i).optional { drop = i } } if drop == none { break } let kept = tuned.slice(0, drop) + tuned.slice(drop + 1) let (t2, h2, g2, tot2) = plan(kept) tuned = t2 hs = h2 gs = g2 total = tot2 } if total > max-h { let deficit = total - max-h // 1. The air between blocks. let (gs2, d1) = take-from(gs, deficit, GAP-FLOOR) gs = gs2 // 2. Everything that is not the headline. let d2 = d1 if d2 > 0pt { let minor = range(hs.len()).filter(i => tuned.at(i).st.role != "title") let vals = minor.map(i => hs.at(i)) let (vals2, rest) = take-from(vals, d2, MINOR-FLOOR) for (n, i) in minor.enumerate() { hs.at(i) = vals2.at(n) } d2 = rest } // 3. Last resort: everything, uniformly. `fit` shrinks the type into it. if d2 > 0pt { let (hs2, _) = take-from(hs, d2, 0) hs = hs2 } } (items: tuned, heights: hs, gaps: gs, height: hs.sum(default: 0pt) + gs.sum(default: 0pt)) } /// Draw a budgeted stack from `(x, y)`, top-anchored, in a `w`-wide column. #let draw-stack(plan, x, y, w, align-to) = { let cy = y for i in range(plan.items.len()) { let it = plan.items.at(i) cy += plan.gaps.at(i) place( top + left, dx: x, dy: cy, block( width: w, fit( it.body, w, plan.heights.at(i), it.st.family, it.st.axes, align-to: align-to, natural-wdth: it.wdth, ..it.args, ), ), ) cy += plan.heights.at(i) } } // --------------------------------------------------------------------------- // Art panel // --------------------------------------------------------------------------- /// The inset artwork, its keyline, and — only when the renderer measured the art as busy /// — a soft scrim along the edge that faces the type. /// /// `fit: "cover"` in a fixed-size, clipped block: the artwork fills the panel at its own /// aspect ratio and the overflow is cropped, so a 4:5 generation is never distorted to /// fill a 2.5:1 cover panel. /// /// The scrim here is NOT a legibility fix — no text ever crosses this panel. It is a /// vignette that settles a noisy image into the frame colour instead of letting it butt /// against it, which is exactly the case (`needs_scrim`) the renderer flags. #let art-panel(spec, pal, w, h, short, scrim-edge) = { let path = _get(spec, "art_file", none) let has-art = type(path) == str and path.trim() != "" let keyline = calc.max(0.2pt, short * KEYLINE-RATIO) block( width: w, height: h, clip: true, // The keyline sits on the panel edge, in ink at low strength: it defines the picture // without competing with it. Over a dark frame it reads light and over a light frame // dark, because `ink` is already the contrast-checked colour for this background. stroke: keyline + pal.ink.transparentize(72%), // No artwork yet (a text-only draft): a flat tint keeps the composition honest // instead of leaving a hole where the picture goes. fill: if has-art { none } else { pal.ink.transparentize(88%) }, { if has-art { image(path, width: 100%, height: 100%, fit: "cover") } if _get(spec, "needs_scrim", false) == true and scrim-edge != none { if scrim-edge == "bottom" { // Solid at the panel's bottom edge, fading up into the picture. place(bottom + left, scrim(h * 0.34, pal.scrim, 90deg, width: 100%, strength: 62%)) } else { // Landscape: solid at the left edge, fading right — away from the type column. place(top + left, scrim(h, pal.scrim, 180deg, width: w * 0.30, strength: 62%)) } } }, ) } // --------------------------------------------------------------------------- // Logo reserve // --------------------------------------------------------------------------- /// `logo-place` anchors the logo at the safe corner, which in this template is inside the /// frame margin — the right place for it. But the frame is only ~7% of the short edge and /// a logo may be up to 40%, so the layout has to be told to keep out of its corner. /// /// Returns the reserve as a length (`0pt` when there is no logo) plus its corner. The /// reserve is a SQUARE of the logo's width, because `logo-place` constrains width only: /// a taller-than-wide logo can still exceed it, which costs a little artwork and never a /// word of type. #let logo-reserve(spec, short) = { let logo = _get(spec, "logo", none) if type(logo) != dictionary { return (size: 0pt, corner: none) } let path = _get(logo, "path", none) if type(path) != str or path.trim() == "" { return (size: 0pt, corner: none) } let raw = _get(logo, "scale", 0.12) let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 } let corner = _get(logo, "corner", "br") ( size: short * s, corner: if corner in ("tl", "tr", "bl", "br") { corner } else { "br" }, ) } // --------------------------------------------------------------------------- // Composition // --------------------------------------------------------------------------- #let compose(spec) = context { let pal = palette-of(spec, surface: "bg") let sa = safe-area(spec) let short = sa.short-edge let b = sa.bleed let tw = sa.trim-width let th = sa.trim-height let frame = frame-of(spec) let gap = short * GAP-RATIO let lg = logo-reserve(spec, short) let items = entries(spec) // Landscape gets the side composition. The threshold sits above 1 on purpose: a // 1080x1350 ig-post is "wide" only arithmetically and reads as a portrait. let landscape = (tw / th) >= 1.2 if landscape { // ---- SIDE: type column left, art right ------------------------------------- let inner-w = tw - 2 * frame let col-w = inner-w * COL-RATIO let art-x = frame + col-w + gap let art-w = tw - frame - art-x let art-y = frame let art-h = th - 2 * frame // A logo on the right would sit on the artwork: give it a full-height gutter instead. // Landscape art is wide and shallow, so width is the cheap axis here. if lg.corner in ("tr", "br") and lg.size > 0pt { art-w = calc.max(inner-w * 0.30, art-w - lg.size - gap) } // A logo on the left sits in the type column: shorten the column at that end. let col-y = art-y let col-h = art-h if lg.corner == "tl" and lg.size > 0pt { col-y += lg.size + gap * 0.6 col-h -= lg.size + gap * 0.6 } else if lg.corner == "bl" and lg.size > 0pt { col-h -= lg.size + gap * 0.6 } let plan = budget(items, col-w, gap, calc.max(0pt, col-h)) // Optically centre the stack in the column: a top-anchored column beside a full-height // picture reads as if the type has slipped. let sy = col-y + calc.max(0pt, (col-h - plan.height) / 2) place(top + left, dx: b + art-x, dy: b + art-y, art-panel(spec, pal, art-w, art-h, short, "left")) draw-stack(plan, b + frame, b + sy, col-w, left) } else { // ---- STACKED: art on top, type band in the bottom margin -------------------- // A logo in a top corner would land on the picture, so the top frame grows to clear // it; in a bottom corner it gets its own strip beneath the type band, which is how // civic posters carry their patron's mark anyway. let clearance = lg.size + gap * 0.6 let top-extra = if lg.corner in ("tl", "tr") { clearance } else { 0pt } let bottom-extra = if lg.corner in ("bl", "br") { clearance } else { 0pt } let art-x = frame let art-y = frame + top-extra let art-w = tw - 2 * frame let band-bottom = th - frame - bottom-extra // The band gets what it asks for, capped so the picture keeps its floor. What it does // not use goes to the artwork rather than to slack: a short title means a big picture. let room = band-bottom - art-y - gap - th * ART-MIN let max-band = calc.max(th * 0.12, calc.min(th * BAND-MAX, room)) let plan = budget(items, art-w, gap, max-band) let band-y = band-bottom - plan.height let art-h = calc.max(th * 0.12, band-y - gap - art-y) place(top + left, dx: b + art-x, dy: b + art-y, art-panel(spec, pal, art-w, art-h, short, "bottom")) draw-stack(plan, b + art-x, b + band-y, art-w, left) } } // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- #let pal = palette-of(spec, surface: "bg") #let sa = safe-area(spec) // `date: none` is the load-bearing line for byte-identical output — without it the PDF // carries a creation timestamp and every golden-file test becomes a coin toss. #set document(date: none) #set text(lang: "it", fill: pal.ink) #set par(linebreaks: "optimized") #set page( width: sa.trim-width, height: sa.trim-height, margin: 0pt, bleed: sa.bleed, // The frame colour IS the page, so it covers the bleed too and the guillotine can land // anywhere in those 3 mm without exposing white. fill: pal.bg, background: compose(spec), foreground: { // Aligned to the frame's inner edge, not to the safe corner: the logo then shares a // margin with the picture and the type instead of hanging out towards the trim. The // composition reserves exactly this much room for it. logo-place(spec, margin: sa.bleed + frame-of(spec)) crop-marks(spec) }, ) // The composition lives entirely in `background:`/`foreground:`, which resolve against the // full bleed page. The body only has to exist so that Typst emits the page. #v(0pt)