Oregami
Repositories/oxedyne/daimond

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.
24function tt(k, v) { return window.DaimondI18n ? window.DaimondI18n.t(k, v) : k; }
25
26const VENDOR = new URL('../vendor/typst/', import.meta.url);
27const GLUE = new URL('typst_ts_web_compiler.mjs', VENDOR);
28const 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.
33const 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.
43const DIAG_FULL = 3;
44
45// The main source path inside the compiler's shadow filesystem.
46const 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.
66let _compilerPromise = null; // memoised compiler build
67let _glue = null; // the glue module, kept for its font-resolver builder
68let _init = null; // the wasm exports, kept so the heap can be read
69let _bundled = null; // the bundled font bytes, kept so a project set can re-add them
70let _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.
74function 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`.
104function 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.
115function 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.
149function 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.
157function 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.
163function 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 ''.
169function 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.
181function 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.
205function 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.
248function 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.
274const 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.
285async 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.
298export 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.
367function 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.
378function 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.
398function 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.
442function 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.
460async 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.
480function 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.
504async 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.
581export 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
621let _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.
633export 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.
651export 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.
661export 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.
685if (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}