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, 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": typeset, letter, epigraph, pullquote, callout, break-scene
8// #show: typeset.with(justified: true, indented: true)
9
10// ── Foundation ──────────────────────────────────────────────────────────────
11
12// @s tokens
13#let ink = rgb("#1a1a1a")
14#let ink-muted = rgb("#5a5a5a")
15#let ink-faint = rgb("#8a8a8a")
16#let rule-color = rgb("#c9c4bd")
17#let rule-strong = rgb("#6f6a64")
18#let wash = rgb("#f4f1ec")
19#let accent = rgb("#7a1f1f")
20
21#let serif = "EB Garamond"
22#let sans = "Source Sans 3"
23#let mono = "IBM Plex Mono"
24
25// A template supplies one of these. Every size in the document comes from it,
26// so nothing downstream has to know which template is in force.
27#let scale-single-column = (
28 xs: 8pt, sm: 9.5pt, base: 11pt,
29 h4: 12pt, h3: 14pt, h2: 18pt, h1: 24pt,
30 leading: 1.45, space: 11pt,
31)
32
33// Two columns: every step comes down. A 77mm column carries 40 characters at
34// 11pt — below the 45-character floor — so the base drops to 9.5pt for 47.
35#let scale-two-column = (
36 xs: 7pt, sm: 8.5pt, base: 9.5pt,
37 h4: 9.5pt, h3: 11pt, h2: 13pt, h1: 20pt,
38 leading: 1.4, space: 9.5pt,
39)
40
41// Defaults, for the standalone helpers below.
42#let base-size = scale-single-column.base
43#let sm = scale-single-column.sm
44#let xs = scale-single-column.xs
45
46// One unit of vertical space: 11pt, one line of base leading.
47#let sp = 11pt
48
49// The spec states line height as a baseline-to-baseline advance. Typst measures
50// leading between line boxes, so pinning the box to the band from the baseline
51// up to 1em makes the advance font-independent:
52// advance = top-edge - bottom-edge + leading = 1em + leading
53// so leading = (line_height - 1) em. 1.45 → 0.45em → 15.95pt at 11pt.
54#let leading-for(line-height) = (line-height - 1) * 1em
55
56#let oldstyle = (number-type: "old-style", number-width: "proportional")
57#let lining = (number-type: "lining", number-width: "proportional")
58#let tabular = (number-type: "lining", number-width: "tabular")
59// @e
60#let smcp = (features: ("smcp", "c2sc"))
61
62// ── Document ────────────────────────────────────────────────────────────────
63
64#let typeset(
65 scale: scale-single-column,
66 // Ragged right is the default. Justification buys a clean right edge at the
67 // cost of uneven word spacing; choose it for continuous prose at a full
68 // measure, and leave it off for a letter or a note addressed to a person.
69 justified: false,
70 indented: false,
71 numbered: false,
72 running-head: true,
73 folio: true,
74 // The measure, not the text width. The page leaves 160mm between its
75 // margins; the spec sets the column at 126mm and keeps the remainder as
76 // slack, which is where marginalia live. Pass `none` where the column IS
77 // the measure, as in two columns.
78 measure: 126mm,
79 doc,
80) = {
81 let sm = scale.sm
82 let xs = scale.xs
83 let sp = scale.space
84// @s page
85 set page(
86 paper: "a4",
87 margin: (top: 25mm, bottom: 25mm, inside: 28mm, outside: 22mm),
88 header: context {
89 // The running head follows the current level-2 heading, and is
90 // suppressed on the opening page.
91 if running-head and here().page() > 1 {
92 let seen = query(selector(heading.where(level: 2)).before(here()))
93 if seen.len() > 0 {
94 set text(font: sans, size: xs, fill: ink-faint, tracking: 0.08em)
95 align(center, upper(seen.last().body))
96 }
97 }
98 },
99 footer: context {
100 if folio and here().page() > 1 {
101 set text(font: serif, size: 9pt, fill: ink-muted, ..oldstyle)
102 align(center, counter(page).display())
103 }
104 },
105// @e
106 )
107
108// @s foundation
109 set text(
110 font: serif,
111 size: scale.base,
112 fill: ink,
113 top-edge: 1em,
114 bottom-edge: 0pt,
115 ..oldstyle,
116 )
117
118 set par(
119 leading: leading-for(scale.leading),
120 spacing: if indented { leading-for(scale.leading) } else { sp },
121 justify: justified,
122 first-line-indent: if indented { (amount: 1.5em, all: false) } else { 0pt },
123// @e
124 linebreaks: "optimized",
125 )
126
127 // Hyphenation is per-language and only ever paired with justification.
128 set text(hyphenate: justified)
129
130 // Widow and orphan control: never one line alone at a page edge.
131 set block(breakable: true)
132
133 if numbered {
134 set heading(numbering: "1.1 ")
135 }
136
137 // ── Headings ──────────────────────────────────────────────────────────────
138 // Space above is four times the space below; a heading belongs to what
139 // follows it. `sticky` is Typst's break-after: avoid.
140
141// @s headings
142 show heading: set text(font: sans, hyphenate: false)
143 show heading: set par(justify: false, first-line-indent: 0pt)
144 show heading: it => block(
145 above: sp * 2,
146 below: sp * 0.5,
147 sticky: true,
148 breakable: false,
149 it,
150 )
151
152 show heading.where(level: 1): set text(size: scale.h1, weight: 300, tracking: -0.015em)
153 show heading.where(level: 1): it => block(above: 0pt, below: sp * 0.5, sticky: true, it)
154 show heading.where(level: 2): set text(size: scale.h2, weight: 600, tracking: -0.01em)
155 show heading.where(level: 3): set text(size: scale.h3, weight: 600)
156 show heading.where(level: 4): set text(size: scale.h4, weight: 600)
157 show heading.where(level: 5): set text(
158 font: serif, size: scale.base, weight: 600, tracking: 0.06em, ..smcp,
159 )
160 show heading.where(level: 6): set text(
161// @e
162 font: serif, size: scale.base, weight: 400, style: "italic",
163 )
164
165 // ── Quotations ────────────────────────────────────────────────────────────
166
167// @s quotes
168 show quote.where(block: true): it => block(
169 above: sp * 1.25,
170 below: sp * 1.25,
171 inset: (left: sp * 1.5),
172 stroke: (left: 1pt + rule-color),
173 breakable: false,
174 {
175 set text(size: 0.955em)
176 it.body
177 if it.attribution != none {
178 set text(size: sm, fill: ink-muted, style: "normal")
179 set par(first-line-indent: 0pt, justify: false)
180 block(above: sp * 0.5, [— #it.attribution])
181 }
182 },
183 )
184
185// @e
186 // ── Lists ─────────────────────────────────────────────────────────────────
187
188// @s lists
189 set list(marker: ([#text(fill: ink-muted, weight: 700)[·]], [#text(fill: ink-muted)[–]]), indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25)
190 set enum(numbering: "1.", indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25, number-align: left)
191 show enum: set text(..tabular)
192
193 set terms(separator: linebreak(), indent: 0pt, hanging-indent: sp * 1.5, spacing: sp * 0.6)
194 show terms: set par(first-line-indent: 0pt)
195 show terms.item: it => block(above: sp * 0.6, below: 0pt, {
196 block(below: 0pt, text(font: sans, size: sm, weight: 600, it.term))
197 block(inset: (left: sp * 1.5), it.description)
198 })
199
200// @e
201 // ── Tables ────────────────────────────────────────────────────────────────
202 // Rules, not grids: a rule above the header, below the header, below the body.
203
204// @s tables
205 set table(
206 stroke: (x, y) => (bottom: if y == 0 { 1pt + rule-strong } else { 0.5pt + rule-color }),
207 inset: (left: 0pt, right: 0.7em, top: 0.45em, bottom: 0.45em),
208 align: left + horizon,
209 )
210 show table: set text(font: sans, size: sm, ..tabular)
211 show table: set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
212 show table.cell.where(y: 0): set text(size: xs, weight: 600, fill: ink-muted, tracking: 0.07em)
213 show table.cell.where(y: 0): upper
214
215// @e
216 // ── Figures & captions ────────────────────────────────────────────────────
217
218// @s figures
219 show figure: set block(above: sp * 1.5, below: sp * 1.5, breakable: false)
220 show figure.caption: it => block(
221 width: 100%,
222 inset: (top: 0.4em),
223 stroke: (top: 0.5pt + rule-color),
224 {
225 set text(font: sans, size: sm, fill: ink-muted)
226 set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
227 align(left, [#text(fill: ink, weight: 600, tracking: 0.06em, ..smcp)[#it.supplement #context it.counter.display()] — #it.body])
228 },
229 )
230
231// @e
232 // ── Code ──────────────────────────────────────────────────────────────────
233
234// @s codeblock
235 show raw: set text(font: mono, size: 0.86em, features: (liga: 0))
236 show raw.where(block: false): box.with(fill: wash, inset: (x: 0.28em), outset: (y: 0.1em), radius: 2pt)
237 show raw.where(block: true): it => block(
238 width: 100%,
239 fill: wash,
240 stroke: (left: 2pt + rule-strong),
241 inset: (x: 1em, y: 0.8em),
242 above: sp * 1.25,
243 below: sp * 1.25,
244 breakable: true,
245 { set text(size: 0.773em); set par(leading: leading-for(1.45), justify: false); it },
246 )
247
248// @e
249 // ── Inline ────────────────────────────────────────────────────────────────
250
251// @s inline
252 show strong: set text(weight: 600)
253 show link: it => underline(offset: 0.14em, stroke: 0.5pt, it)
254 show footnote: set text(fill: accent, size: 0.7em, ..lining)
255 set footnote.entry(separator: line(length: 30%, stroke: 0.5pt + rule-color))
256 show footnote.entry: set text(size: sm)
257
258// @e
259
260 // Running heads and folios still span the full text width; only the flow is
261 // constrained. The block is breakable, so pagination is unaffected.
262 if measure == none { doc } else { block(width: measure, doc) }
263}
264
265// ── Blocks the spec names but no engine provides ────────────────────────────
266
267#let epigraph(attribution: none, body) = context {
268 let u = text.size
269 block(above: 0pt, below: u * 2, width: 24em, {
270 set align(left)
271 set text(size: u * 0.91, style: "italic", fill: ink-muted)
272 set par(justify: false, first-line-indent: 0pt)
273 body
274 if attribution != none {
275 block(above: u * 0.5, text(style: "normal", size: u * 0.86)[— #attribution])
276 }
277 })
278}
279#let epigraph-right(attribution: none, body) = align(right, epigraph(attribution: attribution, body))
280
281#let pullquote(body) = context {
282 let u = text.size
283 block(
284 above: u * 1.5, below: u * 1.5, width: 100%,
285 inset: (y: u),
286 stroke: (top: 1.5pt + rule-strong, bottom: 0.5pt + rule-color),
287 {
288 set text(font: sans, size: u * 1.36, weight: 300)
289 set par(leading: leading-for(1.3), justify: false, first-line-indent: 0pt)
290 align(center, body)
291 },
292 )
293}
294
295#let verse(body) = block(
296 above: sp * 1.25, below: sp * 1.25,
297 inset: (left: sp * 2),
298 {
299 set par(justify: false, first-line-indent: 0pt, hanging-indent: 1.5em)
300 body
301 },
302)
303
304// @s callouts
305#let callout(title: none, warning: false, body) = context {
306 let u = text.size
307 block(
308 above: u * 1.25, below: u * 1.25, width: 100%,
309 fill: wash,
310 stroke: (
311 rest: 0.5pt + rule-color,
312 left: 2.5pt + (if warning { accent } else { rule-strong }),
313 ),
314 inset: (x: 1em, y: 0.75em),
315 breakable: false,
316 {
317 set text(size: u * 0.91)
318 if title != none {
319 block(below: 0.35em, text(
320 font: sans, size: u * 0.73, weight: 700, tracking: 0.09em,
321 fill: if warning { accent } else { ink-muted },
322 upper(title),
323 ))
324 }
325 body
326 },
327 )
328}
329// @e
330
331// Section breaks. A blank line cannot survive a page break, so the mark is
332// always visible.
333// @s breaks
334#let break-scene(kind: "asterisks") = block(above: sp * 1.5, below: sp * 1.5, sticky: true, width: 100%, align(center, {
335 if kind == "asterisks" { box(text(size: 10pt, fill: ink-faint, tracking: 0.6em)[\* \* \*]) }
336 else if kind == "asterism" { box(text(size: 14pt, fill: ink-faint)[⁂]) }
337 else if kind == "fleuron" { box(text(size: 12pt, fill: accent)[❦]) }
338 else if kind == "rule" { line(length: 100%, stroke: 0.5pt + rule-color) }
339}))
340// @e
341
342// Drop cap. Typst has no float, so the spec's three-line wrap is NOT
343// expressible here: text cannot flow around a raised initial. Two honest
344// options, and one trap:
345// * `dropcap` below sets the initial in the margin — a marginal initial. The
346// measure stays intact and the opening still gets its signal.
347// * The `droplet` package implements a true wrap by measuring lines. Use it
348// where the three-line wrap is required rather than approximated.
349// * Do NOT put the cap and the paragraph in a two-column grid: that narrows
350// the entire opening paragraph, not just its first three lines.
351// @s dropcap
352#let dropcap(body) = {
353 let letters = body.text
354 let initial = letters.first()
355 let rest = letters.slice(1)
356 block(above: 0pt, {
357 place(
358 left,
359 dx: -1.35em,
360 dy: -0.08em,
361 text(size: 3.05em, fill: accent, top-edge: "cap-height", bottom-edge: "baseline")[#initial],
362 )
363 par(first-line-indent: 0pt)[#rest]
364 })
365}
366// @e
367
368// A table that fills the measure and closes with a strong rule. Typst's stroke
369// function cannot see the row count, so it is passed in.
370#let ts-table(columns: none, rows: none, ..cells) = {
371 table(
372 columns: columns,
373 stroke: (x, y) => (bottom: if y == 0 or y == rows { 1pt + rule-strong } else { 0.5pt + rule-color }),
374 ..cells,
375 )
376}
377
378// @s two-column
379// Two columns, equal. Papers, journal articles, newsletters, technical notes.
380//
381// `front` is set full width before the columns begin — a title block, an
382// abstract, a level-1 heading. Everything in `doc` flows in two columns.
383//
384// Typst's own `page(columns: 2)` fixes the gutter at 4% of the page width
385// (8.4mm on A4). The spec says 6mm, so the body goes through `columns()`
386// instead, which takes an explicit gutter and still breaks across pages.
387#let two-column(
388 front: none,
389 column-rule: false,
390 justified: true,
391 numbered: false,
392 running-head: true,
393 folio: true,
394 doc,
395) = {
396 // Justification is not optional at a 47-character measure.
397 assert(justified, message: "two-column requires justification: at 47 characters a ragged edge serrates the column")
398
399 show: typeset.with(
400 scale: scale-two-column,
401 measure: none, // the column is the measure
402 justified: true,
403 indented: true, // a blank line costs 3% of a column
404 numbered: numbered,
405 running-head: running-head,
406 folio: folio,
407 )
408
409 if front != none {
410 front
411 v(scale-two-column.space, weak: true)
412 }
413
414 // A hairline where the columns need separating. Most journals omit it — the
415 // gutter is already doing the work. `columns()` has no rule of its own, so it
416 // is drawn on the page behind the text, at the centre of the gutter. The
417 // offset flips with page parity because the margins mirror for duplex.
418 if column-rule {
419 set page(background: context {
420 let inner = if calc.odd(here().page()) { 28mm } else { 22mm }
421 place(
422 top + left,
423 dx: inner + 77mm + 3mm,
424 dy: 25mm,
425 line(angle: 90deg, length: 247mm, stroke: 0.5pt + rule-color),
426 )
427 })
428 }
429
430 columns(2, gutter: 6mm, doc)
431}
432
433// A level-1 heading spans both columns, which in Typst means it must be a
434// parent-scoped float. That works in `front`, before the columns begin. A
435// level-1 heading in the BODY has to be wrapped in `span()` explicitly —
436// Typst cannot promote it out of the column flow on its own. In a paper the
437// body's section headings are level 2 anyway; level 1 is the title.
438//
439// Spans both columns. A spanning element costs a break in both, so it is opt-in
440// per instance and must sit at the top or the bottom of the page — never
441// mid-column, which makes the reader find their place twice.
442#let span(body, at-bottom: false) = place(
443 if at-bottom { bottom } else { top },
444 scope: "parent",
445 float: true,
446 block(width: 100%, body),
447)
448// @e
449
450// ── Front matter ────────────────────────────────────────────────────────────
451
452// @s frontmatter
453#let title-block(title: none, subtitle: none, author: none, place-date: none) = context {
454 let u = text.size
455 block(
456 below: u * 3, width: 100%,
457 inset: (bottom: u),
458 stroke: (bottom: 0.5pt + rule-color),
459 {
460 set par(justify: false, first-line-indent: 0pt)
461 if title != none { heading(level: 1, outlined: false, title) }
462 if subtitle != none {
463 block(below: u, text(font: sans, size: u * 1.27, weight: 300, fill: ink-muted, subtitle))
464 }
465 if author != none { text(tracking: 0.08em, ..smcp, author) }
466 if place-date != none {
467 block(above: 0.2em, text(font: sans, size: u * 0.86, fill: ink-muted, place-date))
468 }
469 },
470 )
471}
472// @e
473
474#let abstract(width: 30em, body) = context {
475 let u = text.size
476 block(below: u * 2, width: width, {
477 set text(size: u * 0.91, fill: ink-muted)
478 set par(justify: false, leading: leading-for(1.45), first-line-indent: 0pt)
479 block(below: 0.3em, text(font: sans, size: u * 0.73, weight: 700, tracking: 0.1em, fill: ink-faint)[ABSTRACT])
480 body
481 })
482}
483
484#let colophon(body) = context {
485 let u = text.size
486 block(
487 above: u * 3, width: 26em,
488 inset: (top: u),
489 stroke: (top: 0.5pt + rule-color),
490 {
491 set text(size: u * 0.86, style: "italic", fill: ink-muted)
492 set par(justify: false, first-line-indent: 0pt)
493 body
494 },
495 )
496}
497
498// ── Letter ──────────────────────────────────────────────────────────────────
499
500// @s letter
501#let letter-page(doc) = {
502 set page(
503 margin: (top: 32mm, bottom: 28mm, x: 25mm),
504 header: none,
505 footer: none,
506 )
507 doc
508}
509// @e
510
511// The sender block is address data, not a masthead: one style throughout, at
512// body size, in the reading face. Nothing bold, nothing in the sans.
513#let letterhead(body) = context {
514 let u = text.size
515 block(below: u * 2.5, {
516 set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
517 body
518 })
519}
520
521// A line under the date — a dedication, a feast, a devotion. It belongs to the
522// date, so it takes no gap of its own.
523#let date-note(body) = context {
524 let u = text.size
525 block(above: 0.1em, below: u * 1.5, {
526 set par(justify: false, first-line-indent: 0pt)
527 text(style: "italic", body)
528 })
529}
530
531#let address(label: none, body) = block(below: sp * 1.5, {
532 set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
533 if label != none {
534 block(below: 0.25em, text(font: sans, size: xs, tracking: 0.1em, fill: ink-faint, upper(label)))
535 }
536 body
537})
538
539// The typed name, with room above it to sign. No rule: a ruled line is a form
540// to be filled in, and this is a letter.
541#let signature(name) = context {
542 let u = text.size
543 block(above: u * 3, breakable: false, {
544 set par(justify: false, first-line-indent: 0pt)
545 name
546 })
547}
548
549// "Enc." introduces a sentence; it does not head a section.
550#let enclosures(body) = context {
551 let u = text.size
552 block(above: u * 2, {
553 set par(justify: false, first-line-indent: 0pt)
554 [Enc. ]
555 body
556 })
557}
558
559// A postscript is a sentence that happens to begin with "P.S." — the label is
560// not a heading, so it matches the text it introduces exactly.
561#let postscript(body) = context {
562 let u = text.size
563 block(above: u, {
564 set par(justify: false, first-line-indent: 0pt)
565 [P.S. ]
566 body
567 })
568}
569
570// ── Apparatus ───────────────────────────────────────────────────────────────
571
572// @s toc
573#let toc() = {
574 show outline.entry: set text(font: sans, size: sm)
575 outline(title: none, fill: repeat(gap: 0.4em)[.], indent: 1.5em)
576}
577// @e
578
579// @s notes
580// Anchored past the text column's right edge. In Typst the column width is
581// explicit (the `measure` argument), so the offset is taken from it directly.
582#let sidenote(body) = place(
583 right,
584 dx: 13em,
585 dy: -0.3em,
586 block(width: 11em, {
587 set text(font: sans, size: xs, fill: ink-muted)
588 set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
589 body
590 }),
591)
592// @e
593
594// ── Utilities ───────────────────────────────────────────────────────────────
595
596// @s utilities
597#let keep-together(body) = block(breakable: false, body)
598#let tie(body) = box(body)
599// @e