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 | |
| 24 | use crate::book; |
| 25 | use crate::compile::{ |
| 26 | self, |
| 27 | Diagnostic, |
| 28 | Report, |
| 29 | }; |
| 30 | use crate::delta; |
| 31 | use crate::doc::Heading; |
| 32 | use crate::emit::svg; |
| 33 | use crate::fonts; |
| 34 | use crate::ledger::Ledger; |
| 35 | use crate::memo::Memo; |
| 36 | use crate::vfs; |
| 37 | |
| 38 | use oxedyne_fe2o3_core::prelude::*; |
| 39 | use oxedyne_fe2o3_jdat::prelude::*; |
| 40 | use oxedyne_fe2o3_font::set::FontSet; |
| 41 | |
| 42 | use std::collections::HashMap; |
| 43 | use std::path::{ |
| 44 | Path, |
| 45 | PathBuf, |
| 46 | }; |
| 47 | use std::sync::Arc; |
| 48 | |
| 49 | use wasm_bindgen::prelude::*; |
| 50 | use 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] |
| 55 | pub 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] |
| 79 | impl 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 | |
| 238 | impl Default for DaimondTypst { |
| 239 | fn default() -> Self { |
| 240 | Self::new() |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | /// Which artefact a run produces. |
| 245 | #[derive(Clone, Copy)] |
| 246 | enum Mode { |
| 247 | Pdf, |
| 248 | Svg, |
| 249 | } |
| 250 | |
| 251 | /// A run's product, matching the mode it was asked for. |
| 252 | enum 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.) |
| 264 | fn catch_compile<T, F>(f: F, main: &str) -> Outcome<T> |
| 265 | where |
| 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. |
| 278 | struct 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. |
| 284 | enum Ran<T> { |
| 285 | Done(T, Report), |
| 286 | Refused(Diagnostic, Report), |
| 287 | } |
| 288 | |
| 289 | impl 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. |
| 427 | fn 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. |
| 444 | fn 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. |
| 449 | fn 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]] }`. |
| 461 | fn 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. |
| 474 | fn 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`. |
| 489 | fn 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. |
| 513 | fn 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. |
| 539 | fn 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. |
| 550 | fn 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`. |
| 555 | fn 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. |
| 563 | fn 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. |
| 570 | fn 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. |
| 587 | fn 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. |
| 593 | fn 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 | |
| 603 | fn 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 }`. |
| 617 | fn 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 }`. |
| 625 | fn 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. |
| 637 | struct 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. |
| 645 | fn 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`. |
| 670 | fn 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 | } |