typeset.typ
The Typst implementation. Same specification, different engine — and Typst does the things CSS cannot: page-foot footnotes, running heads, and table-of-contents page numbers, all natively.
Typst needs the font files themselves. Unlike CSS there is no URL to point
at: a font is either in the project or it is not, and a missing family is
substituted silently. So this one ships as a bundle — the implementation, all
three example documents (essay, letter,
two-column — each with its rendered pages), and the three
font families the spec names, licences included.
Compile with typst compile --font-path fonts implementations/example-essay.typ out.pdf, or drag the unzipped folder into a typst.app project.
The bundle is built from this repository on every deploy, so it can never drift from the fonts and implementation above.
Download typeset.typ + fonts Versioned releases →1// typeset — Typst implementation of https://typeset.adilsoncarvalho.com
2//
3// The normative source is spec.json. Every value below is taken from it. Where
4// this file and the spec disagree, the spec is right.
5//
6// Usage:
7// #import "typeset.typ": *
8// #show: typeset
9//
10// Every name this file defines is a name a document may need, so the import is
11// a star import: a hand-picked list silently denies whichever name the next
12// document reaches for, and the failure reads as "unknown variable" rather than
13// as a missing import.
14//
15// typeset() takes the document's options — paper, margin, justified, indented,
16// numbered, measure and the rest, declared below. A document that sets any of
17// them writes `#show: typeset.with(...)` in place of the bare `#show: typeset`
18// above, never underneath it: typeset() sets the page, and set page() is
19// refused inside a container, which is what a second call would make of the
20// first. So a document on another paper, at another margin, opens with
21// `#show: typeset.with(paper: "a5", margin: margin-narrow)` and nothing else.
22
23// ── Foundation ──────────────────────────────────────────────────────────────
24
25// @s tokens
26#let ink = rgb("#1a1a1a")
27#let ink-muted = rgb("#5a5a5a")
28#let ink-faint = rgb("#8a8a8a")
29#let rule-color = rgb("#c9c4bd")
30#let rule-strong = rgb("#6f6a64")
31#let wash = rgb("#f4f1ec")
32#let accent = rgb("#7a1f1f")
33
34#let serif = "EB Garamond"
35#let sans = "Source Sans 3"
36#let mono = "IBM Plex Mono"
37
38// A template supplies one of these. Every size in the document comes from it,
39// so nothing downstream has to know which template is in force.
40#let scale-single-column = (
41 xs: 8pt, sm: 9.5pt, base: 11pt,
42 h4: 12pt, h3: 14pt, h2: 18pt, h1: 24pt,
43 leading: 1.45, space: 11pt, indent: 1.5em,
44)
45
46// Two columns: every step comes down. On A4 at the standard margin an 82mm
47// column carries 42 characters at 11pt — below the 45-character floor — so the
48// base drops to 9.5pt, which gives 49. The paragraph indent comes down with
49// them: an indent says "new paragraph" as a fraction of the line it sits on,
50// and 1.5em is 6.1% of an 82mm column against 4.5% of a 128mm measure.
51#let scale-two-column = (
52 xs: 7pt, sm: 8.5pt, base: 9.5pt,
53 h4: 9.5pt, h3: 11pt, h2: 13pt, h1: 20pt,
54 leading: 1.4, space: 9.5pt, indent: 1.25em,
55)
56
57// The scale the document is set in, published by _typeset-styles below and
58// read by the standalone paragraph functions. A per-block override owes the
59// same scale as the prose around it — block-spaced() inside a two-column
60// document owes that scale's 9.5pt gap, not the single-column 11pt — and
61// nothing at the call site says which scale is in force. A state is how this
62// file already carries a document-level decision down to a function that
63// needs one (ts-two-column-body, below).
64#let ts-scale = state("ts-scale", scale-single-column)
65
66// Defaults, for the standalone helpers below.
67#let base-size = scale-single-column.base
68#let sm = scale-single-column.sm
69#let xs = scale-single-column.xs
70
71// One unit of vertical space: 11pt, one line of base leading.
72#let sp = 11pt
73
74// The spec states line height as a baseline-to-baseline advance. Typst measures
75// leading between line boxes, so pinning the box to the band from the baseline
76// up to 1em makes the advance font-independent:
77// advance = top-edge - bottom-edge + leading = 1em + leading
78// so leading = (line_height - 1) em. 1.45 → 0.45em → 15.95pt at 11pt.
79#let leading-for(line-height) = (line-height - 1) * 1em
80
81// Paragraph separation is one decision expressed as two par properties, never
82// both at once: a gap (spaced) or an indent (indented). `typeset()`'s own
83// `indented` option and the two standalone functions below (block-spaced,
84// block-indented) both read this, so the whole-document switch and the
85// per-block override can never drift apart into two different indents.
86// spec.json's templates.two-column.element_overrides.paragraph is what makes
87// the indent a scale field rather than a literal: a template states its own,
88// and there has to be somewhere for it to live.
89//
90// Every number here belongs to the scale in force, never to the module: a
91// document on the two-column scale gets ITS 1.4 leading, ITS 9.5pt gap and
92// ITS 1.25em indent, and the standalone functions read the same scale out of
93// ts-scale. The defaults are the single-column scale's own, for a caller with
94// no scale in hand at all.
95#let _paragraphs-rule(indented, leading: 1.45, space: sp, indent: 1.5em) = if indented {
96 (spacing: leading-for(leading), first-line-indent: (amount: indent, all: false))
97} else {
98 (spacing: space, first-line-indent: 0pt)
99}
100
101#let oldstyle = (number-type: "old-style", number-width: "proportional")
102#let lining = (number-type: "lining", number-width: "proportional")
103#let tabular = (number-type: "lining", number-width: "tabular")
104// @e
105#let smcp = (features: ("smcp", "c2sc"))
106
107// ── Page ────────────────────────────────────────────────────────────────────
108
109// Paper and margin are independent choices. A margin is millimetres, and the
110// same millimetres on every paper, so
111// #show: typeset.with(paper: "a5", margin: margin-narrow)
112// asks for both without either one knowing about the other.
113//
114// Symmetric is what a document gets unless it asks otherwise: the named size
115// on all four sides. Duplex is the opt-in for a sheet that will be bound — top
116// and bottom keep the symmetric value, and the sides split into an inner
117// (binding) edge and an outer one, which Typst mirrors by page parity. Each
118// duplex pair sums to twice its symmetric value, so a document keeps the same
119// text width, and the same line breaks, whichever of the two it chooses.
120//
121// The prefix names the family, matching typeset.css's `typeset--margin-narrow`.
122// `narrow` and `wide` also name measure variants, which are a different axis:
123// one word must not reach both.
124#let margin-narrow = (top: 10mm, bottom: 10mm, left: 10mm, right: 10mm)
125#let margin-standard = (top: 20mm, bottom: 20mm, left: 20mm, right: 20mm)
126#let margin-wide = (top: 30mm, bottom: 30mm, left: 30mm, right: 30mm)
127
128#let margin-duplex-narrow = (top: 10mm, bottom: 10mm, inside: 13mm, outside: 7mm)
129#let margin-duplex-standard = (top: 20mm, bottom: 20mm, inside: 23mm, outside: 17mm)
130#let margin-duplex-wide = (top: 30mm, bottom: 30mm, inside: 33mm, outside: 27mm)
131
132// The numbers stay reachable behind the names: `margin` takes any length or
133// any Typst margin dictionary, so `margin: 18mm` is there for a printer who
134// needs a measurement rather than a name.
135
136// Every paper this spec is set for, width before height, under the name Typst
137// knows it by — "us-letter" is the spec's Letter. The two-column derivation is
138// arithmetic on this table, so a paper reaches that arithmetic by being added
139// here.
140#let paper-sizes-mm = (
141 "a4": (210mm, 297mm),
142 "a5": (148mm, 210mm),
143 "us-letter": (215.9mm, 279.4mm),
144)
145
146// The measure — one column's ideal line length. Three named widths, matching
147// typeset.css's own three measure classes: standard is the default, narrow
148// and wide are the modifiers a document opts into. All three are em, the
149// axis spec.json states them in and typeset.css already uses; an em measure
150// follows a scale change, where an mm one would not.
151#let measure-standard = 33em
152#let measure-narrow = 27em
153#let measure-wide = 40em
154
155// The measure typeset(sidenotes: true) narrows to — spec.json's
156// note-sidenote.properties.measure_when_active, tools/check.mjs gate 22
157// holds it to that value. Its own name rather than a reuse of measure-narrow:
158// the two happen to agree today, but they state different things (a column
159// narrowed to taste, against the exact width sidenotes need freed), and a
160// future edit to either must not silently move the other.
161#let measure-sidenote = 27em
162
163// The margin typeset(sidenotes: true) reserves on the right, unsplit, for
164// note-sidenote() to place into — spec.json's
165// note-sidenote.properties.reserved_margin ("14em — the gap plus the note,
166// with an em to spare"), gated the same way as measure-sidenote above.
167#let sidenote-reserved-margin = 14em
168
169// The page's own text block, published by _typeset-styles — which is where a
170// layout() at the top of the document measures it directly, so nothing here
171// has to do arithmetic over the several shapes a margin can take, and a
172// duplex margin's two different sides come out right on both parities.
173#let ts-text-width = state("ts-text-width", none)
174
175// Whether the current document opted into typeset(sidenotes: true).
176// note-sidenote() reads this and refuses to place a note where nothing
177// reserved it room — see note-sidenote's own comment, near its definition,
178// for why a silent placement is the same failure centring was fixing,
179// with a different cause.
180#let ts-sidenotes-active = state("ts-sidenotes-active", false)
181
182// The fourth width, and the only one that is not a number: the full text
183// block. A length here could only ever be ONE paper's — A4 at the standard
184// margin leaves 170mm — and the paper is a document's to choose, so the same
185// constant runs 62mm wider than an A5 sheet's text block, 42mm of that off
186// the sheet entirely, and stops 6mm short of a Letter one's, both in silence.
187// So it is `auto`, resolved against the page actually in force at the point
188// it is used.
189#let measure-full = auto
190
191// `width` here and typeset()'s `measure` are one axis under two names, on
192// purpose. typeset(measure:) is a policy and a MAXIMUM — _typeset-styles
193// clamps it to the page, so a document on a narrower sheet keeps its margins
194// — while `width` is exactly the width asked for and is never clamped. That
195// is what makes a deliberate overset expressible at all, which is what
196// src/demos/foundation.typ shows; naming it `measure` would promise a clamp
197// it does not perform.
198//
199// A document reaches for one of the four names instead of a literal width:
200// `#measured[...]` for the default column, `#measured(width:
201// measure-wide)[...]` for a modifier, `#measured(width: measure-full)[...]`
202// for the full text block — the PAGE's, not the enclosing block's, which is
203// what makes it the way a passage steps outside the document's own measure.
204// Where no template has published one (a bare document, or inside two
205// columns, where the column is the measure), the enclosing block is the only
206// text block there is and measure-full is that.
207//
208// Every branch centres its block, per foundation.rhythm.measure_position: a
209// width narrower than the ambient container sits in the middle of it, not
210// flush left, the same rule _typeset-styles applies to the document's own
211// measure above. Because that ambient container is itself centred in the
212// page's text width, a width WIDER than it — measure-full's whole point — ends
213// up centred in the page too, sharing one axis rather than compounding two
214// different left edges.
215//
216// Centring is `pad()`, on purpose, not `align(center, ..)`: align sets the
217// ambient alignment, and a paragraph's ragged side reads that same ambient
218// state to decide where ITS OWN slack goes — align(center) on the block
219// would have every line of ragged-right prose centre individually, each by
220// half of ITS OWN shortfall, which is a document set at `justified: false`
221// quietly rendered as centred text instead. `pad()` moves the box without
222// touching that state: a negative inset is what lets a wider escape
223// (measure-full, inside a narrower ambient) push past its container
224// symmetrically, which a plain `block()` positioned by alignment cannot do
225// at all.
226#let measured(width: measure-standard, body) = if width != auto {
227 layout(size => {
228 let inset = (size.width - width) / 2
229 pad(left: inset, right: inset, block(width: width, body))
230 })
231} else {
232 context {
233 let full = ts-text-width.get()
234 if full == none { layout(size => block(width: size.width, body)) }
235 else {
236 layout(size => {
237 let inset = (size.width - full) / 2
238 pad(left: inset, right: inset, block(width: full, body))
239 })
240 }
241 }
242}
243
244// ── Document ────────────────────────────────────────────────────────────────
245
246// A document is a page and a set of styles, and the two do not reach equally
247// far. set page() takes effect only where a page can be set, never inside a
248// container; the styles apply wherever content is composed, columns() included.
249// They are separate functions because a template composes them in its own
250// order: typeset() sets the page and holds the body to a measure, while
251// two-column() has to set the page, draw the optional column rule on it, and
252// only then set the styles the columns are composed in.
253
254#let _typeset-page(
255 paper: "a4",
256 margin: margin-standard,
257 scale: scale-single-column,
258 running-head: true,
259 folio: true,
260 doc,
261) = {
262 let xs = scale.xs
263// @s page
264 set page(
265 paper: paper,
266 margin: margin,
267 header: context {
268 // The running head follows the current level-2 heading, and is
269 // suppressed on the opening page.
270 if running-head and here().page() > 1 {
271 let seen = query(selector(heading.where(level: 2)).before(here()))
272 if seen.len() > 0 {
273 set text(font: sans, size: xs, fill: ink-faint, tracking: 0.08em)
274 align(center, upper(seen.last().body))
275 }
276 }
277 },
278 footer: context {
279 if folio and here().page() > 1 {
280 set text(font: serif, size: 9pt, fill: ink-muted, ..oldstyle)
281 align(center, counter(page).display())
282 }
283 },
284// @e
285 )
286
287 doc
288}
289
290// Everything a document is set in: the scale, the paragraph shape, every named
291// style, and the measure the flow is held to. Both templates call this for the
292// half that is not the page, so a second template can never drift into a second
293// set of these rules — they are applied exactly once, by whichever template
294// owns the body, which matters because they are not idempotent: two
295// applications of the heading rule wrap every heading in two blocks.
296
297#let _typeset-styles(
298 scale: scale-single-column,
299 justified: false,
300 indented: false,
301 numbered: false,
302 measure: measure-standard,
303 sidenotes: false,
304 doc,
305) = {
306 let sm = scale.sm
307 let xs = scale.xs
308 let sp = scale.space
309
310 // The scale reaches the standalone paragraph functions, which take no
311 // arguments and so cannot be told which one is in force.
312 ts-scale.update(scale)
313
314// @s foundation
315 set text(
316 font: serif,
317 size: scale.base,
318 fill: ink,
319 top-edge: 1em,
320 bottom-edge: 0pt,
321 ..oldstyle,
322 )
323
324 set par(
325 leading: leading-for(scale.leading),
326 .._paragraphs-rule(indented, leading: scale.leading, space: scale.space, indent: scale.indent),
327 justify: justified,
328// @e
329 linebreaks: "optimized",
330 )
331
332 // Hyphenation is per-language and only ever paired with justification.
333 set text(hyphenate: justified)
334
335 // Widow and orphan control: never one line alone at a page edge.
336 set block(breakable: true)
337
338 set heading(numbering: if numbered { "1.1 " } else { none })
339
340 // ── Headings ──────────────────────────────────────────────────────────────
341 // Space above is four times the space below; a heading belongs to what
342 // follows it. `sticky` is Typst's break-after: avoid.
343
344// @s heading
345 show heading: set text(font: sans, hyphenate: false)
346 show heading: set par(justify: false, first-line-indent: 0pt)
347 show heading: it => block(
348 above: sp * 2,
349 below: sp * 0.5,
350 sticky: true,
351 breakable: false,
352 it,
353 )
354
355 show heading.where(level: 1): set text(size: scale.h1, weight: 300, tracking: -0.015em)
356 show heading.where(level: 1): it => block(above: 0pt, below: sp * 0.5, sticky: true, it)
357 show heading.where(level: 2): set text(size: scale.h2, weight: 600, tracking: -0.01em)
358 show heading.where(level: 3): set text(size: scale.h3, weight: 600)
359 show heading.where(level: 4): set text(size: scale.h4, weight: 600)
360 show heading.where(level: 5): set text(
361 font: serif, size: scale.base, weight: 600, tracking: 0.06em, ..smcp,
362 )
363 show heading.where(level: 6): set text(
364// @e
365 font: serif, size: scale.base, weight: 400, style: "italic",
366 )
367
368 // ── Quotations ────────────────────────────────────────────────────────────
369
370// @s quote
371 show quote.where(block: true): it => block(
372 above: sp * 1.25,
373 below: sp * 1.25,
374 inset: (left: sp * 1.5),
375 stroke: (left: 1pt + rule-color),
376 breakable: false,
377 {
378 set text(size: 0.955em)
379 it.body
380 if it.attribution != none {
381 set text(size: sm, fill: ink-muted, style: "normal")
382 set par(first-line-indent: 0pt, justify: false)
383 block(above: sp * 0.5, [— #it.attribution])
384 }
385 },
386 )
387
388// @e
389 // ── Lists ─────────────────────────────────────────────────────────────────
390
391// @s list
392 set list(marker: ([#text(fill: ink-muted, weight: 700)[·]], [#text(fill: ink-muted)[–]]), indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25)
393 set enum(numbering: "1.", indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25, number-align: left)
394 show enum: set text(..tabular)
395
396 set terms(separator: linebreak(), indent: 0pt, hanging-indent: sp * 1.5, spacing: sp * 0.6)
397 show terms: set par(first-line-indent: 0pt)
398 show terms.item: it => block(above: sp * 0.6, below: 0pt, {
399 block(below: 0pt, text(font: sans, size: sm, weight: 600, it.term))
400 block(inset: (left: sp * 1.5), it.description)
401 })
402
403// @e
404 // ── Tables ────────────────────────────────────────────────────────────────
405 // Rules, not grids: a rule above the header, below the header, below the body.
406
407// @s table
408 set table(
409 stroke: (x, y) => (bottom: if y == 0 { 1pt + rule-strong } else { 0.5pt + rule-color }),
410 inset: (left: 0pt, right: 0.7em, top: 0.45em, bottom: 0.45em),
411 align: left + horizon,
412 )
413 show table: set text(font: sans, size: sm, ..tabular)
414 show table: set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
415 show table.cell.where(y: 0): set text(size: xs, weight: 600, fill: ink-muted, tracking: 0.07em)
416 show table.cell.where(y: 0): upper
417
418// @e
419 // ── Figures & captions ────────────────────────────────────────────────────
420
421// @s figure
422 show figure: set block(above: sp * 1.5, below: sp * 1.5, breakable: false)
423 show figure.caption: it => block(
424 width: 100%,
425 inset: (top: 0.4em),
426 stroke: (top: 0.5pt + rule-color),
427 {
428 set text(font: sans, size: sm, fill: ink-muted)
429 set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
430 align(left, [#text(fill: ink, weight: 600, tracking: 0.06em, ..smcp)[#it.supplement #context it.counter.display()] — #it.body])
431 },
432 )
433
434// @e
435 // ── Code ──────────────────────────────────────────────────────────────────
436
437// @s codeblock
438 show raw: set text(font: mono, size: 0.86em, features: (liga: 0))
439 show raw.where(block: false): box.with(fill: wash, inset: (x: 0.28em), outset: (y: 0.1em), radius: 2pt)
440 show raw.where(block: true): it => block(
441 width: 100%,
442 fill: wash,
443 stroke: (left: 2pt + rule-strong),
444 inset: (x: 1em, y: 0.8em),
445 above: sp * 1.25,
446 below: sp * 1.25,
447 breakable: true,
448 { set text(size: 0.773em); set par(leading: leading-for(1.45), justify: false); it },
449 )
450
451// @e
452 // ── Inline ────────────────────────────────────────────────────────────────
453
454// @s inline
455 show strong: set text(weight: 600)
456 show link: it => underline(offset: 0.14em, stroke: 0.5pt, it)
457 show footnote: set text(fill: accent, size: 0.7em, ..lining)
458 set footnote.entry(separator: line(length: 30%, stroke: 0.5pt + rule-color))
459 show footnote.entry: set text(size: sm)
460
461// @e
462
463 // Running heads and folios still span the full text width; only the flow is
464 // constrained. The block is breakable, so pagination is unaffected. The width
465 // is taken against the page actually in force rather than against A4, and the
466 // measure is resolved to absolute units first, because it is as often given
467 // in ems as in millimetres — except measure-full, which is `auto` and IS the
468 // page, so it is the width this layout() just measured.
469 //
470 // Centred, per foundation.rhythm.measure_position: where the measure is
471 // narrower than the page's own text width, the surplus splits evenly on
472 // both sides rather than landing on the right alone. Two columns take the
473 // `measure == none` branch above and never reach this pad — the column IS
474 // the measure there, with no surplus to split.
475 //
476 // `pad()`, not `align(center, ..)`: align sets the ambient alignment a
477 // paragraph's ragged side reads to place its own slack, so aligning the
478 // whole block would centre every line of ragged-right prose individually
479 // instead of moving the block — see measured()'s own note on the same
480 // trap, just above.
481 //
482 // Sidenotes are the one exception to measure_position, and the spec names
483 // it as one: `sidenotes: true` narrows the measure to measure-sidenote and
484 // reserves sidenote-reserved-margin after it, unsplit — that band is the
485 // note's, not slack to centre away, the same reasoning typeset.css's own
486 // `.typeset--sidenotes` exemption states. ts-text-width is updated to the
487 // space left AFTER the reservation, so a #measured(width: measure-full)
488 // escape inside a sidenotes document respects the same margin
489 // note-sidenote() places into, instead of running text underneath it.
490 ts-sidenotes-active.update(sidenotes)
491 let measure = if sidenotes { measure-sidenote } else { measure }
492 if measure == none { doc } else {
493 layout(size => context {
494 if sidenotes {
495 let available = size.width - sidenote-reserved-margin.to-absolute()
496 ts-text-width.update(available)
497 block(width: calc.min(measure.to-absolute(), available), doc)
498 } else {
499 ts-text-width.update(size.width)
500 let clamped = if measure == auto { size.width }
501 else { calc.min(measure.to-absolute(), size.width) }
502 let inset = (size.width - clamped) / 2
503 pad(left: inset, right: inset, block(width: clamped, doc))
504 }
505 })
506 }
507}
508
509#let typeset(
510 // The paper by its Typst name, and the margin by one of the six names above
511 // — or by any length, for a page box the spec does not name.
512 paper: "a4",
513 margin: margin-standard,
514 scale: scale-single-column,
515 // Ragged right is the default. Justification buys a clean right edge at the
516 // cost of uneven word spacing; choose it for continuous prose at a full
517 // measure, and leave it off for a letter or a note addressed to a person.
518 justified: false,
519 indented: false,
520 numbered: false,
521 running-head: true,
522 folio: true,
523 // The measure, not the text width. A4 at the standard margin leaves 170mm
524 // between its margins; measure-standard is smaller and keeps the remainder
525 // as slack, which is where marginalia live. It is a maximum, so a page with
526 // less than measure-standard between its margins keeps its margins. Pass
527 // `measure-full` where the text block IS the measure, whatever the paper,
528 // and `none` where the column is, as in two columns.
529 measure: measure-standard,
530 // spec.json's note-sidenote.opt_in, this file's counterpart to
531 // typeset.css's `.typeset--sidenotes`: overrides `measure` to
532 // measure-sidenote and reserves sidenote-reserved-margin on the right,
533 // flush, for #note-sidenote() to place into. Forbidden in two-column,
534 // which has no `sidenotes` parameter to reach for — there is no margin
535 // to give (templates.two-column.forbidden.sidenote).
536 sidenotes: false,
537 doc,
538) = _typeset-page(
539 paper: paper,
540 margin: margin,
541 scale: scale,
542 running-head: running-head,
543 folio: folio,
544 _typeset-styles(
545 scale: scale,
546 justified: justified,
547 indented: indented,
548 numbered: numbered,
549 measure: measure,
550 sidenotes: sidenotes,
551 doc,
552 ),
553)
554
555// ── Inline: key ─────────────────────────────────────────────────────────────
556
557// A keycap's visual signature is its bottom edge, heavier than the other
558// three — 1.5pt against 0.5pt, both in rule-color. `stroke`'s dictionary
559// form is what lets one edge carry a different weight than its neighbours;
560// a single `<length> + <color>` stroke draws all four edges alike.
561//
562// The size wraps the box rather than the box's body. An em in `inset`
563// resolves against whatever size is in force where the box is declared, so a
564// size applied inside it leaves the padding measured against the surrounding
565// prose — 3.85pt at an 11pt base, where inline-kbd's 0.35em of a 0.85em
566// keycap is 3.27pt, an 18% overshoot. CSS has no such trap: `padding` on an
567// element resolves against that element's own computed font-size, which the
568// same rule set has already made 0.85em.
569#let key(body) = text(font: sans, size: 0.85em, box(
570 inset: (x: 0.35em, y: 0.15em),
571 radius: 2pt,
572 stroke: (rest: 0.5pt + rule-color, bottom: 1.5pt + rule-color),
573 body,
574))
575
576// ── Paragraphs ──────────────────────────────────────────────────────────────
577
578// `typeset()`'s own `indented` option applies one of these two to the whole
579// document. These exist for the one block that needs the OTHER convention —
580// the same reason `justified`/`ragged-right` exist for alignment. Spaced is
581// the document default and so needs no wrapper of its own for that reason,
582// but it still gets one: the way back, for one block, in a document set to
583// indented.
584//
585// Named for what they do to the reader's block, not for the spec's own
586// paragraph-spaced/paragraph-indented element ids: an author reaching for
587// one of these is asking "how does this block behave", never "which section
588// of the specification is this" — and a name tied to the element id would
589// also have to change the day that id does.
590//
591// `first-line-indent`'s `all: false` is what makes indented prose behave:
592// Typst withholds the indent from a paragraph that opens the document, and
593// from one right after a heading, a blockquote, a figure or a break, because
594// each of those is itself a block and a block resets the same state a
595// document start does. A `block()` wrapper here would do that AGAIN to the
596// first paragraph *inside* this function — flushing a paragraph that is not
597// actually after a heading, a bug this file's own conformance gate compiles
598// a probe to catch. So `body` below is scoped by `context`, which resolves
599// to its own body and carries no layout identity, never by `block()`. The
600// `context` is what lets these two read the document's scale out of
601// ts-scale: a gap or an indent is the active scale's, and neither function
602// takes an argument that could carry one.
603// @s paragraph
604#let block-spaced(body) = context {
605 let scale = ts-scale.get()
606 set par(.._paragraphs-rule(false, leading: scale.leading, space: scale.space, indent: scale.indent))
607 body
608}
609
610#let block-indented(body) = context {
611 let scale = ts-scale.get()
612 set par(.._paragraphs-rule(true, leading: scale.leading, space: scale.space, indent: scale.indent))
613 body
614}
615// @e
616
617// ── Alignment ───────────────────────────────────────────────────────────────
618
619// Ragged right is `typeset()`'s own document-wide default (its `justified`
620// option). These two exist for the one paragraph, list item, blockquote or
621// callout that needs the OTHER convention, so an author reaches for a name
622// instead of `#set par(justify: ..)` and `#set text(hyphenate: ..)` by hand —
623// the same reason block-spaced/block-indented exist for paragraph spacing.
624//
625// The `block-` prefix on that pair, and its absence here, is not drift.
626// Unprefixed, block-indented would be `indented` — already a parameter of
627// both functions below, so `#indented[..]` and `#justified(indented: true)[..]`
628// would be one word meaning two things in adjacent lines of a document, and
629// block-spaced follows its pair. The parameter-twin of `justified` lives on
630// typeset() and two-column(), which a document writes once at the top and
631// never beside a per-block override.
632//
633// Justification and hyphenation are a single decision, never two: hyphenate
634// is never a parameter here, only `lang`, because hyphenation is per-language
635// and requires the document language to be declared. `justified(lang: none)`
636// is how a document states that no dictionary is declared for this block —
637// honestly, not as a footgun to avoid, since that combination (justified,
638// unhyphenated) is exactly what opens rivers of white space down the page.
639// `ragged-right` takes the same `lang` parameter for the language-sensitive
640// typesetting `hyphenate` is not (quotation marks, spacing rules) — but never
641// hyphenates, `lang` or not: a hyphen exists to serve justification, and
642// breaks a word for no gain without it.
643//
644// These set ALIGNMENT. `indented` defaults to `auto`, which is the document's
645// own paragraph convention, left exactly as it is: an author who writes
646// `#justified[..]` inside a document set `typeset(indented: true)` asked for
647// one thing, and getting a flushed first line and an inserted gap along with
648// it is a second thing they never mentioned. A stated `indented:` delegates
649// to block-indented/block-spaced rather than restating the rule, so the
650// paragraph axis has exactly one owner however it is reached.
651//
652// `set align(left)` is not restating the ambient default. Alignment inherits,
653// and Typst resolves a justified paragraph's last line against that same
654// inherited alignment — so a block nested inside a centred or right-aligned
655// context that only set `justify` would flush its last line, or its only
656// line for a one-liner, to whatever alignment surrounds it, correct only
657// where that happens to already be left. Stating `align(left)` here is what
658// makes both functions' own alignment, and their own last-line alignment,
659// hold regardless of where they are nested — never true "by inheritance"
660// alone.
661//
662// Typst's `text()` exposes only `hyphenate: bool`, with no per-language
663// tuning surface — no parameter takes justification-justified's own
664// hyphenation_min_word_chars (6), hyphenation_min_chars_before_break (3),
665// hyphenation_min_chars_after_break (3) or max_consecutive_hyphens (2).
666// typeset.css enforces those same four numbers with `hyphenate-limit-chars`
667// and `hyphenate-limit-lines`; there is no Typst engine feature this file can
668// set in their place.
669// @s justification
670#let ragged-right(indented: auto, lang: "en", body) = {
671 set align(left)
672 set par(justify: false)
673 if lang != none { set text(lang: lang) }
674 set text(hyphenate: false)
675 if indented == auto { body } else if indented { block-indented(body) } else { block-spaced(body) }
676}
677
678#let justified(indented: auto, lang: "en", body) = {
679 set align(left)
680 set par(justify: true)
681 if lang != none { set text(lang: lang) }
682 set text(hyphenate: lang != none)
683 if indented == auto { body } else if indented { block-indented(body) } else { block-spaced(body) }
684}
685// @e
686
687// ── Quotations: one function, four looks ────────────────────────────────────
688
689// Typst's own `quote` element function, published under a second name.
690// `#let quote(type:)` below replaces the element binding with an ordinary
691// function, and an ordinary function cannot carry `.where()` or stand in a
692// set rule — so without this name a document that imports this file could no
693// longer write `#show quote.where(block: true): ...` or `#set quote(...)`,
694// which is how a Typst author reaches a treatment the template does not
695// offer. `native-quote` is that handle: `#show native-quote.where(block:
696// true): it => ...` and `#set native-quote(...)` both select the same
697// elements `#quote(...)` produces. It is also what the definition below
698// calls, since `quote` inside it would name itself.
699#let native-quote = quote
700
701#let _quote(type: none, attribution: none, body) = if type == none {
702 // Always block: the spec defines only a block quote, no inline one. The
703 // show rule above (`quote.where(block: true)`) styles the element this
704 // produces and carries quote-attribution's own treatment.
705 native-quote(block: true, attribution: attribution, body)
706} else if type == "epigraph" {
707 // quote-epigraph's own block_alignment property is "flush right" — a
708 // property of the element, not a variant a caller opts into — so the
709 // type carries the alignment and there is no separate parameter for it.
710 align(right, context {
711 let u = text.size
712 block(above: 0pt, below: u * 2, width: 24em, {
713 set align(left)
714 set text(size: u * 0.91, style: "italic", fill: ink-muted)
715 set par(justify: false, first-line-indent: 0pt)
716 body
717 if attribution != none {
718 block(above: u * 0.5, text(style: "normal", size: u * 0.86)[— #attribution])
719 }
720 })
721 })
722} else if type == "pullquote" {
723 // Repeats body text lifted from elsewhere in the document, so it takes
724 // no attribution parameter.
725 context {
726 let u = text.size
727 block(
728 above: u * 1.5, below: u * 1.5, width: 100%,
729 inset: (y: u),
730 stroke: (top: 1.5pt + rule-strong, bottom: 0.5pt + rule-color),
731 {
732 set text(font: sans, size: u * 1.36, weight: 300)
733 set par(leading: leading-for(1.3), justify: false, first-line-indent: 0pt)
734 align(center, body)
735 },
736 )
737 }
738} else if type == "verse" {
739 // Sets the stanza and nothing else: no attribution parameter.
740 //
741 // quote-verse asks for a runover indent, so that a wrapped line cannot be
742 // mistaken for a line the poet wrote — in verse the breaks are the
743 // author's and carry meaning. The hanging-indent below is that request,
744 // and Typst does not grant it: the property is suppressed inside any
745 // container, and this is one. What renders is the left indent and the
746 // authored line breaks, with a wrapped line flush against the lines
747 // around it. spec.json's fallback on quote-verse states that, and
748 // tools/check.mjs gate 3m measures it in the compiled output, so the
749 // property staying here is a live claim rather than a dormant one: the
750 // day Typst honours it, that gate goes red and the fallback comes off.
751 block(
752 above: sp * 1.25, below: sp * 1.25,
753 inset: (left: sp * 2),
754 {
755 set par(justify: false, first-line-indent: 0pt, hanging-indent: 1.5em)
756 body
757 },
758 )
759} else {
760 panic("quote: unknown type " + repr(type)
761 + " — expected none, \"epigraph\", \"pullquote\" or \"verse\"")
762}
763
764// The public quote(). It also takes `block:`, because Pandoc's Typst writer
765// emits #quote(block: true)[…] for every `>` quotation; it must be true, since
766// the spec defines only a block quote. Kept apart from _quote() because a
767// parameter named `block` would hide the block() its branches call.
768#let quote(type: none, attribution: none, ..args) = {
769 assert(
770 args.pos().len() == 1 and args.named().keys().all(k => k == "block"),
771 message: "quote takes type:, attribution: and the quotation itself",
772 )
773 assert(
774 args.named().at("block", default: true) == true,
775 message: "typeset sets only block quotations — quote(block: false) has no inline form to set",
776 )
777 _quote(type: type, attribution: attribution, args.pos().first())
778}
779
780// @s callout
781#let callout(title: none, warning: false, body) = context {
782 let u = text.size
783 block(
784 above: u * 1.25, below: u * 1.25, width: 100%,
785 fill: wash,
786 stroke: (
787 rest: 0.5pt + rule-color,
788 left: 2.5pt + (if warning { accent } else { rule-strong }),
789 ),
790 inset: (x: 1em, y: 0.75em),
791 breakable: false,
792 {
793 set text(size: u * 0.91)
794 if title != none {
795 block(below: 0.35em, text(
796 font: sans, size: u * 0.73, weight: 700, tracking: 0.09em,
797 fill: if warning { accent } else { ink-muted },
798 upper(title),
799 ))
800 }
801 body
802 },
803 )
804}
805// @e
806
807// Section breaks. A blank line cannot survive a page break, so the mark is
808// always visible. The spec names four registers, so there are four symbols
809// here — a caller should not have to know a string literal to pick one.
810// @s break
811#let _break-mark(mark, size: 10pt, tracking: 0em, color: ink-faint) = block(
812 above: sp * 1.5, below: sp * 1.5, sticky: true, width: 100%,
813 align(center, box(text(size: size, fill: color, tracking: tracking, mark))),
814)
815
816#let break-asterisks() = _break-mark([\* \* \*], tracking: 0.6em)
817#let break-asterism() = _break-mark([⁂], size: 14pt)
818#let break-fleuron() = _break-mark([❦], size: 12pt, color: accent)
819#let break-rule() = block(above: sp * 1.5, below: sp * 1.5, sticky: true, width: 100%,
820 align(center, line(length: 100%, stroke: 0.5pt + rule-color)))
821// @e
822
823// Drop cap. Typst has no float, so the spec's three-line wrap is NOT
824// expressible here: text cannot flow around a raised initial. Two honest
825// options, and one trap:
826// * `dropcap` below sets the initial in the margin — a marginal initial. The
827// measure stays intact and the opening still gets its signal.
828// * The `droplet` package implements a true wrap by measuring lines. Use it
829// where the three-line wrap is required rather than approximated.
830// * Do NOT put the cap and the paragraph in a two-column grid: that narrows
831// the entire opening paragraph, not just its first three lines.
832// @s dropcap
833#let dropcap(body) = {
834 let letters = body.text
835 let initial = letters.first()
836 let rest = letters.slice(1)
837 block(above: 0pt, {
838 place(
839 left,
840 dx: -1.35em,
841 dy: -0.08em,
842 text(size: 3.05em, fill: accent, top-edge: "cap-height", bottom-edge: "baseline")[#initial],
843 )
844 par(first-line-indent: 0pt)[#rest]
845 })
846}
847// @e
848
849// A table that fills the measure and carries the three rules the spec asks
850// for: a strong one above the header, a strong one below it, and a strong one
851// below the last body row, with hairlines between the rows in between. Typst's
852// stroke function cannot see the row count, so it is passed in.
853#let ts-table(columns: none, rows: none, ..cells) = {
854 table(
855 columns: columns,
856 stroke: (x, y) => (
857 top: if y == 0 { 1pt + rule-strong },
858 bottom: if y == 0 or y == rows { 1pt + rule-strong } else { 0.5pt + rule-color },
859 ),
860 ..cells,
861 )
862}
863
864// @s two-column
865// Two columns, equal. Papers, journal articles, newsletters, technical notes.
866//
867// `front` is set full width before the columns begin — a title block, an
868// abstract, a level-1 heading. Everything in `doc` flows in two columns.
869//
870// Typst's own `page(columns: 2)` fixes the gutter at 4% of the page width
871// (8.4mm on A4). The spec says 6mm, so the body goes through `columns()`
872// instead, which takes an explicit gutter and still breaks across pages.
873
874// Whether the content being laid out right now is the two-column body (inside
875// `columns()`), as opposed to `front` or a single-column template — read by
876// frontmatter-title-block, frontmatter-abstract and frontmatter-colophon to
877// decide whether they need to promote themselves.
878#let ts-two-column-body = state("ts-two-column-body", false)
879
880// Promotes body content to a parent-scoped float spanning both columns. A
881// spanning element costs a break in both, so it is opt-in per instance for
882// spanning.optional content (figure, table, code block, pull quote) and must
883// sit at the top or the bottom of the page — never mid-column, which makes
884// the reader find their place twice.
885#let span(body, at-bottom: false) = place(
886 if at-bottom { bottom } else { top },
887 scope: "parent",
888 float: true,
889 block(width: 100%, body),
890)
891
892#let two-column(
893 paper: "a4",
894 margin: margin-standard,
895 front: none,
896 column-rule: false,
897 justified: true,
898 numbered: false,
899 running-head: true,
900 folio: true,
901 doc,
902) = {
903 // The gutter is the one number this template chooses. The column, the
904 // character count and the decision to set at all are functions of the paper
905 // and the margin, recomputed here rather than carried as a list of papers
906 // that work.
907 let gutter = 6mm
908 let floor = 45
909 let base = scale-two-column.base
910
911 // The foundation measure is 66 characters in 128mm at 11pt. Characters in a
912 // column follow from it, by the column's width and by the base in use.
913 let chars-in = width => int(calc.round(66 / 128 * (width / 1mm) * (11pt / base)))
914 let mm-of = value => {
915 let n = calc.round(value / 1mm, digits: 2)
916 if n == calc.round(n) { str(int(n)) } else { str(n) }
917 }
918
919 let size = paper-sizes-mm.at(lower(paper), default: none)
920 if size == none {
921 panic("two columns are derived from the paper's width, and \"" + paper
922 + "\" is not a paper this spec is set for: "
923 + paper-sizes-mm.keys().join(", "))
924 }
925
926 // The margin arrives as one of the six names, as a length, or as any Typst
927 // margin dictionary. The column needs the two horizontal sides; the optional
928 // rule needs all four.
929 let sides = if type(margin) == dictionary {
930 let rest = margin.at("rest", default: none)
931 let x = margin.at("x", default: rest)
932 let y = margin.at("y", default: rest)
933 (
934 top: margin.at("top", default: y),
935 bottom: margin.at("bottom", default: y),
936 inner: margin.at("inside", default: margin.at("left", default: x)),
937 outer: margin.at("outside", default: margin.at("right", default: x)),
938 )
939 } else {
940 (top: margin, bottom: margin, inner: margin, outer: margin)
941 }
942 for (side, value) in sides {
943 if type(value) != length {
944 panic("two columns are derived from the margin in millimetres, and `margin` gives "
945 + side + " as " + repr(value))
946 }
947 }
948
949 let text-width = size.at(0) - sides.inner - sides.outer
950 let column = (text-width - gutter) / 2
951 let chars = chars-in(column)
952
953 // Refused, not set badly. A column below the floor is a measure this spec
954 // does not allow, and the numbers that produced it belong in the message:
955 // the paper, the margin, the column, the count, the floor.
956 if chars < floor {
957 let named = (
958 ("narrow", margin-narrow),
959 ("standard", margin-standard),
960 ("wide", margin-wide),
961 ("duplex-narrow", margin-duplex-narrow),
962 ("duplex-standard", margin-duplex-standard),
963 ("duplex-wide", margin-duplex-wide),
964 )
965 let this-margin = "a " + mm-of(sides.inner) + "mm and " + mm-of(sides.outer) + "mm margin"
966 for (name, value) in named {
967 if margin == value { this-margin = "the " + name + " margin" }
968 }
969 // Which margins do reach the floor on this paper. A duplex pair has its
970 // symmetric pair's total, so it reaches exactly where that one does.
971 let reaching = ()
972 for (name, value) in (("narrow", margin-narrow), ("standard", margin-standard), ("wide", margin-wide)) {
973 if chars-in((size.at(0) - value.left - value.right - gutter) / 2) >= floor {
974 reaching.push(name)
975 }
976 }
977 let alternatives = if reaching.len() > 0 {
978 reaching.join(" and ") + " reach it on this paper"
979 } else {
980 "no named margin reaches it — " + upper(paper) + " cannot carry two columns at " + repr(base)
981 }
982 panic("two columns are refused on " + upper(paper) + " at " + this-margin + " — "
983 + mm-of(size.at(0)) + "mm of paper less " + mm-of(sides.inner) + "mm and "
984 + mm-of(sides.outer) + "mm leaves " + mm-of(text-width) + "mm of text, so a column is ("
985 + mm-of(text-width) + " - " + mm-of(gutter) + ") / 2 = " + mm-of(column)
986 + "mm, carrying " + str(chars) + " characters at " + repr(base) + ", below the "
987 + str(floor) + "-character floor; " + alternatives)
988 }
989
990 // Justification is not optional at a column measure.
991 assert(justified, message: "two-column requires justification: at " + str(chars)
992 + " characters a ragged edge serrates the column")
993
994 show: _typeset-page.with(
995 paper: paper,
996 margin: margin,
997 scale: scale-two-column,
998 running-head: running-head,
999 folio: folio,
1000 )
1001
1002 // A hairline where the columns need separating. Most journals omit it — the
1003 // gutter is already doing the work. `columns()` has no rule of its own, so it
1004 // is drawn on the page behind the text, at the centre of the gutter. The
1005 // offset flips with page parity because a duplex margin puts the inner edge
1006 // on the left of an odd page and on the right of an even one.
1007 //
1008 // The set rule is unconditional, with the condition inside its argument, and
1009 // it comes before any content: a set rule inside an `if` block applies only
1010 // within that block, which holds no content, and a page set rule that follows
1011 // content starts a new page.
1012 set page(background: if column-rule {
1013 context {
1014 let left-margin = if calc.odd(here().page()) { sides.inner } else { sides.outer }
1015 place(
1016 top + left,
1017 dx: left-margin + column + gutter / 2,
1018 dy: sides.top,
1019 line(
1020 angle: 90deg,
1021 length: size.at(1) - sides.top - sides.bottom,
1022 stroke: 0.5pt + rule-color,
1023 ),
1024 )
1025 }
1026 })
1027
1028 show: _typeset-styles.with(
1029 scale: scale-two-column,
1030 measure: none, // the column is the measure
1031 justified: true,
1032 indented: true, // a blank line costs 3% of a column
1033 numbered: numbered,
1034 )
1035
1036 if front != none {
1037 front
1038 v(scale-two-column.space, weak: true)
1039 }
1040
1041 // templates.two-column.spanning.always spans without a mark from here on:
1042 // frontmatter-title-block, frontmatter-abstract and frontmatter-colophon
1043 // read ts-two-column-body themselves (it carries subtitle, byline and
1044 // dateline too — they are that block's own parameters, not standalone
1045 // functions), and a body-level level-1 heading is promoted by the show rule
1046 // below. Both are scoped to `doc`; `front` above renders before the columns
1047 // begin, so it is already full width and never needs the wrap.
1048 ts-two-column-body.update(true)
1049 show heading.where(level: 1): it => span(it)
1050 columns(2, gutter: gutter, doc)
1051}
1052// @e
1053
1054// ── Front matter ────────────────────────────────────────────────────────────
1055
1056// @s frontmatter
1057#let frontmatter-title-block(title: none, subtitle: none, author: none, place-date: none) = context {
1058 let u = text.size
1059 let content = block(
1060 below: u * 3, width: 100%,
1061 inset: (bottom: u),
1062 stroke: (bottom: 0.5pt + rule-color),
1063 {
1064 set par(justify: false, first-line-indent: 0pt)
1065 if title != none { heading(level: 1, outlined: false, title) }
1066 if subtitle != none {
1067 block(below: u, text(font: sans, size: u * 1.27, weight: 300, fill: ink-muted, subtitle))
1068 }
1069 if author != none { text(tracking: 0.08em, ..smcp, author) }
1070 if place-date != none {
1071 block(above: 0.2em, text(font: sans, size: u * 0.86, fill: ink-muted, place-date))
1072 }
1073 },
1074 )
1075 if ts-two-column-body.get() { span(content) } else { content }
1076}
1077// @e
1078
1079#let frontmatter-abstract(width: 30em, body) = context {
1080 let u = text.size
1081 let content = block(below: u * 2, width: width, {
1082 set text(size: u * 0.91, fill: ink-muted)
1083 set par(justify: false, leading: leading-for(1.45), first-line-indent: 0pt)
1084 block(below: 0.3em, text(font: sans, size: u * 0.73, weight: 700, tracking: 0.1em, fill: ink-faint)[ABSTRACT])
1085 body
1086 })
1087 if ts-two-column-body.get() { span(content) } else { content }
1088}
1089
1090#let frontmatter-colophon(body) = context {
1091 let u = text.size
1092 let content = block(
1093 above: u * 3, width: 26em,
1094 inset: (top: u),
1095 stroke: (top: 0.5pt + rule-color),
1096 {
1097 set text(size: u * 0.86, style: "italic", fill: ink-muted)
1098 set par(justify: false, first-line-indent: 0pt)
1099 body
1100 },
1101 )
1102 if ts-two-column-body.get() { span(content, at-bottom: true) } else { content }
1103}
1104
1105// ── Bibliography ────────────────────────────────────────────────────────────
1106
1107// @s bibliography
1108// `references()` owns the block: the entries' size, leading and spacing, and
1109// the heading — a real level-2 heading, so it takes the document's own
1110// running-head and numbering behaviour, and spans both columns the way
1111// frontmatter-title-block and frontmatter-colophon do, because
1112// bibliography-heading (unlike heading-h2 itself) is on
1113// templates.two-column.spanning.always. `reference()` composes one entry —
1114// author roman, title italic, the rest in order — so the punctuation between
1115// them is this file's business, not the document's.
1116//
1117// It also asks for the entries' hanging indent, and Typst does not grant
1118// that: each entry is composed inside a block, and the property is
1119// suppressed inside any container, so a wrapped line sits flush with the
1120// surname instead of indented past it. spec.json's fallback on
1121// bibliography-entry states what renders, and tools/check.mjs gate 3m
1122// measures it.
1123#let references(title: [References], body) = context {
1124 let heading-content = heading(level: 2, title)
1125 if ts-two-column-body.get() { span(heading-content) } else { heading-content }
1126 {
1127 set text(size: sm)
1128 set par(
1129 justify: false,
1130 leading: leading-for(1.4),
1131 first-line-indent: 0pt,
1132 hanging-indent: 1.8em,
1133 )
1134 body
1135 }
1136}
1137
1138// One entry, `break_inside: avoid` — a citation split across a page break
1139// puts the author's surname on one page and the rest of the entry on the
1140// next, which is what makes a bibliography scannable — and
1141// `space_after: 0.55em` between entries, both bibliography-entry's own.
1142#let reference(
1143 author: none, title: none, edition: none,
1144 publisher: none, year: none, note: none,
1145) = block(breakable: false, below: 0.55em, {
1146 if author != none [#author. ]
1147 if title != none [#emph(title). ]
1148 if edition != none [#edition. ]
1149 if publisher != none [#publisher]
1150 if publisher != none and year != none [, ]
1151 if year != none [#year.]
1152 if note != none [ #note]
1153})
1154// @e
1155
1156// ── Letter ──────────────────────────────────────────────────────────────────
1157
1158// @s letter
1159// A letter's page: the standard margin plus letter-inset on every edge, the
1160// text justified at 12pt across the full width that leaves. The letterhead
1161// reaches back out by letter-inset (letter-addresses, letter-crest), so it
1162// stands at the standard margin and the text is framed inside it. A letter
1163// opens with this in place of `#show: typeset` — the two cannot be stacked,
1164// because typeset() sets the page and a page configuration underneath it
1165// sits inside a container, where Typst refuses one.
1166#let letter-inset = 15mm
1167#let letter-margin = (
1168 top: margin-standard.top + letter-inset,
1169 bottom: margin-standard.bottom + letter-inset,
1170 left: margin-standard.left + letter-inset,
1171 right: margin-standard.right + letter-inset,
1172)
1173#let scale-letter = (..scale-single-column, base: 12pt)
1174
1175// A quotation inside a letter: indented on both sides, italic and muted, with
1176// no rule. letter-page makes it the letter's block quote.
1177#let letter-quote(attribution: none, body) = block(
1178 above: 1.5em,
1179 below: 1.5em,
1180 inset: (left: 2em, right: 2em),
1181 {
1182 set text(style: "italic", fill: ink-muted, size: 1.05em)
1183 body
1184 if attribution != none {
1185 block(above: 0.5em, text(style: "normal", size: 0.86em)[— #attribution])
1186 }
1187 },
1188)
1189
1190#let letter-page(
1191 paper: "a4",
1192 margin: letter-margin,
1193 scale: scale-letter,
1194 justified: true,
1195 measure: measure-full,
1196 doc,
1197) = {
1198 show: typeset.with(
1199 paper: paper,
1200 margin: margin,
1201 scale: scale,
1202 justified: justified,
1203 measure: measure,
1204 running-head: false,
1205 folio: false,
1206 )
1207 show native-quote.where(block: true): it => letter-quote(attribution: it.attribution, it.body)
1208 doc
1209}
1210// @e
1211
1212// Splits line-broken content at its first `\`: the name line, then the rest.
1213// Content with no line break is all name.
1214#let _first-line(body) = {
1215 let kids = if body.has("children") { body.children } else { (body,) }
1216 let i = kids.position(k => k.func() == linebreak)
1217 if i == none { (body, none) } else { (kids.slice(0, i).join(), kids.slice(i + 1).join()) }
1218}
1219
1220// Address data, in the reading face: the name line in small caps at body size,
1221// the lines below it a step smaller. A hierarchy of data, not decoration —
1222// nothing bold, nothing in the sans, nothing larger than the text.
1223#let _address-lines(body) = {
1224 let (name, rest) = _first-line(body)
1225 text(..smcp, name)
1226 if rest != none {
1227 linebreak()
1228 text(size: 0.85em, rest)
1229 }
1230}
1231
1232#let letter-sender(body) = context {
1233 let u = text.size
1234 block(below: u * 2.5, {
1235 set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
1236 _address-lines(body)
1237 })
1238}
1239
1240#let letter-address-block(label: none, body) = block(below: sp * 1.5, {
1241 set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
1242 if label != none {
1243 block(below: 0.25em, text(font: sans, size: xs, tracking: 0.1em, fill: ink-faint, upper(label)))
1244 }
1245 _address-lines(body)
1246})
1247
1248// The letterhead sits margin-standard from the paper's edge, or at the text's
1249// own edge where that is nearer the paper. Read from where the text actually
1250// starts, so a wide margin and a centred measure both count.
1251
1252// The sender's and the recipient's address, by role rather than by label.
1253#let letter-address-from(body) = letter-address-block(label: [From], body)
1254#let letter-address-to(body) = letter-address-block(label: [To], body)
1255
1256// The column of sender and recipient blocks.
1257#let letter-addresses(body) = context {
1258 let reach = calc.max(0pt, here().position().x - margin-standard.left)
1259 block(inset: (left: -reach), body)
1260}
1261
1262// A crest or monogram, top right of the first page. `body` is the image
1263// itself; recolour an SVG with svg-recolor first.
1264// Placed twice so it takes no room in the flow: the outer place spans the text
1265// box, which is what layout measures, and the inner one sets the crest.
1266#let letter-crest(width: 20mm, body) = place(top + left, layout(size => context {
1267 let text-right = here().position().x + size.width
1268 let reach = calc.max(0pt, page.width - margin-standard.right - text-right)
1269 block(width: size.width, place(top + right, dx: reach, block(width: width, body)))
1270}))
1271
1272// The date, and beneath it the saint of the day, closed with ", ora pro
1273// nobis" — one paragraph, so the saint is the date's next line rather than a
1274// block of its own. Only the date is required; without a saint there is no
1275// second line.
1276#let _date-note-text(body) = text(size: 0.9em, fill: ink-muted, style: "italic", body)
1277
1278#let letter-date(date, saint: none) = block(above: 3em, below: 2em, {
1279 set par(justify: false, first-line-indent: 0pt)
1280 date
1281 if saint not in (none, "", []) {
1282 linebreak()
1283 _date-note-text[#saint, ora pro nobis]
1284 }
1285})
1286
1287// The note alone, after a date set as its own paragraph: one line's leading
1288// below it, as though it were the date's next line.
1289#let letter-date-note(body) = context {
1290 let u = text.size
1291 block(above: leading-for(1.45), below: 2em, {
1292 set par(justify: false, first-line-indent: 0pt)
1293 _date-note-text(body)
1294 })
1295}
1296
1297// The person's name, after a greeting — "Dear" unless another is given —
1298// and closed with a comma:
1299// #letter-salutation[Fr Lai] Dear Fr Lai,
1300// #letter-salutation("Hey, bro")[Fr Lai] Hey, bro Fr Lai,
1301#let letter-salutation(..args) = {
1302 let pos = args.pos()
1303 assert(
1304 pos.len() in (1, 2) and args.named().len() == 0,
1305 message: "letter-salutation takes the name, optionally after a greeting: "
1306 + "letter-salutation[Fr Lai] or letter-salutation(\"Hey\")[Fr Lai]",
1307 )
1308 let (greeting, name) = if pos.len() == 2 { pos } else { ("Dear", pos.first()) }
1309 block(above: 2em, below: 2em, {
1310 set par(justify: false, first-line-indent: 0pt)
1311 [#greeting #name,]
1312 })
1313}
1314
1315// The closing words — "Yours sincerely" unless others are given — and a comma:
1316// #letter-closing() Yours sincerely,
1317// #letter-closing[Cowabunga] Cowabunga,
1318#let letter-closing(..args) = {
1319 let pos = args.pos()
1320 assert(
1321 pos.len() <= 1 and args.named().len() == 0,
1322 message: "letter-closing takes the closing words, or none for Yours sincerely: "
1323 + "letter-closing() or letter-closing[With love]",
1324 )
1325 let words = if pos.len() == 1 { pos.first() } else [Yours sincerely]
1326 block(above: 3em, below: 2em, {
1327 set par(justify: false, first-line-indent: 0pt)
1328 [#words,]
1329 })
1330}
1331
1332// The typed name, with room above it to sign. No rule: a ruled line is a form
1333// to be filled in, and this is a letter.
1334#let letter-signature(name) = context {
1335 let u = text.size
1336 block(above: u * 3, breakable: false, {
1337 set par(justify: false, first-line-indent: 0pt)
1338 name
1339 })
1340}
1341
1342// "Enc." introduces a sentence; it does not head a section.
1343#let letter-enclosures(body) = context {
1344 let u = text.size
1345 block(above: u * 2, {
1346 set par(justify: false, first-line-indent: 0pt)
1347 [Enc. ]
1348 body
1349 })
1350}
1351
1352// A postscript is a sentence that happens to begin with "P.S." — the label is
1353// not a heading, so it matches the text it introduces exactly. It stands off
1354// the signature by the same 3em the signature stands off the closing.
1355#let letter-postscript(body) = context {
1356 let u = text.size
1357 block(above: u * 3, {
1358 set par(justify: false, first-line-indent: 0pt)
1359 [P.S. ]
1360 body
1361 })
1362}
1363
1364// ── The letter template ─────────────────────────────────────────────────────
1365
1366// A whole letter from its parts, in the order a letter takes them: crest,
1367// addresses, date and saint, salutation, the body, closing, signature,
1368// enclosures and postscript. Each part is optional except the body, and a
1369// part left out is not set at all. The closing is the one part with a
1370// default: `auto` sets "Yours sincerely,", `none` sets no closing, and words
1371// set those words and a comma. Leave every part out and write them in the
1372// body instead to use letter() for its page and its phrase rules alone.
1373//
1374// #show: letter.with(
1375// sender: ("Adilson Carvalho", "10 Wentworth Avenue", "Australia"),
1376// addressee: ("The Registrar", "88 Rundle Street", "Adelaide SA 5000"),
1377// date: "27 August 2026",
1378// salutation: "Registrar",
1379// )
1380//
1381// Addresses take an array of lines or line-broken content. `accent` and
1382// `smallcaps` take phrases to restyle wherever they occur in the body, so a
1383// letter written in Markdown can mark a phrase without markup of its own.
1384#let _letter-accent-ink = accent
1385#let _lines(value) = if type(value) == array { value.join(linebreak()) } else { value }
1386
1387// Restyles phrases in `body` alone: each `accent` phrase in the accent colour,
1388// each `smallcaps` phrase in small caps. Exact and case-sensitive, and a
1389// phrase matches inside a longer word as well. letter() applies it to the
1390// body; a letter written part by part wraps its own paragraphs in it, so a
1391// phrase in the date, an address or the postscript keeps its own setting.
1392// #letter-phrases(accent: ("good and faithful servant",))[…paragraphs…]
1393#let letter-phrases(accent: (), smallcaps: (), body) = {
1394 let styled = accent.fold(body, (b, phrase) => {
1395 show phrase: text.with(fill: _letter-accent-ink)
1396 b
1397 })
1398 smallcaps.fold(styled, (b, phrase) => {
1399 show phrase: it => text(..smcp, lower(it))
1400 b
1401 })
1402}
1403
1404#let letter(
1405 paper: "a4",
1406 margin: letter-margin,
1407 crest: none,
1408 sender: none,
1409 addressee: none,
1410 date: none,
1411 saint: none,
1412 greeting: "Dear",
1413 salutation: none,
1414 closing: auto,
1415 signature: none,
1416 enclosures: none,
1417 postscript: none,
1418 accent: (),
1419 smallcaps: (),
1420 doc,
1421) = {
1422 show: letter-page.with(paper: paper, margin: margin)
1423
1424 // The phrase rules reach the body alone. The parts set from parameters —
1425 // addresses, saint, salutation, postscript — keep the writer's own words.
1426 let body = letter-phrases(accent: accent, smallcaps: smallcaps, doc)
1427
1428 if crest != none { letter-crest(crest) }
1429 if sender != none or addressee != none {
1430 letter-addresses({
1431 if sender != none { letter-address-from(_lines(sender)) }
1432 if addressee != none { letter-address-to(_lines(addressee)) }
1433 })
1434 }
1435 if date != none { letter-date(date, saint: saint) }
1436 if salutation != none { letter-salutation(greeting, salutation) }
1437
1438 body
1439
1440 if closing == auto { letter-closing() } else if closing != none { letter-closing(closing) }
1441 if signature != none { letter-signature(signature) }
1442 if enclosures != none { letter-enclosures(enclosures) }
1443 if postscript != none { letter-postscript(postscript) }
1444}
1445
1446// ── Apparatus ───────────────────────────────────────────────────────────────
1447
1448// @s toc
1449#let toc() = {
1450 show outline.entry: set text(font: sans, size: sm)
1451 set outline.entry(fill: repeat(gap: 0.4em)[.])
1452 outline(title: none, indent: 1.5em)
1453}
1454// @e
1455
1456// @s note
1457// The note's own width and its gap from the column's right edge — spec.json's
1458// note-sidenote.properties.width and the "2em" in its properties.position,
1459// gated the same way as measure-sidenote and sidenote-reserved-margin above.
1460#let sidenote-note-width = 11em
1461#let sidenote-gap = 2em
1462
1463// Anchored past the text column's right edge, per spec.json's own words for
1464// note-sidenote's position ("2em past the text column's own right edge"), NOT
1465// past the measure: `measure` is a maximum, so where the reserved margin is
1466// the binding constraint the column is narrower than it, and an offset taken
1467// from the measure would land on top of the text — the same trap the spec's
1468// own note names.
1469//
1470// Requires typeset(sidenotes: true): that option is what reserves
1471// sidenote-reserved-margin for this note to land in. Without it, dx: 13em
1472// places the note wherever the ambient measure's own right edge happens to
1473// be, which nothing has set aside — on top of following text, off the page,
1474// or (worse) just barely inside it, indistinguishable from correct until
1475// someone measures the margin. A silent, ungoverned placement is exactly the
1476// failure centring the measure was fixing, with a different cause, so this
1477// refuses instead.
1478#let note-sidenote(body) = context {
1479 assert(
1480 ts-sidenotes-active.get(),
1481 message: "note-sidenote() was called without typeset(sidenotes: true) — sidenotes reserve "
1482 + repr(sidenote-reserved-margin) + " of margin for this note to land in "
1483 + "(spec.json's note-sidenote.reserved_margin), and nothing reserves it without the opt-in",
1484 )
1485 place(
1486 right,
1487 dx: sidenote-gap + sidenote-note-width,
1488 dy: -0.3em,
1489 block(width: sidenote-note-width, {
1490 set text(font: sans, size: xs, fill: ink-muted)
1491 set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
1492 body
1493 }),
1494 )
1495}
1496// @e
1497
1498// ── Utilities ───────────────────────────────────────────────────────────────
1499
1500// @s utility
1501#let utility-keep-together(body) = block(breakable: false, body)
1502#let utility-tie(body) = box(body)
1503// @e
1504
1505// An SVG with one colour swapped for another — a crest drawn in a single ink,
1506// set in the letter's accent. `data` is the file's text: pass read("…") from
1507// the document, so the path resolves against the document, not this file.
1508#let svg-recolor(data, from: "#231f20", to: accent, ..args) = image(
1509 bytes(data.replace(from, to.to-hex())),
1510 ..args.named(),
1511)