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 | |
| 22 | use crate::bib::{ |
| 23 | Bibliography, |
| 24 | RefStyle, |
| 25 | }; |
| 26 | use crate::doc::{ |
| 27 | Block, |
| 28 | FrontMatter, |
| 29 | HeadingStyle, |
| 30 | Segment, |
| 31 | }; |
| 32 | use crate::theme::{ |
| 33 | Theme, |
| 34 | ThemePatch, |
| 35 | }; |
| 36 | use crate::fonts; |
| 37 | use crate::fonts::FaceResolver; |
| 38 | use crate::ir::Sp; |
| 39 | use crate::lang::parse::flatten_markup; |
| 40 | use crate::lang; |
| 41 | use crate::page::PageGeometry; |
| 42 | use crate::table::{ |
| 43 | Align, |
| 44 | Cell, |
| 45 | Row, |
| 46 | Table, |
| 47 | }; |
| 48 | use crate::vfs; |
| 49 | |
| 50 | use oxedyne_fe2o3_core::prelude::*; |
| 51 | use oxedyne_fe2o3_font::set::FontSet; |
| 52 | |
| 53 | use std::collections::HashMap; |
| 54 | use std::collections::HashSet; |
| 55 | use std::path::{ |
| 56 | Path, |
| 57 | PathBuf, |
| 58 | }; |
| 59 | use std::sync::Arc; |
| 60 | use 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. |
| 68 | static TERM_DEFS: RwLock<Option<HashMap<String, Vec<Segment>>>> = RwLock::new(None); |
| 69 | |
| 70 | const 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. |
| 74 | pub 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. |
| 95 | pub 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. |
| 101 | pub 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. |
| 120 | pub 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. |
| 127 | fn 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. |
| 140 | fn 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. |
| 160 | pub 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. |
| 176 | fn 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. |
| 198 | pub 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. |
| 205 | fn 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. |
| 233 | fn 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. |
| 260 | pub 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`]). |
| 268 | pub 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. |
| 280 | pub 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. |
| 292 | pub 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. |
| 325 | fn 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. |
| 400 | const 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`). |
| 404 | const DOC_ACKNOWLEDGEMENT: &str = "We acknowledge the Indigenous peoples who have been traditional \ |
| 405 | custodians of lands and waters around the world, past and present. Through their languages, stories, and \ |
| 406 | practices, they have sought to maintain living connections to humanity's deepest roots and the ancient \ |
| 407 | wisdom 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. |
| 412 | fn 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. |
| 430 | fn 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)] |
| 498 | enum 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. |
| 508 | fn 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. |
| 582 | fn 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. |
| 681 | fn 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. |
| 691 | fn 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. |
| 703 | fn 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. |
| 728 | fn 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. |
| 754 | fn 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. |
| 779 | fn 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. |
| 791 | fn 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. |
| 796 | fn 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. |
| 812 | fn 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. |
| 821 | fn 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. |
| 829 | pub 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. |
| 854 | pub 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. |
| 867 | pub 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. |
| 884 | fn 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. |
| 896 | fn 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. |
| 993 | fn 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. |
| 1012 | fn 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)`. |
| 1053 | const GLOSSARY_TEXT_PT: f64 = 9.0; |
| 1054 | |
| 1055 | /// The cell padding a `#print-glossary()` table insets by, matching the template's `inset: 6pt`. |
| 1056 | const 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. |
| 1068 | pub 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. |
| 1116 | fn 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. |
| 1127 | fn 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. |
| 1148 | fn 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. |
| 1167 | fn 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. |
| 1183 | fn 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`. |
| 1195 | fn 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. |
| 1258 | fn 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. |
| 1275 | fn 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. |
| 1294 | fn 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`. |
| 1317 | fn 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`. |
| 1326 | fn 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. |
| 1341 | fn 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`. |
| 1412 | fn 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. |
| 1449 | fn 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. |
| 1488 | fn 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. |
| 1522 | fn 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. |
| 1539 | fn 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. |
| 1571 | fn 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. |
| 1600 | fn 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. |
| 1612 | fn 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. |
| 1627 | fn 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. |
| 1645 | fn 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. |
| 1671 | fn 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. |
| 1703 | pub struct Scope { |
| 1704 | pub tfns: lang::rules::TemplateFns, |
| 1705 | pub cfns: lang::rules::ContentFns, |
| 1706 | pub sfns: lang::rules::ScalarFns, |
| 1707 | } |
| 1708 | |
| 1709 | impl 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. |
| 1728 | pub 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. |
| 1768 | fn 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. |
| 1810 | const 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. |
| 1826 | struct GuardFrame { |
| 1827 | live: bool, |
| 1828 | then_taken: bool, |
| 1829 | in_else: bool, |
| 1830 | refused: bool, |
| 1831 | state: lang::parse::SkipState, |
| 1832 | } |
| 1833 | |
| 1834 | impl 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. |
| 1846 | fn 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 [`)? |
| 1854 | fn 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. |
| 1867 | fn 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? |
| 1886 | fn 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. |
| 1896 | fn 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. |
| 1907 | fn 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. |
| 1912 | fn 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`]. |
| 1934 | pub 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. |
| 1949 | fn 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. |
| 2151 | fn 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. |
| 2171 | fn 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. |
| 2180 | fn 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. |
| 2208 | struct 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. |
| 2224 | fn 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. |
| 2281 | fn 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. |
| 2304 | fn 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. |
| 2314 | fn 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. |
| 2329 | fn 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. |
| 2346 | fn 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. |
| 2368 | fn 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. |
| 2375 | fn 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, ...]`. |
| 2394 | fn 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. |
| 2410 | fn 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. |
| 2423 | fn 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. |
| 2431 | fn 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. |
| 2459 | fn 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`. |
| 2475 | fn 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)] |
| 2481 | mod 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 |