Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/doc.rs

291 KiB, 1861 runs

created by r1870400018:35995, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The authoring layer: blocks of prose above the box-glue-penalty stream.
2//!
3//! [`driver::Document`](crate::driver::Document) is the composed form -- a flat vertical stream the
4//! two-pass driver paginates. This module sits above it. An author writes a [`Block`] list --
5//! headings and paragraphs -- and [`author`] turns each block into the stream: a heading is shaped
6//! bold and larger, its identity recorded as a [`Heading`](crate::ledger::AnchorKind::Heading)
7//! anchor so a running head or a table of contents can later find its page; a paragraph is set into
8//! justified lines by [`break_paragraph`](crate::linebreak::break_paragraph).
9//!
10//! Two facts a reader could not derive. A heading is kept with the first line of its paragraph by
11//! setting the two inside one unbreakable box, so the driver's greedy page breaker never leaves a
12//! heading stranded at a page foot (the widow guard). And the page furniture -- the running head and
13//! the folio -- is added by [`decorate`] after the document has converged, because it lives in the
14//! margins, outside the text block, and so cannot disturb the pagination it describes. The running
15//! head is TeX's `\mark` reimplemented through the ledger: the section current at the top of a page
16//! is the most recent heading the ledger resolved to an earlier page.
17
18use crate::bib::Bibliography;
19use crate::driver::{
20 Document,
21 FootStyle,
22};
23use crate::font::ShapedText;
24use crate::ir::{
25 BoxNode,
26 ColumnsNode,
27 Dims,
28 DrawOp,
29 FloatNode,
30 FloatPlacement,
31 FloatScope,
32 Floating,
33 PageColumns,
34 Footnote,
35 Glue,
36 Graphic,
37 Leaf,
38 LeafKind,
39 Length,
40 Node,
41 Penalty,
42 RasterImage,
43 Sp,
44};
45use crate::ledger::{
46 AnchorId,
47 AnchorKind,
48 Ledger,
49 Ref,
50};
51use crate::memo::{
52 BlockEntry,
53 BlockState,
54 Fnv,
55 Memo,
56};
57use crate::linebreak::{
58 break_paragraph,
59 break_paragraph_pieces,
60 Piece,
61};
62use crate::math::{
63 self,
64 Atom,
65};
66use crate::table::{
67 self,
68 Align,
69 Cell,
70 Row,
71 Table,
72};
73use crate::page::{
74 Frame,
75 Page,
76 PageGeometry,
77 Placed,
78 PlacedKind,
79};
80use crate::theme::{
81 Theme,
82 ThemePatch,
83};
84
85use oxedyne_fe2o3_core::prelude::*;
86use crate::fonts::FaceResolver;
87use crate::lang::ast::Spacing;
88
89use oxedyne_fe2o3_font::{
90 face::Role,
91 font::Font,
92 set::FontSet,
93 shape::{
94 Dir,
95 Feature,
96 },
97};
98use oxedyne_fe2o3_graphics::{
99 colour::Rgba,
100 path::{
101 Bounds,
102 Path,
103 PathBuilder,
104 Pt,
105 },
106 svg_doc::{
107 Anchor,
108 SvgOp,
109 SvgPicture,
110 },
111 transform::Transform,
112};
113
114use std::collections::HashMap;
115use std::collections::HashSet;
116use std::sync::Arc;
117
118/// One run of a rich paragraph: a stretch of body text, a strongly emphasised run (`*strong*`, set
119/// bold), an emphasised run (`/emph/`, set italic), or a footnote whose mark falls after the run before
120/// it. The note text is set at the foot of the page the mark lands on, and numbered in document order.
121#[derive(Clone, Debug)]
122pub enum Segment {
123 Text(String),
124 Strong(String), // set in the bold face
125 Emph(String), // set in the italic face
126 BoldItalic(String), // `*_x_*`/`_*x*_`, set in the bold-italic face
127 Super(String), // #super[...], set raised and smaller, its baseline lifted above the line's
128 Sub(String), // #sub[...], set dropped and smaller, its baseline lowered below the line's
129 SmallCaps(String), // #smallcaps[...], shaped with the font's small-capitals (`smcp`) feature
130 Footnote { note: Vec<Segment> },
131 Math(Atom), // an inline maths expression, set within the running line
132 PageRef(String), // a cross-reference to a labelled anchor, resolving to its page number
133 Code(String), // an inline code span, set in the mono face
134 Glossary { term: String, display: String }, // a glossary term: bold-italic on its first document use, plain after
135 Cite(Vec<String>), // a citation, resolved to "(Author Year)" against the bibliography
136 // A `#claim-label(...)` or `#claim-refs(...)`: a zero-width margin anchor setting nothing in the body
137 // column. `display` is the compressed code a label draws in the outside margin (empty for a metadata-only
138 // reference); `codes` are the raw codes a reference registers for the reverse claim index (empty for a label).
139 MarginNote { display: String, codes: Vec<String> },
140 // An index marker: records the term's occurrence for the back-matter index and sets nothing in the body.
141 // `term` is the sort key (markup flattened, e.g. "March, James"); `display` is the styled text the index
142 // page sets (e.g. "James March", or an italicised case name), so the index prints the display and never
143 // the sort key. `sub` carries a nested entry's child term. `main` marks a primary reference (`#idx-main`),
144 // whose folio the index page sets bold, as in-dexter's `index-main = index.with(fmt: strong)` does. The
145 // back-matter index reads every occurrence's page back from the ledger post-convergence.
146 Index { term: String, sub: Option<String>, display: Vec<Segment>, main: bool },
147}
148
149impl Segment {
150 pub fn text<S: Into<String>>(text: S) -> Self {
151 Self::Text(text.into())
152 }
153
154 pub fn strong<S: Into<String>>(text: S) -> Self {
155 Self::Strong(text.into())
156 }
157
158 pub fn emph<S: Into<String>>(text: S) -> Self {
159 Self::Emph(text.into())
160 }
161
162 pub fn bold_italic<S: Into<String>>(text: S) -> Self {
163 Self::BoldItalic(text.into())
164 }
165
166 pub fn superscript<S: Into<String>>(text: S) -> Self {
167 Self::Super(text.into())
168 }
169
170 pub fn subscript<S: Into<String>>(text: S) -> Self {
171 Self::Sub(text.into())
172 }
173
174 pub fn footnote(note: Vec<Segment>) -> Self {
175 Self::Footnote { note }
176 }
177
178 pub fn math(expr: Atom) -> Self {
179 Self::Math(expr)
180 }
181
182 pub fn page_ref<S: Into<String>>(label: S) -> Self {
183 Self::PageRef(label.into())
184 }
185
186 pub fn code<S: Into<String>>(text: S) -> Self {
187 Self::Code(text.into())
188 }
189
190 pub fn glossary<T: Into<String>, D: Into<String>>(term: T, display: D) -> Self {
191 Self::Glossary { term: term.into(), display: display.into() }
192 }
193
194 pub fn cite(keys: Vec<String>) -> Self {
195 Self::Cite(keys)
196 }
197
198 pub fn margin_note<S: Into<String>>(display: S, codes: Vec<String>) -> Self {
199 Self::MarginNote { display: display.into(), codes }
200 }
201
202 pub fn index<T: Into<String>>(term: T, sub: Option<String>, main: bool, display: Vec<Segment>) -> Self {
203 Self::Index { term: term.into(), sub, display, main }
204 }
205}
206
207/// One entry of a [`Block::List`]: its own rich runs and any lists nested beneath it, so a step carrying
208/// indented sub-bullets keeps them under the step. A nested list is itself a [`Block::List`], set at an
209/// increased left indent when the parent renders.
210#[derive(Clone, Debug)]
211pub struct ListEntry {
212 pub segments: Vec<Segment>,
213 pub children: Vec<Block>,
214}
215
216/// One block of the authored document. The closed vocabulary the block layer sets; richer blocks
217/// (lists, quotes, figures) are later variants here.
218#[derive(Clone, Debug)]
219pub enum Block {
220 Heading { level: u8, segments: Vec<Segment>, label: Option<String> }, // segments: the title's rich runs; label: an author anchor a `#ref` resolves to
221 Paragraph { text: String },
222 RichParagraph { segments: Vec<Segment> }, // a paragraph carrying footnote marks
223 List { ordered: bool, items: Vec<ListEntry>, loose: bool }, // a bullet or numbered list; an entry may nest sub-lists; loose when a blank line parts its items
224 Code { lines: Vec<String> }, // a verbatim code block, set in the mono face, whitespace preserved
225 Table(Table),
226 Equation { expr: Atom, numbered: bool, label: Option<String> }, // a display equation on its own centred line; label anchors an @-reference
227 // A drawn figure, centred, numbered, captioned. `placement` is `Some` when the source floated it
228 // (`figure(placement: auto | top | bottom)`): the driver then sets it at the top or foot of the next
229 // page it fits on rather than in the flow. `None` (the Typst default, and `placement: none`) sets it
230 // where it stands.
231 Figure { graphic: Graphic, caption: Option<String>, placement: Option<Floating> },
232 // A `#figure(...)` wrapping a `#table(...)`: the ruled table, then a numbered caption beneath. The
233 // supplement is the caption's leading word ("Table"/"Figure"); the label anchors a cross-reference.
234 TableFigure { table: Table, caption: Option<Vec<Segment>>, supplement: String, label: Option<String>, placement: Option<Floating> },
235 // A `#figure(...)` wrapping an image: the loaded raster centred in the measure with the numbered
236 // caption beneath, or -- when the path resolves to nothing or is a vector SVG with no raster beside
237 // it -- a sized placeholder box in its place. The sizing hints size the drawn image.
238 ImageFigure {
239 path: String,
240 width: Option<Length>,
241 height: Option<Length>,
242 scale: Option<f64>,
243 caption: Option<Vec<Segment>>,
244 supplement: String,
245 label: Option<String>,
246 placement: Option<Floating>,
247 },
248 // A `#figure(...)` whose body is drawn by code -- a CeTZ/Fletcher diagram, a bar chart or a line plot.
249 // The graphic is built at render time from the document's font set and placed like an image figure,
250 // with the numbered caption beneath.
251 CodeFigure {
252 figure: crate::lang::codefig::CodeFigure,
253 caption: Option<Vec<Segment>>,
254 supplement: String,
255 label: Option<String>,
256 placement: Option<Floating>,
257 },
258 // A back-matter section title (the Bibliography) on its own page, set left in the display face and
259 // unnumbered. It records a heading anchor so the contents lists it, and a back-matter marker so the
260 // running head is dropped and the folio centres from here on.
261 BackMatterHeading { title: String },
262 // One bibliography reference: its styled runs, each carrying whether it sets in italic. Set small,
263 // as a paragraph the reader reads as one entry.
264 Reference { runs: Vec<(String, bool)> },
265 // A standalone `#line(...)` horizontal divider: a stroked rule of the given width (a fraction of the
266 // measure or an absolute length), thickness in points, and grey level, with a paragraph skip either side.
267 Rule { width: Length, thickness: f64, grey: u8 },
268 // A line-leading `#padded-image(...)`/`#image(...)`: the loaded image centred in the measure with a
269 // little space either side, carrying no figure number or caption -- a section opener's logo, not a float.
270 Image { path: String, width: Option<Length>, height: Option<Length>, scale: Option<f64> },
271 // A line-leading `#section-banner("logo")`: a fresh page, then the template's full-width grey bar hanging
272 // into the top and side margins, carrying the section's logo right-aligned on the band's vertical middle.
273 SectionBanner { path: String },
274 // A line-leading `#print-glossary()` before the book layer resolves it: a placeholder the assembler
275 // replaces in place with a [`Table`] of the document's glossary terms and their definitions. It never
276 // survives to layout -- `book::resolve_glossary` walks the assembled blocks and swaps it out -- so the
277 // layout and word-count passes treat a stray one as empty rather than setting anything for it.
278 Glossary,
279 // The back-matter index placeholder: a marker the assembler appends after the bibliography and glossary
280 // when the root asks for an index (`meta-data.index: true`) and the body carries index markers. It sets
281 // nothing itself; [`author`] builds the alphabetical entry list from the index-marker occurrences it
282 // gathered walking the body, each entry's page list read back from the ledger post-convergence.
283 Index,
284 // The reverse claim-reference index placeholder: a marker a line-leading `#context { ... collect-claim-refs()
285 // ... }` in the Logic appendix lowers to. It sets nothing itself; [`author`] groups the claim references
286 // gathered walking the body by code (byte order, matching Typst's `.sorted()`) and sets one wrapped paragraph
287 // per code -- the bold code, a colon, and one page per reference in document order (NOT deduplicated: a code
288 // referenced twice on a page lists that page twice, as `claims.typ`'s `pages.join(", ")` does), each page read
289 // back from the ledger post-convergence.
290 ClaimIndex,
291 // A `#styled-box[...]` callout: its inner blocks set inside a padded box that runs the full measure,
292 // washed the template's `colours.veronica.lighten(90%)` (a pale violet) with a 4 pt corner radius. The
293 // callout is laid out as one keep box, so it moves whole to the next page rather than splitting the wash
294 // from its words. `patch` is the theme overlay the box body's own `#set` declarations lower to, applied
295 // to the box's subtree at render (H3) so a `#set` inside a callout scopes to it, not the document.
296 // `placement` is `Some` when the callout is a float (an `#aside-box(float: true)` re-wrapped in
297 // `figure(placement: auto)`): the driver then defers it to the next page it fits on rather than pushing
298 // the flow down. `None` sets it where it stands.
299 Box { blocks: Vec<Block>, patch: ThemePatch, placement: Option<FloatPlacement> },
300 // A theme scope: `patch` is overlaid on the effective theme for the nested `blocks`, and lifts again
301 // when they end. Nesting the governed blocks rather than bracketing them with a separate open/close
302 // marker makes an unmatched or missing close structurally impossible, and every pass that recurses over
303 // `blocks` scopes for free -- an included chapter's (or any selected subtree's) `#set` declarations
304 // style only that subtree, not the document. This is the general mechanism the rule engine's set-fields
305 // transform reuses to patch a subtree.
306 Scoped { patch: ThemePatch, blocks: Vec<Block> },
307 // A vertical space a `#show` template's `v(<len>)` lowers to: a fixed leading emitted as a sibling
308 // before or after the element the template wraps. It carries no words and anchors no reference.
309 Space(Sp),
310 // A line-leading `#pagebreak()` (strong, the default) or `#pagebreak(weak: true)`: a forced page eject at
311 // this point in the flow. A STRONG break always ejects, opening a blank page when there is nothing left to
312 // place (trailing) or when the current page is already empty (consecutive breaks); a WEAK break ejects only
313 // a page that carries content -- the same drop-on-fresh-page semantics the section furniture turns the page
314 // with (see the `SectionBanner` arm), which stays weak. The flag is honoured in the driver's compose.
315 PageBreak { weak: bool },
316 // A line-leading `#colbreak()`: a forced column break, strong unless `weak`. On a page of one column the
317 // driver turns the page, as Typst does.
318 ColBreak { weak: bool },
319 // A floating `#place(...)[ ... ]`: its blocks set as one float at the top or foot of its column, or --
320 // `scope: "parent"` -- spanning every column of the page. `clearance` is the gap to the body, Typst's
321 // 1.5em when the source names none.
322 Place { blocks: Vec<Block>, floating: Floating, clearance: Option<Spacing> },
323}
324
325impl Block {
326 pub fn heading<S: Into<String>>(level: u8, text: S) -> Self {
327 Self::Heading { level, segments: vec![Segment::text(text.into())], label: None }
328 }
329
330 /// A heading carrying an author label, so a `#ref(<label>)` elsewhere resolves to its page.
331 pub fn heading_labelled<S: Into<String>>(level: u8, text: S, label: Option<String>) -> Self {
332 Self::Heading { level, segments: vec![Segment::text(text.into())], label }
333 }
334
335 /// A heading whose title carries rich inline runs -- emphasis, a glossary term, an index call or a
336 /// maths span -- so each sets its display text in the head and the table of contents rather than
337 /// leaking its raw source.
338 pub fn heading_rich(level: u8, segments: Vec<Segment>, label: Option<String>) -> Self {
339 Self::Heading { level, segments, label }
340 }
341
342 pub fn paragraph<S: Into<String>>(text: S) -> Self {
343 Self::Paragraph { text: text.into() }
344 }
345
346 pub fn rich(segments: Vec<Segment>) -> Self {
347 Self::RichParagraph { segments }
348 }
349
350 /// A bullet (`ordered` false) or numbered (`ordered` true) list. Each entry carries its run sequence --
351 /// emphasis, a footnote or inline maths, as a rich paragraph does -- and any sub-lists nested beneath it.
352 pub fn list(ordered: bool, items: Vec<ListEntry>, loose: bool) -> Self {
353 Self::List { ordered, items, loose }
354 }
355
356 /// A verbatim code block: each line set in the mono face with its whitespace preserved and no
357 /// justification, the way source is shown.
358 pub fn code(lines: Vec<String>) -> Self {
359 Self::Code { lines }
360 }
361
362 pub fn table(table: Table) -> Self {
363 Self::Table(table)
364 }
365
366 pub fn rule(width: Length, thickness: f64, grey: u8) -> Self {
367 Self::Rule { width, thickness, grey }
368 }
369
370 pub fn space(height: Sp) -> Self { Self::Space(height) }
371
372 pub fn page_break(weak: bool) -> Self { Self::PageBreak { weak } }
373
374 /// A `#styled-box[...]` callout: the inner blocks set in a padded box washed the template's pale
375 /// violet. The wash is the theme's `callout.fill` at render (its default that pale violet), so a
376 /// `#set`/rule that lowers a callout fill reaches it; the caller supplies the body and the theme patch
377 /// the box's own `#set` declarations lowered to (empty when it declared none).
378 pub fn box_callout(blocks: Vec<Block>, patch: ThemePatch) -> Self {
379 Self::Box { blocks, patch, placement: None }
380 }
381
382 /// A `#styled-box`/`#aside-box` callout the source floated (`float: true`): the driver defers it to the
383 /// top or foot of the next page it fits on, its wash and words kept together.
384 pub fn box_callout_float(blocks: Vec<Block>, patch: ThemePatch, placement: FloatPlacement) -> Self {
385 Self::Box { blocks, patch, placement: Some(placement) }
386 }
387
388 /// A display equation set centred on its own line. A numbered one takes the next equation number at
389 /// the right margin and records an [`Equation`](crate::ledger::AnchorKind::Equation) anchor; a trailing
390 /// `<label>` lets an `@`-reference resolve to "Equation N".
391 pub fn equation(expr: Atom, numbered: bool, label: Option<String>) -> Self {
392 Self::Equation { expr, numbered, label }
393 }
394
395 /// A drawn figure, centred on its own line and captioned "Figure N" beneath, its identity recorded
396 /// as a [`Float`](crate::ledger::AnchorKind::Float) anchor so a cross-reference resolves its page.
397 pub fn figure(graphic: Graphic, caption: Option<String>, placement: Option<Floating>) -> Self {
398 Self::Figure { graphic, caption, placement }
399 }
400
401 /// A table wrapped in a figure: the ruled grid, then a "{supplement} N: {caption}" line beneath,
402 /// numbered per supplement so tables and figures carry independent counts.
403 pub fn table_figure(
404 table: Table,
405 caption: Option<Vec<Segment>>,
406 supplement: String,
407 label: Option<String>,
408 placement: Option<Floating>,
409 )
410 -> Self
411 {
412 Self::TableFigure { table, caption, supplement, label, placement }
413 }
414
415 /// An image wrapped in a figure: the raster at `path`, sized by the declared hints, centred in the
416 /// measure with its numbered caption beneath. A path that resolves to nothing, or a vector SVG with no
417 /// raster beside it, falls back to a placeholder box at render time.
418 #[allow(clippy::too_many_arguments)]
419 pub fn image_figure(
420 path: String,
421 width: Option<Length>,
422 height: Option<Length>,
423 scale: Option<f64>,
424 caption: Option<Vec<Segment>>,
425 supplement: String,
426 label: Option<String>,
427 placement: Option<Floating>,
428 )
429 -> Self
430 {
431 Self::ImageFigure { path, width, height, scale, caption, supplement, label, placement }
432 }
433
434 /// A figure drawn by code (a diagram, bar chart or line plot): its builder, numbered caption, and the
435 /// label a cross-reference resolves to. The graphic is built at render time from the font set.
436 pub fn code_figure(
437 figure: crate::lang::codefig::CodeFigure,
438 caption: Option<Vec<Segment>>,
439 supplement: String,
440 label: Option<String>,
441 placement: Option<Floating>,
442 )
443 -> Self
444 {
445 Self::CodeFigure { figure, caption, supplement, label, placement }
446 }
447
448 /// A back-matter section heading (the Bibliography), on its own page, unnumbered.
449 pub fn back_matter_heading<S: Into<String>>(title: S) -> Self {
450 Self::BackMatterHeading { title: title.into() }
451 }
452
453 /// One bibliography reference, a sequence of runs each flagged for italic.
454 pub fn reference(runs: Vec<(String, bool)>) -> Self {
455 Self::Reference { runs }
456 }
457
458 /// A plain centred image (a `#padded-image`/`#image` section logo), with any declared sizing.
459 pub fn image(path: String, width: Option<Length>, height: Option<Length>, scale: Option<f64>) -> Self {
460 Self::Image { path, width, height, scale }
461 }
462
463 /// A documentation section's opening banner: a fresh page carrying the template's full-width grey bar
464 /// with the logo at `path` right-aligned on it.
465 pub fn section_banner(path: String) -> Self {
466 Self::SectionBanner { path }
467 }
468}
469
470/// The point sizes and vertical spaces the block layer sets to. Every length is scaled points, so
471/// How a top-level heading opens. A book chapter opens with the giant grey number and dotted numbering
472/// the manuscripts use ([`BookOpener`](HeadingStyle::BookOpener)); a documentation tree that opens each
473/// chapter with the template's full-width grey banner bar and no numbering takes
474/// ([`DocBanner`](HeadingStyle::DocBanner)); a documentation tree whose sections carry their own
475/// `#section-banner` logo bar (the Hematite guide) sets its level-1 headings inline instead, with no
476/// banner and no numbering ([`DocInline`](HeadingStyle::DocInline)) -- the template's `chapter-banners:
477/// false`. The block layer reads this to pick the opener and to decide whether a heading carries a
478/// number, so one authoring path serves every idiom.
479#[derive(Clone, Copy, Debug, PartialEq, Eq)]
480pub enum HeadingStyle {
481 BookOpener,
482 DocBanner,
483 DocInline,
484 DocGrid, // a doc tree whose template opens level 1 with a fixed logo/title grid, no number -- oxeweb's template.typ
485}
486
487/// A recorded heading: the anchor identity the ledger resolves to a page, its level, and its display
488/// title. The block layer keeps this table beside the composed stream so [`decorate`] can read a
489/// title back from an anchor -- the ledger stores only the identity, not the words.
490#[derive(Clone, Debug)]
491pub struct Heading {
492 pub id: AnchorId,
493 pub level: u8,
494 pub title: String, // the display words, markup removed, for the anchor slug and a plain fallback
495 pub segments: Vec<Segment>, // the title's rich runs, so a running head or contents entry renders its maths and emphasis
496 pub number: String, // the dotted number a numbered heading shows ("2.3.1"); empty for a part divider
497 pub banner: bool, // set inline beneath a `#section-banner`, so the page suppresses its running head like a chapter opener
498}
499
500/// The book's front matter, read from the root's template call: the title, subtitle and author the
501/// title page sets, the cover raster a development build carries, and the imprint the meta page prints.
502/// A field a book omits is `None` and its line is not set. The whole struct is `None` for a lone
503/// manuscript, which carries no front matter at all.
504#[derive(Clone, Debug, Default)]
505pub struct FrontMatter {
506 pub title: String,
507 pub subtitle: Option<String>,
508 pub author: String,
509 pub cover_image: Option<String>, // a `/assets/...` raster path, set only in a development build
510 pub logo_image: Option<String>, // the publisher logo under the title, often an SVG (then not set)
511 pub publisher: Option<String>,
512 pub edition: Option<String>,
513 pub isbn: Option<String>,
514 pub copyright: Option<String>, // the already-composed "Copyright © 2026 ..." line
515 pub rights: Option<String>,
516 pub ai_declaration: Option<String>,
517 pub website: Option<String>,
518 pub toolchain: bool, // whether to print the "Created using ..." toolchain line
519 pub dedication: Option<String>,
520 pub about_author: Option<String>, // the author biography, set on its own page
521 // The display sizes the title page and back-matter titles set, read from the config's type scale.
522 pub title_size: Sp,
523 pub subtitle_size: Sp,
524 pub author_size: Sp,
525 pub back_title_size: Sp, // the "About the Author"/"Bibliography" heading size
526 // The documentation template's two-column title page (`template.typ`'s `title-page`): a full-height
527 // coloured sidebar down the left carrying a logo near its top and one near its foot, with the title and
528 // subtitle centred on the white right. `sidebar_grey` marks this idiom -- `Some(luma)` draws it and
529 // `None` keeps the book's plain centred title page. The rest are read from the root's `doc.with` call.
530 pub sidebar_grey: Option<u8>, // the sidebar fill as a grey level; None keeps the plain title page
531 pub sidebar_frac: f64, // the sidebar width as a fraction of the page width (`margins.title_page`)
532 pub title_smallcaps: bool, // whether the title sets in small caps rather than italic
533 pub top_logo: Option<String>, // the logo near the sidebar's top
534 pub top_logo_width: Sp,
535 pub bottom_logo: Option<String>, // the logo near the sidebar's foot
536 pub bottom_logo_width: Sp,
537 pub footer_logo: Option<String>, // the logo the template seats at the left of the page footer
538 // The documentation template's meta/colophon page (`template.typ`'s `meta-page`): a bordered
539 // Ver/Date/Author(s)/Notes table over an acknowledgement, a copyright line and a toolchain line at the
540 // page foot. `meta_rows` carries the revision rows, newest first; a non-empty list (or a named author)
541 // marks the idiom, so the doc meta page is composed only for a doc tree, never over the book imprint.
542 pub meta_rows: Vec<MetaRow>, // the revision rows the version table sets, in source order
543 pub reading_min: Option<u32>, // the whole-document reading time in minutes, appended to the last row's notes
544 pub acknowledgement: Option<String>, // the acknowledgement paragraph set near the page foot
545}
546
547/// One revision row of the documentation meta/colophon table: its version, date, author(s), notes, and
548/// the AI-declaration mark the row carries beneath its author. A field the row omits is `None`, and its
549/// column is left out of the table when every row omits it (matching the template's `filled` test).
550#[derive(Clone, Debug)]
551pub struct MetaRow {
552 pub version: Option<String>,
553 pub date: Option<String>,
554 pub authors: String,
555 pub notes: Option<String>,
556 pub ai_mark_path: Option<String>, // the declaration mark image, resolved from the row's slug
557 pub ai_mark_words: Option<String>, // the mark's caption, the row's own words when it rescopes them
558 pub ai_mark_url: Option<String>, // the scheme page the mark links to, <scheme>/<slug>/<medium>
559}
560
561/// The index markers gathered walking the body: a document-order counter making each occurrence's anchor
562/// identity unique, and the occurrences themselves (the term, a nested child term, and the anchor keyed by
563/// the counter). The back-matter index groups these by term, and each entry reads its occurrences' pages
564/// back from the ledger after convergence. A throwaway one is handed to a measurement flow, whose markers
565/// never reach the document.
566#[derive(Default)]
567pub(crate) struct IndexGather {
568 pub(crate) no: u32,
569 // Each occurrence: the sort key, a nested child term, the styled display the index page sets, the anchor
570 // keyed by the counter, and whether it is a primary (`#idx-main`) reference whose folio sets bold. The
571 // display is carried per occurrence and read from the first of a group.
572 pub(crate) occ: Vec<(String, Option<String>, Vec<Segment>, AnchorId, bool)>,
573}
574
575/// The claim references gathered walking the body: one `(code, anchor)` pair per code named in a
576/// `#claim-refs(...)` call, the anchor the zero-width margin anchor recorded at the reference point. The
577/// reverse claim index groups these by code, and each entry reads its references' pages back from the ledger
578/// after convergence, exactly as the back-matter index reads its folios. A throwaway one is handed to a
579/// measurement flow, whose references never reach the document.
580#[derive(Default)]
581pub(crate) struct ClaimGather {
582 pub(crate) occ: Vec<(String, AnchorId)>,
583}
584
585/// The mutable authoring state and immutable context of one document render, so the block walk can
586/// recurse into a [`Block::Scoped`] subtree -- setting its blocks under the scoped theme while every
587/// document-order counter (headings, footnotes, figures, the glossary first-use set) keeps counting
588/// across the boundary. The counters and accumulators are shared; only the theme changes per scope.
589struct Authoring<'a> {
590 // Immutable render context.
591 fonts: Arc<FontSet>,
592 geom: PageGeometry,
593 faces: &'a FaceResolver,
594 measure: Sp,
595 bib: Option<&'a Bibliography>,
596 refs: HashMap<String, String>,
597 // The composed body nodes and the heading table, both grown in document order.
598 nodes: Vec<Node>,
599 heads: Vec<Heading>,
600 // Running document-order state.
601 first: bool,
602 sec: [u32; 6],
603 prev_para: bool,
604 pending_banner: bool,
605 part_no: u32,
606 foot_no: u32,
607 ref_no: u32,
608 margin_no: u32, // a document-order counter making each margin note's anchor identity unique
609 eq_no: u32,
610 fig_no: u32,
611 counters: HashMap<String, u32>,
612 seen: HashSet<String>,
613 index_gather: IndexGather, // the back-matter index's markers, gathered in document order
614 want_index: bool, // a `Block::Index` placeholder was met, so the index is built after the walk
615 claim_gather: ClaimGather, // the reverse claim index's references, gathered in document order
616 want_claim_index: bool, // a `Block::ClaimIndex` placeholder was met, so the claim index is built after the walk
617 claim_index_at: Option<usize>, // the body-node position the `Block::ClaimIndex` placeholder sat at, where the listing is spliced in flow
618 global_fp: u64, // the compile-wide fingerprint (theme, geometry, cross-reference targets) every block memo key folds in
619}
620
621/// A continuation handed to [`Authoring::walk`]: the block that follows the walked slice at its parent's
622/// level, and the theme it is set under. A heading that is the last block of a [`Block::Scoped`] reads this
623/// so its "keep with the next paragraph" pairing sees through the scope's closing edge to the sibling
624/// beyond it, rather than stranding the heading and setting the gap's leading twice. The continuation is
625/// carried with its own (parent) theme, since it lies outside the scope the heading sits in. `None` at the
626/// top level, where the slice has no parent to continue from.
627#[derive(Clone, Copy)]
628struct Cont<'a> {
629 block: &'a Block,
630 theme: &'a Theme,
631}
632
633/// The paragraph a heading keeps with, seen through any single-block `#show par` scope, and the effective
634/// theme its kept line is broken under. A bare [`Block::Paragraph`] returns its text and `theme` unchanged;
635/// a [`Block::Scoped`] a `par` set-fields rule produced -- always the one-block shape [`rules::wrap_matching`]
636/// makes -- is descended, folding each `patch` onto the theme, so the kept line takes the rule's styling.
637/// Only a single-block scope is seen through: a multi-block chapter scope (from the book assembler) is left
638/// opaque, so a heading never reaches across a chapter boundary to strand its own keep. `None` when the
639/// lookahead is not a paragraph (a figure, a list, another heading), so the heading keeps with nothing.
640fn keep_with_next_para<'a>(look: &'a Block, theme: &Theme) -> Option<(&'a str, Theme)> {
641 match look {
642 Block::Paragraph { text } => Some((text.as_str(), theme.clone())),
643 // The exact single-block wrap a `par` rule makes: descend it under the folded theme. A multi-block
644 // scope is not a rule wrap and is left opaque, so the heading does not see through a chapter boundary.
645 Block::Scoped { patch, blocks } if blocks.len() == 1 => {
646 let scoped = { let mut t = theme.clone(); t.apply(patch); t };
647 keep_with_next_para(&blocks[0], &scoped)
648 },
649 _ => None,
650 }
651}
652
653impl<'a> Authoring<'a> {
654 /// Authors a float's blocks into material of their own: the whole block walk, headings, figures and all,
655 /// at the float's measure, with every document-order counter counting on across it. The material is laid
656 /// as one unit that never breaks, so its penalties and repeated-header markers are dropped; a float, a
657 /// columns block or a column-layout change inside it has no band or column of its own, and is refused.
658 fn float_material(&mut self, blocks: &[Block], style: &Theme, scope: FloatScope) -> Outcome<Vec<Node>> {
659 let outer_nodes = std::mem::take(&mut self.nodes);
660 let outer_measure = self.measure;
661 let outer_first = self.first;
662 let outer_para = self.prev_para;
663 self.measure = self.float_measure(scope);
664 self.first = true;
665 self.prev_para = false;
666 let walked = self.walk(blocks, style, None, &mut None);
667 let material = std::mem::replace(&mut self.nodes, outer_nodes);
668 self.measure = outer_measure;
669 self.first = outer_first;
670 self.prev_para = outer_para;
671 let _ = res!(walked);
672 let mut out: Vec<Node> = Vec::with_capacity(material.len());
673 for node in material {
674 match node {
675 // A penalty marks a break the unit never takes -- an author's page or column break was already
676 // refused at parse time, and an opener's own eject has no page to turn here.
677 Node::RepeatHead(_) | Node::Penalty(_) => {},
678 Node::Float(_) | Node::Columns(_) | Node::PageColumns(_) => return Err(err!(
679 "A float, a columns block or a page-column change inside a floating place cannot be set: the \
680 float is laid out as one unit, with no band or column of its own."; Input, Invalid)),
681 other => out.push(other),
682 }
683 }
684 Ok(out)
685 }
686
687 /// The column layout a theme sets the body in, and the measure one column of it gives.
688 fn page_columns(&self, t: &Theme) -> (PageColumns, Sp) {
689 let count = t.page.columns.max(1);
690 let gutter = if count > 1 { t.page.gutter_for(self.geom.content_width()) } else { Sp::ZERO };
691 let cols = PageColumns::new(count, gutter);
692 (cols, self.geom.column_slice(0, count, gutter).content_width())
693 }
694
695 /// The measure a float's material is set to: the column's, or the whole page's for one spanning every
696 /// column (`scope: "parent"`). On a page of one column the two are the same.
697 fn float_measure(&self, scope: FloatScope) -> Sp {
698 match scope {
699 FloatScope::Column => self.measure,
700 FloatScope::Parent => self.geom.content_width(),
701 }
702 }
703
704 /// The reading set a scope's patch puts in force: the enclosing set unless the patch names a body
705 /// family list, in which case that list's set, required (and so built) at assembly.
706 fn fonts_for(&self, patch: &ThemePatch) -> Outcome<Arc<FontSet>> {
707 let families = match &patch.text.faces.body {
708 Some(f) => f,
709 None => return Ok(self.fonts.clone()),
710 };
711 match res!(self.faces.scope_set(families)) {
712 Some(set) => Ok(set),
713 None => Err(err!(
714 "The scoped body family list {:?} was never required at assembly, so no reading set was built \
715 for it; every assembly path runs `FaceResolver::require` over the families the tree names.",
716 families; Bug, Missing)),
717 }
718 }
719
720 /// Sets a block slice under `style`, the theme in force for it. A [`Block::Scoped`] overlays its patch
721 /// on `style` and recurses over its own blocks under that scoped theme, so a `#set` inside an included
722 /// chapter (or any bracketed subtree) styles only that subtree; the shared counters count on across the
723 /// boundary. With no scope -- every corpus document today -- `style` is the document theme throughout, so
724 /// the render is byte-identical.
725 ///
726 /// `cont` is the block that follows this slice at the parent's level (`None` at the top). Returns whether
727 /// the slice's final heading pulled that continuation paragraph into its keep box -- the caller then skips
728 /// the paragraph rather than setting it a second time, so a heading alone in a scope still keeps with the
729 /// sibling paragraph beyond the scope edge.
730 fn walk(
731 &mut self,
732 blocks: &[Block],
733 style: &Theme,
734 cont: Option<Cont<'_>>,
735 memo: &mut Option<&mut Memo>,
736 )
737 -> Outcome<bool>
738 {
739 // Set when the final heading of this slice keeps with the parent's continuation paragraph, so the
740 // caller skips that paragraph rather than setting it twice.
741 let mut consumed_cont = false;
742 let mut i = 0usize;
743 // The block-authoring memo runs only at the top level, where `style` is the document theme every
744 // key is fingerprinted under and there is no parent continuation to reach across; a scoped subtree
745 // (an included chapter that declares its own styling -- rare) recurses with the memo switched off,
746 // so it is always authored fresh and stays byte-identical. `pending` holds the markers of a block
747 // currently being authored on a cache miss: its delta is captured and stored at the top of the next
748 // iteration (or after the loop), which is where a `continue`-ing arm -- a chapter opener -- lands.
749 let use_memo = cont.is_none() && memo.is_some();
750 let mut pending: Option<PendingBlock> = None;
751 while i < blocks.len() {
752 // Close out the block authored on the previous miss, now that `i` has advanced past it.
753 if let Some(p) = pending.take() {
754 self.memo_capture(memo, p, i);
755 }
756 if let Block::Scoped { patch, blocks: inner } = &blocks[i] {
757 let scoped = { let mut t = style.clone(); t.apply(patch); t };
758 // The scoped slice's continuation is the block that follows the scope at THIS level, set under
759 // THIS theme -- so a heading ending the scope keeps with the sibling paragraph beyond it. When
760 // the scope is this slice's last block, the continuation is instead this walk's own (the
761 // parent's block beyond every enclosing scope, under its own theme), threaded inward so a
762 // heading ending a *nested* scope -- an authored rule stacked on the default one -- still keeps
763 // with the paragraph past the scopes' closing edges rather than stranding it.
764 let has_sibling = i + 1 < blocks.len();
765 let inner_cont = if has_sibling {
766 Some(Cont { block: &blocks[i + 1], theme: style })
767 } else {
768 cont
769 };
770 // A scoped subtree is authored fresh (the memo is switched off inside it), so its render stays
771 // byte-identical whether or not the memo is present. A scope naming its own body family sets
772 // its blocks in that family's reading set, lifted again when the scope ends.
773 let scoped_fonts = res!(self.fonts_for(patch));
774 let outer_fonts = std::mem::replace(&mut self.fonts, scoped_fonts);
775 // A scope setting its own page columns starts them on a fresh page and returns to the enclosing
776 // layout on another when it ends, as Typst's `set page` inside a scope does; its blocks are set
777 // at the scope's column measure.
778 let relaid = scoped.page.columns != style.page.columns
779 || scoped.page.column_gutter != style.page.column_gutter;
780 let outer_measure = self.measure;
781 if relaid {
782 let (cols, measure) = self.page_columns(&scoped);
783 self.nodes.push(Node::PageColumns(cols));
784 self.measure = measure;
785 }
786 let ate = res!(self.walk(inner, &scoped, inner_cont, &mut None));
787 if relaid {
788 let (cols, _) = self.page_columns(style);
789 self.nodes.push(Node::PageColumns(cols));
790 self.measure = outer_measure;
791 }
792 self.fonts = outer_fonts;
793 if ate {
794 if has_sibling {
795 // The inner walk pulled this slice's next sibling into its keep box: skip it here.
796 i += 2;
797 } else {
798 // The inner walk pulled THIS walk's own continuation (the parent's block): tell the
799 // caller to skip it, exactly as a bare final heading of this slice would.
800 consumed_cont = true;
801 i += 1;
802 }
803 } else {
804 i += 1;
805 }
806 continue;
807 }
808
809 // The block-authoring memo: at the top level, before authoring the block, look it up by its
810 // content and the counter state it enters under. A hit splices the previously authored nodes,
811 // heads and index/claim occurrences straight back and restores the exit state, skipping the
812 // shaping and line breaking entirely; a miss records the markers and captures the delta once the
813 // block has been authored (at the next iteration's top). A block whose output is position- or
814 // flag-dependent -- the index and claim-index placeholders -- is never memoised.
815 if use_memo && memoisable(&blocks[i]) {
816 let look = if matches!(&blocks[i], Block::Heading { .. }) {
817 blocks.get(i + 1)
818 } else {
819 None
820 };
821 let key = self.block_key(&blocks[i], look);
822 if let Some(m) = memo.as_deref_mut() {
823 if let Some(entry) = m.block_lookup(key) {
824 let consume = entry.consume;
825 self.apply_block_entry(entry);
826 i += consume;
827 continue;
828 }
829 }
830 // A miss: remember where authoring this block begins, so its delta can be captured after.
831 pending = Some(PendingBlock {
832 key,
833 i_before: i,
834 nodes_before: self.nodes.len(),
835 heads_before: self.heads.len(),
836 index_before: self.index_gather.occ.len(),
837 claim_before: self.claim_gather.occ.len(),
838 seen_before: self.seen.clone(),
839 counters_before: self.counters.clone(),
840 });
841 }
842 match &blocks[i] {
843 Block::Heading { level, segments, label } => {
844 // Step the counters for a numbered level (1..); a part divider (level 0) steps none.
845 if *level >= 1 {
846 let l = (*level as usize).min(6);
847 self.sec[l - 1] += 1;
848 for k in l..6 { self.sec[k] = 0; }
849 }
850 // A documentation tree sets `numbering: none`: its headings carry no dotted number, on the
851 // heading line, in the contents, or before a sub-heading. A book keeps the document-order number.
852 let number = match style.heading.kind {
853 HeadingStyle::DocBanner | HeadingStyle::DocInline | HeadingStyle::DocGrid => String::new(),
854 HeadingStyle::BookOpener => heading_number_themed(*level, &self.sec, style),
855 };
856
857 // The rendered title, its markup reduced to display words: it keys the anchor slug and is the
858 // title the contents list and the running head read back. The heading itself is set from the
859 // rich runs below, so a glossary term or emphasis in a heading renders rather than leaking.
860 let title = flatten_segments(segments);
861 let id = AnchorId::new(AnchorKind::Heading, fmt!("{:02}-{}", self.heads.len() + 1, slug(&title)));
862 // A level-1 heading that follows a `#section-banner` opens its section beneath the banner, so
863 // its page suppresses the running head like a chapter opener; the flag is one-shot.
864 let banner = self.pending_banner && *level == 1;
865 self.pending_banner = false;
866 self.heads.push(Heading {
867 id: id.clone(),
868 level: *level,
869 title: title.clone(),
870 segments: segments.clone(),
871 number: number.clone(),
872 banner,
873 });
874
875 // A chapter (level 1) or a part divider (level 0) opens a fresh page and stands alone; a
876 // deeper heading binds to the first line of the paragraph it introduces, so the greedy page
877 // breaker never strands it at a page foot. A level-1 heading that carries its own
878 // `#section-banner` is the exception: it is set inline beneath the banner the section drew, so
879 // it takes the sub-heading path with no page break of its own -- the banner already turned the
880 // page. This holds whether the tree sets every section that way (`DocInline`, the Hematite
881 // guide) or opts one chapter in with an explicit `#section-banner` while defaulting to the
882 // grey title bar (`DocBanner`): an explicit banner always owns its chapter's header, so the
883 // duplicate title bar is suppressed regardless of the doc's default mode.
884 let opens = *level == 0
885 || (*level == 1 && style.heading.kind != HeadingStyle::DocInline && !banner);
886 if opens {
887 if !self.first {
888 self.nodes.push(Node::Penalty(Penalty::eject()));
889 }
890 // A part divider (level 0) carries a "Part N" run-in label above its title; a chapter carries
891 // none. The ordinal is a Roman numeral, the template's `smallcaps(part-counter.display("I"))`.
892 let part_label = if *level == 0 {
893 self.part_no += 1;
894 fmt!("Part {}", roman(self.part_no))
895 } else {
896 String::new()
897 };
898 res!(chapter_opener(
899 &mut self.nodes, &self.fonts, self.faces, style, self.geom, self.measure, *level, &number, &title,
900 &part_label, &id, label.as_deref()));
901 i += 1;
902 self.first = false;
903 self.prev_para = false; // the opener is not a paragraph, so the first body line takes no indent
904 continue;
905 }
906
907 // Space above the heading. At a page top the driver discards it, so the first heading on a
908 // page still sits flush to the text block. A heading following a non-consuming heading omits
909 // it, so the gap between the two is the upper heading's `space_below` alone.
910 if !self.first {
911 self.nodes.push(Node::Glue(Glue::fixed(style.space_above(*level))));
912 }
913
914 let hbox = res!(subheading_hbox(
915 self.fonts.clone(), self.faces, style, *level, &number, segments, &mut self.seen));
916
917 let mut keep: Vec<Node> = vec![Node::Anchor(id)];
918 if let Some(l) = label {
919 keep.push(Node::Anchor(AnchorId::new(AnchorKind::Label, l.clone())));
920 }
921 keep.push(hbox);
922 keep.push(Node::Glue(Glue::fixed(style.space_below(*level))));
923 let mut rest: Vec<Node> = Vec::new();
924 let mut consumed_para = false;
925 // The paragraph the heading keeps with: its in-slice next sibling, or -- when the heading is
926 // the last block of a scope -- the parent's continuation beyond the scope's closing edge. The
927 // continuation is broken under its own (parent) theme, since it lies outside this scope.
928 let (look, look_theme): (Option<&Block>, &Theme) = if i + 1 < blocks.len() {
929 (Some(&blocks[i + 1]), style)
930 } else {
931 match cont {
932 Some(c) => (Some(c.block), c.theme),
933 None => (None, style),
934 }
935 };
936 // The lookahead is seen through a single-block `#show par` scope, so a rule-wrapped paragraph
937 // keeps with the heading just as a bare one does; `eff_theme` folds any such scope's patch so
938 // the kept line is broken at the scoped size. A bare paragraph returns the theme unchanged, so
939 // the render is byte-identical where no `par` rule wraps it.
940 let kept = look.and_then(|block| keep_with_next_para(block, look_theme));
941 if let Some((para, eff_theme)) = kept {
942 // The first paragraph after a heading opens the section, so it takes no first-line indent.
943 let mut lines = res!(break_paragraph(
944 self.fonts.clone(), Role::Body, Dir::Ltr, eff_theme.text.body_size, para, self.measure, eff_theme.text.leading, eff_theme.text.hyphenate, eff_theme.text.fill,
945 Some(cap_edge(&eff_theme, eff_theme.text.body_size))));
946 if !lines.is_empty() {
947 keep.push(lines.remove(0)); // the first line joins the heading
948 rest = guard_widows(lines); // its leading glue and the remaining lines follow
949 }
950 consumed_para = true;
951 if i + 1 < blocks.len() {
952 i += 2;
953 } else {
954 // The paragraph was the parent's continuation, in the parent's slice: leave it there
955 // for the caller to skip, since this walk cannot advance past its own slice's end.
956 consumed_cont = true;
957 i += 1;
958 }
959 } else {
960 i += 1;
961 }
962
963 self.nodes.push(vbox(keep, self.measure));
964 self.nodes.extend(rest);
965 self.first = false;
966 // A heading opens a section: the paragraph it swallowed took no indent, but the NEXT paragraph
967 // follows a paragraph and so is indented.
968 self.prev_para = consumed_para;
969 },
970 Block::Paragraph { text } => {
971 if !self.first {
972 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
973 }
974 // A plain paragraph is set through the piece breaker so a leading indent box can ride the
975 // front of its first line; without an indent it produces exactly what `break_paragraph` does.
976 let mut pieces = Vec::new();
977 if self.prev_para && style.par.indent.raw() > 0 {
978 pieces.push(indent_piece(style.par.indent));
979 }
980 pieces.push(Piece::Text { text: text.clone(), role: Role::Body });
981 let lines = res!(break_paragraph_pieces(
982 self.fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &pieces, self.measure, style.text.leading, style.text.justify, style.text.hyphenate, style.text.fill,
983 Some(cap_edge(style, style.text.body_size))));
984 self.nodes.extend(guard_widows(lines));
985 i += 1;
986 self.first = false;
987 self.prev_para = true;
988 },
989 Block::RichParagraph { segments } => {
990 if !self.first {
991 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
992 }
993 let mut pieces = Vec::new();
994 if self.prev_para && style.par.indent.raw() > 0 {
995 pieces.push(indent_piece(style.par.indent));
996 }
997 pieces.extend(res!(build_pieces(
998 self.fonts.clone(), self.geom, style, segments, Role::Body, &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen, &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs)));
999 let lines = res!(break_paragraph_pieces(
1000 self.fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &pieces, self.measure, style.text.leading, style.text.justify, style.text.hyphenate, style.text.fill,
1001 Some(cap_edge(style, style.text.body_size))));
1002 self.nodes.extend(guard_widows(lines));
1003 i += 1;
1004 self.first = false;
1005 self.prev_para = true;
1006 },
1007 Block::List { ordered, items, loose } => {
1008 if !self.first {
1009 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1010 }
1011 res!(list(&mut self.nodes, self.fonts.clone(), self.geom, style, self.measure, *ordered, items, *loose, &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen, &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs));
1012 i += 1;
1013 self.first = false;
1014 self.prev_para = false;
1015 },
1016 Block::Code { lines: src } => {
1017 if !self.first {
1018 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1019 }
1020 res!(code_block(&mut self.nodes, self.fonts.clone(), style, src));
1021 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1022 i += 1;
1023 self.first = false;
1024 self.prev_para = false;
1025 },
1026 Block::Table(t) => {
1027 // Space above the table, discarded at a page top like any other leading. A plain table lowers
1028 // to one keep box, moved whole to the next page when it will not fit; a breakable table (the
1029 // glossary) sets one keep box per row, so the driver paginates between its rows.
1030 if !self.first {
1031 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1032 }
1033 if t.breakable {
1034 self.nodes.extend(res!(table::lower_rows(
1035 self.fonts.clone(), self.geom, style, self.measure, t,
1036 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen,
1037 &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs)));
1038 } else {
1039 self.nodes.push(res!(table::lower(
1040 self.fonts.clone(), self.geom, style, self.measure, t,
1041 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen,
1042 &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs)));
1043 }
1044 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1045 i += 1;
1046 self.first = false;
1047 self.prev_para = false;
1048 },
1049 Block::Equation { expr, numbered, .. } => {
1050 if !self.first {
1051 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1052 }
1053 let number = if *numbered { self.eq_no += 1; Some(self.eq_no) } else { None };
1054 res!(equation(&mut self.nodes, self.fonts.clone(), style, self.measure, expr, number));
1055 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1056 i += 1;
1057 self.first = false;
1058 self.prev_para = false;
1059 },
1060 Block::Figure { graphic, caption, placement } => {
1061 // Space above the figure, discarded at a page top like any other leading. The figure is
1062 // one keep box, so the breaker moves it whole to the next page when it will not fit; a
1063 // floated one leaves the flow entirely (see [`push_float`]).
1064 self.fig_no += 1;
1065 match placement {
1066 Some(p) => {
1067 let mut mid = Vec::new();
1068 res!(figure(&mut mid, self.fonts.clone(), style, self.float_measure(p.scope), graphic.clone(), caption.as_deref(), self.fig_no));
1069 push_float(&mut self.nodes, mid, float_clearance(style), *p);
1070 },
1071 None => {
1072 if !self.first {
1073 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1074 }
1075 res!(figure(&mut self.nodes, self.fonts.clone(), style, self.measure, graphic.clone(), caption.as_deref(), self.fig_no));
1076 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1077 },
1078 }
1079 i += 1;
1080 self.first = false;
1081 },
1082 Block::TableFigure { table, caption, supplement, label, placement } => {
1083 let number = next_number(&mut self.counters, supplement);
1084 match placement {
1085 Some(p) => {
1086 let mut mid = Vec::new();
1087 res!(table_figure(
1088 &mut mid, self.fonts.clone(), self.geom, style, self.float_measure(p.scope), table,
1089 caption.as_deref(), supplement, number, label.as_deref(),
1090 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen,
1091 &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs));
1092 push_float(&mut self.nodes, mid, float_clearance(style), *p);
1093 },
1094 None => {
1095 if !self.first {
1096 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1097 }
1098 res!(table_figure(
1099 &mut self.nodes, self.fonts.clone(), self.geom, style, self.measure, table,
1100 caption.as_deref(), supplement, number, label.as_deref(),
1101 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen,
1102 &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs));
1103 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1104 },
1105 }
1106 i += 1;
1107 self.first = false;
1108 },
1109 Block::ImageFigure { path, width, height, scale, caption, supplement, label, placement } => {
1110 let number = next_number(&mut self.counters, supplement);
1111 match placement {
1112 Some(p) => {
1113 let mut mid = Vec::new();
1114 res!(image_figure(
1115 &mut mid, self.fonts.clone(), style, self.float_measure(p.scope), path, *width, *height, *scale,
1116 caption.as_deref(), supplement, number, label.as_deref()));
1117 push_float(&mut self.nodes, mid, float_clearance(style), *p);
1118 },
1119 None => {
1120 if !self.first {
1121 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1122 }
1123 res!(image_figure(
1124 &mut self.nodes, self.fonts.clone(), style, self.measure, path, *width, *height, *scale,
1125 caption.as_deref(), supplement, number, label.as_deref()));
1126 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1127 },
1128 }
1129 i += 1;
1130 self.first = false;
1131 },
1132 Block::CodeFigure { figure, caption, supplement, label, placement } => {
1133 let number = next_number(&mut self.counters, supplement);
1134 match placement {
1135 Some(p) => {
1136 let mut mid = Vec::new();
1137 res!(code_figure(
1138 &mut mid, self.fonts.clone(), style, self.float_measure(p.scope), figure,
1139 caption.as_deref(), supplement, number, label.as_deref()));
1140 push_float(&mut self.nodes, mid, float_clearance(style), *p);
1141 },
1142 None => {
1143 if !self.first {
1144 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1145 }
1146 res!(code_figure(
1147 &mut self.nodes, self.fonts.clone(), style, self.measure, figure,
1148 caption.as_deref(), supplement, number, label.as_deref()));
1149 self.nodes.push(Node::Glue(Glue::fixed(style.table.skip)));
1150 },
1151 }
1152 i += 1;
1153 self.first = false;
1154 },
1155 Block::BackMatterHeading { title } => {
1156 if !self.first {
1157 self.nodes.push(Node::Penalty(Penalty::eject()));
1158 }
1159 // The back-matter marker (a Citation anchor) fixes where the running head drops and the
1160 // folio centres; a heading anchor lists it in the contents. Both sit at the page top.
1161 self.nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Citation, slug(title))));
1162 let id = AnchorId::new(AnchorKind::Heading, fmt!("{:02}-{}", self.heads.len() + 1, slug(title)));
1163 self.heads.push(Heading {
1164 id: id.clone(),
1165 level: 0,
1166 title: title.clone(),
1167 segments: vec![Segment::text(title.clone())],
1168 number: String::new(),
1169 banner: false,
1170 });
1171 self.nodes.push(Node::Anchor(id));
1172 // The title left in the display face at the chapter-title size (the template's
1173 // glossary-index-title size, equal to it in these books' scales).
1174 let sh = res!(head_shape(&self.fonts, &resolved_head_face(1, style, self.faces, is_doc_heading(style)), style.heading.levels[0].size, title));
1175 let d = sh.dims();
1176 self.nodes.push(Node::HBox(BoxNode::new(
1177 vec![Node::Leaf(Leaf::text(sh))], Dims::new(self.measure, d.height, d.depth))));
1178 self.nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(20.0))));
1179 i += 1;
1180 self.first = false;
1181 self.prev_para = false;
1182 },
1183 Block::Reference { runs } => {
1184 // Typst joins bibliography entries with a paragraph break, so they part by the bibliography's
1185 // own paragraph spacing, not by a footnote interline gap. The book template sets the reference
1186 // list at `text(size: 0.85em)`, and paragraph spacing scales with the text size, so the gap is
1187 // the body paragraph skip (`par.skip`, 1.2 em at the body size) scaled by the same 0.85 -- the
1188 // earlier fix sized the entry text and leading this way but left this gap on the footnote metric,
1189 // which set the entries far too tight.
1190 if !self.first {
1191 let gap = Sp(style.par.skip.raw() * 85 / 100);
1192 self.nodes.push(Node::Glue(Glue::fixed(gap)));
1193 }
1194 res!(reference_block(&mut self.nodes, self.fonts.clone(), style, self.measure, runs));
1195 i += 1;
1196 self.first = false;
1197 self.prev_para = false;
1198 },
1199 Block::Rule { width, thickness, grey } => {
1200 if !self.first {
1201 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1202 }
1203 rule_divider(&mut self.nodes, self.measure, *width, *thickness, *grey);
1204 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1205 i += 1;
1206 self.first = false;
1207 self.prev_para = false;
1208 },
1209 Block::Image { path, width, height, scale } => {
1210 res!(plain_image(&mut self.nodes, self.fonts.clone(), self.measure, path, *width, *height, *scale));
1211 i += 1;
1212 self.first = false;
1213 self.prev_para = false;
1214 },
1215 Block::SectionBanner { path } => {
1216 // The template's `#section-banner` turns the page first (`pagebreak(weak: true)`); a forced
1217 // eject the driver drops when the page is already fresh, so it never opens a blank one.
1218 self.nodes.push(Node::Penalty(Penalty::eject()));
1219 res!(section_banner(&mut self.nodes, self.fonts.clone(), self.geom, self.measure, path));
1220 self.pending_banner = true; // the section's level-1 heading follows and opens beneath this banner
1221 i += 1;
1222 self.first = false;
1223 self.prev_para = false;
1224 },
1225 Block::Box { blocks: inner, patch, placement } => {
1226 // Space above the callout, discarded at a page top like any other leading. It lowers to one keep
1227 // box, so the breaker moves it whole to the next page when it will not fit; a floated callout
1228 // (an `#aside-box(float: true)`) leaves the flow and defers (see [`push_float`]).
1229 // The box body is set with the document theme overlaid by the box's own `#set` declarations,
1230 // scoped to the box (H3). An empty patch leaves the document theme, so a callout that declares
1231 // nothing renders byte-identically. The wash is the scoped theme's `callout.fill`.
1232 let scoped = { let mut t = style.clone(); t.apply(patch); t };
1233 let fill = scoped.callout.fill;
1234 let box_fonts = res!(self.fonts_for(patch));
1235 match placement {
1236 Some(p) => {
1237 // A floated callout is a `figure(placement: ...)` under the bonnet, so it records a
1238 // zero-extent [`Float`](crate::ledger::AnchorKind::Float) anchor -- keyed by a running
1239 // aside count -- as Typst counts it among its figures. The anchor rides inside the float,
1240 // so it takes the page and position the callout settles on.
1241 let mut mid = Vec::new();
1242 let n = next_number(&mut self.counters, "aside");
1243 mid.push(Node::Anchor(AnchorId::new(AnchorKind::Float, fmt!("aside-{}", n))));
1244 res!(styled_box(
1245 &mut mid, box_fonts.clone(), self.geom, &scoped, self.measure, inner, fill,
1246 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen, &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs));
1247 push_float(&mut self.nodes, mid, float_clearance(style), Floating::column(*p));
1248 },
1249 None => {
1250 if !self.first {
1251 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1252 }
1253 res!(styled_box(
1254 &mut self.nodes, box_fonts.clone(), self.geom, &scoped, self.measure, inner, fill,
1255 &mut self.foot_no, &mut self.ref_no, &mut self.margin_no, &mut self.seen, &mut self.index_gather, &mut self.claim_gather, self.bib, &self.refs));
1256 self.nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
1257 },
1258 }
1259 i += 1;
1260 self.first = false;
1261 self.prev_para = false;
1262 },
1263 // The book layer resolves every `#print-glossary()` placeholder into a table before layout, so one
1264 // reaching here (a lone-file compile that never ran the resolver) sets nothing rather than failing.
1265 Block::Glossary => { i += 1; },
1266 // The back-matter index placeholder: it sets nothing here, only marks that the index is wanted,
1267 // so `author` builds the entry list from the markers gathered walking the body once the walk
1268 // ends. The heading above it (a `Block::BackMatterHeading`) opens the section and lists it.
1269 Block::Index => { self.want_index = true; i += 1; },
1270 // The reverse claim-reference index placeholder: it sets nothing here, but unlike `Block::Index`
1271 // the listing is set IN FLOW at this source position (the `#context { ... collect-claim-refs() }`
1272 // block sits in the Logic appendix, before the bibliography), not appended as back matter. So the
1273 // body-node position is recorded now and the listing spliced in once the walk has gathered every
1274 // reference, so its heading and intro are never stranded on a page ahead of a headless listing.
1275 Block::ClaimIndex => { self.want_claim_index = true; self.claim_index_at = Some(self.nodes.len()); i += 1; },
1276 // A scope is handled by the recursion at the loop top; named here only for exhaustiveness.
1277 Block::Scoped { .. } => { i += 1; },
1278 Block::Space(sp) => {
1279 // A template's `v(<len>)`, set as a fixed leading between its siblings. Not discarded at a
1280 // page top: the author asked for it, so it holds like any authored space.
1281 self.nodes.push(Node::Glue(Glue::fixed(*sp)));
1282 i += 1;
1283 self.first = false;
1284 self.prev_para = false;
1285 },
1286 Block::ColBreak { weak } => {
1287 // A column break sets no ink, so the block that follows leads against the column top.
1288 self.nodes.push(Node::Penalty(Penalty::column_eject(*weak)));
1289 i += 1;
1290 },
1291 Block::Place { blocks: inner, floating, clearance } => {
1292 // A floating `#place`: its blocks authored at the float's measure -- the column's, or the
1293 // page's for a parent-scoped float -- into material of their own, then deferred to a band.
1294 let mid = res!(self.float_material(inner, style, floating.scope));
1295 let clr = match clearance {
1296 Some(Spacing::Pt(pt)) => Sp::from_pt(*pt),
1297 Some(Spacing::Em(em)) => Sp::from_pt(style.text.body_size.to_pt() * em),
1298 None => float_clearance(style),
1299 };
1300 push_float(&mut self.nodes, mid, clr, *floating);
1301 i += 1;
1302 self.first = false;
1303 self.prev_para = false;
1304 },
1305 Block::PageBreak { weak } => {
1306 // A line-leading `#pagebreak()`: a forced eject. A WEAK break emits the ordinary eject the
1307 // driver drops when the frame is already empty, so one landing at a page top opens no blank
1308 // page -- exactly as the section banner turns the page. A STRONG break emits a strong eject the
1309 // driver honours even on an empty page, so a trailing or consecutive strong break opens a blank
1310 // page, matching Typst 0.15.1. `first`/`prev_para` are left untouched -- the break sets no ink,
1311 // so the block that follows leads against the page top, not against a paragraph.
1312 let eject = if *weak { Penalty::eject() } else { Penalty::strong_eject() };
1313 self.nodes.push(Node::Penalty(eject));
1314 i += 1;
1315 },
1316 }
1317 }
1318 // Capture the last authored block's delta, which has no next iteration to close it.
1319 if let Some(p) = pending.take() {
1320 self.memo_capture(memo, p, blocks.len());
1321 }
1322 Ok(consumed_cont)
1323 }
1324
1325 /// Snapshots the scalar authoring counters as they stand: the state a block enters under (folded into
1326 /// its memo key) or leaves (stored in its memo value and restored on a hit).
1327 fn block_state(&self) -> BlockState {
1328 BlockState {
1329 first: self.first,
1330 prev_para: self.prev_para,
1331 pending_banner: self.pending_banner,
1332 sec: self.sec,
1333 part_no: self.part_no,
1334 foot_no: self.foot_no,
1335 ref_no: self.ref_no,
1336 margin_no: self.margin_no,
1337 eq_no: self.eq_no,
1338 fig_no: self.fig_no,
1339 heads_len: self.heads.len() as u32,
1340 index_no: self.index_gather.no,
1341 }
1342 }
1343
1344 /// Restores the scalar counters to a cached block's exit state on a hit. The heading and index/claim
1345 /// occurrence vectors are extended separately, so `heads.len()` and the index counter already stand
1346 /// where the exit state records them; the rest are set here.
1347 fn restore_state(&mut self, s: &BlockState) {
1348 self.first = s.first;
1349 self.prev_para = s.prev_para;
1350 self.pending_banner = s.pending_banner;
1351 self.sec = s.sec;
1352 self.part_no = s.part_no;
1353 self.foot_no = s.foot_no;
1354 self.ref_no = s.ref_no;
1355 self.margin_no = s.margin_no;
1356 self.eq_no = s.eq_no;
1357 self.fig_no = s.fig_no;
1358 self.index_gather.no = s.index_no;
1359 }
1360
1361 /// An order-independent fingerprint of the glossary first-use set: a block that sets a term bold-italic
1362 /// on its first use and plain after depends on exactly which terms have already been seen, so the set is
1363 /// in every key. The fold is by XOR of each term's hash, which needs no sort and updates in step with a
1364 /// growing set without ever caring about insertion order.
1365 fn seen_hash(&self) -> u64 {
1366 let mut acc = 0u64;
1367 for term in &self.seen {
1368 let mut h = Fnv::new();
1369 h.write_str(term);
1370 acc ^= h.finish();
1371 }
1372 acc
1373 }
1374
1375 /// An order-independent fingerprint of the per-supplement counters (Figure, Table, aside), each folded
1376 /// with its current value, so a block that stamps the next figure or table number keys on the number it
1377 /// will actually stamp.
1378 fn counters_hash(&self) -> u64 {
1379 let mut acc = 0u64;
1380 for (k, v) in &self.counters {
1381 let mut h = Fnv::new();
1382 h.write_str(k);
1383 h.write_u32(*v);
1384 acc ^= h.finish();
1385 }
1386 acc
1387 }
1388
1389 /// The memo key of a block at the current position: the compile-wide fingerprint, the measure, the
1390 /// block's own content (and, for a heading, the following block it may keep the first line of), and the
1391 /// counter state it enters under. Two compiles that reach a block with the same content and the same
1392 /// entering state produce byte-identical nodes, so they must share a key; an edit that shifts any of
1393 /// those must not.
1394 fn block_key(&self, block: &Block, look: Option<&Block>) -> u64 {
1395 let mut h = Fnv::new();
1396 h.write(b"block");
1397 h.write_u64(self.global_fp);
1398 h.write_i32(self.measure.raw());
1399 // The block's full content. The derived `Debug` is a faithful, total structural rendering -- it can
1400 // never silently drop a field the way a hand-written walker can -- and no type reachable from a
1401 // `Block` carries a lossy `Debug` (the one that summarises, `ShapedText`, appears only after
1402 // authoring, in `Node`). Formatting a block's `Debug` costs a fraction of shaping and breaking it.
1403 h.write_str(&fmt!("{:?}", block));
1404 if let Some(la) = look {
1405 h.write_str(&fmt!("{:?}", la));
1406 }
1407 self.block_state().hash_into(&mut h);
1408 h.write_u64(self.seen_hash());
1409 h.write_u64(self.counters_hash());
1410 h.finish()
1411 }
1412
1413 /// Applies a cached block result on a hit: splices its authored nodes, heading records and index/claim
1414 /// occurrences back in, advances the glossary and supplement accumulators by the deltas the block made,
1415 /// and restores the exit counter state -- so the authoring state stands exactly where a fresh authoring
1416 /// of the same block would have left it.
1417 fn apply_block_entry(&mut self, entry: BlockEntry) {
1418 self.nodes.extend(entry.nodes);
1419 self.heads.extend(entry.heads);
1420 self.index_gather.occ.extend(entry.index_occ);
1421 self.claim_gather.occ.extend(entry.claim_occ);
1422 for term in entry.seen_add {
1423 self.seen.insert(term);
1424 }
1425 for (k, v) in entry.counters_set {
1426 self.counters.insert(k, v);
1427 }
1428 self.restore_state(&entry.exit);
1429 }
1430
1431 /// Captures the delta a just-authored block produced and stores it under its key: the nodes, heads and
1432 /// occurrences it appended, the source blocks it consumed, the glossary terms it newly marked seen, the
1433 /// supplement counters it changed, and its exit counter state. `i_end` is the position after the block,
1434 /// so the consumed count carries a chapter heading's swallowed paragraph.
1435 fn memo_capture(&mut self, memo: &mut Option<&mut Memo>, p: PendingBlock, i_end: usize) {
1436 let m = match memo.as_deref_mut() {
1437 Some(m) => m,
1438 None => return,
1439 };
1440 let seen_add: Vec<String> = self.seen.iter()
1441 .filter(|t| !p.seen_before.contains(*t))
1442 .cloned()
1443 .collect();
1444 let counters_set: Vec<(String, u32)> = self.counters.iter()
1445 .filter(|(k, v)| p.counters_before.get(*k) != Some(*v))
1446 .map(|(k, v)| (k.clone(), *v))
1447 .collect();
1448 let entry = BlockEntry::new(
1449 i_end - p.i_before,
1450 self.nodes[p.nodes_before..].to_vec(),
1451 self.heads[p.heads_before..].to_vec(),
1452 self.index_gather.occ[p.index_before..].to_vec(),
1453 self.claim_gather.occ[p.claim_before..].to_vec(),
1454 seen_add,
1455 counters_set,
1456 self.block_state(),
1457 );
1458 m.block_store(p.key, entry);
1459 }
1460}
1461
1462/// Is a block one the authoring memo caches? A scope recurses (and is authored fresh), and the index and
1463/// claim-index placeholders set nothing but a document-position or a flag the post-walk assembly reads, so
1464/// neither is cached; every other block is a self-contained authoring unit keyed on its content and state.
1465fn memoisable(block: &Block) -> bool {
1466 !matches!(block, Block::Scoped { .. } | Block::Index | Block::ClaimIndex | Block::Glossary)
1467}
1468
1469/// The markers of a block being authored on a cache miss: its key, where it starts in each accumulator,
1470/// and the glossary and supplement state before it ran, so the delta it makes can be captured once it has.
1471struct PendingBlock {
1472 key: u64,
1473 i_before: usize,
1474 nodes_before: usize,
1475 heads_before: usize,
1476 index_before: usize,
1477 claim_before: usize,
1478 seen_before: HashSet<String>,
1479 counters_before: HashMap<String, u32>,
1480}
1481
1482/// Turns an authored block list into the composed document, and the heading table the running heads
1483/// resolve against. The geometry fixes the measure every paragraph is set to.
1484///
1485/// When `front` is set the front matter -- cover, title page, imprint, dedication and author note --
1486/// is composed ahead of the body, so the body's first heading fixes where the printed folio restarts
1487/// at one; a lone manuscript passes `None` and carries no front matter.
1488pub fn author(
1489 fonts: Arc<FontSet>,
1490 geom: PageGeometry,
1491 style: &Theme,
1492 faces: &FaceResolver,
1493 blocks: &[Block],
1494 front: Option<&FrontMatter>,
1495 bib: Option<&Bibliography>,
1496)
1497 -> Outcome<(Document, Vec<Heading>)>
1498{
1499 author_memo(fonts, geom, style, faces, blocks, front, bib, None)
1500}
1501
1502/// [`author`] with the incremental block-authoring memo threaded through: a live edit-and-re-render loop
1503/// hands the same [`Memo`] each compile so an unchanged block splices its previously authored nodes back
1504/// rather than re-shaping and re-breaking them. Passing `None` is exactly [`author`], byte for byte -- the
1505/// memo path never runs -- which is why every other caller keeps calling `author`.
1506///
1507/// The memo's fingerprint scopes it to one (fonts, faces, geometry, theme, cross-reference) configuration;
1508/// the caller opens each compile with [`Memo::begin`] carrying that fingerprint (see [`memo_fingerprint`]),
1509/// so a change to any of those clears the stale cache rather than serving it.
1510#[allow(clippy::too_many_arguments)]
1511pub fn author_memo(
1512 fonts: Arc<FontSet>,
1513 geom: PageGeometry,
1514 style: &Theme,
1515 faces: &FaceResolver,
1516 blocks: &[Block],
1517 front: Option<&FrontMatter>,
1518 bib: Option<&Bibliography>,
1519 mut memo: Option<&mut Memo>,
1520)
1521 -> Outcome<(Document, Vec<Heading>)>
1522{
1523 // The text every labelled cross-reference resolves to, settled once from document order so a forward
1524 // reference reads its referent's supplement and number without a layout round-trip.
1525 let refs = ref_targets(blocks, style);
1526 // The compile-wide fingerprint every block key folds in, so a change to the theme, the geometry, the
1527 // cross-reference targets (a renumbered chapter a `@ref` points at) or the bibliography (the text a
1528 // `#cite` resolves to) invalidates the whole cache.
1529 let global_fp = memo_fingerprint(style, geom, &refs, bib);
1530 if let Some(m) = memo.as_deref_mut() {
1531 m.begin(global_fp);
1532 }
1533 let mut authoring = Authoring {
1534 fonts: fonts.clone(),
1535 geom,
1536 faces,
1537 measure: geom.content_width(),
1538 bib,
1539 refs,
1540 nodes: Vec::new(),
1541 heads: Vec::new(),
1542 first: true,
1543 sec: [0; 6],
1544 prev_para: false,
1545 pending_banner: false,
1546 part_no: 0,
1547 foot_no: 0,
1548 ref_no: 0,
1549 margin_no: 0,
1550 eq_no: 0,
1551 fig_no: 0,
1552 counters: HashMap::new(),
1553 seen: HashSet::new(),
1554 index_gather: IndexGather::default(),
1555 want_index: false,
1556 claim_gather: ClaimGather::default(),
1557 want_claim_index: false,
1558 claim_index_at: None,
1559 global_fp,
1560 };
1561 // A body set in several columns (`#set page(columns: n)`) opens with the layout marker, so the front
1562 // matter composed ahead of it keeps the single-column page, and every block is set at the column measure.
1563 if style.page.columns > 1 {
1564 let (cols, measure) = authoring.page_columns(style);
1565 authoring.measure = measure;
1566 authoring.nodes.push(Node::PageColumns(cols));
1567 }
1568 // The top level has no parent continuation; the returned "consumed" flag is meaningless here and dropped.
1569 res!(authoring.walk(blocks, style, None, &mut memo));
1570 // The back-matter index, built from the markers gathered walking the body once the `Block::Index`
1571 // placeholder has been met and every occurrence's anchor is woven in. Its entries read their pages back
1572 // from the ledger after convergence, so they are set after the body they point into, as back matter.
1573 if authoring.want_index && !authoring.index_gather.occ.is_empty() {
1574 let occ = std::mem::take(&mut authoring.index_gather.occ);
1575 // The back-matter index sets in two columns, as Typst's `print-index` wraps `make-index` in
1576 // `columns(2)`. The gutter is 4% of the page measure; each entry is set to the resulting column
1577 // width, so a folio list fills its own column rather than the whole page. The driver's multi-column
1578 // pass flows the entries down the first column, then the second, breaking to a fresh page as needed.
1579 //
1580 // A body already set in page columns flows the index in those same columns, at the column measure,
1581 // rather than nesting a second column layout inside one column: the idiom's two-column index then
1582 // reads as the page's own columns.
1583 if style.page.columns > 1 {
1584 let entries = res!(index_nodes(&fonts, style, authoring.measure, &occ));
1585 authoring.nodes.extend(entries);
1586 } else {
1587 let gutter = Sp(geom.content_width().raw() * 4 / 100);
1588 let col_measure = geom.column_slice(0, 2, gutter).content_width();
1589 let entries = res!(index_nodes(&fonts, style, col_measure, &occ));
1590 authoring.nodes.push(Node::Columns(ColumnsNode::new(entries, 2, gutter)));
1591 }
1592 }
1593 // The reverse claim-reference index, built from the references gathered walking the body once the
1594 // `Block::ClaimIndex` placeholder has been met. It sets in a single column (Typst's appendix wraps it in
1595 // no `columns`), one wrapped paragraph per code, each code's pages read back from the ledger as forward
1596 // references. Unlike the back-matter index it is spliced in flow at the placeholder's own source position
1597 // -- the `#context` block sits in the Logic appendix before the bibliography, so the §heading and intro
1598 // above it must be followed immediately by the listing, not by a headless run of entries pages later.
1599 if let Some(at) = authoring.claim_index_at {
1600 if !authoring.claim_gather.occ.is_empty() {
1601 let occ = std::mem::take(&mut authoring.claim_gather.occ);
1602 let entries = res!(claim_index_nodes(&fonts, style, authoring.measure, &occ));
1603 // The parbreak gap Typst leaves between the appendix intro paragraph and the first listing entry,
1604 // the same block skip an authored paragraph takes above it.
1605 let mut spliced: Vec<Node> = Vec::with_capacity(entries.len() + 1);
1606 spliced.push(Node::Glue(Glue::fixed(style.par.skip)));
1607 spliced.extend(entries);
1608 let at = at.min(authoring.nodes.len());
1609 authoring.nodes.splice(at..at, spliced);
1610 }
1611 }
1612 let heads = authoring.heads;
1613
1614 // The front matter is composed ahead of the body so its cover, title, imprint and note leaves take
1615 // the physical pages before the body opens; the body then carries no heading anchor of the front
1616 // matter's, so the driver fixes the folio restart at the first body heading.
1617 let mut stream: Vec<Node> = Vec::new();
1618 if let Some(fm) = front {
1619 res!(front_matter(&mut stream, &fonts, faces, geom, style, fm));
1620 // The contents follows the front matter and precedes the body, resolving each entry's folio as a
1621 // forward reference into the body the driver has not composed yet.
1622 stream.extend(res!(contents(
1623 fonts.clone(), faces, geom, style, fm.back_title_size, &heads)));
1624 }
1625 stream.extend(authoring.nodes);
1626
1627 let mut document = Document::new(stream, geom);
1628 document.foot = foot_style(style);
1629 // The two-generation sweep is deferred to the caller, after the emit stage: the page-emit cache is
1630 // touched during emit, which runs after this returns, so sweeping here would drop last compile's page
1631 // entries before this compile's emit could reuse them. `Memo::begin` (called above) opened the
1632 // generation; `Memo::sweep`, called once the pages are emitted, closes it.
1633 let _ = memo;
1634 Ok((document, heads))
1635}
1636
1637/// The compile-wide fingerprint the block-authoring memo scopes every key to: the theme, the page
1638/// geometry, the settled cross-reference targets and the bibliography. A change to any of them changes
1639/// what every block authors -- the theme decides sizes and faces; the geometry decides the measure; a
1640/// `@ref`'s resolved text changes when the thing it points at is renumbered; a `#cite` resolves against
1641/// the bibliography -- so it must invalidate the cache wholesale. The document's fonts and faces are held
1642/// constant by the memo's per-document contract: a font or face change takes a fresh [`Memo`], not this one.
1643pub fn memo_fingerprint(
1644 style: &Theme,
1645 geom: PageGeometry,
1646 refs: &HashMap<String, String>,
1647 bib: Option<&Bibliography>,
1648)
1649 -> u64
1650{
1651 let mut h = Fnv::new();
1652 h.write(b"global");
1653 // The theme as data. Its derived `Debug` renders every styled value in a canonical field order, so
1654 // two identical themes fingerprint alike and any change to one shows.
1655 h.write_str(&fmt!("{:?}", style));
1656 h.write_i32(geom.width.raw());
1657 h.write_i32(geom.height.raw());
1658 h.write_i32(geom.inside.raw());
1659 h.write_i32(geom.outside.raw());
1660 h.write_i32(geom.top.raw());
1661 h.write_i32(geom.bottom.raw());
1662 // The cross-reference targets, folded order-independently: a label maps to its resolved "Chapter 4"
1663 // text, and that text changing (a renumber) must miss every block that sets a reference.
1664 let mut rf = 0u64;
1665 for (k, v) in refs {
1666 let mut e = Fnv::new();
1667 e.write_str(k);
1668 e.write_str(v);
1669 rf ^= e.finish();
1670 }
1671 h.write_u64(rf);
1672 // The bibliography as data, so editing a `.bib` (which changes the text a `#cite` resolves to without
1673 // touching any block's own content) misses every citation-bearing block rather than serving it stale.
1674 h.write_bool(bib.is_some());
1675 if let Some(b) = bib {
1676 h.write_str(&fmt!("{:?}", b));
1677 }
1678 h.finish()
1679}
1680
1681/// The foot spacing derived from the block style, so the separator rule and the gaps around the notes
1682/// match the document's other furniture. The rule runs a third of the measure, a conventional short
1683/// footnote rule.
1684fn foot_style(style: &Theme) -> FootStyle {
1685 FootStyle {
1686 gap_above_rule: style.par.skip,
1687 rule_thick: style.table.rule_thin,
1688 rule_width: Sp(style.text.body_size.raw() * 12),
1689 gap_below_rule: Sp::from_pt(4.0),
1690 gap_between: Sp::from_pt(3.0),
1691 }
1692}
1693
1694/// A first-line indent as a rigid leading piece: an empty box of the indent width that the optimiser
1695/// counts against the first line and that never breaks, so the first word sits one indent in and the
1696/// line still fills the measure. Modelled as a maths piece of zero height carrying a single fixed glue,
1697/// which is how the piece breaker already threads a pre-built inline cluster into the line.
1698fn indent_piece(indent: Sp) -> Piece {
1699 Piece::Math {
1700 nodes: vec![Node::Glue(Glue::fixed(indent))],
1701 width: indent,
1702 height: Sp::ZERO,
1703 depth: Sp::ZERO,
1704 over: Sp::ZERO,
1705 }
1706}
1707
1708/// The block top edge for prose set at `size`: the cap height, as the fraction of the em the theme
1709/// calibrates ([`ThemeCalibration::line_box_em`](crate::theme::ThemeCalibration)). Passed to the
1710/// paragraph breakers as the block-edge model's top, so a paragraph's first line seats its top at the cap
1711/// height and its inter-block glue attaches where Typst's does.
1712pub(crate) fn cap_edge(style: &Theme, size: Sp) -> Sp {
1713 Sp((size.raw() as f64 * style.calibration.line_box_em).round() as i32)
1714}
1715
1716/// Guards a flow paragraph's set lines against a widow or an orphan across a page break. The page breaker
1717/// may break at any interline glue; a forbidden penalty set immediately after the first line and
1718/// immediately before the last line stops it stranding a single line either side of a break -- an orphan at
1719/// the page foot or a widow at the page head. The lines then move as a unit rather than splitting one off,
1720/// which is the page-bottom slack Typst leaves. A paragraph of one or two lines becomes wholly unbreakable;
1721/// three or more keep at least two lines on each side of any break they do take. `lines` is the breaker's
1722/// output -- HBoxes joined by interline glue -- and the return is the same list with the two penalties woven
1723/// in; a paragraph of a single line is returned unchanged.
1724fn guard_widows(lines: Vec<Node>) -> Vec<Node> {
1725 let n = lines.iter().filter(|node| matches!(node, Node::HBox(_))).count();
1726 if n < 2 {
1727 return lines;
1728 }
1729 let forbid = Node::Penalty(Penalty::new(Penalty::INFINITY, false));
1730 let mut out = Vec::with_capacity(lines.len() + 2);
1731 let mut seen = 0usize; // HBoxes emitted so far
1732 for node in lines {
1733 let is_line = matches!(node, Node::HBox(_));
1734 out.push(node);
1735 if is_line {
1736 seen += 1;
1737 // After the first line: forbid the break that would orphan it at a page foot. After the
1738 // last-but-one line: forbid the break that would widow the last line at a page head. The two
1739 // coincide for a two-line paragraph, forbidding its only interior break.
1740 if seen == 1 || seen == n - 1 {
1741 out.push(forbid.clone());
1742 }
1743 }
1744 }
1745 out
1746}
1747
1748/// The text each labelled cross-reference resolves to, fixed in a document-order pre-pass. A reference's
1749/// supplement word and number depend only on document order, not on layout, so they are settled once here
1750/// and set as static text -- Typst's own "Chapter 4" for a chapter, "Section 7.7" for a section, and
1751/// "{supplement} {number}" for a figure or table -- matching the oracle's own output. The heading and
1752/// figure and equation counters are stepped exactly as [`author`] steps them, so a label's number here is
1753/// the number the block itself sets -- a chapter, section, figure, table or "Equation N". A label the
1754/// pre-pass never records is left for the caller's page-number fallback.
1755fn ref_targets(blocks: &[Block], style: &Theme) -> HashMap<String, String> {
1756 let mut out: HashMap<String, String> = HashMap::new();
1757 let mut sec: [u32; 6] = [0; 6];
1758 let mut counters: HashMap<String, u32> = HashMap::new();
1759 let mut eq_no = 0u32; // the equation counter, stepped exactly as `author` steps it
1760 ref_targets_walk(blocks, style, &mut out, &mut sec, &mut counters, &mut eq_no);
1761 out
1762}
1763
1764/// The document-order counting walk behind [`ref_targets`], recursing into a [`Block::Scoped`] under its
1765/// overlaid theme with the counters shared across the boundary -- exactly as [`Authoring::walk`] numbers
1766/// the same tree, so a heading, figure or equation inside a scope takes the number it will actually be set
1767/// with, and a cross-reference after or into a scope resolves to the right one. The heading number is read
1768/// through [`heading_number_themed`], honouring any per-level numbering pattern the scope's theme carries,
1769/// so a numbered pattern matches the rendered heading rather than the plain dotted arabic.
1770fn ref_targets_walk(
1771 blocks: &[Block],
1772 style: &Theme,
1773 out: &mut HashMap<String, String>,
1774 sec: &mut [u32; 6],
1775 counters: &mut HashMap<String, u32>,
1776 eq_no: &mut u32,
1777) {
1778 for block in blocks {
1779 match block {
1780 Block::Scoped { patch, blocks: inner } => {
1781 let scoped = { let mut t = style.clone(); t.apply(patch); t };
1782 ref_targets_walk(inner, &scoped, out, sec, counters, eq_no);
1783 },
1784 // A floating place is authored through the whole block walk, so its headings, figures and
1785 // equations are numbered in document order where the place stands.
1786 Block::Place { blocks: inner, .. } => {
1787 ref_targets_walk(inner, style, out, sec, counters, eq_no);
1788 },
1789 Block::Heading { level, label, .. } => {
1790 if *level >= 1 {
1791 let l = (*level as usize).min(6);
1792 sec[l - 1] += 1;
1793 for k in l..6 { sec[k] = 0; }
1794 }
1795 if let Some(l) = label {
1796 let number = heading_number_themed(*level, sec, style);
1797 // A chapter (level 1) takes the "Chapter" supplement the template sets; a deeper heading
1798 // takes "Section" with its full dotted number, as Typst's default heading reference does. A
1799 // part divider (level 0) carries no number and is no reference target.
1800 let text = match *level {
1801 0 => continue,
1802 1 => fmt!("Chapter {}", number),
1803 _ => fmt!("Section {}", number),
1804 };
1805 out.insert(l.clone(), text);
1806 }
1807 },
1808 Block::TableFigure { supplement, label, .. }
1809 | Block::ImageFigure { supplement, label, .. }
1810 | Block::CodeFigure { supplement, label, .. } => {
1811 let n = next_number(counters, supplement);
1812 if let Some(l) = label {
1813 out.insert(l.clone(), fmt!("{} {}", supplement, n));
1814 }
1815 },
1816 Block::Equation { numbered, label, .. } => {
1817 // A numbered equation steps the counter; a labelled one anchors "Equation N", Typst's
1818 // default equation reference. An unnumbered equation carries no number, so its label is
1819 // left to the caller's page-number fallback.
1820 if *numbered {
1821 *eq_no += 1;
1822 if let Some(l) = label {
1823 out.insert(l.clone(), fmt!("Equation {}", *eq_no));
1824 }
1825 }
1826 },
1827 // A `#styled-box` body sets running prose only -- `Authoring::walk` numbers no heading, figure or
1828 // equation inside it -- so a label there is no numbered target and the box is not descended, exactly
1829 // as the render leaves it.
1830 _ => {},
1831 }
1832 }
1833}
1834
1835/// Turns a rich paragraph's segments into the pieces the line breaker weaves, assigning each footnote
1836/// its number from the running fold and setting its note as a small paragraph at the foot measure, and
1837/// each cross-reference a reserved inline slot the driver resolves in pass B. A text segment is a piece
1838/// as it stands; a footnote becomes a superscript mark piece carrying the set note; a page reference or
1839/// a total-pages call becomes a shrink-to-fit reserved leaf, unique by the running `ref_no`. `base` is the
1840/// face plain text and a resolved reference take: `Role::Body` in the running flow, a header row's `Role::Bold`
1841/// when a table cell is built through this same path, so a cell renders, cites, gathers its index markers and
1842/// records its claim anchors exactly as a body paragraph does.
1843#[allow(clippy::too_many_arguments)]
1844pub(crate) fn build_pieces(
1845 fonts: Arc<FontSet>,
1846 geom: PageGeometry,
1847 style: &Theme,
1848 segments: &[Segment],
1849 base: Role,
1850 foot_no: &mut u32,
1851 ref_no: &mut u32,
1852 margin_no: &mut u32,
1853 seen: &mut HashSet<String>,
1854 idx: &mut IndexGather,
1855 claim: &mut ClaimGather,
1856 bib: Option<&Bibliography>,
1857 refs: &HashMap<String, String>,
1858)
1859 -> Outcome<Vec<Piece>>
1860{
1861 let measure = geom.content_width();
1862 let mut pieces = Vec::with_capacity(segments.len());
1863 for seg in segments {
1864 match seg {
1865 Segment::Text(text) => {
1866 pieces.push(Piece::Text { text: text.clone(), role: base });
1867 },
1868 Segment::Strong(text) => {
1869 pieces.push(Piece::Text { text: text.clone(), role: Role::Bold });
1870 },
1871 Segment::Emph(text) => {
1872 pieces.push(Piece::Text { text: text.clone(), role: crate::table::emph_role(base) });
1873 },
1874 Segment::BoldItalic(text) => {
1875 pieces.push(Piece::Text { text: text.clone(), role: Role::BoldItalic });
1876 },
1877 Segment::SmallCaps(text) => {
1878 pieces.push(Piece::SmallCaps { text: text.clone(), role: base });
1879 },
1880 Segment::Super(text) => {
1881 // The same raise the footnote mark rides: a run shaped at 0.7x, its box shortened so the
1882 // emitter seats its baseline above the line's. It is rigid and never breaks -- the space
1883 // after it may -- exactly as a mark piece behaves.
1884 let (shaped, dims) = res!(superscript(fonts.clone(), Role::Body, style.text.body_size, text));
1885 pieces.push(Piece::Mark(Leaf::text_dims(shaped, dims)));
1886 },
1887 Segment::Sub(text) => {
1888 // The mirror of the superscript arm just above: a run shaped at 0.7x, its box lengthened so
1889 // the emitter seats its baseline below the line's.
1890 let (shaped, dims) = res!(subscript(fonts.clone(), Role::Body, style.text.body_size, text));
1891 pieces.push(Piece::Mark(Leaf::text_dims(shaped, dims)));
1892 },
1893 Segment::Footnote { note } => {
1894 *foot_no += 1;
1895 let label = fmt!("{}", *foot_no);
1896 let (mark, dims) = res!(superscript(fonts.clone(), Role::Body, style.text.body_size, &label));
1897 let footnote = res!(build_footnote(fonts.clone(), style, measure, *foot_no, note, mark));
1898 pieces.push(Piece::Mark(Leaf::mark(footnote, dims)));
1899 },
1900 Segment::Math(expr) => {
1901 // The inline box is flattened to leaves and glue by the maths layout; unwrap the HBox it
1902 // returns and weave its children into the line, so they draw as real glyphs rather than as
1903 // a nested rectangle. The box seats its baseline on the text baseline -- a body ascent
1904 // below the line top -- so the line asks for that ascent as its height; anything the maths
1905 // reaches above it is the overshoot the line above must open for.
1906 let node = res!(math::layout(fonts.clone(), style, expr, false));
1907 if let Node::HBox(b) = node {
1908 let ascent = res!(ShapedText::new(
1909 fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, "0")).dims().height;
1910 let over = if b.dims.height > ascent { b.dims.height - ascent } else { Sp::ZERO };
1911 pieces.push(Piece::Math {
1912 nodes: b.list,
1913 width: b.dims.width,
1914 height: ascent,
1915 depth: b.dims.depth,
1916 over,
1917 });
1918 }
1919 },
1920 Segment::PageRef(label) => {
1921 // A cross-reference resolves to Typst's own supplement-and-number text -- "Chapter 4",
1922 // "Section 7.7", "Figure 2", "Table 1", "Equation 9" -- fixed by the document-order pre-pass
1923 // and set as body text. A label the pre-pass did not record falls back to the reserved
1924 // page-number slot the driver resolves in pass B, so the reference still reads rather than
1925 // vanishing.
1926 match refs.get(label) {
1927 Some(text) => pieces.push(Piece::Text { text: text.clone(), role: base }),
1928 None => pieces.push(Piece::Mark(res!(ref_slot(
1929 fonts.clone(), style, ref_no,
1930 Ref::PageOf(AnchorId::new(AnchorKind::Label, label.clone())))))),
1931 }
1932 },
1933 Segment::Code(text) => {
1934 pieces.push(Piece::Text { text: text.clone(), role: Role::Mono });
1935 },
1936 Segment::Glossary { term, display } => {
1937 // The first mention of a term is set bold-italic, matching the template's `*_term_*`;
1938 // every later mention is plain body text. Document order is the traversal order, so the
1939 // set alone decides, with no second pass.
1940 let role = if seen.insert(term.clone()) { Role::BoldItalic } else { base };
1941 pieces.push(Piece::Text { text: display.clone(), role });
1942 },
1943 Segment::Cite(keys) => {
1944 // Resolve the citation to "(Author Year)" against the bibliography, set as body text. A
1945 // key the bibliography does not hold, or a run with no bibliography loaded, falls back to
1946 // the bracketed keys so the citation still reads rather than vanishing.
1947 let text = match bib {
1948 Some(b) => {
1949 let refs: Vec<&str> = keys.iter().map(|k| k.as_str()).collect();
1950 b.format_citation(&refs).unwrap_or_else(|_| fmt!("({})", keys.join("; ")))
1951 },
1952 None => fmt!("({})", keys.join("; ")),
1953 };
1954 pieces.push(Piece::Text { text, role: base });
1955 },
1956 Segment::MarginNote { display, codes } => {
1957 // A margin note sets nothing in the body column: it weaves a zero-width anchor into the line
1958 // at this point, recording where it landed so `decorate` draws the compressed code in the
1959 // outside margin after convergence. The identity carries a document-order ordinal, so two
1960 // identical codes stay distinct in the ledger, and the display text itself, which `decorate`
1961 // reads back from the key -- no side table has to be threaded out of the layout. A metadata-only
1962 // `#claim-refs` carries an empty display, so `decorate` draws nothing for it, but the anchor is
1963 // still recorded so the reverse claim index can read the page each of its codes was referenced on.
1964 *margin_no += 1;
1965 let key = fmt!("{}\u{1f}{}", *margin_no, display);
1966 let id = AnchorId::new(AnchorKind::MarginNote, key);
1967 // Each referenced code is remembered against this anchor, so the reverse claim index groups the
1968 // references by code and reads their pages back from the ledger, exactly as the index does its folios.
1969 for code in codes {
1970 claim.occ.push((code.clone(), id.clone()));
1971 }
1972 pieces.push(Piece::Anchor(id));
1973 },
1974 Segment::Index { term, sub, display, main } => {
1975 // An index marker sets nothing in the body column: it weaves a zero-width anchor into the line
1976 // at this point, so the driver records the folio it lands on, and remembers the occurrence so
1977 // the back-matter index lists the term with the page. The ordinal makes each occurrence's
1978 // identity unique, so two mentions of one term on one page stay distinct until the entry
1979 // deduplicates their resolved folios. The styled display travels with the occurrence so the
1980 // index page sets the display words, not the sort key; `main` travels too, so a primary
1981 // reference's folio sets bold on the index page.
1982 idx.no += 1;
1983 let key = fmt!("{}\u{1f}{}\u{1f}{}", idx.no, term, sub.as_deref().unwrap_or(""));
1984 let id = AnchorId::new(AnchorKind::IndexEntry, key);
1985 idx.occ.push((term.clone(), sub.clone(), display.clone(), id.clone(), *main));
1986 pieces.push(Piece::Anchor(id));
1987 },
1988 }
1989 }
1990 Ok(pieces)
1991}
1992
1993/// The compressed code a margin note's anchor key carries, recovered for [`decorate`] to draw: the key is
1994/// `<ordinal>\u{1f}<display>`, the ordinal making the identity unique and the display the words. An
1995/// unexpected key with no separator yields the empty string, so a stray anchor draws nothing rather than
1996/// its own bookkeeping.
1997fn margin_display(key: &str) -> &str {
1998 match key.split_once('\u{1f}') {
1999 Some((_, display)) => display,
2000 None => "",
2001 }
2002}
2003
2004/// Builds one inline cross-reference: a reserved leaf, unique by the running `ref_no`, that reserves a
2005/// three-digit slot and shrinks to the value the driver resolves for `refr` in pass B. It seats on the
2006/// body baseline, taking a body digit's height and depth so it aligns with the prose around it.
2007fn ref_slot(
2008 fonts: Arc<FontSet>,
2009 style: &Theme,
2010 ref_no: &mut u32,
2011 refr: Ref,
2012)
2013 -> Outcome<Leaf>
2014{
2015 *ref_no += 1;
2016 let own = AnchorId::new(AnchorKind::Label, fmt!("ref-{}", *ref_no));
2017 let slot = res!(ShapedText::new(fonts, Role::Body, Dir::Ltr, style.text.body_size, "000"));
2018 let sd = slot.dims();
2019 Ok(Leaf::reserved_inline(own, refr, Dims::new(sd.width, sd.height, sd.depth)))
2020}
2021
2022/// Sets a bullet or numbered list into the vertical list. Each item is broken at a measure reduced by
2023/// the marker column and then hung under its marker: the first line carries the marker leaf and a gap
2024/// that together fill the indent, the rest are shifted right by it, so every line's right edge still
2025/// lands on the measure. The marker column is the widest marker the list uses plus
2026/// [`marker_gap`](crate::theme::ThemeList::marker_gap), so a bullet list and a numbered list of ten
2027/// items align their text alike. Items are parted by [`item_skip`](crate::theme::ThemeList::item_skip);
2028/// the list's space from its neighbours is the
2029/// caller's. Each item is a segment run, so it breaks through the same path a rich paragraph does and
2030/// may carry emphasis, a footnote or inline maths.
2031#[allow(clippy::too_many_arguments)]
2032fn list(
2033 nodes: &mut Vec<Node>,
2034 fonts: Arc<FontSet>,
2035 geom: PageGeometry,
2036 style: &Theme,
2037 measure: Sp,
2038 ordered: bool,
2039 items: &[ListEntry],
2040 loose: bool,
2041 foot_no: &mut u32,
2042 ref_no: &mut u32,
2043 margin_no: &mut u32,
2044 seen: &mut HashSet<String>,
2045 idx: &mut IndexGather,
2046 claim: &mut ClaimGather,
2047 bib: Option<&Bibliography>,
2048 refs: &HashMap<String, String>,
2049)
2050 -> Outcome<()>
2051{
2052 // An ordered list takes its metrics and marker pattern from the `enumeration` group, a bulleted list
2053 // from `list`. The two groups' defaults match, so an untouched theme sets either alike; a
2054 // `#set enum(...)` reaches the ordered branch alone.
2055 let (marker_gap, spacing) = if ordered {
2056 (style.enumeration.marker_gap, style.enumeration.item_skip)
2057 } else {
2058 (style.list.marker_gap, style.list.item_skip)
2059 };
2060 // The extra vertical space stacked on top of the baselineskip between items. Typst's `spacing` is
2061 // `auto` by default: a tight list (no blank line parts its items) then sets at the body pitch alone,
2062 // so the extra is zero; a loose one adds the paragraph block spacing. A `#set list(spacing:)` /
2063 // `#set enum(spacing:)` overrides both to a fixed value.
2064 let item_skip = match spacing {
2065 Some(s) => s,
2066 None if loose => style.par.skip,
2067 None => Sp::ZERO,
2068 };
2069 let leading = style.text.leading;
2070 // Shape every marker once and keep the widest, so each item's text starts at the one indent. The
2071 // number counts across every entry regardless of any sub-list, so an ordered list stays 1..N.
2072 let mut markers: Vec<ShapedText> = Vec::with_capacity(items.len());
2073 let mut marker_w = Sp::ZERO;
2074 for idx in 0..items.len() {
2075 // The ordered marker follows the theme's `enumeration.numbering` pattern where set (Typst's
2076 // `#set enum(numbering: ...)`), else the plain `N.` the template sets; a bulleted item is a bullet.
2077 let label = if ordered {
2078 match &style.enumeration.numbering {
2079 Some(pattern) => format_numbering(pattern, &[idx as u32 + 1]),
2080 None => fmt!("{}.", idx + 1),
2081 }
2082 } else {
2083 "\u{2022}".to_string() // U+2022 bullet
2084 };
2085 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &label));
2086 if shaped.dims().width > marker_w { marker_w = shaped.dims().width; }
2087 markers.push(shaped);
2088 }
2089 let indent = marker_w + marker_gap;
2090 let inner = if measure > indent { measure - indent } else { measure };
2091
2092 // The depth of the last line this list has appended so far, kept so the gap to the next item (or to a
2093 // nested sub-list) can be sized by the baselineskip rule. `None` before the first block: the list's
2094 // space from its neighbours is the caller's, so the first item takes no inter-item gap.
2095 let mut prev_depth: Option<Sp> = None;
2096 for (ei, entry) in items.iter().enumerate() {
2097 let pieces = res!(build_pieces(fonts.clone(), geom, style, &entry.segments, Role::Body, foot_no, ref_no, margin_no, seen, idx, claim, bib, refs));
2098 let mut lines = res!(break_paragraph_pieces(
2099 fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &pieces, inner, style.text.leading, style.text.justify, style.text.hyphenate, Rgba::BLACK,
2100 Some(cap_edge(style, style.text.body_size))));
2101 indent_item(&mut lines, Leaf::text(markers[ei].clone()), indent);
2102 let block = guard_widows(lines);
2103 push_item_gap(nodes, &block, &mut prev_depth, leading, item_skip);
2104 nodes.extend(block);
2105 // A list nested under this item sets at an increased left indent, with its own kind and numbering:
2106 // it is laid out within the item's inner measure and then shifted right by this list's indent.
2107 for child in &entry.children {
2108 if let Block::List { ordered: cord, items: citems, loose: cloose } = child {
2109 let mut sub: Vec<Node> = Vec::new();
2110 res!(list(&mut sub, fonts.clone(), geom, style, inner, *cord, citems, *cloose,
2111 foot_no, ref_no, margin_no, seen, idx, claim, bib, refs));
2112 shift_nodes(&mut sub, indent);
2113 push_item_gap(nodes, &sub, &mut prev_depth, leading, item_skip);
2114 nodes.extend(sub);
2115 }
2116 }
2117 }
2118 Ok(())
2119}
2120
2121/// The height of the first line box in a laid-out block, or `None` when it holds no line -- so the
2122/// baselineskip gap to it can read the height the cap-edge model left on its first line.
2123fn first_box_height(nodes: &[Node]) -> Option<Sp> {
2124 nodes.iter().find_map(|n| match n {
2125 Node::HBox(b) => Some(b.dims.height),
2126 _ => None,
2127 })
2128}
2129
2130/// The depth of the last line box in a laid-out block, or `None` when it holds no line -- the lower
2131/// term the baselineskip gap from it reads, taken after the cap-edge model has trimmed it to the
2132/// baseline (or to inline maths' true depth).
2133fn last_box_depth(nodes: &[Node]) -> Option<Sp> {
2134 nodes.iter().rev().find_map(|n| match n {
2135 Node::HBox(b) => Some(b.dims.depth),
2136 _ => None,
2137 })
2138}
2139
2140/// Separates a list's next block -- an item or its nested sub-list -- from the previous one by the same
2141/// baselineskip rule [`set_lines`] uses between prose lines, then adds the list's `item_skip`. The gap
2142/// is sized from the previous block's last-line depth and the next block's first-line height, both read
2143/// after the cap-edge model has trimmed them, so item-to-item baseline pitch is the body pitch (a tight
2144/// list, `item_skip` zero) or the body pitch plus the block spacing (a loose one) -- never the raw skip
2145/// stacked on cap-trimmed edges, which set the items tighter than the body they sit in. The first block
2146/// takes no gap: its `prev_depth` is `None`, and the list's space from its neighbours is the caller's.
2147fn push_item_gap(nodes: &mut Vec<Node>, block: &[Node], prev_depth: &mut Option<Sp>, leading: Sp, item_skip: Sp) {
2148 if let Some(pd) = *prev_depth {
2149 let fh = first_box_height(block).unwrap_or(Sp::ZERO);
2150 let base = if leading > pd + fh { leading - pd - fh } else { Sp::ZERO };
2151 nodes.push(Node::Glue(Glue::fixed(base + item_skip)));
2152 }
2153 if let Some(ld) = last_box_depth(block) {
2154 *prev_depth = Some(ld);
2155 }
2156}
2157
2158/// Shifts every line box in `nodes` right by `by`, inserting a leading glue and growing the box width, so
2159/// a nested list sets indented under its parent item. The interline glue between the boxes is left alone.
2160fn shift_nodes(nodes: &mut [Node], by: Sp) {
2161 for node in nodes.iter_mut() {
2162 if let Node::HBox(b) = node {
2163 b.list.insert(0, Node::Glue(Glue::fixed(by)));
2164 b.dims = Dims::new(b.dims.width + by, b.dims.height, b.dims.depth);
2165 }
2166 }
2167}
2168
2169/// Sets a verbatim code block: each source line in the mono face, its leading whitespace preserved by
2170/// shaping the whole line, given a one-em hanging indent, and never justified or wrapped. A blank line
2171/// keeps the mono line's height so the block's vertical rhythm holds. The block's space from its
2172/// neighbours is the caller's. A long line overflows the measure rather than wrapping -- code is not
2173/// reflowed; a scrolling or wrapping treatment is a later refinement, as is keeping the block whole
2174/// across a page break.
2175fn code_block(
2176 nodes: &mut Vec<Node>,
2177 fonts: Arc<FontSet>,
2178 style: &Theme,
2179 lines: &[String],
2180)
2181 -> Outcome<()>
2182{
2183 // Code is set a touch smaller than the body, as most templates do, so more of a wide line fits the
2184 // measure before it overflows. The size is the theme's own `code` group, so a `#set raw(...)` unit can
2185 // lower into it; its default matches the footnote size the block has always used.
2186 let size = style.code.size;
2187 let indent = style.text.body_size; // a one-em hang, so the block sits off the left margin
2188 let sample = res!(ShapedText::new(fonts.clone(), Role::Mono, Dir::Ltr, size, "0"));
2189 let sh = sample.dims().height; // a mono digit fixes the height of a blank line
2190 let sd = sample.dims().depth;
2191 for (i, line) in lines.iter().enumerate() {
2192 let shaped = res!(ShapedText::new(
2193 fonts.clone(), Role::Mono, Dir::Ltr, size,
2194 if line.is_empty() { " " } else { line }));
2195 let d = shaped.dims();
2196 let h = if d.height > Sp::ZERO { d.height } else { sh };
2197 let dep = if d.depth > Sp::ZERO { d.depth } else { sd };
2198 let children = vec![Node::Glue(Glue::fixed(indent)), Node::Leaf(Leaf::text(shaped))];
2199 nodes.push(Node::HBox(BoxNode::new(children, Dims::new(indent + d.width, h, dep))));
2200 if i + 1 < lines.len() {
2201 let gap = if style.text.leading > h + dep { style.text.leading - h - dep } else { style.table.line_gap };
2202 nodes.push(Node::Glue(Glue::fixed(gap)));
2203 }
2204 }
2205 Ok(())
2206}
2207
2208/// Hangs a broken item under its marker. The first line takes the marker leaf and a gap filling the
2209/// rest of the indent; every line takes a leading glue that shifts it right by the indent; each line's
2210/// box grows to the full measure. The item was broken at `measure - indent`, so the right edge lands on
2211/// the measure. Only [`Node::HBox`] lines are shifted -- the interline glue between them is left alone.
2212fn indent_item(lines: &mut [Node], mut marker: Leaf, indent: Sp) {
2213 let mut first = true;
2214 for line in lines.iter_mut() {
2215 if let Node::HBox(b) = line {
2216 if first {
2217 // Seat the marker on the line's text baseline. The cap-edge model has already raised the
2218 // line's text leaves by `drop = ascender - cap` (a negative shift); the marker was shaped
2219 // at the body ascender like them, so without the same shift the emitter -- which draws a run
2220 // at `line_top + height + shift` -- would seat it `drop` (~0.4-0.5 em) below the text. Copying
2221 // the first text leaf's shift puts the bullet's own baseline on the text's, where U+2022
2222 // x-height-centres by font design and an enumerator's digits sit on the baseline, matching
2223 // Typst. A line whose leaves carry no raise (the cap model off) leaves the shift zero.
2224 let text_shift = b.list.iter().find_map(|n| match n {
2225 Node::Leaf(l) => Some(l.shift),
2226 _ => None,
2227 }).unwrap_or(Sp::ZERO);
2228 marker.shift = text_shift;
2229 let gap = if indent > marker.dims.width { indent - marker.dims.width } else { Sp::ZERO };
2230 b.list.insert(0, Node::Glue(Glue::fixed(gap)));
2231 b.list.insert(0, Node::Leaf(marker.clone()));
2232 first = false;
2233 } else {
2234 b.list.insert(0, Node::Glue(Glue::fixed(indent)));
2235 }
2236 b.dims = Dims::new(b.dims.width + indent, b.dims.height, b.dims.depth);
2237 }
2238 }
2239}
2240
2241/// Builds a footnote from its already-shaped body mark and its note text. The note is set as a small
2242/// paragraph at the foot measure, prefixed by the number as a hanging superscript, and its stacked
2243/// height noted so the page breaker can reserve it.
2244fn build_footnote(
2245 fonts: Arc<FontSet>,
2246 style: &Theme,
2247 measure: Sp,
2248 number: u32,
2249 note: &[Segment],
2250 mark: ShapedText,
2251)
2252 -> Outcome<Footnote>
2253{
2254 // The note's own inline runs, so a `*strong*` or `_emph_` term in the note sets with its own face
2255 // rather than flattening to upright text. A nested footnote or a cross-reference in a note -- rare --
2256 // sets nothing here, as a footnote carries no counter or reserved slot of its own.
2257 let pieces = res!(footnote_pieces(fonts.clone(), style, note));
2258
2259 // The number sets as a small superscript that hangs to the left of the note: the note breaks at a
2260 // measure reduced by the mark's hang, its first line carries the mark and a gap that together fill the
2261 // hang, and every continuation line is shifted right by it, so the note's text block sits proud of its
2262 // mark exactly as Typst hangs a footnote.
2263 let (pre_shaped, pre_dims) = res!(superscript(fonts.clone(), Role::Body, style.furniture.foot_size, &fmt!("{}", number)));
2264 let gap = Sp(style.furniture.foot_size.raw() / 4);
2265 let hang = pre_dims.width + gap;
2266 let inner = if measure > hang { measure - hang } else { measure };
2267
2268 let mut lines = res!(break_paragraph_pieces(
2269 fonts.clone(), Role::Body, Dir::Ltr, style.furniture.foot_size, &pieces, inner, style.furniture.foot_leading, true, true, Rgba::BLACK,
2270 Some(cap_edge(style, style.furniture.foot_size))));
2271 indent_item(&mut lines, Leaf::text_dims(pre_shaped, pre_dims), hang);
2272
2273 let mut height = Sp::ZERO;
2274 for n in &lines {
2275 height += n.vextent();
2276 }
2277
2278 Ok(Footnote { number, mark, note: lines, height })
2279}
2280
2281/// Turns a footnote's inline runs into the pieces the line breaker weaves: a text run keeps its face, a
2282/// `*strong*` sets bold, an `_emph_` italic, a superscript rides raised, a code span sets mono, an in-note
2283/// maths span is flattened to leaves, and a glossary term sets its display text. A nested footnote, a
2284/// cross-reference and a citation are set as plain text or dropped, since a footnote carries no counter,
2285/// reserved page slot or bibliography of its own at this increment.
2286fn footnote_pieces(
2287 fonts: Arc<FontSet>,
2288 style: &Theme,
2289 segments: &[Segment],
2290)
2291 -> Outcome<Vec<Piece>>
2292{
2293 let size = style.furniture.foot_size;
2294 let mut pieces = Vec::with_capacity(segments.len());
2295 for seg in segments {
2296 match seg {
2297 Segment::Text(t) => pieces.push(Piece::Text { text: t.clone(), role: Role::Body }),
2298 Segment::Strong(t) => pieces.push(Piece::Text { text: t.clone(), role: Role::Bold }),
2299 Segment::Emph(t) => pieces.push(Piece::Text { text: t.clone(), role: Role::Italic }),
2300 Segment::BoldItalic(t) => pieces.push(Piece::Text { text: t.clone(), role: Role::BoldItalic }),
2301 Segment::SmallCaps(t) => pieces.push(Piece::SmallCaps { text: t.clone(), role: Role::Body }),
2302 Segment::Code(t) => pieces.push(Piece::Text { text: t.clone(), role: Role::Mono }),
2303 Segment::Glossary { display, .. }
2304 => pieces.push(Piece::Text { text: display.clone(), role: Role::Body }),
2305 Segment::Cite(keys) => pieces.push(Piece::Text { text: fmt!("({})", keys.join("; ")), role: Role::Body }),
2306 Segment::PageRef(_) => {}, // a cross-reference in a note carries no reserved slot here
2307 Segment::Footnote { .. } => {}, // a nested footnote is not set within a footnote
2308 Segment::MarginNote { .. } => {}, // a margin note is not set within a footnote's own body
2309 Segment::Index { .. } => {}, // an index marker in a note is not recorded here
2310 Segment::Super(t) => {
2311 let (shaped, dims) = res!(superscript(fonts.clone(), Role::Body, size, t));
2312 pieces.push(Piece::Mark(Leaf::text_dims(shaped, dims)));
2313 },
2314 Segment::Sub(t) => {
2315 let (shaped, dims) = res!(subscript(fonts.clone(), Role::Body, size, t));
2316 pieces.push(Piece::Mark(Leaf::text_dims(shaped, dims)));
2317 },
2318 Segment::Math(expr) => {
2319 let node = res!(math::layout(fonts.clone(), style, expr, false));
2320 if let Node::HBox(b) = node {
2321 let ascent = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, "0")).dims().height;
2322 let over = if b.dims.height > ascent { b.dims.height - ascent } else { Sp::ZERO };
2323 pieces.push(Piece::Math { nodes: b.list, width: b.dims.width, height: ascent, depth: b.dims.depth, over });
2324 }
2325 },
2326 }
2327 }
2328 Ok(pieces)
2329}
2330
2331/// Shapes a short run at `0.7x` the surrounding size and returns it with the box that raises its
2332/// baseline. The box height is the surrounding ascent less a raise of a third of that ascent; the
2333/// emitter draws a run's baseline at `y + height`, so a shorter box lifts the run above the line's
2334/// baseline. The width and depth are the small run's own, keeping the mark narrow.
2335pub(crate) fn superscript(
2336 fonts: Arc<FontSet>,
2337 role: Role,
2338 base: Sp,
2339 text: &str,
2340)
2341 -> Outcome<(ShapedText, Dims)>
2342{
2343 let small = Sp(base.raw() * 7 / 10);
2344 let shaped = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, small, text));
2345 let sd = shaped.dims();
2346
2347 // The surrounding line's ascent, taken from a body-size digit, and the raise off its baseline.
2348 let sample = res!(ShapedText::new(fonts, role, Dir::Ltr, base, "0"));
2349 let ascent = sample.dims().height;
2350 let raise = Sp(ascent.raw() * 35 / 100);
2351 let height = if ascent > raise { ascent - raise } else { ascent };
2352
2353 Ok((shaped, Dims::new(sd.width, height, sd.depth)))
2354}
2355
2356/// Shapes a short run at `0.7x` the surrounding size and returns it with the box that drops its
2357/// baseline, the mirror of [`superscript`]. The box height is the surrounding ascent plus a drop of a
2358/// fifth of that ascent; since the emitter draws a run's baseline at `y + height`, a taller box seats
2359/// the run below the line's baseline. The width and depth are the small run's own, keeping the mark
2360/// narrow.
2361pub(crate) fn subscript(
2362 fonts: Arc<FontSet>,
2363 role: Role,
2364 base: Sp,
2365 text: &str,
2366)
2367 -> Outcome<(ShapedText, Dims)>
2368{
2369 let small = Sp(base.raw() * 7 / 10);
2370 let shaped = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, small, text));
2371 let sd = shaped.dims();
2372
2373 // The surrounding line's ascent, taken from a body-size digit, and the drop below its baseline.
2374 let sample = res!(ShapedText::new(fonts, role, Dir::Ltr, base, "0"));
2375 let ascent = sample.dims().height;
2376 let drop = Sp(ascent.raw() * 20 / 100);
2377 let height = ascent + drop;
2378
2379 Ok((shaped, Dims::new(sd.width, height, sd.depth)))
2380}
2381
2382/// Sets a display equation as a centred line, appended to the vertical list. The maths box is laid
2383/// out, its returned HBox unwrapped, and its leaves centred in the measure; a numbered equation gets
2384/// its number flush at the right margin and an [`Equation`](crate::ledger::AnchorKind::Equation) anchor
2385/// recorded just before the line, so the ledger can later resolve a reference to it. The line's height
2386/// and depth take the greater of the maths extent and a body digit, so a short equation still leaves
2387/// room for its number.
2388fn equation(
2389 nodes: &mut Vec<Node>,
2390 fonts: Arc<FontSet>,
2391 style: &Theme,
2392 measure: Sp,
2393 expr: &Atom,
2394 number: Option<u32>,
2395)
2396 -> Outcome<()>
2397{
2398 let node = res!(math::layout(fonts.clone(), style, expr, true));
2399 let (list, dims) = match node {
2400 Node::HBox(b) => (b.list, b.dims),
2401 _ => return Err(err!(
2402 "Maths layout returned a non-HBox node for a display equation."; Bug)),
2403 };
2404
2405 let w = dims.width;
2406 let centre = if measure > w { Sp((measure.raw() - w.raw()) / 2) } else { Sp::ZERO };
2407 let baseline = dims.height; // the maths baseline's distance below the line top
2408
2409 // A body digit fixes the line's minimum height and depth, so the number is never clipped when the
2410 // maths sits shallow.
2411 let sample = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, "0"));
2412 let height = if baseline > sample.dims().height { baseline } else { sample.dims().height };
2413 let depth = if dims.depth > sample.dims().depth { dims.depth } else { sample.dims().depth };
2414
2415 let mut children: Vec<Node> = Vec::new();
2416 if centre.raw() > 0 {
2417 children.push(Node::Glue(Glue::fixed(centre)));
2418 }
2419 for n in list {
2420 children.push(n);
2421 }
2422 let cursor = centre + w; // where the maths ends, from the line's left
2423
2424 if let Some(num) = number {
2425 let label = fmt!("({})", num);
2426 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &label));
2427 let nw = shaped.dims().width;
2428 let target = if measure > nw { measure - nw } else { cursor };
2429 if target > cursor {
2430 children.push(Node::Glue(Glue::fixed(target - cursor)));
2431 }
2432 // The number sits on the maths baseline; a zero-height leaf plus the baseline shift seats it there.
2433 let leaf = Leaf::text_dims(shaped, Dims::new(nw, Sp::ZERO, Sp::ZERO)).with_shift(baseline);
2434 children.push(Node::Leaf(leaf));
2435
2436 let id = AnchorId::new(AnchorKind::Equation, fmt!("eq-{}", num));
2437 nodes.push(Node::Anchor(id));
2438 }
2439
2440 nodes.push(Node::HBox(BoxNode::new(children, Dims::new(measure, height, depth))));
2441 Ok(())
2442}
2443
2444/// Wraps a float's already-lowered material `mid` (a figure and its caption, or an aside box) as a
2445/// [`Node::Float`]. No block spacing is added around it: Typst frames a float with `clearance` (default
2446/// 1.5em of the float's font size), which the driver lays as the gap between the float and the body, so
2447/// the committed height the break weighs is `mid` alone.
2448fn push_float(nodes: &mut Vec<Node>, mid: Vec<Node>, clearance: Sp, floating: Floating) {
2449 let mut h = Sp::ZERO;
2450 for n in &mid {
2451 h += n.vextent();
2452 }
2453 nodes.push(Node::Float(FloatNode::new(mid, h, clearance, floating)));
2454}
2455
2456/// The clearance a float is framed with -- Typst's `place.clearance` default, 1.5em of the float's font
2457/// size, resolved here against the body text size in force where the float is set.
2458fn float_clearance(style: &Theme) -> Sp {
2459 Sp::from_pt(style.text.body_size.to_pt() * 1.5)
2460}
2461
2462/// Sets a figure: its identity as a [`Float`](crate::ledger::AnchorKind::Float) anchor, the graphic
2463/// centred on its own line, and a caption centred beneath. The graphic's dimensions are its bounding
2464/// box, `height` the whole visual extent and `depth` zero, so the line advances by the figure's height
2465/// and the greedy breaker moves it whole. The anchor is recorded before the ink so a reference to the
2466/// figure resolves the page it lands on.
2467fn figure(
2468 nodes: &mut Vec<Node>,
2469 fonts: Arc<FontSet>,
2470 style: &Theme,
2471 measure: Sp,
2472 graphic: Graphic,
2473 caption: Option<&str>,
2474 number: u32,
2475)
2476 -> Outcome<()>
2477{
2478 let id = AnchorId::new(AnchorKind::Float, fmt!("fig-{}", number));
2479 nodes.push(Node::Anchor(id));
2480
2481 // The graphic centred: a fixed box with glue to its left, on a line whose height is the figure's.
2482 let leaf = Leaf::graphic(graphic);
2483 let gw = leaf.dims.width;
2484 let gh = leaf.dims.height + leaf.dims.depth;
2485 let pad = if measure > gw { Sp((measure.raw() - gw.raw()) / 2) } else { Sp::ZERO };
2486 let mut row: Vec<Node> = Vec::new();
2487 if pad.raw() > 0 {
2488 row.push(Node::Glue(Glue::fixed(pad)));
2489 }
2490 row.push(Node::Leaf(leaf));
2491 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, gh, Sp::ZERO))));
2492
2493 // The caption, centred beneath the figure, set in the italic at the footnote size.
2494 let text = match caption {
2495 Some(c) => fmt!("Figure {}. {}", number, c),
2496 None => fmt!("Figure {}.", number),
2497 };
2498 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(5.0))));
2499 // The caption sets in the italic at the theme's figure caption size (its default the footnote size).
2500 let shaped = res!(ShapedText::new(fonts, Role::Italic, Dir::Ltr, style.figure.caption_size, &text));
2501 let cd = shaped.dims();
2502 let cpad = if measure > cd.width { Sp((measure.raw() - cd.width.raw()) / 2) } else { Sp::ZERO };
2503 let mut crow: Vec<Node> = Vec::new();
2504 if cpad.raw() > 0 {
2505 crow.push(Node::Glue(Glue::fixed(cpad)));
2506 }
2507 crow.push(Node::Leaf(Leaf::text(shaped)));
2508 nodes.push(Node::HBox(BoxNode::new(crow, Dims::new(measure, cd.height, cd.depth))));
2509 Ok(())
2510}
2511
2512/// The next number for a figure supplement, incrementing its running count so tables and figures carry
2513/// independent sequences.
2514fn next_number(counters: &mut HashMap<String, u32>, supplement: &str) -> u32 {
2515 let n = counters.entry(supplement.to_string()).or_insert(0);
2516 *n += 1;
2517 *n
2518}
2519
2520/// Sets a table wrapped in a figure: the figure's anchors, the ruled table as one keep box, then a
2521/// numbered caption beneath. The table lowers exactly as a bare [`Block::Table`] does, so it moves whole
2522/// to the next page when it will not fit where it stands.
2523#[allow(clippy::too_many_arguments)]
2524fn table_figure(
2525 nodes: &mut Vec<Node>,
2526 fonts: Arc<FontSet>,
2527 geom: PageGeometry,
2528 style: &Theme,
2529 measure: Sp,
2530 table: &Table,
2531 caption: Option<&[Segment]>,
2532 supplement: &str,
2533 number: u32,
2534 label: Option<&str>,
2535 foot_no: &mut u32,
2536 ref_no: &mut u32,
2537 margin_no: &mut u32,
2538 seen: &mut HashSet<String>,
2539 idx: &mut IndexGather,
2540 claim: &mut ClaimGather,
2541 bib: Option<&Bibliography>,
2542 refs: &HashMap<String, String>,
2543)
2544 -> Outcome<()>
2545{
2546 figure_anchors(nodes, supplement, number, label);
2547 nodes.push(res!(table::lower(
2548 fonts.clone(), geom, style, measure, table,
2549 foot_no, ref_no, margin_no, seen, idx, claim, bib, refs)));
2550 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(5.0))));
2551 res!(captioned(nodes, fonts, style, measure, supplement, number, caption));
2552 Ok(())
2553}
2554
2555/// Sets an image wrapped in a figure: the figure's anchors, the loaded raster centred in the measure,
2556/// then a numbered caption beneath. A path that resolves to nothing, or a vector SVG with no raster
2557/// beside it, falls back to the placeholder box, which holds the same space so pagination is unchanged.
2558#[allow(clippy::too_many_arguments)]
2559fn image_figure(
2560 nodes: &mut Vec<Node>,
2561 fonts: Arc<FontSet>,
2562 style: &Theme,
2563 measure: Sp,
2564 path: &str,
2565 width: Option<Length>,
2566 height: Option<Length>,
2567 scale: Option<f64>,
2568 caption: Option<&[Segment]>,
2569 supplement: &str,
2570 number: u32,
2571 label: Option<&str>,
2572)
2573 -> Outcome<()>
2574{
2575 figure_anchors(nodes, supplement, number, label);
2576
2577 // The loaded figure sized to the measure, or the placeholder box when nothing loads. A load failure
2578 // is not fatal: the figure keeps its space and its caption, and the missing ink is a reported gap. A
2579 // raster fills a rectangle; an SVG is drawn as its own scaled paths.
2580 let graphic = match crate::image::load_figure(path) {
2581 Ok(crate::image::Figure::Raster(img)) => res!(image_graphic(measure, img, width, height, scale)),
2582 Ok(crate::image::Figure::Vector(pic)) => res!(svg_graphic(fonts.clone(), measure, pic, width, height, scale)),
2583 Err(_) => res!(placeholder(measure)),
2584 };
2585 let leaf = Leaf::graphic(graphic);
2586 let gw = leaf.dims.width;
2587 let gh = leaf.dims.height + leaf.dims.depth;
2588 let pad = if measure > gw { Sp((measure.raw() - gw.raw()) / 2) } else { Sp::ZERO };
2589 let mut row: Vec<Node> = Vec::new();
2590 if pad.raw() > 0 {
2591 row.push(Node::Glue(Glue::fixed(pad)));
2592 }
2593 row.push(Node::Leaf(leaf));
2594 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, gh, Sp::ZERO))));
2595 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(5.0))));
2596 res!(captioned(nodes, fonts, style, measure, supplement, number, caption));
2597 Ok(())
2598}
2599
2600/// Sets a plain centred image with no figure number or caption -- a `#padded-image`/`#image` section
2601/// opener's logo. The image is sized and loaded exactly as a figure's is (an SVG drawn as its own scaled
2602/// paths, a raster to fill its box, a failed load standing in with the placeholder), then centred in the
2603/// measure with the template's 10 pt of padding above and below, so the words after it keep their air.
2604fn plain_image(
2605 nodes: &mut Vec<Node>,
2606 fonts: Arc<FontSet>,
2607 measure: Sp,
2608 path: &str,
2609 width: Option<Length>,
2610 height: Option<Length>,
2611 scale: Option<f64>,
2612)
2613 -> Outcome<()>
2614{
2615 let graphic = match crate::image::load_figure(path) {
2616 Ok(crate::image::Figure::Raster(img)) => res!(image_graphic(measure, img, width, height, scale)),
2617 Ok(crate::image::Figure::Vector(pic)) => res!(svg_graphic(fonts.clone(), measure, pic, width, height, scale)),
2618 Err(_) => res!(placeholder(measure)),
2619 };
2620 let pad = Sp::from_pt(10.0); // the template's `padded-image` padding, above and below
2621 nodes.push(Node::Glue(Glue::fixed(pad)));
2622 let leaf = Leaf::graphic(graphic);
2623 let gw = leaf.dims.width;
2624 let gh = leaf.dims.height + leaf.dims.depth;
2625 let lpad = if measure > gw { Sp((measure.raw() - gw.raw()) / 2) } else { Sp::ZERO };
2626 let mut row: Vec<Node> = Vec::new();
2627 if lpad.raw() > 0 {
2628 row.push(Node::Glue(Glue::fixed(lpad)));
2629 }
2630 row.push(Node::Leaf(leaf));
2631 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, gh, Sp::ZERO))));
2632 nodes.push(Node::Glue(Glue::fixed(pad)));
2633 Ok(())
2634}
2635
2636/// Sets a figure drawn by code: the figure's anchors, the built graphic centred in the measure (scaled
2637/// down uniformly if it is wider than the measure), then a numbered caption beneath. Building can fail --
2638/// a malformed diagram -- in which case the placeholder holds the space so pagination is unchanged.
2639#[allow(clippy::too_many_arguments)]
2640fn code_figure(
2641 nodes: &mut Vec<Node>,
2642 fonts: Arc<FontSet>,
2643 style: &Theme,
2644 measure: Sp,
2645 figure: &crate::lang::codefig::CodeFigure,
2646 caption: Option<&[Segment]>,
2647 supplement: &str,
2648 number: u32,
2649 label: Option<&str>,
2650)
2651 -> Outcome<()>
2652{
2653 figure_anchors(nodes, supplement, number, label);
2654
2655 let graphic = match figure.build(fonts.clone()) {
2656 Ok(g) => res!(fit_graphic(g, measure)),
2657 Err(_) => res!(placeholder(measure)),
2658 };
2659 let leaf = Leaf::graphic(graphic);
2660 let gw = leaf.dims.width;
2661 let gh = leaf.dims.height + leaf.dims.depth;
2662 let pad = if measure > gw { Sp((measure.raw() - gw.raw()) / 2) } else { Sp::ZERO };
2663 let mut row: Vec<Node> = Vec::new();
2664 if pad.raw() > 0 {
2665 row.push(Node::Glue(Glue::fixed(pad)));
2666 }
2667 row.push(Node::Leaf(leaf));
2668 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, gh, Sp::ZERO))));
2669 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(5.0))));
2670 res!(captioned(nodes, fonts, style, measure, supplement, number, caption));
2671 Ok(())
2672}
2673
2674/// Scales a built graphic down uniformly if it is wider than the measure, so a wide diagram fits the text
2675/// block; a graphic already within the measure is returned unchanged. Every path is carried through the
2676/// same factor, and the dimensions follow.
2677fn fit_graphic(g: Graphic, measure: Sp) -> Outcome<Graphic> {
2678 let w = g.dims.width;
2679 if w <= measure || w.raw() <= 0 {
2680 return Ok(g);
2681 }
2682 let s = measure.to_pt() as f32 / w.to_pt() as f32;
2683 let t = Transform::scale(s, s);
2684 let mut ops: Vec<DrawOp> = Vec::with_capacity(g.ops.len());
2685 for op in g.ops {
2686 ops.push(match op {
2687 DrawOp::Fill { path, colour } => DrawOp::Fill { path: res!(path.transform(&t)), colour },
2688 DrawOp::Stroke { path, colour, width } => DrawOp::Stroke {
2689 path: res!(path.transform(&t)),
2690 colour,
2691 width: width * s,
2692 },
2693 DrawOp::Image { image, x, y, w, h } => DrawOp::Image {
2694 image, x: x * s, y: y * s, w: w * s, h: h * s,
2695 },
2696 });
2697 }
2698 let dims = Dims::new(
2699 Sp::from_pt(g.dims.width.to_pt() * s as f64),
2700 Sp::from_pt(g.dims.height.to_pt() * s as f64),
2701 Sp::from_pt(g.dims.depth.to_pt() * s as f64),
2702 );
2703 Ok(Graphic::new(ops, dims))
2704}
2705
2706/// Builds a graphic that draws a loaded raster to fill a box sized from the declared hints and the
2707/// image's own aspect. With no hint the image fills the measure; a `width`/`height` in the source sets
2708/// that axis and the other follows the aspect; a hint that would overflow the measure is clamped to it.
2709/// A single [`DrawOp::Image`] carries the pixels, so the emitters place one raster per figure.
2710fn image_graphic(
2711 measure: Sp,
2712 img: RasterImage,
2713 width: Option<Length>,
2714 height: Option<Length>,
2715 scale: Option<f64>,
2716)
2717 -> Outcome<Graphic>
2718{
2719 let m = measure.to_pt();
2720 let iw = img.width.max(1) as f64;
2721 let ih = img.height.max(1) as f64;
2722 let aspect = ih / iw;
2723
2724 // Resolve the declared width and height to points; a percentage is of the measure, a length absolute.
2725 let resolve = |len: Length| -> f64 {
2726 match len {
2727 Length::Rel(f) => m * f,
2728 Length::Abs(pt) => pt,
2729 }
2730 };
2731
2732 // A width wins the sizing; else a height sets it through the aspect; else the image fills the
2733 // measure. `scale` on a `padded-image` multiplies a filled measure, so a 100% scale is the measure.
2734 let mut w = match (width, height) {
2735 (Some(wl), _) => resolve(wl),
2736 (None, Some(hl)) => resolve(hl) / aspect,
2737 (None, None) => m * scale.unwrap_or(1.0),
2738 };
2739 if w > m || w <= 0.0 {
2740 w = m;
2741 }
2742 let h = match height {
2743 Some(hl) if width.is_none() && scale.is_none() => resolve(hl),
2744 _ => w * aspect,
2745 };
2746
2747 let wf = w as f32;
2748 let hf = h as f32;
2749 let ops = vec![DrawOp::Image { image: Arc::new(img), x: 0.0, y: 0.0, w: wf, h: hf }];
2750 Ok(Graphic::new(ops, Dims::new(Sp::from_pt(w), Sp::from_pt(h), Sp::ZERO)))
2751}
2752
2753/// Builds a graphic from a read SVG, scaled to fit the box the sizing hints and the picture's own aspect
2754/// ask for -- the same sizing a raster gets -- and its paths mapped to fill and stroke ops.
2755///
2756/// The picture comes out of the reader in its viewBox units, which for a typesetter's SVG are points, so
2757/// the intrinsic size stands in for a raster's pixel dimensions. One uniform factor scales every path;
2758/// a dashed or a capped stroke is baked to a filled outline first, since a plain [`DrawOp::Stroke`]
2759/// carries only a width, and the emitter would otherwise draw it solid. An illustrator's live `<text>`
2760/// arrives unshaped, so it is shaped here with the book's font set and baked to glyph outlines, and an
2761/// embedded raster is placed as a scaled [`DrawOp::Image`].
2762fn svg_graphic(
2763 fonts: Arc<FontSet>,
2764 measure: Sp,
2765 pic: SvgPicture,
2766 width: Option<Length>,
2767 height: Option<Length>,
2768 scale: Option<f64>,
2769)
2770 -> Outcome<Graphic>
2771{
2772 let m = measure.to_pt();
2773 let iw = (pic.width as f64).max(1.0);
2774 let ih = (pic.height as f64).max(1.0);
2775 let aspect = ih / iw;
2776
2777 let resolve = |len: Length| -> f64 {
2778 match len {
2779 Length::Rel(f) => m * f,
2780 Length::Abs(pt) => pt,
2781 }
2782 };
2783 let mut w = match (width, height) {
2784 (Some(wl), _) => resolve(wl),
2785 (None, Some(hl)) => resolve(hl) / aspect,
2786 (None, None) => m * scale.unwrap_or(1.0),
2787 };
2788 if w > m || w <= 0.0 {
2789 w = m;
2790 }
2791 let h = match height {
2792 Some(hl) if width.is_none() && scale.is_none() => resolve(hl),
2793 _ => w * aspect,
2794 };
2795
2796 // A uniform factor from the picture's intrinsic width to the drawn width; the height follows the
2797 // same factor, since the aspect was preserved above.
2798 let s = (w / iw) as f32;
2799 let t = Transform::scale(s, s);
2800 let mut ops: Vec<DrawOp> = Vec::with_capacity(pic.ops.len());
2801 for op in pic.ops {
2802 match op {
2803 SvgOp::Fill { path, colour } => {
2804 ops.push(DrawOp::Fill { path: res!(path.transform(&t)), colour });
2805 },
2806 SvgOp::Stroke { path, colour, stroke } => {
2807 if stroke.dash.is_some() {
2808 // Bake the dashes into an outline in the picture's frame, then scale that with the rest.
2809 let outline = res!(path.stroke(&stroke));
2810 ops.push(DrawOp::Fill { path: res!(outline.transform(&t)), colour });
2811 } else {
2812 ops.push(DrawOp::Stroke {
2813 path: res!(path.transform(&t)),
2814 colour,
2815 width: stroke.width * s,
2816 });
2817 }
2818 },
2819 SvgOp::Text { text, local, x, y, size, anchor, italic, bold, colour } => {
2820 res!(bake_svg_text(
2821 &mut ops, fonts.clone(), &text, &local, &t, x, y, size, anchor, italic, bold, colour));
2822 },
2823 SvgOp::Image { rgba, iw, ih, x, y, w: iwd, h: ihd } => {
2824 // The raster's placement rectangle is in the picture frame; the same factor scales it.
2825 let img = RasterImage { width: iw, height: ih, rgba };
2826 ops.push(DrawOp::Image {
2827 image: Arc::new(img),
2828 x: x * s,
2829 y: y * s,
2830 w: iwd * s,
2831 h: ihd * s,
2832 });
2833 },
2834 }
2835 }
2836 Ok(Graphic::new(ops, Dims::new(Sp::from_pt(w), Sp::from_pt(h), Sp::ZERO)))
2837}
2838
2839/// Shapes one live SVG text run with the book's font set and bakes it to filled glyph outlines. The run
2840/// is shaped at its own font-size in the picture's units; `local` maps that frame to the picture frame
2841/// and `t` the picture frame to the drawn frame. The anchor slides the pen from the run's start once the
2842/// advance is known, and each glyph's y-up outline is flipped onto the SVG's y-down baseline before the
2843/// two frame transforms carry it home -- the same bake the diagram and plot labels use.
2844#[allow(clippy::too_many_arguments)]
2845fn bake_svg_text(
2846 ops: &mut Vec<DrawOp>,
2847 fonts: Arc<FontSet>,
2848 text: &str,
2849 local: &Transform,
2850 t: &Transform,
2851 x: f32,
2852 y: f32,
2853 size: f32,
2854 anchor: Anchor,
2855 italic: bool,
2856 bold: bool,
2857 colour: Rgba,
2858)
2859 -> Outcome<()>
2860{
2861 if size <= 0.0 {
2862 return Ok(());
2863 }
2864 let role = match (bold, italic) {
2865 (true, true) => Role::BoldItalic,
2866 (true, false) => Role::Bold,
2867 (false, true) => Role::Italic,
2868 (false, false) => Role::Body,
2869 };
2870 let shaped = res!(ShapedText::new(fonts, role, Dir::Ltr, Sp::from_pt(size as f64), text));
2871 let advance = shaped.dims().width.to_pt() as f32;
2872 let pen_x = match anchor {
2873 Anchor::Start => x,
2874 Anchor::Middle => x - advance / 2.0,
2875 Anchor::End => x - advance,
2876 };
2877 for glyph in &shaped.run().glyphs {
2878 let outline = res!(shaped.outline(glyph));
2879 if outline.is_empty() {
2880 continue; // a space carries an advance but no ink
2881 }
2882 let place = Transform::scale(1.0, -1.0)
2883 .then(&Transform::translate(pen_x + glyph.x, y - glyph.y))
2884 .then(local)
2885 .then(t);
2886 ops.push(DrawOp::Fill { path: res!(outline.transform(&place)), colour });
2887 }
2888 Ok(())
2889}
2890
2891/// Records a figure's anchors: an author label (when the source labelled it) so a cross-reference
2892/// resolves the figure's page, and a [`Float`](crate::ledger::AnchorKind::Float) anchor keyed by
2893/// supplement and number for the figure's own identity.
2894fn figure_anchors(nodes: &mut Vec<Node>, supplement: &str, number: u32, label: Option<&str>) {
2895 if let Some(l) = label {
2896 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, l.to_string())));
2897 }
2898 nodes.push(Node::Anchor(AnchorId::new(
2899 AnchorKind::Float, fmt!("{}-{}", supplement.to_lowercase(), number))));
2900}
2901
2902/// One typeset unit of a caption: an unbreakable cluster of one or more boxes (a word, or a word with an
2903/// attached superscript, or a maths cluster) with its extent, or a breakable interword space.
2904enum CapTok {
2905 Unit { nodes: Vec<Node>, width: Sp, height: Sp, depth: Sp },
2906 Space,
2907}
2908
2909/// Sets a figure caption -- "{supplement} {number}: {caption}" -- centred beneath the figure, wrapped
2910/// greedily into ragged centred lines at the body size. The caption's own runs are set with their faces,
2911/// so an emphasised word, a superscript or an in-caption maths span renders rather than flattening to
2912/// upright text or vanishing. A caption with no text sets just its number.
2913fn captioned(
2914 nodes: &mut Vec<Node>,
2915 fonts: Arc<FontSet>,
2916 style: &Theme,
2917 measure: Sp,
2918 supplement: &str,
2919 number: u32,
2920 caption: Option<&[Segment]>,
2921)
2922 -> Outcome<()>
2923{
2924 let size = style.text.body_size;
2925 let space_w = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, " ")).dims().width;
2926
2927 // The leading "{supplement} {number}: " (or just the number when the caption has no text), then the
2928 // caption's segments, tokenised into words, spaces, superscripts and maths clusters. A shared
2929 // pending-space flag carries a run's trailing space to the next token, so spacing follows the source.
2930 let has_text = caption.map(|c| segments_have_text(c)).unwrap_or(false);
2931 let prefix = if has_text { fmt!("{} {}: ", supplement, number) } else { fmt!("{} {}", supplement, number) };
2932
2933 let mut toks: Vec<CapTok> = Vec::new();
2934 let mut pending = false;
2935 res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Body, size, &prefix, &[]));
2936 if let Some(segs) = caption {
2937 for seg in segs {
2938 match seg {
2939 Segment::Text(t) => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Body, size, t, &[])),
2940 Segment::Strong(t) => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Bold, size, t, &[])),
2941 Segment::Emph(t) => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Italic, size, t, &[])),
2942 Segment::BoldItalic(t) => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::BoldItalic, size, t, &[])),
2943 Segment::Code(t) => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Mono, size, t, &[])),
2944 Segment::SmallCaps(t) => res!(push_caption_text(
2945 &mut toks, &mut pending, fonts.clone(), Role::Body, size, t, &[Feature::SMALL_CAPS])),
2946 Segment::Glossary { display, .. }
2947 => res!(push_caption_text(&mut toks, &mut pending, fonts.clone(), Role::Body, size, display, &[])),
2948 Segment::Cite(keys) => res!(push_caption_text(
2949 &mut toks, &mut pending, fonts.clone(), Role::Body, size, &fmt!("({})", keys.join("; ")), &[])),
2950 Segment::PageRef(_) => {}, // a cross-reference in a caption is not resolved here
2951 Segment::Footnote { .. } => {}, // a footnote in a caption is not set here
2952 Segment::MarginNote { .. } => {}, // a margin note in a caption sets nothing here
2953 Segment::Index { .. } => {}, // an index marker in a caption sets nothing here
2954 Segment::Super(t) => {
2955 let (shaped, dims) = res!(superscript(fonts.clone(), Role::Body, size, t));
2956 push_caption_box(&mut toks, &mut pending,
2957 vec![Node::Leaf(Leaf::text_dims(shaped, dims))], dims.width, dims.height, dims.depth);
2958 },
2959 Segment::Sub(t) => {
2960 let (shaped, dims) = res!(subscript(fonts.clone(), Role::Body, size, t));
2961 push_caption_box(&mut toks, &mut pending,
2962 vec![Node::Leaf(Leaf::text_dims(shaped, dims))], dims.width, dims.height, dims.depth);
2963 },
2964 Segment::Math(expr) => {
2965 let node = res!(math::layout(fonts.clone(), style, expr, false));
2966 if let Node::HBox(b) = node {
2967 push_caption_box(&mut toks, &mut pending, b.list, b.dims.width, b.dims.height, b.dims.depth);
2968 }
2969 },
2970 }
2971 }
2972 }
2973
2974 // Greedy line fill: units joined by single spaces, broken before the unit that would overrun the
2975 // measure. Each finished line is centred by a left glue of half its slack.
2976 let mut line: Vec<&CapTok> = Vec::new();
2977 let mut line_w = Sp::ZERO;
2978 let mut first = true;
2979 for tok in &toks {
2980 if let CapTok::Unit { width, .. } = tok {
2981 let add = if line.is_empty() { *width } else { space_w + *width };
2982 if !line.is_empty() && line_w + add > measure {
2983 res!(emit_caption_units(nodes, style, measure, space_w, &line, line_w, &mut first));
2984 line.clear();
2985 line_w = Sp::ZERO;
2986 }
2987 line_w += if line.is_empty() { *width } else { space_w + *width };
2988 line.push(tok);
2989 }
2990 }
2991 if !line.is_empty() {
2992 res!(emit_caption_units(nodes, style, measure, space_w, &line, line_w, &mut first));
2993 }
2994 Ok(())
2995}
2996
2997/// Whether any caption segment carries visible text, so the colon prefix is set only for a real caption.
2998fn segments_have_text(segs: &[Segment]) -> bool {
2999 segs.iter().any(|s| match s {
3000 Segment::Text(t) | Segment::Strong(t) | Segment::Emph(t) | Segment::BoldItalic(t) | Segment::Code(t) | Segment::Super(t) | Segment::Sub(t)
3001 => !t.trim().is_empty(),
3002 Segment::Glossary { display, .. } => !display.trim().is_empty(),
3003 Segment::Math(_) | Segment::Cite(_) => true,
3004 _ => false,
3005 })
3006}
3007
3008/// Tokenises a text run into word units and interword spaces, in the given face, appending to `toks`. A
3009/// leading or run-crossing space is carried in `pending` and emitted only before the next word, so the
3010/// source's spacing survives and a trailing space attaches to whatever segment follows.
3011fn push_caption_text(
3012 toks: &mut Vec<CapTok>,
3013 pending: &mut bool,
3014 fonts: Arc<FontSet>,
3015 role: Role,
3016 size: Sp,
3017 text: &str,
3018 features: &[Feature],
3019)
3020 -> Outcome<()>
3021{
3022 let mut word = String::new();
3023 for c in text.chars() {
3024 if c.is_whitespace() {
3025 if !word.is_empty() {
3026 res!(flush_caption_word(toks, pending, fonts.clone(), role, size, &mut word, features));
3027 }
3028 *pending = true;
3029 } else {
3030 word.push(c);
3031 }
3032 }
3033 if !word.is_empty() {
3034 res!(flush_caption_word(toks, pending, fonts.clone(), role, size, &mut word, features));
3035 }
3036 Ok(())
3037}
3038
3039/// Shapes one word and pushes it as a unit, emitting a pending space before it when one is due.
3040fn flush_caption_word(
3041 toks: &mut Vec<CapTok>,
3042 pending: &mut bool,
3043 fonts: Arc<FontSet>,
3044 role: Role,
3045 size: Sp,
3046 word: &mut String,
3047 features: &[Feature],
3048)
3049 -> Outcome<()>
3050{
3051 let shaped = res!(ShapedText::new_with_features(fonts, role, Dir::Ltr, size, word, features));
3052 let d = shaped.dims();
3053 push_caption_box(toks, pending, vec![Node::Leaf(Leaf::text(shaped))], d.width, d.height, d.depth);
3054 word.clear();
3055 Ok(())
3056}
3057
3058/// Pushes a pre-built box as a caption unit, emitting a pending interword space before it first. Adjacent
3059/// boxes with no pending space between them (a word and its attached superscript) become one unit.
3060fn push_caption_box(
3061 toks: &mut Vec<CapTok>,
3062 pending: &mut bool,
3063 mut boxes: Vec<Node>,
3064 width: Sp,
3065 height: Sp,
3066 depth: Sp,
3067)
3068{
3069 if *pending {
3070 toks.push(CapTok::Space);
3071 *pending = false;
3072 } else if let Some(CapTok::Unit { nodes, width: w, height: h, depth: dp }) = toks.last_mut() {
3073 // No space since the previous unit: attach to it, so a word and its superscript stay unbreakable.
3074 nodes.append(&mut boxes);
3075 *w = *w + width;
3076 *h = (*h).max(height);
3077 *dp = (*dp).max(depth);
3078 return;
3079 }
3080 toks.push(CapTok::Unit { nodes: boxes, width, height, depth });
3081}
3082
3083/// Sets one centred caption line from its units, with interline leading before every line but the first.
3084fn emit_caption_units(
3085 nodes: &mut Vec<Node>,
3086 style: &Theme,
3087 measure: Sp,
3088 space_w: Sp,
3089 line: &[&CapTok],
3090 line_w: Sp,
3091 first: &mut bool,
3092)
3093 -> Outcome<()>
3094{
3095 let mut height = Sp::ZERO;
3096 let mut depth = Sp::ZERO;
3097 for tok in line {
3098 if let CapTok::Unit { height: h, depth: d, .. } = tok {
3099 height = height.max(*h);
3100 depth = depth.max(*d);
3101 }
3102 }
3103 if !*first {
3104 let vext = height + depth;
3105 let gap = if style.text.leading > vext { style.text.leading - vext } else { style.table.line_gap };
3106 nodes.push(Node::Glue(Glue::fixed(gap)));
3107 }
3108 *first = false;
3109
3110 let pad = if measure > line_w { Sp((measure.raw() - line_w.raw()) / 2) } else { Sp::ZERO };
3111 let mut row: Vec<Node> = Vec::new();
3112 if pad.raw() > 0 {
3113 row.push(Node::Glue(Glue::fixed(pad)));
3114 }
3115 for (k, tok) in line.iter().enumerate() {
3116 if let CapTok::Unit { nodes: ns, .. } = tok {
3117 if k > 0 {
3118 row.push(Node::Glue(Glue::fixed(space_w))); // the single interword space between units
3119 }
3120 for n in ns { row.push(n.clone()); }
3121 }
3122 }
3123 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, height, depth))));
3124 Ok(())
3125}
3126
3127/// Builds the placeholder box that stands in for an image this increment does not load: a light-filled,
3128/// lightly-stroked rectangle the width of the measure and half as tall, capped so a wide page does not
3129/// leave a giant void. The caption beneath still names the figure.
3130fn placeholder(measure: Sp) -> Outcome<Graphic> {
3131 let w = measure.to_pt() as f32;
3132 let h = (w * 0.5).clamp(120.0, 360.0);
3133 let mut pb = PathBuilder::new();
3134 pb.move_to(Pt::new(0.0, 0.0));
3135 pb.line_to(Pt::new(w, 0.0));
3136 pb.line_to(Pt::new(w, h));
3137 pb.line_to(Pt::new(0.0, h));
3138 pb.close();
3139 let path = res!(pb.finish());
3140 let ops = vec![
3141 DrawOp::Fill { path: path.clone(), colour: Rgba::opaque(238, 238, 240) },
3142 DrawOp::Stroke { path, colour: Rgba::opaque(150, 150, 150), width: 0.8 },
3143 ];
3144 Ok(Graphic::new(ops, Dims::new(Sp::from_pt(w as f64), Sp::from_pt(h as f64), Sp::ZERO)))
3145}
3146
3147// ┌───────────────────────────────────────────────────────────────────────────┐
3148// │ FRONT MATTER │
3149// └───────────────────────────────────────────────────────────────────────────┘
3150
3151/// Composes the front matter ahead of the body: the cover raster (a development build only), the title
3152/// page, the imprint, an optional dedication, and an optional author biography, each on its own page
3153/// closed by a forced break. None of these leaves sets a heading anchor, so the body's first heading
3154/// still fixes where the printed folio restarts at one.
3155fn front_matter(
3156 nodes: &mut Vec<Node>,
3157 fonts: &Arc<FontSet>,
3158 faces: &FaceResolver,
3159 geom: PageGeometry,
3160 style: &Theme,
3161 fm: &FrontMatter,
3162)
3163 -> Outcome<()>
3164{
3165 // The title-page display font: the level-1 heading face resolved once, or `None` when the tree ships no
3166 // display face, in which case the title helpers set in the body role exactly as before.
3167 let display = head_solo(&resolved_head_face(1, style, faces, is_doc_heading(style)));
3168 // Cover: the raster filling the content box, a development build only. A path that will not load
3169 // (an SVG, or a missing file) sets no cover page rather than a placeholder.
3170 if let Some(path) = &fm.cover_image {
3171 if let Ok(node) = fm_cover_node(geom, path) {
3172 nodes.push(node);
3173 nodes.push(Node::Penalty(Penalty::eject()));
3174 }
3175 }
3176
3177 // A documentation tree draws the template's two-column title page (a coloured sidebar with its logos and
3178 // the title on the right); a book draws its plain centred title page. The sidebar grey marks the idiom.
3179 // A `Label` anchor at the leaf's top records its page for the PDF outline; it sets no heading, so it
3180 // stays out of the running heads and the contents.
3181 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, "frontmatter:title")));
3182 if fm.sidebar_grey.is_some() {
3183 res!(fm_doc_title_page(nodes, fonts, geom, fm));
3184 } else {
3185 res!(fm_title_page(nodes, fonts, geom, style, fm));
3186 }
3187 nodes.push(Node::Penalty(Penalty::eject()));
3188
3189 // The meta page: a doc tree draws the template's Ver/Date/Author(s)/Notes colophon, a book its plain
3190 // imprint page. Both push the `frontmatter:meta` anchor so the outline lists a Meta entry at this leaf.
3191 if fm.sidebar_grey.is_some() {
3192 if fm_has_doc_meta(fm) {
3193 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, "frontmatter:meta")));
3194 res!(fm_doc_meta_page(nodes, fonts, geom, style, fm));
3195 nodes.push(Node::Penalty(Penalty::eject()));
3196 }
3197 } else if fm_has_imprint(fm) {
3198 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, "frontmatter:meta")));
3199 res!(fm_meta_page(nodes, fonts, geom, style, fm));
3200 nodes.push(Node::Penalty(Penalty::eject()));
3201 }
3202
3203 if let Some(ded) = &fm.dedication {
3204 res!(fm_dedication_page(nodes, fonts, geom, style, ded));
3205 nodes.push(Node::Penalty(Penalty::eject()));
3206 }
3207
3208 if let Some(bio) = &fm.about_author {
3209 res!(fm_about_author_page(nodes, fonts, display, geom, style, fm.back_title_size, bio));
3210 nodes.push(Node::Penalty(Penalty::eject()));
3211 }
3212
3213 Ok(())
3214}
3215
3216/// Does the book set any imprint field, so a meta page is worth composing?
3217fn fm_has_imprint(fm: &FrontMatter) -> bool {
3218 fm.publisher.is_some() || fm.edition.is_some() || fm.isbn.is_some() || fm.copyright.is_some()
3219 || fm.rights.is_some() || fm.ai_declaration.is_some() || fm.website.is_some() || fm.toolchain
3220}
3221
3222/// Does the doc tree state a revision, so the template's meta/colophon page is worth composing? A doc
3223/// root always sets `meta-data` with at least one row, or names an author, so this holds for every doc.
3224fn fm_has_doc_meta(fm: &FrontMatter) -> bool {
3225 !fm.meta_rows.is_empty() || !fm.author.is_empty()
3226}
3227
3228/// A rigid vertical spacer that a page top does not discard, so front-matter elements sit at fixed
3229/// fractions of the page down from the top. Modelled as an empty horizontal box of the wanted height,
3230/// which the greedy breaker advances the cursor by without placing any ink.
3231fn fm_spacer(height: Sp) -> Node {
3232 Node::HBox(BoxNode::new(Vec::new(), Dims::new(Sp::ZERO, height, Sp::ZERO)))
3233}
3234
3235/// Shapes one line and pushes it centred in the measure, returning its vertical extent so the caller can
3236/// track the cursor down the page.
3237fn fm_centred_line(
3238 nodes: &mut Vec<Node>,
3239 fonts: &Arc<FontSet>,
3240 display: Option<&Arc<Font>>,
3241 role: Role,
3242 size: Sp,
3243 text: &str,
3244 measure: Sp,
3245)
3246 -> Outcome<Sp>
3247{
3248 let shaped = match display {
3249 Some(f) => res!(ShapedText::new_with_font((*f).clone(), Dir::Ltr, size, text)),
3250 None => res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, size, text)),
3251 };
3252 let d = shaped.dims();
3253 let pad = if measure > d.width { Sp((measure.raw() - d.width.raw()) / 2) } else { Sp::ZERO };
3254 let mut row: Vec<Node> = Vec::new();
3255 if pad.raw() > 0 {
3256 row.push(Node::Glue(Glue::fixed(pad)));
3257 }
3258 row.push(Node::Leaf(Leaf::text(shaped)));
3259 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, d.height, d.depth))));
3260 Ok(d.height + d.depth)
3261}
3262
3263/// Sets a run of text as greedily-wrapped centred lines (the title, which may not fit one line),
3264/// returning the total vertical extent set.
3265fn fm_centred_wrap(
3266 nodes: &mut Vec<Node>,
3267 fonts: &Arc<FontSet>,
3268 role: Role,
3269 size: Sp,
3270 text: &str,
3271 measure: Sp,
3272 leading: Sp,
3273)
3274 -> Outcome<Sp>
3275{
3276 let mut line = String::new();
3277 let mut total = Sp::ZERO;
3278 let mut first = true;
3279 let mut flush = |nodes: &mut Vec<Node>, line: &str, first: &mut bool, total: &mut Sp| -> Outcome<()> {
3280 let shaped = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, size, line));
3281 let d = shaped.dims();
3282 if !*first {
3283 let vext = d.height + d.depth;
3284 let gap = if leading > vext { leading - vext } else { Sp::ZERO };
3285 nodes.push(Node::Glue(Glue::fixed(gap)));
3286 *total += gap;
3287 }
3288 *first = false;
3289 let pad = if measure > d.width { Sp((measure.raw() - d.width.raw()) / 2) } else { Sp::ZERO };
3290 let mut row: Vec<Node> = Vec::new();
3291 if pad.raw() > 0 {
3292 row.push(Node::Glue(Glue::fixed(pad)));
3293 }
3294 row.push(Node::Leaf(Leaf::text(shaped)));
3295 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, d.height, d.depth))));
3296 *total += d.height + d.depth;
3297 Ok(())
3298 };
3299 for word in text.split_whitespace() {
3300 let trial = if line.is_empty() { word.to_string() } else { fmt!("{} {}", line, word) };
3301 let shaped = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, size, &trial));
3302 if shaped.dims().width > measure && !line.is_empty() {
3303 res!(flush(nodes, &line, &mut first, &mut total));
3304 line = word.to_string();
3305 } else {
3306 line = trial;
3307 }
3308 }
3309 if !line.is_empty() {
3310 res!(flush(nodes, &line, &mut first, &mut total));
3311 }
3312 Ok(total)
3313}
3314
3315/// Builds the cover page: the raster at `path` bleeding to the trim edge on all four sides. The box the
3316/// breaker measures is the content area, so the cover paginates as a single leaf closed by the caller's
3317/// eject; the image inside is offset back to the physical page origin and sized to the whole trim, so it
3318/// paints under the margins to the paper edge. The emitter does not clip a graphic to its box, so the
3319/// overpaint lands. Page one is a recto -- the inside margin is the left one and no mirror shift applies
3320/// -- so the offset is simply the top and inside margins.
3321fn fm_cover_node(geom: PageGeometry, path: &str) -> Outcome<Node> {
3322 let img = res!(crate::image::load(path));
3323 let cw = geom.content_width();
3324 let ch = geom.content_height();
3325 let ox = -(geom.content_left().to_pt() as f32);
3326 let oy = -(geom.content_top().to_pt() as f32);
3327 let pw = geom.width.to_pt() as f32;
3328 let ph = geom.height.to_pt() as f32;
3329 let ops = vec![DrawOp::Image { image: Arc::new(img), x: ox, y: oy, w: pw, h: ph }];
3330 let graphic = Graphic::new(ops, Dims::new(cw, ch, Sp::ZERO));
3331 Ok(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::graphic(graphic))], Dims::new(cw, ch, Sp::ZERO))))
3332}
3333
3334/// Sets the title page: the author name in the upper band, the title and subtitle about the centre, and
3335/// the publisher logo near the foot -- the template's three-band grid, approximated with fixed fractions
3336/// of the page height.
3337fn fm_title_page(
3338 nodes: &mut Vec<Node>,
3339 fonts: &Arc<FontSet>,
3340 geom: PageGeometry,
3341 style: &Theme,
3342 fm: &FrontMatter,
3343)
3344 -> Outcome<()>
3345{
3346 let measure = geom.content_width();
3347 let h = geom.content_height();
3348 let mut y = Sp::ZERO;
3349
3350 // The author name, in the upper fifth.
3351 nodes.push(fm_spacer(Sp(h.raw() * 17 / 100)));
3352 y += Sp(h.raw() * 17 / 100);
3353 y += res!(fm_centred_line(nodes, fonts, None, Role::Body, fm.author_size, &fm.author, measure));
3354
3355 // The title about the vertical centre, wrapped when it will not fit one line, then the subtitle.
3356 let target = Sp(h.raw() * 38 / 100);
3357 if target > y {
3358 nodes.push(fm_spacer(target - y));
3359 y = target;
3360 }
3361 let title_lead = Sp(fm.title_size.raw() * 6 / 5);
3362 y += res!(fm_centred_wrap(nodes, fonts, Role::Bold, fm.title_size, &fm.title, measure, title_lead));
3363 if let Some(sub) = &fm.subtitle {
3364 let gap = Sp(fm.title_size.raw() * 3 / 5);
3365 nodes.push(fm_spacer(gap));
3366 y += gap;
3367 y += res!(fm_centred_line(nodes, fonts, None, Role::Italic, fm.subtitle_size, sub, measure));
3368 }
3369
3370 // The publisher logo near the foot, when it loads (an SVG logo does not, and is simply omitted).
3371 if let Some(logo) = &fm.logo_image {
3372 if let Ok(node) = fm_logo_node(fonts, geom, style, logo) {
3373 let target = Sp(h.raw() * 84 / 100);
3374 if target > y {
3375 nodes.push(fm_spacer(target - y));
3376 }
3377 nodes.push(node);
3378 }
3379 }
3380 Ok(())
3381}
3382
3383/// Builds the logo line: the raster at `path` centred at a modest width. An SVG or missing file errors,
3384/// and the title page omits the logo.
3385fn fm_logo_node(_fonts: &Arc<FontSet>, geom: PageGeometry, _style: &Theme, path: &str) -> Outcome<Node> {
3386 let img = res!(crate::image::load(path));
3387 let measure = geom.content_width();
3388 let w = Sp::from_pt(110.0); // the type scale's logo width, about 110 pt
3389 let aspect = (img.height.max(1) as f64) / (img.width.max(1) as f64);
3390 let hh = Sp::from_pt(110.0 * aspect);
3391 let ops = vec![DrawOp::Image {
3392 image: Arc::new(img), x: 0.0, y: 0.0, w: w.to_pt() as f32, h: hh.to_pt() as f32 }];
3393 let graphic = Graphic::new(ops, Dims::new(w, hh, Sp::ZERO));
3394 let pad = if measure > w { Sp((measure.raw() - w.raw()) / 2) } else { Sp::ZERO };
3395 let mut row: Vec<Node> = Vec::new();
3396 if pad.raw() > 0 {
3397 row.push(Node::Glue(Glue::fixed(pad)));
3398 }
3399 row.push(Node::Leaf(Leaf::graphic(graphic)));
3400 Ok(Node::HBox(BoxNode::new(row, Dims::new(measure, hh, Sp::ZERO))))
3401}
3402
3403/// Sets the documentation template's two-column title page (`template.typ`'s `title-page`): a full-height
3404/// coloured sidebar down the left carrying a logo near its top and one near its foot, and the title (large,
3405/// in small caps or italic) with its subtitle centred on the white right. The whole page is one box whose
3406/// graphic ops bleed past the box bounds to the paper edges -- the emitter clips nothing -- exactly as
3407/// `doc_banner` draws its full-bleed bar. Its box origin is the content top-left (y down), so the page
3408/// origin is `(-inside, -top)` and the paper corner `(page_w - inside, page_h - top)`.
3409fn fm_doc_title_page(
3410 nodes: &mut Vec<Node>,
3411 fonts: &Arc<FontSet>,
3412 geom: PageGeometry,
3413 fm: &FrontMatter,
3414)
3415 -> Outcome<()>
3416{
3417 let measure = geom.content_width();
3418 let box_h = geom.content_height();
3419 let il = geom.content_left().to_pt() as f32; // left margin, and the sidebar logos' `margins.a4` pad
3420 let it = geom.content_top().to_pt() as f32; // top margin, equal to the template's `margins.a4`
3421 let pw = geom.width.to_pt() as f32;
3422 let ph = geom.height.to_pt() as f32;
3423 let frac = fm.sidebar_frac as f32;
3424 let side_w = frac * pw; // the sidebar width, `margins.title_page` of the page
3425
3426 let mut ops: Vec<DrawOp> = Vec::new();
3427
3428 // The sidebar: a solid rectangle from the page's top-left corner, `side_w` wide and the full page tall.
3429 let grey = fm.sidebar_grey.unwrap_or(240);
3430 let fill = Rgba::opaque(grey, grey, grey);
3431 ops.push(DrawOp::Fill {
3432 path: res!(Path::rect(Bounds::new(-il, -it, -il + side_w, -it + ph))),
3433 colour: fill,
3434 });
3435
3436 // The top logo, centred across the sidebar, its top edge one `margins.a4` down from the page top -- which
3437 // equals the top margin, so its box-frame top is zero. The bottom logo sits one `margins.a4` up from the
3438 // page foot. Both are drawn at the width the `doc.with` call declared; a logo that will not load is left
3439 // out, as the template's own missing-image path would leave a gap.
3440 let side_mid_box = -il + side_w / 2.0; // the sidebar's horizontal centre, in the box frame
3441 if let Some(path) = &fm.top_logo {
3442 let w = fm.top_logo_width.to_pt() as f32;
3443 if let Ok((logo, _)) = logo_ops(fonts, path, w, side_mid_box - w / 2.0, 0.0) {
3444 ops.extend(logo);
3445 }
3446 }
3447 if let Some(path) = &fm.bottom_logo {
3448 let w = fm.bottom_logo_width.to_pt() as f32;
3449 if let Ok((logo, lh)) = logo_ops(fonts, path, w, 0.0, 0.0) {
3450 // Re-place now the height is known: bottom edge one `margins.a4` up from the page foot.
3451 let dy = (ph - it) - it - lh;
3452 let placed = res!(translate_ops(logo, side_mid_box - w / 2.0, dy));
3453 ops.extend(placed);
3454 }
3455 }
3456
3457 // The title and subtitle centred on the right column: from the sidebar's right edge plus the template's
3458 // 20 pt, running to the page's right margin less 20 pt. The title rides the column's vertical centre
3459 // (the template's 40%/10%/50% grid seats it at the half), the subtitle two lines below. The title wraps
3460 // within `col_w`, exactly as the template's `rect(width: size.width - margins.title_page - 40pt)` wraps
3461 // `#text(size: 35pt)[#emph(title)]` -- a long title (e.g. "Oxegen Technical Specification") otherwise
3462 // shapes as one run and overruns the rail.
3463 let col_l = side_w + 20.0;
3464 let col_w = pw - side_w - 40.0;
3465 let centre_box = -il + col_l + col_w / 2.0;
3466 let title_size = 35.0f32; // the template's fixed title size, independent of the config type scale
3467 let sub_size = 20.0f32;
3468 let sample = res!(head_shape(fonts, &HeadFace::Role(Role::Body), Sp::from_pt(title_size as f64), "Ag"));
3469 let asc = sample.dims().height.to_pt() as f32;
3470 let dep = sample.dims().depth.to_pt() as f32;
3471 let leading = (asc + dep) * 1.2; // title line height, leading proportioned as the body text is
3472
3473 let lines = res!(wrap_title_lines(fonts, &fm.title, title_size, col_w, fm.title_smallcaps));
3474 let extra = (lines.len().saturating_sub(1)) as f32 * leading;
3475 // The column centre, in the box frame, shifted up by half the extra lines' height so a wrapped title
3476 // still balances about the same point a single line would occupy.
3477 let title_top = ph / 2.0 - it - extra / 2.0;
3478 let mut title_base = title_top + asc;
3479 for (line, fit_size) in &lines {
3480 res!(title_run_ops(&mut ops, fonts, line, *fit_size, centre_box, title_base, fm.title_smallcaps));
3481 title_base += leading;
3482 }
3483 if let Some(sub) = &fm.subtitle {
3484 // Two blank lines below the title (the template's `\ \`), then the subtitle in italic. `title_base`
3485 // has already stepped past the last title line, so back off one `leading` to its baseline.
3486 let sub_base = title_base - leading + dep + 28.0 + sub_size;
3487 res!(title_run_ops(&mut ops, fonts, sub, sub_size, centre_box, sub_base, false));
3488 }
3489
3490 let graphic = Graphic::new(ops, Dims::new(measure, box_h, Sp::ZERO));
3491 nodes.push(Node::HBox(BoxNode::new(
3492 vec![Node::Leaf(Leaf::graphic(graphic))], Dims::new(measure, box_h, Sp::ZERO))));
3493 Ok(())
3494}
3495
3496/// Loads a logo (an SVG drawn as its own scaled paths, or a raster) at the drawn width `w`, translates its
3497/// ops to `(dx, dy)` in the caller's frame, and returns them with the drawn height. The picture comes out
3498/// sized to `w` with its aspect kept, so the height stands for where a bottom-aligned logo's top sits.
3499fn logo_ops(
3500 fonts: &Arc<FontSet>,
3501 path: &str,
3502 w: f32,
3503 dx: f32,
3504 dy: f32,
3505)
3506 -> Outcome<(Vec<DrawOp>, f32)>
3507{
3508 let width = Some(Length::Abs(w as f64));
3509 let graphic = match crate::image::load_figure(path) {
3510 Ok(crate::image::Figure::Raster(img)) => res!(image_graphic(Sp::from_pt(w as f64), img, width, None, None)),
3511 Ok(crate::image::Figure::Vector(pic)) => res!(svg_graphic(fonts.clone(), Sp::from_pt(w as f64), pic, width, None, None)),
3512 Err(e) => return Err(e),
3513 };
3514 let h = (graphic.dims.height + graphic.dims.depth).to_pt() as f32;
3515 let ops = res!(translate_ops(graphic.ops, dx, dy));
3516 Ok((ops, h))
3517}
3518
3519/// Translates every op of a graphic by `(dx, dy)` -- the fill and stroke paths through a translation, an
3520/// embedded raster by shifting its placement corner. Used to seat a logo built at the origin where it belongs.
3521fn translate_ops(src: Vec<DrawOp>, dx: f32, dy: f32) -> Outcome<Vec<DrawOp>> {
3522 let t = Transform::translate(dx, dy);
3523 let mut out: Vec<DrawOp> = Vec::with_capacity(src.len());
3524 for op in src {
3525 out.push(match op {
3526 DrawOp::Fill { path, colour } => DrawOp::Fill { path: res!(path.transform(&t)), colour },
3527 DrawOp::Stroke { path, colour, width } => DrawOp::Stroke { path: res!(path.transform(&t)), colour, width },
3528 DrawOp::Image { image, x, y, w, h } => DrawOp::Image { image, x: x + dx, y: y + dy, w, h },
3529 });
3530 }
3531 Ok(out)
3532}
3533
3534/// Measures a title run's total advance exactly as `title_run_ops` shapes it (the same small-caps
3535/// splitting, so a wrap decided from this width breaks where the baked glyphs will actually fall).
3536fn title_run_width(fonts: &Arc<FontSet>, text: &str, size_pt: f32, smallcaps: bool) -> Outcome<f32> {
3537 let size = Sp::from_pt(size_pt as f64);
3538 let small_size = Sp(size.raw() * 3 / 4);
3539 let face = if smallcaps { HeadFace::Role(Role::Body) } else { HeadFace::Role(Role::Italic) };
3540 let runs = if smallcaps { smallcaps_runs(text) } else { vec![(text.to_string(), false)] };
3541 let mut total = 0.0f32;
3542 for (run, is_small) in &runs {
3543 let rs = if *is_small { small_size } else { size };
3544 let shaped = res!(head_shape(fonts, &face, rs, run));
3545 total += shaped.dims().width.to_pt() as f32;
3546 }
3547 Ok(total)
3548}
3549
3550/// Greedily word-wraps a title to `col_w`, returning each line with the size it draws at. A line is
3551/// normally `size_pt`; the one exception is a single word that is still wider than `col_w` on its own
3552/// (an unbreakable overflow), which is kept alone on its line and scaled down to fit rather than left to
3553/// overrun the rail.
3554fn wrap_title_lines(
3555 fonts: &Arc<FontSet>,
3556 text: &str,
3557 size_pt: f32,
3558 col_w: f32,
3559 smallcaps: bool,
3560)
3561 -> Outcome<Vec<(String, f32)>>
3562{
3563 let mut lines: Vec<String> = Vec::new();
3564 let mut line = String::new();
3565 for word in text.split_whitespace() {
3566 let trial = if line.is_empty() { word.to_string() } else { fmt!("{} {}", line, word) };
3567 let w = res!(title_run_width(fonts, &trial, size_pt, smallcaps));
3568 if w > col_w && !line.is_empty() {
3569 lines.push(line.clone());
3570 line = word.to_string();
3571 } else {
3572 line = trial;
3573 }
3574 }
3575 if !line.is_empty() {
3576 lines.push(line);
3577 }
3578 if lines.is_empty() {
3579 lines.push(String::new());
3580 }
3581
3582 let mut out: Vec<(String, f32)> = Vec::with_capacity(lines.len());
3583 for line in lines {
3584 let mut fit = size_pt;
3585 let w = res!(title_run_width(fonts, &line, fit, smallcaps));
3586 if w > col_w && w > 0.0 {
3587 fit = fit * col_w / w; // shrink-to-fit: an unbreakable word wider than the rail
3588 }
3589 out.push((line, fit));
3590 }
3591 Ok(out)
3592}
3593
3594/// Bakes a title or subtitle run to filled glyph outlines centred on `centre_x` at baseline `base_y`, in
3595/// the box frame (y down). Small caps are synthesised run by run as the banner sets them (was-lowercase
3596/// letters uppercased at 0.75 of the size); a plain run sets italic, matching the template's `emph`. The
3597/// advance is measured first so the run seats on its centre, then each glyph's y-up outline is flipped
3598/// onto the y-down baseline.
3599fn title_run_ops(
3600 ops: &mut Vec<DrawOp>,
3601 fonts: &Arc<FontSet>,
3602 text: &str,
3603 size_pt: f32,
3604 centre_x: f32,
3605 base_y: f32,
3606 smallcaps: bool,
3607)
3608 -> Outcome<()>
3609{
3610 let size = Sp::from_pt(size_pt as f64);
3611 let small_size = Sp(size.raw() * 3 / 4);
3612 // Small caps sets upright (the template's `smallcaps`); a plain title sets italic (its `emph`).
3613 let face = if smallcaps { HeadFace::Role(Role::Body) } else { HeadFace::Role(Role::Italic) };
3614 let runs = if smallcaps { smallcaps_runs(text) } else { vec![(text.to_string(), false)] };
3615
3616 // Total advance, so the run seats centred on `centre_x`.
3617 let total = res!(title_run_width(fonts, text, size_pt, smallcaps));
3618
3619 let mut x = centre_x - total / 2.0;
3620 for (run, is_small) in &runs {
3621 let rs = if *is_small { small_size } else { size };
3622 let shaped = res!(head_shape(fonts, &face, rs, run));
3623 for glyph in &shaped.run().glyphs {
3624 let outline = res!(shaped.outline(glyph));
3625 if outline.is_empty() {
3626 continue; // a space carries an advance but no ink
3627 }
3628 let t = Transform::scale(1.0, -1.0)
3629 .then(&Transform::translate(x + glyph.x, base_y - glyph.y));
3630 ops.push(DrawOp::Fill { path: res!(outline.transform(&t)), colour: Rgba::BLACK });
3631 }
3632 x += shaped.dims().width.to_pt() as f32;
3633 }
3634 Ok(())
3635}
3636
3637/// Sets the imprint (meta) page: the publisher, edition, copyright, rights, AI declaration, website and
3638/// toolchain lines, set small in the lower half of the page as the template bottom-aligns them.
3639fn fm_meta_page(
3640 nodes: &mut Vec<Node>,
3641 fonts: &Arc<FontSet>,
3642 geom: PageGeometry,
3643 style: &Theme,
3644 fm: &FrontMatter,
3645)
3646 -> Outcome<()>
3647{
3648 let measure = geom.content_width();
3649 let h = geom.content_height();
3650 let size = Sp(style.text.body_size.raw() * 4 / 5); // the template's 0.8em imprint
3651
3652 // Drop to the lower part of the page; the template bottom-aligns, approximated here by a top spacer.
3653 nodes.push(fm_spacer(Sp(h.raw() * 48 / 100)));
3654
3655 let mut lines: Vec<String> = Vec::new();
3656 if let Some(p) = &fm.publisher { lines.push(p.clone()); }
3657 if let Some(e) = &fm.edition { lines.push(e.clone()); }
3658 if let Some(i) = &fm.isbn { lines.push(fmt!("ISBN {}", i)); }
3659 if let Some(c) = &fm.copyright { lines.push(c.clone()); }
3660 if let Some(r) = &fm.rights { lines.push(r.clone()); }
3661 if let Some(a) = &fm.ai_declaration { lines.push(a.clone()); }
3662 if let Some(w) = &fm.website { lines.push(w.clone()); }
3663 if fm.toolchain {
3664 lines.push("Created using Austenite (built using Rust) and Inkscape.".to_string());
3665 }
3666
3667 let mut first = true;
3668 for line in &lines {
3669 if !first {
3670 nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
3671 }
3672 first = false;
3673 let broken = res!(break_paragraph(fonts.clone(), Role::Body, Dir::Ltr, size, line, measure, Sp(size.raw() * 6 / 5), true, Rgba::BLACK, None));
3674 nodes.extend(broken);
3675 }
3676 Ok(())
3677}
3678
3679/// Sets the documentation template's meta/colophon page (`template.typ`'s `meta-page`): a bordered
3680/// Ver/Date/Author(s)/Notes table at the top carrying the one revision row -- its version, date, author
3681/// with the "Made with AI" declaration mark beneath the name, and its notes with the reading time
3682/// appended -- then, seated at the page foot, the acknowledgement paragraph, the copyright line, the
3683/// "created using" line and the footer logo. The template `place`s the foot block against the page
3684/// bottom; here the foot block is measured and a rigid spacer drops it there, so the whole page sets as
3685/// one flow without a second leaf. The footer logo is drawn into the page here rather than by `decorate`,
3686/// which seats the folio footer on body pages only and leaves the front matter clean.
3687fn fm_doc_meta_page(
3688 nodes: &mut Vec<Node>,
3689 fonts: &Arc<FontSet>,
3690 geom: PageGeometry,
3691 style: &Theme,
3692 fm: &FrontMatter,
3693)
3694 -> Outcome<()>
3695{
3696 let measure = geom.content_width();
3697 let h = geom.content_height();
3698
3699 // The version table, at the very top of the content box, exactly as the template sets it flush under
3700 // the top margin.
3701 let table = res!(build_meta_table(fm));
3702 let refs: HashMap<String, String> = HashMap::new();
3703 // The colophon table carries only plain text (version, date, author, notes), so its cells raise no
3704 // footnote, cross-reference, citation, index marker or claim anchor; throwaway counters and gathers
3705 // absorb what the shared cell path would record and are discarded with the front matter.
3706 let mut foot_no = 0u32;
3707 let mut ref_no = 0u32;
3708 let mut margin_no = 0u32;
3709 let mut seen: HashSet<String> = HashSet::new();
3710 let mut idx = IndexGather::default();
3711 let mut claim = ClaimGather::default();
3712 let tnode = res!(table::lower(
3713 fonts.clone(), geom, style, measure, &table,
3714 &mut foot_no, &mut ref_no, &mut margin_no, &mut seen, &mut idx, &mut claim, None, &refs));
3715 let table_h = node_vext(&tnode);
3716 nodes.push(tnode);
3717
3718 // The foot block: the acknowledgement, the copyright line, the toolchain line and the footer logo,
3719 // built into a buffer so its height is known and a spacer can drop it to the page foot. The gaps
3720 // between the four elements approximate the template's `place(bottom, dy: ..)` offsets.
3721 let mut foot: Vec<Node> = Vec::new();
3722 let mut foot_h = Sp::ZERO;
3723 let gap = Sp(style.text.body_size.raw() * 3 / 4);
3724
3725 if let Some(ack) = &fm.acknowledgement {
3726 let size = Sp(style.text.body_size.raw() * 85 / 100);
3727 let broken = res!(break_paragraph(fonts.clone(), Role::Body, Dir::Ltr, size, ack, measure, Sp(size.raw() * 6 / 5), true, Rgba::BLACK, None));
3728 for n in &broken { foot_h += node_vext(n); }
3729 foot.extend(broken);
3730 }
3731 if let Some(cr) = &fm.copyright {
3732 foot.push(Node::Glue(Glue::fixed(gap)));
3733 foot_h += gap;
3734 let size = style.text.body_size;
3735 let broken = res!(break_paragraph(fonts.clone(), Role::Body, Dir::Ltr, size, cr, measure, Sp(size.raw() * 6 / 5), true, Rgba::BLACK, None));
3736 for n in &broken { foot_h += node_vext(n); }
3737 foot.extend(broken);
3738 }
3739 // The toolchain line, the template's fixed "created using" credit for the doc idiom.
3740 {
3741 foot.push(Node::Glue(Glue::fixed(gap)));
3742 foot_h += gap;
3743 let size = Sp(style.text.body_size.raw() * 3 / 4);
3744 let line = "This document was created using Austenite (built using Rust).";
3745 let broken = res!(break_paragraph(fonts.clone(), Role::Body, Dir::Ltr, size, line, measure, Sp(size.raw() * 6 / 5), true, Rgba::BLACK, None));
3746 for n in &broken { foot_h += node_vext(n); }
3747 foot.extend(broken);
3748 }
3749 if let Some(path) = &fm.footer_logo {
3750 if let Ok(graphic) = image_at_height(fonts, path, 18.0) {
3751 let logo = Leaf::graphic(graphic);
3752 let lh = logo.dims.height + logo.dims.depth;
3753 let big = Sp(style.text.body_size.raw() * 3 / 2); // a little more air above the logo
3754 foot.push(Node::Glue(Glue::fixed(big)));
3755 foot_h += big + lh;
3756 foot.push(Node::HBox(BoxNode::new(vec![Node::Leaf(logo)], Dims::new(measure, lh, Sp::ZERO))));
3757 }
3758 }
3759
3760 // Drop the foot block to the page bottom: a rigid spacer taking up the slack between the table and the
3761 // foot. A page too short for both simply sets them adjacent rather than overflowing to a second leaf.
3762 let used = table_h + foot_h;
3763 if h > used {
3764 nodes.push(fm_spacer(h - used));
3765 }
3766 nodes.extend(foot);
3767 Ok(())
3768}
3769
3770/// Builds the meta page's Ver/Date/Author(s)/Notes table from the doc's revision rows. A column every row
3771/// leaves blank is dropped (the template's `filled` test): Author and Notes always stand, Ver and Date
3772/// only when some row sets them. Each row's author cell carries the declaration mark stacked beneath the
3773/// name, and the last row's notes take the reading time appended -- matching the template's `meta-page`
3774/// table with its `2fr, 2fr, 4fr, 6fr` columns.
3775fn build_meta_table(fm: &FrontMatter) -> Outcome<Table> {
3776 let has_ver = fm.meta_rows.iter().any(|r| r.version.as_deref().unwrap_or("") != "");
3777 let has_date = fm.meta_rows.iter().any(|r| r.date.as_deref().unwrap_or("") != "");
3778
3779 let mut weights: Vec<f64> = Vec::new();
3780 let mut header: Vec<Cell> = Vec::new();
3781 if has_ver {
3782 weights.push(2.0);
3783 header.push(Cell::rich(vec![Segment::strong("Ver")], Align::Centre));
3784 }
3785 if has_date {
3786 weights.push(2.0);
3787 header.push(Cell::rich(vec![Segment::strong("Date")], Align::Centre));
3788 }
3789 weights.push(4.0);
3790 header.push(Cell::rich(vec![Segment::strong("Author(s)")], Align::Left));
3791 weights.push(6.0);
3792 header.push(Cell::rich(vec![Segment::strong("Notes")], Align::Left));
3793
3794 let mut rows = vec![Row::new(header)];
3795 let last = fm.meta_rows.len().saturating_sub(1);
3796 for (i, mr) in fm.meta_rows.iter().enumerate() {
3797 let mut cells: Vec<Cell> = Vec::new();
3798 if has_ver {
3799 cells.push(Cell::rich(vec![Segment::text(mr.version.clone().unwrap_or_default())], Align::Centre));
3800 }
3801 if has_date {
3802 cells.push(Cell::rich(vec![Segment::text(mr.date.clone().unwrap_or_default())], Align::Centre));
3803 }
3804 // The author cell carries the name and, where the row declares one, the AI mark beneath it.
3805 let author_cell = match (&mr.ai_mark_path, &mr.ai_mark_words) {
3806 (Some(path), Some(words)) => {
3807 let mark = crate::table::CellMark {
3808 path: path.clone(),
3809 height: Sp::from_pt(36.0), // the template's `image(.., height: 36pt)`
3810 words: words.clone(),
3811 url: mr.ai_mark_url.clone(),
3812 };
3813 Cell::rich_with_mark(vec![Segment::text(mr.authors.clone())], Align::Left, mark)
3814 },
3815 _ => Cell::rich(vec![Segment::text(mr.authors.clone())], Align::Left),
3816 };
3817 cells.push(author_cell);
3818 // The reading time is appended to the last row's notes only, as the template does.
3819 let notes = mr.notes.clone().unwrap_or_default();
3820 let notes = match (i == last, fm.reading_min) {
3821 (true, Some(m)) => if notes.is_empty() {
3822 fmt!("Reading time: {} [min]", m)
3823 } else {
3824 fmt!("{} Reading time: {} [min]", notes, m)
3825 },
3826 _ => notes,
3827 };
3828 cells.push(Cell::rich(vec![Segment::text(notes)], Align::Left));
3829 rows.push(Row::new(cells));
3830 }
3831
3832 Ok(Table::with_weights(true, rows, weights))
3833}
3834
3835/// Counts the words in a block stream, matching the template's reading-time counter, which steps once per
3836/// maximal run of letters (`\p{L}+`) as the body renders. Every text-bearing block contributes -- prose,
3837/// headings, list items, table cells, figure captions, code and references -- so the tally tracks Typst's
3838/// own `words.final()` closely; the reading time is that count over the average reading speed.
3839pub(crate) fn count_words(blocks: &[Block]) -> usize {
3840 fn count_str(s: &str, n: &mut usize) {
3841 let mut in_word = false;
3842 for ch in s.chars() {
3843 if ch.is_alphabetic() {
3844 if !in_word { *n += 1; in_word = true; }
3845 } else {
3846 in_word = false;
3847 }
3848 }
3849 }
3850 fn count_segs(segs: &[Segment], n: &mut usize) {
3851 for seg in segs {
3852 match seg {
3853 Segment::Text(t) | Segment::Strong(t) | Segment::Emph(t) | Segment::BoldItalic(t)
3854 | Segment::Super(t) | Segment::Sub(t) | Segment::Code(t) | Segment::SmallCaps(t) => count_str(t, n),
3855 Segment::Glossary { display, .. } => count_str(display, n),
3856 Segment::Footnote { note } => count_segs(note, n),
3857 Segment::Cite(keys) => for k in keys { count_str(k, n); },
3858 Segment::PageRef(_) | Segment::Math(_) | Segment::MarginNote { .. } | Segment::Index { .. } => {},
3859 }
3860 }
3861 }
3862 fn count_cells(table: &Table, n: &mut usize) {
3863 for row in &table.rows {
3864 for cell in &row.cells {
3865 count_segs(&cell.content, n);
3866 }
3867 }
3868 }
3869 let mut n = 0usize;
3870 for b in blocks {
3871 match b {
3872 Block::Heading { segments, .. } => count_segs(segments, &mut n),
3873 Block::Paragraph { text } => count_str(text, &mut n),
3874 Block::RichParagraph { segments } => count_segs(segments, &mut n),
3875 Block::List { items, .. } => for it in items {
3876 count_segs(&it.segments, &mut n);
3877 n += count_words(&it.children);
3878 },
3879 Block::Code { lines } => for l in lines { count_str(l, &mut n); },
3880 Block::Table(t) => count_cells(t, &mut n),
3881 Block::Figure { caption, .. } => if let Some(c) = caption { count_str(c, &mut n); },
3882 Block::TableFigure { table, caption, .. } => {
3883 count_cells(table, &mut n);
3884 if let Some(c) = caption { count_segs(c, &mut n); }
3885 },
3886 Block::ImageFigure { caption, .. } | Block::CodeFigure { caption, .. }
3887 => if let Some(c) = caption { count_segs(c, &mut n); },
3888 Block::BackMatterHeading { title } => count_str(title, &mut n),
3889 Block::Reference { runs } => for (t, _) in runs { count_str(t, &mut n); },
3890 Block::Box { blocks, .. } => n += count_words(blocks),
3891 // A scope carries its words in its own nested blocks, counted here rather than as flat siblings.
3892 Block::Scoped { blocks, .. } => n += count_words(blocks),
3893 Block::Equation { .. } | Block::Rule { .. } | Block::Image { .. }
3894 | Block::SectionBanner { .. } | Block::Glossary | Block::Index | Block::ClaimIndex
3895 | Block::Space(_) | Block::PageBreak { .. } | Block::ColBreak { .. } => {},
3896 Block::Place { blocks, .. } => n += count_words(blocks),
3897 }
3898 }
3899 n
3900}
3901
3902/// The vertical extent a node occupies in a flow: a box's height plus depth, a glue's natural size, a
3903/// leaf's height plus depth. Anchors and penalties take no space.
3904fn node_vext(n: &Node) -> Sp {
3905 match n {
3906 Node::HBox(b) | Node::VBox(b) => b.dims.height + b.dims.depth,
3907 Node::Leaf(l) => l.dims.height + l.dims.depth,
3908 Node::Glue(g) => g.natural,
3909 _ => Sp::ZERO,
3910 }
3911}
3912
3913/// Sets the dedication page: the dedication centred, in italic, about the vertical centre.
3914fn fm_dedication_page(
3915 nodes: &mut Vec<Node>,
3916 fonts: &Arc<FontSet>,
3917 geom: PageGeometry,
3918 style: &Theme,
3919 text: &str,
3920)
3921 -> Outcome<()>
3922{
3923 let measure = geom.content_width();
3924 let h = geom.content_height();
3925 nodes.push(fm_spacer(Sp(h.raw() * 40 / 100)));
3926 let size = Sp(style.text.body_size.raw() * 11 / 10);
3927 res!(fm_centred_wrap(nodes, fonts, Role::Italic, size, text, measure, Sp(size.raw() * 6 / 5)));
3928 Ok(())
3929}
3930
3931/// Sets the "About the Author" page: the title in the display face, then the biography justified below.
3932fn fm_about_author_page(
3933 nodes: &mut Vec<Node>,
3934 fonts: &Arc<FontSet>,
3935 display: Option<&Arc<Font>>,
3936 geom: PageGeometry,
3937 style: &Theme,
3938 title_size: Sp,
3939 bio: &str,
3940)
3941 -> Outcome<()>
3942{
3943 let measure = geom.content_width();
3944 nodes.push(fm_spacer(Sp::from_pt(24.0)));
3945 let title_face = display.map(HeadFace::Solo).unwrap_or(HeadFace::Role(Role::Bold));
3946 let title = res!(head_shape(fonts, &title_face, title_size, "About the Author"));
3947 let td = title.dims();
3948 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::text(title))], td)));
3949 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(18.0))));
3950 let size = Sp(style.text.body_size.raw() * 9 / 10);
3951 let broken = res!(break_paragraph(fonts.clone(), Role::Body, Dir::Ltr, size, bio, measure, Sp(size.raw() * 7 / 5), true, Rgba::BLACK, None));
3952 nodes.extend(broken);
3953 Ok(())
3954}
3955
3956/// The deepest heading level the contents lists, matching the template's `outline(depth: 3)`.
3957const TOC_DEPTH: u8 = 3;
3958
3959/// Sets a table of contents from the heading table: the "Contents" title in the display face, then one
3960/// entry per heading -- its number in a column indented by level, its title, a dotted leader, and its
3961/// printed folio flush at the right. The folio is a forward reference resolved with [`Ref::FolioOf`]
3962/// against the incoming ledger, so it reads the body folio (which restarts at one) rather than the
3963/// physical page, reusing the same reserve-then-resolve slot the driver runs for any forward reference.
3964/// The caller prepends these nodes after the front matter; a trailing forced break opens the body.
3965///
3966/// A fact a reader could not derive. Each entry reserves a fixed slot for its folio -- three digits
3967/// wide, so a resolved number never outgrows it -- and its line height is the entry's, whatever the
3968/// folio turns out to be, and the dotted leader takes the width left over. The contents block therefore
3969/// has a constant vertical extent from the first pass, so the body it displaces settles once and the
3970/// forward references converge in the usual two passes, with no special case in the driver.
3971pub fn contents(
3972 fonts: Arc<FontSet>,
3973 faces: &FaceResolver,
3974 geom: PageGeometry,
3975 style: &Theme,
3976 title_size: Sp,
3977 heads: &[Heading],
3978)
3979 -> Outcome<Vec<Node>>
3980{
3981 let measure = geom.content_width();
3982 let mut nodes: Vec<Node> = Vec::new();
3983
3984 // A `Label` anchor at the top of the contents leaf records its page for the PDF outline. It sets no
3985 // heading, so the contents is neither a running-head section nor an entry in its own list.
3986 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, "frontmatter:contents")));
3987
3988 // The block's own heading, in the display face at the back-matter title size, recorded as no anchor --
3989 // so it is neither a running-head section nor an entry in its own list.
3990 let title = res!(head_shape(&fonts, &resolved_head_face(1, style, faces, is_doc_heading(style)), title_size, "Contents"));
3991 let td = title.dims();
3992 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::text(title))], td)));
3993 nodes.push(Node::Glue(Glue::fixed(style.space_below(1))));
3994
3995 // A fixed slot wide enough for a three-digit folio, so a resolved number never overflows its
3996 // reservation and every entry keeps a constant height across passes.
3997 let slot = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, "000"));
3998 let slot_w = slot.dims().width;
3999 // A dot-and-space leader unit, measured once, so a leader is filled with a whole number of dots.
4000 let dot = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, ". "));
4001 let dot_w = dot.dims().width.raw().max(1);
4002 // The step a level indents the number column by, and the gap between a number and its title.
4003 let step = Sp(style.text.body_size.raw() * 3 / 2);
4004 let gap = Sp(style.text.body_size.raw() * 3 / 5);
4005
4006 for (i, h) in heads.iter().enumerate() {
4007 // The template sets `outline(depth: 3)`, so the contents stops at level 3 (a `===` subsection,
4008 // dotted number x.y.z); a level-4 `====` heading is listed in no contents and is skipped here.
4009 if h.level > TOC_DEPTH {
4010 continue;
4011 }
4012 // The number column is indented per level: a part (level 0) and a chapter (level 1) sit at the
4013 // margin, deeper levels step right. The number is empty for a part, which then shows title alone.
4014 let depth = (h.level.max(1) - 1) as i32;
4015 let indent = step * depth;
4016 let numw = if h.number.is_empty() {
4017 Sp::ZERO
4018 } else {
4019 let n = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &h.number));
4020 n.dims().width
4021 };
4022 // The entry's title set from its rich runs, so a maths span or emphasis in a heading renders here
4023 // rather than dropping to a gap. The height is the sample's, constant across passes.
4024 let (entry, ed) = res!(inline_segments(&fonts, style, &h.segments, Role::Body, style.text.body_size));
4025
4026 // The leader span from the title's end to the folio slot; a title too wide to leave a one-em
4027 // minimum keeps that minimum and runs under its folio -- the over-wide case, left as it falls.
4028 let num_col = if numw.raw() > 0 { numw + gap } else { Sp::ZERO };
4029 let taken = indent + num_col + ed.width + slot_w;
4030 let min_lead = style.text.body_size;
4031 let leader_w = if measure > taken + min_lead { measure - taken } else { min_lead };
4032
4033 // Fill the leader with a whole number of dots, padded to the folio slot on the right so the slot's
4034 // right edge falls on the measure.
4035 let lead_margin = Sp(style.text.body_size.raw() / 2);
4036 let usable = (leader_w.raw() - lead_margin.raw()).max(0);
4037 let n_dots = (usable / dot_w).max(0) as usize;
4038 let dots = res!(ShapedText::new(
4039 fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &". ".repeat(n_dots)));
4040 let dots_w = dots.dims().width;
4041 let trailing = if leader_w > lead_margin + dots_w { leader_w - lead_margin - dots_w } else { Sp::ZERO };
4042
4043 // The entry's own identity, distinct from the heading it points at, so recording the slot never
4044 // overwrites the heading's ledger row. Its reference resolves the heading's folio.
4045 let toc_id = AnchorId::new(AnchorKind::Label, fmt!("toc-{}", h.id.key));
4046 let slot_dims = Dims::new(slot_w, ed.height, ed.depth);
4047
4048 let mut children: Vec<Node> = Vec::new();
4049 if indent.raw() > 0 {
4050 children.push(Node::Glue(Glue::fixed(indent)));
4051 }
4052 if numw.raw() > 0 {
4053 children.push(Node::Leaf(Leaf::text(res!(
4054 ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &h.number)))));
4055 children.push(Node::Glue(Glue::fixed(gap)));
4056 }
4057 children.extend(entry);
4058 children.push(Node::Glue(Glue::fixed(lead_margin)));
4059 children.push(Node::Leaf(Leaf::text(dots)));
4060 if trailing.raw() > 0 {
4061 children.push(Node::Glue(Glue::fixed(trailing)));
4062 }
4063 children.push(Node::Leaf(Leaf::reserved(toc_id, Ref::FolioOf(h.id.clone()), slot_dims)));
4064
4065 let line_dims = Dims::new(measure, ed.height, ed.depth);
4066 nodes.push(Node::HBox(BoxNode::new(children, line_dims)));
4067
4068 // Leading between entries, but not after the last.
4069 if i + 1 < heads.len() {
4070 let vextent = ed.height + ed.depth;
4071 let lead = if style.text.leading > vextent { style.text.leading - vextent } else { Sp::ZERO };
4072 nodes.push(Node::Glue(Glue::fixed(lead)));
4073 }
4074 }
4075
4076 // The contents stands alone at the front; the body opens on a fresh page.
4077 nodes.push(Node::Penalty(Penalty::eject()));
4078 Ok(nodes)
4079}
4080
4081/// Sets the back-matter index from the markers gathered walking the body: one entry per index term,
4082/// alphabetical and case-insensitive, its display followed by the folio list its occurrences resolved to.
4083/// A nested marker (`#idx-nested("extraction", "Roman")`) sets its child term as an indented sub-entry
4084/// under its parent. Each entry's page list is a forward reference -- an [`IndexFolios`](Ref::IndexFolios)
4085/// slot the driver resolves against the previous pass's ledger, deduplicating and run-compressing the
4086/// folios -- so the index reads the pages its terms fell on without a layout query.
4087///
4088/// A fact a reader could not derive: each entry reserves a fixed slot wide enough for its occurrences'
4089/// folios set uncompressed ("999, " apiece), so a resolved (compressed) list never outgrows it and the
4090/// section's extent is settled from the first pass. An over-long single entry runs past the column rather
4091/// than wrapping, the same over-wide case the table of contents leaves as it falls. `measure` is the width
4092/// of one column: the caller wraps the returned entries in a [`Node::Columns`], and the driver flows them
4093/// down each column in turn, so this sets every entry to the column width, matching the Typst template's
4094/// two-column `print-index`.
4095fn index_nodes(
4096 fonts: &Arc<FontSet>,
4097 style: &Theme,
4098 measure: Sp,
4099 occ: &[(String, Option<String>, Vec<Segment>, AnchorId, bool)],
4100)
4101 -> Outcome<Vec<Node>>
4102{
4103 // Group by term (case-insensitive), each term carrying its own direct occurrences and its nested
4104 // children, so a term with sub-entries lists them indented beneath it. Each occurrence carries whether it
4105 // is a primary (`#idx-main`) reference, so its folio sets bold. The folios are deduplicated and sorted at
4106 // resolution, so document order within a group need not be kept here. `display` is the styled text the
4107 // index page sets -- the first occurrence's, since every mention of one term carries the same -- so a
4108 // `#idx-as[March, James][James March]` entry prints "James March" and an emphasised case name sets italic,
4109 // rather than the sort key leaking to the page.
4110 struct Group {
4111 display: Vec<Segment>,
4112 direct: Vec<(AnchorId, bool)>,
4113 subs: std::collections::BTreeMap<String, (String, Vec<(AnchorId, bool)>)>,
4114 }
4115 let mut groups: std::collections::BTreeMap<String, Group> = std::collections::BTreeMap::new();
4116 for (term, sub, display, id, main) in occ {
4117 let g = groups.entry(term.to_lowercase()).or_insert_with(|| Group {
4118 display: display.clone(),
4119 direct: Vec::new(),
4120 subs: std::collections::BTreeMap::new(),
4121 });
4122 match sub {
4123 None => g.direct.push((id.clone(), *main)),
4124 Some(child) => {
4125 let e = g.subs.entry(child.to_lowercase()).or_insert_with(|| (child.clone(), Vec::new()));
4126 e.1.push((id.clone(), *main));
4127 },
4128 }
4129 }
4130
4131 // The index sets at 9pt, as Typst's `print-index` does with `set text(size: 9pt)`, a little below the
4132 // body so more entries fit a column. Its leading keeps the body's line-to-size ratio at the smaller
4133 // size; a 1.5em gap parts one alphabetic section from the next (Typst's `spaced-section` `v(1.5em)`),
4134 // and entries within a section are parted by that ordinary leading. The single-line entry HBoxes are
4135 // already ragged (no justification), matching the template's `set par(justify: false)`.
4136 let body = Sp::from_pt(9.0);
4137 // The index leading keeps the body's line-to-size ratio at the smaller size. Computed in i64 so the
4138 // scaled-point product does not overflow i32 before the divide brings it back into range.
4139 let idx_lead = if style.text.body_size.raw() > 0 {
4140 Sp(((body.raw() as i64 * style.text.leading.raw() as i64) / style.text.body_size.raw() as i64) as i32)
4141 } else {
4142 body
4143 };
4144 let entry_lead = if idx_lead > body { idx_lead - body } else { Sp::ZERO };
4145 let section_gap = Sp(body.raw() * 3 / 2); // 1.5em at the index size: Typst's per-section v(1.5em)
4146 let step = Sp(body.raw() * 3 / 2); // the indent a nested sub-entry sets in by
4147 // One occurrence's worst-case folio width ("999, "), so a compressed list never outgrows its slot.
4148 let unit = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, body, "999, ")).dims().width;
4149
4150 let mut nodes: Vec<Node> = Vec::new();
4151 let mut ref_no = 0u32;
4152 let mut prev_letter: Option<char> = None;
4153 for (key, g) in groups.iter() {
4154 let letter = key.chars().next().map(|c| c.to_ascii_uppercase());
4155 if !nodes.is_empty() {
4156 // Part alphabetic sections by 1.5em, entries within a section by the ordinary index leading.
4157 let lead = if letter != prev_letter { section_gap } else { entry_lead };
4158 nodes.push(Node::Glue(Glue::fixed(lead)));
4159 }
4160 prev_letter = letter;
4161 res!(index_entry_line(fonts, body, measure, &g.display, &g.direct, 0, unit, step, &mut ref_no, &mut nodes));
4162 for (_, (disp, ids)) in &g.subs {
4163 nodes.push(Node::Glue(Glue::fixed(entry_lead))); // a sub-entry sits one leading below its fellow
4164 let sub_display = vec![Segment::text(disp.clone())];
4165 res!(index_entry_line(fonts, body, measure, &sub_display, ids, 1, unit, step, &mut ref_no, &mut nodes));
4166 }
4167 }
4168 Ok(nodes)
4169}
4170
4171/// The index text size's face and text for one display segment: an emphasised run sets italic, a strong
4172/// run bold, a bold-italic run both, and everything else (plain text, a code span whose mono face the index
4173/// does not reproduce, any richer segment flattened to its words) sets in the body face. This is what lets
4174/// `_Browder v. Gayle_` reach the index page as an italic case name rather than literal underscores.
4175fn index_display_run(seg: &Segment) -> (String, Role) {
4176 match seg {
4177 Segment::Text(t) => (t.clone(), Role::Body),
4178 Segment::Emph(t) => (t.clone(), Role::Italic),
4179 Segment::Strong(t) => (t.clone(), Role::Bold),
4180 Segment::BoldItalic(t) => (t.clone(), Role::BoldItalic),
4181 other => (flatten_segments(std::slice::from_ref(other)), Role::Body),
4182 }
4183}
4184
4185/// Sets one index entry line: the styled display, indented by `depth`, then -- when the term has any page --
4186/// a comma, a space and the folio list the driver resolves. A term with only nested children (no direct
4187/// page) sets its name alone, a heading for the indented sub-entries beneath it. The comma before the folios
4188/// is the in-dexter separator (`entry, page`), and the display sets in its own faces, so an emphasised entry
4189/// italicises. `ref_no` makes each slot's own ledger identity unique.
4190///
4191/// The folios split into non-main (set in the body face) and main (`#idx-main`, set bold, reproducing
4192/// in-dexter's `index-main = index.with(fmt: strong)`); each group resolves as its own
4193/// [`IndexFolios`](Ref::IndexFolios) slot -- deduplicated, sorted and run-compressed independently -- and
4194/// the two are parted by `", "` when both are present. in-dexter interleaves a term's main and plain folios
4195/// by document order rather than grouping them; the two coincide for an entry whose references are all main
4196/// or all plain (the common case, and every entry in the oracle corpus), and differ only in the order of a
4197/// single entry that mixes the two, which no fixture yet exercises.
4198#[allow(clippy::too_many_arguments)]
4199fn index_entry_line(
4200 fonts: &Arc<FontSet>,
4201 body: Sp, // the index text size (9pt), the term set and the line box sized at it
4202 measure: Sp,
4203 display: &[Segment],
4204 ids: &[(AnchorId, bool)],
4205 depth: i32,
4206 unit: Sp,
4207 step: Sp,
4208 ref_no: &mut u32,
4209 nodes: &mut Vec<Node>,
4210)
4211 -> Outcome<()>
4212{
4213 let indent = step * depth;
4214
4215 let mut children: Vec<Node> = Vec::new();
4216 if indent.raw() > 0 {
4217 children.push(Node::Glue(Glue::fixed(indent)));
4218 }
4219 // The display runs, each shaped in its own face; the line's extent is the tallest run's.
4220 let mut height = Sp::ZERO;
4221 let mut depth_ = Sp::ZERO;
4222 for seg in display {
4223 let (text, role) = index_display_run(seg);
4224 if text.is_empty() {
4225 continue;
4226 }
4227 let shaped = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, body, &text));
4228 let sd = shaped.dims();
4229 if sd.height > height { height = sd.height; }
4230 if sd.depth > depth_ { depth_ = sd.depth; }
4231 children.push(Node::Leaf(Leaf::text(shaped)));
4232 }
4233 if !ids.is_empty() {
4234 // Split into non-main and main folios, keeping each group's document order (the sort happens at
4235 // resolution). A main folio sets bold, a non-main folio in the body face.
4236 let plain: Vec<AnchorId> = ids.iter().filter(|(_, m)| !*m).map(|(id, _)| id.clone()).collect();
4237 let main: Vec<AnchorId> = ids.iter().filter(|(_, m)| *m).map(|(id, _)| id.clone()).collect();
4238 // The bold folio's worst-case width ("999, " in the bold face), so a bold slot never outgrows it.
4239 let bold_unit = res!(ShapedText::new(fonts.clone(), Role::Bold, Dir::Ltr, body, "999, ")).dims().width;
4240
4241 // The in-dexter separator between an entry and its folios is a comma and a space, not a bare gap.
4242 let sep = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, body, ", "));
4243 let sepd = sep.dims();
4244 if sepd.height > height { height = sepd.height; }
4245 if sepd.depth > depth_ { depth_ = sepd.depth; }
4246 children.push(Node::Leaf(Leaf::text(sep.clone())));
4247
4248 // The non-main folios, set in the body face.
4249 if !plain.is_empty() {
4250 *ref_no += 1;
4251 let slot_w = Sp(unit.raw() * plain.len() as i32);
4252 let id = AnchorId::new(AnchorKind::Label, fmt!("index-slot-{}", *ref_no));
4253 let dims = Dims::new(slot_w, height, depth_);
4254 children.push(Node::Leaf(Leaf::reserved_inline(id, Ref::IndexFolios(plain), dims)));
4255 }
4256 // The main folios, set bold. When both groups are present, a `", "` parts them.
4257 if !main.is_empty() {
4258 if children.last().map_or(false, |n| matches!(n, Node::Leaf(l) if matches!(l.kind, LeafKind::Reserved(..)))) {
4259 children.push(Node::Leaf(Leaf::text(sep)));
4260 }
4261 *ref_no += 1;
4262 let slot_w = Sp(bold_unit.raw() * main.len() as i32);
4263 let id = AnchorId::new(AnchorKind::Label, fmt!("index-slot-{}", *ref_no));
4264 let dims = Dims::new(slot_w, height, depth_);
4265 children.push(Node::Leaf(Leaf::reserved_inline_bold(id, Ref::IndexFolios(main), dims)));
4266 }
4267 }
4268 let line_dims = Dims::new(measure, height, depth_);
4269 nodes.push(Node::HBox(BoxNode::new(children, line_dims)));
4270 Ok(())
4271}
4272
4273/// Sets the reverse claim-reference index: one wrapped paragraph per code, the codes in byte order (Typst's
4274/// `.sorted()`), each the bold code, a colon and space, then the pages the code was referenced on -- one
4275/// reserved [`FolioOf`](Ref::FolioOf) slot per reference in document order, parted by `", "`, and a closing
4276/// full stop. This is the Logic appendix's `[#strong(code): #pages.join(", ").]` line for line, and the
4277/// per-reference slots (rather than one range-compressed slot) are what let a long page list wrap across
4278/// lines the way Typst's does, so the section runs to the same length. Entries stack at the body's own
4279/// interline pitch, matching Typst's `linebreak()` between them. `measure` is the full text measure, since
4280/// the appendix sets the index in a single column.
4281fn claim_index_nodes(
4282 fonts: &Arc<FontSet>,
4283 style: &Theme,
4284 measure: Sp,
4285 occ: &[(String, AnchorId)],
4286)
4287 -> Outcome<Vec<Node>>
4288{
4289 // Group by code, keeping each code's references in the order they were gathered (document order), so the
4290 // page list reads in the order Typst's query returns them. Byte order over the keys matches `.sorted()`.
4291 let mut groups: std::collections::BTreeMap<String, Vec<AnchorId>> = std::collections::BTreeMap::new();
4292 for (code, id) in occ {
4293 groups.entry(code.clone()).or_default().push(id.clone());
4294 }
4295
4296 let size = style.text.body_size;
4297 let leading = style.text.leading;
4298
4299 // One folio slot's reserved width: three digits at the index size, so a resolved page never outgrows it.
4300 // Keyed `claim-slot-{n}` -- its OWN prefix, distinct from `ref_slot`'s `ref-{n}` (a body cross-reference)
4301 // and `index_nodes`'s `index-slot-{n}` -- so a book carrying a `#pageref`-style slot AND a claim index does
4302 // not have one silently overwrite the other in the ledger's by-id map.
4303 let slot_probe = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, "000"));
4304 let slot_dims = slot_probe.dims();
4305 let mut nodes: Vec<Node> = Vec::new();
4306 let mut slot_no = 0u32;
4307 // The depth of the previous entry's last set line, so the glue to the next entry seats its baseline
4308 // `leading` below -- the body's own interline pitch, exactly the rule `set_lines` applies within a
4309 // paragraph. Reading the real box metrics (not a probe glyph's) is what keeps the entries at body leading:
4310 // the block-edge model caps a single-line entry's own height and depth, so a fixed probe-derived gap would
4311 // stack them far tighter than the body sets its lines.
4312 let mut prev_depth: Option<Sp> = None;
4313 for (code, ids) in groups.iter() {
4314 let mut pieces: Vec<Piece> = Vec::with_capacity(ids.len() * 2 + 3);
4315 pieces.push(Piece::Text { text: code.clone(), role: Role::Bold });
4316 pieces.push(Piece::Text { text: ": ".to_string(), role: Role::Body });
4317 for (n, id) in ids.iter().enumerate() {
4318 if n > 0 {
4319 pieces.push(Piece::Text { text: ", ".to_string(), role: Role::Body });
4320 }
4321 slot_no += 1;
4322 let slot_id = AnchorId::new(AnchorKind::Label, fmt!("claim-slot-{}", slot_no));
4323 let leaf = Leaf::reserved_inline(slot_id, Ref::FolioOf(id.clone()),
4324 Dims::new(slot_dims.width, slot_dims.height, slot_dims.depth));
4325 pieces.push(Piece::Mark(leaf));
4326 }
4327 pieces.push(Piece::Text { text: ".".to_string(), role: Role::Body });
4328 let lines = res!(break_paragraph_pieces(
4329 fonts.clone(), Role::Body, Dir::Ltr, size, &pieces, measure, leading,
4330 style.text.justify, style.text.hyphenate, style.text.fill, Some(cap_edge(style, size))));
4331 // This entry's first set line's height and last set line's depth, from the boxes as they will draw.
4332 let first_h = lines.iter().find_map(|n| if let Node::HBox(b) = n { Some(b.dims.height) } else { None }).unwrap_or(Sp::ZERO);
4333 let last_d = lines.iter().rev().find_map(|n| if let Node::HBox(b) = n { Some(b.dims.depth) } else { None }).unwrap_or(Sp::ZERO);
4334 if let Some(pd) = prev_depth {
4335 // Seat this entry's baseline `leading` below the previous entry's: gap = leading - depth above -
4336 // height below, the same measure `set_lines` uses between two lines of one paragraph.
4337 let want = leading - pd - first_h;
4338 let gap = if want > Sp::ZERO { want } else { Sp::ZERO };
4339 nodes.push(Node::Glue(Glue::fixed(gap)));
4340 }
4341 nodes.extend(lines);
4342 prev_depth = Some(last_d);
4343 }
4344 Ok(nodes)
4345}
4346
4347/// Sets one bibliography reference: its runs woven into justified lines at the book template's
4348/// body-relative reference size, with a hanging indent -- the first line flush left, every continuation
4349/// line indented, as a Chicago reference list sets. The runs' italic flag chooses the face, so a book or
4350/// journal title sets italic. The template's `set text(size: 0.85em)` on the bibliography (see the book
4351/// template) sizes it a touch below the body, not at the footnote furniture size, and its leading follows
4352/// the body's proportionally: 0.85 of the body baseline, since both the line box and the paragraph gap
4353/// scale with the font size.
4354fn reference_block(
4355 nodes: &mut Vec<Node>,
4356 fonts: Arc<FontSet>,
4357 style: &Theme,
4358 measure: Sp,
4359 runs: &[(String, bool)],
4360)
4361 -> Outcome<()>
4362{
4363 let ref_size = Sp(style.text.body_size.raw() * 85 / 100); // the template's 0.85em bibliography size
4364 let ref_leading = Sp(style.text.leading.raw() * 85 / 100); // the body baseline scaled by the same 0.85
4365 let hang = Sp(ref_size.raw() * 3 / 2); // the 1.5 em hang the continuation lines take, at the reference size
4366 let inner = if measure > hang { measure - hang } else { measure };
4367
4368 let mut pieces: Vec<Piece> = Vec::with_capacity(runs.len());
4369 for (text, italic) in runs {
4370 let role = if *italic { Role::Italic } else { Role::Body };
4371 pieces.push(Piece::Text { text: text.clone(), role });
4372 }
4373 let mut lines = res!(break_paragraph_pieces(
4374 fonts.clone(), Role::Body, Dir::Ltr, ref_size, &pieces, inner, ref_leading, true, true, Rgba::BLACK,
4375 Some(cap_edge(style, ref_size))));
4376
4377 // Indent every line but the first by the hang, so the entry hangs under its first line.
4378 let mut first = true;
4379 for line in lines.iter_mut() {
4380 if let Node::HBox(b) = line {
4381 if first {
4382 first = false;
4383 } else {
4384 b.list.insert(0, Node::Glue(Glue::fixed(hang)));
4385 b.dims = Dims::new(b.dims.width + hang, b.dims.height, b.dims.depth);
4386 }
4387 }
4388 }
4389 nodes.extend(lines);
4390 Ok(())
4391}
4392
4393/// Wraps a vertical run of nodes as a keep box, its extent the sum of its children's, so the driver
4394/// places it whole or moves it whole. The whole extent is carried as height; a block has no baseline
4395/// the page cares about, so the depth is zero.
4396fn vbox(list: Vec<Node>, width: Sp) -> Node {
4397 let mut ext = Sp::ZERO;
4398 for n in &list {
4399 ext += n.vextent();
4400 }
4401 Node::VBox(BoxNode::new(list, Dims::new(width, ext, Sp::ZERO)))
4402}
4403
4404/// The plain display words of a heading's rich runs: the text a reader sees with the markup removed, so
4405/// the anchor slug, the table-of-contents entry and the running head read the rendered title rather than
4406/// its raw source. A glossary term contributes its display, emphasis and code their inner words; a maths
4407/// span, a cross-reference, a footnote and a citation have no plain form here and contribute nothing.
4408fn flatten_segments(segments: &[Segment]) -> String {
4409 let mut out = String::new();
4410 for seg in segments {
4411 match seg {
4412 Segment::Text(t) => out.push_str(t),
4413 Segment::Strong(t) => out.push_str(t),
4414 Segment::Emph(t) => out.push_str(t),
4415 Segment::BoldItalic(t) => out.push_str(t),
4416 Segment::Super(t) => out.push_str(t),
4417 Segment::Sub(t) => out.push_str(t),
4418 Segment::SmallCaps(t) => out.push_str(t),
4419 Segment::Code(t) => out.push_str(t),
4420 Segment::Glossary { display, .. } => out.push_str(display),
4421 Segment::Math(_) => {},
4422 Segment::PageRef(_) => {},
4423 Segment::Footnote { .. } => {},
4424 Segment::Cite(_) => {},
4425 Segment::MarginNote { .. } => {}, // the margin code is not part of the flattened body text
4426 Segment::Index { .. } => {}, // an index marker is not part of the flattened body text
4427 }
4428 }
4429 out
4430}
4431
4432/// Sets a title's rich runs into one horizontal line at `size` in `role`, so a running head or a
4433/// table-of-contents entry renders the title's maths, emphasis and glossary term rather than flattening
4434/// them to plain words or dropping the maths to a gap. Every run seats on a common baseline taken from a
4435/// full-size sample; a maths span is set at `size` and its glyphs woven into the line as the body sets
4436/// inline maths. A cross-reference, footnote or citation in a title has no form here and is dropped. The
4437/// returned dims carry the line's total width and the sample's ascent and depth, so a caller lays it out
4438/// with a constant height whatever the runs turn out to be.
4439fn inline_segments(
4440 fonts: &Arc<FontSet>,
4441 style: &Theme,
4442 segments: &[Segment],
4443 role: Role,
4444 size: Sp,
4445)
4446 -> Outcome<(Vec<Node>, Dims)>
4447{
4448 let sample = res!(ShapedText::new(fonts.clone(), role, Dir::Ltr, size, "Ag"));
4449 let asc = sample.dims().height;
4450 let dep = sample.dims().depth;
4451 let italic = role == Role::Italic;
4452
4453 let mut children: Vec<Node> = Vec::new();
4454 let mut width = Sp::ZERO;
4455 for seg in segments {
4456 // A maths span is unwrapped and its leaves woven straight into the line; every other run resolves to
4457 // a text string set in a face chosen against the base role, so an emphasis in an italic running head
4458 // toggles upright as Typst sets it.
4459 let mut features: &[Feature] = &[];
4460 let (text, r): (&str, Role) = match seg {
4461 Segment::Text(t) => (t, role),
4462 Segment::SmallCaps(t) => { features = &[Feature::SMALL_CAPS]; (t, role) },
4463 Segment::Strong(t) => (t, if italic { Role::BoldItalic } else { Role::Bold }),
4464 Segment::Emph(t) => (t, if italic { Role::Body } else { Role::Italic }),
4465 Segment::BoldItalic(t) => (t, if italic { Role::Bold } else { Role::BoldItalic }),
4466 Segment::Super(t) => (t, role),
4467 Segment::Sub(t) => (t, role),
4468 Segment::Code(t) => (t, Role::Mono),
4469 Segment::Glossary { display, .. } => (display, role),
4470 Segment::Math(atom) => {
4471 let mut hs = style.clone();
4472 hs.text.body_size = size;
4473 if let Node::HBox(b) = res!(math::layout(fonts.clone(), &hs, atom, false)) {
4474 width += b.dims.width;
4475 children.extend(b.list);
4476 }
4477 continue;
4478 },
4479 Segment::PageRef(_) | Segment::Footnote { .. } | Segment::Cite(_) | Segment::MarginNote { .. } | Segment::Index { .. } => continue,
4480 };
4481 let sh = res!(ShapedText::new_with_features(fonts.clone(), r, Dir::Ltr, size, text, features));
4482 let w = sh.dims().width;
4483 children.push(Node::Leaf(Leaf::text_dims(sh, Dims::new(w, asc, dep))));
4484 width += w;
4485 }
4486 Ok((children, Dims::new(width, asc, dep)))
4487}
4488
4489/// Places a horizontal run of leaves (a rich running head from [`inline_segments`]) into a page frame,
4490/// starting at `x0` with `top` the run's box top. It mirrors the driver's own line placement: a text or
4491/// graphic leaf lands at the running x plus its own shift, glue advances the cursor, and a reserved or
4492/// rule leaf -- neither of which a heading run holds -- is skipped.
4493fn place_run(frame: &mut Frame, nodes: &[Node], x0: Sp, top: Sp) {
4494 let mut x = x0;
4495 for n in nodes {
4496 match n {
4497 Node::Leaf(l) => {
4498 let y = top + l.shift;
4499 match &l.kind {
4500 LeafKind::Text(sh) => frame.push(Placed::new(x, y, l.dims, PlacedKind::Text(sh.clone()))),
4501 LeafKind::Graphic(g) => frame.push(Placed::new(x, y, l.dims, PlacedKind::Graphic(g.clone()))),
4502 _ => {},
4503 }
4504 x += l.dims.width;
4505 },
4506 Node::Glue(g) => x += g.natural,
4507 _ => {},
4508 }
4509 }
4510}
4511
4512/// A filesystem-safe key from a heading's words: lowercase, runs of non-alphanumerics collapsed to a
4513/// single dash. Prefixed with an ordinal by the caller, so two headings of the same words stay
4514/// distinct identities.
4515fn slug(text: &str) -> String {
4516 let mut out = String::new();
4517 let mut dash = false;
4518 for c in text.chars() {
4519 if c.is_ascii_alphanumeric() {
4520 out.push(c.to_ascii_lowercase());
4521 dash = false;
4522 } else if !dash && !out.is_empty() {
4523 out.push('-');
4524 dash = true;
4525 }
4526 }
4527 while out.ends_with('-') {
4528 out.pop();
4529 }
4530 if out.is_empty() { "heading".to_string() } else { out }
4531}
4532
4533// ┌───────────────────────────────────────────────────────────────────────────┐
4534// │ HEADINGS │
4535// └───────────────────────────────────────────────────────────────────────────┘
4536
4537/// The number shown before a heading: the chapter number alone for a chapter (level 1), the dotted path
4538/// for a deeper level (`2.3.1`), and nothing for a part divider (level 0).
4539fn heading_number(level: u8, sec: &[u32; 6]) -> String {
4540 match level {
4541 0 => String::new(),
4542 1 => fmt!("{}", sec[0]),
4543 _ => {
4544 let l = (level as usize).min(6);
4545 let parts: Vec<String> = sec[..l].iter().map(|n| fmt!("{}", n)).collect();
4546 parts.join(".")
4547 },
4548 }
4549}
4550
4551/// The number shown before a heading of `level`, honouring a per-level Typst numbering pattern the theme
4552/// carries (what `#set heading(numbering: "1.1")` lowers into every level, or a per-level override); with
4553/// no pattern -- the default -- it falls to the plain dotted arabic of [`heading_number`], so an untouched
4554/// theme renders unchanged. A part divider (level 0) carries no number.
4555fn heading_number_themed(level: u8, sec: &[u32; 6], style: &Theme) -> String {
4556 if level == 0 {
4557 return String::new();
4558 }
4559 let idx = (level as usize).saturating_sub(1).min(style.heading.levels.len().saturating_sub(1));
4560 if let Some(pattern) = style.heading.levels.get(idx).and_then(|l| l.numbering.as_deref()) {
4561 let l = (level as usize).min(6);
4562 return format_numbering(pattern, &sec[..l]);
4563 }
4564 heading_number(level, sec)
4565}
4566
4567/// Renders a Typst numbering pattern against a list of counter values, as `numbering(pattern, ..nums)`
4568/// does: each counting symbol (`1`, `a`, `A`, `i`, `I`) consumes one number and renders it in that
4569/// system, the literal text between symbols is kept, and when there are more numbers than symbols the
4570/// last symbol and its leading literal repeat -- so `"1.1"` over `[1, 2, 3]` gives `1.2.3`. A pattern with
4571/// no counting symbol is a fixed literal, returned unchanged.
4572fn format_numbering(pattern: &str, nums: &[u32]) -> String {
4573 // Each piece is the literal text leading up to one counting symbol; `suffix` is the literal after the
4574 // last symbol.
4575 let mut pieces: Vec<(String, char)> = Vec::new();
4576 let mut prefix = String::new();
4577 for c in pattern.chars() {
4578 if matches!(c, '1' | 'a' | 'A' | 'i' | 'I') {
4579 pieces.push((std::mem::take(&mut prefix), c));
4580 } else {
4581 prefix.push(c);
4582 }
4583 }
4584 let suffix = prefix;
4585 if pieces.is_empty() {
4586 return pattern.to_string();
4587 }
4588 let mut out = String::new();
4589 for (k, n) in nums.iter().enumerate() {
4590 let (pre, sym) = &pieces[k.min(pieces.len() - 1)];
4591 out.push_str(pre);
4592 out.push_str(&numbering_symbol(*sym, *n));
4593 }
4594 out.push_str(&suffix);
4595 out
4596}
4597
4598/// One counter value rendered in the system a Typst counting symbol names.
4599fn numbering_symbol(symbol: char, n: u32) -> String {
4600 match symbol {
4601 '1' => fmt!("{}", n),
4602 'a' => alpha_label(n, false),
4603 'A' => alpha_label(n, true),
4604 'i' => roman(n).to_lowercase(),
4605 'I' => roman(n),
4606 _ => fmt!("{}", n),
4607 }
4608}
4609
4610/// A bijective base-26 letter label: 1 -> `a`, 26 -> `z`, 27 -> `aa`, uppercased when `upper` -- Typst's
4611/// `"a"`/`"A"` counting symbols. Zero renders empty, as an unstarted counter does.
4612fn alpha_label(mut n: u32, upper: bool) -> String {
4613 if n == 0 {
4614 return String::new();
4615 }
4616 let base = if upper { b'A' } else { b'a' };
4617 let mut chars: Vec<char> = Vec::new();
4618 while n > 0 {
4619 let rem = ((n - 1) % 26) as u8;
4620 chars.push((base + rem) as char);
4621 n = (n - 1) / 26;
4622 }
4623 chars.iter().rev().collect()
4624}
4625
4626/// A heading run's face: the display face (Radley) a book supplies for its chapters and level-2
4627/// sections, or a reading-set role for the finer levels.
4628#[derive(Clone, Copy)]
4629enum HeadFace<'a> {
4630 Solo(&'a Arc<Font>),
4631 Role(Role),
4632}
4633
4634/// The face a heading level sets in. Levels 0-2 take the display face when the book supplies one, else
4635/// the body bold; level 3 is Libertinus italic and level 4+ Libertinus upright -- the template's
4636/// `if it.level <= 2 { "Radley" } else { "Libertinus Serif" }` with its level-3 italic.
4637/// The face a heading of `level` sets in: for levels 1 and 2, the theme's per-level display face -- or the
4638/// role-default heading face -- resolved through the loaded `faces`; a level 3 or deeper, or a named face
4639/// the book ships no file for, falls to the idiom's role behaviour (`doc_head_face` for a documentation
4640/// tree, `head_face_role`'s body bold/italic/upright for a book). This is what carries a document's
4641/// `heading-font` onto the page: a resolvable name renders in that face, an unresolvable one (the body
4642/// family, or a face the tree does not ship) renders exactly as the body role did before.
4643fn resolved_head_face<'a>(level: u8, style: &'a Theme, faces: &'a FaceResolver, doc: bool) -> HeadFace<'a> {
4644 if level <= 2 {
4645 let idx = (level.max(1) as usize) - 1;
4646 let lvl = style.heading.levels.get(idx);
4647 let per_level = lvl.and_then(|l| l.face.as_deref());
4648 let role = style.heading.face.as_deref();
4649 // The level's own weight and slant choose the variant; the default (no weight, upright) resolves to
4650 // the Regular face, so a document naming a plain display face renders exactly as before.
4651 let bold = lvl.and_then(|l| l.weight).map_or(false, |w| w >= 600);
4652 let italic = lvl.map_or(false, |l| l.italic);
4653 for name in [per_level, role].into_iter().flatten() {
4654 if let Some(font) = faces.resolve_weighted(name, bold, italic) {
4655 return HeadFace::Solo(font);
4656 }
4657 }
4658 }
4659 if doc { doc_head_face(level) } else { head_face_role(level) }
4660}
4661
4662/// The body-role face a heading level falls to when no display face is set or resolves: the body bold for
4663/// levels 1 and 2, italic for level 3, upright for deeper -- the book idiom's role fallback, split out of
4664/// the former `head_face` now that the display face comes from the resolver rather than a passed handle.
4665fn head_face_role(level: u8) -> HeadFace<'static> {
4666 match level {
4667 3 => HeadFace::Role(Role::Italic),
4668 _ if level <= 2 => HeadFace::Role(Role::Bold), // no display face: the body bold stands in
4669 _ => HeadFace::Role(Role::Body),
4670 }
4671}
4672
4673/// Is this theme's heading kind a documentation tree's (banner or inline), whose role fallback differs
4674/// from a book's? A book takes the display face or body bold; a doc small-caps and italicises by level.
4675fn is_doc_heading(style: &Theme) -> bool {
4676 matches!(style.heading.kind, HeadingStyle::DocBanner | HeadingStyle::DocInline | HeadingStyle::DocGrid)
4677}
4678
4679/// The concrete display font a resolved head face names, or `None` when it falls to a role face -- for the
4680/// front-matter title helpers, which set from a font handle rather than a `HeadFace`.
4681fn head_solo<'a>(face: &HeadFace<'a>) -> Option<&'a Arc<Font>> {
4682 match face {
4683 HeadFace::Solo(f) => Some(f),
4684 HeadFace::Role(_) => None,
4685 }
4686}
4687
4688/// Shapes one heading run in its face.
4689fn head_shape(
4690 fonts: &Arc<FontSet>,
4691 face: &HeadFace,
4692 size: Sp,
4693 text: &str,
4694)
4695 -> Outcome<ShapedText>
4696{
4697 match face {
4698 HeadFace::Solo(f) => ShapedText::new_with_font((*f).clone(), Dir::Ltr, size, text),
4699 HeadFace::Role(r) => ShapedText::new(fonts.clone(), *r, Dir::Ltr, size, text),
4700 }
4701}
4702
4703/// Splits a title into runs for synthetic small caps: a run of originally-lowercase letters, uppercased
4704/// and to be set at the small size, alternates with runs of everything else (capitals, digits, spaces,
4705/// punctuation) kept at the full size. The bool is true for the small (was-lowercase) runs. Synthetic
4706/// because the shaper applies no OpenType `smcp`; used only where the template's face (Libertinus, levels
4707/// 3-4) really carries small caps -- Radley does not, so the level-1/2 titles keep their case.
4708fn smallcaps_runs(text: &str) -> Vec<(String, bool)> {
4709 let mut runs: Vec<(String, bool)> = Vec::new();
4710 let mut cur = String::new();
4711 let mut small = false;
4712 for ch in text.chars() {
4713 let is_small = ch.is_lowercase();
4714 if !cur.is_empty() && is_small != small {
4715 runs.push((std::mem::take(&mut cur), small));
4716 }
4717 small = is_small;
4718 if is_small {
4719 for u in ch.to_uppercase() { cur.push(u); }
4720 } else {
4721 cur.push(ch);
4722 }
4723 }
4724 if !cur.is_empty() {
4725 runs.push((cur, small));
4726 }
4727 runs
4728}
4729
4730/// Builds a sub-heading line (levels 2-4): the number in the heading face, a thin gap, then the title
4731/// set from its rich runs, small-capped from level 3 down. Runs of differing size seat on one baseline
4732/// by taking a common ascent and depth from a full-size sample, so the small caps and the full caps sit
4733/// level. A glossary term keeps its own first-use bold-italic (recorded in `seen`, shared with the body
4734/// so document order decides), emphasis its face, and a maths span is set at the heading size and its
4735/// glyphs woven into the line -- so a call in a heading renders rather than leaking its raw source.
4736fn subheading_hbox(
4737 fonts: Arc<FontSet>,
4738 faces: &FaceResolver,
4739 style: &Theme,
4740 level: u8,
4741 number: &str,
4742 segments: &[Segment],
4743 seen: &mut HashSet<String>,
4744)
4745 -> Outcome<Node>
4746{
4747 // A documentation tree sets its sub-headings from the template's show rule -- an inline level-1 heading
4748 // (a `DocInline` tree) bold small-caps, level 2 bold-italic, level 3 italic, deeper levels upright; a
4749 // book takes the display face (or body bold) and small-caps the finer levels.
4750 let doc = is_doc_heading(style);
4751 let face = resolved_head_face(level, style, faces, doc);
4752 let size = style.heading_size(level);
4753 let small_size = Sp(size.raw() * 3 / 4); // small caps at 0.75 of the heading size
4754 let smallcaps = if doc { level == 1 } else { level >= 3 };
4755 let sample = res!(head_shape(&fonts, &face, size, "Ag"));
4756 let asc = sample.dims().height;
4757 let dep = sample.dims().depth;
4758
4759 let mut children: Vec<Node> = Vec::new();
4760 let mut width = Sp::ZERO;
4761
4762 if !number.is_empty() {
4763 let sh = res!(head_shape(&fonts, &face, size, number));
4764 let w = sh.dims().width;
4765 children.push(Node::Leaf(Leaf::text_dims(sh, Dims::new(w, asc, dep))));
4766 width += w;
4767 let gap = Sp(size.raw() / 5); // ~0.2 em, the template's `h(0.2em)`
4768 children.push(Node::Glue(Glue::fixed(gap)));
4769 width += gap;
4770 }
4771
4772 for seg in segments {
4773 match seg {
4774 Segment::Text(t) => res!(push_head_text(
4775 &mut children, &mut width, &fonts, &face, size, small_size, smallcaps, t, asc, dep)),
4776 Segment::Strong(t) => res!(push_head_text(
4777 &mut children, &mut width, &fonts, &head_run_face(&face, HeadRun::Strong), size, small_size, smallcaps, t, asc, dep)),
4778 Segment::Emph(t) => res!(push_head_text(
4779 &mut children, &mut width, &fonts, &head_run_face(&face, HeadRun::Emph), size, small_size, smallcaps, t, asc, dep)),
4780 Segment::BoldItalic(t) => res!(push_head_text(
4781 &mut children, &mut width, &fonts, &head_run_face(&face, HeadRun::BoldItalic), size, small_size, smallcaps, t, asc, dep)),
4782 // A superscript or subscript in a heading is vanishingly rare; set its text in the heading face
4783 // rather than raising or dropping it, so the words are kept without a scripted run in display type.
4784 Segment::Super(t) => res!(push_head_text(
4785 &mut children, &mut width, &fonts, &face, size, small_size, smallcaps, t, asc, dep)),
4786 Segment::Sub(t) => res!(push_head_text(
4787 &mut children, &mut width, &fonts, &face, size, small_size, smallcaps, t, asc, dep)),
4788 // A `#smallcaps[...]` in a heading takes the heading line's own small-capitals setting -- the
4789 // same one a small-capped heading level uses -- since a display face need carry no `smcp`.
4790 Segment::SmallCaps(t) => res!(push_head_text(
4791 &mut children, &mut width, &fonts, &face, size, small_size, true, t, asc, dep)),
4792 Segment::Code(t) => res!(push_head_text(
4793 &mut children, &mut width, &fonts, &face, size, small_size, smallcaps, t, asc, dep)),
4794 Segment::Glossary { term, display: disp } => {
4795 // First use is set bold-italic, matching the template's `*_term_*`; a later use takes the
4796 // heading's own face. The set is the body's, so a term first seen in a heading is plain in
4797 // the prose after it, exactly as document order dictates.
4798 let f = if seen.insert(term.clone()) { head_run_face(&face, HeadRun::Gloss) } else { face };
4799 res!(push_head_text(
4800 &mut children, &mut width, &fonts, &f, size, small_size, smallcaps, disp, asc, dep));
4801 },
4802 Segment::Math(atom) => {
4803 // The span is set at the heading size and unwrapped, its leaves woven into the line as the
4804 // body sets inline maths, so a subscripted variable in a heading draws as real glyphs.
4805 let mut hs = style.clone();
4806 hs.text.body_size = size;
4807 if let Node::HBox(b) = res!(math::layout(fonts.clone(), &hs, atom, false)) {
4808 width += b.dims.width;
4809 children.extend(b.list);
4810 }
4811 },
4812 // A footnote, cross-reference, citation or margin note in a heading is vanishingly rare and has no
4813 // display form here; it is dropped rather than set, leaving the heading its words.
4814 Segment::Footnote { .. } => {},
4815 Segment::PageRef(_) => {},
4816 Segment::Cite(_) => {},
4817 Segment::MarginNote { .. } => {},
4818 Segment::Index { .. } => {}, // an index marker in a heading is not recorded
4819 }
4820 }
4821
4822 Ok(Node::HBox(BoxNode::new(children, Dims::new(width, asc, dep))))
4823}
4824
4825/// The face a documentation heading level sets in, matching `template.typ`'s heading show rule: an inline
4826/// level-1 heading bold (and small-capped by its caller), level 2 bold-italic, level 3 italic, level 4 and
4827/// deeper upright. All in the body family (Libertinus), which is the doc heading family too, so no display
4828/// face is consulted.
4829fn doc_head_face(level: u8) -> HeadFace<'static> {
4830 match level {
4831 1 => HeadFace::Role(Role::Bold),
4832 2 => HeadFace::Role(Role::BoldItalic),
4833 3 => HeadFace::Role(Role::Italic),
4834 _ => HeadFace::Role(Role::Body),
4835 }
4836}
4837
4838/// A heading run marked for emphasis: strong (`*..*`), emph (`_.._`), or a glossary term's first-use
4839/// bold-italic.
4840enum HeadRun {
4841 Strong,
4842 Emph,
4843 BoldItalic,
4844 Gloss,
4845}
4846
4847/// The face one emphasised heading run sets in. A display face (Radley, levels 1-2) has no role variants
4848/// loaded, so every run keeps it; a role face (levels 3-4) takes the run's own role, an emphasis inside
4849/// an italic heading toggling upright as Typst sets it, a strong one going bold-italic.
4850fn head_run_face<'a>(base: &HeadFace<'a>, run: HeadRun) -> HeadFace<'a> {
4851 match base {
4852 HeadFace::Solo(f) => HeadFace::Solo(f),
4853 HeadFace::Role(r) => {
4854 let italic = *r == Role::Italic;
4855 let role = match run {
4856 HeadRun::Strong => if italic { Role::BoldItalic } else { Role::Bold },
4857 HeadRun::BoldItalic => if italic { Role::Bold } else { Role::BoldItalic }, // nested emphasis toggles against an italic heading
4858 HeadRun::Gloss => Role::BoldItalic,
4859 HeadRun::Emph => if italic { Role::Body } else { Role::Italic }, // emph toggles against an italic heading
4860 };
4861 HeadFace::Role(role)
4862 },
4863 }
4864}
4865
4866/// Sets one heading text run into the line, small-capping it (levels 3-4) run by run so a was-lowercase
4867/// stretch sets uppercase at the small size while capitals keep the full size, both seated on the common
4868/// baseline. A run with no small caps sets whole at the full size.
4869#[allow(clippy::too_many_arguments)]
4870fn push_head_text(
4871 children: &mut Vec<Node>,
4872 width: &mut Sp,
4873 fonts: &Arc<FontSet>,
4874 face: &HeadFace,
4875 size: Sp,
4876 small_size: Sp,
4877 smallcaps: bool,
4878 text: &str,
4879 asc: Sp,
4880 dep: Sp,
4881)
4882 -> Outcome<()>
4883{
4884 if smallcaps {
4885 for (run, is_small) in smallcaps_runs(text) {
4886 let rs = if is_small { small_size } else { size };
4887 let sh = res!(head_shape(fonts, face, rs, &run));
4888 let w = sh.dims().width;
4889 children.push(Node::Leaf(Leaf::text_dims(sh, Dims::new(w, asc, dep))));
4890 *width += w;
4891 }
4892 } else {
4893 let sh = res!(head_shape(fonts, face, size, text));
4894 let w = sh.dims().width;
4895 children.push(Node::Leaf(Leaf::text_dims(sh, Dims::new(w, asc, dep))));
4896 *width += w;
4897 }
4898 Ok(())
4899}
4900
4901/// Renders a shaped run as a coloured graphic: each glyph outline filled in `colour`, so a heading can
4902/// take a fill the text emitter (which draws every run black) does not carry. The outline is font-frame,
4903/// y up; it is flipped and seated on the run's baseline, `height` below the box top.
4904fn coloured_run(shaped: &ShapedText, colour: Rgba) -> Outcome<Graphic> {
4905 let base_y = shaped.dims().height.to_pt() as f32;
4906 let mut ops = Vec::new();
4907 for glyph in &shaped.run().glyphs {
4908 let path = res!(shaped.outline(glyph));
4909 if path.is_empty() {
4910 continue;
4911 }
4912 let t = Transform::scale(1.0, -1.0)
4913 .then(&Transform::translate(glyph.x, base_y - glyph.y));
4914 ops.push(DrawOp::Fill { path: res!(path.transform(&t)), colour });
4915 }
4916 Ok(Graphic::new(ops, shaped.dims()))
4917}
4918
4919/// Pushes a shaped run centred within `measure` on its own line.
4920fn push_centred_shape(nodes: &mut Vec<Node>, sh: ShapedText, measure: Sp) -> Outcome<()> {
4921 let d = sh.dims();
4922 let pad = if measure > d.width { Sp((measure.raw() - d.width.raw()) / 2) } else { Sp::ZERO };
4923 let mut row: Vec<Node> = Vec::new();
4924 if pad.raw() > 0 {
4925 row.push(Node::Glue(Glue::fixed(pad)));
4926 }
4927 row.push(Node::Leaf(Leaf::text(sh)));
4928 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, d.height, d.depth))));
4929 Ok(())
4930}
4931
4932/// The upper-case Roman numeral for `n`, covering the part range a book uses.
4933fn roman(mut n: u32) -> String {
4934 let table = [
4935 (100u32, "C"), (90, "XC"), (50, "L"), (40, "XL"),
4936 (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
4937 ];
4938 let mut out = String::new();
4939 for (v, s) in table {
4940 while n >= v {
4941 out.push_str(s);
4942 n -= v;
4943 }
4944 }
4945 out
4946}
4947
4948/// Sets a chapter opener (level 1) or a part divider (level 0) on a fresh page. A chapter shows its
4949/// number as a giant grey display numeral centred near the page top, then its title beneath in the
4950/// display face at the chapter-title size. A part divider carries `part_label` ("Part I") in the
4951/// display face above its title, both centred and set about the vertical middle of the page, matching
4952/// the template's `align(center + horizon)` part page. The anchor (and any label) is recorded at the
4953/// opener, so a running head or a cross-reference finds its page.
4954#[allow(clippy::too_many_arguments)]
4955fn chapter_opener(
4956 nodes: &mut Vec<Node>,
4957 fonts: &Arc<FontSet>,
4958 faces: &FaceResolver,
4959 style: &Theme,
4960 geom: PageGeometry,
4961 measure: Sp,
4962 level: u8,
4963 number: &str,
4964 title: &str,
4965 part_label: &str,
4966 id: &AnchorId,
4967 label: Option<&str>,
4968)
4969 -> Outcome<()>
4970{
4971 nodes.push(Node::Anchor(id.clone()));
4972 if let Some(l) = label {
4973 nodes.push(Node::Anchor(AnchorId::new(AnchorKind::Label, l.to_string())));
4974 }
4975
4976 let face = resolved_head_face(level, style, faces, is_doc_heading(style));
4977
4978 // A part divider fills its own page: a small display label, a gap, then the title, the block set
4979 // about the vertical centre. The label is upper-cased, the template's tracked small caps rendered as
4980 // plain caps here (the shaper carries no tracking); the gap is the template's `#v(2em)`, two body em.
4981 if level == 0 {
4982 let label_size = Sp::from_pt(style.heading.levels[0].size.to_pt() * 0.55); // the template's part-label, ~13/24 of the title
4983 let lab = res!(head_shape(fonts, &face, label_size, &part_label.to_uppercase()));
4984 let ttl = res!(head_shape(fonts, &face, style.heading.levels[0].size, title));
4985 let gap = Sp::from_pt(style.text.body_size.to_pt() * 2.0); // #v(2em)
4986 let lab_v = lab.dims().height + lab.dims().depth;
4987 let ttl_v = ttl.dims().height + ttl.dims().depth;
4988 let block_v = lab_v + gap + ttl_v;
4989 let content_h = geom.content_height();
4990 // Drop the block so its middle sits at the page's vertical centre; a box spacer, which a page top
4991 // keeps where glue would be discarded.
4992 if content_h > block_v {
4993 let top = Sp((content_h.raw() - block_v.raw()) / 2);
4994 nodes.push(Node::HBox(BoxNode::new(vec![], Dims::new(Sp::ZERO, top, Sp::ZERO))));
4995 }
4996 res!(push_centred_shape(nodes, lab, measure));
4997 nodes.push(Node::Glue(Glue::fixed(gap)));
4998 res!(push_centred_shape(nodes, ttl, measure));
4999 return Ok(());
5000 }
5001
5002 // A documentation tree opens a level-1 heading with the template's full-width grey banner bar carrying
5003 // the title in small caps, rather than a numbered chapter opener.
5004 if level == 1 && style.heading.kind == HeadingStyle::DocBanner {
5005 res!(doc_banner(nodes, fonts, faces, style, geom, measure, title));
5006 return Ok(());
5007 }
5008
5009 if level == 1 && (!number.is_empty() || style.heading.kind == HeadingStyle::DocGrid) {
5010 // The opener reproduces the template's four-row grid (`chapter-grid-rows`): a tall band holding the
5011 // number centred on its middle, a gap, a shorter band holding the title on its foot, and a gap down
5012 // to the body. Every row is a box, not glue -- a page top discards leading glue, and the opener sits
5013 // at the page top -- so the bands hold their heights and the body lands on the grid's foot. A grid
5014 // template (oxeweb) carries no number: the first band is then the reserved logo band, an empty box.
5015 if !number.is_empty() {
5016 let sh = res!(head_shape(fonts, &face, style.opener.chap_num_size, number));
5017 let d = sh.dims();
5018 let num_v = d.height + d.depth;
5019 let band = style.opener.chap_grid[0];
5020 // The number rides the middle of its band (Typst's `center + horizon`): the slack splits above and
5021 // below. A band shorter than the number leaves no slack and the number simply fills it.
5022 let above = if band > num_v { Sp((band.raw() - num_v.raw()) / 2) } else { Sp::ZERO };
5023 let below = if band > num_v + above { band - num_v - above } else { Sp::ZERO };
5024 nodes.push(vspacer(above));
5025
5026 let graphic = res!(coloured_run(&sh, style.colours.chap_num_grey));
5027 let pad = if measure > d.width { Sp((measure.raw() - d.width.raw()) / 2) } else { Sp::ZERO };
5028 let mut row: Vec<Node> = Vec::new();
5029 if pad.raw() > 0 {
5030 row.push(Node::Glue(Glue::fixed(pad)));
5031 }
5032 row.push(Node::Leaf(Leaf::graphic(graphic)));
5033 nodes.push(Node::HBox(BoxNode::new(row, Dims::new(measure, num_v, Sp::ZERO))));
5034 nodes.push(vspacer(below));
5035 } else {
5036 // The reserved logo/title band the grid template lays down at row 0 (oxeweb's 240pt), no number.
5037 nodes.push(vspacer(style.opener.chap_grid[0]));
5038 }
5039 nodes.push(vspacer(style.opener.chap_grid[1])); // the gap row between number and title
5040
5041 // The title rides the foot of its band (Typst's `left + bottom`): all the slack sits above it.
5042 let sh_t = res!(head_shape(fonts, &face, style.heading.levels[0].size, title));
5043 let dt = sh_t.dims();
5044 let title_v = dt.height + dt.depth;
5045 let band2 = style.opener.chap_grid[2];
5046 let top2 = if band2 > title_v { band2 - title_v } else { Sp::ZERO };
5047 nodes.push(vspacer(top2));
5048 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::text(sh_t))], Dims::new(measure, dt.height, dt.depth))));
5049 nodes.push(vspacer(style.opener.chap_grid[3])); // the gap row down to the body
5050 // A grid opener has no number band, so Typst's block-below glue (par.spacing) between the opener grid
5051 // and the first paragraph is added explicitly; the numbered book path stays byte-identical.
5052 if number.is_empty() {
5053 nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
5054 }
5055 return Ok(());
5056 }
5057
5058 // An unnumbered level-1 opener (no grid number): the title set left in the display face, then a gap.
5059 let sh = res!(head_shape(fonts, &face, style.heading.levels[0].size, title));
5060 let d = sh.dims();
5061 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::text(sh))], Dims::new(measure, d.height, d.depth))));
5062 nodes.push(Node::Glue(Glue::fixed(Sp::from_pt(20.0))));
5063 Ok(())
5064}
5065
5066/// Draws the documentation template's chapter banner: a full-width grey bar hanging into the page's top
5067/// and side margins (`template.typ`'s `chapter-banner`, a `place`d rect 150 pt tall from the page top,
5068/// `100% + 2*margin` wide), carrying the title left-aligned in small-caps bold, seated on the band's
5069/// vertical middle. The bar is drawn from one box whose ops bleed past its bounds -- the emitter clips
5070/// nothing -- and the box holds the template's `#v(95pt)` of following space, so the body lands where the
5071/// oracle sets it. No number is drawn: a doc tree sets `numbering: none`.
5072fn doc_banner(
5073 nodes: &mut Vec<Node>,
5074 fonts: &Arc<FontSet>,
5075 faces: &FaceResolver,
5076 style: &Theme,
5077 geom: PageGeometry,
5078 measure: Sp,
5079 title: &str,
5080)
5081 -> Outcome<()>
5082{
5083 let grey = Rgba::opaque(240, 240, 240); // the template's `colours.lightgrey`, luma(240)
5084 let banner_h = 150.0f32; // the template's rect height
5085 let follow = Sp::from_pt(95.0); // the template's `#v(95pt)` down to the body
5086 // The box origin is the content top-left; the graphic's ops are in that frame, y down. The bar reaches
5087 // the page's left edge (x = -inside) and top edge (y = -top), and runs the full page width and 150 pt
5088 // deep, so it hangs into both margins exactly as the placed rect does.
5089 let inside_pt = geom.content_left().to_pt() as f32;
5090 let top_pt = geom.content_top().to_pt() as f32;
5091 let page_w_pt = geom.width.to_pt() as f32;
5092 let x0 = -inside_pt;
5093 let y0 = -top_pt;
5094 let x1 = page_w_pt - inside_pt;
5095 let y1 = banner_h - top_pt;
5096
5097 let mut ops: Vec<DrawOp> = Vec::new();
5098 ops.push(DrawOp::Fill { path: res!(Path::rect(Bounds::new(x0, y0, x1, y1))), colour: grey });
5099
5100 // The title in the resolved heading face (the template's `heading-font`, e.g. Graystroke), falling to
5101 // the body bold when the tree ships no display face, at the template's 26 pt, small-capped run by run
5102 // (the shaper carries no `smcp`, so the case is synthesised: was-lowercase letters uppercased at 0.75
5103 // of the size).
5104 let face = resolved_head_face(1, style, faces, true);
5105 let size = Sp::from_pt(26.0);
5106 let small_size = Sp(size.raw() * 3 / 4);
5107 let sample = res!(head_shape(fonts, &face, size, "Ag"));
5108 let asc = sample.dims().height.to_pt() as f32;
5109 let dep = sample.dims().depth.to_pt() as f32;
5110 // The band's vertical middle in the box frame, then the baseline that centres the run's box on it.
5111 let band_mid = (banner_h / 2.0) - top_pt;
5112 let base_y = band_mid + (asc - dep) / 2.0;
5113
5114 let mut x_off = 0.0f32; // the title's left edge sits at the content left (box origin)
5115 for (run, is_small) in smallcaps_runs(title) {
5116 let rs = if is_small { small_size } else { size };
5117 let shaped = res!(head_shape(fonts, &face, rs, &run));
5118 for glyph in &shaped.run().glyphs {
5119 let path = res!(shaped.outline(glyph));
5120 if path.is_empty() {
5121 continue;
5122 }
5123 let t = Transform::scale(1.0, -1.0)
5124 .then(&Transform::translate(x_off + glyph.x, base_y - glyph.y));
5125 ops.push(DrawOp::Fill { path: res!(path.transform(&t)), colour: Rgba::BLACK });
5126 }
5127 x_off += shaped.dims().width.to_pt() as f32;
5128 }
5129
5130 let graphic = Graphic::new(ops, Dims::new(measure, follow, Sp::ZERO));
5131 // The box holds the `#v(95pt)` of flow space; the bar draws past its top edge into the margins.
5132 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::graphic(graphic))], Dims::new(measure, follow, Sp::ZERO))));
5133 Ok(())
5134}
5135
5136/// Draws the documentation template's section banner (`template.typ`'s `section-banner`): the same
5137/// full-width grey bar `doc_banner` hangs into the page's top and side margins, but carrying the section's
5138/// logo right-aligned on the band's vertical middle rather than a title -- the Hematite guide's per-section
5139/// mark. The logo is loaded at the template's 30 pt height and its right edge seated one page margin
5140/// (2.5 cm) in from the page's right edge (the template's `pad(right: 2.5cm)`), which lands on the content's
5141/// right edge. The bar is drawn from one box whose ops bleed past its bounds -- the emitter clips nothing --
5142/// and the box holds the template's `#v(95pt)` of following space, so the inline heading beneath lands where
5143/// the oracle sets it. A logo that will not load leaves the bar alone.
5144fn section_banner(
5145 nodes: &mut Vec<Node>,
5146 fonts: Arc<FontSet>,
5147 geom: PageGeometry,
5148 measure: Sp,
5149 path: &str,
5150)
5151 -> Outcome<()>
5152{
5153 let grey = Rgba::opaque(240, 240, 240); // the template's `colours.lightgrey`, luma(240)
5154 let banner_h = 150.0f32; // the template's rect height
5155 let follow = Sp::from_pt(95.0); // the template's `#v(95pt)` down to the heading
5156 let logo_h = 30.0f32; // the template's `image(logo_path, height: 30pt)`
5157 // The box origin is the content top-left, y down; the bar reaches the page's left edge (x = -inside) and
5158 // top edge (y = -top), runs the full page width and 150 pt deep, so it hangs into both margins.
5159 let inside_pt = geom.content_left().to_pt() as f32;
5160 let top_pt = geom.content_top().to_pt() as f32;
5161 let page_w_pt = geom.width.to_pt() as f32;
5162 let x0 = -inside_pt;
5163 let y0 = -top_pt;
5164 let x1 = page_w_pt - inside_pt;
5165 let y1 = banner_h - top_pt;
5166
5167 let mut ops: Vec<DrawOp> = Vec::new();
5168 ops.push(DrawOp::Fill { path: res!(Path::rect(Bounds::new(x0, y0, x1, y1))), colour: grey });
5169
5170 // The logo, loaded 30 pt tall, its right edge one page margin in from the page's right edge (the content
5171 // right edge) and its box centred on the band's vertical middle. Its own ops are in a top-left frame,
5172 // y down; a plain translation seats them. A logo that will not load draws the bar alone.
5173 if let Ok(logo) = image_at_height(&fonts, path, logo_h as f64) {
5174 let lw = logo.dims.width.to_pt() as f32;
5175 let lh = (logo.dims.height + logo.dims.depth).to_pt() as f32;
5176 let right = x1 - inside_pt; // 2.5 cm in from the page right edge = the content right edge
5177 let band_mid = (banner_h / 2.0) - top_pt; // the band's vertical middle, box frame, y down
5178 let tx = right - lw;
5179 let ty = band_mid - lh / 2.0;
5180 let t = Transform::translate(tx, ty);
5181 for op in logo.ops {
5182 ops.push(match op {
5183 DrawOp::Fill { path, colour } => DrawOp::Fill { path: res!(path.transform(&t)), colour },
5184 DrawOp::Stroke { path, colour, width } => DrawOp::Stroke { path: res!(path.transform(&t)), colour, width },
5185 DrawOp::Image { image, x, y, w, h } => DrawOp::Image { image, x: x + tx, y: y + ty, w, h },
5186 });
5187 }
5188 }
5189
5190 let graphic = Graphic::new(ops, Dims::new(measure, follow, Sp::ZERO));
5191 // The box holds the `#v(95pt)` of flow space; the bar draws past its top edge into the margins.
5192 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::graphic(graphic))], Dims::new(measure, follow, Sp::ZERO))));
5193 Ok(())
5194}
5195
5196/// A rigid vertical spacer: a zero-width box of the given height, so it holds its space at a page top
5197/// where leading glue would be discarded.
5198fn vspacer(height: Sp) -> Node {
5199 Node::HBox(BoxNode::new(vec![], Dims::new(Sp::ZERO, height, Sp::ZERO)))
5200}
5201
5202/// Sets a `#styled-box[...]` callout: its inner blocks laid out at the measure less the horizontal insets,
5203/// seated one inset in from the left and top, over a filled rounded rectangle that runs the full measure.
5204/// The template's own box takes `inset: (x: 1em, y: 1em, bottom: 1.2em)`, so the sides and top pad one body
5205/// em and the foot 1.2 em, and `radius: 4pt` rounds the corners; the wash is `colours.veronica.lighten(90%)`,
5206/// a pale violet. A `#show` rule's `block.with(fill:, inset:, radius:)` can override any of the four on
5207/// `style.callout` (each `None` until a rule names it), so this reads them off `style` and falls back to
5208/// the template's own constants precisely where a rule left them unset -- the bare `#styled-box[...]` path,
5209/// which sets no such rule, always takes every fallback and renders exactly as before. The wash draws first
5210/// with no vertical extent of its own, so the words overlay it, and the whole callout is one keep box -- the
5211/// breaker moves it entire rather than splitting the wash from its text.
5212#[allow(clippy::too_many_arguments)]
5213fn styled_box(
5214 nodes: &mut Vec<Node>,
5215 fonts: Arc<FontSet>,
5216 geom: PageGeometry,
5217 style: &Theme,
5218 measure: Sp,
5219 blocks: &[Block],
5220 fill: Rgba,
5221 foot_no: &mut u32,
5222 ref_no: &mut u32,
5223 margin_no: &mut u32,
5224 seen: &mut HashSet<String>,
5225 idx: &mut IndexGather,
5226 claim: &mut ClaimGather,
5227 bib: Option<&Bibliography>,
5228 refs: &HashMap<String, String>,
5229)
5230 -> Outcome<()>
5231{
5232 let em = style.text.body_size;
5233 // Each falls back to the template's own constant precisely where a rule left it unset, so the bare
5234 // `#styled-box[...]` path -- which sets no such rule -- takes every fallback and is unchanged. The left
5235 // and right pads take an asymmetric `inset.left`/`inset.right` override first (a `#let` template block's
5236 // `inset: (left:, right:)`), then the symmetric `inset.x`, then one body em -- so a callout that names
5237 // neither is exactly as before.
5238 let inset_left = style.callout.inset_left.or(style.callout.inset_x).unwrap_or(em); // `inset.left`, default one body em
5239 let inset_right = style.callout.inset_right.or(style.callout.inset_x).unwrap_or(em); // `inset.right`, default one body em
5240 let inset_top = style.callout.inset_top.unwrap_or(em); // `inset.y`, default one body em
5241 let inset_bot = style.callout.inset_bot.unwrap_or(Sp::from_pt(em.to_pt() * 1.2)); // `inset.bottom`, default 1.2 em
5242 let radius = style.callout.radius.map_or(4.0f32, |sp| sp.to_pt() as f32); // `radius`, default 4pt
5243 let two_x = inset_left + inset_right;
5244 let inner_w = if measure > two_x { measure - two_x } else { measure };
5245
5246 // The inner blocks laid out at the reduced measure, then each line shifted one left inset in by a leading
5247 // glue: `place_vbox` seats every child at the content left, so the horizontal inset rides inside the line
5248 // rather than on the box.
5249 let mut inner: Vec<Node> = Vec::new();
5250 res!(box_flow(&mut inner, fonts.clone(), geom, style, inner_w, blocks, foot_no, ref_no, margin_no, seen, idx, claim, bib, refs));
5251 for node in inner.iter_mut() {
5252 if let Node::HBox(b) = node {
5253 b.list.insert(0, Node::Glue(Glue::fixed(inset_left)));
5254 b.dims = Dims::new(b.dims.width + inset_left, b.dims.height, b.dims.depth);
5255 }
5256 }
5257
5258 // The stacked height of the inner content, so the wash encloses it plus the top and bottom insets.
5259 let mut content_h = Sp::ZERO;
5260 for node in &inner {
5261 content_h += node.vextent();
5262 }
5263 let total = inset_top + content_h + inset_bot;
5264
5265 let mut children: Vec<Node> = Vec::new();
5266 // The wash and the left rule, both drawn behind the words as one zero-extent graphic: a rounded rectangle
5267 // the full measure wide and the whole box tall, then -- when a `stroke: (left: <w> + <colour>)` names one
5268 // -- a vertical bar of that width and colour seated at the left edge. A fully transparent fill (a `#let`
5269 // template block with no `fill:` -- a plain indented block, not a washed callout) draws no rectangle, so
5270 // the inset positions the text with no panel behind it; a left rule with no fill still draws.
5271 let mut ops: Vec<DrawOp> = Vec::new();
5272 if fill.a != 0 {
5273 let rect = res!(Path::round_rect(
5274 Bounds::new(0.0, 0.0, measure.to_pt() as f32, total.to_pt() as f32), radius));
5275 ops.push(DrawOp::Fill { path: rect, colour: fill });
5276 }
5277 if let (Some(w), Some(col)) = (style.callout.stroke_left_w, style.callout.stroke_left_col) {
5278 if w.to_pt() > 0.0 && col.a != 0 {
5279 let bar = res!(Path::rect(Bounds::new(0.0, 0.0, w.to_pt() as f32, total.to_pt() as f32)));
5280 ops.push(DrawOp::Fill { path: bar, colour: col });
5281 }
5282 }
5283 if !ops.is_empty() {
5284 let graphic = Graphic::new(ops, Dims::new(measure, Sp::ZERO, Sp::ZERO));
5285 children.push(Node::Leaf(Leaf::graphic(graphic)));
5286 }
5287 children.push(Node::Glue(Glue::fixed(inset_top)));
5288 children.append(&mut inner);
5289 children.push(Node::Glue(Glue::fixed(inset_bot)));
5290 nodes.push(vbox(children, measure));
5291 Ok(())
5292}
5293
5294/// Measures a block flow without placing it: the blocks are set at `measure` exactly as [`box_flow`] sets
5295/// them, and the stacked vertical extent is returned as [`Dims`] -- `width` the measure, `height` the sum of
5296/// the flow's node extents, `depth` zero. The overlay pass sizes a note this way before drawing it, the same
5297/// measure Typst's own `measure(content)` gives. The document-order counters a full render threads are
5298/// throwaway here (a measure numbers nothing), so a footnote or reference inside the measured blocks counts
5299/// only within this scratch flow and never reaches the document.
5300pub fn measure_blocks(
5301 fonts: Arc<FontSet>,
5302 geom: PageGeometry,
5303 style: &Theme,
5304 measure: Sp,
5305 blocks: &[Block],
5306 bib: Option<&Bibliography>,
5307 refs: &HashMap<String, String>,
5308)
5309 -> Outcome<Dims>
5310{
5311 let mut nodes: Vec<Node> = Vec::new();
5312 let mut foot_no = 0u32;
5313 let mut ref_no = 0u32;
5314 let mut margin_no = 0u32;
5315 let mut seen: HashSet<String> = HashSet::new();
5316 // A scratch measurement flow numbers nothing that reaches the document, so its index markers and claim
5317 // references are gathered into throwaways that are dropped -- they must not join the real back matter.
5318 let mut idx = IndexGather::default();
5319 let mut claim = ClaimGather::default();
5320 res!(box_flow(&mut nodes, fonts, geom, style, measure, blocks,
5321 &mut foot_no, &mut ref_no, &mut margin_no, &mut seen, &mut idx, &mut claim, bib, refs));
5322 let mut height = Sp::ZERO;
5323 for n in &nodes {
5324 height += n.vextent();
5325 }
5326 Ok(Dims::new(measure, height, Sp::ZERO))
5327}
5328
5329/// Lays a callout's inner blocks into a flow of line nodes at `measure`: a plain or rich paragraph is
5330/// woven into justified lines and a list set as its bullets, blocks parted by a paragraph skip. Only the
5331/// block kinds a callout body carries are set -- a `#styled-box` wraps running prose, not a heading, a
5332/// figure or a table -- so any other block is passed over.
5333#[allow(clippy::too_many_arguments)]
5334fn box_flow(
5335 nodes: &mut Vec<Node>,
5336 fonts: Arc<FontSet>,
5337 geom: PageGeometry,
5338 style: &Theme,
5339 measure: Sp,
5340 blocks: &[Block],
5341 foot_no: &mut u32,
5342 ref_no: &mut u32,
5343 margin_no: &mut u32,
5344 seen: &mut HashSet<String>,
5345 idx: &mut IndexGather,
5346 claim: &mut ClaimGather,
5347 bib: Option<&Bibliography>,
5348 refs: &HashMap<String, String>,
5349)
5350 -> Outcome<()>
5351{
5352 let mut first = true;
5353 res!(box_flow_scoped(nodes, fonts, geom, style, measure, blocks, foot_no, ref_no, margin_no, seen, idx, claim, bib, refs, &mut first));
5354 Ok(())
5355}
5356
5357/// The recursive core of [`box_flow`]: sets a callout's blocks under `style`, descending into a
5358/// [`Block::Scoped`] under its overlaid theme so a `#set` inside a callout body styles only its subtree
5359/// rather than being dropped. `first` is shared across the recursion so the inter-block paragraph skip is
5360/// placed on document order, not reset at a scope boundary.
5361#[allow(clippy::too_many_arguments)]
5362fn box_flow_scoped(
5363 nodes: &mut Vec<Node>,
5364 fonts: Arc<FontSet>,
5365 geom: PageGeometry,
5366 style: &Theme,
5367 measure: Sp,
5368 blocks: &[Block],
5369 foot_no: &mut u32,
5370 ref_no: &mut u32,
5371 margin_no: &mut u32,
5372 seen: &mut HashSet<String>,
5373 idx: &mut IndexGather,
5374 claim: &mut ClaimGather,
5375 bib: Option<&Bibliography>,
5376 refs: &HashMap<String, String>,
5377 first: &mut bool,
5378)
5379 -> Outcome<()>
5380{
5381 for block in blocks {
5382 if let Block::Scoped { patch, blocks: inner } = block {
5383 let scoped = { let mut t = style.clone(); t.apply(patch); t };
5384 res!(box_flow_scoped(nodes, fonts.clone(), geom, &scoped, measure, inner,
5385 foot_no, ref_no, margin_no, seen, idx, claim, bib, refs, first));
5386 continue;
5387 }
5388 if !*first {
5389 nodes.push(Node::Glue(Glue::fixed(style.par.skip)));
5390 }
5391 match block {
5392 Block::Paragraph { text } => {
5393 let pieces = vec![Piece::Text { text: text.clone(), role: Role::Body }];
5394 let lines = res!(break_paragraph_pieces(
5395 fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &pieces, measure, style.text.leading, style.text.justify, style.text.hyphenate, style.text.fill,
5396 Some(cap_edge(style, style.text.body_size))));
5397 nodes.extend(lines);
5398 },
5399 Block::RichParagraph { segments } => {
5400 let pieces = res!(build_pieces(
5401 fonts.clone(), geom, style, segments, Role::Body, foot_no, ref_no, margin_no, seen, idx, claim, bib, refs));
5402 let lines = res!(break_paragraph_pieces(
5403 fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, &pieces, measure, style.text.leading, style.text.justify, style.text.hyphenate, style.text.fill,
5404 Some(cap_edge(style, style.text.body_size))));
5405 nodes.extend(lines);
5406 },
5407 Block::List { ordered, items, loose } => {
5408 res!(list(
5409 nodes, fonts.clone(), geom, style, measure, *ordered, items, *loose,
5410 foot_no, ref_no, margin_no, seen, idx, claim, bib, refs));
5411 },
5412 // A verbatim code block a template moved into a washed box (`#show raw: block.with(fill: ...)`):
5413 // set in the mono face at the scoped `code.size`, the same as a top-level code block. Without this
5414 // arm the box body dropped its code silently.
5415 Block::Code { lines } => {
5416 res!(code_block(nodes, fonts.clone(), style, lines));
5417 },
5418 // A nested space a template placed inside a boxed body.
5419 Block::Space(sp) => {
5420 nodes.push(Node::Glue(Glue::fixed(*sp)));
5421 },
5422 // A `#pagebreak()` inside a callout body has no page to turn, so it is refused visibly at parse
5423 // time ([`crate::lang::parse::refuse_nested_page_breaks`]) and never reaches here. This explicit
5424 // arm keeps it out of the silent catch-all below, so a future path that did route one here would
5425 // surface as a compile-time non-exhaustiveness rather than a silent drop.
5426 Block::PageBreak { .. } => {},
5427 _ => {},
5428 }
5429 *first = false;
5430 }
5431 Ok(())
5432}
5433
5434/// Appends a horizontal rule -- a standalone `#line(...)` divider -- as a filled grey bar of the given
5435/// width (a fraction of the measure or an absolute length), thickness and grey level, seated flush left.
5436/// A degenerate rule (zero width or thickness) adds nothing rather than an empty box.
5437fn rule_divider(nodes: &mut Vec<Node>, measure: Sp, width: Length, thickness: f64, grey: u8) {
5438 let w = match width {
5439 Length::Rel(f) => Sp::from_pt(measure.to_pt() * f),
5440 Length::Abs(pt) => Sp::from_pt(pt),
5441 };
5442 let wf = w.to_pt() as f32;
5443 let hf = thickness as f32;
5444 if wf <= 0.0 || hf <= 0.0 {
5445 return;
5446 }
5447 let h = Sp::from_pt(thickness);
5448 let colour = Rgba::opaque(grey, grey, grey);
5449 let rect = match Path::rect(Bounds::new(0.0, 0.0, wf, hf)) {
5450 Ok(r) => r,
5451 Err(_) => return,
5452 };
5453 let graphic = Graphic::new(vec![DrawOp::Fill { path: rect, colour }], Dims::new(w, h, Sp::ZERO));
5454 nodes.push(Node::HBox(BoxNode::new(vec![Node::Leaf(Leaf::graphic(graphic))], Dims::new(measure, h, Sp::ZERO))));
5455}
5456
5457/// Loads an image at a fixed drawn height: the image at `path` read (an SVG as its own scaled paths, a
5458/// raster to fill its box) at height `h`, its ops seated at the origin so a caller can place it. The
5459/// height fixes the size and the width follows the aspect, matching the template's `image(.., height: Npt)`.
5460/// Used for the page footer logo, and for the meta page's declaration mark within a table cell.
5461pub(crate) fn image_at_height(fonts: &Arc<FontSet>, path: &str, h: f64) -> Outcome<Graphic> {
5462 let height = Some(Length::Abs(h));
5463 // A wide box so the height hint, not the measure, governs the size; the picture keeps its aspect.
5464 let box_w = Sp::from_pt(1000.0);
5465 match crate::image::load_figure(path) {
5466 Ok(crate::image::Figure::Raster(img)) => image_graphic(box_w, img, None, height, None),
5467 Ok(crate::image::Figure::Vector(pic)) => svg_graphic(fonts.clone(), box_w, pic, None, height, None),
5468 Err(e) => Err(e),
5469 }
5470}
5471
5472/// Draws the page furniture -- a running head in the top margin and a folio -- onto every composed
5473/// page. Called after the driver has converged: the furniture sits outside the text block, so adding
5474/// it moves nothing and cannot reopen the fixed point.
5475///
5476/// The running head follows the book's own scheme, the even/odd split the template sets. A verso (even)
5477/// page carries the folio at the outer edge and the book title, in italic, at the inner; a recto (odd)
5478/// page carries the current chapter title, in italic, at the inner edge and the folio at the outer. The
5479/// current chapter is the most recent level-1 heading the ledger resolved to an earlier page. A page a
5480/// chapter opens at its very top -- and the first page, before any chapter runs -- omits the running
5481/// head and sets a centred folio at the foot instead, the usual chapter-opening treatment. The frame is
5482/// laid at the recto (binding-left) split; `ingot` mirrors a verso page's whole frame to the fore-edge
5483/// afterwards, so placing the folio at the block's left on a verso page lands it at the outer margin.
5484/// Both the head and the folio are shaped through the same path as the body and drawn as glyph outlines.
5485pub fn decorate(
5486 pages: &mut [Page],
5487 ledger: &Ledger,
5488 heads: &[Heading],
5489 fonts: &Arc<FontSet>,
5490 style: &Theme,
5491 geom: PageGeometry,
5492 book_title: &str,
5493 footer_logo: Option<&str>,
5494)
5495 -> Outcome<()>
5496{
5497 let content_top = geom.content_top();
5498 let content_left = geom.content_left();
5499 let content_width = geom.content_width();
5500 // The documentation template seats a logo at the left of every page footer. It is loaded once and
5501 // placed on each body page; a logo that will not load leaves the footer to the folio alone.
5502 let footer = footer_logo.and_then(|p| image_at_height(fonts, p, 18.0).ok().map(Arc::new));
5503 // The body opens on this physical page; the printed folio restarts at one here, so a body page's
5504 // folio is its physical page less the front matter before it. A run with no headings (a lone
5505 // manuscript) leaves `body_start_page` zero, so the whole document is body and the folio is physical.
5506 let body_start = ledger.body_start_page.max(1);
5507 for page in pages.iter_mut() {
5508 // Front matter -- the cover, title, imprint and contents leaves before the body -- carries no
5509 // running head and no folio, exactly as the template sets `numbering: none` there.
5510 if page.number < body_start {
5511 continue;
5512 }
5513 let folio = page.number - (body_start - 1);
5514
5515 // The footer logo, seated at the left of the foot on every body page, its top at the folio's foot line.
5516 if let Some(g) = &footer {
5517 let foot_top = content_top + geom.content_height() + Sp::from_pt(14.0);
5518 page.frame.push(Placed::new(content_left, foot_top, g.dims, PlacedKind::Graphic(g.clone())));
5519 }
5520
5521 // The back matter -- the bibliography and beyond -- drops the running head and centres the folio at
5522 // the foot, as the template sets it.
5523 let back_start = ledger.back_matter_start_page;
5524 if back_start != 0 && page.number >= back_start {
5525 let foot_top = content_top + geom.content_height() + Sp::from_pt(14.0);
5526 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.furniture.folio_size, &fmt!("{}", folio)));
5527 let d = shaped.dims();
5528 let x = centre_x(geom, d.width);
5529 page.frame.push(Placed::new(x, foot_top, d, PlacedKind::Text(shaped)));
5530 continue;
5531 }
5532
5533 // The chapter running at the top of this page (the most recent level-1 heading resolved to an
5534 // earlier page), whether a chapter opens at the very top of this one, and whether a part divider
5535 // does -- a part page carries no folio at all.
5536 let mut chapter: Option<&Heading> = None;
5537 let mut opens = false;
5538 let mut opens_part = false;
5539 for h in heads {
5540 if let Some(a) = ledger.get(&h.id) {
5541 if a.pos.page < page.number {
5542 if h.level == 1 { chapter = Some(h); }
5543 } else if a.pos.page == page.number {
5544 // A chapter (or part divider) that resolves to this page opens it: a book/doc-banner chapter
5545 // and a part divider force a fresh page, so a level-1/level-0 heading present here is this
5546 // page's opener, whether it sits at the very top or has been shifted down by a top float set
5547 // above it -- detecting it by presence rather than by `y == content_top` is what keeps a
5548 // running head and folio off a float-shifted opener. A DocInline section (`= Section` mid-file,
5549 // no banner) does NOT force a page and sits mid-page, so it must NOT be read as an opener or
5550 // the previous section's running head would wrongly drop; it is admitted only when it carries
5551 // a banner, matching the opener test the chapter-banner path uses.
5552 if h.level == 1 && (h.banner || style.heading.kind != HeadingStyle::DocInline) { opens = true; }
5553 if h.level == 0 { opens_part = true; }
5554 } else {
5555 break; // headings are in document order, so the rest resolve to later pages
5556 }
5557 }
5558 }
5559
5560 // A part divider stands alone with no folio and no head, as the template's part page sets.
5561 if opens_part {
5562 continue;
5563 }
5564
5565 // The head baseline sits a fixed step above the text block; a folio at the foot sits a step below.
5566 let head_base = content_top - Sp::from_pt(8.0);
5567 let foot_top = content_top + geom.content_height() + Sp::from_pt(14.0);
5568 let num = fmt!("{}", folio);
5569
5570 if opens || chapter.is_none() {
5571 // A chapter-opening page: no running head, a centred folio at the foot.
5572 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.furniture.folio_size, &num));
5573 let d = shaped.dims();
5574 let x = centre_x(geom, d.width);
5575 page.frame.push(Placed::new(x, foot_top, d, PlacedKind::Text(shaped)));
5576 continue;
5577 }
5578
5579 // The folio, at the outer margin of the running head. On a recto (odd) page the outer edge is the
5580 // block's right; on a verso (even) page it is the block's left, which the mirror shift carries to
5581 // the fore-edge.
5582 let folio = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.furniture.folio_size, &num));
5583 let fd = folio.dims();
5584 let folio_x = if page.number % 2 == 0 {
5585 content_left
5586 } else {
5587 content_left + content_width - fd.width
5588 };
5589 page.frame.push(Placed::new(folio_x, head_base - fd.height, fd, PlacedKind::Text(folio)));
5590
5591 // The title side: the book title on a verso page, set against the folio at the inner edge; the
5592 // chapter title on a recto, set from its rich runs so a maths span or emphasis in the title renders
5593 // rather than dropping to a gap. Both italic.
5594 if page.number % 2 == 0 {
5595 if !book_title.is_empty() {
5596 let shaped = res!(ShapedText::new(fonts.clone(), Role::Italic, Dir::Ltr, style.furniture.header_size, book_title));
5597 let d = shaped.dims();
5598 let x = content_left + content_width - d.width; // verso: title at the inner (spine) edge
5599 page.frame.push(Placed::new(x, head_base - d.height, d, PlacedKind::Text(shaped)));
5600 }
5601 } else if let Some(ch) = chapter {
5602 let (rnodes, rd) = res!(inline_segments(fonts, style, &ch.segments, Role::Italic, style.furniture.header_size));
5603 // Recto: title at the inner (spine) edge, its box top a full ascent above the head baseline.
5604 place_run(&mut page.frame, &rnodes, content_left, head_base - rd.height);
5605 }
5606 }
5607
5608 // The overlay's second job: the marginalia the composition recorded, drawn on every page from the same
5609 // converged ledger the running head reads. A run over all pages, separate from the running-head loop's
5610 // front-matter and part-page early-outs, since a margin note belongs to its own body page regardless of
5611 // that page's running-head treatment.
5612 for page in pages.iter_mut() {
5613 res!(draw_marginalia(page, ledger, fonts, style, geom));
5614 }
5615 Ok(())
5616}
5617
5618/// Draws the marginalia the composition recorded: each `#claim-label(...)`'s compressed code, set at the
5619/// corpus's 6.5 pt `luma(90)` grey in the outside margin at the vertical position its zero-width anchor
5620/// landed. It is the overlay pass's second job beside the running head -- both are zero-flow ink drawn from
5621/// the converged ledger, so neither can reopen the fixed point. The frame is laid at the recto split, so a
5622/// recto note seats flush against the block's right (its outer edge) and a verso note flush against the
5623/// block's left, which `ingot`'s mirror shift then carries out to the fore-edge -- the same mirror the folio
5624/// rides. The code's baseline is seated on the body baseline of the line its anchor sits in, so it lines up
5625/// with the prose it annotates rather than floating at the line top.
5626fn draw_marginalia(
5627 page: &mut Page,
5628 ledger: &Ledger,
5629 fonts: &Arc<FontSet>,
5630 style: &Theme,
5631 geom: PageGeometry,
5632)
5633 -> Outcome<()>
5634{
5635 let content_left = geom.content_left();
5636 let content_width = geom.content_width();
5637 let size = Sp::from_pt(6.5);
5638 let colour = Rgba::opaque(90, 90, 90); // Typst's `luma(90)`
5639 // The body ascent, so the small code's baseline meets the prose baseline of the line it annotates.
5640 let body_asc = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size, "Ag")).dims().height;
5641 for anchor in ledger.anchors() {
5642 if anchor.id.kind != AnchorKind::MarginNote || anchor.pos.page != page.number {
5643 continue;
5644 }
5645 let display = margin_display(&anchor.id.key);
5646 if display.is_empty() {
5647 continue;
5648 }
5649 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, display)).with_colour(colour);
5650 let d = shaped.dims();
5651 // Horizontal, in recto (binding-left) coordinates: a recto page seats the code's left edge at the
5652 // block's right edge (the outer margin); a verso page seats its right edge at the block's left edge,
5653 // which the verso mirror shift `ingot` applies afterwards carries out to the fore-edge.
5654 let x = if page.number % 2 == 0 {
5655 content_left - d.width
5656 } else {
5657 content_left + content_width
5658 };
5659 // Vertical: the anchor's y is the top of the line it landed in; the line's baseline is a body ascent
5660 // below that, and the code is lifted by its own height so its baseline -- not its top -- meets it.
5661 let y = anchor.pos.y + body_asc - d.height;
5662 page.frame.push(Placed::new(x, y, d, PlacedKind::Text(shaped)));
5663 }
5664 Ok(())
5665}
5666
5667/// The x that centres a box of width `w` in the text block. A box wider than the measure starts at
5668/// the left edge rather than hanging off it.
5669fn centre_x(geom: PageGeometry, w: Sp) -> Sp {
5670 let slack = (geom.content_width().raw() - w.raw()).max(0) / 2;
5671 geom.content_left() + Sp(slack)
5672}
5673
5674#[cfg(test)]
5675mod tests {
5676 use super::*;
5677
5678 #[test]
5679 fn count_words_counts_letter_runs_across_blocks() {
5680 // Letter runs, as the template's `\p{L}+` counter steps: "don't" is two runs, a bare number none.
5681 let blocks = vec![
5682 Block::Heading { level: 1, segments: vec![Segment::text("The Purpose")], label: None },
5683 Block::Paragraph { text: "It reads a document and writes 42 pages.".to_string() },
5684 Block::List { ordered: false, loose: false, items: vec![
5685 ListEntry { segments: vec![Segment::strong("one two")], children: vec![] }] },
5686 ];
5687 // Heading: 2; paragraph: "It reads a document and writes pages" = 7 (the "42" counts none);
5688 // list item: 2. Total 11.
5689 assert_eq!(count_words(&blocks), 11);
5690 }
5691
5692 #[test]
5693 fn build_meta_table_appends_reading_time_and_mark() {
5694 let fm = FrontMatter {
5695 title: "Austenite".to_string(),
5696 subtitle: None,
5697 author: "J. D. Hoogland".to_string(),
5698 cover_image: None,
5699 logo_image: None,
5700 publisher: None,
5701 edition: None,
5702 isbn: None,
5703 copyright: Some("Copyright © 12025 Oxedyne. All rights reserved.".to_string()),
5704 rights: None,
5705 ai_declaration: None,
5706 website: None,
5707 toolchain: false,
5708 dedication: None,
5709 about_author: None,
5710 title_size: Sp::from_pt(28.0),
5711 subtitle_size: Sp::from_pt(16.0),
5712 author_size: Sp::from_pt(17.0),
5713 back_title_size: Sp::from_pt(14.0),
5714 sidebar_grey: Some(240),
5715 sidebar_frac: 0.45,
5716 title_smallcaps: true,
5717 top_logo: None,
5718 top_logo_width: Sp::ZERO,
5719 bottom_logo: None,
5720 bottom_logo_width: Sp::ZERO,
5721 footer_logo: None,
5722 meta_rows: vec![MetaRow {
5723 version: Some("0.1.0".to_string()),
5724 date: Some("12026-08-08".to_string()),
5725 authors: "J. D. Hoogland".to_string(),
5726 notes: Some("Created.".to_string()),
5727 ai_mark_path: Some("assets/svg/doc_made_with_ai_opt.svg".to_string()),
5728 ai_mark_words: Some("Made with AI".to_string()),
5729 ai_mark_url: Some("https://need2know.ai/with-ai/doc".to_string()),
5730 }],
5731 reading_min: Some(51),
5732 acknowledgement: Some("We acknowledge...".to_string()),
5733 };
5734 let table = build_meta_table(&fm).expect("meta table builds");
5735 assert_eq!(table.rows.len(), 2, "one header row and one revision row");
5736 assert!(table.header, "the first row is the header");
5737 // The author cell carries the declaration mark stacked beneath the name (column index 2: Ver, Date, Author).
5738 assert!(table.rows[1].cells[2].mark.is_some(), "the author cell carries the AI mark");
5739 // The notes cell has the reading time appended to the authored notes.
5740 let notes = match &table.rows[1].cells[3].content[0] {
5741 Segment::Text(t) => t.clone(),
5742 _ => String::new(),
5743 };
5744 assert!(notes.contains("Created.") && notes.contains("Reading time: 51 [min]"),
5745 "the notes cell appends the reading time: {:?}", notes);
5746 }
5747
5748 #[test]
5749 fn docbanner_section_banner_sets_its_heading_inline_not_a_second_opener() -> Outcome<()> {
5750 // A `DocBanner` tree -- its default chapter opener is the grey title bar -- that opts one chapter in
5751 // with an explicit `#section-banner` must set that chapter's title inline beneath the banner, not
5752 // open a second grey bar of its own. The section banner forces one page eject; the heading that
5753 // follows it must add none. Before the fix the heading opened as a chapter and the forced-eject
5754 // count was two, which is the duplicate bar this guards against.
5755 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5756 let geom = PageGeometry::a4();
5757 let mut style = Theme::default();
5758 style.heading.kind = HeadingStyle::DocBanner;
5759 let blocks = vec![
5760 Block::Paragraph { text: "Intro before the section.".to_string() },
5761 Block::section_banner("assets/svg/pearlite_logo_text_right.svg".to_string()),
5762 Block::Heading { level: 1, segments: vec![Segment::text("Pearlite")], label: None },
5763 Block::Paragraph { text: "Pearlite is the format.".to_string() },
5764 ];
5765 let (doc, heads) = res!(author(fonts, geom, &style, &FaceResolver::default(), &blocks, None, None));
5766 assert!(heads.iter().any(|h| h.level == 1 && h.title == "Pearlite" && h.banner),
5767 "the level-1 heading after a #section-banner carries the banner flag");
5768 let forced = doc.nodes.iter()
5769 .filter(|n| matches!(n, Node::Penalty(p) if p.is_forced()))
5770 .count();
5771 assert_eq!(forced, 1,
5772 "only the section banner forces a page break; its heading opens inline, adding none (got {})", forced);
5773 Ok(())
5774 }
5775
5776 /// A [`Block::Scoped`] carrying `#set text(size: 20pt)` sets its own paragraph at 20 pt while a sibling
5777 /// paragraph outside the scope keeps the document's 11 pt -- proving the scope machinery end to end
5778 /// through a real render: the patch reaches the renderer (the scoped line is taller) and does not leak
5779 /// past the scope boundary (the sibling matches an unscoped control exactly). Before this, no test
5780 /// rendered through a scope at all.
5781 #[test]
5782 fn a_scoped_set_styles_its_own_subtree_and_no_further() -> Outcome<()> {
5783 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5784 let geom = PageGeometry::a4();
5785 let style = Theme::default();
5786 let para = || Block::Paragraph { text:
5787 "A paragraph long enough to set at least one full line of body text on the page.".to_string() };
5788
5789 // The tallest and shortest paragraph-line heights in a rendered document.
5790 fn line_heights(doc: &Document) -> (Sp, Sp) {
5791 let hs: Vec<Sp> = doc.nodes.iter().filter_map(|n| match n {
5792 Node::HBox(b) if b.dims.height > Sp::ZERO => Some(b.dims.height),
5793 _ => None,
5794 }).collect();
5795 let max = hs.iter().copied().fold(Sp::ZERO, |a, h| if h > a { h } else { a });
5796 let min = hs.iter().copied().fold(max, |a, h| if h < a { h } else { a });
5797 (min, max)
5798 }
5799
5800 // Chapter A carries `#set text(size: 20pt)`; chapter B (the flat sibling) carries nothing.
5801 let mut scope_patch = ThemePatch::default();
5802 scope_patch.text.body_size = Some(Sp::from_pt(20.0));
5803 let scoped = vec![
5804 Block::Scoped { patch: scope_patch, blocks: vec![para()] },
5805 para(),
5806 ];
5807 let (doc, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &scoped, None, None));
5808 let (min_s, max_s) = line_heights(&doc);
5809
5810 // A control with no scope: both paragraphs at the document's 11 pt, so every line is the same height.
5811 let plain = vec![para(), para()];
5812 let (doc_p, _) = res!(author(fonts, geom, &style, &FaceResolver::default(), &plain, None, None));
5813 let (min_p, max_p) = line_heights(&doc_p);
5814
5815 assert_eq!(min_p, max_p, "the unscoped control must set every paragraph at one size");
5816 assert!(max_s > min_s, "the scoped 20pt paragraph must set taller lines than the 11pt sibling");
5817 // The sibling outside the scope matches the control exactly: the scope did not leak past its blocks.
5818 assert_eq!(min_s, min_p, "the paragraph outside the scope must keep the document's own size");
5819 // And the scoped paragraph really rose above the document size, so the patch reached the renderer.
5820 assert!(max_s > max_p, "the scoped paragraph must exceed the unscoped document size");
5821 Ok(())
5822 }
5823
5824 // Every Fill colour drawn anywhere in a rendered node tree, for a callout-fill assertion.
5825 fn collect_fills(nodes: &[Node], out: &mut Vec<Rgba>) {
5826 for n in nodes {
5827 match n {
5828 Node::HBox(b) | Node::VBox(b) => collect_fills(&b.list, out),
5829 Node::Leaf(l) => if let LeafKind::Graphic(g) = &l.kind {
5830 for op in &g.ops {
5831 if let DrawOp::Fill { colour, .. } = op { out.push(*colour); }
5832 }
5833 },
5834 _ => {},
5835 }
5836 }
5837 }
5838
5839 // The ink width of a set line -- the sum of its children's advances -- which fills to the measure on a
5840 // justified interior line and falls short of it on a ragged one, though the line box is measure-wide
5841 // either way.
5842 fn line_ink_width(line: &[Node]) -> Sp {
5843 let mut w = Sp::ZERO;
5844 for n in line {
5845 w = w + match n {
5846 Node::Leaf(l) => l.dims.width,
5847 Node::Glue(g) => g.natural,
5848 Node::HBox(b) | Node::VBox(b) => b.dims.width,
5849 _ => Sp::ZERO,
5850 };
5851 }
5852 w
5853 }
5854
5855 /// `format_numbering` renders each Typst counting system, keeps literal text, and repeats the last
5856 /// symbol for a hierarchical number -- and a pattern with no counting symbol is a fixed literal.
5857 #[test]
5858 fn numbering_pattern_renders_each_system() {
5859 assert_eq!(format_numbering("1.1", &[2, 3]), "2.3"); // hierarchical: the last symbol repeats
5860 assert_eq!(format_numbering("1.", &[3]), "3.");
5861 assert_eq!(format_numbering("(a)", &[2]), "(b)");
5862 assert_eq!(format_numbering("A", &[27]), "AA");
5863 assert_eq!(format_numbering("I", &[4]), "IV");
5864 assert_eq!(format_numbering("i.", &[9]), "ix.");
5865 assert_eq!(format_numbering("Q", &[3]), "Q"); // no counting symbol: a fixed literal
5866 }
5867
5868 /// A `#set heading(numbering: ...)` pattern reaches the rendered heading number: a level-1 heading set
5869 /// to pattern "A" carries "A", where the untouched default carries the plain arabic "1".
5870 #[test]
5871 fn heading_numbering_pattern_reaches_the_number() -> Outcome<()> {
5872 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5873 let geom = PageGeometry::a4();
5874 let blocks = vec![Block::Heading { level: 1, segments: vec![Segment::text("Alpha")], label: None }];
5875
5876 let (_, heads_d) = res!(author(fonts.clone(), geom, &Theme::default(), &FaceResolver::default(), &blocks, None, None));
5877 assert_eq!(heads_d[0].number, "1", "the default heading number is the plain arabic count");
5878
5879 let mut alpha = Theme::default();
5880 for l in &mut alpha.heading.levels { l.numbering = Some("A".to_string()); }
5881 let (_, heads_a) = res!(author(fonts, geom, &alpha, &FaceResolver::default(), &blocks, None, None));
5882 assert_eq!(heads_a[0].number, "A", "a heading numbering pattern must reach the rendered number");
5883 Ok(())
5884 }
5885
5886 /// A heading inside a scope is counted in document order, and a cross-reference into that scope resolves
5887 /// its number: `[Scoped{[H1 "A" <a>]}, H1 "B", @a]` numbers the headings 1 and 2 and resolves `@a` to
5888 /// "Chapter 1". Before the reference pre-pass recursed into a `Block::Scoped`, the scoped heading was
5889 /// invisible to it -- `@a` fell back to a page number and the top-level "B" would have taken "Chapter 1"
5890 /// -- so this fixture would have caught that blindness.
5891 #[test]
5892 fn cross_reference_into_a_scope_resolves_the_scoped_heading_number() -> Outcome<()> {
5893 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5894 let geom = PageGeometry::a4();
5895 let style = Theme::default(); // BookOpener: headings carry a document-order number
5896 let heading = |t: &str, label: Option<&str>| Block::Heading {
5897 level: 1,
5898 segments: vec![Segment::text(t)],
5899 label: label.map(|s| s.to_string()),
5900 };
5901 let blocks = vec![
5902 Block::Scoped { patch: ThemePatch::default(), blocks: vec![heading("A", Some("a"))] },
5903 heading("B", None),
5904 Block::RichParagraph { segments: vec![Segment::page_ref("a".to_string())] },
5905 ];
5906
5907 // The reference pre-pass sees the heading inside the scope: `@a` resolves to "Chapter 1", the number
5908 // that heading actually takes -- not a page-number fallback, and not the top-level count.
5909 let refs = ref_targets(&blocks, &style);
5910 assert_eq!(refs.get("a").map(String::as_str), Some("Chapter 1"),
5911 "a cross-reference into a scope must resolve the scoped heading's own number");
5912
5913 // And the headings number 1, 2 in document order across the scope boundary.
5914 let (_, heads) = res!(author(fonts, geom, &style, &FaceResolver::default(), &blocks, None, None));
5915 let nums: Vec<&str> = heads.iter().map(|h| h.number.as_str()).collect();
5916 assert_eq!(nums, vec!["1", "2"], "headings number in document order across a scope edge");
5917 Ok(())
5918 }
5919
5920 /// A sub-heading alone in a scope still keeps with the sibling paragraph beyond the scope's closing edge:
5921 /// `[P, Scoped{[H2]}, P]` sets an identical node stream to the flat `[P, H2, P]`, the heading's first
5922 /// paragraph line joining its keep box either way. This is the "keep with next" gate a per-element
5923 /// heading rule (which wraps each heading in its own scope) relies on to stay byte-identical.
5924 #[test]
5925 fn a_scoped_heading_keeps_with_the_paragraph_beyond_the_scope() -> Outcome<()> {
5926 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5927 let geom = PageGeometry::a4();
5928 let style = Theme::default();
5929 let para = |t: &str| Block::Paragraph { text: t.to_string() };
5930 let h2 = || Block::Heading { level: 2, segments: vec![Segment::text("A Section")], label: None };
5931 let body = "A paragraph long enough to set at least one full line of body text on the page, \
5932 and then a little more to be sure it wraps onto a second line.";
5933
5934 // A signature of the node stream: each node's kind tag and its vertical extent, which together fix
5935 // where the heading keep box sits and how the following paragraph's lines are placed.
5936 fn sig(doc: &Document) -> Vec<(u8, i32)> {
5937 doc.nodes.iter().map(|n| {
5938 let tag = match n {
5939 Node::HBox(_) => 0u8,
5940 Node::VBox(_) => 1,
5941 Node::Glue(_) => 2,
5942 Node::Penalty(_) => 3,
5943 _ => 9,
5944 };
5945 (tag, n.vextent().raw())
5946 }).collect()
5947 }
5948
5949 let flat = vec![para("Intro."), h2(), para(body)];
5950 let (doc_flat, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &flat, None, None));
5951
5952 let wrapped = vec![
5953 para("Intro."),
5954 Block::Scoped { patch: ThemePatch::default(), blocks: vec![h2()] },
5955 para(body),
5956 ];
5957 let (doc_wrap, _) = res!(author(fonts, geom, &style, &FaceResolver::default(), &wrapped, None, None));
5958
5959 assert_eq!(sig(&doc_flat), sig(&doc_wrap),
5960 "a heading alone in a scope must keep with the sibling paragraph beyond it, exactly as the flat pairing does");
5961 Ok(())
5962 }
5963
5964 /// `#set par(justify: false)` reaches the renderer: a justified paragraph fills its first (interior)
5965 /// line to the measure, an unjustified one leaves it ragged, short of the measure.
5966 #[test]
5967 fn par_justify_false_leaves_lines_ragged() -> Outcome<()> {
5968 let fonts = Arc::new(res!(crate::fonts::libertinus()));
5969 let geom = PageGeometry::a4();
5970 let measure = geom.content_width();
5971 let blocks = vec![Block::Paragraph { text:
5972 "A paragraph written long enough that it must wrap onto at least two lines, so its first line is \
5973an interior line justification fills to the measure while ragged setting does not.".to_string() }];
5974
5975 // The ink width of the paragraph's first (interior) line: filled to the measure when justified,
5976 // short of it when ragged.
5977 fn first_line_ink(doc: &Document) -> Sp {
5978 doc.nodes.iter().find_map(|n| match n {
5979 Node::HBox(b) if b.dims.height > Sp::ZERO => Some(line_ink_width(&b.list)),
5980 _ => None,
5981 }).unwrap_or(Sp::ZERO)
5982 }
5983
5984 let (dj, _) = res!(author(fonts.clone(), geom, &Theme::default(), &FaceResolver::default(), &blocks, None, None));
5985 let mut ragged = Theme::default();
5986 ragged.text.justify = false;
5987 let (dr, _) = res!(author(fonts, geom, &ragged, &FaceResolver::default(), &blocks, None, None));
5988
5989 // The justified interior line fills to the measure; the ragged one leaves its slack on the right.
5990 assert!(first_line_ink(&dj) > first_line_ink(&dr),
5991 "a justified line fills more of the measure than a ragged one ({:?} vs {:?})",
5992 first_line_ink(&dj), first_line_ink(&dr));
5993 assert!(first_line_ink(&dj) >= measure - Sp::from_pt(1.0),
5994 "a justified interior line reaches the measure");
5995 Ok(())
5996 }
5997
5998 /// `#set text(hyphenate: false)` reaches the renderer: a long word set on a narrow page breaks across
5999 /// lines when hyphenation is on and stays whole -- fewer lines -- when it is off.
6000 #[test]
6001 fn text_hyphenate_false_stops_word_breaking() -> Outcome<()> {
6002 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6003 // A narrow page, so a long word must hyphenate to fit; `content_width` here is ~24pt.
6004 let geom = PageGeometry::new(Sp::from_pt(90.0), Sp::from_pt(400.0), Sp::from_pt(33.0));
6005 let blocks = vec![Block::Paragraph { text:
6006 "antidisestablishmentarianism antidisestablishmentarianism".to_string() }];
6007
6008 fn line_count(doc: &Document) -> usize {
6009 doc.nodes.iter().filter(|n| matches!(n, Node::HBox(b) if b.dims.height > Sp::ZERO)).count()
6010 }
6011
6012 let (on, _) = res!(author(fonts.clone(), geom, &Theme::default(), &FaceResolver::default(), &blocks, None, None));
6013 let mut no_hyph = Theme::default();
6014 no_hyph.text.hyphenate = false;
6015 let (off, _) = res!(author(fonts, geom, &no_hyph, &FaceResolver::default(), &blocks, None, None));
6016
6017 assert!(line_count(&on) > line_count(&off),
6018 "hyphenation on must split the long words into more lines than off ({} vs {})",
6019 line_count(&on), line_count(&off));
6020 Ok(())
6021 }
6022
6023 /// A `#set enum(numbering: ...)` pattern reaches the ordered-list marker: setting `"(a)"` shapes a
6024 /// different first marker from the default `"1."`, so the marker glyph really came from the theme.
6025 #[test]
6026 fn enum_numbering_changes_the_marker() -> Outcome<()> {
6027 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6028 let geom = PageGeometry::a4();
6029 let items = || vec![
6030 ListEntry { segments: vec![Segment::text("First item.")], children: vec![] },
6031 ListEntry { segments: vec![Segment::text("Second item.")], children: vec![] },
6032 ];
6033 let blocks = vec![Block::list(true, items(), false)];
6034
6035 // The width of the first list item's leading marker leaf.
6036 fn first_marker_width(doc: &Document) -> Sp {
6037 for n in &doc.nodes {
6038 if let Node::HBox(b) = n {
6039 if let Some(Node::Leaf(l)) = b.list.first() {
6040 return l.dims.width;
6041 }
6042 }
6043 }
6044 Sp::ZERO
6045 }
6046
6047 let (dd, _) = res!(author(fonts.clone(), geom, &Theme::default(), &FaceResolver::default(), &blocks, None, None));
6048 let mut alpha = Theme::default();
6049 alpha.enumeration.numbering = Some("(a)".to_string());
6050 let (da, _) = res!(author(fonts, geom, &alpha, &FaceResolver::default(), &blocks, None, None));
6051
6052 assert!(first_marker_width(&dd) > Sp::ZERO, "the default ordered marker has width");
6053 assert_ne!(first_marker_width(&dd), first_marker_width(&da),
6054 "a `#set enum(numbering: \"(a)\")` must change the rendered marker from the default \"1.\"");
6055 Ok(())
6056 }
6057
6058 /// The `code`, `figure` and `callout` theme groups reach the renderer: a code block's mono size, a
6059 /// drawn figure's caption size and a callout's wash are each taken from the theme, so nudging them off
6060 /// their defaults visibly changes the render.
6061 #[test]
6062 fn code_caption_and_callout_read_the_theme() -> Outcome<()> {
6063 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6064 let geom = PageGeometry::a4();
6065 let faces = FaceResolver::default();
6066
6067 // Code size: a taller `code.size` sets taller code lines.
6068 let code_blocks = vec![Block::Code { lines: vec!["let x = 1;".to_string()] }];
6069 let (cd, _) = res!(author(fonts.clone(), geom, &Theme::default(), &faces, &code_blocks, None, None));
6070 let mut big_code = Theme::default();
6071 big_code.code.size = Sp::from_pt(20.0);
6072 let (cb, _) = res!(author(fonts.clone(), geom, &big_code, &faces, &code_blocks, None, None));
6073 let tallest = |doc: &Document| doc.nodes.iter().filter_map(|n| match n {
6074 Node::HBox(b) if b.dims.height > Sp::ZERO => Some(b.dims.height), _ => None,
6075 }).fold(Sp::ZERO, |a, h| if h > a { h } else { a });
6076 assert!(tallest(&cb) > tallest(&cd), "a larger code.size must set taller code lines");
6077
6078 // Callout fill: the wash colour is the theme's `callout.fill`.
6079 let box_blocks = vec![Block::box_callout(
6080 vec![Block::Paragraph { text: "Inside a callout.".to_string() }], ThemePatch::default())];
6081 let mut red = Theme::default();
6082 red.callout.fill = Rgba::opaque(200, 20, 20);
6083 let (bd, _) = res!(author(fonts, geom, &red, &faces, &box_blocks, None, None));
6084 let mut fills = Vec::new();
6085 collect_fills(&bd.nodes, &mut fills);
6086 assert!(fills.contains(&Rgba::opaque(200, 20, 20)),
6087 "the callout wash must be the theme's callout.fill, not a hard-coded colour: {:?}", fills);
6088 Ok(())
6089 }
6090
6091 /// The face resolver reaches a named display face and its weight/slant variants: a theme naming a face
6092 /// the crate `fonts/` dir ships resolves to a Solo display face (not a role), shaping differently from
6093 /// the body Bold role; and asking a level for `weight: bold` resolves to the Bold file, distinct from
6094 /// the Regular. A name with no file falls to the role, unchanged.
6095 #[test]
6096 fn resolver_reaches_named_face_and_its_weight_variants() -> Outcome<()> {
6097 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6098 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
6099 let faces = FaceResolver::load(&dir, &["LibertinusSerif".to_string()]);
6100 let size = Sp::from_pt(16.0);
6101 let text = "Chapter Heading";
6102
6103 let mut style = Theme::default();
6104 style.heading.face = Some("LibertinusSerif".to_string());
6105
6106 // A resolvable heading face resolves to a Solo display face, and shapes differently from the body
6107 // bold role a book falls to when no display face resolves.
6108 let regular = resolved_head_face(2, &style, &faces, false);
6109 assert!(matches!(regular, HeadFace::Solo(_)), "a resolvable face must resolve to a Solo display face");
6110 let solo_w = res!(head_shape(&fonts, &regular, size, text)).dims().width;
6111 let bold_role_w = res!(head_shape(&fonts, &HeadFace::Role(Role::Bold), size, text)).dims().width;
6112 assert_ne!(solo_w, bold_role_w, "the resolved display face must shape differently from the body bold");
6113
6114 // A level asking for bold resolves to the Bold file, distinct in width from the Regular Solo.
6115 let mut bold_style = style.clone();
6116 bold_style.heading.levels[1].weight = Some(700);
6117 let bold = resolved_head_face(2, &bold_style, &faces, false);
6118 let bold_w = res!(head_shape(&fonts, &bold, size, text)).dims().width;
6119 assert_ne!(solo_w, bold_w, "weight: bold must resolve to the Bold file, not the Regular");
6120
6121 // A name with no file resolves nothing, so the heading falls to the role as before.
6122 let mut absent = Theme::default();
6123 absent.heading.face = Some("NoSuchDisplayFace".to_string());
6124 assert!(matches!(resolved_head_face(2, &absent, &faces, false), HeadFace::Role(_)),
6125 "an unresolvable face must fall to a role face");
6126 Ok(())
6127 }
6128
6129 /// A margin note's anchor is zero flow extent: the body it sits in sets byte-identically to the same body
6130 /// without it. The driver's frames (which carry no marginalia -- that is `decorate`'s overlay) must match
6131 /// placement for placement whether or not a `#claim-label` splits the paragraph, so the anchor cannot
6132 /// perturb a line or page break. This is the unit-level shadow of the oracle's byte-identity gate.
6133 #[test]
6134 fn margin_anchor_is_zero_extent_body_is_byte_identical() -> Outcome<()> {
6135 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6136 let geom = PageGeometry::a4();
6137 let style = Theme::default();
6138 let body = "The claim sits amid a paragraph long enough to wrap across several justified \
6139 lines so a zero-width anchor woven into it has every chance to shift a break if it were \
6140 not truly weightless, which is exactly what must not happen.";
6141 // Same body, but a `#claim-label` splits it after the third word -- the anchor lands mid-line.
6142 let plain = vec![Block::rich(vec![Segment::text(body)])];
6143 let (head, tail) = body.split_at(body.find("amid").unwrap_or(0) + 4);
6144 let noted = vec![Block::rich(vec![
6145 Segment::text(head), Segment::margin_note("A1", vec!["A1".to_string()]), Segment::text(tail)])];
6146
6147 let metrics = crate::font::FontMetrics::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size);
6148 let (doc_p, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &plain, None, None));
6149 let (doc_n, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &noted, None, None));
6150 let out_p = res!(crate::driver::run(&doc_p, &metrics, crate::driver::Config::default()));
6151 let out_n = res!(crate::driver::run(&doc_n, &metrics, crate::driver::Config::default()));
6152
6153 assert_eq!(out_p.pages.len(), out_n.pages.len(), "the anchor must not change the page count");
6154 for (pp, pn) in out_p.pages.iter().zip(out_n.pages.iter()) {
6155 assert_eq!(pp.frame.placed.len(), pn.frame.placed.len(),
6156 "the anchor draws no body ink, so the frames must carry the same number of placed boxes");
6157 for (a, b) in pp.frame.placed.iter().zip(pn.frame.placed.iter()) {
6158 assert_eq!((a.x, a.y), (b.x, b.y),
6159 "every body box must land where it did without the anchor");
6160 }
6161 }
6162 // The noted run recorded exactly one margin anchor; the plain run recorded none.
6163 let count = |o: &crate::driver::CompileOutput| o.ledger.anchors()
6164 .filter(|a| a.id.kind == crate::ledger::AnchorKind::MarginNote).count();
6165 assert_eq!(count(&out_n), 1, "the claim label records one margin anchor");
6166 assert_eq!(count(&out_p), 0, "the plain body records none");
6167 Ok(())
6168 }
6169
6170 /// A body cross-reference slot (keyed `ref-N`) and a reverse-claim-index folio slot must not collide in
6171 /// the ledger's by-id map: the claim index keys its slots `claim-slot-N`, distinct from `ref_slot`'s
6172 /// `ref-N`, so a document carrying both an unresolved `#pageref`-style slot AND a claim index records
6173 /// both anchors rather than one silently overwriting the other (a lost slot, an under-converged folio).
6174 #[test]
6175 fn claim_index_slots_do_not_collide_with_body_pagerefs_in_the_ledger() -> Outcome<()> {
6176 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6177 let geom = PageGeometry::a4();
6178 let style = Theme::default();
6179 let blocks = vec![
6180 Block::heading(1, "Body"),
6181 // An unresolved cross-reference (its label names no target, so it falls back to a `ref-1` slot) and
6182 // a claim reference (gathered into the index) in the same body paragraph.
6183 Block::rich(vec![
6184 Segment::text("See "),
6185 Segment::page_ref("undefined-target"),
6186 Segment::text(" while claim "),
6187 Segment::margin_note("", vec!["X1".to_string()]),
6188 Segment::text(" is referenced here."),
6189 ]),
6190 Block::ClaimIndex,
6191 ];
6192 let metrics = crate::font::FontMetrics::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size);
6193 let (doc, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &blocks, None, None));
6194 let out = res!(crate::driver::run(&doc, &metrics, crate::driver::Config::default()));
6195 let keys: Vec<String> = out.ledger.anchors().map(|a| a.id.key.clone()).collect();
6196 assert!(keys.iter().any(|k| k == "ref-1"),
6197 "the body cross-reference records its own ref-1 slot: {:?}", keys);
6198 assert!(keys.iter().any(|k| k == "claim-slot-1"),
6199 "the claim index records a distinct claim-slot-1 slot, not colliding with ref-1: {:?}", keys);
6200 Ok(())
6201 }
6202
6203 /// The overlay pass draws a recorded margin note in the outside margin -- flush against the block's right
6204 /// edge on a recto page, and against its left edge on a verso page (which the frame mirror then carries to
6205 /// the fore-edge) -- at the corpus's 6.5 pt, and never inside the text block.
6206 #[test]
6207 fn draw_marginalia_places_the_code_in_the_outside_margin_both_sides() -> Outcome<()> {
6208 use crate::ledger::{Anchor, Position}; // AnchorId, AnchorKind, Ledger are already in scope
6209
6210 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6211 let geom = PageGeometry::a4();
6212 let style = Theme::default();
6213 let cl = geom.content_left();
6214 let cw = geom.content_width();
6215
6216 // One ledger, the same code recorded on a recto (page 1) and a verso (page 2) at a mid-block y.
6217 let y = geom.content_top() + Sp::from_pt(120.0);
6218 let mut ledger = Ledger::new();
6219 ledger.record(Anchor::new(AnchorId::new(AnchorKind::MarginNote, "1\u{1f}A1"), Position::new(1, cl, y)));
6220 ledger.record(Anchor::new(AnchorId::new(AnchorKind::MarginNote, "2\u{1f}B4"), Position::new(2, cl, y)));
6221
6222 // Recto (page 1): the code's left edge seats at the block's right edge, in the outer margin.
6223 let mut recto = Page::new(1, geom, Frame::new());
6224 res!(draw_marginalia(&mut recto, &ledger, &fonts, &style, geom));
6225 let rp = recto.frame.placed.iter().find(|p| matches!(p.kind, PlacedKind::Text(_)))
6226 .ok_or_else(|| err!("the recto margin note was not drawn"; Test, Missing))?;
6227 assert_eq!(rp.x, cl + cw, "a recto note's left edge sits at the block's right (outer) edge");
6228 assert!(rp.dims.height.raw() > 0, "the note has real shaped extent");
6229
6230 // Verso (page 2): the code's right edge seats at the block's left edge; the frame mirror carries it out.
6231 let mut verso = Page::new(2, geom, Frame::new());
6232 res!(draw_marginalia(&mut verso, &ledger, &fonts, &style, geom));
6233 let vp = verso.frame.placed.iter().find(|p| matches!(p.kind, PlacedKind::Text(_)))
6234 .ok_or_else(|| err!("the verso margin note was not drawn"; Test, Missing))?;
6235 assert_eq!(vp.x + vp.dims.width, cl, "a verso note's right edge sits at the block's left edge");
6236 Ok(())
6237 }
6238
6239 /// `measure_blocks` sizes a block flow without placing it: a paragraph measures a positive height at the
6240 /// measure width, an empty flow measures zero, and the same blocks measure the same height twice.
6241 #[test]
6242 fn measure_blocks_sizes_a_flow() -> Outcome<()> {
6243 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6244 let geom = PageGeometry::a4();
6245 let style = Theme::default();
6246 let measure = geom.content_width();
6247 let refs = HashMap::new();
6248
6249 let blocks = vec![Block::rich(vec![Segment::text(
6250 "A paragraph with enough words to wrap onto at least a second line at this measure.")])];
6251 let d1 = res!(measure_blocks(fonts.clone(), geom, &style, measure, &blocks, None, &refs));
6252 let d2 = res!(measure_blocks(fonts.clone(), geom, &style, measure, &blocks, None, &refs));
6253 assert_eq!(d1.width, measure, "the measured width is the measure it was set at");
6254 assert!(d1.height.raw() > 0, "a real paragraph has positive height");
6255 assert_eq!(d1.height, d2.height, "measuring is deterministic");
6256
6257 let empty = res!(measure_blocks(fonts, geom, &style, measure, &[], None, &refs));
6258 assert_eq!(empty.height, Sp::ZERO, "an empty flow measures zero height");
6259 Ok(())
6260 }
6261
6262 /// A line-leading `#pagebreak()` set directly beneath a prose line (no blank line parting the two) forces
6263 /// a new page, exactly as Typst 0.15.1 does -- proved at the RENDER level by the page count, not the parse
6264 /// tree. Before the fix the parser dropped a builtin whenever a paragraph was open, so `prose\n#pagebreak()`
6265 /// silently lost the break and the closing prose backfilled page one; a bare heading before the break
6266 /// escaped only because a heading line does not open a paragraph, which is the asymmetry the live drive saw
6267 /// (and why `#section-banner`, a distinct capture kind, always turned the page after prose). The gate is
6268 /// self-non-vacuous: the SAME source with the `#pagebreak()` line removed lays one page, so the second page
6269 /// is the break's own work -- revert the parser fix and the "with break" render collapses to that one page,
6270 /// failing this test rather than passing silently. Typst renders both bodies below at two pages (checked
6271 /// against `typst` 0.15.1: `= H\n\npara\n#pagebreak()\npara` and `para\n#pagebreak()\npara` each give 2).
6272 #[test]
6273 fn pagebreak_beneath_prose_forces_a_new_page() -> Outcome<()> {
6274 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6275 let geom = PageGeometry::a4();
6276 let style = Theme::default();
6277 let metrics = crate::font::FontMetrics::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size);
6278
6279 let pages = |src: &str| -> Outcome<usize> {
6280 let blocks = res!(crate::lang::to_blocks(src));
6281 let (d, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &blocks, None, None));
6282 let o = res!(crate::driver::run(&d, &metrics, crate::driver::Config::default()));
6283 Ok(o.pages.len())
6284 };
6285
6286 // Case A -- a bare heading, then a paragraph, then the break directly beneath that paragraph, then
6287 // closing prose (plain, so no heading eject of its own can mask the break's work).
6288 let with_a = "= A Heading\n\nAn opening paragraph before the forced break.\n#pagebreak()\nA closing paragraph after the break.\n";
6289 let sans_a = "= A Heading\n\nAn opening paragraph before the forced break.\nA closing paragraph after the break.\n";
6290 assert_eq!(res!(pages(with_a)), 2, "heading + paragraph + adjacent #pagebreak must lay two pages (Typst renders two)");
6291 assert_eq!(res!(pages(sans_a)), 1, "the same body without the break lays one page: the second page is the break's own work");
6292
6293 // Case B -- only a paragraph precedes the break (no heading anywhere), the case the live drive reported
6294 // as collapsing to a single page.
6295 let with_b = "An opening paragraph before the forced break.\n#pagebreak()\nA closing paragraph after the break.\n";
6296 let sans_b = "An opening paragraph before the forced break.\nA closing paragraph after the break.\n";
6297 assert_eq!(res!(pages(with_b)), 2, "paragraph + adjacent #pagebreak must lay two pages (Typst renders two)");
6298 assert_eq!(res!(pages(sans_b)), 1, "the same body without the break lays one page: the break turned it");
6299 Ok(())
6300 }
6301
6302 /// The strong/weak distinction on `#pagebreak()`, proved at the RENDER level by the page count through the
6303 /// real `to_blocks -> author -> driver::run` pipeline, against `typst` 0.15.1 (each source below was
6304 /// compiled with the installed typst and its page count read from the PDF): a TRAILING strong `#pagebreak()`
6305 /// opens a blank final page (2), a TRAILING `#pagebreak(weak: true)` opens none (1), and TWO strong breaks in
6306 /// a row open two blank pages (3). The gate is self-non-vacuous: revert the strong-eject branch in
6307 /// [`crate::driver`] (make a strong break drop on an empty page, as a weak one does) and every strong count
6308 /// below collapses -- the trailing strong break falls to 1 and the consecutive pair to 1 -- reddening here.
6309 #[test]
6310 fn strong_and_weak_pagebreaks_lay_the_typst_page_counts() -> Outcome<()> {
6311 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6312 let geom = PageGeometry::a4();
6313 let style = Theme::default();
6314 let metrics = crate::font::FontMetrics::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size);
6315
6316 let pages = |src: &str| -> Outcome<usize> {
6317 let blocks = res!(crate::lang::to_blocks(src));
6318 let (d, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &blocks, None, None));
6319 let o = res!(crate::driver::run(&d, &metrics, crate::driver::Config::default()));
6320 Ok(o.pages.len())
6321 };
6322
6323 // A trailing strong break opens a blank second page (Typst: 2); the same document with the break removed
6324 // is one page, so the second page is the strong break's own work.
6325 assert_eq!(res!(pages("Hello\n\n#pagebreak()\n")), 2,
6326 "a trailing strong #pagebreak() opens a blank final page (Typst renders two)");
6327 assert_eq!(res!(pages("Hello\n")), 1, "the same body without the break is one page");
6328 // A trailing WEAK break opens no blank page (Typst: 1) -- the pre-existing drop-on-empty-page behaviour.
6329 assert_eq!(res!(pages("Hello\n\n#pagebreak(weak: true)\n")), 1,
6330 "a trailing #pagebreak(weak: true) opens no blank page (Typst renders one)");
6331 // Two strong breaks in a row: the first ejects the content page, the second ejects the now-empty page,
6332 // and the trailing open page is blank too (Typst: 3).
6333 assert_eq!(res!(pages("Hello\n\n#pagebreak()\n#pagebreak()\n")), 3,
6334 "two consecutive strong #pagebreak() lay three pages (Typst renders three)");
6335 Ok(())
6336 }
6337
6338 /// A line-leading `#v(50pt)` set directly beneath a prose line shifts the content after it down by exactly
6339 /// 50 pt, matching Typst 0.15.1 -- proved at the RENDER level by a placed item's y coordinate, not the
6340 /// parse tree. Before the fix the parser dropped the builtin whenever a paragraph was open, so an explicit
6341 /// vertical space directly beneath prose vanished and the following block set byte-identically to a document
6342 /// with no `#v()` at all. The gate is self-non-vacuous: the same source with the `#v(50pt)` line removed
6343 /// leaves the following paragraph at its unshifted y, so the 50 pt delta is the space's own work -- revert
6344 /// the parser fix and the delta falls to zero, failing this test.
6345 #[test]
6346 fn explicit_v_space_shifts_following_content() -> Outcome<()> {
6347 let fonts = Arc::new(res!(crate::fonts::libertinus()));
6348 let geom = PageGeometry::a4();
6349 let style = Theme::default();
6350 let metrics = crate::font::FontMetrics::new(fonts.clone(), Role::Body, Dir::Ltr, style.text.body_size);
6351
6352 // The y of the first line of the second paragraph -- the first content element beneath the space. The
6353 // first paragraph sets one line (its words share a y); the second paragraph is the first content set
6354 // lower, so its line y is the smallest placed y strictly greater than the first line's.
6355 let second_para_y = |src: &str| -> Outcome<Sp> {
6356 let blocks = res!(crate::lang::to_blocks(src));
6357 let (d, _) = res!(author(fonts.clone(), geom, &style, &FaceResolver::default(), &blocks, None, None));
6358 let o = res!(crate::driver::run(&d, &metrics, crate::driver::Config::default()));
6359 let p = res!(o.pages.first().ok_or_else(|| err!("no page was laid"; Missing)));
6360 let first = res!(p.frame.placed.first().ok_or_else(|| err!("no content was placed"; Missing))).y;
6361 let below = p.frame.placed.iter().map(|pl| pl.y).filter(|&y| y > first).min();
6362 Ok(res!(below.ok_or_else(|| err!("expected a second line of content below the first"; Missing))))
6363 };
6364
6365 // `with_v` sets the space directly beneath the first paragraph (no blank line -- the adjacency the fix
6366 // restores); `sans_v` parts the two paragraphs with a blank line instead, so both lay the same two
6367 // paragraphs and differ only by the 50 pt the `#v()` adds. The blank-line control avoids merging the two
6368 // lines into one wrapped paragraph, which a bare newline between them would do.
6369 let with_v = second_para_y("First paragraph.\n#v(50pt)\nSecond paragraph after the vertical space.\n");
6370 let sans_v = second_para_y("First paragraph.\n\nSecond paragraph after the vertical space.\n");
6371 let (with_v, sans_v) = (res!(with_v), res!(sans_v));
6372 assert_eq!(with_v - sans_v, Sp::from_pt(50.0),
6373 "the content after #v(50pt) must sit exactly 50pt lower than without it (Typst shifts by the same); got {:?}", with_v - sans_v);
6374 Ok(())
6375 }
6376}