Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/wasm.rs

29.8 KiB, 190 runs

created by r1870400018:40273, 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 wasm-bindgen compile surface, shaped to Daimond's existing Typst-wasm contract so the app can
2//! swap the Typst compiler for Austenite without rewiring its callers.
3//!
4//! A [`DaimondTypst`] is a long-lived compiler instance: it builds the embedded Libertinus reading set
5//! once and holds it across compiles, and it keeps the last compile's ledger so a section-rail query can
6//! read heading pages back without recompiling. Nothing here touches a filesystem, a font service or a
7//! package registry: every source, asset and font a document needs is injected as bytes and read back
8//! through [`crate::vfs`], the same seam the native assembler reads the real filesystem through. This is
9//! the wasm equivalent of Typst's dummy-access model -- a path not injected simply does not resolve.
10//!
11//! Every method resolves; none rejects or throws. A failure returns `{ error: "<file>:<line>:<col>:
12//! <message>", diagnostics, skipped }` -- `0:0` where the cause could not be traced to a source line --
13//! never a bare access-denied line and never a JavaScript exception, so a caller composes its diagnostics
14//! from a value it always receives. A project carrying `strict: true` turns a result that passed over a
15//! construct, produced no pages or set no content into such a failure, so a partial PDF never reads as
16//! success.
17//!
18//! One capability is deliberately out of this lane and documented as a gap rather than stubbed: a recompile
19//! is from scratch -- the instance is shaped to hold an incremental block cache, but this lane does not
20//! build one. The per-page SVG does carry a transparent selectable-text layer -- a `.tsel` twin of the
21//! glyph outlines, emitted by [`crate::emit::svg`] -- which is what [`DaimondTypst::compile_project_vector`]
22//! returns and the section rail selects over.
23
24use crate::book;
25use crate::compile::{
26 self,
27 Diagnostic,
28 Report,
29};
30use crate::delta;
31use crate::doc::Heading;
32use crate::emit::svg;
33use crate::fonts;
34use crate::ledger::Ledger;
35use crate::memo::Memo;
36use crate::vfs;
37
38use oxedyne_fe2o3_core::prelude::*;
39use oxedyne_fe2o3_jdat::prelude::*;
40use oxedyne_fe2o3_font::set::FontSet;
41
42use std::collections::HashMap;
43use std::path::{
44 Path,
45 PathBuf,
46};
47use std::sync::Arc;
48
49use wasm_bindgen::prelude::*;
50use wasm_bindgen::JsCast;
51
52/// A long-lived Austenite compiler for the browser: the embedded reading set built once, and the last
53/// compile's resolved ledger and heading table kept for [`DaimondTypst::query_project`].
54#[wasm_bindgen]
55pub struct DaimondTypst {
56 // The embedded Libertinus reading set, built once and reused so a compile does not re-parse the five
57 // faces each call. `None` only if the embedded bytes failed to parse -- which does not happen in
58 // practice; a compile then reports it rather than the constructor throwing.
59 fonts: Option<Arc<FontSet>>,
60 // The last compile's ledger and heading table, so a section-rail query reads heading pages back without
61 // recompiling. Replaced by each successful compile's own values.
62 last_ledger: Option<Ledger>,
63 last_heads: Vec<Heading>,
64 // A strictly monotonic compile tick for the changed-only delta (see `compile_project_delta`). The one
65 // piece of delta state this instance keeps: NOT the prior id set, which lives with the consumer and is
66 // supplied on each compile -- only a counter, so a consumer can discard a stale async return by version.
67 // Zero until the first delta.
68 version: u32,
69 // The incremental block-authoring memo, retained across delta compiles so an unedited block splices its
70 // previously authored nodes back rather than re-shaping and re-breaking its paragraphs -- the whole point
71 // of the swap (see `compile_project_delta`). Only the BLOCK-authoring layer fills here: the delta renders
72 // each changed page through `svg::render_page` (not the page-emit memo), so the page-SVG cache stays empty
73 // and the heap holds the working set of blocks, never the rendered document (the consumer holds that). The
74 // non-delta `compileProject`/`compileProjectVector` paths pass no memo, so their bytes are untouched.
75 memo: Memo,
76}
77
78#[wasm_bindgen]
79impl DaimondTypst {
80 /// Builds a compiler instance, parsing the embedded reading set once. Infallible by contract -- a font
81 /// that will not parse leaves the set unbuilt and is reported at compile time, never thrown here.
82 #[wasm_bindgen(constructor)]
83 pub fn new() -> DaimondTypst {
84 DaimondTypst {
85 fonts: fonts::libertinus().ok().map(Arc::new),
86 last_ledger: None,
87 last_heads: Vec::new(),
88 version: 0,
89 memo: Memo::new(),
90 }
91 }
92
93 /// Compiles a project to a single PDF: `{ pdf: Uint8Array, pages, diagnostics: [{ file, line, col,
94 /// message }], skipped: string | null }` on success, `{ error, diagnostics, skipped }` otherwise.
95 /// `project` is `{ main, sources: [[path, text], ...], assets: [[path, bytes], ...], fonts: [[path,
96 /// bytes], ...], strict?: bool }`; every entry is injected into the source map and nothing outside it
97 /// is read. `diagnostics` lists every construct the reader passed over; under `strict` any such site,
98 /// zero pages or a source setting no content is returned as `{ error }` instead of a PDF.
99 #[wasm_bindgen(js_name = compileProject)]
100 pub fn compile_project(&mut self, project: &JsValue) -> JsValue {
101 match self.run(project, Mode::Pdf) {
102 Ok((Product::Pdf(bytes), rep)) => ok_pdf(bytes, &rep),
103 Ok((Product::Svg(_), _)) => err_obj(&internal("a PDF compile returned SVG")),
104 Err(fail) => err_obj(&fail),
105 }
106 }
107
108 /// The fast live-view path: compiles a project to Austenite's per-page SVG (already glyph outlines),
109 /// `{ svg: string[], pages, diagnostics, skipped }` on success or `{ error, diagnostics, skipped }`
110 /// otherwise, `strict` as for [`Self::compile_project`]. Cheaper than a PDF -- no object graph, no
111 /// cross-page stream -- so a watch loop can call it per keystroke.
112 #[wasm_bindgen(js_name = compileProjectVector)]
113 pub fn compile_project_vector(&mut self, project: &JsValue) -> JsValue {
114 match self.run(project, Mode::Svg) {
115 Ok((Product::Svg(pages), rep)) => ok_svg(pages, &rep),
116 Ok((Product::Pdf(_), _)) => err_obj(&internal("an SVG compile returned PDF")),
117 Err(fail) => err_obj(&fail),
118 }
119 }
120
121 /// The live-view path Daimond consumes: compiles a project to a changed-only page delta, returning
122 /// `{ version, order: string[], changed: [{ id, svg }], reset }` on success or `{ error }` otherwise.
123 /// The project carries `known: string[]` -- the ids the consumer still holds in its own SVG cache -- and
124 /// only the pages whose id is not among them carry their SVG in `changed`; the rest the consumer serves
125 /// from that cache. The compiler keeps no cache state of its own, so a consumer that has cleared its
126 /// cache (a document close or switch) sends `known: []` and gets a full resend (`reset: true`) rather
127 /// than a blank preview against a cache that no longer holds anything. The memory-frugal successor to
128 /// [`Self::compile_project_vector`] -- see [`crate::delta`] for the shape and the residency contract.
129 /// Ids are opaque decimal strings, since a JavaScript number cannot hold every 64-bit hash exactly.
130 /// The return also carries `pages`, `diagnostics: [{ file, line, col, message }]` and `skipped` (the
131 /// terse summary line, or `null`) exactly as [`Self::compile_project`] does, and honours `strict` the
132 /// same way. On a strict refusal the version does not step, so the consumer's cache stays valid.
133 #[wasm_bindgen(js_name = compileProjectDelta)]
134 pub fn compile_project_delta(&mut self, project: &JsValue) -> JsValue {
135 match self.run_delta(project) {
136 Ok(out) => ok_delta(&out),
137 Err(fail) => err_obj(&fail),
138 }
139 }
140
141 /// The font families a compile of `project` can set by name, as a sorted `string[]`: the embedded
142 /// families (`Libertinus Serif`, `Libertinus Mono`, `New Computer Modern Math`) and the family of each
143 /// `project.fonts` entry named `<Family>-<Variant>.{ttf,otf}` that the engine's face resolver loads.
144 /// `project` is optional; with none, or with no fonts, only the embedded families are listed. For a
145 /// missing-font pre-check before a compile.
146 #[wasm_bindgen(js_name = fontFamilies)]
147 pub fn font_families(&self, project: &JsValue) -> JsValue {
148 let main_path = PathBuf::from(main_of(project));
149 let mut files: HashMap<PathBuf, Vec<u8>> = HashMap::new();
150 let injected = read_font_pairs(project, &main_path, &mut files);
151 let families = if files.is_empty() {
152 compile::font_families(&main_path, &[])
153 } else {
154 match vfs::install(files) {
155 Ok(()) => {
156 let f = compile::font_families(&main_path, &injected);
157 let _ = vfs::clear();
158 f
159 },
160 Err(_) => compile::font_families(&main_path, &[]),
161 }
162 };
163 let arr = js_sys::Array::new();
164 for f in &families {
165 arr.push(&JsValue::from_str(f));
166 }
167 arr.into()
168 }
169
170 /// The engine's identity, `{ engine: "austenite", version, git }`: the crate version and the commit it
171 /// was built from (`<12 hex>`, `-dirty` when built from uncommitted changes, or `unknown`).
172 #[wasm_bindgen(js_name = engineInfo)]
173 pub fn engine_info(&self) -> JsValue {
174 let obj = js_sys::Object::new();
175 set(&obj, "engine", &JsValue::from_str("austenite"));
176 set(&obj, "version", &JsValue::from_str(compile::engine_version()));
177 set(&obj, "git", &JsValue::from_str(compile::engine_git_hash()));
178 obj.into()
179 }
180
181 /// Compiles a single source string to PDF, wrapping it as the project's `/main.typ`. The convenience
182 /// entry for a one-file document with no injected assets or fonts.
183 #[wasm_bindgen]
184 pub fn compile(&mut self, source: &str) -> JsValue {
185 self.compile_project(&single_source_project(source))
186 }
187
188 /// The section rail's query: heading rows from the last compile's ledger as a JSON array of
189 /// `{ kind, label, title, level, page }`, or `null` when nothing has compiled yet or the selector is
190 /// one this lane does not resolve. The consumer degrades on `null`.
191 ///
192 /// This is a heading/anchor query only, not full Typst `query` semantics: a selector naming headings
193 /// (the section rail's use) returns heading rows; any other selector returns `null` so the caller falls
194 /// back rather than receiving a wrong answer.
195 #[wasm_bindgen(js_name = queryProject)]
196 pub fn query_project(&self, selector: &str, _field: &str) -> JsValue {
197 let ledger = match &self.last_ledger {
198 Some(l) => l,
199 None => return JsValue::NULL,
200 };
201 let sel = selector.to_lowercase();
202 if !(sel.contains("head") || sel.contains("outline") || sel.is_empty()) {
203 return JsValue::NULL;
204 }
205 let mut rows: Vec<Dat> = Vec::new();
206 for h in &self.last_heads {
207 let page = match ledger.page_of(&h.id) {
208 Some(p) => p,
209 None => continue,
210 };
211 rows.push(omapdat!{
212 "kind" => dat!("heading"),
213 "label" => dat!(h.id.key.clone()),
214 "title" => dat!(h.title.clone()),
215 "level" => dat!(h.level as u32),
216 "page" => dat!(page),
217 });
218 }
219 let json = match Dat::List(rows).json() {
220 Ok(s) => s,
221 Err(_) => return JsValue::NULL,
222 };
223 match js_sys::JSON::parse(&json) {
224 Ok(v) => v,
225 Err(_) => JsValue::NULL,
226 }
227 }
228
229 /// The compiler's current wasm linear-memory size, in megabytes -- a heap-usage proxy for the app's
230 /// memory gauge.
231 #[wasm_bindgen(js_name = heapMB)]
232 pub fn heap_mb(&self) -> f64 {
233 // `memory_size` counts 64 KiB pages of the one linear memory, so pages / 16 is the size in MiB.
234 core::arch::wasm32::memory_size(0) as f64 / 16.0
235 }
236}
237
238impl Default for DaimondTypst {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244/// Which artefact a run produces.
245#[derive(Clone, Copy)]
246enum Mode {
247 Pdf,
248 Svg,
249}
250
251/// A run's product, matching the mode it was asked for.
252enum Product {
253 Pdf(Vec<u8>),
254 Svg(Vec<String>),
255}
256
257/// Runs a compile closure under [`std::panic::catch_unwind`], turning a panic into an ordinary error rather
258/// than letting it escape as a wasm trap. A trap unwinds no Rust state and leaves the instance's shadow
259/// stack unrestored, so a single panicked compile would corrupt the [`DaimondTypst`] instance until the page
260/// reloaded; catching it here keeps the instance usable and surfaces an `{ error }` object instead. The
261/// engine's own cycle and depth caps mean a well-formed document never panics -- this is the net for the
262/// unforeseen. (Under a `panic = "abort"` build the abort still traps; the guard is effective wherever
263/// unwinding is enabled, and is harmless otherwise.)
264fn catch_compile<T, F>(f: F, main: &str) -> Outcome<T>
265where
266 F: FnOnce() -> Outcome<T>,
267{
268 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
269 Ok(outcome) => outcome,
270 Err(_) => Err(err!(
271 "The compiler panicked while assembling {:?}; the document was not produced.", main; Bug)),
272 }
273}
274
275/// A compile that did not produce its artefact: the leading diagnostic (the `{ error }` line) and whatever
276/// report the compile got as far as building -- all its refusals under a strict refusal, none for a hard
277/// error, which stops before the report exists.
278struct Failure {
279 head: Diagnostic,
280 report: Option<Report>,
281}
282
283/// What [`DaimondTypst::run_inner`] ends with: the artefact and its report, or a strict refusal.
284enum Ran<T> {
285 Done(T, Report),
286 Refused(Diagnostic, Report),
287}
288
289impl DaimondTypst {
290 /// Installs the project, runs a compile and clears the source map before returning either way, so one
291 /// instance compiles many documents in turn without a stale file leaking between them. A hard error is
292 /// placed at a source position while the map is still installed.
293 fn run(&mut self, project: &JsValue, mode: Mode) -> Result<(Product, Report), Failure> {
294 let main = main_of(project);
295 let strict = bool_field(project, "strict");
296 self.guarded(project, &main, |me, main_path| me.run_inner(main_path, mode, strict))
297 }
298
299 /// As [`Self::run`] for the changed-only delta: steps the compile tick and computes the delta of this
300 /// compile against the consumer's supplied `known` ids. The compiler retains no prior id set; only the
301 /// rendered SVG of a newly-changed page is built, carried into the delta, and dropped.
302 fn run_delta(&mut self, project: &JsValue) -> Result<DeltaOut, Failure> {
303 let main = main_of(project);
304 let strict = bool_field(project, "strict");
305 let known = parse_known(project);
306 match self.guarded(project, &main, |me, main_path| me.run_delta_inner(main_path, &known, strict)) {
307 Ok((delta, report)) => Ok(DeltaOut { delta, report }),
308 Err(fail) => Err(fail),
309 }
310 }
311
312 /// The shared frame of every compile: install the project into the source map, run `body` under the
313 /// panic guard, place a hard error at its source position, and clear the map whatever happened.
314 fn guarded<T, F>(&mut self, project: &JsValue, main: &str, body: F) -> Result<(T, Report), Failure>
315 where
316 F: FnOnce(&mut Self, &Path) -> Outcome<Ran<T>>,
317 {
318 let main_path = PathBuf::from(main);
319 let sources = match install_project(project, &main_path) {
320 Ok(s) => s,
321 Err(e) => {
322 let _ = vfs::clear();
323 return Err(Failure { head: compile::locate_error(&e, &main_path, &[]), report: None });
324 },
325 };
326 let outcome = catch_compile(|| body(self, &main_path), main);
327 let result = match outcome {
328 Ok(Ran::Done(t, report)) => Ok((t, report)),
329 Ok(Ran::Refused(head, report)) => Err(Failure { head, report: Some(report) }),
330 Err(e) => Err(Failure {
331 head: compile::locate_error(&e, &main_path, &sources),
332 report: None,
333 }),
334 };
335 let _ = vfs::clear();
336 result
337 }
338
339 fn run_delta_inner(&mut self, main_path: &Path, known: &[u64], strict: bool)
340 -> Outcome<Ran<delta::PageDelta>>
341 {
342 // The consumer owns the SVG cache, so it -- not this instance -- is the authority on which page ids
343 // are already held. It supplies them as `known`; a page whose id is not among them is resent. Holding
344 // the prior set here would blank the preview whenever the consumer cleared its cache (a document
345 // close or switch) while this singleton compiler lived on: the delta would report nothing changed
346 // against a cache holding nothing. So `reset` is `known.is_empty()` by construction -- true exactly
347 // when the consumer has nothing to reuse, and a cleared cache recovers with a full resend.
348 //
349 // The delta path passes the persistent block-authoring memo, so an unedited block splices its cached
350 // nodes rather than re-authoring: the incremental recompile the swap exists for.
351 let (rendered, report) = res!(self.assemble_and_run(main_path, true));
352 // Close the memo generation now the authoring pass is done, dropping block entries untouched for two
353 // compiles. `author_and_run_memo` opened it with `Memo::begin`; the page-emit cache is never touched on
354 // this path (the delta renders through `svg::render_page`), so nothing but blocks is swept, and the
355 // retained heap is the working set of blocks -- never the rendered page SVG.
356 self.memo.sweep();
357 if strict {
358 if let Some(head) = report.strict_failure(main_path) {
359 return Ok(Ran::Refused(head, report));
360 }
361 }
362 let d = res!(delta::compute(&rendered.out.pages, known, self.version));
363 // The version is the one piece of delta state this instance keeps: a strictly monotonic tick, so a
364 // consumer that dispatches compiles without awaiting each can discard a stale async return by its
365 // version. It is deliberately not consumer-supplied -- two compiles dispatched before either returned
366 // would carry the same supplied version and could not be ordered -- and a cache clear does not
367 // disturb it, since recovery is driven by an empty `known`, not by the counter.
368 self.version = d.version;
369 Ok(Ran::Done(d, report))
370 }
371
372 /// Assembles, authors, runs, decorates and mirror-shifts the installed project through the shared
373 /// pipeline -- the same code the native binary drives, so the two surfaces cannot drift -- and keeps the
374 /// resolved ledger and heading table for a later section-rail query. The lone-file path takes this
375 /// instance's once-built reading set rather than rebuilding it; the base directory for `/assets/...`
376 /// figures is set inside `assemble`. The report is built here, while the source map still holds the
377 /// text each refusal's position is read from.
378 ///
379 /// `use_memo` threads this instance's persistent block-authoring memo through the authoring stage (the
380 /// delta path), so an unedited block reuses its cached layout across recompiles; the non-memo paths pass
381 /// `false` and stay byte-identical to the native production compile.
382 fn assemble_and_run(&mut self, main_path: &Path, use_memo: bool) -> Outcome<(compile::Rendered, Report)> {
383 let fonts = match &self.fonts {
384 Some(f) => f.clone(),
385 None => return Err(err!("The embedded font set could not be built."; Init, Missing)),
386 };
387 let (assembled, refusals, skip) = res!(compile::assemble(main_path, || Ok(fonts.clone())));
388 let empty = assembled.blocks.is_empty();
389 let rendered = if use_memo {
390 res!(compile::author_and_run_memo(assembled, Some(&mut self.memo)))
391 } else {
392 res!(compile::author_and_run(assembled))
393 };
394 let report = Report::new(rendered.out.pages.len(), &refusals, skip, empty);
395
396 // Keep the resolved ledger and heading table for a later section-rail query.
397 self.last_ledger = Some(rendered.out.ledger.clone());
398 self.last_heads = rendered.heads.clone();
399 Ok((rendered, report))
400 }
401
402 fn run_inner(&mut self, main_path: &Path, mode: Mode, strict: bool) -> Outcome<Ran<Product>> {
403 let (rendered, report) = res!(self.assemble_and_run(main_path, false));
404 // A strict refusal is decided before the emit, so a refused compile spends nothing on a PDF.
405 if strict {
406 if let Some(head) = report.strict_failure(main_path) {
407 return Ok(Ran::Refused(head, report));
408 }
409 }
410 let compile::Rendered { mut out, heads, geom: _ } = rendered;
411 let product = match mode {
412 Mode::Svg => {
413 let mut pages: Vec<String> = Vec::with_capacity(out.pages.len());
414 for page in &out.pages {
415 pages.push(res!(svg::render_page(page)));
416 }
417 Product::Svg(pages)
418 },
419 Mode::Pdf => Product::Pdf(res!(compile::emit_pdf(&mut out, &heads))),
420 };
421 Ok(Ran::Done(product, report))
422 }
423}
424
425/// Installs every source, asset and font of `project` into the source map, the main path naming the root
426/// among them, and returns the installed paths for placing a later error.
427fn install_project(project: &JsValue, main_path: &Path) -> Outcome<Vec<PathBuf>> {
428 let mut files: HashMap<PathBuf, Vec<u8>> = HashMap::new();
429 read_text_pairs(project, "sources", &mut files);
430 read_byte_pairs(project, "assets", &mut files);
431 // A font is installed at the path the consumer named AND at the location the lone-file face resolver
432 // reads, so a face the document names resolves whatever path the consumer chose (see `read_font_pairs`).
433 let _ = read_font_pairs(project, main_path, &mut files);
434 if !files.contains_key(main_path) {
435 return Err(err!("The project has no source for its main file {:?}.", main_path; Input, Missing));
436 }
437 let mut paths: Vec<PathBuf> = files.keys().cloned().collect();
438 paths.sort();
439 res!(vfs::install(files));
440 Ok(paths)
441}
442
443/// The project's main path, `/main.typ` when it names none.
444fn main_of(project: &JsValue) -> String {
445 string_field(project, "main").unwrap_or_else(|| "/main.typ".to_string())
446}
447
448/// A failure the engine itself caused, with no source position to give.
449fn internal(msg: &str) -> Failure {
450 Failure {
451 head: Diagnostic { file: String::new(), line: 0, col: 0, message: fmt!("internal: {}", msg) },
452 report: None,
453 }
454}
455
456// ┌───────────────────────────────────────────────────────────────────────────┐
457// │ JS INTEROP │
458// └───────────────────────────────────────────────────────────────────────────┘
459
460/// A single-source project object `{ main: "/main.typ", sources: [["/main.typ", source]] }`.
461fn single_source_project(source: &str) -> JsValue {
462 let obj = js_sys::Object::new();
463 let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("main"), &JsValue::from_str("/main.typ"));
464 let sources = js_sys::Array::new();
465 let pair = js_sys::Array::new();
466 pair.push(&JsValue::from_str("/main.typ"));
467 pair.push(&JsValue::from_str(source));
468 sources.push(&pair);
469 let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("sources"), &sources);
470 obj.into()
471}
472
473/// Reads a `[[path, text], ...]` field into the source map, each text UTF-8 encoded.
474fn read_text_pairs(project: &JsValue, key: &str, out: &mut HashMap<PathBuf, Vec<u8>>) {
475 let arr = match array_field(project, key) {
476 Some(a) => a,
477 None => return,
478 };
479 for entry in arr.iter() {
480 if let Ok(pair) = entry.dyn_into::<js_sys::Array>() {
481 if let (Some(path), Some(text)) = (pair.get(0).as_string(), pair.get(1).as_string()) {
482 out.insert(PathBuf::from(path), text.into_bytes());
483 }
484 }
485 }
486}
487
488/// Reads a `[[path, bytes], ...]` field into the source map, each value a `Uint8Array` or `ArrayBuffer`.
489fn read_byte_pairs(project: &JsValue, key: &str, out: &mut HashMap<PathBuf, Vec<u8>>) {
490 let arr = match array_field(project, key) {
491 Some(a) => a,
492 None => return,
493 };
494 for entry in arr.iter() {
495 if let Ok(pair) = entry.dyn_into::<js_sys::Array>() {
496 if let Some(path) = pair.get(0).as_string() {
497 if let Some(bytes) = to_bytes(&pair.get(1)) {
498 out.insert(PathBuf::from(path), bytes);
499 }
500 }
501 }
502 }
503}
504
505/// Reads the project's `fonts: [[path, bytes], ...]` into the source map, each installed at the path the
506/// consumer named AND -- so the lone-file face resolver discovers it whatever path was chosen -- at the
507/// resolver's own `<root>/assets/fonts/<basename>` location (see [`book::project_font_path`]). Without the
508/// second placement an injected font is present in the map but invisible to the resolver, which reads only
509/// its own directory: a face the document names would silently fall back to the reading role. The consumer
510/// still names a usable face by its `<Family>-<Variant>.{ttf,otf}` basename and declares that family as a
511/// heading face; this makes such a font resolve regardless of the path it was injected under. Returns the
512/// paths as the consumer gave them.
513fn read_font_pairs(project: &JsValue, main_path: &Path, out: &mut HashMap<PathBuf, Vec<u8>>) -> Vec<PathBuf> {
514 let mut given_paths = Vec::new();
515 let arr = match array_field(project, "fonts") {
516 Some(a) => a,
517 None => return given_paths,
518 };
519 for entry in arr.iter() {
520 if let Ok(pair) = entry.dyn_into::<js_sys::Array>() {
521 if let Some(path) = pair.get(0).as_string() {
522 if let Some(bytes) = to_bytes(&pair.get(1)) {
523 let given = PathBuf::from(path);
524 if let Some(routed) = book::project_font_path(main_path, &given) {
525 if routed != given {
526 out.insert(routed, bytes.clone());
527 }
528 }
529 given_paths.push(given.clone());
530 out.insert(given, bytes);
531 }
532 }
533 }
534 }
535 given_paths
536}
537
538/// The bytes of a `Uint8Array` or an `ArrayBuffer`, or `None` for anything else.
539fn to_bytes(v: &JsValue) -> Option<Vec<u8>> {
540 if let Ok(u8arr) = v.clone().dyn_into::<js_sys::Uint8Array>() {
541 return Some(u8arr.to_vec());
542 }
543 if let Ok(buf) = v.clone().dyn_into::<js_sys::ArrayBuffer>() {
544 return Some(js_sys::Uint8Array::new(&buf).to_vec());
545 }
546 None
547}
548
549/// A string-valued field of a JS object, or `None` when it is absent or not a string.
550fn string_field(obj: &JsValue, key: &str) -> Option<String> {
551 js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok().and_then(|v| v.as_string())
552}
553
554/// A boolean field of a JS object, false when it is absent or not `true`.
555fn bool_field(obj: &JsValue, key: &str) -> bool {
556 match js_sys::Reflect::get(obj, &JsValue::from_str(key)) {
557 Ok(v) => v.as_bool().unwrap_or(false),
558 Err(_) => false,
559 }
560}
561
562/// An array-valued field of a JS object, or `None` when it is absent or not an array.
563fn array_field(obj: &JsValue, key: &str) -> Option<js_sys::Array> {
564 js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok().and_then(|v| v.dyn_into::<js_sys::Array>().ok())
565}
566
567/// The consumer's currently-cached page ids, read from the project's `known: string[]` field and parsed
568/// from decimal (the shape [`ok_delta`] emits them in). An absent field, or an entry that is not a decimal
569/// string, is skipped; an empty result forces a full reset -- the safe direction, a resend over a blank.
570fn parse_known(project: &JsValue) -> Vec<u64> {
571 let arr = match array_field(project, "known") {
572 Some(a) => a,
573 None => return Vec::new(),
574 };
575 let mut ids = Vec::with_capacity(arr.length() as usize);
576 for entry in arr.iter() {
577 if let Some(s) = entry.as_string() {
578 if let Ok(id) = s.parse::<u64>() {
579 ids.push(id);
580 }
581 }
582 }
583 ids
584}
585
586/// Sets one property; a failed set on a fresh plain object cannot happen, so it is not reported.
587fn set(obj: &js_sys::Object, key: &str, val: &JsValue) {
588 let _ = js_sys::Reflect::set(obj, &JsValue::from_str(key), val);
589}
590
591/// Sets `pages`, `diagnostics: [{ file, line, col, message }]` and `skipped` (string or `null`) from a
592/// report, the fields every compile result carries.
593fn set_report(obj: &js_sys::Object, rep: &Report) {
594 set(obj, "pages", &JsValue::from_f64(rep.pages as f64));
595 set(obj, "diagnostics", &diagnostics_array(&rep.diagnostics));
596 let skipped = match &rep.skipped {
597 Some(s) => JsValue::from_str(s),
598 None => JsValue::NULL,
599 };
600 set(obj, "skipped", &skipped);
601}
602
603fn diagnostics_array(diags: &[Diagnostic]) -> js_sys::Array {
604 let arr = js_sys::Array::new();
605 for d in diags {
606 let entry = js_sys::Object::new();
607 set(&entry, "file", &JsValue::from_str(&d.file));
608 set(&entry, "line", &JsValue::from_f64(d.line as f64));
609 set(&entry, "col", &JsValue::from_f64(d.col as f64));
610 set(&entry, "message", &JsValue::from_str(&d.message));
611 arr.push(&entry);
612 }
613 arr
614}
615
616/// `{ pdf: Uint8Array, pages, diagnostics, skipped }`.
617fn ok_pdf(bytes: Vec<u8>, rep: &Report) -> JsValue {
618 let obj = js_sys::Object::new();
619 set(&obj, "pdf", &js_sys::Uint8Array::from(bytes.as_slice()));
620 set_report(&obj, rep);
621 obj.into()
622}
623
624/// `{ svg: string[], pages, diagnostics, skipped }`.
625fn ok_svg(pages: Vec<String>, rep: &Report) -> JsValue {
626 let obj = js_sys::Object::new();
627 let arr = js_sys::Array::new();
628 for s in &pages {
629 arr.push(&JsValue::from_str(s));
630 }
631 set(&obj, "svg", &arr);
632 set_report(&obj, rep);
633 obj.into()
634}
635
636/// A delta compile's result: the changed-only page delta and the compile's report.
637struct DeltaOut {
638 delta: delta::PageDelta,
639 report: Report,
640}
641
642/// `{ version, order: string[], changed: [{ id, svg }], reset, pages, diagnostics, skipped }`. Ids (page
643/// content hashes) are decimal strings, since a JavaScript number holds only 53 bits exactly and would
644/// silently corrupt a 64-bit hash; the consumer treats them as opaque keys.
645fn ok_delta(out: &DeltaOut) -> JsValue {
646 let d = &out.delta;
647 let obj = js_sys::Object::new();
648 set(&obj, "version", &JsValue::from_f64(d.version as f64));
649 let order = js_sys::Array::new();
650 for id in &d.order {
651 order.push(&JsValue::from_str(&fmt!("{}", id)));
652 }
653 set(&obj, "order", &order);
654 let changed = js_sys::Array::new();
655 for (id, svg) in &d.changed {
656 let entry = js_sys::Object::new();
657 set(&entry, "id", &JsValue::from_str(&fmt!("{}", id)));
658 set(&entry, "svg", &JsValue::from_str(svg));
659 changed.push(&entry);
660 }
661 set(&obj, "changed", &changed);
662 set(&obj, "reset", &JsValue::from_bool(d.reset));
663 set_report(&obj, &out.report);
664 obj.into()
665}
666
667/// `{ error: "file:line:col: message", diagnostics, skipped }` -- the shape every compile returns on
668/// failure, so a caller never sees a throw. `diagnostics` leads with the error's own entry, then every
669/// refused site the compile reached; `skipped` is the terse line, or `null`.
670fn err_obj(fail: &Failure) -> JsValue {
671 let obj = js_sys::Object::new();
672 set(&obj, "error", &JsValue::from_str(&fmt!("{}", fail.head)));
673 let mut diags = vec![fail.head.clone()];
674 let mut skipped = JsValue::NULL;
675 if let Some(rep) = &fail.report {
676 // A strict refusal's head is its first site restated; list the sites once, after it.
677 diags.extend(rep.diagnostics.iter().cloned());
678 set(&obj, "pages", &JsValue::from_f64(rep.pages as f64));
679 if let Some(s) = &rep.skipped {
680 skipped = JsValue::from_str(s);
681 }
682 }
683 set(&obj, "diagnostics", &diagnostics_array(&diags));
684 set(&obj, "skipped", &skipped);
685 obj.into()
686}