Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/book.rs

143 KiB, 743 runs

created by r1870400018:36653, 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//! Whole-book assembly: follow a Typst root's `#include` chain into one ordered block stream, and read
2//! the concrete production values the book's `config.typ` selects.
3//!
4//! A book is not one source file. Its root (`oxpecker.typ`, `lucronics.typ`) sets the page through a
5//! template call and then pulls each chapter in with `#include "chap.typ"`; the geometry and type are
6//! chosen in `config.typ` by a `format` switch. The reader ([`lang::to_blocks`](crate::lang)) sets one
7//! file and skips code lines, so the include-following and the config-reading live here, above it: this
8//! module resolves the includes in document order, feeds each chapter through the reader, and reads the
9//! one branch of `config.typ` the book's `format` selects into a [`PageGeometry`] and [`Style`].
10//!
11//! This is targeted extraction, not a Typst evaluator. It reads the concrete fields these books define
12//! -- page size, mirror margins, body and heading type -- from the arm the `format` string picks, and
13//! nothing more. A field a book does not set keeps the engine default.
14//!
15//! Two root idioms are recognised. A *book* root (the elearnity manuscripts) carries a `config.typ`
16//! beside it and selects its geometry and type from a `format` switch there. A *doc-template* root (the
17//! oxedyne documentation trees -- the Hematite guide, the Austenite design) has no `config.typ`: it takes
18//! its A4 page and 2.5 cm margins from the shared `template.typ` and its body size from the `#show:
19//! doc.with(...)` call. The presence of `config.typ` picks the path; both follow the root's includes the
20//! same way, and both degrade to a readable A4 default rather than failing on a field they cannot find.
21
22use crate::bib::{
23 Bibliography,
24 RefStyle,
25};
26use crate::doc::{
27 Block,
28 FrontMatter,
29 HeadingStyle,
30 Segment,
31};
32use crate::theme::{
33 Theme,
34 ThemePatch,
35};
36use crate::fonts;
37use crate::fonts::FaceResolver;
38use crate::ir::Sp;
39use crate::lang::parse::flatten_markup;
40use crate::lang;
41use crate::page::PageGeometry;
42use crate::table::{
43 Align,
44 Cell,
45 Row,
46 Table,
47};
48use crate::vfs;
49
50use oxedyne_fe2o3_core::prelude::*;
51use oxedyne_fe2o3_font::set::FontSet;
52
53use std::collections::HashMap;
54use std::collections::HashSet;
55use std::path::{
56 Path,
57 PathBuf,
58};
59use std::sync::Arc;
60use std::sync::RwLock;
61
62/// The book's `term-defs`, read from a sibling `terms.typ` and installed before assembly, so
63/// [`resolve_glossary`] can fill a `#print-glossary()` placeholder with each used term's definition. A
64/// process-global, mirroring the term-dictionary the parser installs: the glossary table is built once
65/// the whole document's blocks are assembled, from a map set at the same point the term-dictionary is.
66/// `None` until the loader installs one, under which no term carries a definition and the glossary is
67/// its header row alone. The value is already the lowered definition runs, parsed from the content once.
68static TERM_DEFS: RwLock<Option<HashMap<String, Vec<Segment>>>> = RwLock::new(None);
69
70const MM_PER_PT: f64 = 72.0 / 25.4; // points in one millimetre
71
72/// A whole book, assembled and ready to author: the block stream in document order, the page geometry
73/// and type style read from its config, and the faces loaded by path from its assets.
74pub struct BookSpec {
75 pub geom: PageGeometry,
76 pub style: Theme,
77 pub fonts: Arc<FontSet>,
78 pub blocks: Vec<Block>,
79 pub title: String, // the book title, for the verso running head
80 // The document's named heading display faces (Radley for a book, the doc template's `heading-font`
81 // for a doc tree), each loaded by path from the book's assets and resolved by the renderer from the
82 // theme's face names; empty when the document names only its body family, so headings fall to the body.
83 pub faces: FaceResolver,
84 pub front: FrontMatter, // the title page, cover and imprint, read from the root's template call
85 // The bibliography, parsed from the file the root names and marked with the keys the body cited, so
86 // the block layer resolves an in-text `#cite` against it. None when the book names no bibliography.
87 pub bib: Option<Bibliography>,
88 // The constructs the reader skipped across every chapter, merged into one tally, so the binary reports
89 // a book or doc compile's skipped constructs on the same terse line a lone file already prints.
90 pub skips: lang::Refusals,
91}
92
93/// Does this source read as a book root -- a Typst file that assembles chapters through `#include`?
94/// A single manuscript has none, so the binary can tell a book from a lone file by the source itself.
95pub fn is_book_root(src: &str) -> bool {
96 src.lines().any(|l| l.trim_start().starts_with("#include"))
97}
98
99/// The heading display-face names a theme carries, for the resolver to load: the role-default heading
100/// face and every per-level face, deduplicated in first-seen order.
101pub fn heading_face_names(theme: &Theme) -> Vec<String> {
102 let mut names: Vec<String> = Vec::new();
103 if let Some(n) = &theme.heading.face {
104 names.push(n.clone());
105 }
106 for l in &theme.heading.levels {
107 if let Some(n) = &l.face {
108 if !names.contains(n) {
109 names.push(n.clone());
110 }
111 }
112 }
113 names
114}
115
116/// Every heading display-face name reachable in the document: the root theme's, and every name a scoped
117/// or box subtree's patch introduces, deduplicated in first-seen order. A rule or an included chapter may
118/// name a face the root does not, so the resolver is built from this union rather than the root alone --
119/// otherwise a scoped face could never resolve and would fall silently to the body role.
120pub fn all_face_names(root_theme: &Theme, blocks: &[Block]) -> Vec<String> {
121 let mut names = heading_face_names(root_theme);
122 collect_patch_face_names(blocks, &mut names);
123 names
124}
125
126/// Adds every heading face name a scoped or box subtree's patch names, descending through nested subtrees.
127fn collect_patch_face_names(blocks: &[Block], out: &mut Vec<String>) {
128 for b in blocks {
129 match b {
130 Block::Scoped { patch, blocks } => { patch_face_names(patch, out); collect_patch_face_names(blocks, out); },
131 Block::Box { patch, blocks, .. } => { patch_face_names(patch, out); collect_patch_face_names(blocks, out); },
132 Block::Place { blocks, .. } => collect_patch_face_names(blocks, out),
133 _ => {},
134 }
135 }
136}
137
138/// The heading face names a single patch introduces -- its role-default heading face and any per-level
139/// face it sets to a name -- appended if not already present.
140fn patch_face_names(patch: &crate::theme::ThemePatch, out: &mut Vec<String>) {
141 if let Some(Some(n)) = &patch.heading.face {
142 if !n.is_empty() && !out.contains(n) { out.push(n.clone()); }
143 }
144 if let Some(Some(n)) = &patch.heading.face_all {
145 if !n.is_empty() && !out.contains(n) { out.push(n.clone()); }
146 }
147 for l in &patch.heading.levels {
148 if let Some(Some(n)) = &l.face {
149 if !n.is_empty() && !out.contains(n) { out.push(n.clone()); }
150 }
151 }
152}
153
154/// Every font family the document names itself, for the missing-family precheck
155/// ([`FaceResolver::require`]): the body family lists its `text(font: ...)` sets name, at the root and in
156/// every scope, and the heading faces it names per level (a `doc.with(heading-font:)` argument, a
157/// `#show heading: set text(font:)` rule). The root theme's role-default heading face is left out: it is
158/// the idiom's own preference (the book idiom's Radley, used where the tree ships it), not a family the
159/// author wrote, so its absence is the ordinary fall-back to the body role rather than an error.
160pub fn named_families(root: &Theme, blocks: &[Block]) -> (Vec<Vec<String>>, Vec<String>) {
161 let mut bodies: Vec<Vec<String>> = Vec::new();
162 let mut headings: Vec<String> = Vec::new();
163 if !root.text.faces.body.is_empty() {
164 bodies.push(root.text.faces.body.clone());
165 }
166 for l in &root.heading.levels {
167 if let Some(n) = &l.face {
168 if !n.is_empty() && !headings.contains(n) { headings.push(n.clone()); }
169 }
170 }
171 collect_named_families(blocks, &mut bodies, &mut headings);
172 (bodies, headings)
173}
174
175/// Adds the families every scoped or box subtree's patch names, descending through nested subtrees.
176fn collect_named_families(blocks: &[Block], bodies: &mut Vec<Vec<String>>, headings: &mut Vec<String>) {
177 for b in blocks {
178 let (patch, inner) = match b {
179 Block::Scoped { patch, blocks } => (patch, blocks),
180 Block::Box { patch, blocks, .. } => (patch, blocks),
181 Block::Place { blocks, .. } => { collect_named_families(blocks, bodies, headings); continue; },
182 _ => continue,
183 };
184 if let Some(list) = &patch.text.faces.body {
185 if !bodies.contains(list) { bodies.push(list.clone()); }
186 }
187 patch_face_names(patch, headings);
188 collect_named_families(inner, bodies, headings);
189 }
190}
191
192/// Records a note for each heading level that names a face and asks for a weight or slant the book ships
193/// no file for -- so a bold or italic heading falling back to Regular is visible rather than silent. Checks
194/// the root theme's own levels, then descends every scoped or box subtree, folding its patch onto the theme
195/// in force at that point (mirroring the merge [`Theme::apply`] performs) so a rule- or chapter-scoped face
196/// is checked with the same weight/italic the renderer would set, not only the root's own. A level whose
197/// face has no file at all is not noted here: that is the ordinary role fall-back, not a missing variant.
198pub fn note_missing_face_variants(theme: &Theme, blocks: &[Block], faces: &FaceResolver, skips: &mut lang::Refusals) {
199 note_missing_variants_for_levels(theme, faces, skips);
200 note_missing_face_variants_in(theme, blocks, faces, skips);
201}
202
203/// The per-level check [`note_missing_face_variants`] runs at the root and, folded onto a scope's merged
204/// theme, at every scoped or box subtree.
205fn note_missing_variants_for_levels(theme: &Theme, faces: &FaceResolver, skips: &mut lang::Refusals) {
206 for (i, l) in theme.heading.levels.iter().enumerate() {
207 let name = match l.face.as_deref().or(theme.heading.face.as_deref()) {
208 Some(n) => n,
209 None => continue,
210 };
211 if !faces.resolves(name) {
212 continue;
213 }
214 let bold = l.weight.map_or(false, |w| w >= 600);
215 let italic = l.italic;
216 if (bold || italic) && !faces.has_variant(name, bold, italic) {
217 let slant = match (bold, italic) {
218 (true, true) => "bold-italic",
219 (true, false) => "bold",
220 (false, true) => "italic",
221 (false, false) => "regular",
222 };
223 skips.record(
224 &fmt!("heading face {:?} level {}: no {} file, set in Regular", name, i + 1, slant),
225 crate::ir::Span::new(0, 0));
226 }
227 }
228}
229
230/// Descends every [`Block::Scoped`]/[`Block::Box`] subtree, folding its patch onto `parent` (the theme in
231/// force at that point) before checking the merged levels and recursing, so a nested scope's own patch
232/// folds onto its immediate parent's, not the document root's.
233fn note_missing_face_variants_in(parent: &Theme, blocks: &[Block], faces: &FaceResolver, skips: &mut lang::Refusals) {
234 for b in blocks {
235 match b {
236 Block::Scoped { patch, blocks } => {
237 let mut scoped = parent.clone();
238 scoped.apply(patch);
239 note_missing_variants_for_levels(&scoped, faces, skips);
240 note_missing_face_variants_in(&scoped, blocks, faces, skips);
241 },
242 Block::Box { patch, blocks, .. } => {
243 let mut scoped = parent.clone();
244 scoped.apply(patch);
245 note_missing_variants_for_levels(&scoped, faces, skips);
246 note_missing_face_variants_in(&scoped, blocks, faces, skips);
247 },
248 Block::Place { blocks, .. } => note_missing_face_variants_in(parent, blocks, faces, skips),
249 _ => {},
250 }
251 }
252}
253
254/// Builds a face resolver for a lone chapter rooted at `root_dir`, loading every heading face the `theme`
255/// and `blocks` name -- the root theme's own, and every name a scoped or box subtree's patch introduces --
256/// from the tree's `assets/fonts` (one level up from the root, beside a shared template). This mirrors the
257/// whole-book path's [`all_face_names`] union rather than the root theme alone, so a rule- or chapter-scoped
258/// face resolves here too and not only when a whole book assembles it. A lone file with no such directory,
259/// or naming only its body family, yields an empty resolver, so its headings set in the body role as before.
260pub fn face_resolver(root_dir: &Path, theme: &Theme, blocks: &[Block]) -> FaceResolver {
261 FaceResolver::load(&lone_font_dir(root_dir), &all_face_names(theme, blocks))
262}
263
264/// The directory a lone document's [`face_resolver`] reads its display faces from: the tree's
265/// `assets/fonts`, one level up from the root (beside a shared template), or under the root itself when it
266/// has no parent. Shared with the wasm surface, which routes a document's injected fonts here so a named
267/// face resolves whatever path the consumer chose to inject its file under (see [`project_font_path`]).
268pub fn lone_font_dir(root_dir: &Path) -> PathBuf {
269 match root_dir.parent() {
270 Some(d) => d.join("assets").join("fonts"),
271 None => root_dir.join("assets").join("fonts"),
272 }
273}
274
275/// Where an injected project font must sit for the lone-file [`face_resolver`] to discover it: the
276/// resolver's [`lone_font_dir`] joined with the font file's own basename, so `Radley-Regular.otf` injected
277/// under any path is found as the `Radley` face's Regular variant. `None` when `given` has no file name.
278/// The resolver keys a face on its `<Family>-<Variant>.{ttf,otf}` basename and the family name a document
279/// declares (a heading face), so the injected file's basename must follow that convention to be usable.
280pub fn project_font_path(main_path: &Path, given: &Path) -> Option<PathBuf> {
281 let root_dir = main_path.parent().unwrap_or(main_path);
282 given.file_name().map(|name| lone_font_dir(root_dir).join(name))
283}
284
285/// Assembles the document rooted at `root_path` into a [`BookSpec`], recognising both root idioms the
286/// house Typst trees use. A *book* root sets its geometry and type through a `config.typ` beside it,
287/// chosen by a `format` switch (the elearnity manuscripts); a *doc-template* root has no `config.typ`
288/// and takes its A4 geometry from the shared `template.typ` and its body size from the `#show:
289/// doc.with(...)` call (the oxedyne documentation trees -- the Hematite guide, the Austenite design).
290/// The presence of `config.typ` beside the root selects the path: the book reader is unchanged, and a
291/// root without a config falls to the doc reader rather than failing on the missing file.
292pub fn load(root_path: &Path) -> Outcome<BookSpec> {
293 // Canonicalise the root so its parent and grandparent are real absolute directories. A root given
294 // relatively (`lucronics.typ`) otherwise has an empty parent, and the project directory the shared
295 // `refs.bib` and assets sit in cannot be found -- a symlinked `assets` masks this for fonts, but the
296 // bibliography one level up is missed.
297 let root_path = vfs::canonicalize(root_path).unwrap_or_else(|_| root_path.to_path_buf());
298 let root_path = root_path.as_path();
299 let root_dir = match root_path.parent() {
300 Some(d) => d.to_path_buf(),
301 None => return Err(err!("The book root {:?} has no parent directory.", root_path; Input, Invalid)),
302 };
303 let root_src = match vfs::read_to_string(root_path) {
304 Ok(s) => s,
305 Err(e) => return Err(err!(e, "Could not read the book root {:?}.", root_path; File, Read)),
306 };
307
308 // Install the book's `term-dict` and `term-defs` from a `terms.typ` beside or above the root: the
309 // dictionary so the glossary family resolves each key to its value as the chapters are read, and the
310 // definitions so a `#print-glossary()` can be filled once the document's used terms are known.
311 res!(install_term_dict(&root_dir));
312 res!(install_term_defs(&root_dir));
313
314 // A `config.typ` beside the root marks the book (`format`-switch) idiom; without it, the root sets its
315 // page through the shared `template.typ` and the `doc.with` call, which is the documentation idiom.
316 let config_path = root_dir.join("config.typ");
317 if !vfs::exists(&config_path) {
318 return load_doc(root_path, &root_dir, &root_src);
319 }
320 load_book(root_path, &root_dir, &root_src)
321}
322
323/// The book (`format`-switch) path: reads the `config.typ` beside the root, loads the shared Libertinus
324/// faces by path from the project assets tree, and follows the root's includes into one block stream.
325fn load_book(root_path: &Path, root_dir: &Path, root_src: &str) -> Outcome<BookSpec> {
326 // The config sits beside the root; the assets tree is one level up (the project root), holding the
327 // Libertinus directory both books share.
328 let config_path = root_dir.join("config.typ");
329 let config_src = match vfs::read_to_string(&config_path) {
330 Ok(s) => s,
331 Err(e) => return Err(err!(e, "Could not read the book config {:?}.", config_path; File, Read)),
332 };
333 let project_dir = match root_dir.parent() {
334 Some(d) => d.to_path_buf(),
335 None => root_dir.to_path_buf(),
336 };
337 let assets_fonts = project_dir.join("assets").join("fonts");
338 let libertinus_dir = assets_fonts.join("libertinus");
339 let fonts = Arc::new(res!(fonts::libertinus_from_dir(&libertinus_dir)));
340
341 let (geom, raw) = res!(read_config(&config_src));
342 let mut style = build_style(&raw);
343 // A book sets its chapter and level-2 headings in Radley, the display face beside Libertinus in the
344 // shared assets tree. It is named on the theme's role-default heading face here, then loaded by the
345 // resolver below; a book whose tree ships no Radley resolves nothing and sets headings in the body
346 // bold, the same fall-back as before.
347 style.heading.face = Some("Radley".to_string());
348 // The root's own declarative styling -- its `#show: doc.with(...)` application and any lowerable
349 // top-level `#set` -- lowers onto the theme. The config file's `#let` type scale is read separately
350 // by `read_config` above; this reads only the root's own top-level declarations.
351 lang::set::lower_root_declarations(root_src, &mut style);
352 // The book's `#let` scope -- its furniture functions (`#pr-note`, `#aside-box`) and content bindings --
353 // collected once against the document's body size so every `em` in a furniture definition resolves to an
354 // absolute, then threaded into every chapter the assembler reads so a call expands rather than being
355 // tallied as a skipped construct.
356 let scope = collect_scope(root_src, root_dir, style.text.body_size);
357 let binds = scope.bindings();
358 // The book config's `media` (and any other guard scalar) reaches the assembler here, so a chapter's
359 // `#if media == "..."` include guard follows only its taken branch.
360 let (mut blocks, mut skips) = res!(assemble(root_src, root_dir, root_path, binds, &config_src));
361 // The styling rule engine runs over the assembled tree here, BEFORE the face resolver is built: a rule
362 // that names a heading face wraps its matched elements in a scope carrying that face, and the resolver's
363 // face union descends into those scopes -- so a rule-named face must already be on the tree when the
364 // union is taken. The default rules re-assert the theme's own heading sizes (byte-neutral); the root's
365 // own `#show <selector>: <transform>` rules are appended, refused where a transform reads the page or
366 // patches a field the renderer does not read.
367 let rules = lang::rules::rule_set_for(&style, root_src, &mut skips);
368 lang::rules::apply_rules(&mut blocks, &rules, geom.content_width());
369 // The resolver is built from every heading face the document can name -- the root theme's, and every
370 // name a scoped or box subtree's patch introduces -- so a face a chapter or a rule names still loads,
371 // not only the root's own. A note is recorded where a heading asks for a weight or slant the book ships
372 // no file for.
373 let mut faces = FaceResolver::load(&assets_fonts, &all_face_names(&style, &blocks));
374 let (bodies, headings) = named_families(&style, &blocks);
375 res!(faces.require(&assets_fonts, &bodies, &headings));
376 note_missing_face_variants(&style, &blocks, &faces, &mut skips);
377 // A book root may also place a `#print-glossary()`; fill it in place once its chapters are assembled.
378 resolve_glossary(&mut blocks, false);
379 let title = content_field(root_src, "title").unwrap_or_default();
380 let front = read_front_matter(root_src, &config_src, &title);
381
382 // The bibliography the root names, if any: parse it, mark every key the body cited, and append the
383 // Chicago reference list as back matter. The marked bibliography then resolves each in-text `#cite`.
384 let bib = res!(load_bibliography(root_src, &project_dir, &mut blocks));
385
386 // The glossary and index back matter the root's `meta-data.glossary`/`meta-data.index` flags ask for,
387 // after the bibliography and gated on the body actually carrying the content -- a book that sets a flag
388 // but uses no glossary or index term emits neither section, matching the template's own gate.
389 append_flag_back_matter(root_src, &mut blocks);
390
391 Ok(BookSpec { geom, style, fonts, blocks, title, faces, front, bib, skips })
392}
393
394// ┌───────────────────────────────────────────────────────────────────────────┐
395// │ DOCUMENTATION IDIOM │
396// └───────────────────────────────────────────────────────────────────────────┘
397
398/// The template's `avg_reading_speed`, in words per minute, used to turn the word count into the reading
399/// time the meta page shows.
400const AVG_READING_SPEED: usize = 230;
401
402/// The acknowledgement paragraph the documentation template seats at the foot of its meta page, a fixed
403/// constant of the doc idiom rather than a `meta-data` field (`template.typ`'s `meta-page`).
404const DOC_ACKNOWLEDGEMENT: &str = "We acknowledge the Indigenous peoples who have been traditional \
405custodians of lands and waters around the world, past and present. Through their languages, stories, and \
406practices, they have sought to maintain living connections to humanity's deepest roots and the ancient \
407wisdom of sustainable coexistence with Earth's ecosystems.";
408
409/// Maps an AI-declaration slug to its mark image path and caption, mirroring the template's
410/// `ai-declarations` dictionary and its `doc` medium. The path is image-base-relative, resolved the same
411/// way as the title logos; an unknown slug yields no mark, so the author cell sets the name alone.
412fn ai_declaration_mark(slug: &str) -> Option<(String, String)> {
413 let (file, words) = match slug {
414 "no-ai" => ("doc_made_with_no_ai_opt.svg", "Made with no AI"),
415 "some-ai" => ("doc_made_with_some_ai_opt.svg", "Made with some AI"),
416 "with-ai" => ("doc_made_with_ai_opt.svg", "Made with AI"),
417 "mostly-ai" => ("doc_made_with_ai_mostly_opt.svg", "Made mostly with AI"),
418 "entirely-ai" => ("doc_made_with_ai_entirely_opt.svg", "Made with AI entirely"),
419 _ => return None,
420 };
421 Some((fmt!("assets/svg/{}", file), words.to_string()))
422}
423
424/// The documentation (`doc.with`) path: the oxedyne doc trees (Hematite, Austenite) carry no
425/// `config.typ`. Their A4 page and 2.5 cm margins live in the shared `template.typ`, and the body size
426/// is the `text-size:` argument of the root's `#show: doc.with(...)` call. Geometry and type are read
427/// from those two sources; the body font is the embedded Libertinus, which is the doc body and heading
428/// family both (a doc heading is Libertinus bold, so no separate display face is loaded); and the
429/// includes are followed exactly as for a book. A field the tree omits keeps a readable default.
430fn load_doc(root_path: &Path, root_dir: &Path, root_src: &str) -> Outcome<BookSpec> {
431 let (geom, raw, opener) = res!(read_doc_config(root_dir, root_src));
432 let mut style = build_style(&raw);
433 // The doc root's own `#show: doc.with(...)` application (and any lowerable top-level `#set`) lowers
434 // onto the theme; its per-format type scale is read from the config by `read_doc_config` above.
435 lang::set::lower_root_declarations(root_src, &mut style);
436 let title = content_field(root_src, "title").unwrap_or_default();
437 // A doc tree sets `numbering: none`; how its top-level headings open turns first on the template
438 // idiom read above. A grid template (oxeweb) opens level 1 with a fixed logo/title grid and no
439 // number -- neither a banner bar nor an inline heading -- so its opener reserves the grid bands
440 // (`DocGrid`). A banner template's top-level headings open with the grey banner bar unless the tree
441 // draws its own per-section `#section-banner` logo bars, in which case each level-1 heading is set
442 // inline beneath the section's banner. This mirrors the template's `chapter-banners` argument: an
443 // explicit `true`/`false` decides, and its `auto` default turns the chapter banners off only for the
444 // Hematite guide, whose sections carry logo banners instead.
445 let want_banners = tri_bool(root_src, "chapter-banners").unwrap_or(title != "Hematite");
446 style.heading.kind = if opener == DocOpener::Grid {
447 HeadingStyle::DocGrid
448 } else if want_banners {
449 HeadingStyle::DocBanner
450 } else {
451 HeadingStyle::DocInline
452 };
453 let fonts = Arc::new(res!(fonts::libertinus()));
454 // The doc template's `#show: doc.with(heading-font: ...)` lowered its heading face onto the theme's
455 // levels above; the resolver loads it from the tree's own `assets/fonts` (one level up from the root,
456 // beside the shared `template.typ`). A tree naming its body family (or no face) resolves nothing, so
457 // its headings fall to the body role -- Libertinus bold -- exactly as before. oxeweb's "Graystroke"
458 // resolves and its chapter and level-2 headings now set in it, as the template renders them.
459 let assets_fonts = match root_dir.parent() {
460 Some(d) => d.join("assets").join("fonts"),
461 None => root_dir.join("assets").join("fonts"),
462 };
463 let scope = collect_scope(root_src, root_dir, style.text.body_size);
464 let binds = scope.bindings();
465 // The documentation idiom carries no `config.typ`, so the guard evaluator sees an empty config and
466 // falls back to each file's own `#let` bindings; a doc tree writing no include guard is unaffected.
467 let (mut blocks, mut skips) = res!(assemble(root_src, root_dir, root_path, binds, ""));
468 // The styling rule engine runs over the assembled tree before the resolver is built, so a rule-named
469 // face is in the union the resolver loads (see `load_book` for the same seam and why it sits here).
470 let rules = lang::rules::rule_set_for(&style, root_src, &mut skips);
471 lang::rules::apply_rules(&mut blocks, &rules, geom.content_width());
472 // The resolver loads every heading face the document can name -- the root theme's and every scoped or
473 // box subtree's -- so a face a chapter names still loads; a heading asking for a weight/slant with no
474 // file is noted rather than silently set in Regular.
475 let mut faces = FaceResolver::load(&assets_fonts, &all_face_names(&style, &blocks));
476 let (bodies, headings) = named_families(&style, &blocks);
477 res!(faces.require(&assets_fonts, &bodies, &headings));
478 note_missing_face_variants(&style, &blocks, &faces, &mut skips);
479 // Fill each `#print-glossary()` placeholder with the Term/Definition table now the whole document's
480 // blocks are assembled and its used glossary terms known, before the word count and layout walk them.
481 resolve_glossary(&mut blocks, false);
482 let mut front = read_doc_front_matter(root_dir, root_src, &raw, &title);
483
484 // The reading time the meta page appends to its notes cell: the whole-document word count over the
485 // template's 230 words/min, rounded up, matching its `calc.ceil(words.final() / avg_reading_speed)`.
486 let words = crate::doc::count_words(&blocks);
487 front.reading_min = Some(((words + AVG_READING_SPEED - 1) / AVG_READING_SPEED) as u32);
488
489 // A doc tree names its bibliography, glossary and index through raw Typst calls the reader skips, not
490 // the book's `meta-data.bibliography` field, so no reference back matter is assembled here.
491 Ok(BookSpec { geom, style, fonts, blocks, title, faces, front, bib: None, skips })
492}
493
494/// Which level-1 opener idiom a doc template uses, read from its `show heading` block. A grid template
495/// (oxeweb) opens with a fixed `#grid` of logo/title bands and no number; the oxedyne banner template
496/// draws a grey `#section-banner` bar. The two take different opener metrics and heading scales.
497#[derive(Clone, Copy, Debug, PartialEq, Eq)]
498enum DocOpener {
499 Grid,
500 Banner,
501}
502
503/// Reads a doc-template root's geometry and type: the paper and margins from the shared `template.typ`
504/// beside the root, and the body size from the root's `doc.with(text-size: ..)` argument. The template
505/// fixes uniform margins with a slightly deeper foot (`margins.a4 + 0.25cm`), matching its `set page`.
506/// Everything the tree does not state -- leading, paragraph spacing, heading sizes -- takes the Typst
507/// default the template inherits, so an unfamiliar doc root still assembles onto a readable A4 page.
508fn read_doc_config(root_dir: &Path, root_src: &str) -> Outcome<(PageGeometry, RawStyle, DocOpener)> {
509 // The template is symlinked in beside the root; a tree without it falls back to A4 at 2.5 cm.
510 let template = vfs::read_to_string(&root_dir.join("template.typ")).unwrap_or_default();
511
512 let paper_name = first_quoted_after(&template, "paper:").unwrap_or_else(|| "a4".to_string());
513 let (pw_mm, ph_mm) = paper_dims_mm(&paper_name);
514
515 // The uniform margin: the `a4:` entry of the template's `#let margins = (...)` dictionary, a length.
516 let margin_pt = let_dict_field(&template, "margins", "a4")
517 .and_then(|v| parse_len_pt(&v))
518 .unwrap_or(2.5 * 10.0 * MM_PER_PT); // 2.5 cm default
519 let foot_extra = 0.25 * 10.0 * MM_PER_PT; // the template's `bottom: margins.a4 + 0.25cm`
520
521 let geom = PageGeometry::with_margins(
522 Sp::from_pt(pw_mm * MM_PER_PT),
523 Sp::from_pt(ph_mm * MM_PER_PT),
524 Sp::from_pt(margin_pt),
525 Sp::from_pt(margin_pt),
526 Sp::from_pt(margin_pt),
527 Sp::from_pt(margin_pt + foot_extra),
528 );
529
530 // The body size the doc.with call sets, else the template's own `text-size: 11pt` default.
531 let body_pt = first_len_after(root_src, "text-size:")
532 .or_else(|| first_len_after(&template, "text-size:"))
533 .unwrap_or(11.0);
534
535 // The doc template inherits Typst's default leading (0.65 em) but its OWN paragraph spacing: the
536 // template sets no `#set par(spacing:)`, so a paragraph gap takes Typst's default `par.spacing` of
537 // 1.2 em (the earlier 0.65 was wrong -- it under-set the gap and, once the block edges were pinned to
538 // cap-height/baseline, drove the whole doc short of the oracle). Correct for both idioms.
539 //
540 // The level-1 opener idiom is read from the template's `show heading` block. A grid template
541 // (oxeweb) opens with a fixed `#grid(rows: (240pt, 10pt, 40pt, 20pt))` -- a logo band, a gap, a 40 pt
542 // title band set at `size: 32pt` small-caps, and a gap to the body -- and its sub-headings size by
543 // `(18, 14, 13, 12).at(level - 1)`, so level 2 is 14 pt, level 3 13 pt, level 4 12 pt (the level-1
544 // entry, 18 pt, is unused: level 1 takes the grid). The oxedyne banner template opens with a grey
545 // `#section-banner` bar and no grid, and keeps its own smaller heading scale (chapter title 14 pt).
546 // The presence of a `rows:` tuple after the rule tells the two apart; pinning the grid's metrics onto a
547 // banner tree over-set its level-1 and back-matter headings. A future doc template with other values
548 // should have them read from its own `template.typ` rather than pinned here. The anchor is the rule form
549 // `show heading: it =>`, not the bare words: the banner template carries a `//` comment naming
550 // `show heading`/`set heading`, and matching that comment (with a `rows:` tuple anywhere after it) would
551 // misread the banner as a grid.
552 let head_tail = template.find("show heading: it =>").map(|at| &template[at..]);
553 let grid_rows = head_tail.and_then(|t| tuple_after(t, "rows:")).filter(|r| r.len() >= 4);
554 let (opener, chap_grid, h1_pt, h2_pt, h3_pt, h4_pt) = match grid_rows {
555 Some(rows) => {
556 let h1 = head_tail.and_then(|t| num_after(t, "size:")).unwrap_or(32.0); // the grid title, `size: 32pt`
557 (DocOpener::Grid, [rows[0], rows[1], rows[2], rows[3]], h1, 14.0, 13.0, 12.0)
558 },
559 None => (DocOpener::Banner, [72.0, 8.0, 36.0, 20.0], 14.0, 12.0, 13.0, 12.0),
560 };
561
562 let raw = RawStyle {
563 body_pt,
564 leading_em: 0.65,
565 par_skip_em: 1.2,
566 indent_em: 0.0,
567 chap_num_pt: 54.0,
568 chap_grid,
569 h1_pt,
570 h2_pt,
571 h3_pt,
572 h4_pt,
573 };
574 Ok((geom, raw, opener))
575}
576
577/// The front matter a doc root states: its title and subtitle, the author from the first `meta-data`
578/// entry, and the documentation template's two-column title-page furniture -- the sidebar width and
579/// colour, the two sidebar logos with their declared widths, the small-caps flag, and the footer logo --
580/// read from the `#show: doc.with(...)` call and the shared `template.typ`. A doc tree carries no imprint
581/// (no ISBN, publisher or copyright tuple), so only a title page and the contents are composed from this.
582fn read_doc_front_matter(root_dir: &Path, root_src: &str, raw: &RawStyle, title: &str) -> FrontMatter {
583 let subtitle = content_field(root_src, "subtitle");
584 let meta = meta_block(root_src).unwrap_or_default();
585 let author = string_field(&meta, "authors").unwrap_or_default();
586
587 // The AI scheme address the mark links to, `<scheme>/<slug>/<medium>`, read from the shared template's
588 // `ai-scheme-url` and `ai-medium` lets (the template's `link(ai-scheme-url + "/" + slug + "/" +
589 // ai-medium, ..)`). A tree without the template falls back to the scheme's permanent home and doc medium.
590 let template = vfs::read_to_string(&root_dir.join("template.typ")).unwrap_or_default();
591 let ai_scheme_url = first_quoted_after(&template, "ai-scheme-url").unwrap_or_else(|| "https://need2know.ai".to_string());
592 let ai_medium = first_quoted_after(&template, "ai-medium").unwrap_or_else(|| "doc".to_string());
593
594 // The revision rows the template's meta/colophon page draws: each row's version, date, notes, and the
595 // AI declaration whose slug picks the mark image, its caption (a `declaration-words` field rescopes the
596 // caption without changing the mark) and the scheme page the mark links to. A doc tree may state several
597 // rows, newest first.
598 let meta_rows: Vec<crate::doc::MetaRow> = meta_rows(&meta).iter().map(|row| {
599 let (ai_mark_path, ai_mark_words, ai_mark_url) = match string_field(row, "declaration") {
600 Some(slug) => match ai_declaration_mark(&slug) {
601 Some((path, words)) => {
602 let words = string_field(row, "declaration-words").unwrap_or(words);
603 let url = fmt!("{}/{}/{}", ai_scheme_url, slug, ai_medium);
604 (Some(path), Some(words), Some(url))
605 },
606 None => (None, None, None),
607 },
608 None => (None, None, None),
609 };
610 crate::doc::MetaRow {
611 version: string_field(row, "version"),
612 date: string_field(row, "date"),
613 authors: string_field(row, "authors").unwrap_or_default(),
614 notes: string_field(row, "notes"),
615 ai_mark_path,
616 ai_mark_words,
617 ai_mark_url,
618 }
619 }).collect();
620 // The colophon furniture the template fixes for the doc idiom: the acknowledgement paragraph, and the
621 // copyright line composed from the organisation the term dictionary names (`#t("org")`), falling back
622 // to Oxedyne. Both are template constants rather than `meta-data`, so they are set here for every doc.
623 let acknowledgement = Some(DOC_ACKNOWLEDGEMENT.to_string());
624 let org = crate::lang::parse::term_value("org").unwrap_or_else(|| "Oxedyne".to_string());
625 let copyright = Some(fmt!("Copyright © 12025 {}. All rights reserved.", org));
626
627 // The sidebar width is `margins.title_page` in the shared template (a percentage of the page); the fill
628 // is the `title-colour` the call names, resolved to a grey level. A doc tree always draws the sidebar,
629 // so `sidebar_grey` is set here (marking the two-column idiom) even when the call omits its colour.
630 let template = vfs::read_to_string(&root_dir.join("template.typ")).unwrap_or_default();
631 let sidebar_frac = let_dict_field(&template, "margins", "title_page")
632 .and_then(|v| parse_percent(&v))
633 .unwrap_or(0.45);
634 let colour_name = string_field(root_src, "title-colour").unwrap_or_default();
635 let sidebar_grey = Some(grey_luma(&colour_name));
636
637 let non_empty = |s: Option<String>| s.filter(|p| !p.is_empty());
638 let top_logo = non_empty(string_field(root_src, "title-top-logo-path"));
639 let bottom_logo = non_empty(string_field(root_src, "title-bottom-logo-path"));
640 let footer_logo = non_empty(string_field(root_src, "footer-left-logo-path"));
641 let top_w = first_len_after(root_src, "title-top-logo-width:").unwrap_or(80.0);
642 let bottom_w = first_len_after(root_src, "title-bottom-logo-width:").unwrap_or(120.0);
643 let smallcaps = bool_field(root_src, "title-smallcaps");
644
645 FrontMatter {
646 title: title.to_string(),
647 subtitle,
648 author,
649 cover_image: None,
650 logo_image: None,
651 publisher: None,
652 edition: None,
653 isbn: None,
654 copyright,
655 rights: None,
656 ai_declaration: None,
657 website: None,
658 toolchain: false,
659 dedication: None,
660 about_author: None,
661 title_size: Sp::from_pt(28.0),
662 subtitle_size: Sp::from_pt(16.0),
663 author_size: Sp::from_pt(17.0),
664 back_title_size: Sp::from_pt(raw.h1_pt),
665 sidebar_grey,
666 sidebar_frac,
667 title_smallcaps: smallcaps,
668 top_logo,
669 top_logo_width: Sp::from_pt(top_w),
670 bottom_logo,
671 bottom_logo_width: Sp::from_pt(bottom_w),
672 footer_logo,
673 meta_rows,
674 reading_min: None, // set by `load_doc`, which has the body blocks to count
675 acknowledgement,
676 }
677}
678
679/// Resolves a `template.typ` colour name to a grey level. Only the greys the doc trees reach for are
680/// mapped by name; every other name falls to the template's `colours.light` (luma 240), a light sidebar.
681fn grey_luma(name: &str) -> u8 {
682 match name {
683 "white" => 255,
684 "lightgrey" => 240,
685 "light" => 240,
686 _ => 240,
687 }
688}
689
690/// Reads a Typst percentage literal (`45%`) as a fraction (`0.45`). `None` when no number leads it.
691fn parse_percent(s: &str) -> Option<f64> {
692 first_num(s).map(|n| n / 100.0)
693}
694
695// ┌───────────────────────────────────────────────────────────────────────────┐
696// │ BIBLIOGRAPHY │
697// └───────────────────────────────────────────────────────────────────────────┘
698
699/// Parses the bibliography the root's `meta-data.bibliography` names, marks every key the body cited,
700/// and appends the Bibliography back matter (a heading and the sorted, cited-only reference list) to the
701/// block stream. Returns the marked bibliography for the in-text citation formatter, or `None` when the
702/// book names no bibliography or the file cannot be read.
703fn load_bibliography(root_src: &str, project_dir: &Path, blocks: &mut Vec<Block>) -> Outcome<Option<Bibliography>> {
704 let meta = match meta_block(root_src) {
705 Some(m) => m,
706 None => return Ok(None),
707 };
708 let path_str = match string_field(&meta, "bibliography") {
709 Some(p) => p,
710 None => return Ok(None), // no bibliography named
711 };
712
713 // The path is Typst-root-relative (`/refs.bib`); resolve it against the project directory.
714 let rel = path_str.trim_start_matches('/');
715 let bib_path = project_dir.join(rel);
716 let src = match vfs::read_to_string(&bib_path) {
717 Ok(s) => s,
718 Err(_) => return Ok(None), // a named bibliography that will not read is a reported gap, not a failure
719 };
720 let bib = res!(Bibliography::parse(&src));
721 Ok(Some(append_bibliography(bib, blocks)))
722}
723
724/// Marks every key the body cited on `bib`, then appends the Bibliography back matter -- the section
725/// heading and one Reference block per sorted, cited reference -- to the block stream, returning the
726/// marked bibliography for the in-text citation formatter. Shared by the whole-book path and the lone
727/// chapter path, so a chapter compiled on its own resolves its citations exactly as the book does.
728fn append_bibliography(mut bib: Bibliography, blocks: &mut Vec<Block>) -> Bibliography {
729 // Mark every key the body cited, so the reference list holds exactly the cited works.
730 for keys in collect_cite_keys(blocks) {
731 for k in keys {
732 bib.mark_cited(&k);
733 }
734 }
735
736 // Append the back matter: the section heading, then one Reference block per sorted, cited reference.
737 blocks.push(Block::back_matter_heading("Bibliography"));
738 for reference in bib.reference_list() {
739 let runs: Vec<(String, bool)> = reference.runs.iter()
740 .map(|r| (r.text.clone(), r.style == RefStyle::Italic))
741 .collect();
742 blocks.push(Block::reference(runs));
743 }
744 bib
745}
746
747/// Appends the glossary and index back matter the root's `meta-data` flags ask for, each gated on the body
748/// carrying its content. The glossary section is a back-matter heading, a one-line note and a
749/// [`Block::Glossary`] placeholder [`resolve_glossary`] fills with the Term/Definition table; it is set
750/// only when the book uses at least one defined glossary term (`meta-data.glossary: true` alone, with no
751/// used term, sets nothing, as the template's own gate does). The index section is a back-matter heading
752/// and a [`Block::Index`] placeholder [`crate::doc::author`] fills from the index markers it gathers walking
753/// the body; it is set only when the body carries at least one index marker.
754fn append_flag_back_matter(root_src: &str, blocks: &mut Vec<Block>) {
755 let meta = match meta_block(root_src) {
756 Some(m) => m,
757 None => return,
758 };
759
760 if bool_field(&meta, "glossary") && has_defined_glossary_terms(blocks) {
761 blocks.push(Block::back_matter_heading("Glossary"));
762 blocks.push(Block::RichParagraph {
763 segments: vec![Segment::text("Terms are shown by their order of appearance.")],
764 });
765 blocks.push(Block::Glossary);
766 // Fill the placeholder just appended: the earlier whole-book `resolve_glossary` ran before it existed.
767 resolve_glossary(blocks, true);
768 }
769
770 if bool_field(&meta, "index") && has_index_occurrences(blocks) {
771 blocks.push(Block::back_matter_heading("Index"));
772 blocks.push(Block::Index);
773 }
774}
775
776/// Does the body carry at least one glossary term that has a definition? The same walk
777/// [`resolve_glossary`] makes, so the gate agrees with what the table would hold: a term with no `term-defs`
778/// entry contributes no row and does not count.
779fn has_defined_glossary_terms(blocks: &[Block]) -> bool {
780 let mut seen: HashSet<String> = HashSet::new();
781 let mut ordered: Vec<String> = Vec::new();
782 for block in blocks {
783 collect_glossary_terms(block, &mut seen, &mut ordered);
784 }
785 !ordered.is_empty()
786}
787
788/// Does the body carry at least one index marker anywhere -- in a heading, paragraph, list item, table cell
789/// or callout, or a footnote's own runs? The gate that keeps the index section from being set for a book
790/// that asks for one but marks no term.
791fn has_index_occurrences(blocks: &[Block]) -> bool {
792 blocks.iter().any(block_has_index)
793}
794
795/// Whether one block, or anything nested in it, carries an index marker segment.
796fn block_has_index(block: &Block) -> bool {
797 match block {
798 Block::Heading { segments, .. } => segments_have_index(segments),
799 Block::RichParagraph { segments } => segments_have_index(segments),
800 Block::List { items, .. } => items.iter().any(|it|
801 segments_have_index(&it.segments) || it.children.iter().any(block_has_index)),
802 Block::Table(t) => table_has_index(t),
803 Block::TableFigure { table, .. } => table_has_index(table),
804 Block::Box { blocks, .. } => blocks.iter().any(block_has_index),
805 Block::Scoped { blocks, .. } => blocks.iter().any(block_has_index),
806 Block::Place { blocks, .. } => blocks.iter().any(block_has_index),
807 _ => false,
808 }
809}
810
811/// Whether a run of segments carries an index marker, descending into a footnote's own runs.
812fn segments_have_index(segments: &[Segment]) -> bool {
813 segments.iter().any(|seg| match seg {
814 Segment::Index { .. } => true,
815 Segment::Footnote { note } => segments_have_index(note),
816 _ => false,
817 })
818}
819
820/// Whether any cell of a table carries an index marker.
821fn table_has_index(table: &Table) -> bool {
822 table.rows.iter().any(|row| row.cells.iter().any(|cell| segments_have_index(&cell.content)))
823}
824
825/// Locates a `refs.bib` beside a lone chapter or in an ancestor directory, parses it, marks the keys the
826/// chapter cited, appends the reference list as back matter, and returns the marked bibliography so the
827/// block layer resolves each in-text `#cite` to Chicago author-year -- as a whole-book compile does.
828/// `None` when no `refs.bib` is found or it will not read, in which case the raw cite key stands as before.
829pub fn load_lone_bibliography(source: &Path, blocks: &mut Vec<Block>) -> Outcome<Option<Bibliography>> {
830 let start = match source.parent() {
831 Some(d) => d,
832 None => return Ok(None),
833 };
834 let bib_path = match find_up(start, "refs.bib") {
835 Some(p) => p,
836 None => return Ok(None),
837 };
838 let src = match vfs::read_to_string(&bib_path) {
839 Ok(s) => s,
840 Err(_) => return Ok(None), // a bibliography found but unreadable is a reported gap, not a failure
841 };
842 let bib = res!(Bibliography::parse(&src));
843 Ok(Some(append_bibliography(bib, blocks)))
844}
845
846// ┌───────────────────────────────────────────────────────────────────────────┐
847// │ TERM DICTIONARY │
848// └───────────────────────────────────────────────────────────────────────────┘
849
850/// Reads the book's `term-dict` from a `terms.typ` beside or above `start_dir` and installs it, so the
851/// term-dictionary glossary family (`t`, `tcap`, `graw`, `g`, `gi`, `gcap`, `gcapi`) resolves each key to
852/// its value while the chapters are read. An absent or `term-dict`-less `terms.typ` installs an empty
853/// map, under which every key falls back to its own text.
854pub fn install_term_dict(start_dir: &Path) -> Outcome<()> {
855 let src = match find_up(start_dir, "terms.typ") {
856 Some(p) => vfs::read_to_string(&p).unwrap_or_default(),
857 None => String::new(),
858 };
859 res!(crate::lang::parse::set_term_dict(parse_term_dict(&src)));
860 Ok(())
861}
862
863/// Reads the book's `term-defs` from a `terms.typ` beside or above `start_dir` and installs it, so
864/// [`resolve_glossary`] can give each glossary term used in the document its definition row. An absent or
865/// `term-defs`-less `terms.typ` installs an empty map, under which every term contributes no row and the
866/// glossary sets its header alone -- the same early return the template's style makes for an undefined key.
867pub fn install_term_defs(start_dir: &Path) -> Outcome<()> {
868 let src = match find_up(start_dir, "terms.typ") {
869 Some(p) => vfs::read_to_string(&p).unwrap_or_default(),
870 None => String::new(),
871 };
872 let mut defs: HashMap<String, Vec<Segment>> = HashMap::new();
873 for (key, content) in parse_term_defs(&src) {
874 defs.insert(key, lang::inline_segments(&content));
875 }
876 let mut guard = lock_write!(TERM_DEFS, "While recording the term definitions");
877 *guard = Some(defs);
878 Ok(())
879}
880
881/// The lowered definition runs a `term-defs` key resolves to, or `None` when no map is installed or it
882/// holds no such key. A poisoned lock reads as absent, so a missing definition drops the term's row
883/// rather than failing the compile -- the safe degradation, matching the template's undefined-key branch.
884fn term_def(key: &str) -> Option<Vec<Segment>> {
885 match TERM_DEFS.read() {
886 Ok(guard) => guard.as_ref().and_then(|m| m.get(key).cloned()),
887 Err(_) => None,
888 }
889}
890
891/// Parses the `#let term-defs = ( "key": [definition], ... )` block from a `terms.typ` source into
892/// key→content pairs. Unlike the term-dictionary, whose values are quoted strings, a definition is Typst
893/// *content* (`[...]`) carrying inline markup, so each value is the balanced-bracket group's inner source,
894/// left for [`lang::inline_segments`] to parse. Pairs are returned in source order; a value that is not a
895/// content group (a tuple or bare string) is skipped, since the live term files use plain content only.
896fn parse_term_defs(src: &str) -> Vec<(String, String)> {
897 let mut out: Vec<(String, String)> = Vec::new();
898 // The assignment, not a `// term-defs: ...` mention: the name must be followed, after only whitespace,
899 // by `=`, exactly as the term-dictionary reader guards its own literal.
900 let at = match assignment_offset(src, "term-defs") {
901 Some(a) => a,
902 None => return out,
903 };
904 let chars: Vec<char> = src[at..].chars().collect();
905 let n = chars.len();
906
907 // Advance to the opening parenthesis of the dictionary literal, then step past it.
908 let mut i = 0;
909 while i < n && chars[i] != '(' {
910 i += 1;
911 }
912 if i >= n {
913 return out;
914 }
915 i += 1;
916
917 loop {
918 // Skip the whitespace, commas and comments between entries; stop at the closing parenthesis or the
919 // source end. Comments must be skipped whole: `terms.typ` carries `// ...` banners and notes between
920 // term groups, and a `)` inside one (a parenthetical aside) would otherwise read as the literal's
921 // closing parenthesis and truncate the parse -- which dropped half of Lucronics' 346 definitions.
922 loop {
923 while i < n && (chars[i].is_whitespace() || chars[i] == ',') {
924 i += 1;
925 }
926 if i + 1 < n && chars[i] == '/' && chars[i + 1] == '/' {
927 i += 2;
928 while i < n && chars[i] != '\n' {
929 i += 1;
930 }
931 continue;
932 }
933 if i + 1 < n && chars[i] == '/' && chars[i + 1] == '*' {
934 i += 2;
935 while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
936 i += 1;
937 }
938 i = (i + 2).min(n);
939 continue;
940 }
941 break;
942 }
943 if i >= n || chars[i] == ')' {
944 break;
945 }
946 if chars[i] != '"' {
947 i += 1; // a stray token inside the literal; step over it
948 continue;
949 }
950 // The quoted key, honouring string escapes so a quote inside it does not end it early.
951 let (key, next) = read_string(&chars, i);
952 i = next;
953 // The `:` between key and value, and the whitespace either side of it.
954 while i < n && chars[i].is_whitespace() {
955 i += 1;
956 }
957 if i < n && chars[i] == ':' {
958 i += 1;
959 }
960 while i < n && chars[i].is_whitespace() {
961 i += 1;
962 }
963 // The value: a `[...]` content group is the definition; anything else is skipped to the next entry.
964 if i < n && chars[i] == '[' {
965 let (content, next) = read_content(&chars, i);
966 out.push((key, content));
967 i = next;
968 } else {
969 // Not a content group: advance to the next top-level comma so the reader resynchronises.
970 let mut depth = 0i32;
971 while i < n {
972 match chars[i] {
973 '(' | '[' | '{' => depth += 1,
974 ')' | ']' | '}' => {
975 if depth == 0 {
976 break;
977 }
978 depth -= 1;
979 },
980 ',' if depth == 0 => break,
981 _ => {},
982 }
983 i += 1;
984 }
985 }
986 }
987 out
988}
989
990/// Reads a `"..."` string whose opening quote sits at `i`, returning its unescaped contents and the index
991/// just past the closing quote. A backslash sets the next character literally, so a quote or backslash
992/// inside the string does not end it early.
993fn read_string(chars: &[char], i: usize) -> (String, usize) {
994 let mut s = String::new();
995 let mut j = i + 1; // past the opening quote
996 let mut esc = false;
997 while j < chars.len() {
998 let c = chars[j];
999 if esc { s.push(c); esc = false; }
1000 else if c == '\\' { esc = true; }
1001 else if c == '"' { j += 1; break; }
1002 else { s.push(c); }
1003 j += 1;
1004 }
1005 (s, j)
1006}
1007
1008/// Reads a `[...]` content group whose opening bracket sits at `i`, returning its inner source and the
1009/// index just past the closing bracket. Brackets nested in the content (a `#emph[...]` inside a
1010/// definition) are balanced, and a quoted string inside the content is skipped whole so a `]` within it
1011/// does not close the group early.
1012fn read_content(chars: &[char], i: usize) -> (String, usize) {
1013 let mut depth = 0i32;
1014 let mut j = i;
1015 let mut inner = String::new();
1016 while j < chars.len() {
1017 match chars[j] {
1018 '[' => {
1019 depth += 1;
1020 if depth > 1 {
1021 inner.push('['); // a nested opener is part of the content
1022 }
1023 },
1024 ']' => {
1025 depth -= 1;
1026 if depth == 0 {
1027 j += 1;
1028 break;
1029 }
1030 inner.push(']');
1031 },
1032 '"' => {
1033 // Copy the whole quoted string verbatim so a bracket inside it is not read as structure.
1034 let (s, next) = read_string(chars, j);
1035 inner.push('"');
1036 inner.push_str(&s);
1037 inner.push('"');
1038 j = next;
1039 continue;
1040 },
1041 c => inner.push(c),
1042 }
1043 j += 1;
1044 }
1045 (inner.trim().to_string(), j)
1046}
1047
1048// ┌───────────────────────────────────────────────────────────────────────────┐
1049// │ GLOSSARY │
1050// └───────────────────────────────────────────────────────────────────────────┘
1051
1052/// The point size a `#print-glossary()` table sets at, matching the template's `text(size: 9pt)`.
1053const GLOSSARY_TEXT_PT: f64 = 9.0;
1054
1055/// The cell padding a `#print-glossary()` table insets by, matching the template's `inset: 6pt`.
1056const GLOSSARY_INSET_PT: f64 = 6.0;
1057
1058/// Fills each `#print-glossary()` placeholder in an assembled block stream with a Term/Definition table
1059/// of the glossary terms the document uses, in first-appearance order. The placeholder is replaced in
1060/// place, so the glossary sets where the author wrote the call rather than as appended back matter.
1061///
1062/// The terms are the [`Segment::Glossary`] runs the parser recorded, walked in document order and
1063/// deduplicated by term key -- the key the template's `glossary-seen` set keys by -- keeping the first
1064/// occurrence. A term with no `term-defs` entry contributes no row, matching the template style's early
1065/// return for an undefined key. The Term column shows the term-dictionary value where the key has one
1066/// (the `g`/`gcap` family) and the key itself otherwise (the `gs` family), reproducing the metadata
1067/// `value` the template stores; the Definition column carries the parsed definition content.
1068pub fn resolve_glossary(blocks: &mut Vec<Block>, breakable: bool) {
1069 // The placeholder may sit inside a scoped (or callout) subtree -- an included chapter's own
1070 // `#print-glossary()` -- not only at top level, so it is sought through the whole tree. With none
1071 // anywhere, nothing is built, exactly as before.
1072 if !any_glossary(blocks) {
1073 return;
1074 }
1075 // The glossary term keys in first-appearance order, deduplicated, keeping only those with a definition.
1076 let mut seen: HashSet<String> = HashSet::new();
1077 let mut ordered: Vec<String> = Vec::new();
1078 for block in blocks.iter() {
1079 collect_glossary_terms(block, &mut seen, &mut ordered);
1080 }
1081
1082 // The header row, then one row per defined term: the Term column its display value, the Definition
1083 // column its parsed content. The header sets bold and centred (a header row's own face and alignment);
1084 // body cells set left, matching the template's `(left, left).at(col)`.
1085 let mut rows: Vec<Row> = Vec::new();
1086 rows.push(Row::new(vec![
1087 Cell::rich(vec![Segment::strong("Term")], Align::Centre),
1088 Cell::rich(vec![Segment::strong("Definition")], Align::Centre),
1089 ]));
1090 for key in &ordered {
1091 let def = match term_def(key) {
1092 Some(d) => d,
1093 None => continue,
1094 };
1095 let value = crate::lang::parse::term_value(key).unwrap_or_else(|| key.clone());
1096 rows.push(Row::new(vec![
1097 Cell::rich(vec![Segment::text(value)], Align::Left),
1098 Cell::rich(def, Align::Left),
1099 ]));
1100 }
1101
1102 let mut table = Table::with_weights(true, rows, vec![1.0, 3.0]);
1103 table.text_size = Some(Sp::from_pt(GLOSSARY_TEXT_PT));
1104 table.inset = Some(Sp::from_pt(GLOSSARY_INSET_PT));
1105 // A book's whole-document glossary runs to many pages, so it sets one keep box per row and paginates
1106 // between rows rather than clipping to a single box, matching the template's `block(breakable: true)`
1107 // glossary table. A short in-body `#print-glossary()` stays one box, so a doc's existing glossary is
1108 // byte-for-byte unchanged.
1109 table.breakable = breakable;
1110 // Replace the first placeholder in document order, wherever in the tree it sits, with the built table.
1111 let _ = replace_first_glossary(blocks, Block::Table(table));
1112}
1113
1114/// Is there a `#print-glossary()` placeholder anywhere in the tree, descending into scoped and callout
1115/// subtrees? The guard that keeps [`resolve_glossary`] from building a table no placeholder will consume.
1116fn any_glossary(blocks: &[Block]) -> bool {
1117 blocks.iter().any(|b| match b {
1118 Block::Glossary => true,
1119 Block::Scoped { blocks, .. } | Block::Box { blocks, .. } | Block::Place { blocks, .. } => any_glossary(blocks),
1120 _ => false,
1121 })
1122}
1123
1124/// Replaces the first [`Block::Glossary`] placeholder in document order -- at top level or inside a scoped
1125/// or callout subtree -- with `table`, returning `Ok(())` when it did and `Err(table)` (the table handed
1126/// back) when the slice held no placeholder, so the search threads on through the rest of the tree.
1127fn replace_first_glossary(blocks: &mut [Block], table: Block) -> Result<(), Block> {
1128 let mut slot = table;
1129 for b in blocks.iter_mut() {
1130 if matches!(b, Block::Glossary) {
1131 *b = slot;
1132 return Ok(());
1133 }
1134 if let Block::Scoped { blocks: inner, .. } | Block::Box { blocks: inner, .. } | Block::Place { blocks: inner, .. } = b {
1135 match replace_first_glossary(inner, slot) {
1136 Ok(()) => return Ok(()),
1137 Err(returned) => slot = returned, // not in this subtree; keep the table and walk on
1138 }
1139 }
1140 }
1141 Err(slot)
1142}
1143
1144/// Walks one block's rich runs, recording each glossary term key on its first appearance -- in document
1145/// order, deduplicated -- when the key carries a `term-defs` definition. Headings, paragraphs, list items
1146/// and table cells all carry glossary terms, and a term inside a footnote counts as a use, so each is
1147/// walked. A term with no definition is passed over, so the ordered set holds only rows the glossary sets.
1148fn collect_glossary_terms(block: &Block, seen: &mut HashSet<String>, ordered: &mut Vec<String>) {
1149 match block {
1150 Block::Heading { segments, .. } => collect_from_segments(segments, seen, ordered),
1151 Block::RichParagraph { segments } => collect_from_segments(segments, seen, ordered),
1152 Block::List { items, .. } => for it in items {
1153 collect_from_segments(&it.segments, seen, ordered);
1154 for child in &it.children { collect_glossary_terms(child, seen, ordered); }
1155 },
1156 Block::Table(t) => collect_from_table(t, seen, ordered),
1157 Block::TableFigure { table, .. } => collect_from_table(table, seen, ordered),
1158 Block::Box { blocks, .. } => for b in blocks { collect_glossary_terms(b, seen, ordered); },
1159 Block::Scoped { blocks, .. } => for b in blocks { collect_glossary_terms(b, seen, ordered); },
1160 Block::Place { blocks, .. } => for b in blocks { collect_glossary_terms(b, seen, ordered); },
1161 _ => {},
1162 }
1163}
1164
1165/// Records each glossary term in a run of segments, descending into a footnote's own runs so a term first
1166/// used inside a note is ordered by the note's position, as the template's document-order query is.
1167fn collect_from_segments(segments: &[Segment], seen: &mut HashSet<String>, ordered: &mut Vec<String>) {
1168 for seg in segments {
1169 match seg {
1170 Segment::Glossary { term, .. } => {
1171 if term_def(term).is_some() && seen.insert(term.clone()) {
1172 ordered.push(term.clone());
1173 }
1174 },
1175 Segment::Footnote { note } => collect_from_segments(note, seen, ordered),
1176 _ => {},
1177 }
1178 }
1179}
1180
1181/// Records each glossary term across a table's cells, row-major, so a term first used in a table is
1182/// ordered by the cell it appears in.
1183fn collect_from_table(table: &Table, seen: &mut HashSet<String>, ordered: &mut Vec<String>) {
1184 for row in &table.rows {
1185 for cell in &row.cells {
1186 collect_from_segments(&cell.content, seen, ordered);
1187 }
1188 }
1189}
1190
1191/// Parses the `#let term-dict = ( "key": "value", ... )` block from a `terms.typ` source into a key→value
1192/// map. `terms.typ` is a pure data file (no imports, state or side effects), so the literal is read
1193/// directly rather than evaluated: the quoted strings inside the dictionary's balanced parentheses come in
1194/// key, value order, and are paired off. An empty map when the source names no `term-dict`.
1195fn parse_term_dict(src: &str) -> HashMap<String, String> {
1196 let mut map = HashMap::new();
1197 // Find the assignment `term-dict =`, not a mention in a comment: the name must be followed, after only
1198 // whitespace, by `=`. The file opens with a `// term-dict: ...` comment whose own parentheses would
1199 // otherwise be read as the literal, so the first bare occurrence is not enough.
1200 let at = match assignment_offset(src, "term-dict") {
1201 Some(a) => a,
1202 None => return map,
1203 };
1204 let chars: Vec<char> = src[at..].chars().collect();
1205
1206 // Advance to the opening parenthesis of the dictionary literal.
1207 let mut i = 0;
1208 while i < chars.len() && chars[i] != '(' {
1209 i += 1;
1210 }
1211 if i >= chars.len() {
1212 return map;
1213 }
1214
1215 // Walk the balanced group, collecting each `"..."` string; string escapes are honoured so a quote or
1216 // backslash inside a value does not end it early. The strings alternate key, value, key, value.
1217 let mut depth = 0i32;
1218 let mut strings: Vec<String> = Vec::new();
1219 while i < chars.len() {
1220 match chars[i] {
1221 '(' => depth += 1,
1222 ')' => {
1223 depth -= 1;
1224 if depth == 0 {
1225 break;
1226 }
1227 },
1228 '"' => {
1229 let mut s = String::new();
1230 let mut esc = false;
1231 i += 1;
1232 while i < chars.len() {
1233 let c = chars[i];
1234 if esc { s.push(c); esc = false; }
1235 else if c == '\\' { esc = true; }
1236 else if c == '"' { break; }
1237 else { s.push(c); }
1238 i += 1;
1239 }
1240 strings.push(s);
1241 },
1242 _ => {},
1243 }
1244 i += 1;
1245 }
1246
1247 let mut k = 0;
1248 while k + 1 < strings.len() {
1249 map.insert(strings[k].clone(), strings[k + 1].clone());
1250 k += 2;
1251 }
1252 map
1253}
1254
1255/// The byte offset of a `name =` assignment in `src` -- the position of `name` where the next
1256/// non-whitespace character after it is `=`. Skips a mention of the name in a comment or another context
1257/// (say `// name: ...`), returning the first true assignment, or `None` when there is none.
1258fn assignment_offset(src: &str, name: &str) -> Option<usize> {
1259 let mut from = 0;
1260 while let Some(rel) = src[from..].find(name) {
1261 let at = from + rel;
1262 let after = at + name.len();
1263 let rest = src[after..].trim_start();
1264 if rest.starts_with('=') {
1265 return Some(at);
1266 }
1267 from = after;
1268 }
1269 None
1270}
1271
1272/// Searches `start` and up to a few ancestor directories for a file named `name`, returning the first
1273/// that exists. The bound keeps a lone-file compile from walking to the filesystem root: a book's shared
1274/// `terms.typ` or `refs.bib` sits at most a couple of levels above a chapter.
1275fn find_up(start: &Path, name: &str) -> Option<PathBuf> {
1276 const MAX_HOPS: usize = 6;
1277 let mut dir = Some(start);
1278 let mut hops = 0usize;
1279 while let Some(d) = dir {
1280 let cand = d.join(name);
1281 if vfs::exists(&cand) {
1282 return Some(cand);
1283 }
1284 if hops >= MAX_HOPS {
1285 break;
1286 }
1287 hops += 1;
1288 dir = d.parent();
1289 }
1290 None
1291}
1292
1293/// Gathers the citation keys the body's blocks carry, in document order, so each can be marked cited.
1294fn collect_cite_keys(blocks: &[Block]) -> Vec<Vec<String>> {
1295 let mut out = Vec::new();
1296 for block in blocks {
1297 match block {
1298 Block::RichParagraph { segments } => collect_cite_segments(segments, &mut out),
1299 Block::List { items, .. } => for item in items {
1300 collect_cite_segments(&item.segments, &mut out);
1301 out.extend(collect_cite_keys(&item.children));
1302 },
1303 Block::Box { blocks, .. } => out.extend(collect_cite_keys(blocks)),
1304 Block::Scoped { blocks, .. } => out.extend(collect_cite_keys(blocks)),
1305 Block::Place { blocks, .. } => out.extend(collect_cite_keys(blocks)),
1306 // A table cell is set through the body's own segment pipeline, so a `#cite` in a cell renders and
1307 // must be marked cited too, or its work would render but its reference vanish from the list.
1308 Block::Table(t) => collect_cite_from_table(t, &mut out),
1309 Block::TableFigure { table, .. } => collect_cite_from_table(table, &mut out),
1310 _ => {},
1311 }
1312 }
1313 out
1314}
1315
1316/// Pushes the keys of every citation in any cell of a table onto `out`.
1317fn collect_cite_from_table(table: &Table, out: &mut Vec<Vec<String>>) {
1318 for row in &table.rows {
1319 for cell in &row.cells {
1320 collect_cite_segments(&cell.content, out);
1321 }
1322 }
1323}
1324
1325/// Pushes the keys of every citation segment in `segments` onto `out`.
1326fn collect_cite_segments(segments: &[Segment], out: &mut Vec<Vec<String>>) {
1327 for seg in segments {
1328 if let Segment::Cite(keys) = seg {
1329 out.push(keys.clone());
1330 }
1331 }
1332}
1333
1334// ┌───────────────────────────────────────────────────────────────────────────┐
1335// │ FRONT MATTER EXTRACTION │
1336// └───────────────────────────────────────────────────────────────────────────┘
1337
1338/// Reads the front matter the root's `#show: doc.with(...)` sets: the title and subtitle, the author and
1339/// imprint from `meta-data`, the cover the config selects for a development build, and the display sizes
1340/// from the config's type scale. A field the book omits is left `None`, and its page or line is not set.
1341fn read_front_matter(root_src: &str, config_src: &str, title: &str) -> FrontMatter {
1342 let subtitle = content_field(root_src, "subtitle");
1343 let meta = meta_block(root_src).unwrap_or_default();
1344
1345 let author = string_field(&meta, "authors").unwrap_or_default();
1346 let publisher = content_field(&meta, "publisher").map(|s| clean_content(&s));
1347 let edition = string_field(&meta, "edition");
1348 let isbn = string_field(&meta, "isbn");
1349 let copyright = copyright_line(&meta);
1350 let rights = content_field(&meta, "rights").map(|s| clean_content(&s));
1351 let ai_decl = content_field(&meta, "ai-declaration").map(|s| clean_content(&s));
1352 let website = content_field(&meta, "website").map(|s| clean_content(&s)).filter(|s| !s.is_empty());
1353 let toolchain = bool_field(&meta, "show-toolchain");
1354 let dedication = string_field(&meta, "dedication").filter(|s| s != "none" && !s.is_empty());
1355 let about = content_field(&meta, "bio").map(|s| clean_content(&s)).filter(|s| !s.is_empty());
1356 let logo = string_field(root_src, "title-logo-path");
1357
1358 // The cover the config picks: none in an interior build, the format's raster in a development one.
1359 let format = read_let_string(config_src, "format").unwrap_or_default();
1360 let mode = read_let_string(config_src, "mode").unwrap_or_default();
1361 let cover = if mode == "interior" {
1362 None
1363 } else {
1364 arm(config_src, "cover-image-path", &format).as_deref().and_then(first_quoted)
1365 };
1366
1367 // The display sizes from the type scale the format selects.
1368 let scale = arm(config_src, "type-scale", &format);
1369 let sz = |key: &str, default: f64| -> Sp {
1370 Sp::from_pt(scale.as_deref().and_then(|a| num_after(a, key)).unwrap_or(default))
1371 };
1372
1373 FrontMatter {
1374 title: title.to_string(),
1375 subtitle,
1376 author,
1377 cover_image: cover,
1378 logo_image: logo,
1379 publisher,
1380 edition,
1381 isbn,
1382 copyright,
1383 rights,
1384 ai_declaration: ai_decl,
1385 website,
1386 toolchain,
1387 dedication,
1388 about_author: about,
1389 title_size: sz("title:", 28.0),
1390 subtitle_size: sz("subtitle:", 16.0),
1391 author_size: sz("author:", 17.0),
1392 back_title_size: sz("back-matter-title:", 17.0),
1393 // A book draws its own plain centred title page, not the doc template's two-column one, so the
1394 // documentation sidebar and logos are left unset (`sidebar_grey: None` keeps the plain title page).
1395 sidebar_grey: None,
1396 sidebar_frac: 0.0,
1397 title_smallcaps: false,
1398 top_logo: None,
1399 top_logo_width: Sp::ZERO,
1400 bottom_logo: None,
1401 bottom_logo_width: Sp::ZERO,
1402 footer_logo: None,
1403 // The doc template's meta/colophon fields; a book draws its own imprint page, not the doc colophon.
1404 meta_rows: Vec::new(),
1405 reading_min: None,
1406 acknowledgement: None,
1407 }
1408}
1409
1410/// The inner text of the root's `meta-data: ( ... )` argument, balanced across nested groups and
1411/// strings, or `None` when the root sets no `meta-data`.
1412fn meta_block(src: &str) -> Option<String> {
1413 let at = src.find("meta-data:")?;
1414 let rest = &src[at + "meta-data:".len()..];
1415 let open = rest.find('(')?;
1416 let bytes = rest.as_bytes();
1417 let mut depth = 0i32;
1418 let mut in_str = false;
1419 let mut esc = false;
1420 let mut i = open;
1421 while i < bytes.len() {
1422 let c = bytes[i] as char;
1423 if in_str {
1424 if esc { esc = false; }
1425 else if c == '\\' { esc = true; }
1426 else if c == '"' { in_str = false; }
1427 i += 1;
1428 continue;
1429 }
1430 match c {
1431 '"' => in_str = true,
1432 '(' => depth += 1,
1433 ')' => {
1434 depth -= 1;
1435 if depth == 0 {
1436 return Some(rest[open + 1..i].to_string());
1437 }
1438 },
1439 _ => {},
1440 }
1441 i += 1;
1442 }
1443 None
1444}
1445
1446/// Splits a `meta-data` block into its revision rows: the text inside each top-level parenthesised tuple,
1447/// in source order. Nested parentheses and strings are respected, so a row whose value carries a comma or
1448/// a bracket is not split early. A block with no nested tuple (a bare single row) yields no rows.
1449fn meta_rows(block: &str) -> Vec<String> {
1450 let bytes = block.as_bytes();
1451 let mut rows: Vec<String> = Vec::new();
1452 let mut depth = 0i32;
1453 let mut in_str = false;
1454 let mut esc = false;
1455 let mut start = 0usize;
1456 let mut i = 0usize;
1457 while i < bytes.len() {
1458 let c = bytes[i] as char;
1459 if in_str {
1460 if esc { esc = false; }
1461 else if c == '\\' { esc = true; }
1462 else if c == '"' { in_str = false; }
1463 i += 1;
1464 continue;
1465 }
1466 match c {
1467 '"' => in_str = true,
1468 '(' => {
1469 if depth == 0 { start = i + 1; }
1470 depth += 1;
1471 },
1472 ')' => {
1473 depth -= 1;
1474 if depth == 0 {
1475 rows.push(block[start..i].to_string());
1476 }
1477 },
1478 _ => {},
1479 }
1480 i += 1;
1481 }
1482 rows
1483}
1484
1485/// The string a `name: "..."` field binds: the first `"..."` in the field's value, which runs to the
1486/// next top-level comma (a comma inside the string does not end it). `None` when the value holds no
1487/// string literal -- a `name: none` reads as absent -- so a later field's value is never read by mistake.
1488fn string_field(src: &str, name: &str) -> Option<String> {
1489 let needle = fmt!("{}:", name);
1490 let at = src.find(&needle)?;
1491 let rest = &src[at + needle.len()..];
1492 // Bound the value at the next depth-zero comma, respecting strings, so the search stays in this field.
1493 let bytes = rest.as_bytes();
1494 let mut depth = 0i32;
1495 let mut in_str = false;
1496 let mut esc = false;
1497 let mut end = rest.len();
1498 let mut i = 0usize;
1499 while i < bytes.len() {
1500 let c = bytes[i] as char;
1501 if in_str {
1502 if esc { esc = false; }
1503 else if c == '\\' { esc = true; }
1504 else if c == '"' { in_str = false; }
1505 i += 1;
1506 continue;
1507 }
1508 match c {
1509 '"' => in_str = true,
1510 '(' | '[' | '{' => depth += 1,
1511 ')' | ']' | '}' => depth -= 1,
1512 ',' if depth == 0 => { end = i; break; },
1513 _ => {},
1514 }
1515 i += 1;
1516 }
1517 first_quoted(&rest[..end])
1518}
1519
1520/// The `Copyright © YEAR HOLDER. NOTICE` line the template composes from the `copyright: (year, [holder],
1521/// notice)` tuple, or `None` when the book sets no copyright tuple.
1522fn copyright_line(meta: &str) -> Option<String> {
1523 let at = meta.find("copyright:")?;
1524 let rest = &meta[at + "copyright:".len()..];
1525 let open = rest.find('(')?;
1526 // The tuple's three parts: a year string, a `[holder]` content, and a notice string.
1527 let inner = balanced_parens(&rest[open..])?;
1528 let parts = split_top(&inner);
1529 if parts.is_empty() {
1530 return None;
1531 }
1532 let year = parts.first().map(|s| unquote_or_content(s)).unwrap_or_default();
1533 let holder = parts.get(1).map(|s| unquote_or_content(s)).unwrap_or_default();
1534 let notice = parts.get(2).map(|s| unquote_or_content(s)).unwrap_or_default();
1535 Some(fmt!("Copyright © {} {}. {}", year.trim(), holder.trim(), notice.trim()))
1536}
1537
1538/// The contents of a `(...)` at the start of `s`, balanced across nesting and strings.
1539fn balanced_parens(s: &str) -> Option<String> {
1540 let bytes = s.as_bytes();
1541 let mut depth = 0i32;
1542 let mut in_str = false;
1543 let mut esc = false;
1544 let mut i = 0usize;
1545 while i < bytes.len() {
1546 let c = bytes[i] as char;
1547 if in_str {
1548 if esc { esc = false; }
1549 else if c == '\\' { esc = true; }
1550 else if c == '"' { in_str = false; }
1551 i += 1;
1552 continue;
1553 }
1554 match c {
1555 '"' => in_str = true,
1556 '(' => depth += 1,
1557 ')' => {
1558 depth -= 1;
1559 if depth == 0 {
1560 return Some(s[1..i].to_string());
1561 }
1562 },
1563 _ => {},
1564 }
1565 i += 1;
1566 }
1567 None
1568}
1569
1570/// Splits `s` at its top-level commas, respecting nesting and strings.
1571fn split_top(s: &str) -> Vec<String> {
1572 let mut out: Vec<String> = Vec::new();
1573 let mut cur = String::new();
1574 let mut depth = 0i32;
1575 let mut in_str = false;
1576 let mut esc = false;
1577 for c in s.chars() {
1578 if in_str {
1579 cur.push(c);
1580 if esc { esc = false; }
1581 else if c == '\\' { esc = true; }
1582 else if c == '"' { in_str = false; }
1583 continue;
1584 }
1585 match c {
1586 '"' => { in_str = true; cur.push(c); },
1587 '(' | '[' | '{' => { depth += 1; cur.push(c); },
1588 ')' | ']' | '}' => { depth -= 1; cur.push(c); },
1589 ',' if depth == 0 => out.push(std::mem::take(&mut cur)),
1590 _ => cur.push(c),
1591 }
1592 }
1593 if !cur.trim().is_empty() {
1594 out.push(cur);
1595 }
1596 out
1597}
1598
1599/// Reads a tuple part as a plain string: a `"..."` literal unquoted, or a `[...]` content flattened.
1600fn unquote_or_content(part: &str) -> String {
1601 let t = part.trim();
1602 if t.starts_with('"') && t.ends_with('"') && t.len() >= 2 {
1603 return t[1..t.len() - 1].to_string();
1604 }
1605 if t.starts_with('[') && t.ends_with(']') && t.len() >= 2 {
1606 return flatten_markup(&t[1..t.len() - 1]);
1607 }
1608 t.to_string()
1609}
1610
1611/// Whether a `name: true` boolean field is set true.
1612fn bool_field(src: &str, name: &str) -> bool {
1613 let needle = fmt!("{}:", name);
1614 match src.find(&needle) {
1615 Some(at) => {
1616 let rest = &src[at + needle.len()..];
1617 let end = rest.find(',').unwrap_or(rest.len());
1618 rest[..end].trim().starts_with("true")
1619 },
1620 None => false,
1621 }
1622}
1623
1624/// A three-way boolean argument in the root's template call: `Some(true)`/`Some(false)` when the field is
1625/// set to `true`/`false`, and `None` when it is absent or left `auto`, so the caller can supply its own
1626/// default for the `auto` case.
1627fn tri_bool(src: &str, name: &str) -> Option<bool> {
1628 let needle = fmt!("{}:", name);
1629 let at = src.find(&needle)?;
1630 let rest = &src[at + needle.len()..];
1631 let end = rest.find(',').unwrap_or(rest.len());
1632 let val = rest[..end].trim();
1633 if val.starts_with("true") {
1634 Some(true)
1635 } else if val.starts_with("false") {
1636 Some(false)
1637 } else {
1638 None
1639 }
1640}
1641
1642/// Reduces a content field to a single line of display text: markup flattened, Typst line breaks (`\`)
1643/// turned to spaces, and any leftover `#name[...]` term call stripped, so an imprint or biography line
1644/// reads as plain prose.
1645fn clean_content(s: &str) -> String {
1646 let flat = flatten_markup(s);
1647 let mut out = flat.replace('\\', " ");
1648 // Drop a `#ident[...]` or `#ident("...")` term call left after flattening (e.g. `#t[website]`).
1649 while let Some(h) = out.find('#') {
1650 let tail = &out[h + 1..];
1651 let name_len = tail.chars().take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_').count();
1652 let after = &tail[name_len..];
1653 let end = if after.starts_with('[') {
1654 after.find(']').map(|e| h + 1 + name_len + e + 1)
1655 } else if after.starts_with('(') {
1656 after.find(')').map(|e| h + 1 + name_len + e + 1)
1657 } else {
1658 Some(h + 1 + name_len)
1659 };
1660 match end {
1661 Some(e) => { out.replace_range(h..e, ""); },
1662 None => break,
1663 }
1664 }
1665 out.split_whitespace().collect::<Vec<_>>().join(" ")
1666}
1667
1668/// The text of a `name: [ ... ]` content field in the root's template call -- the book title, say --
1669/// with the surrounding brackets dropped and inner whitespace trimmed. Bracket-balanced, so a nested
1670/// group does not close it early.
1671fn content_field(src: &str, name: &str) -> Option<String> {
1672 let needle = fmt!("{}:", name);
1673 let at = src.find(&needle)?;
1674 let rest = &src[at + needle.len()..];
1675 let open = rest.find('[')?;
1676 let bytes = rest.as_bytes();
1677 let mut depth = 0i32;
1678 let mut i = open;
1679 while i < bytes.len() {
1680 match bytes[i] {
1681 b'[' => depth += 1,
1682 b']' => {
1683 depth -= 1;
1684 if depth == 0 {
1685 return Some(rest[open + 1..i].trim().to_string());
1686 }
1687 },
1688 _ => {},
1689 }
1690 i += 1;
1691 }
1692 None
1693}
1694
1695// ┌───────────────────────────────────────────────────────────────────────────┐
1696// │ #let SCOPE (furniture + content bindings, collected across the whole tree) │
1697// └───────────────────────────────────────────────────────────────────────────┘
1698
1699/// The owned `#let` bindings a document compiles against: the furniture functions ([`TemplateFns`]), the
1700/// content bindings ([`ContentFns`]) and the scalar value bindings ([`lang::rules::ScalarFns`]). Held here
1701/// so [`collect_scope`] can hand back all three, and [`Self::bindings`] borrows them into a
1702/// [`lang::rules::Bindings`] to thread through the reader.
1703pub struct Scope {
1704 pub tfns: lang::rules::TemplateFns,
1705 pub cfns: lang::rules::ContentFns,
1706 pub sfns: lang::rules::ScalarFns,
1707}
1708
1709impl Scope {
1710 /// Borrows this scope's three `#let` binding kinds into a [`lang::rules::Bindings`] for the reader.
1711 pub fn bindings(&self) -> lang::rules::Bindings<'_, 'static> {
1712 lang::rules::Bindings::with_scalars(&self.tfns, &self.cfns, &self.sfns)
1713 }
1714}
1715
1716/// Collects the whole `#let` scope a document compiles against -- furniture functions, content bindings and
1717/// scalar value bindings alike -- from `main_src`, the template chain it `#import`s (imports-first, so a
1718/// definition sees the palettes and bindings its own imports supply) and each chapter it `#include`s. Shared
1719/// by the book/doc path and the lone-file path, so an `#import` resolves whether or not the document also
1720/// carries an `#include`: a lone file has none, so the include walk is simply a no-op there.
1721///
1722/// Furniture is lowered against `body_size` so every `em` resolves to an absolute and every `colours.<name>`
1723/// fill/stroke resolves against the palette; a furniture name that clashes with a built-in construct is
1724/// refused inside [`lang::rules::collect_template_fns`], and a content-binding or scalar-binding name
1725/// likewise inside [`lang::rules::collect_content_fns`]/[`lang::rules::collect_scalar_fns`], so a template's
1726/// own `#let styled-box` never shadows the reader's. A tree that defines none of the three yields three
1727/// empty maps and reads exactly as before.
1728pub fn collect_scope(main_src: &str, main_dir: &Path, body_size: Sp) -> Scope {
1729 let mut palette = lang::rules::Palette::new();
1730 let mut tfns = lang::rules::TemplateFns::new();
1731 let mut cfns = lang::rules::ContentFns::new();
1732 let mut sfns = lang::rules::ScalarFns::new();
1733 // The template chain the main source imports: builds the palette and collects furniture, content and
1734 // scalar bindings (the `#aside-box` furniture, the `#greet` content binding, a `#let title = "..."`
1735 // scalar, the palette they resolve against).
1736 for line in main_src.lines() {
1737 let t = line.trim_start();
1738 if let Some(rest) = t.strip_prefix("#import") {
1739 if let Some(rel) = first_quoted(rest) {
1740 walk_template_imports(main_dir, &rel, body_size, &mut palette, &mut tfns, &mut cfns, &mut sfns, 0);
1741 }
1742 }
1743 }
1744 // The main source's own definitions, and each included chapter's (pr-note), with the palette now in hand.
1745 lang::rules::collect_palette(main_src, &mut palette);
1746 lang::rules::collect_template_fns(main_src, body_size, &palette, &mut tfns);
1747 lang::rules::collect_content_fns(main_src, &mut cfns);
1748 lang::rules::collect_scalar_fns(main_src, &mut sfns);
1749 for line in main_src.lines() {
1750 let t = line.trim_start();
1751 if let Some(rest) = t.strip_prefix("#include") {
1752 if let Some(rel) = first_quoted(rest) {
1753 if let Ok(src) = vfs::read_to_string(&main_dir.join(&rel)) {
1754 lang::rules::collect_template_fns(&src, body_size, &palette, &mut tfns);
1755 lang::rules::collect_content_fns(&src, &mut cfns);
1756 lang::rules::collect_scalar_fns(&src, &mut sfns);
1757 }
1758 }
1759 }
1760 }
1761 Scope { tfns, cfns, sfns }
1762}
1763
1764/// Follows a local `#import "<rel>"` from `dir`, imports-first, collecting each file's `#let colours`
1765/// palette, its furniture definitions, its content bindings and its scalar value bindings -- so a file's
1766/// fills resolve against the palettes its own imports supply. A package import (`@preview/...`), a missing
1767/// file, or a cycle past the depth cap is skipped.
1768fn walk_template_imports(
1769 dir: &Path,
1770 rel: &str,
1771 body_size: Sp,
1772 palette: &mut lang::rules::Palette,
1773 tfns: &mut lang::rules::TemplateFns,
1774 cfns: &mut lang::rules::ContentFns,
1775 sfns: &mut lang::rules::ScalarFns,
1776 depth: u32,
1777)
1778{
1779 if depth > 4 || rel.starts_with('@') {
1780 return;
1781 }
1782 let path = dir.join(rel);
1783 let src = match vfs::read_to_string(&path) {
1784 Ok(s) => s,
1785 Err(_) => return,
1786 };
1787 let next_dir = path.parent().unwrap_or(dir);
1788 // Imports first, so a palette, furniture or binding this file depends on is collected before its own.
1789 for line in src.lines() {
1790 let t = line.trim_start();
1791 if let Some(rest) = t.strip_prefix("#import") {
1792 if let Some(inner_rel) = first_quoted(rest) {
1793 walk_template_imports(next_dir, &inner_rel, body_size, palette, tfns, cfns, sfns, depth + 1);
1794 }
1795 }
1796 }
1797 lang::rules::collect_palette(&src, palette);
1798 lang::rules::collect_template_fns(&src, body_size, palette, tfns);
1799 lang::rules::collect_content_fns(&src, cfns);
1800 lang::rules::collect_scalar_fns(&src, sfns);
1801}
1802
1803// ┌───────────────────────────────────────────────────────────────────────────┐
1804// │ INCLUDE FOLLOWING │
1805// └───────────────────────────────────────────────────────────────────────────┘
1806
1807/// Recursion depth cap on `#include` following. No real book nests chapters anywhere near this deep, so
1808/// hitting it is itself the sign of a self- or mutually-referential include cycle; that is reported as a
1809/// refusal rather than recursed into forever or dropped without a trace.
1810const MAX_INCLUDE_DEPTH: u32 = 64;
1811
1812/// One open `#if` include guard on the assembler's stack while it walks a file's lines. `live` records
1813/// that this guard actually decides emission: its parent branch was being kept, and its condition was one
1814/// the evaluator could resolve. A guard nested inside a dropped branch, or one whose form was refused, is
1815/// not `live` and keeps neither branch. `then_taken` is the resolved condition; `in_else` tracks which of
1816/// the two branches the walk is currently inside.
1817///
1818/// `state` is the guard's own bracket balance, seeded from its opener line so it starts at depth one: a
1819/// lone `]` deeper inside the branch (a `#block[...]`/`#align(..)[...]`/`#quote[...]` closer) is then told
1820/// apart from the guard's own matching closer by depth alone, rather than by line text -- the marker-based
1821/// extent this replaces treated any bare `]` line as the guard's end, following both branches once one
1822/// closed early and leaking the markers and the truncated tail as prose. `refused` marks a guard pushed
1823/// only to keep this bracket balance for an unsupported form already reported at its opener, so the
1824/// balance reaching zero on an ordinary body line (its own closer, not the guard's `]`/`else` shape) is not
1825/// reported a second time.
1826struct GuardFrame {
1827 live: bool,
1828 then_taken: bool,
1829 in_else: bool,
1830 refused: bool,
1831 state: lang::parse::SkipState,
1832}
1833
1834impl GuardFrame {
1835 /// Should the branch currently open under this guard have its content and includes emitted? A
1836 /// non-live guard emits from neither branch; a live one emits the then-branch when the condition held
1837 /// and the else-branch when it did not.
1838 fn emits(&self) -> bool {
1839 self.live && (self.then_taken != self.in_else)
1840 }
1841}
1842
1843/// Does this whitespace-trimmed line open an evaluable `#if` include guard -- `#if <cond> [` with the
1844/// content bracket last on the line? Returns the condition text between `#if ` and the `[`. The one-line
1845/// and non-bracket forms deliberately fail here, so [`assemble_into`] refuses rather than mis-follows them.
1846fn guard_open(marker: &str) -> Option<&str> {
1847 let Some(inner) = marker.strip_prefix("#if ") else { return None; };
1848 let Some(cond) = inner.strip_suffix('[') else { return None; };
1849 Some(cond.trim())
1850}
1851
1852/// Is this whitespace-trimmed line the `] else [` divider between an include guard's two branches,
1853/// however its own internal spacing is written (`]else[`, `] else [`)?
1854fn is_guard_else(marker: &str) -> bool {
1855 let squeezed: String = marker.chars().filter(|c| !c.is_whitespace()).collect();
1856 squeezed == "]else["
1857}
1858
1859/// Evaluates an include-guard condition to which branch to keep -- `Some(true)` for the then-branch,
1860/// `Some(false)` for the else-branch -- or `None` when the form is beyond the two the assembler reads or
1861/// its variable resolves to no value, so the caller refuses it rather than guessing.
1862///
1863/// The two forms are `<var> == "<literal>"` (kept when the resolved scalar equals the literal) and a bare
1864/// `<var>` (kept when the resolved boolean is true). The variable is resolved from the book's `config.typ`
1865/// first, then from the guard's own file -- so a book's `#import "config.typ": media` and a lone file's
1866/// own `#let` both answer.
1867fn eval_guard(cond: &str, config: &str, file_src: &str) -> Option<bool> {
1868 let cond = cond.trim();
1869 if let Some(eq) = cond.find("==") {
1870 let var = cond[..eq].trim();
1871 let rhs = cond[eq + 2..].trim();
1872 if !is_simple_ident(var) {
1873 return None;
1874 }
1875 let Some(lit) = string_literal(rhs) else { return None; };
1876 let Some(val) = guard_scalar(config, file_src, var) else { return None; };
1877 return Some(val == lit);
1878 }
1879 if is_simple_ident(cond) {
1880 return guard_bool(config, file_src, cond);
1881 }
1882 None
1883}
1884
1885/// Is `s` a single plain identifier -- a config-variable name, no operator or call around it?
1886fn is_simple_ident(s: &str) -> bool {
1887 let mut cs = s.chars();
1888 match cs.next() {
1889 Some(c) if c.is_alphabetic() || c == '_' => {},
1890 _ => return false,
1891 }
1892 cs.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
1893}
1894
1895/// The text inside a `"..."` string literal filling the whole of `s`, or `None` when `s` is not one.
1896fn string_literal(s: &str) -> Option<&str> {
1897 let Some(stripped) = s.strip_prefix('"') else { return None; };
1898 let Some(inner) = stripped.strip_suffix('"') else { return None; };
1899 // A stray interior quote would mean this is not one flat literal; the guard then refuses.
1900 if inner.contains('"') {
1901 return None;
1902 }
1903 Some(inner)
1904}
1905
1906/// The scalar an include-guard variable resolves to: the book config's binding, else the guard file's own.
1907fn guard_scalar(config: &str, file_src: &str, name: &str) -> Option<String> {
1908 read_let_string(config, name).or_else(|| read_let_string(file_src, name))
1909}
1910
1911/// The boolean an include-guard variable resolves to: the book config's binding, else the guard file's own.
1912fn guard_bool(config: &str, file_src: &str, name: &str) -> Option<bool> {
1913 read_let_bool(config, name).or_else(|| read_let_bool(file_src, name))
1914}
1915
1916/// Follows a root's `#include "..."` lines in order, reading each chapter and setting it through the
1917/// reader, and lifts each `#part-page[...]` divider to a level-1 heading so the part titles keep their
1918/// place in the flow. The root's own inline markup between the code lines is read too, in document order:
1919/// a doc root opens with a section (`= Purpose ...`) written straight in the root before its includes,
1920/// where a book root carries only the template call. Non-code lines are accumulated and flushed through
1921/// the reader at each include or part boundary, so the reader sees whole markup runs -- a heading and its
1922/// paragraphs together -- and the root's opening section keeps its place ahead of the first chapter. The
1923/// template call itself (`#show: doc.with(...)`, `#import`, `#pagebreak`) is code the reader skips and
1924/// tallies, so it never leaks into the flow.
1925///
1926/// A chapter may itself `#include` a file -- Lucronics' `chap_dynstrat_captonic_dynamics.typ` pulls in
1927/// `../evidence/lucronics_evidence.typ` this way -- so the walk is recursive: [`assemble_into`] follows
1928/// every level's own includes, each resolved against *that file's own directory*, exactly as Typst
1929/// resolves one, rather than always against the book root's.
1930///
1931/// `config` is the book's `config.typ` source (empty for the documentation idiom, which has none), so a
1932/// `#if <var> == "..."` include guard in a chapter can be resolved against the same scalars the config
1933/// binds -- `media` above all -- and only the taken branch's includes followed. See [`assemble_into`].
1934pub fn assemble(root_src: &str, root_dir: &Path, root_path: &Path, binds: lang::rules::Bindings, config: &str)
1935 -> Outcome<(Vec<Block>, lang::Refusals)>
1936{
1937 let mut blocks: Vec<Block> = Vec::new();
1938 let mut skips = lang::Refusals::default();
1939 res!(assemble_into(root_src, root_dir, root_path, binds, config, 0, &mut blocks, &mut skips));
1940 Ok((blocks, skips))
1941}
1942
1943/// The recursive body of [`assemble`]. `dir` is the directory `src` was itself read from -- the book
1944/// root's directory at depth 0, an included chapter's own directory one level down -- so a `#include
1945/// "../x.typ"` climbs relative to wherever it is written, not the top of the book. An include that
1946/// cannot be read, or one written past [`MAX_INCLUDE_DEPTH`], is recorded as a refusal rather than left
1947/// to fall through into `buf`, where the generic reader would set its raw `#include "..."` line as
1948/// literal body text -- exactly the silent-loss failure this recursion exists to close.
1949fn assemble_into(
1950 src: &str,
1951 dir: &Path,
1952 path: &Path,
1953 binds: lang::rules::Bindings,
1954 config: &str,
1955 depth: u32,
1956 blocks: &mut Vec<Block>,
1957 skips: &mut lang::Refusals,
1958)
1959 -> Outcome<()>
1960{
1961 let mut buf = String::new(); // this file's own inline markup gathered since the last boundary
1962 // This file's own inline markup (its opening section, any tail after its last include) is tagged with
1963 // its own path, exactly as an included chapter's blocks are tagged with theirs -- see `Refusal`'s doc
1964 // comment on why the span alone does not already say which file it came from.
1965 let label = path.display().to_string();
1966 // The open `#if` include guards this file is walking inside, innermost last. A branch's content and
1967 // includes are followed only when every guard on the stack is keeping its currently-open branch;
1968 // otherwise they are dropped (reported once at the guard, never leaked as prose). See [`GuardFrame`].
1969 let mut guards: Vec<GuardFrame> = Vec::new();
1970 let mut byte: u32 = 0; // running byte offset, so a refusal's span points at its own line (G4)
1971 for raw in src.split_inclusive('\n') {
1972 let start = byte;
1973 byte = byte.saturating_add(raw.len() as u32);
1974 // Strip the line terminator without treating it as a real character, exactly as the reader's own
1975 // line loop does (`lang::parse::to_blocks`), so the span below covers the line, not its newline.
1976 let mut line = raw;
1977 if let Some(s) = line.strip_suffix('\n') { line = s; }
1978 if let Some(s) = line.strip_suffix('\r') { line = s; }
1979 let end = start.saturating_add(line.len() as u32);
1980 let span = crate::ir::Span::new(start, end);
1981
1982 let t = line.trim_start();
1983 let marker = t.trim_end(); // a guard marker line, matched clear of trailing whitespace
1984 // The innermost open guard's own bracket depth (`None` with no guard open at all). Seeded from the
1985 // opener line at one, this is what tells the guard's own matching closer apart from a `]` deeper
1986 // inside its branch -- see [`GuardFrame`].
1987 let guard_depth = guards.last().map(|g| g.state.open_brackets());
1988
1989 // A guard closer `]` on its own line, exactly at the guard's own depth: close the innermost open
1990 // guard. A `]` deeper than that -- a `#block[...]`/`#align(..)[...]`/`#quote[...]` closer inside the
1991 // branch -- is not the guard's own and falls through below, to the generic per-line scan and then
1992 // to ordinary content. A lone `]` with no guard open at all is likewise ordinary content.
1993 if marker == "]" && guard_depth == Some(1) {
1994 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
1995 guards.pop();
1996 continue;
1997 }
1998 // A guard divider `] else [`, at the guard's own depth: switch the innermost guard to its else
1999 // branch. Its `]` closes the content bracket and its `[` reopens it, so the depth is unchanged and
2000 // the state is left as it stands.
2001 if is_guard_else(marker) && guard_depth == Some(1) {
2002 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2003 if let Some(top) = guards.last_mut() {
2004 top.in_else = true;
2005 }
2006 continue;
2007 }
2008 // A guard opener `#if <cond> [`: evaluate the condition against the config (and this file's own
2009 // `#let` bindings) and open a guard, seeding its bracket state from this opener line so it starts
2010 // at depth one. A guard opened inside a dropped branch, or one whose form or variable the evaluator
2011 // cannot resolve, keeps neither branch -- the latter is reported, so an unsupported guard form is
2012 // never silently followed nor leaked.
2013 if let Some(cond) = guard_open(marker) {
2014 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2015 let parent_active = guards.iter().all(|g| g.emits());
2016 let (live, then_taken) = if !parent_active {
2017 (false, false)
2018 } else {
2019 match eval_guard(cond, config, src) {
2020 Some(taken) => (true, taken),
2021 None => {
2022 skips.record(&fmt!("#if {} (unsupported include-guard form)", cond), span);
2023 skips.tag_file(&label);
2024 (false, false)
2025 },
2026 }
2027 };
2028 let mut state = lang::parse::SkipState::new();
2029 lang::parse::scan_brackets(marker, &mut state);
2030 guards.push(GuardFrame { live, then_taken, in_else: false, refused: false, state });
2031 continue;
2032 }
2033 // Any other `#if ...` line is a guard form the assembler does not evaluate (a one-line
2034 // `#if c [..] else [..]`, or a brace-bodied `#if cond {`): refuse and drop it rather than let its
2035 // raw source leak. A balanced one-liner refuses just this line, as before; a brace body still open
2036 // at the line's end pushes a refused guard so the generic per-line scan below consumes the whole
2037 // block -- its body, `} else {` and closing `}` -- instead of leaking it as prose. `#if(` with no
2038 // space is left to the reader's own code-skip path.
2039 if marker.starts_with("#if ") && guards.iter().all(|g| g.emits()) {
2040 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2041 skips.record(&fmt!("#if (unsupported include-guard form): {:?}", marker), span);
2042 skips.tag_file(&label);
2043 let mut state = lang::parse::SkipState::new();
2044 lang::parse::scan_brackets(marker, &mut state);
2045 if state.has_open_bracket() {
2046 guards.push(GuardFrame { live: false, then_taken: false, in_else: false, refused: true, state });
2047 }
2048 continue;
2049 }
2050 // Any other line while a guard is open: fold its own brackets into the innermost guard's state,
2051 // whether or not the branch it stands in emits -- a dropped branch's own `#block[...]`/`{...}` still
2052 // balances the stack, so a later real closer is not mistaken for one of these (or vice versa). If
2053 // the state closes to zero here, rather than through one of the recognised `]`/`else`/`#if` shapes
2054 // above, the guard's own bracket has just ended on an ordinary body line: a refused guard already
2055 // reported its opener, so this is its expected close and stays silent; any other guard closing this
2056 // way is a shape the guard did not predict, so it is reported rather than left to leak whatever
2057 // follows as prose. Either way the line itself is the guard's own structural end, not content, so
2058 // it is consumed here rather than falling through to the buffer below.
2059 if let Some(top) = guards.last_mut() {
2060 lang::parse::scan_brackets(line, &mut top.state);
2061 if !top.state.has_open_bracket() {
2062 let refused = top.refused;
2063 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2064 guards.pop();
2065 if !refused {
2066 skips.record(&fmt!("#if guard closed on an unrecognised line: {:?}", marker), span);
2067 skips.tag_file(&label);
2068 }
2069 continue;
2070 }
2071 }
2072 // Inside a dropped or refused branch: the content is the untaken alternative, dropped silently
2073 // (the guard already carries the report). Markers above are still tracked so the stack balances.
2074 if !guards.iter().all(|g| g.emits()) {
2075 continue;
2076 }
2077 if let Some(rest) = t.strip_prefix("#include") {
2078 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2079 match first_quoted(rest) {
2080 Some(rel) if depth >= MAX_INCLUDE_DEPTH => {
2081 skips.record(&fmt!("#include {:?} (cycle: depth exceeds {})", rel, MAX_INCLUDE_DEPTH),
2082 span);
2083 skips.tag_file(&label);
2084 },
2085 Some(rel) => {
2086 let inc_path = dir.join(&rel);
2087 let inc_src = match vfs::read_to_string(&inc_path) {
2088 Ok(s) => s,
2089 Err(e) => return Err(err!(e,
2090 "Could not read the included chapter {:?}.", inc_path; File, Read)),
2091 };
2092 let inc_dir = inc_path.parent().unwrap_or(dir);
2093 let mut chap_blocks: Vec<Block> = Vec::new();
2094 let mut chap_skips = lang::Refusals::default();
2095 res!(assemble_into(&inc_src, inc_dir, &inc_path, binds, config, depth + 1,
2096 &mut chap_blocks, &mut chap_skips));
2097 // The chapter's own top-level `#set`/`#show: doc.with(...)` declarations lower to a patch
2098 // scoped to this chapter's subtree (H1): the reader captures them but holds no theme to lower
2099 // them onto, so it is done here, where the chapter boundary is known. A chapter that declares
2100 // no styling -- every corpus chapter today -- lowers to an empty patch and nests nothing,
2101 // splicing its blocks in flat and keeping the block stream and the render byte-identical.
2102 let chap_patch = lang::set::lower_declarations(&inc_src);
2103 if chap_patch == ThemePatch::default() {
2104 blocks.extend(chap_blocks);
2105 } else {
2106 blocks.push(Block::Scoped { patch: chap_patch, blocks: chap_blocks });
2107 }
2108 skips.merge(chap_skips);
2109 },
2110 None => {
2111 // A malformed `#include` with no quoted path: reported, not left to fall through as a
2112 // literal line of body text.
2113 skips.record("#include", span);
2114 skips.tag_file(&label);
2115 },
2116 }
2117 } else if t.starts_with("#part-page") {
2118 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2119 // A part divider: its title is the last bracket group on the line. A part is a level-0 heading
2120 // -- unnumbered and centred on its own page, outside the chapter numbering -- so a chapter keeps
2121 // its number across a part boundary and a part never appears in a running head.
2122 if let Some(title) = bracket_body(t) {
2123 blocks.push(Block::heading(0, title));
2124 }
2125 } else {
2126 buf.push_str(line);
2127 buf.push('\n');
2128 }
2129 }
2130 // A guard still open at end of file never met its own closer: reported so a truncated branch is never
2131 // silently accepted as complete. A refused guard already reported its opener, so only a guard that was
2132 // genuinely live and open is reported here, to avoid a duplicate on the one already-reported form.
2133 let eof = crate::ir::Span::new(byte, byte);
2134 for g in &guards {
2135 if !g.refused {
2136 skips.record("#if guard never closed (end of file)", eof);
2137 skips.tag_file(&label);
2138 }
2139 }
2140 // The tail after the last include: back-matter markup a doc root (or the last chapter of a nested
2141 // include) closes with, if any.
2142 res!(flush_inline(&mut buf, blocks, skips, &label, binds));
2143 Ok(())
2144}
2145
2146/// Reads the accumulated inline markup through the reader, appending its blocks and merging its skips
2147/// (tagged with `file`, the root's own path -- this buffer is always the root's inline text, never a
2148/// chapter's, which is tagged separately where it is read), then clears the buffer. A buffer holding
2149/// only code and whitespace yields no blocks -- a book root's template call reduces to nothing, so the
2150/// book path is unchanged.
2151fn flush_inline(
2152 buf: &mut String,
2153 blocks: &mut Vec<Block>,
2154 skips: &mut lang::Refusals,
2155 file: &str,
2156 binds: lang::rules::Bindings,
2157)
2158 -> Outcome<()>
2159{
2160 if !buf.trim().is_empty() {
2161 let (b, mut s) = res!(lang::to_blocks_with_templates(buf, binds));
2162 s.tag_file(file);
2163 blocks.extend(b);
2164 skips.merge(s);
2165 }
2166 buf.clear();
2167 Ok(())
2168}
2169
2170/// The first double-quoted run in a slice, its contents without the quotes.
2171fn first_quoted(s: &str) -> Option<String> {
2172 let open = s.find('"')?;
2173 let rest = &s[open + 1..];
2174 let close = rest.find('"')?;
2175 Some(rest[..close].to_string())
2176}
2177
2178/// The contents of the first `[...]` group in a line, balanced so a nested bracket does not close it
2179/// early. Used to lift a `#part-page[Title]` divider's title.
2180fn bracket_body(s: &str) -> Option<String> {
2181 let open = s.find('[')?;
2182 let bytes = s.as_bytes();
2183 let mut depth = 0i32;
2184 let mut i = open;
2185 while i < bytes.len() {
2186 match bytes[i] {
2187 b'[' => depth += 1,
2188 b']' => {
2189 depth -= 1;
2190 if depth == 0 {
2191 return Some(s[open + 1..i].trim().to_string());
2192 }
2193 },
2194 _ => {},
2195 }
2196 i += 1;
2197 }
2198 None
2199}
2200
2201// ┌───────────────────────────────────────────────────────────────────────────┐
2202// │ CONFIG EXTRACTION │
2203// └───────────────────────────────────────────────────────────────────────────┘
2204
2205/// The raw type values read from a config arm, before they are turned into a [`Style`]. Kept apart from
2206/// the geometry because the geometry is complete on its own, while the style also needs the loaded font
2207/// metrics to turn an em-leading into a baseline distance.
2208struct RawStyle {
2209 body_pt: f64,
2210 leading_em: f64, // par leading, a multiple of the em
2211 par_skip_em: f64, // space between paragraphs, a multiple of the em
2212 indent_em: f64, // first-line indent, a multiple of the em
2213 chap_num_pt: f64, // the giant chapter-opener number size
2214 chap_grid: [f64; 4], // chapter-opener grid rows: number band, gap, title band, gap-to-body, in points
2215 h1_pt: f64, // chapter-title size
2216 h2_pt: f64, // level-2 sub-heading size
2217 h3_pt: f64, // level-3 sub-heading size
2218 h4_pt: f64, // level-4 sub-heading size
2219}
2220
2221/// Reads the branch of a config the `format` switch selects into a geometry and the raw type values.
2222/// A book that omits a field falls back to a readable default rather than failing, so an unfamiliar
2223/// config still assembles.
2224fn read_config(src: &str) -> Outcome<(PageGeometry, RawStyle)> {
2225 let format = match read_let_string(src, "format") {
2226 Some(f) => f,
2227 None => return Err(err!(
2228 "The config sets no `#let format = \"...\"`, so no page branch can be chosen."; Input, Missing)),
2229 };
2230
2231 let dims = arm(src, "page-dims", &format);
2232 let margins = arm(src, "page-margins", &format);
2233 let scale = arm(src, "type-scale", &format);
2234
2235 let width = dims.as_deref().and_then(|a| num_after(a, "width:")).unwrap_or(148.0);
2236 let height = dims.as_deref().and_then(|a| num_after(a, "height:")).unwrap_or(210.0);
2237 let inside = margins.as_deref().and_then(|a| num_after(a, "inside:")).unwrap_or(19.0);
2238 let outside = margins.as_deref().and_then(|a| num_after(a, "outside:")).unwrap_or(17.0);
2239 let top = margins.as_deref().and_then(|a| num_after(a, "top:")).unwrap_or(19.0);
2240 let bottom = margins.as_deref().and_then(|a| num_after(a, "bottom:")).unwrap_or(21.0);
2241
2242 let geom = PageGeometry::with_margins(
2243 Sp::from_pt(width * MM_PER_PT),
2244 Sp::from_pt(height * MM_PER_PT),
2245 Sp::from_pt(inside * MM_PER_PT),
2246 Sp::from_pt(outside * MM_PER_PT),
2247 Sp::from_pt(top * MM_PER_PT),
2248 Sp::from_pt(bottom * MM_PER_PT),
2249 );
2250
2251 let body_pt = arm(src, "body-text-size", &format).as_deref().and_then(first_num).unwrap_or(11.0);
2252 let leading_em = arm(src, "body-line-spacing", &format).as_deref().and_then(first_num).unwrap_or(0.75);
2253 let par_skip_em = arm(src, "body-par-spacing", &format).as_deref().and_then(first_num).unwrap_or(0.75);
2254 let indent_em = arm(src, "body-par-indent", &format).as_deref().and_then(first_num).unwrap_or(0.0);
2255 let chap_num_pt = scale.as_deref().and_then(|a| num_after(a, "chapter-num:")).unwrap_or(54.0);
2256 // The chapter-opener grid rows: the number band, the gap below it, the title band, and the gap down to
2257 // the body. Absent, the opener falls back to spacers roughly matching a 20 pt body scale.
2258 let grid = scale.as_deref().and_then(|a| tuple_after(a, "chapter-grid-rows:")).unwrap_or_default();
2259 let chap_grid = [
2260 grid.first().copied().unwrap_or(72.0),
2261 grid.get(1).copied().unwrap_or(8.0),
2262 grid.get(2).copied().unwrap_or(36.0),
2263 grid.get(3).copied().unwrap_or(20.0),
2264 ];
2265 let h1_pt = scale.as_deref().and_then(|a| num_after(a, "chapter-title:")).unwrap_or(20.0);
2266 // The template sizes a sub-heading by `sub-headings.at(level - 1)`: level 2 takes the second entry,
2267 // level 3 the third, level 4 the fourth. The first entry is the section-title reserve, unused by the
2268 // show rule, so the level-2 heading is only a step above the body.
2269 let subs = scale.as_deref().and_then(|a| tuple_after(a, "sub-headings:")).unwrap_or_default();
2270 let h2_pt = subs.get(1).copied().unwrap_or(12.5);
2271 let h3_pt = subs.get(2).copied().unwrap_or(11.5);
2272 let h4_pt = subs.get(3).copied().unwrap_or(11.0);
2273
2274 Ok((geom, RawStyle { body_pt, leading_em, par_skip_em, indent_em, chap_num_pt, chap_grid, h1_pt, h2_pt, h3_pt, h4_pt }))
2275}
2276
2277/// Turns the raw config values into a [`Theme`]. The leading is the one derived value: the config sets
2278/// a gap in ems, and the driver wants a baseline-to-baseline distance, so the Libertinus line box (the
2279/// theme's [`ThemeCalibration::line_box_em`](crate::theme::ThemeCalibration), which documents the
2280/// calibration) is added to it -- what puts the line grid on the oracle's.
2281fn build_style(raw: &RawStyle) -> Theme {
2282 let mut style = Theme::default();
2283 let baseline = (style.calibration.line_box_em + raw.leading_em) * raw.body_pt;
2284
2285 style.text.body_size = Sp::from_pt(raw.body_pt);
2286 style.text.leading = Sp::from_pt(baseline);
2287 style.par.skip = Sp::from_pt(raw.par_skip_em * raw.body_pt);
2288 style.par.indent = Sp::from_pt(raw.indent_em * raw.body_pt);
2289 style.opener.chap_num_size = Sp::from_pt(raw.chap_num_pt);
2290 style.opener.chap_grid = [
2291 Sp::from_pt(raw.chap_grid[0]),
2292 Sp::from_pt(raw.chap_grid[1]),
2293 Sp::from_pt(raw.chap_grid[2]),
2294 Sp::from_pt(raw.chap_grid[3]),
2295 ];
2296 style.heading.levels[0].size = Sp::from_pt(raw.h1_pt);
2297 style.heading.levels[1].size = Sp::from_pt(raw.h2_pt);
2298 style.heading.levels[2].size = Sp::from_pt(raw.h3_pt);
2299 style.heading.levels[3].size = Sp::from_pt(raw.h4_pt);
2300 style
2301}
2302
2303/// The string a `#let <name> = "..."` binds, if the config sets one as a plain literal.
2304fn read_let_string(src: &str, name: &str) -> Option<String> {
2305 let needle = fmt!("#let {} =", name);
2306 let at = src.find(&needle)?;
2307 let rest = &src[at + needle.len()..];
2308 first_quoted(rest)
2309}
2310
2311/// The boolean a `#let <name> = true` / `= false` binds, if the source sets one as a plain literal. A
2312/// binding to anything else (a string, an expression) is not a boolean an include guard can test, so it
2313/// yields `None` and the guard refuses rather than inventing a truth value.
2314fn read_let_bool(src: &str, name: &str) -> Option<bool> {
2315 let needle = fmt!("#let {} =", name);
2316 let Some(at) = src.find(&needle) else { return None; };
2317 let rest = src[at + needle.len()..].trim_start();
2318 let tok: String = rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
2319 match tok.as_str() {
2320 "true" => Some(true),
2321 "false" => Some(false),
2322 _ => None,
2323 }
2324}
2325
2326/// The body of the `if`/`else if` arm a `#let <name> = if format == "<fmt>" {...}` chain selects for
2327/// `fmt`. Bounds the search to the one `#let` so a later binding's arms are not read by mistake, finds
2328/// the arm whose condition tests this format, and returns its balanced `{...}` body.
2329fn arm(src: &str, name: &str, fmt: &str) -> Option<String> {
2330 let needle = fmt!("#let {} =", name);
2331 let start = src.find(&needle)?;
2332 let tail = &src[start + needle.len()..];
2333 // The binding ends at the next top-level `#let`, or the end of the file.
2334 let end = tail.find("\n#let ").unwrap_or(tail.len());
2335 let block = &tail[..end];
2336
2337 let cond = fmt!("== \"{}\"", fmt);
2338 let at = block.find(&cond)?;
2339 let after = &block[at..];
2340 let brace = after.find('{')?;
2341 balanced_braces(&after[brace..])
2342}
2343
2344/// The contents of a `{...}` at the start of `s`, matched by brace depth so a nested record does not
2345/// close it early.
2346fn balanced_braces(s: &str) -> Option<String> {
2347 let bytes = s.as_bytes();
2348 let mut depth = 0i32;
2349 let mut i = 0usize;
2350 while i < bytes.len() {
2351 match bytes[i] {
2352 b'{' => depth += 1,
2353 b'}' => {
2354 depth -= 1;
2355 if depth == 0 {
2356 return Some(s[1..i].to_string());
2357 }
2358 },
2359 _ => {},
2360 }
2361 i += 1;
2362 }
2363 None
2364}
2365
2366/// The first number after `key` in `s` -- the digits and one decimal point that follow the key. The
2367/// unit (`mm`, `pt`, `em`) is known from the key, so it is read off and dropped.
2368fn num_after(s: &str, key: &str) -> Option<f64> {
2369 let at = s.find(key)?;
2370 first_num(&s[at + key.len()..])
2371}
2372
2373/// The first number appearing anywhere in `s`, as an `f64` -- the leading numeric run after any
2374/// non-numeric lead-in. `11pt` and `0.75em` both read as their number.
2375fn first_num(s: &str) -> Option<f64> {
2376 let bytes = s.as_bytes();
2377 let mut i = 0usize;
2378 // Skip to the first digit or a decimal point that starts a number.
2379 while i < bytes.len() && !(bytes[i].is_ascii_digit() || bytes[i] == b'.') {
2380 i += 1;
2381 }
2382 let begin = i;
2383 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
2384 i += 1;
2385 }
2386 if i == begin {
2387 return None;
2388 }
2389 s[begin..i].parse::<f64>().ok()
2390}
2391
2392/// The numbers of the first `( ... )` tuple after `key` -- `sub-headings: (15pt, 12.5pt, ...)` reads as
2393/// `[15.0, 12.5, ...]`.
2394fn tuple_after(s: &str, key: &str) -> Option<Vec<f64>> {
2395 let at = s.find(key)?;
2396 let after = &s[at + key.len()..];
2397 let open = after.find('(')?;
2398 let close = after[open..].find(')')?;
2399 let inner = &after[open + 1..open + close];
2400 let nums: Vec<f64> = inner.split(',').filter_map(first_num).collect();
2401 Some(nums)
2402}
2403
2404// ┌───────────────────────────────────────────────────────────────────────────┐
2405// │ DOC TEMPLATE EXTRACTION HELPERS │
2406// └───────────────────────────────────────────────────────────────────────────┘
2407
2408/// The trim of a named paper, in millimetres. Only the sizes the doc trees reach for are tabulated;
2409/// an unknown name falls to A4, so a doc that sets an exotic paper still lands on a readable page.
2410fn paper_dims_mm(name: &str) -> (f64, f64) {
2411 match name {
2412 "a3" => (297.0, 420.0),
2413 "a4" => (210.0, 297.0),
2414 "a5" => (148.0, 210.0),
2415 "us-letter" => (215.9, 279.4),
2416 "us-legal" => (215.9, 355.6),
2417 _ => (210.0, 297.0),
2418 }
2419}
2420
2421/// The first `"..."` string after `key` anywhere in `src` -- `paper: "a4"` reads as `a4`. Used to read a
2422/// bare `name: "value"` setting that is not bounded by the field machinery the book path needs.
2423fn first_quoted_after(src: &str, key: &str) -> Option<String> {
2424 let at = src.find(key)?;
2425 first_quoted(&src[at + key.len()..])
2426}
2427
2428/// The value bound to `field` inside a top-level `#let <dict> = ( ... )` dictionary -- the `a4:` entry of
2429/// the template's `#let margins = (a4: 2.5cm, ...)`, say. The dictionary is matched by paren depth from
2430/// the `#let`, and the field's value runs to the next depth-zero comma, so a nested group does not end it.
2431fn let_dict_field(src: &str, dict: &str, field: &str) -> Option<String> {
2432 let needle = fmt!("#let {} =", dict);
2433 let start = src.find(&needle)?;
2434 let tail = &src[start + needle.len()..];
2435 let open = tail.find('(')?;
2436 let body = balanced_parens(&tail[open..])?;
2437 // Within the dictionary body, find `field:` and take its value up to the next top-level comma.
2438 let key = fmt!("{}:", field);
2439 let at = body.find(&key)?;
2440 let rest = &body[at + key.len()..];
2441 let bytes = rest.as_bytes();
2442 let mut depth = 0i32;
2443 let mut end = rest.len();
2444 let mut i = 0usize;
2445 while i < bytes.len() {
2446 match bytes[i] as char {
2447 '(' | '[' | '{' => depth += 1,
2448 ')' | ']' | '}' => depth -= 1,
2449 ',' if depth == 0 => { end = i; break; },
2450 _ => {},
2451 }
2452 i += 1;
2453 }
2454 Some(rest[..end].trim().to_string())
2455}
2456
2457/// A Typst length token as points: the leading number scaled by its unit (`cm`, `mm`, `in`, `pt`). A
2458/// bare number with no unit reads as points. `None` when no number leads the slice.
2459fn parse_len_pt(s: &str) -> Option<f64> {
2460 let n = first_num(s)?;
2461 let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == '-' || c.is_whitespace());
2462 let per_pt = if unit.starts_with("cm") {
2463 10.0 * MM_PER_PT
2464 } else if unit.starts_with("mm") {
2465 MM_PER_PT
2466 } else if unit.starts_with("in") {
2467 72.0
2468 } else {
2469 1.0 // `pt` or unitless
2470 };
2471 Some(n * per_pt)
2472}
2473
2474/// The first length after `key` in `src`, in points -- `text-size: 11pt` reads as `11.0`.
2475fn first_len_after(src: &str, key: &str) -> Option<f64> {
2476 let at = src.find(key)?;
2477 parse_len_pt(&src[at + key.len()..])
2478}
2479
2480#[cfg(test)]
2481mod tests {
2482 use super::*;
2483
2484 // A miniature two-format config with the shape the real books use: a `format` switch and a chain of
2485 // `if format == "..." {...}` arms per setting.
2486 const CFG: &str = r#"
2487#let format = "ingram-5x8"
2488#let page-dims = if format == "ingram-5x8" {
2489 (width: 127mm, height: 203mm)
2490} else {
2491 (width: 148mm, height: 210mm)
2492}
2493#let page-margins = if format == "ingram-5x8" {
2494 (inside: 17mm, outside: 15mm, top: 18mm, bottom: 18mm)
2495} else {
2496 (inside: 19mm, outside: 17mm, top: 19mm, bottom: 21mm)
2497}
2498#let body-text-size = if format == "ingram-5x8" { 11pt } else { 12pt }
2499#let body-line-spacing = if format == "ingram-5x8" { 0.75em } else { 0.75em }
2500#let body-par-spacing = if format == "ingram-5x8" { 0.75em } else { 0.75em }
2501#let type-scale = if format == "ingram-5x8" {
2502 ( title: 24pt, chapter-title: 20pt, sub-headings: (15pt, 12.5pt, 11.5pt, 11pt) )
2503} else {
2504 ( title: 27pt, chapter-title: 23pt, sub-headings: (17pt, 14.5pt, 13pt, 12.5pt) )
2505}
2506"#;
2507
2508 #[test]
2509 fn test_the_selected_format_arm_is_read_00() -> Outcome<()> {
2510 let (geom, raw) = res!(read_config(CFG));
2511 // 127 mm and 203 mm in points, not the a5 fallback branch.
2512 assert_eq!(geom.width.to_pt().round() as i64, 360, "width should be 127 mm = 360 pt");
2513 assert_eq!(geom.height.to_pt().round() as i64, 575, "height should be 203 mm = 575 pt");
2514 // Mirror margins: inside binds wider than the fore-edge.
2515 assert_eq!(geom.inside.to_pt().round() as i64, 48, "inside 17 mm = 48 pt");
2516 assert_eq!(geom.outside.to_pt().round() as i64, 43, "outside 15 mm = 43 pt");
2517 assert!(geom.inside > geom.outside, "the binding margin is the wider of the two");
2518 assert!((raw.body_pt - 11.0).abs() < 1e-9, "body 11 pt, found {}", raw.body_pt);
2519 assert!((raw.h1_pt - 20.0).abs() < 1e-9, "h1 = chapter-title 20 pt, found {}", raw.h1_pt);
2520 // The level-2 heading takes sub-headings[1], not the first (section-title) entry.
2521 assert!((raw.h2_pt - 12.5).abs() < 1e-9, "h2 = sub-heading[1] 12.5 pt, found {}", raw.h2_pt);
2522 assert!((raw.h3_pt - 11.5).abs() < 1e-9, "h3 = sub-heading[2] 11.5 pt, found {}", raw.h3_pt);
2523 assert!((raw.h4_pt - 11.0).abs() < 1e-9, "h4 = sub-heading[3] 11 pt, found {}", raw.h4_pt);
2524 Ok(())
2525 }
2526
2527 #[test]
2528 fn test_the_mirror_shift_moves_a_verso_to_the_fore_edge_01() -> Outcome<()> {
2529 let (geom, _) = res!(read_config(CFG));
2530 // Recto content starts at the inside margin; the verso shift lands it at the outside one.
2531 let verso_left = geom.content_left() + geom.mirror_shift();
2532 assert_eq!(verso_left, geom.outside, "a shifted verso page's left edge is the fore-edge margin");
2533 Ok(())
2534 }
2535
2536 #[test]
2537 fn test_a_root_with_includes_reads_as_a_book_02() {
2538 assert!(is_book_root("#show: doc.with()\n#include \"chap_01.typ\"\n"));
2539 assert!(!is_book_root("= A lone heading\n\nSome prose.\n"));
2540 }
2541
2542 #[test]
2543 fn test_a_typst_length_reads_in_points_04() -> Outcome<()> {
2544 assert!((res!(parse_len_pt("11pt").ok_or_else(|| err!("no number"; Test, Bug))) - 11.0).abs() < 1e-9);
2545 // 2.5 cm = 25 mm = 25 * 72 / 25.4 = 70.866 pt.
2546 let cm = res!(parse_len_pt("2.5cm").ok_or_else(|| err!("no number"; Test, Bug)));
2547 assert!((cm - 70.866).abs() < 1e-2, "2.5 cm should be ~70.87 pt, found {}", cm);
2548 let mm = res!(parse_len_pt("18mm").ok_or_else(|| err!("no number"; Test, Bug)));
2549 assert!((mm - 51.024).abs() < 1e-2, "18 mm should be ~51.02 pt, found {}", mm);
2550 // A bare number reads as points.
2551 assert!((res!(parse_len_pt("150").ok_or_else(|| err!("no number"; Test, Bug))) - 150.0).abs() < 1e-9);
2552 Ok(())
2553 }
2554
2555 #[test]
2556 fn test_the_margin_dict_field_and_paper_read_05() -> Outcome<()> {
2557 let tmpl = r#"
2558#let margins = (
2559 a4: 2.5cm,
2560 title_page: 45%,
2561 section_header: 150pt,
2562)
2563#let doc(body) = {
2564 set page(paper: "a4", margin: (top: 0pt))
2565 body
2566}
2567"#;
2568 let a4 = res!(let_dict_field(tmpl, "margins", "a4").ok_or_else(|| err!("no a4 field"; Test, Bug)));
2569 assert_eq!(a4, "2.5cm", "the margins.a4 entry is the 2.5cm length");
2570 let paper = res!(first_quoted_after(tmpl, "paper:").ok_or_else(|| err!("no paper"; Test, Bug)));
2571 assert_eq!(paper, "a4", "the page paper is a4");
2572 let (w, h) = paper_dims_mm(&paper);
2573 assert_eq!((w as i64, h as i64), (210, 297), "a4 is 210 by 297 mm");
2574 Ok(())
2575 }
2576
2577 #[test]
2578 fn test_doc_front_matter_reads_title_subtitle_author_06() {
2579 let root = r#"
2580#show: doc.with(
2581 title: [Austenite],
2582 subtitle: [Design Document],
2583 text-size: 11pt,
2584 meta-data: (
2585 ( version: "0.1.0", authors: "J. D. Hoogland", notes: "Initial." ),
2586 ),
2587)
2588= Purpose
2589"#;
2590 let raw = RawStyle {
2591 body_pt: 11.0, leading_em: 0.65, par_skip_em: 0.65, indent_em: 0.0,
2592 chap_num_pt: 54.0, chap_grid: [72.0, 8.0, 36.0, 20.0],
2593 h1_pt: 14.0, h2_pt: 12.0, h3_pt: 13.0, h4_pt: 12.0,
2594 };
2595 let fm = read_doc_front_matter(std::path::Path::new("/nonexistent"), root, &raw, "Austenite");
2596 assert_eq!(fm.title, "Austenite");
2597 assert_eq!(fm.subtitle.as_deref(), Some("Design Document"));
2598 assert_eq!(fm.author, "J. D. Hoogland");
2599 // A doc tree carries no book imprint (no ISBN), but it does compose the template's colophon: the
2600 // revision fields, the copyright line and the acknowledgement paragraph the meta page seats.
2601 assert!(fm.isbn.is_none(), "a doc tree carries no book imprint");
2602 assert_eq!(fm.meta_rows.len(), 1, "one revision row");
2603 assert_eq!(fm.meta_rows[0].version.as_deref(), Some("0.1.0"));
2604 assert_eq!(fm.meta_rows[0].notes.as_deref(), Some("Initial."));
2605 assert!(fm.copyright.as_deref().unwrap_or_default().contains("All rights reserved."),
2606 "a doc meta page carries a copyright line");
2607 assert!(fm.acknowledgement.is_some(), "a doc meta page carries an acknowledgement");
2608 // A doc tree always draws the template's two-column title page, so the sidebar is marked even when
2609 // the miniature root names no colour; the fraction falls to the template default with no template file.
2610 assert!(fm.sidebar_grey.is_some(), "a doc title page draws the sidebar");
2611 assert!((fm.sidebar_frac - 0.45).abs() < 1e-9, "the sidebar default fraction is 0.45");
2612 }
2613
2614 #[test]
2615 fn test_doc_front_matter_reads_declaration_mark_07() {
2616 let root = r#"
2617#show: doc.with(
2618 title: [Austenite],
2619 footer-left-logo-path: "assets/svg/fe2o3_logo_text_right.svg",
2620 meta-data: (
2621 ( version: "0.1.0", date: "12026-08-08", authors: "J. D. Hoogland", declaration: "with-ai", notes: "Created." ),
2622 ),
2623)
2624= Purpose
2625"#;
2626 let raw = RawStyle {
2627 body_pt: 11.0, leading_em: 0.65, par_skip_em: 0.65, indent_em: 0.0,
2628 chap_num_pt: 54.0, chap_grid: [72.0, 8.0, 36.0, 20.0],
2629 h1_pt: 14.0, h2_pt: 12.0, h3_pt: 13.0, h4_pt: 12.0,
2630 };
2631 let fm = read_doc_front_matter(std::path::Path::new("/nonexistent"), root, &raw, "Austenite");
2632 assert_eq!(fm.meta_rows.len(), 1, "one revision row");
2633 let mr = &fm.meta_rows[0];
2634 assert_eq!(mr.date.as_deref(), Some("12026-08-08"));
2635 assert_eq!(mr.ai_mark_words.as_deref(), Some("Made with AI"));
2636 assert!(mr.ai_mark_path.as_deref().unwrap_or_default().ends_with("doc_made_with_ai_opt.svg"),
2637 "the with-ai slug picks the doc mark image");
2638 assert_eq!(fm.footer_logo.as_deref(), Some("assets/svg/fe2o3_logo_text_right.svg"));
2639 }
2640
2641 #[test]
2642 fn test_doc_front_matter_reads_multiple_rows_and_custom_words_09() {
2643 let root = r#"
2644#show: doc.with(
2645 title: [Hematite],
2646 meta-data: (
2647 ( version: "2.0.0", date: "12026-04-11", authors: "J. D. Hoogland", declaration: "entirely-ai", declaration-words: "Additions made entirely with AI", notes: "Restructured." ),
2648 ( version: "1.0.0", date: "12023-10-01", authors: "J. D. Hoogland", declaration: "some-ai", notes: "Initial release." ),
2649 ),
2650)
2651= Purpose
2652"#;
2653 let raw = RawStyle {
2654 body_pt: 11.0, leading_em: 0.65, par_skip_em: 0.65, indent_em: 0.0,
2655 chap_num_pt: 54.0, chap_grid: [72.0, 8.0, 36.0, 20.0],
2656 h1_pt: 14.0, h2_pt: 12.0, h3_pt: 13.0, h4_pt: 12.0,
2657 };
2658 let fm = read_doc_front_matter(std::path::Path::new("/nonexistent"), root, &raw, "Hematite");
2659 assert_eq!(fm.meta_rows.len(), 2, "both revision rows are read");
2660 assert_eq!(fm.meta_rows[0].version.as_deref(), Some("2.0.0"));
2661 // A `declaration-words` rescopes the caption without changing the mark image.
2662 assert_eq!(fm.meta_rows[0].ai_mark_words.as_deref(), Some("Additions made entirely with AI"));
2663 assert!(fm.meta_rows[0].ai_mark_path.as_deref().unwrap_or_default().ends_with("doc_made_with_ai_entirely_opt.svg"));
2664 assert_eq!(fm.meta_rows[1].version.as_deref(), Some("1.0.0"));
2665 assert_eq!(fm.meta_rows[1].ai_mark_words.as_deref(), Some("Made with some AI"));
2666 }
2667
2668 #[test]
2669 fn test_doc_front_matter_reads_title_page_logos_08() {
2670 let root = r#"
2671#show: doc.with(
2672 title: [Austenite],
2673 subtitle: [Design Document],
2674 title-colour: "lightgrey",
2675 title-smallcaps: true,
2676 title-top-logo-path: "assets/svg/austenite_logo_text_right.svg",
2677 title-top-logo-width: 150pt,
2678 title-bottom-logo-path: "assets/svg/oxedyne_logo_dark_text_below_opt.svg",
2679 title-bottom-logo-width: 120pt,
2680 footer-left-logo-path: "assets/svg/fe2o3_logo_text_right.svg",
2681 meta-data: (
2682 ( version: "0.1.0", authors: "J. D. Hoogland", notes: "Initial." ),
2683 ),
2684)
2685= Purpose
2686"#;
2687 let raw = RawStyle {
2688 body_pt: 11.0, leading_em: 0.65, par_skip_em: 0.65, indent_em: 0.0,
2689 chap_num_pt: 54.0, chap_grid: [72.0, 8.0, 36.0, 20.0],
2690 h1_pt: 14.0, h2_pt: 12.0, h3_pt: 13.0, h4_pt: 12.0,
2691 };
2692 let fm = read_doc_front_matter(std::path::Path::new("/nonexistent"), root, &raw, "Austenite");
2693 assert_eq!(fm.sidebar_grey, Some(240), "lightgrey resolves to luma 240");
2694 assert!(fm.title_smallcaps, "the title sets in small caps");
2695 assert_eq!(fm.top_logo.as_deref(), Some("assets/svg/austenite_logo_text_right.svg"));
2696 assert_eq!(fm.bottom_logo.as_deref(), Some("assets/svg/oxedyne_logo_dark_text_below_opt.svg"));
2697 assert_eq!(fm.footer_logo.as_deref(), Some("assets/svg/fe2o3_logo_text_right.svg"));
2698 assert!((fm.top_logo_width.to_pt() - 150.0).abs() < 1e-6, "top logo width 150 pt");
2699 assert!((fm.bottom_logo_width.to_pt() - 120.0).abs() < 1e-6, "bottom logo width 120 pt");
2700 }
2701
2702 #[test]
2703 fn test_root_inline_markup_is_read_before_includes_07() -> Outcome<()> {
2704 let dir = std::path::Path::new("/nonexistent");
2705 // A doc root opens with an inline section written straight in the root, ahead of its includes (the
2706 // Austenite design's `= Purpose`). With no include present, only that inline markup is read; its
2707 // heading and paragraph must both survive, and the template call above them must be skipped, not set.
2708 let root = "#import \"template.typ\": *\n#show: doc.with(title: [X])\n\n= Purpose\n\nAustenite is an engine.\n";
2709 let (blocks, _skips) = res!(assemble(root, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2710 assert!(
2711 blocks.iter().any(|b| matches!(b, Block::Heading { level: 1, .. })),
2712 "the root's inline level-1 heading is read into the flow");
2713 assert!(
2714 blocks.iter().any(|b| matches!(b, Block::Paragraph { .. } | Block::RichParagraph { .. })),
2715 "the inline paragraph beneath the heading is read too");
2716 Ok(())
2717 }
2718
2719 /// A `#if media == "..." [ ... ] else [ ... ]` include guard is evaluated, not both-branches-followed:
2720 /// only the taken branch's content survives, and the `#if`/`] else [`/`]` marker lines never leak as
2721 /// prose. The scalar resolves from the book config first (so a real `#import "config.typ": media`
2722 /// answers), then from the guard's own file; an unsupported guard form is refused and reported, never
2723 /// guessed or leaked. This is the DEFECT B regression gate at the unit level, beside the oracle fixture.
2724 #[test]
2725 fn if_media_guard_follows_only_the_taken_branch_08() -> Outcome<()> {
2726 let dir = std::path::Path::new("/nonexistent");
2727 let root = "#let media = \"ebook\"\n\nIntro paragraph.\n\n#if media == \"ebook\" [\nEbook only paragraph.\n] else [\nPrint only paragraph.\n]\n\nTail paragraph.\n";
2728
2729 // File-local `#let media = "ebook"`, no config: the then-branch is taken.
2730 let (blocks, _skips) = res!(assemble(root, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2731 let body = fmt!("{:?}", blocks);
2732 assert!(body.contains("Intro paragraph") && body.contains("Tail paragraph"),
2733 "prose around the guard must survive: {}", body);
2734 assert!(body.contains("Ebook only paragraph"), "the taken branch must render: {}", body);
2735 assert!(!body.contains("Print only paragraph"), "the untaken branch must be dropped: {}", body);
2736 assert!(!body.contains("] else [") && !body.contains("#if "),
2737 "no guard marker line may leak as prose: {}", body);
2738
2739 // The book config binds `media = "print"` and takes precedence over the file's own `#let`: the
2740 // else-branch is taken instead, proving the config-first resolution the real books rely on.
2741 let (blocks, _skips) = res!(assemble(root, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), "#let media = \"print\"\n"));
2742 let body = fmt!("{:?}", blocks);
2743 assert!(body.contains("Print only paragraph"), "config `media` selects the else-branch: {}", body);
2744 assert!(!body.contains("Ebook only paragraph"), "the ebook branch is dropped under the config: {}", body);
2745
2746 // An unsupported guard form is refused and reported, not followed nor leaked.
2747 let odd = "#if media > 3 [\nSomething.\n]\n";
2748 let (blocks, skips) = res!(assemble(odd, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2749 let body = fmt!("{:?}", blocks);
2750 assert!(!body.contains("Something"), "a refused guard follows neither branch: {}", body);
2751 assert!(skips.report().map(|r| r.contains("#if")).unwrap_or(false),
2752 "a refused guard form must be reported: {:?}", skips.report());
2753 Ok(())
2754 }
2755
2756 /// A lone `]` line deeper inside a taken branch -- here an `#emph[...]` aside's own closer -- must not
2757 /// be mistaken for the guard's own closing bracket (G1). Before the bracket-depth extent, ANY bare `]`
2758 /// line closed the guard: the aside's closer ended it early, so the rest of the taken branch, the
2759 /// guard's own markers and the untaken branch all leaked into the body as prose from that point on.
2760 #[test]
2761 fn if_guard_bracket_extent_survives_an_inner_content_closer() -> Outcome<()> {
2762 let dir = std::path::Path::new("/nonexistent");
2763 let root = "#let media = \"ebook\"\n\n#if media == \"ebook\" [\nEbook lead-in with an aside: #emph[\nspanning more than one line\n]\nand the branch continues here.\n] else [\nPrint branch text.\n]\n\nTail paragraph.\n";
2764 let (blocks, skips) = res!(assemble(root, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2765 let body = fmt!("{:?}", blocks);
2766 assert!(body.contains("Ebook lead-in") && body.contains("spanning more than one line")
2767 && body.contains("and the branch continues here"),
2768 "the taken branch's own content, before AND after the inner closer, must all survive: {}", body);
2769 assert!(body.contains("Tail paragraph"), "prose after the guard must still survive: {}", body);
2770 assert!(!body.contains("Print branch text"), "the untaken branch must stay dropped: {}", body);
2771 assert!(!body.contains("] else [") && !body.contains("#if "),
2772 "no guard marker line may leak as prose: {}", body);
2773 // The reader's own `#let` skip is expected and unrelated; the guard itself must report nothing.
2774 assert!(!skips.report().map(|r| r.contains("#if")).unwrap_or(false),
2775 "a correctly bracket-tracked guard reports no #if refusal of its own: {:?}", skips.report());
2776 Ok(())
2777 }
2778
2779 /// A brace-bodied `#if <cond> { ... } else { ... }` guard is a form the assembler does not evaluate,
2780 /// but its refusal must consume the whole block -- body, `} else {` divider and closing `}` -- rather
2781 /// than only the opener line (G2). Before the fix, only `#if ... {` itself was refused; everything
2782 /// after it fell through as ordinary prose.
2783 #[test]
2784 fn if_brace_bodied_form_is_refused_as_one_block_not_leaked() -> Outcome<()> {
2785 let dir = std::path::Path::new("/nonexistent");
2786 let root = "Intro.\n\n#if media == \"ebook\" {\n let x = 1\n} else {\n let x = 2\n}\n\nTail.\n";
2787 let (blocks, skips) = res!(assemble(root, dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2788 let body = fmt!("{:?}", blocks);
2789 assert!(body.contains("Intro") && body.contains("Tail"), "prose around the guard must survive: {}", body);
2790 assert!(!body.contains("let x") && !body.contains("} else {"),
2791 "the brace-bodied guard's body and its else divider must not leak as prose: {}", body);
2792 assert_eq!(skips.total(), 1,
2793 "exactly one refusal for the whole brace-bodied guard, not one per leaked line: {:?}", skips.sites());
2794 Ok(())
2795 }
2796
2797 #[test]
2798 fn test_a_part_page_divider_lifts_to_a_heading_03() -> Outcome<()> {
2799 let dir = std::path::Path::new("/nonexistent");
2800 let (blocks, _skips) = res!(assemble("#part-page(label: \"Part\")[The Pattern]\n", dir, &dir.join("root.typ"), lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2801 assert_eq!(blocks.len(), 1, "one divider, one heading");
2802 match &blocks[0] {
2803 Block::Heading { level, segments, .. } => {
2804 assert_eq!(*level, 0, "a part divider is a level-0 heading, outside the chapter numbering");
2805 let title = segments.iter().map(|s| match s {
2806 Segment::Text(t) => t.clone(),
2807 _ => String::new(),
2808 }).collect::<String>();
2809 assert_eq!(title, "The Pattern", "the title is the bracket body");
2810 },
2811 other => return Err(err!("expected a heading, found {:?}", other; Test, Bug)),
2812 }
2813 Ok(())
2814 }
2815
2816 /// The term-dict reader picks the `#let term-dict = (...)` assignment, not the `// term-dict: ...`
2817 /// comment above it whose own parentheses would otherwise be read as the literal.
2818 #[test]
2819 fn test_term_dict_reader_skips_the_comment_04() {
2820 let src = r#"
2821// term-dict: key -> display value (plain strings)
2822#let term-dict = (
2823 "org": "Elearnity Pty Ltd",
2824 "website": "elearnity.oxegen.io",
2825 "iniverse": "iniverse",
2826)
2827"#;
2828 let map = parse_term_dict(src);
2829 assert_eq!(map.get("org").map(String::as_str), Some("Elearnity Pty Ltd"));
2830 assert_eq!(map.get("website").map(String::as_str), Some("elearnity.oxegen.io"));
2831 assert_eq!(map.get("iniverse").map(String::as_str), Some("iniverse"));
2832 assert_eq!(map.len(), 3, "unexpected entries: {:?}", map);
2833 }
2834
2835 /// A lone chapter finds a `refs.bib` in an ancestor directory, marks the key it cited, and returns a
2836 /// bibliography that resolves that key to an author-year citation rather than the raw key.
2837 #[test]
2838 fn test_lone_chapter_resolves_its_citation_05() -> Outcome<()> {
2839 // A unique scratch tree: refs.bib at the top, the chapter one level down, so the walk-up finds it.
2840 let base = std::env::temp_dir().join(fmt!("austenite-bibtest-{}",
2841 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
2842 .map(|d| d.as_nanos()).unwrap_or(0)));
2843 let sub = base.join("chapters");
2844 res!(std::fs::create_dir_all(&sub));
2845 res!(std::fs::write(base.join("refs.bib"),
2846 "@article{smith2020, author = {Smith, John}, title = {A Title}, year = {2020}, journal = {J}}\n"));
2847 let chapter = sub.join("chap.typ");
2848 res!(std::fs::write(&chapter, "cited here"));
2849
2850 let mut blocks = vec![Block::RichParagraph { segments: vec![Segment::Cite(vec!["smith2020".to_string()])] }];
2851 let bib = res!(load_lone_bibliography(&chapter, &mut blocks));
2852
2853 // Clean up before asserting, so a failed assertion still leaves no scratch behind.
2854 let _ = std::fs::remove_dir_all(&base);
2855
2856 let bib = res!(bib.ok_or_else(|| err!("no bibliography was found beside the chapter"; Test, Missing)));
2857 let cite = res!(bib.format_citation(&["smith2020"]));
2858 assert!(cite.contains("Smith") && cite.contains("2020"),
2859 "citation did not resolve to author-year: {:?}", cite);
2860 assert!(!cite.contains("smith2020"), "the raw cite key leaked: {:?}", cite);
2861 Ok(())
2862 }
2863
2864 /// An included chapter's own `#set text(size: ...)` is lowered and scoped to that chapter's subtree
2865 /// (H1): `assemble` nests the chapter's blocks inside one `Block::Scoped` carrying the lowered patch,
2866 /// and a sibling chapter that declares nothing is left unwrapped. Before this, a chapter's `#set` was
2867 /// captured by the reader but never lowered, since lowering ran only over the root.
2868 #[test]
2869 fn test_included_chapter_set_is_scoped_to_its_subtree_h1() -> Outcome<()> {
2870 let base = std::env::temp_dir().join(fmt!("austenite-h1-{}",
2871 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
2872 .map(|d| d.as_nanos()).unwrap_or(0)));
2873 res!(std::fs::create_dir_all(&base));
2874 // Chapter A declares a body size; chapter B declares nothing.
2875 res!(std::fs::write(base.join("chap_a.typ"), "#set text(size: 20pt)\n= Chapter A\nAlpha body.\n"));
2876 res!(std::fs::write(base.join("chap_b.typ"), "= Chapter B\nBeta body.\n"));
2877 let root_src = "#include \"chap_a.typ\"\n#include \"chap_b.typ\"\n";
2878 let root_path = base.join("root.typ");
2879
2880 let (blocks, _skips) = res!(assemble(root_src, &base, &root_path, lang::rules::Bindings::new(&lang::rules::TemplateFns::new(), &lang::rules::ContentFns::new()), ""));
2881
2882 // Clean up before asserting, so a failed assertion leaves no scratch behind.
2883 let _ = std::fs::remove_dir_all(&base);
2884
2885 // Exactly one scope, carrying chapter A's lowered body size, its nested blocks opening with that
2886 // chapter's heading -- the `#set` line emitted no block of its own (it lowered into the patch).
2887 let scopes: Vec<(&ThemePatch, &Vec<Block>)> = blocks.iter().filter_map(|b| match b {
2888 Block::Scoped { patch, blocks } => Some((patch, blocks)),
2889 _ => None,
2890 }).collect();
2891 assert_eq!(scopes.len(), 1, "expected exactly one scope, got {}", scopes.len());
2892 let (patch, inner) = scopes[0];
2893 assert_eq!(patch.text.body_size, Some(Sp::from_pt(20.0)),
2894 "the chapter's #set text(size:) did not lower into the scope patch");
2895 assert!(matches!(inner.first(), Some(Block::Heading { .. })),
2896 "the scope's first nested block should be chapter A's heading");
2897
2898 // Chapter B declares nothing, so it is a flat sibling of the scope: exactly one heading sits at the
2899 // top level (chapter B's), chapter A's being nested inside the scope rather than a flat sibling.
2900 let top_headings = blocks.iter().filter(|b| matches!(b, Block::Heading { .. })).count();
2901 assert_eq!(top_headings, 1,
2902 "chapter B's heading must be the one flat-sibling heading; A's is nested in the scope");
2903 Ok(())
2904 }
2905
2906 /// The `term-defs` reader lifts each key's content group, not the leading comment's, keeping the
2907 /// definition's inner markup source and pairing it with its key in source order.
2908 #[test]
2909 fn test_term_defs_reader_reads_content_groups_10() {
2910 let src = r#"
2911// term-defs: key -> definition (content)
2912#let term-defs = (
2913 "org": [The Oxegence Foundation, a non-profit.],
2914 "ai": [Artificial Intelligence.],
2915)
2916"#;
2917 let defs = parse_term_defs(src);
2918 assert_eq!(defs.len(), 2, "unexpected entries: {:?}", defs);
2919 assert_eq!(defs[0].0, "org");
2920 assert_eq!(defs[0].1, "The Oxegence Foundation, a non-profit.");
2921 assert_eq!(defs[1].0, "ai");
2922 assert_eq!(defs[1].1, "Artificial Intelligence.");
2923 }
2924
2925 /// `#print-glossary()` collects the document's glossary terms in first-appearance order, deduplicated
2926 /// by key, dropping a term with no definition, and fills the placeholder with a Term/Definition table
2927 /// whose Term column is the term-dictionary value where the key has one and the key itself otherwise.
2928 #[test]
2929 fn test_resolve_glossary_orders_dedupes_and_skips_undefined_11() -> Outcome<()> {
2930 // The term-dictionary gives the `g`-family its display value; `meet` has none, so its Term column is
2931 // the key itself, as the `gs`-family metadata stores.
2932 res!(crate::lang::parse::set_term_dict(HashMap::from([
2933 ("org".to_string(), "Oxegence Foundation".to_string()),
2934 ])));
2935 {
2936 let mut guard = lock_write!(TERM_DEFS, "test term-defs");
2937 let mut m: HashMap<String, Vec<Segment>> = HashMap::new();
2938 m.insert("org".to_string(), vec![Segment::text("The Foundation.")]);
2939 m.insert("meet".to_string(), vec![Segment::text("To oxedize.")]);
2940 *guard = Some(m);
2941 }
2942 let mut blocks = vec![
2943 Block::rich(vec![
2944 Segment::glossary("meet", "oxedize"),
2945 Segment::text(" then "),
2946 Segment::glossary("org", "Oxegence Foundation"),
2947 ]),
2948 Block::rich(vec![
2949 Segment::glossary("meet", "oxedize"), // a second use adds no row
2950 Segment::glossary("surplus", "surplus"), // no definition, so no row
2951 ]),
2952 Block::Glossary,
2953 ];
2954 resolve_glossary(&mut blocks, false);
2955
2956 let table = match &blocks[2] {
2957 Block::Table(t) => t,
2958 other => return Err(err!("expected a glossary table, found {:?}", other; Test, Bug)),
2959 };
2960 assert!(table.header, "the glossary sets a header row");
2961 assert_eq!(table.weights, vec![1.0, 3.0], "columns are 1fr / 3fr");
2962 assert_eq!(table.rows.len(), 3, "header plus the two defined terms");
2963
2964 // The Term column of a body row, flattened to its text.
2965 let term_of = |r: usize| -> String {
2966 table.rows[r].cells[0].content.iter().map(|s| match s {
2967 Segment::Text(t) => t.clone(),
2968 _ => String::new(),
2969 }).collect()
2970 };
2971 assert_eq!(term_of(1), "meet", "first appearance, a key with no dict value shows the key itself");
2972 assert_eq!(term_of(2), "Oxegence Foundation", "second appearance, a key with a dict value shows it");
2973 Ok(())
2974 }
2975
2976 /// A `#print-glossary()` placeholder inside a scoped subtree -- an included chapter's own call -- is found
2977 /// and filled in place, not dropped: the resolver descends into the scope and swaps the nested placeholder
2978 /// for the glossary table. Before it recursed, only a top-level placeholder was ever seen, so a scoped
2979 /// call silently vanished. Needs no term definitions: with none, a header-only table is built and set.
2980 #[test]
2981 fn test_resolve_glossary_fills_a_placeholder_inside_a_scope_12() -> Outcome<()> {
2982 let mut blocks = vec![
2983 Block::paragraph("Body."),
2984 Block::Scoped {
2985 patch: ThemePatch::default(),
2986 blocks: vec![Block::Glossary],
2987 },
2988 ];
2989 resolve_glossary(&mut blocks, false);
2990 let inner = match &blocks[1] {
2991 Block::Scoped { blocks, .. } => blocks,
2992 other => return Err(err!("the scope must survive resolution, found {:?}", other; Test, Bug)),
2993 };
2994 match &inner[0] {
2995 Block::Table(_) => Ok(()),
2996 other => Err(err!("a #print-glossary inside a scope must be filled, found {:?}", other; Test, Bug)),
2997 }
2998 }
2999
3000 /// `face_resolver` (the lone-file path's resolver builder) must resolve a face named only inside the
3001 /// file's own block tree -- by a rule or a `#styled-box`'s scope -- not only one the root theme names
3002 /// itself. Before this, it loaded from [`heading_face_names`] alone, so a rule-named face never resolved
3003 /// on the lone-file path even though the whole-book path (via [`all_face_names`]) already handled it.
3004 #[test]
3005 fn test_face_resolver_reads_a_block_scoped_face_name_13() -> Outcome<()> {
3006 let base = std::env::temp_dir().join(fmt!("austenite-facer-{}",
3007 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
3008 .map(|d| d.as_nanos()).unwrap_or(0)));
3009 let fonts_dir = base.join("assets").join("fonts");
3010 res!(std::fs::create_dir_all(&fonts_dir));
3011 let src_font = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LibertinusSerif-Regular.otf");
3012 res!(std::fs::copy(&src_font, fonts_dir.join("LibertinusSerif-Regular.otf")));
3013
3014 // The root theme names no display face at all; only a scoped patch -- as a rule or a `#styled-box`
3015 // would build -- names one, so `heading_face_names(&theme)` alone would find nothing.
3016 let theme = Theme::default();
3017 assert!(heading_face_names(&theme).is_empty(), "the root theme must name no face for this to test the block path");
3018 let blocks = vec![
3019 Block::Scoped {
3020 patch: crate::theme::ThemePatch {
3021 heading: crate::theme::ThemeHeadingPatch {
3022 face: Some(Some("LibertinusSerif".to_string())),
3023 ..Default::default()
3024 },
3025 ..Default::default()
3026 },
3027 blocks: vec![],
3028 },
3029 ];
3030 // `root_dir`'s parent is `base`, matching a lone chapter's own directory beside `base/assets/fonts`.
3031 let root_dir = base.join("chapter");
3032 let faces = face_resolver(&root_dir, &theme, &blocks);
3033
3034 let _ = std::fs::remove_dir_all(&base);
3035 assert!(faces.resolves("LibertinusSerif"),
3036 "a face named only by a scoped patch in the block tree must still resolve on the lone-file path");
3037 Ok(())
3038 }
3039
3040 /// `note_missing_face_variants` must also flag a scoped or box subtree's own heading levels, folding its
3041 /// patch onto the theme in force at that point -- not only the document root's levels. A rule or a
3042 /// `#styled-box` that sets a bold heading in a face the tree ships only Regular for must be noted, the
3043 /// same as a root-level heading would be.
3044 #[test]
3045 fn test_note_missing_face_variants_descends_scoped_patches_14() -> Outcome<()> {
3046 let base = std::env::temp_dir().join(fmt!("austenite-missvar-{}",
3047 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
3048 .map(|d| d.as_nanos()).unwrap_or(0)));
3049 res!(std::fs::create_dir_all(&base));
3050 // Only a Regular file for "TestFace": `has_variant` for bold must come back false.
3051 let src_font = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LibertinusSerif-Regular.otf");
3052 res!(std::fs::copy(&src_font, base.join("TestFace-Regular.otf")));
3053 let faces = FaceResolver::load(&base, &["TestFace".to_string()]);
3054 let _ = std::fs::remove_dir_all(&base);
3055 assert!(faces.resolves("TestFace"), "the Regular file must load");
3056 assert!(!faces.has_variant("TestFace", true, false), "no Bold file was shipped, so bold must not be an exact variant");
3057
3058 // The root theme names no face at all, so the root-level pass records nothing; only the scoped
3059 // patch's level-1 override names "TestFace" in bold.
3060 let theme = Theme::default();
3061 let blocks = vec![
3062 Block::Scoped {
3063 patch: crate::theme::ThemePatch {
3064 heading: crate::theme::ThemeHeadingPatch {
3065 levels: vec![
3066 crate::theme::ThemeHeadingLevelPatch {
3067 face: Some(Some("TestFace".to_string())),
3068 weight: Some(Some(700)),
3069 ..Default::default()
3070 },
3071 ],
3072 ..Default::default()
3073 },
3074 ..Default::default()
3075 },
3076 blocks: vec![],
3077 },
3078 ];
3079 let mut skips = lang::Refusals::default();
3080 note_missing_face_variants(&theme, &blocks, &faces, &mut skips);
3081 assert_eq!(skips.total(), 1, "the scoped bold heading in a Regular-only face must be noted exactly once");
3082 assert!(skips.sites()[0].name.contains("TestFace") && skips.sites()[0].name.contains("bold"),
3083 "the note must name the face and the missing slant, found {:?}", skips.sites()[0].name);
3084 Ok(())
3085 }
3086
3087 /// A citation that sits only inside a table cell is still marked cited, so it appears in the Chicago
3088 /// reference list -- the fix for the silent loss where 15 cell-only bibliography entries vanished from the
3089 /// whole document. Reverting `collect_cite_keys` to skip `Block::Table` reds this: the reference list
3090 /// comes back empty because the cell's key is never marked.
3091 #[test]
3092 fn cite_in_a_table_cell_is_marked_for_the_bibliography() -> Outcome<()> {
3093 const MINI_BIB: &str = r#"
3094@book{scott1976moral,
3095 author = {Scott, James C.},
3096 title = {The Moral Economy of the Peasant},
3097 year = {1976},
3098 publisher = {Yale University Press}
3099}
3100"#;
3101 let bib = res!(Bibliography::parse(MINI_BIB));
3102 // A document whose only citation is inside a table cell.
3103 let cell = Cell::rich(vec![
3104 Segment::text("Author "),
3105 Segment::cite(vec!["scott1976moral".to_string()]),
3106 ], Align::Left);
3107 let mut blocks = vec![Block::Table(Table::new(false, vec![Row::new(vec![cell])]))];
3108
3109 let marked = append_bibliography(bib, &mut blocks);
3110 let refs = marked.reference_list();
3111 if refs.len() != 1 {
3112 return Err(err!("A #cite inside a table cell was not marked for the bibliography: the reference \
3113 list holds {} entries, expected 1.", refs.len(); Test, Mismatch));
3114 }
3115 Ok(())
3116 }
3117}
3118