Oregami
Repositories/oxedyne/fe2o3

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
15use crate::bib::Bibliography;
16use crate::book;
17use crate::doc::{
18 self,
19 Block,
20 FrontMatter,
21 Heading,
22};
23use crate::driver::{
24 self,
25 CompileOutput,
26 Config,
27};
28use crate::font::FontMetrics;
29use crate::fonts;
30use crate::fonts::FaceResolver;
31use crate::ledger::{
32 AnchorId,
33 AnchorKind,
34 Ledger,
35};
36use crate::lang;
37use crate::page::PageGeometry;
38use crate::theme::Theme;
39use crate::vfs;
40
41use oxedyne_fe2o3_core::prelude::*;
42use oxedyne_fe2o3_font::{
43 face::Role,
44 set::FontSet,
45 shape::Dir,
46};
47use oxedyne_fe2o3_graphics::pdf::OutlineItem;
48
49use std::collections::HashMap;
50use std::fmt;
51use std::path::{
52 Path,
53 PathBuf,
54};
55use std::sync::Arc;
56
57/// The pieces a compile needs after assembly, from either the whole-book path or the lone-file path.
58pub 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.
71pub 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.
90pub fn assemble<F>(main_path: &Path, lone_fonts: F) -> Outcome<(Assembled, lang::Refusals, Option<String>)>
91where
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.
197pub 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.
206pub 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.
246pub 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.
276fn 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.
290pub 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)]
311pub struct Diagnostic {
312 pub file: String,
313 pub line: usize,
314 pub col: usize,
315 pub message: String,
316}
317
318impl 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)]
328pub 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
335impl 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.
370pub 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.
396pub 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.
426fn 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`]).
457pub 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}`.
464const 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.
472pub 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.
496fn 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.
513pub 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`.
517pub fn engine_git_hash() -> &'static str { env!("AUSTENITE_GIT_HASH") }