oxedyne/fe2o3/fe2o3_austenite/src/compile.rs
23.4 KiB, 29 runs
created by r1870400018:46216, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The one compile pipeline, from a source root to resolved pages, shared by both entry points. |
| 2 | //! |
| 3 | //! The native `austenite` binary and the wasm `DaimondTypst` surface each |
| 4 | //! wrap this module: the binary drives it with a parallel, filesystem-writing emit; the wasm surface with a |
| 5 | //! sequential, in-memory one. Everything between the source and the decorated pages -- the book-vs-lone |
| 6 | //! dispatch, the term-dictionary install, authoring, the two-pass driver, decoration and the verso |
| 7 | //! mirror-shift -- lives here once, so the two callers cannot drift. It was split out because they had |
| 8 | //! drifted: [`build_outline`] was a verbatim copy in each, and the lone-file assembly had already diverged |
| 9 | //! (the binary lowered its root declarations and installed its `#let` furniture before reading blocks; the |
| 10 | //! wasm copy did neither). |
| 11 | //! |
| 12 | //! File reads route through [`crate::vfs`], so the same code reads the real filesystem on a native build and |
| 13 | //! the injected source map under wasm, with no `#cfg` at the call sites. |
| 14 | |
| 15 | use crate::bib::Bibliography; |
| 16 | use crate::book; |
| 17 | use crate::doc::{ |
| 18 | self, |
| 19 | Block, |
| 20 | FrontMatter, |
| 21 | Heading, |
| 22 | }; |
| 23 | use crate::driver::{ |
| 24 | self, |
| 25 | CompileOutput, |
| 26 | Config, |
| 27 | }; |
| 28 | use crate::font::FontMetrics; |
| 29 | use crate::fonts; |
| 30 | use crate::fonts::FaceResolver; |
| 31 | use crate::ledger::{ |
| 32 | AnchorId, |
| 33 | AnchorKind, |
| 34 | Ledger, |
| 35 | }; |
| 36 | use crate::lang; |
| 37 | use crate::page::PageGeometry; |
| 38 | use crate::theme::Theme; |
| 39 | use crate::vfs; |
| 40 | |
| 41 | use oxedyne_fe2o3_core::prelude::*; |
| 42 | use oxedyne_fe2o3_font::{ |
| 43 | face::Role, |
| 44 | set::FontSet, |
| 45 | shape::Dir, |
| 46 | }; |
| 47 | use oxedyne_fe2o3_graphics::pdf::OutlineItem; |
| 48 | |
| 49 | use std::collections::HashMap; |
| 50 | use std::fmt; |
| 51 | use std::path::{ |
| 52 | Path, |
| 53 | PathBuf, |
| 54 | }; |
| 55 | use std::sync::Arc; |
| 56 | |
| 57 | /// The pieces a compile needs after assembly, from either the whole-book path or the lone-file path. |
| 58 | pub struct Assembled { |
| 59 | pub blocks: Vec<Block>, |
| 60 | pub fonts: Arc<FontSet>, |
| 61 | pub geom: PageGeometry, |
| 62 | pub style: Theme, |
| 63 | pub title: String, |
| 64 | pub faces: FaceResolver, |
| 65 | pub front: Option<FrontMatter>, |
| 66 | pub bib: Option<Bibliography>, |
| 67 | } |
| 68 | |
| 69 | /// The resolved output of a compile: the decorated, mirror-shifted pages with their ledger and pass count, |
| 70 | /// the heading table (for an outline or a section-rail query) and the geometry the emit stage reads. |
| 71 | pub struct Rendered { |
| 72 | pub out: CompileOutput, |
| 73 | pub heads: Vec<Heading>, |
| 74 | pub geom: PageGeometry, |
| 75 | } |
| 76 | |
| 77 | /// Assembles the source at `main_path`: a book or doc root through [`book::load`], a lone file through the |
| 78 | /// reader with the lone-file styling, furniture, glossary and bibliography steps. Returns the document |
| 79 | /// pieces, the full refusal table (every refused site, for the binary's `--explain`) and the terse skip |
| 80 | /// line (`skipped: #show ×2, ...`, or `None` when the reader set everything it met). |
| 81 | /// |
| 82 | /// `lone_fonts` supplies the reading set for the lone-file path only -- the embedded Libertinus -- and is |
| 83 | /// not called on the book path, which carries its own fonts. It is a thunk so the native binary builds the |
| 84 | /// set only when it is a lone file, while the wasm surface hands back its once-built cached instance. |
| 85 | /// |
| 86 | /// The skip line and the refusal table are not the same object on the lone path: the line is snapshotted |
| 87 | /// from the block-reading refusals alone, before the styling rules and the missing-face check append their |
| 88 | /// own sites, so a lone compile's terse line matches what it always printed while `--explain` still walks |
| 89 | /// every site. On the book path the two coincide, both taken after the whole assembly. |
| 90 | pub fn assemble<F>(main_path: &Path, lone_fonts: F) -> Outcome<(Assembled, lang::Refusals, Option<String>)> |
| 91 | where |
| 92 | F: FnOnce() -> Outcome<Arc<FontSet>>, |
| 93 | { |
| 94 | let src = match vfs::read_to_string(main_path) { |
| 95 | Ok(s) => s, |
| 96 | Err(e) => return Err(err!(e, |
| 97 | "Could not read the source file {:?}.", main_path; File, Read)), |
| 98 | }; |
| 99 | |
| 100 | // A figure's `/assets/...` image path is root-relative in Typst, not filesystem-absolute; the image |
| 101 | // loader resolves it against this directory and, failing that, its ancestors, so a chapter compiled on |
| 102 | // its own finds the shared assets through the book's `assets` entry just as a whole book does. |
| 103 | if let Some(dir) = main_path.parent() { |
| 104 | res!(crate::image::set_base_dir(dir.to_path_buf())); |
| 105 | } |
| 106 | |
| 107 | if book::is_book_root(&src) { |
| 108 | // A book or doc root assembles its chapters through the reader and merges each chapter's refusal |
| 109 | // table into one, so a whole-book or whole-doc compile reports its skipped constructs on the same |
| 110 | // terse line the lone-file path prints, and `--explain` walks every chapter's sites. |
| 111 | let spec = res!(book::load(main_path)); |
| 112 | let skip_line = terse_skip_line(&spec.skips); |
| 113 | // A root `#set text(font: ...)` sets the whole document in that family's reading set, in place of the |
| 114 | // idiom's own; a document naming no family keeps its set untouched, byte for byte. |
| 115 | let fonts = spec.faces.body_set(&spec.style.text.faces.body).unwrap_or(spec.fonts); |
| 116 | let assembled = Assembled { |
| 117 | blocks: spec.blocks, |
| 118 | fonts, |
| 119 | geom: spec.geom, |
| 120 | style: spec.style, |
| 121 | title: spec.title, |
| 122 | faces: spec.faces, |
| 123 | front: Some(spec.front), |
| 124 | bib: spec.bib, |
| 125 | }; |
| 126 | return Ok((assembled, spec.skips, skip_line)); |
| 127 | } |
| 128 | |
| 129 | // A lone chapter installs the shared `term-dict` from a `terms.typ` beside or above it, so its |
| 130 | // `#t`/`#g` term calls resolve to their values just as in a whole-book compile. |
| 131 | if let Some(dir) = main_path.parent() { |
| 132 | res!(book::install_term_dict(dir)); |
| 133 | res!(book::install_term_defs(dir)); |
| 134 | } |
| 135 | // A lone file may carry its own `#show: doc.with(...)` or a lowerable top-level `#set`; the reader |
| 136 | // captures those rather than refusing them, so their styling is lowered onto the theme here -- otherwise |
| 137 | // the capture would be a silent skip. Lowered before the blocks are read so a furniture definition |
| 138 | // resolves its `em` insets against the file's own body size. |
| 139 | let mut style = Theme::default(); |
| 140 | lang::set::lower_root_declarations(&src, &mut style); |
| 141 | // Collect the lone file's whole `#let` scope -- its own furniture (an `#aside-box`/`#pr-note` defined in |
| 142 | // the file), its content bindings, and everything the files it `#import`s supply -- so a lone chapter |
| 143 | // honours its furniture and content bindings exactly as the book assembler does for a whole book. A |
| 144 | // furniture call expands into its padded box (or floating figure) and a content-binding reference into its |
| 145 | // re-read markup, rather than being dropped as an unknown construct. The import walk resolves an `#import` |
| 146 | // even with no `#include` present, which the lone path by definition has none of. |
| 147 | let scope = book::collect_scope(&src, main_path.parent().unwrap_or_else(|| Path::new(".")), style.text.body_size); |
| 148 | let binds = scope.bindings(); |
| 149 | let (mut blocks, mut skips) = res!(lang::to_blocks_with_templates(&src, binds)); |
| 150 | skips.tag_file(&main_path.display().to_string()); |
| 151 | let skip_line = terse_skip_line(&skips); |
| 152 | let mut refusals = skips; |
| 153 | // Fill a `#print-glossary()` the lone chapter carries, as a whole-doc compile does after assembly. |
| 154 | book::resolve_glossary(&mut blocks, false); |
| 155 | // Resolve citations against a `refs.bib` found beside or above the chapter, so a lone-file compile sets |
| 156 | // Chicago author-year in text and a reference list at the end rather than the raw cite key. |
| 157 | let bib = res!(book::load_lone_bibliography(main_path, &mut blocks)); |
| 158 | let fonts = res!(lone_fonts()); |
| 159 | // The styling rule engine runs over the lone chapter's block tree here, at the blocks->author seam, |
| 160 | // before its faces are resolved -- so a rule-named face reaches the resolver. The default rules re-assert |
| 161 | // the theme's own heading sizes (byte-neutral); the file's own `#show <selector>: <transform>` rules are |
| 162 | // appended, refused where a transform reads the page or an unread field. |
| 163 | let rules = lang::rules::rule_set_for(&style, &src, &mut refusals); |
| 164 | // A lone file sets on A4 (its geometry below), so the placement width a template resolves against is A4's. |
| 165 | lang::rules::apply_rules(&mut blocks, &rules, PageGeometry::a4().content_width()); |
| 166 | // A lone file may name a heading font in its own `#show: doc.with(...)`, or a rule/scope inside its own |
| 167 | // block tree may name one; resolve against the union of both against the tree's assets, the same way a |
| 168 | // whole book or doc does, so a lone chapter's heading face reaches the page whichever source names it. A |
| 169 | // heading asking for a weight/slant the tree ships no file for is noted, as for a book. |
| 170 | let mut faces = match main_path.parent() { |
| 171 | Some(dir) => book::face_resolver(dir, &style, &blocks), |
| 172 | None => FaceResolver::default(), |
| 173 | }; |
| 174 | // Every family the file names must be declared by a font it was given: a hard error otherwise, never a |
| 175 | // silent fall-back (see `FaceResolver::require`). |
| 176 | let (bodies, headings) = book::named_families(&style, &blocks); |
| 177 | let font_dir = book::lone_font_dir(main_path.parent().unwrap_or_else(|| Path::new("."))); |
| 178 | res!(faces.require(&font_dir, &bodies, &headings)); |
| 179 | book::note_missing_face_variants(&style, &blocks, &faces, &mut refusals); |
| 180 | let fonts = faces.body_set(&style.text.faces.body).unwrap_or(fonts); |
| 181 | let assembled = Assembled { |
| 182 | blocks, |
| 183 | fonts, |
| 184 | geom: PageGeometry::a4(), |
| 185 | style, |
| 186 | title: String::new(), |
| 187 | faces, |
| 188 | front: None, |
| 189 | bib, |
| 190 | }; |
| 191 | Ok((assembled, refusals, skip_line)) |
| 192 | } |
| 193 | |
| 194 | /// Authors the assembled blocks, runs the two-pass driver to its fixed point, decorates each page with a |
| 195 | /// running head and folio, and mirrors the verso margins. The result carries the resolved pages, their |
| 196 | /// ledger and pass count, the heading table and the geometry, ready for either caller's emit stage. |
| 197 | pub fn author_and_run(a: Assembled) -> Outcome<Rendered> { |
| 198 | author_and_run_memo(a, None) |
| 199 | } |
| 200 | |
| 201 | /// [`author_and_run`] with the incremental memo threaded through the authoring stage, and the body/ |
| 202 | /// furniture split point recorded on each page for the page-emit memo. Passing `None` is exactly |
| 203 | /// [`author_and_run`], byte for byte. The caller reuses one [`Memo`](crate::memo::Memo) across recompiles |
| 204 | /// of the same document (see the native `--watch` path); the emit stage then renders each page through |
| 205 | /// [`crate::emit::svg::render_page_memo`] against that same memo. |
| 206 | pub fn author_and_run_memo(a: Assembled, memo: Option<&mut crate::memo::Memo>) -> Outcome<Rendered> { |
| 207 | let (document, heads) = res!(doc::author_memo( |
| 208 | a.fonts.clone(), a.geom, &a.style, &a.faces, &a.blocks, a.front.as_ref(), a.bib.as_ref(), memo)); |
| 209 | let metrics = FontMetrics::new(a.fonts.clone(), Role::Body, Dir::Ltr, a.style.text.body_size); |
| 210 | let mut out = res!(driver::run(&document, &metrics, Config::default())); |
| 211 | // Record where each page's body ends before decoration appends its running head and folio, so the |
| 212 | // page-emit memo hashes the body alone and draws the furniture (whose folio differs page to page) |
| 213 | // fresh. Recorded here, at the one point the split is known; a page never decorated leaves it at the |
| 214 | // whole frame, which the non-memo emit path ignores. |
| 215 | for page in &mut out.pages { |
| 216 | page.set_body_len(page.frame.placed.len()); |
| 217 | } |
| 218 | let footer_logo = a.front.as_ref().and_then(|f| f.footer_logo.as_deref()); |
| 219 | res!(doc::decorate(&mut out.pages, &out.ledger, &heads, &a.fonts, &a.style, a.geom, &a.title, footer_logo)); |
| 220 | |
| 221 | // Mirror the margins: the driver laid every page at the recto split (binding on the left). A verso page |
| 222 | // -- an even folio -- is that whole frame shifted to the fore-edge, so the binding margin sits at the |
| 223 | // spine on both sides of the leaf. Uniform margins give a zero shift, so a non-book run is untouched. |
| 224 | let shift = a.geom.mirror_shift(); |
| 225 | if shift.raw() != 0 { |
| 226 | for page in &mut out.pages { |
| 227 | if page.number % 2 == 0 { |
| 228 | for placed in &mut page.frame.placed { |
| 229 | placed.x = placed.x + shift; |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | Ok(Rendered { out, heads, geom: a.geom }) |
| 236 | } |
| 237 | |
| 238 | /// Builds the PDF document outline (the viewer's bookmark side panel) from the resolved ledger: the three |
| 239 | /// front-matter leaves first -- title page, meta (imprint) page and contents -- then every body heading in |
| 240 | /// reading order. The front-matter pages carry no heading of their own, so the block layer records a |
| 241 | /// `Label` anchor at the top of each (`frontmatter:title`, `frontmatter:meta`, `frontmatter:contents`); |
| 242 | /// this reads their page back from the ledger. A leaf the book omits sets no anchor, so its entry is simply |
| 243 | /// absent. Body headings resolve their page through the heading anchor, and their depth matches the contents |
| 244 | /// list -- a chapter or a part at the top, deeper headings nested under it. Pages are zero-based, as |
| 245 | /// [`OutlineItem`] wants; the ledger stores them one-based. |
| 246 | pub fn build_outline(heads: &[Heading], ledger: &Ledger) -> Vec<OutlineItem> { |
| 247 | let mut items: Vec<OutlineItem> = Vec::new(); |
| 248 | |
| 249 | // The front matter, at the top and at depth zero, so it stands as a sibling of the first body level. |
| 250 | let front = [ |
| 251 | ("frontmatter:title", "Title"), |
| 252 | ("frontmatter:meta", "Meta"), |
| 253 | ("frontmatter:contents", "Contents"), |
| 254 | ]; |
| 255 | for (key, label) in front { |
| 256 | let id = AnchorId::new(AnchorKind::Label, key); |
| 257 | if let Some(page) = ledger.page_of(&id) { |
| 258 | items.push(OutlineItem { title: label.to_string(), page: (page - 1) as usize, level: 0 }); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // Every body heading, its depth the contents indent: a chapter or a part at depth zero, a `==` section |
| 263 | // at one, and so on. A heading the ledger has not fixed is skipped rather than guessed. |
| 264 | for h in heads { |
| 265 | if let Some(page) = ledger.page_of(&h.id) { |
| 266 | let level = (h.level.max(1) - 1) as u8; |
| 267 | items.push(OutlineItem { title: h.title.clone(), page: (page - 1) as usize, level }); |
| 268 | } |
| 269 | } |
| 270 | items |
| 271 | } |
| 272 | |
| 273 | /// The one terse skip line -- `skipped: #show ×2, #columns ×1` -- built from the summary's per-name counts, |
| 274 | /// or `None` when the reader set everything it met. Ordered by the summary (descending count, then name), |
| 275 | /// so the line leads with the construct that cost the most. |
| 276 | fn terse_skip_line(skips: &lang::Refusals) -> Option<String> { |
| 277 | if skips.is_empty() { |
| 278 | return None; |
| 279 | } |
| 280 | let parts: Vec<String> = skips.entries().into_iter() |
| 281 | .map(|(n, c)| fmt!("{} ×{}", n, c)) |
| 282 | .collect(); |
| 283 | Some(fmt!("skipped: {}", parts.join(", "))) |
| 284 | } |
| 285 | |
| 286 | /// Builds the PDF for resolved pages in one sequential, in-memory pass: the document outline from the |
| 287 | /// heading table, then each page rendered and folded in, its frame freed as soon as it is written. The |
| 288 | /// browser has no threads, so this is the wasm surface's emit; the native binary chunks the same calls in |
| 289 | /// parallel. |
| 290 | pub fn emit_pdf(out: &mut CompileOutput, heads: &[Heading]) -> Outcome<Vec<u8>> { |
| 291 | let mut buf: Vec<u8> = Vec::new(); |
| 292 | let outline = build_outline(heads, &out.ledger); |
| 293 | let mut pdf = res!(crate::emit::pdf::open_document_with_outline(&mut buf, out.pages.len(), outline)); |
| 294 | for page in &mut out.pages { |
| 295 | let built = res!(crate::emit::pdf::render_page(page)); |
| 296 | res!(crate::emit::pdf::write_built_page(&mut pdf, &built)); |
| 297 | page.frame = crate::page::Frame::new(); |
| 298 | } |
| 299 | res!(pdf.finish()); |
| 300 | Ok(buf) |
| 301 | } |
| 302 | |
| 303 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 304 | // │ DIAGNOSTICS AND STRICT MODE │ |
| 305 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 306 | |
| 307 | /// One problem at the source position a caller shows the user. `line` and `col` are 1-based; a hard error |
| 308 | /// whose cause could not be traced to a source line reports `0:0` against the main file rather than a |
| 309 | /// guessed position. |
| 310 | #[derive(Clone, Debug, PartialEq)] |
| 311 | pub struct Diagnostic { |
| 312 | pub file: String, |
| 313 | pub line: usize, |
| 314 | pub col: usize, |
| 315 | pub message: String, |
| 316 | } |
| 317 | |
| 318 | impl fmt::Display for Diagnostic { |
| 319 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { |
| 320 | write!(f, "{}:{}:{}: {}", self.file, self.line, self.col, self.message) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | /// What a finished compile reports beside its artefact: the page count, every refused construct at its |
| 325 | /// source position, the terse skip line, and whether the source set no content at all. Built while the |
| 326 | /// source map is still installed, since each refusal's line and column are read from its source text. |
| 327 | #[derive(Clone, Debug)] |
| 328 | pub struct Report { |
| 329 | pub pages: usize, |
| 330 | pub diagnostics: Vec<Diagnostic>, |
| 331 | pub skipped: Option<String>, |
| 332 | pub empty: bool, // no content block was read |
| 333 | } |
| 334 | |
| 335 | impl Report { |
| 336 | pub fn new(pages: usize, refusals: &lang::Refusals, skipped: Option<String>, empty: bool) -> Self { |
| 337 | Self { pages, diagnostics: diagnostics(refusals), skipped, empty } |
| 338 | } |
| 339 | |
| 340 | /// Why a strict compile must refuse this result, or `None` when it may stand. A strict caller wants no |
| 341 | /// false green: a PDF that silently passed over a construct, that has no pages, or that set nothing is |
| 342 | /// an error, reported at the first refused site or, failing one, at the top of the main file. |
| 343 | pub fn strict_failure(&self, main: &Path) -> Option<Diagnostic> { |
| 344 | let at_main = |message: String| Diagnostic { |
| 345 | file: main.display().to_string(), |
| 346 | line: 1, |
| 347 | col: 1, |
| 348 | message, |
| 349 | }; |
| 350 | if let Some(first) = self.diagnostics.first() { |
| 351 | let line = self.skipped.clone().unwrap_or_else(|| fmt!("skipped: {} site(s)", self.diagnostics.len())); |
| 352 | return Some(Diagnostic { |
| 353 | message: fmt!("strict: {} construct site(s) were not set ({}); first: {}", |
| 354 | self.diagnostics.len(), line, first.message), |
| 355 | ..first.clone() |
| 356 | }); |
| 357 | } |
| 358 | if self.pages == 0 { |
| 359 | return Some(at_main("strict: the compile produced no pages.".to_string())); |
| 360 | } |
| 361 | if self.empty { |
| 362 | return Some(at_main("strict: the source sets no content.".to_string())); |
| 363 | } |
| 364 | None |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | /// Resolves each refused site to a [`Diagnostic`], reading the tagged source file's line and column through |
| 369 | /// [`vfs`]. A site whose source cannot be read still reports, at `0:0`, so a refusal is never dropped. |
| 370 | pub fn diagnostics(refusals: &lang::Refusals) -> Vec<Diagnostic> { |
| 371 | let mut out = Vec::with_capacity(refusals.total()); |
| 372 | let mut cache: HashMap<String, Option<String>> = HashMap::new(); |
| 373 | for r in refusals.sites() { |
| 374 | let src = cache.entry(r.file.clone()) |
| 375 | .or_insert_with(|| vfs::read_to_string(&PathBuf::from(&r.file)).ok()); |
| 376 | let (line, col) = match src { |
| 377 | Some(text) => { let (l, c, _) = lang::line_col_of(text, r.span.start); (l, c) }, |
| 378 | None => (0, 0), |
| 379 | }; |
| 380 | out.push(Diagnostic { |
| 381 | file: r.file.clone(), |
| 382 | line, |
| 383 | col, |
| 384 | message: fmt!("skipped {} ({})", r.name, r.class.label()), |
| 385 | }); |
| 386 | } |
| 387 | out |
| 388 | } |
| 389 | |
| 390 | /// Places a hard compile error at a source position. The engine's file errors name the path they could not |
| 391 | /// read (`Could not read the included chapter "/p/ch1.typ".`) but not the line that asked for it, so each |
| 392 | /// quoted path in the message is looked for among the string literals of `sources` -- resolved against the |
| 393 | /// citing file's directory, as the reader resolves them -- and the first citing literal gives the position. |
| 394 | /// An error naming no source-cited path reports `0:0` against `main`. The message is the error's plain |
| 395 | /// words, free of source-code frames and colour. |
| 396 | pub fn locate_error(e: &Error<ErrTag>, main: &Path, sources: &[PathBuf]) -> Diagnostic { |
| 397 | let message = e.plain(); |
| 398 | for cited in quoted_literals(&message) { |
| 399 | let target = match vfs::canonicalize(Path::new(&cited.1)) { |
| 400 | Ok(p) => p, |
| 401 | Err(_) => PathBuf::from(&cited.1), |
| 402 | }; |
| 403 | for src_path in sources { |
| 404 | let text = match vfs::read_to_string(src_path) { |
| 405 | Ok(t) => t, |
| 406 | Err(_) => continue, |
| 407 | }; |
| 408 | let dir = src_path.parent().unwrap_or_else(|| Path::new("/")); |
| 409 | for (off, lit) in quoted_literals(&text) { |
| 410 | let resolved = match vfs::canonicalize(&dir.join(&lit)) { |
| 411 | Ok(p) => p, |
| 412 | Err(_) => dir.join(&lit), |
| 413 | }; |
| 414 | if resolved == target { |
| 415 | let (line, col, _) = lang::line_col_of(&text, off as u32); |
| 416 | return Diagnostic { file: src_path.display().to_string(), line, col, message }; |
| 417 | } |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | Diagnostic { file: main.display().to_string(), line: 0, col: 0, message } |
| 422 | } |
| 423 | |
| 424 | /// Every double-quoted literal in `s` with the byte offset of its opening quote. No escape handling beyond |
| 425 | /// a backslash-quote, which is all a path literal needs. |
| 426 | fn quoted_literals(s: &str) -> Vec<(usize, String)> { |
| 427 | let mut out = Vec::new(); |
| 428 | let bytes = s.as_bytes(); |
| 429 | let mut i = 0; |
| 430 | while i < bytes.len() { |
| 431 | if bytes[i] == b'"' { |
| 432 | let start = i; |
| 433 | let mut j = i + 1; |
| 434 | while j < bytes.len() && bytes[j] != b'"' && bytes[j] != b'\n' { |
| 435 | if bytes[j] == b'\\' { |
| 436 | j += 1; |
| 437 | } |
| 438 | j += 1; |
| 439 | } |
| 440 | if j < bytes.len() && bytes[j] == b'"' { |
| 441 | out.push((start, s[start + 1..j].to_string())); |
| 442 | i = j + 1; |
| 443 | continue; |
| 444 | } |
| 445 | } |
| 446 | i += 1; |
| 447 | } |
| 448 | out |
| 449 | } |
| 450 | |
| 451 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 452 | // │ FONT FAMILIES AND ENGINE IDENTITY │ |
| 453 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 454 | |
| 455 | /// The families embedded in the crate, under the names a Typst source uses for them: the Libertinus |
| 456 | /// reading set ([`crate::fonts::libertinus`]) and the maths face ([`crate::math`]). |
| 457 | pub const EMBEDDED_FAMILIES: [&str; 3] = [ |
| 458 | "Libertinus Serif", |
| 459 | "Libertinus Mono", |
| 460 | "New Computer Modern Math", |
| 461 | ]; |
| 462 | |
| 463 | // The weight/slant suffixes the named-face resolver loads, `<Family>-<Variant>.{ttf,otf}`. |
| 464 | const FACE_VARIANTS: [&str; 4] = ["Regular", "Bold", "Italic", "BoldItalic"]; |
| 465 | |
| 466 | /// The font families a compile of the project rooted at `main` can set: the embedded families, then each |
| 467 | /// injected font's family that the engine's own face resolver actually loads, sorted and deduplicated. |
| 468 | /// `injected` are the paths the consumer gave its fonts under; each is looked for where the wasm surface |
| 469 | /// routes it ([`book::project_font_path`]), so the source map must be installed as a compile installs it. |
| 470 | /// A file not named `<Family>-<Variant>.{ttf,otf}`, or one that will not parse, is not a family the engine |
| 471 | /// can resolve by name, so it is not listed. |
| 472 | pub fn font_families(main: &Path, injected: &[PathBuf]) -> Vec<String> { |
| 473 | let mut out: Vec<String> = fonts::embedded_families(); |
| 474 | for given in injected { |
| 475 | let routed = match book::project_font_path(main, given) { |
| 476 | Some(p) => p, |
| 477 | None => continue, |
| 478 | }; |
| 479 | let family = match face_family(&routed) { |
| 480 | Some(f) => f, |
| 481 | None => continue, |
| 482 | }; |
| 483 | let dir = routed.parent().unwrap_or_else(|| Path::new("/")); |
| 484 | // SWITCH: the one call site to move to the font lane's family-list accessor on `FaceResolver` |
| 485 | // once it lands; until then a family counts when the resolver itself loads it by that name. |
| 486 | if FaceResolver::load(dir, &[family.clone()]).resolves(&family) { |
| 487 | out.push(family); |
| 488 | } |
| 489 | } |
| 490 | out.sort(); |
| 491 | out.dedup(); |
| 492 | out |
| 493 | } |
| 494 | |
| 495 | /// The family named by a `<Family>-<Variant>.{ttf,otf}` file, or `None` for any other name. |
| 496 | fn face_family(path: &Path) -> Option<String> { |
| 497 | let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); |
| 498 | if !matches!(ext.as_deref(), Some("ttf") | Some("otf")) { |
| 499 | return None; |
| 500 | } |
| 501 | let stem = match path.file_stem().and_then(|s| s.to_str()) { |
| 502 | Some(s) => s, |
| 503 | None => return None, |
| 504 | }; |
| 505 | match stem.rsplit_once('-') { |
| 506 | Some((family, variant)) if !family.is_empty() && FACE_VARIANTS.contains(&variant) |
| 507 | => Some(family.to_string()), |
| 508 | _ => None, |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | /// The crate version, as released. |
| 513 | pub fn engine_version() -> &'static str { env!("CARGO_PKG_VERSION") } |
| 514 | |
| 515 | /// The git commit the engine was built from (12 hex digits, with `-dirty` when the crate's tree had |
| 516 | /// uncommitted changes), or `unknown` when the build had no git to ask. Captured by `build.rs`. |
| 517 | pub fn engine_git_hash() -> &'static str { env!("AUSTENITE_GIT_HASH") } |