510 lines
21 KiB
Typst
510 lines
21 KiB
Typst
// 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)
|