499 lines
24 KiB
Typst
499 lines
24 KiB
Typst
// 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
|
|
|
|
/// The base vertical rhythm, and the spacing unit for the footer line.
|
|
#let gap = sa.short-edge * 0.016
|
|
|
|
/// Space after a block, before the next one. A flat gap makes a poster read as a list:
|
|
/// the break after a 38 mm headline has to be bigger than the break after a 7 mm detail
|
|
/// line or the title runs straight into the subtitle. So the gap is the base rhythm or a
|
|
/// third of the block's own type size, whichever is larger — which in practice means the
|
|
/// title (and only the title) buys itself real air.
|
|
#let _gap-raw(e) = calc.max(gap, e.style.size * 0.32)
|
|
#let _gap-sum(list) = {
|
|
if list.len() < 2 { return 0pt }
|
|
let s = 0pt
|
|
for e in list.slice(0, list.len() - 1) { s += _gap-raw(e) }
|
|
s
|
|
}
|
|
|
|
/// 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.
|
|
/// Total space the gaps take for a candidate stack, capped so the air can never outweigh
|
|
/// the words: without the cap a panel of nothing but large roles would spend most of its
|
|
/// height on the spaces between them.
|
|
#let gaps-of(list) = calc.min(_gap-sum(list), flow-h * 0.35)
|
|
|
|
#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 + gaps-of(l)
|
|
}
|
|
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 - gaps-of(flow))
|
|
|
|
/// The factor the cap in `gaps-of` implies for each individual gap, so the drawn spacing
|
|
/// and the space the budgets were computed against are always the same number.
|
|
#let _gap-scale = {
|
|
let raw = _gap-sum(flow)
|
|
if raw > 0pt { gaps-of(flow) / raw } else { 1.0 }
|
|
}
|
|
#let gap-after(e) = _gap-raw(e) * _gap-scale
|
|
#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 `wdth` value `fit` will START from for this family: the natural instance (100),
|
|
/// or the axis extreme if the axis does not reach it. `none` when the family has no
|
|
/// width axis, in which case Typst draws the default instance and no variation is set.
|
|
///
|
|
/// This has to be the WIDEST width `fit` can use, not the narrowest. `fit` spends the
|
|
/// axis only when the paragraph is too TALL for its box; a block with a generous height
|
|
/// budget — a two-block spec where the title owns most of the panel — never narrows at
|
|
/// all, so a cap computed at the condensed end would not bind and the word would run off
|
|
/// the page. Measured at ig-story: "DELL'AUTUNNO" left the panel by 4.8 mm that way.
|
|
#let _wdth-start(axes) = {
|
|
if type(axes) != dictionary { return none }
|
|
let a = axes.at("wdth", default: none)
|
|
let pair = if type(a) == array and a.len() >= 2 { (a.at(0), a.at(1)) }
|
|
else if type(a) == dictionary {
|
|
let lo = a.at("min", default: a.at("lo", default: none))
|
|
let hi = a.at("max", default: a.at("hi", default: none))
|
|
if lo != none and hi != none { (lo, hi) } else { none }
|
|
}
|
|
else if type(a) in (int, float) { (a, a) }
|
|
else { none }
|
|
if pair == none { return none }
|
|
let lo = calc.min(..pair)
|
|
let hi = calc.max(..pair)
|
|
calc.max(lo, calc.min(hi, 100))
|
|
}
|
|
|
|
/// 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, which is the only way to get its true width past the clamp
|
|
/// in (2), at the width `fit` starts from. `fit` may then narrow further and gain
|
|
/// room back; it can never need more.
|
|
#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 start-wdth = _wdth-start(st.axes)
|
|
let probe(word) = measure(text(
|
|
..(if st.family != none { (font: st.family) } else { (:) }),
|
|
..(if start-wdth != none { (variations: (wdth: start-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 blocks, in spec order, each auto-fitted into its slice of the panel.
|
|
///
|
|
/// `set block(spacing: 0pt)` is not cosmetic: Typst's default 1.2em block spacing is
|
|
/// measured against the AMBIENT text size, which in a page background is the 11pt
|
|
/// default and has nothing to do with this poster. All vertical rhythm here is `gap`,
|
|
/// which is a fraction of the trim, so it must be the only spacing in play.
|
|
#let stack-body = {
|
|
set block(spacing: 0pt)
|
|
set par(spacing: 0pt)
|
|
for (i, e) in flow.enumerate() {
|
|
if i > 0 { v(gap-after(flow.at(i - 1)), weak: 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 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.
|
|
///
|
|
/// The blocks are NOT pinned to their budgets, only fitted inside them: each takes its
|
|
/// natural height, so the slack a short block leaves collapses into one pool of air that
|
|
/// the centring then splits evenly, instead of being scattered as trailing gaps. Since
|
|
/// every fitted height is at most its budget and the budgets plus gaps were normalised
|
|
/// to `flow-h`, the stack cannot be taller than the flow area. Measured, with the full
|
|
/// seven-block spec: 256/313 pt at yt-thumb, 352/500 at A3, 320/414 at ig-story.
|
|
///
|
|
/// The one way past that bound is a spec whose blocks cannot fit even at the floor sizes
|
|
/// `block-style` allows — reachable only with an absurd `safeMm`, since the optional
|
|
/// blocks are dropped first. It then spills symmetrically over the seam and the colophon
|
|
/// rather than overlapping itself, which is the failure worth having: visibly wrong, but
|
|
/// with nothing silently deleted from an event poster.
|
|
/// `breakable: false` is not decoration. A breakable block with a fixed height whose
|
|
/// content does not fit is split across REGIONS, and a page background offers exactly
|
|
/// one — so the remainder is re-laid at the same origin and the blocks print on top of
|
|
/// each other. Measured with `safeMm: 80`: subtitle, date and venue all piled onto the
|
|
/// same three lines. Unbreakable, the same spec simply overflows in one piece.
|
|
#let type-stack = block(
|
|
width: panel.width,
|
|
height: flow-h,
|
|
breakable: false,
|
|
align(horizon + left, stack-body),
|
|
)
|
|
|
|
/// 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 }
|
|
// The height is pinned as well as the width. Bottom-anchored content that is free to
|
|
// grow would grow UPWARD into the stack, which is exactly the collision the reserve
|
|
// above exists to prevent.
|
|
place(bottom + left, dx: x, dy: -sa.y, block(
|
|
width: w,
|
|
height: calc.max(foot-text-h, 1mm),
|
|
align(bottom, 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)
|
|
},
|
|
)
|