oxedyne/daimond/www/js/typst.js
33.3 KiB, 1 run
created by r2519314175:1463, 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 | /* ============================================================ |
| 2 | Daimond — in-browser Typst compiler (Stage 4b) |
| 3 | ------------------------------------------------------------ |
| 4 | A thin, self-hosted wrapper over the Typst wasm compiler |
| 5 | (typst.ts web-compiler, vendored under www/vendor/typst/). |
| 6 | It compiles a `.typ` source string, or a whole PROJECT of |
| 7 | sources, assets and fonts gathered in Rust, to a PDF |
| 8 | (Uint8Array) entirely in the browser — no server, no CDN. |
| 9 | |
| 10 | The 28 MB compiler wasm and the default fonts are fetched |
| 11 | from the vendored directory only; nothing leaves the origin. |
| 12 | The compiler and its font set are built once, lazily, on the |
| 13 | first compile and reused thereafter. |
| 14 | |
| 15 | Security (H5): this module produces bytes only; it never |
| 16 | touches the DOM. The caller renders the PDF from a blob URL |
| 17 | in an <embed>, and shows any diagnostics via textContent. |
| 18 | ============================================================ */ |
| 19 | |
| 20 | // Vendored assets, resolved relative to this module's own URL so |
| 21 | // the paths hold wherever `www/` is served from. |
| 22 | /// What the app says. A module, not a script, so the engine is reached through |
| 23 | /// the window rather than through a shared closure. |
| 24 | function tt(k, v) { return window.DaimondI18n ? window.DaimondI18n.t(k, v) : k; } |
| 25 | |
| 26 | const VENDOR = new URL('../vendor/typst/', import.meta.url); |
| 27 | const GLUE = new URL('typst_ts_web_compiler.mjs', VENDOR); |
| 28 | const WASM = new URL('typst_ts_web_compiler_bg.wasm', VENDOR); |
| 29 | |
| 30 | // The default font set: Libertinus Serif (Typst's default body |
| 31 | // and heading family) plus New Computer Modern Math (the default |
| 32 | // maths font), so a heading, paragraph and equation all render. |
| 33 | const FONTS = [ |
| 34 | 'LibertinusSerif-Regular.otf', |
| 35 | 'LibertinusSerif-Bold.otf', |
| 36 | 'LibertinusSerif-Italic.otf', |
| 37 | 'LibertinusSerif-BoldItalic.otf', |
| 38 | 'NewCMMath-Regular.otf', |
| 39 | ]; |
| 40 | |
| 41 | // Full diagnostics (see typst.ts: none=1, unix=2, full=3), so a |
| 42 | // failed compile carries a human-readable message. |
| 43 | const DIAG_FULL = 3; |
| 44 | |
| 45 | // The main source path inside the compiler's shadow filesystem. |
| 46 | const MAIN = '/main.typ'; |
| 47 | |
| 48 | // ── The memo is the incremental compiler, and it is not an optimisation ───── |
| 49 | // |
| 50 | // `_compilerPromise` holds ONE `TypstCompiler` for the life of the document. It is |
| 51 | // never freed and never rebuilt, and the module-URL guard at the foot of this file |
| 52 | // exists specifically to stop a second one being installed. |
| 53 | // |
| 54 | // THAT MEMO IS WHY A REBUILD IS HALF A SECOND RATHER THAN THREE. Every compile calls |
| 55 | // `reset_shadow()` and re-`add_source`s all 63 files of a book, which wipes the shadow |
| 56 | // filesystem -- but NOT comemo's memo cache, which is keyed on content hashes. Identical |
| 57 | // text re-added under the same path hashes the same, so the cached layout is still valid |
| 58 | // and only what changed is laid out again. Measured on the author's 281-page book: 2960 ms |
| 59 | // the first time, 182 ms the second, 384 ms after a real edit -- a factor of fifteen, and |
| 60 | // the whole difference between a watch loop and a build. |
| 61 | // |
| 62 | // So a tidy-up that gave each compile a fresh `TypstCompiler` -- which would look like good |
| 63 | // hygiene, and which nothing here would fail on -- is a silent fifteen-fold regression. It |
| 64 | // would break no test, because every test would still get its PDF. The measurements are in |
| 65 | // `dev/TYPST_WATCH.md` §4. |
| 66 | let _compilerPromise = null; // memoised compiler build |
| 67 | let _glue = null; // the glue module, kept for its font-resolver builder |
| 68 | let _init = null; // the wasm exports, kept so the heap can be read |
| 69 | let _bundled = null; // the bundled font bytes, kept so a project set can re-add them |
| 70 | let _fontState = ''; // which project fonts the live compiler was last given |
| 71 | |
| 72 | /// Build (once) and return the Typst compiler with its fonts |
| 73 | /// loaded. Subsequent calls reuse the same instance. |
| 74 | function getCompiler() { |
| 75 | if (_compilerPromise) return _compilerPromise; |
| 76 | _compilerPromise = (async function () { |
| 77 | const mod = await import(GLUE.href); |
| 78 | _glue = mod; |
| 79 | // Initialise the wasm module from the vendored path. |
| 80 | _init = await mod.default(WASM); |
| 81 | const builder = new mod.TypstCompilerBuilder(); |
| 82 | // No external file/package access is needed: sources are |
| 83 | // injected as shadow files, so a dummy access model is fine. |
| 84 | builder.set_dummy_access_model(); |
| 85 | _bundled = []; |
| 86 | for (const name of FONTS) { |
| 87 | const url = new URL('fonts/' + name, VENDOR); |
| 88 | const resp = await fetch(url); |
| 89 | if (!resp.ok) { |
| 90 | throw new Error('Typst: font fetch failed for ' + name + ' (' + resp.status + ')'); |
| 91 | } |
| 92 | const buf = new Uint8Array(await resp.arrayBuffer()); |
| 93 | _bundled.push(buf); |
| 94 | await builder.add_raw_font(buf); |
| 95 | } |
| 96 | return await builder.build(); |
| 97 | })(); |
| 98 | return _compilerPromise; |
| 99 | } |
| 100 | |
| 101 | /// Extract the PDF bytes from the compiler's return value, which |
| 102 | /// across versions is either the artifact directly or an object |
| 103 | /// carrying `result`/`artifact` alongside `diagnostics`. |
| 104 | function extractPdf(ret) { |
| 105 | if (ret instanceof Uint8Array) return ret; |
| 106 | if (ret && typeof ret === 'object') { |
| 107 | const cand = ret.result || ret.artifact || ret.pdf || ret.output; |
| 108 | if (cand instanceof Uint8Array) return cand; |
| 109 | if (cand && cand.buffer) return new Uint8Array(cand.buffer); |
| 110 | } |
| 111 | return null; |
| 112 | } |
| 113 | |
| 114 | /// Pull any diagnostics into a printable string. |
| 115 | function diagText(ret) { |
| 116 | if (!ret || typeof ret !== 'object') return ''; |
| 117 | const d = ret.diagnostics; |
| 118 | if (!d) return ''; |
| 119 | if (typeof d === 'string') return d; |
| 120 | if (Array.isArray(d)) { |
| 121 | return d.map(function (e) { |
| 122 | if (typeof e === 'string') return e; |
| 123 | if (e && e.message) return (e.severity ? e.severity + ': ' : '') + e.message; |
| 124 | try { return JSON.stringify(e); } catch (_) { return String(e); } |
| 125 | }).join('\n'); |
| 126 | } |
| 127 | try { return JSON.stringify(d); } catch (_) { return String(d); } |
| 128 | } |
| 129 | |
| 130 | // ── Saying what actually went wrong ───────────────────────────────────────── |
| 131 | // |
| 132 | // Typst's dummy access model answers EVERY unreachable path with one sentence: |
| 133 | // |
| 134 | // failed to load file (access denied), hints: cannot read file outside of |
| 135 | // project root, you can adjust the project root with the --root argument |
| 136 | // |
| 137 | // That sentence describes a setting the caller could change, and there is no |
| 138 | // such setting here -- there is no `--root` to pass, and the file was not outside |
| 139 | // a root, it was simply never handed over. In a real session it cost the author |
| 140 | // an hour: his daimon read "outside of project root", concluded the book's images |
| 141 | // were out of bounds, and went looking for assets that were never the problem. |
| 142 | // |
| 143 | // So the message is composed here instead, out of what this side actually knows: |
| 144 | // which file was being read, which line, which path that line names, and what the |
| 145 | // compiler was given. Typst's own words are kept underneath, because they name |
| 146 | // the failing construct, but they no longer lead. |
| 147 | |
| 148 | /// The diagnostics of a failed compile, as an array of objects. |
| 149 | function diagList(ret) { |
| 150 | if (!ret || typeof ret !== 'object') return []; |
| 151 | const d = ret.diagnostics; |
| 152 | if (Array.isArray(d)) return d.filter(function (e) { return e && typeof e === 'object'; }); |
| 153 | return []; |
| 154 | } |
| 155 | |
| 156 | /// The zero-based line number a typst range like `4:7-4:24` starts on, or -1. |
| 157 | function rangeLine(range) { |
| 158 | const m = /^(\d+):(\d+)/.exec(String(range || '')); |
| 159 | return m ? parseInt(m[1], 10) : -1; |
| 160 | } |
| 161 | |
| 162 | /// The zero-based column a typst range starts at, or -1. |
| 163 | function rangeCol(range) { |
| 164 | const m = /^(\d+):(\d+)/.exec(String(range || '')); |
| 165 | return m ? parseInt(m[2], 10) : -1; |
| 166 | } |
| 167 | |
| 168 | /// The source line a diagnostic points at, trimmed, or ''. |
| 169 | function lineAt(text, idx) { |
| 170 | if (idx < 0) return ''; |
| 171 | const lines = String(text || '').split('\n'); |
| 172 | return idx < lines.length ? lines[idx].trim() : ''; |
| 173 | } |
| 174 | |
| 175 | /// The quoted literal a diagnostic points at. |
| 176 | /// |
| 177 | /// The column is used to pick which literal on a busy line, but the whole line |
| 178 | /// is searched rather than the exact byte span: typst counts columns in its own |
| 179 | /// units, and being one unit out must not turn a precise message back into a |
| 180 | /// vague one. |
| 181 | function literalAt(text, range) { |
| 182 | const line = lineAt(text, rangeLine(range)); |
| 183 | if (!line) return ''; |
| 184 | const col = rangeCol(range); |
| 185 | const hits = []; |
| 186 | const re = /"([^"\n]*)"/g; |
| 187 | let m; |
| 188 | while ((m = re.exec(line)) !== null) hits.push({ at: m.index, val: m[1] }); |
| 189 | if (!hits.length) return ''; |
| 190 | for (const h of hits) { |
| 191 | // The line was trimmed, so allow generous slack around the column. |
| 192 | if (col >= 0 && Math.abs(h.at - col) <= 8) return h.val; |
| 193 | } |
| 194 | return hits[0].val; |
| 195 | } |
| 196 | |
| 197 | /// One diagnostic, said in terms of what this compiler was actually given. |
| 198 | /// |
| 199 | /// # Arguments |
| 200 | /// * `d` - The diagnostic object typst returned. |
| 201 | /// * `ctx` - `{ texts, count, root, searched, single }`: the sources by shadow path, |
| 202 | /// how many files went in, where the root was put, which folders a |
| 203 | /// root-relative name was looked for in, and whether this was a |
| 204 | /// single-file compile with no project behind it at all. |
| 205 | function explainDiag(d, ctx) { |
| 206 | const where = d.path ? (d.path + (rangeLine(d.range) >= 0 ? ':' + (rangeLine(d.range) + 1) : '')) : ''; |
| 207 | const head = (d.severity || 'error') + (where ? ' at ' + where : '') + ': '; |
| 208 | const msg = String(d.message || ''); |
| 209 | const text = ctx.texts[d.path] || ''; |
| 210 | const named = literalAt(text, d.range); |
| 211 | |
| 212 | if (/failed to load package/i.test(msg)) { |
| 213 | const pkg = named || 'a package'; |
| 214 | return head + '"' + pkg + '" is a REGISTRY package, not a file. Names beginning "@" ' |
| 215 | + 'come from Typst Universe, which the command-line compiler downloads over the ' |
| 216 | + 'network and caches; this compiler runs inside the page with no network at all, ' |
| 217 | + 'and nothing supplies it packages yet. So this is not a missing file, not a path ' |
| 218 | + 'problem and not a root problem, and no amount of moving files or attaching ' |
| 219 | + 'folders will fix it: do not go looking, and do not rewrite the source to work ' |
| 220 | + 'around it. Compile this document with the command-line typst, which can fetch ' |
| 221 | + 'the package — or copy what it provides into the project as ordinary files and ' |
| 222 | + 'import it by path.'; |
| 223 | } |
| 224 | if (/failed to load file/i.test(msg) || /access denied/i.test(msg)) { |
| 225 | if (ctx.single) { |
| 226 | return head + 'this line reaches for ' + (named ? '"' + named + '"' : 'another file') |
| 227 | + ', and only the one source was given to the compiler. Nothing else was gathered, ' |
| 228 | + 'so there is no file of that name for it to read. Typst\'s own wording, below, ' |
| 229 | + 'talks about a project root and a --root argument; there is no root to adjust ' |
| 230 | + 'here, because a single string was compiled rather than a folder.' |
| 231 | + '\n typst said: ' + msg; |
| 232 | } |
| 233 | return head + 'this line reaches for ' + (named ? '"' + named + '"' : 'a file') |
| 234 | + ', which was not among the ' + ctx.count + ' files gathered for this compile. ' |
| 235 | + 'The project root was put at ' + ctx.root + ', worked out from the imports rather ' |
| 236 | + 'than set anywhere, and a name beginning with "/" was looked for in ' |
| 237 | + (ctx.searched.length ? ctx.searched.join(', then ') : 'the root') + '. A path built ' |
| 238 | + 'at run time -- joined from a variable, say -- cannot be seen when the project is ' |
| 239 | + 'gathered, and would look exactly like this. Check that the file is in one of those ' |
| 240 | + 'folders, and that the source names it as a plain string.' |
| 241 | + '\n typst said: ' + msg; |
| 242 | } |
| 243 | const line = lineAt(text, rangeLine(d.range)); |
| 244 | return head + msg + (line ? '\n ' + line : ''); |
| 245 | } |
| 246 | |
| 247 | /// Every diagnostic of a failed compile, explained. |
| 248 | function explainDiags(ret, ctx) { |
| 249 | const list = diagList(ret); |
| 250 | if (!list.length) return diagText(ret); |
| 251 | return list.map(function (d) { return explainDiag(d, ctx); }).join('\n\n'); |
| 252 | } |
| 253 | |
| 254 | // ── Typesetting is bought, not shipped ────────────────────────── |
| 255 | // |
| 256 | // Typesetting is sold as a pack, and there are THREE ways into this compiler: the |
| 257 | // model's `typst_compile` tool, the ⚙ Compile button in the Doc panel, and this |
| 258 | // driver itself, which the other two share so the 30 MB wasm is built once. |
| 259 | // |
| 260 | // So the gate is here, at the one point all three meet. A gate at the Rust tool |
| 261 | // alone would stop the model and leave the button free, which is not a gate on the |
| 262 | // capability -- it is a gate on one of its doors. The Rust one is still there and |
| 263 | // still first for a tool call: it answers the MODEL, in the model's language, with |
| 264 | // what the pack is and what to tell the user. This one answers a PERSON, in |
| 265 | // theirs, in the header line where they clicked. |
| 266 | // |
| 267 | // The wasm bundle is the single authority on this device for what was bought: the |
| 268 | // page sets it there from `/api/tools`, and `Tool::guard` in Rust reads the very |
| 269 | // same value, so the button and the tool cannot disagree about one purchase. |
| 270 | // Nothing here holds a pack key -- `tool_locked` is asked about the TOOL, and the |
| 271 | // mapping from tool to pack stays in one language. |
| 272 | |
| 273 | /// The tool this compiler is, as the registry names it. |
| 274 | const TOOL = 'typst_compile'; |
| 275 | |
| 276 | /// Whether this account has not bought the pack the compiler is sold in. |
| 277 | /// |
| 278 | /// Asked afresh at every compile rather than memoised: a purchase completes in the |
| 279 | /// middle of a sitting, and a memo would go on refusing a customer who has just paid. |
| 280 | /// |
| 281 | /// Answers `false` -- not locked -- when it cannot ask at all: no bundle, or one not |
| 282 | /// yet initialised. Refusing on that would take a bought tool away from a paying |
| 283 | /// customer over a load order or a network blink, and the gate that actually takes |
| 284 | /// the money is the gateway's, which is not reachable from here in any case. |
| 285 | async function packLocked() { |
| 286 | try { |
| 287 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 288 | return mod.tool_locked(TOOL) === true; |
| 289 | } catch (e) { |
| 290 | return false; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /// Compile a Typst source string to a PDF. |
| 295 | /// |
| 296 | /// Returns `{ pdf: Uint8Array }` on success, or `{ error: string }` |
| 297 | /// when the compiler reports diagnostics or produces no bytes. |
| 298 | export async function compilePdf(source) { |
| 299 | // Before the compiler is built, not after: an account that has not bought the |
| 300 | // pack never fetches the 30 MB wasm, and the refusal is immediate rather than |
| 301 | // arriving at the end of a long download that was always going to be refused. |
| 302 | // |
| 303 | // The wording does not name the pack, deliberately. The catalogue owns its name |
| 304 | // and its price, an operator may change either from the console, and a name |
| 305 | // copied into eight translations would be the copy that goes stale. The Tools |
| 306 | // panel states both, from the table the till charges against, so this points |
| 307 | // there instead. |
| 308 | if (await packLocked()) { |
| 309 | return { error: tt('typst.pack_locked') }; |
| 310 | } |
| 311 | let compiler; |
| 312 | try { |
| 313 | compiler = await getCompiler(); |
| 314 | } catch (e) { |
| 315 | return { error: tt('typst.load_failed', { reason: (e && e.message ? e.message : e) }) }; |
| 316 | } |
| 317 | // A single file has no project behind it, so there is provably nowhere a font |
| 318 | // could come from: a family that is not one of the five bundled here cannot be |
| 319 | // satisfied by anything, and rendering it in a substitute would be the same |
| 320 | // silent lie the project door refuses. Said here as well as there, because the |
| 321 | // document does not become less wrong for having arrived by the smaller door. |
| 322 | const bundledFams = []; |
| 323 | for (const f of _bundled) for (const n of familiesOf(f)) bundledFams.push(n); |
| 324 | const lack = missingFamilies(fontSetsOf(source), bundledFams); |
| 325 | if (lack.length) { |
| 326 | return { error: 'This document asks for the font "' + lack.join('", and for "') |
| 327 | + '", and a single file brings no fonts with it. Only ' + bundledFams.join(', ') |
| 328 | + ' are bundled with this compiler. Typst would substitute one silently, and the ' |
| 329 | + 'line breaks and page count of what came back would not be the ones that print. ' |
| 330 | + 'Put the font file beside the source and compile it as a project, or set a family ' |
| 331 | + 'that is bundled.' }; |
| 332 | } |
| 333 | |
| 334 | try { |
| 335 | // Start from a clean shadow filesystem each time. |
| 336 | compiler.reset_shadow(); |
| 337 | await useFonts([]); |
| 338 | compiler.add_source(MAIN, source); |
| 339 | const ret = compiler.compile(MAIN, undefined, 'pdf', DIAG_FULL); |
| 340 | const pdf = extractPdf(ret); |
| 341 | if (pdf && pdf.length > 4) { |
| 342 | return { pdf: pdf }; |
| 343 | } |
| 344 | const texts = {}; texts[MAIN] = source; |
| 345 | const diag = explainDiags(ret, { texts: texts, count: 1, root: 'nowhere', searched: [], single: true }); |
| 346 | return { error: diag || tt('typst.no_pdf') }; |
| 347 | } catch (e) { |
| 348 | return { error: tt('typst.compile_error', { reason: (e && e.message ? e.message : e) }) }; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // ── Fonts are a correctness question ──────────────────────────────────────── |
| 353 | // |
| 354 | // A family the compiler does not have is not an error and not a warning: this |
| 355 | // build reports diagnostics only when the compile FAILS, and an unknown family |
| 356 | // simply falls back. Measured on this very compiler: a document that sets |
| 357 | // "Radley" and one that sets nothing at all produced byte-identical PDFs. |
| 358 | // |
| 359 | // That silence is the danger. The author proofreads a 281-page book for widows, |
| 360 | // short last lines and page count, and every one of those is decided by the font |
| 361 | // metrics. A preview typeset in a substitute is a preview of a book that will |
| 362 | // never be printed, and nothing on the screen would say so. So the rule here is |
| 363 | // to refuse rather than approximate: if the project names a family that cannot be |
| 364 | // loaded, no PDF is produced and the refusal names the family and where to put it. |
| 365 | |
| 366 | /// The font families a font file provides. |
| 367 | function familiesOf(bytes) { |
| 368 | try { |
| 369 | const info = new _glue.TypstFontResolverBuilder().get_font_info(bytes); |
| 370 | const list = (info && info.info) || []; |
| 371 | return list.map(function (i) { return String(i.family || ''); }).filter(Boolean); |
| 372 | } catch (e) { |
| 373 | return []; |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | /// A family name reduced to what two spellings of the same font share. |
| 378 | function famKey(name) { |
| 379 | return String(name || '').trim().toLowerCase().replace(/\s+/g, ' '); |
| 380 | } |
| 381 | |
| 382 | /// The font families a source asks for, as alternative sets. |
| 383 | /// |
| 384 | /// A parenthesised tuple is typst's fallback chain -- it takes the first family it |
| 385 | /// has -- so the whole tuple is ONE set, satisfied by any member. Anything else, |
| 386 | /// including the `if it.level <= 2 { "Radley" } else { "Libertinus Serif" }` form |
| 387 | /// the author's template uses, yields one set per family: both branches really do |
| 388 | /// render, and folding them into a chain would let a missing font through. |
| 389 | /// |
| 390 | /// This lives here rather than in the Rust gatherer, and only here, because here is |
| 391 | /// where the answer is compared against the fonts actually loaded. Two scanners in |
| 392 | /// two languages agreeing today is two scanners disagreeing later. |
| 393 | /// |
| 394 | /// Its limit, stated because it is a real one: a family reached through a variable |
| 395 | /// (`#let f = "Radley"` … `font: f`) is invisible to it, and this build gives no |
| 396 | /// warning to fall back on -- measured, typst returns diagnostics only when a |
| 397 | /// compile FAILS, and a missing family does not fail. |
| 398 | function fontSetsOf(src) { |
| 399 | const s = String(src || ''); |
| 400 | const out = []; |
| 401 | let i = 0; |
| 402 | for (;;) { |
| 403 | const at = s.indexOf('font:', i); |
| 404 | if (at < 0) break; |
| 405 | let j = at + 'font:'.length; |
| 406 | while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++; |
| 407 | let end; |
| 408 | let asTuple = false; |
| 409 | if (s[j] === '(') { |
| 410 | let depth = 0; |
| 411 | let k = j; |
| 412 | for (; k < s.length; k++) { |
| 413 | if (s[k] === '(') depth++; |
| 414 | else if (s[k] === ')') { depth--; if (!depth) { k++; break; } } |
| 415 | } |
| 416 | end = k; |
| 417 | asTuple = true; |
| 418 | } else { |
| 419 | const nl = s.indexOf('\n', j); |
| 420 | end = nl < 0 ? s.length : nl; |
| 421 | } |
| 422 | const found = []; |
| 423 | const re = /"([^"\n]*)"/g; |
| 424 | const slice = s.slice(j, end); |
| 425 | let m; |
| 426 | while ((m = re.exec(slice)) !== null) if (m[1]) found.push(m[1]); |
| 427 | if (found.length) { |
| 428 | if (asTuple) out.push(found); |
| 429 | else for (const f of found) out.push([f]); |
| 430 | } |
| 431 | i = end > at ? end : at + 1; |
| 432 | } |
| 433 | return out; |
| 434 | } |
| 435 | |
| 436 | /// A cheap fingerprint of a font's bytes, for deciding whether the loaded set is |
| 437 | /// still the set on disk. |
| 438 | /// |
| 439 | /// Path and length alone would miss a font edited in place to the same size, and the |
| 440 | /// consequence of missing it is the whole reason this file refuses substitutes: a |
| 441 | /// book typeset in yesterday's metrics, with today's font sitting in the folder. |
| 442 | function sample(bytes) { |
| 443 | let h = 0; |
| 444 | for (let i = 0; i < bytes.length; i += 997) h = (h * 31 + bytes[i]) >>> 0; |
| 445 | return h; |
| 446 | } |
| 447 | |
| 448 | /// Give the live compiler the bundled fonts plus `extra`, or just the bundled |
| 449 | /// ones when `extra` is empty. |
| 450 | /// |
| 451 | /// `set_fonts` REPLACES the font book rather than adding to it -- measured: a |
| 452 | /// resolver built from one project font alone left the compiler unable to find |
| 453 | /// Libertinus Serif, which it had been built with. So the bundled bytes are |
| 454 | /// re-added every time, and the set is left alone when it has not changed, since |
| 455 | /// rebuilding a resolver per compile would be paid for on every keystroke of a |
| 456 | /// preview loop. |
| 457 | /// |
| 458 | /// # Arguments |
| 459 | /// * `extra` - `[name, Uint8Array]` pairs gathered from the project. |
| 460 | async function useFonts(extra) { |
| 461 | const key = extra.map(function (f) { return f[0] + ':' + f[1].length + ':' + sample(f[1]); }).join('|'); |
| 462 | if (key === _fontState) return; |
| 463 | const compiler = await getCompiler(); |
| 464 | const b = new _glue.TypstFontResolverBuilder(); |
| 465 | for (const f of _bundled) b.add_raw_font(f); |
| 466 | for (const f of extra) b.add_raw_font(f[1]); |
| 467 | compiler.set_fonts(await b.build()); |
| 468 | _fontState = key; |
| 469 | } |
| 470 | |
| 471 | /// Which of the families a project asks for it cannot supply. |
| 472 | /// |
| 473 | /// A `wanted` entry is a set of ALTERNATIVES -- typst takes the first family in |
| 474 | /// a fallback tuple that it has -- so a set is satisfied when any member is |
| 475 | /// available, and unsatisfied only when none is. |
| 476 | /// |
| 477 | /// # Arguments |
| 478 | /// * `wanted` - Alternative sets, from `fontSetsOf` over every source. |
| 479 | /// * `available` - Every family the compiler will have for this compile. |
| 480 | function missingFamilies(wanted, available) { |
| 481 | const have = new Set(available.map(famKey)); |
| 482 | const out = []; |
| 483 | for (const set of wanted) { |
| 484 | if (!set.length) continue; |
| 485 | if (set.some(function (n) { return have.has(famKey(n)); })) continue; |
| 486 | const label = set.join('" or "'); |
| 487 | if (out.indexOf(label) < 0) out.push(label); |
| 488 | } |
| 489 | return out; |
| 490 | } |
| 491 | |
| 492 | /// Compile a gathered project to `fmt`, which is `'pdf'` or `'vector'`. |
| 493 | /// |
| 494 | /// The argument carries CONTENTS, never a path to contents: `sources` and |
| 495 | /// `assets` are keyed by position in the compiler's in-memory shadow filesystem, |
| 496 | /// which exists only inside the wasm module. Every byte in it was read on the |
| 497 | /// Rust side, through the OPFS edge, under the path jail and the per-account |
| 498 | /// namespace -- see `src/wasm/typst.rs`, which is the only thing that builds one |
| 499 | /// of these. There is nothing here that could open a file if it tried. |
| 500 | /// |
| 501 | /// # Arguments |
| 502 | /// * `p` - `{ root, main, sources, assets, fonts, fontDirs, searched }`. |
| 503 | /// * `fmt` - `'pdf'` for the publishing path, `'vector'` for the live view. |
| 504 | async function compileProjectAs(p, fmt) { |
| 505 | if (await packLocked()) { |
| 506 | return { error: tt('typst.pack_locked') }; |
| 507 | } |
| 508 | if (!p || !Array.isArray(p.sources) || !p.sources.length) { |
| 509 | return { error: 'Nothing was gathered to compile.' }; |
| 510 | } |
| 511 | let compiler; |
| 512 | try { |
| 513 | compiler = await getCompiler(); |
| 514 | } catch (e) { |
| 515 | return { error: tt('typst.load_failed', { reason: (e && e.message ? e.message : e) }) }; |
| 516 | } |
| 517 | |
| 518 | // Fonts first, and refuse before compiling rather than after: a PDF handed |
| 519 | // back and then disowned is a PDF somebody keeps. |
| 520 | const fonts = (p.fonts || []).map(function (f) { return [String(f[0]), f[1]]; }); |
| 521 | const available = []; |
| 522 | for (const f of _bundled) for (const n of familiesOf(f)) available.push(n); |
| 523 | for (const f of fonts) for (const n of familiesOf(f[1])) available.push(n); |
| 524 | const wanted = []; |
| 525 | for (const s of p.sources) for (const set of fontSetsOf(s[1])) wanted.push(set); |
| 526 | const missing = missingFamilies(wanted, available); |
| 527 | if (missing.length) { |
| 528 | // Where it looked, always -- and when it found nothing, the folders it looked |
| 529 | // in rather than the root alone. The search starts at the compiled file's own |
| 530 | // folder and works outward to the root, so naming the root (which this did |
| 531 | // until seq 117) described neither where it looked nor where to put a font. |
| 532 | const where = (p.searched || []).length ? p.searched : [String(p.root || 'the project root')]; |
| 533 | const dirs = (p.fontDirs || []).length |
| 534 | ? 'Fonts were looked for in ' + p.fontDirs.join(' and ') + '.' |
| 535 | : 'No font directory was found: this compile looked for an "assets/fonts" and a ' |
| 536 | + '"fonts" folder in ' + where.join(', then ') + ', and found neither.'; |
| 537 | return { error: 'This project asks for the font "' + missing.join('", and for "') |
| 538 | + '", which is not among the fonts available to compile it. Nothing was produced. ' |
| 539 | + 'A missing family is not an error to Typst -- it substitutes another silently -- ' |
| 540 | + 'so a PDF made without it would have different line breaks, different last lines ' |
| 541 | + 'and a different page count from the one that prints, with nothing on screen to ' |
| 542 | + 'say so. ' + dirs + ' Put the font file (.ttf, .otf or .ttc) in one of those and ' |
| 543 | + 'compile again.' }; |
| 544 | } |
| 545 | |
| 546 | try { |
| 547 | await useFonts(fonts); |
| 548 | compiler.reset_shadow(); |
| 549 | const texts = {}; |
| 550 | for (const s of p.sources) { |
| 551 | const path = String(s[0]); |
| 552 | texts[path] = String(s[1]); |
| 553 | if (compiler.add_source(path, texts[path]) === false) { |
| 554 | return { error: 'The compiler would not accept the source ' + path + '.' }; |
| 555 | } |
| 556 | } |
| 557 | for (const a of (p.assets || [])) { |
| 558 | if (compiler.map_shadow(String(a[0]), a[1]) === false) { |
| 559 | return { error: 'The compiler would not accept the file ' + a[0] + '.' }; |
| 560 | } |
| 561 | } |
| 562 | const count = p.sources.length + (p.assets || []).length; |
| 563 | // What a later `queryProject` will ask about. Recorded BEFORE the compile |
| 564 | // rather than after it, so a compile that fails still leaves the compiler's |
| 565 | // shadow filesystem and this name describing the same project. |
| 566 | _lastMain = String(p.main); |
| 567 | const ret = compiler.compile(String(p.main), undefined, fmt, DIAG_FULL); |
| 568 | const out = extractPdf(ret); |
| 569 | if (out && out.length > 4) { |
| 570 | return fmt === 'vector' ? { vector: out } : { pdf: out }; |
| 571 | } |
| 572 | const ctx = { texts: texts, count: count, root: String(p.root || 'the workspace root'), |
| 573 | searched: (p.searched || []), single: false }; |
| 574 | return { error: explainDiags(ret, ctx) || tt('typst.no_pdf') }; |
| 575 | } catch (e) { |
| 576 | return { error: tt('typst.compile_error', { reason: (e && e.message ? e.message : e) }) }; |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | /// Compile a gathered project to a PDF -- the publishing path, unchanged. |
| 581 | export async function compileProjectPdf(p) { |
| 582 | return await compileProjectAs(p, 'pdf'); |
| 583 | } |
| 584 | |
| 585 | // ── Laying the pages out is not writing the document out ──────────────────── |
| 586 | // |
| 587 | // Measured on the author's 281-page book, on the same warm compiler and the same |
| 588 | // layout: `compile → 'pdf'` is 1834-2069 ms and `compile → 'vector'` is 235-272 ms. |
| 589 | // WRITING THE PDF IS ABOUT EIGHT TIMES WHAT LAYING THE PAGES OUT COSTS. A watch loop |
| 590 | // that produced a PDF on every save would therefore spend seven eighths of its time |
| 591 | // making a file nobody is going to open, since the reader is looking at the screen. |
| 592 | // |
| 593 | // `vector` is typst.ts's own intermediate format (SIR), which the vendored renderer |
| 594 | // (`typst_ts_renderer_bg.wasm`, the same typst checkout -- see the version note in |
| 595 | // `www/js/typstwatch.js`) turns into SVG. The pages are the SAME layout from the SAME |
| 596 | // compiler with the SAME fonts, drawn as glyph outlines rather than as text, so the |
| 597 | // live view is the book and not an approximation of it. `dev/verify_typstwatch.mjs` |
| 598 | // proves that against poppler rather than asserting it. |
| 599 | // |
| 600 | // PDF stays the publishing path and is untouched: the Compile button still writes one, |
| 601 | // `file_show` still opens one, and export is unaffected. |
| 602 | |
| 603 | // ── Asking the compiler ABOUT the document it has just laid out ───────────── |
| 604 | // |
| 605 | // The live view's section rail wants the document's headings, in order, with their |
| 606 | // levels. `TypstCompiler.query` answers exactly that and costs 6 ms on a small |
| 607 | // document and 9-14 ms on the author's 281-page book, because it runs against the |
| 608 | // layout comemo has already memoised rather than laying it out again. |
| 609 | // |
| 610 | // IT NEEDS NOTHING BUT THE MAIN PATH. The shadow filesystem is populated by |
| 611 | // `compileProjectAs` and is not cleared afterwards -- `reset_shadow` runs at the |
| 612 | // START of a compile -- so the last project compiled is still in there. Which is why |
| 613 | // `_lastMain` is kept: it is the only piece of the gathered project a query needs, |
| 614 | // and keeping the project itself would be holding a copy of the book on this heap for |
| 615 | // no reason. |
| 616 | // |
| 617 | // A QUERY IS NOT A COMPILE AND MUST NOT BECOME ONE. It is called from the watch loop |
| 618 | // in the same turn as the compile that produced the pages, never from a control the |
| 619 | // reader touched. |
| 620 | |
| 621 | let _lastMain = ''; // the main path of the last project compiled |
| 622 | |
| 623 | /// Ask the compiler about the document it last laid out. |
| 624 | /// |
| 625 | /// Returns the parsed answer -- an array of elements as typst serialises them -- or |
| 626 | /// `null` when there is nothing to ask about, the selector is refused, or the answer |
| 627 | /// is not JSON. A refusal is not an error here: a rail that cannot be built is a rail |
| 628 | /// that is not shown, and nothing else in the view depends on it. |
| 629 | /// |
| 630 | /// # Arguments |
| 631 | /// * `selector` - A typst selector, as `typst query` takes it: `heading`, `<label>`. |
| 632 | /// * `field` - One field of each element, or nothing for all of them. |
| 633 | export async function queryProject(selector, field) { |
| 634 | if (!_lastMain || !_compilerPromise) return null; |
| 635 | try { |
| 636 | const compiler = await getCompiler(); |
| 637 | const json = compiler.query(_lastMain, undefined, String(selector), |
| 638 | field == null ? undefined : String(field)); |
| 639 | const out = JSON.parse(String(json)); |
| 640 | return Array.isArray(out) ? out : null; |
| 641 | } catch (e) { |
| 642 | return null; |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | /// Compile a gathered project to typst.ts's vector format, for the live view. |
| 647 | /// |
| 648 | /// Returns `{ vector: Uint8Array }` or `{ error: string }`, with the error composed |
| 649 | /// exactly as the PDF path composes it -- the same diagnostics, naming the same file |
| 650 | /// and line -- so a failed rebuild reads the same wherever it came from. |
| 651 | export async function compileProjectVector(p) { |
| 652 | return await compileProjectAs(p, 'vector'); |
| 653 | } |
| 654 | |
| 655 | /// The compiler's wasm heap, in megabytes, or 0 before it has been built. |
| 656 | /// |
| 657 | /// A wasm32 `WebAssembly.Memory` can GROW and can NEVER SHRINK, so this is the |
| 658 | /// high-water mark of everything compiled in this page so far rather than a reading |
| 659 | /// of what is live. That is the property the watch loop's budget rests on: a figure |
| 660 | /// that only ever goes up can be compared against a ceiling without sampling. |
| 661 | export function heapMB() { |
| 662 | if (!_init || !_init.memory) return 0; |
| 663 | return _init.memory.buffer.byteLength / 1048576; |
| 664 | } |
| 665 | |
| 666 | // ── The driver the agent's `typst_compile` tool reaches ───────── |
| 667 | // The compiler was wired to a human's Compile button and to nothing |
| 668 | // else, so the tool registry had no Typst tool in it and a model |
| 669 | // asked to produce a PDF correctly answered that it could not. |
| 670 | // `window.DaimondTypst` is the one object the Rust side looks for |
| 671 | // (see `src/wasm/typst.rs`), and it exchanges CONTENTS AND BYTES |
| 672 | // only: source text comes in, PDF bytes go out, and every file touch |
| 673 | // stays in Rust where the OPFS path jail and the per-account |
| 674 | // namespace apply. A project comes in the same way -- as the files |
| 675 | // themselves, gathered on the Rust side, keyed by positions in the |
| 676 | // compiler's in-memory filesystem. Nothing here is ever given a |
| 677 | // path it could open. |
| 678 | // |
| 679 | // The memo lives in this module (`_compilerPromise`), so the 30 MB |
| 680 | // wasm is built once however it is reached -- the button's dynamic |
| 681 | // `import()` and this global resolve to the same module instance, |
| 682 | // because a module URL is evaluated once per document. The guard |
| 683 | // below is belt and braces for a second evaluation under a |
| 684 | // different URL, which would otherwise install a second compiler. |
| 685 | if (typeof window !== 'undefined' && !window.DaimondTypst) { |
| 686 | window.DaimondTypst = { |
| 687 | /// Compile a Typst source string, resolving `{ pdf }` or `{ error }`. |
| 688 | /// It RESOLVES on failure rather than rejecting, so the compiler's own |
| 689 | /// diagnostics reach the caller as the reason instead of as an exception. |
| 690 | compile: function (source) { return compilePdf(String(source == null ? '' : source)); }, |
| 691 | /// Compile a project GATHERED IN RUST, resolving `{ pdf }` or `{ error }`. |
| 692 | /// The object holds file contents and virtual shadow paths; it holds no |
| 693 | /// path this side could open, which is how the OPFS jail stays the only |
| 694 | /// way a byte gets in. |
| 695 | compileProject: function (project) { return compileProjectPdf(project); }, |
| 696 | /// The same project, laid out but not written out: `{ vector }` or |
| 697 | /// `{ error }`. What the live view draws, and what makes it affordable. |
| 698 | compileProjectVector: function (project) { return compileProjectVector(project); }, |
| 699 | /// What the compiler will say ABOUT the document it last laid out -- the |
| 700 | /// headings the live view's section rail is made of. It lays nothing out |
| 701 | /// again: comemo hands back the layout it already has. |
| 702 | queryProject: function (selector, field) { return queryProject(selector, field); }, |
| 703 | /// The compiler's wasm heap in MB, which never shrinks. The watch loop's |
| 704 | /// budget is measured against this. |
| 705 | heapMB: heapMB, |
| 706 | }; |
| 707 | // The live view registers `window.DaimondTypstWatch`, which is the one object |
| 708 | // `src/wasm/typst.rs` looks for when a compile finishes at the page's door. It |
| 709 | // is loaded from HERE, beside the driver, for the same reason the driver is |
| 710 | // loaded from `daimond.js`: a module nothing imports is a module nothing |
| 711 | // installs, and a Rust side that cannot import would find nothing on `window`. |
| 712 | // It costs a few kilobytes; the renderer wasm it needs is fetched lazily, on |
| 713 | // the first live view, and a session that never compiles never pays for it. |
| 714 | import('./typstwatch.js').catch(function () { /* no live view on this build */ }); |
| 715 | } |