Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/typstwatch.js

111 KiB, 1 run

created by r2519314175:1465, 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 — the watched live document
3 ------------------------------------------------------------
4 `typst watch` and a document viewer, inside the page. The
5 author edits a `.typ` — or a daimon does — and the pages he is
6 looking at become the new ones, in place, without him asking.
7
8 window.DaimondTypstWatch = {
9 began, stop, touched, rebuild, budgetMB, zoom, dark,
10 state, pageBox, goToPage, rail, sections, fitPage
11 }
12
13 Nine things decide the shape of this file, and each of them is
14 a measurement rather than a preference. The measurements are
15 in `dev/TYPST_WATCH.md` and in `dev/probe_typstsvg.mjs`; what
16 follows is what they cost.
17
18 1. THE PAGES ARE DRAWN HERE, AND ARE NOT A PDF.
19 On the author's 281-page book, on one warm compiler and one
20 layout, `compile → 'pdf'` is 1834-2069 ms and
21 `compile → 'vector'` is 235-272 ms. Writing the document
22 out costs about eight times what laying it out does, and the
23 reader is looking at a screen, not at a file. So the live
24 view asks for the layout and draws it, and the PDF stays
25 exactly where it was: the Compile button still writes one,
26 `file_show` still opens one, export is untouched.
27
28 The second reason is position. Chrome's PDF viewer answers
29 NOTHING through an `<embed>` — no `contentDocument`, no
30 `contentWindow`, no reply to a `postMessage` — so where the
31 reader has scrolled to cannot be read back, only set. These
32 pages are ours, so their scroll is ours, and "put it back
33 where he was" stops being a guess.
34
35 2. IT IS THE SAME COMPILER, THE SAME FONTS AND THE SAME LAYOUT
36 as the PDF, and that is checked rather than asserted.
37 `dev/verify_typstwatch.mjs` compiles one page both ways,
38 rasterises the PDF with poppler — which has nothing to do
39 with typst — rasterises these pages in the browser, and
40 compares the ink. Anti-aliasing differs. Nothing else may.
41
42 3. A REBUILD MUST NEVER BLANK THE VIEW. The new pages are
43 built in a detached node and swapped in one operation, with
44 the scroll restored in the same turn. There is no moment at
45 which the host holds nothing: the old document stays on
46 screen, at the reader's place, while the new one is compiled
47 and parsed, and a failed compile leaves it there and puts
48 the compiler's own words underneath.
49
50 3b. AND ONLY THE PAGES IN VIEW ARE EVER DRAWN. The whole book
51 at once costs seven seconds of DOM on a 281-page document
52 and, drawn as one element the height of the book, comes out
53 a pale wash from about page forty. Neither is the
54 compiler's fault and neither is visible from the code. The
55 three measurements that settle it are at "Drawing" below;
56 they are the reason this file is as long as it is.
57
58 4. CROSSING THE WASM HEAP CEILING BRICKS THE COMPILER.
59 Measured: the heap climbed to 3171 MB, the call trapped, and
60 EVERY SUBSEQUENT COMPILE failed with `recursive use of an
61 object detected which would lead to unsafe aliasing in rust`
62 — a wasm-bindgen borrow left held by the trapped call — and
63 the memory was never returned. Only a reload recovers. A
64 one-shot button hides this because a person who sees one
65 failure reloads. A LOOP DOES NOT: it will eventually fire on
66 a document that is too big and the compiler is dead from
67 then on. So this budgets the heap and refuses to START a
68 rebuild that might not fit, falling back to a Rebuild button
69 the user presses knowingly. See `holdCheck` below.
70
71 5. THE COMPILER IS MEMOISED IN `typst.js` AND THAT IS WHY THIS
72 WORKS AT ALL. One `TypstCompiler` for the life of the page,
73 whose comemo cache survives `reset_shadow()`, is the whole
74 difference between 0.4 s and 3 s per rebuild. The note is at
75 the top of `typst.js`, where the memo is.
76
77 VENDORED, NOT FETCHED. The renderer is
78 `typst_ts_renderer_bg.wasm`, from `@myriaddreamin/typst-ts-
79 renderer@0.7.0`, sitting beside the compiler it came out with:
80 the compiler here is byte-identical to
81 `@myriaddreamin/typst-ts-web-compiler@0.7.0`, and both wasm
82 modules carry the SAME typst checkout in their own bytes —
83 `checkouts/typst-a1cd3ade704ca26e/951788c`, typst 0.14.2 with
84 typst-assets-0.14.2. That is a stronger pairing than matching
85 version numbers: it is the same typst source tree, read out of
86 the binaries rather than off a filename. Nothing is fetched at
87 run time and nothing leaves the origin.
88
89 SECURITY. The renderer returns markup, and markup goes into
90 this origin's DOM. Three things follow, and none is optional. The
91 `<script>` typst.ts embeds for its own hover and link behaviour
92 is CUT OUT of the string before it is parsed — it is not wanted,
93 and a document an agent wrote after reading a web page is not
94 something to run scripts from. The `onclick=` attributes that
95 script's helpers were the target of go with it, for the same
96 reason and because they now name a function nobody defines. And
97 the pages live in a SHADOW ROOT, because the stylesheet typst.ts
98 emits carries a bare `svg { fill: none; }` rule that would
99 otherwise blank every icon in the app, and because the app's own
100 CSS must not reach in and change what the book looks like.
101
102 6. AND THE STYLESHEET HAS TO BE PUT BACK, because `render_in_window`
103 DOES NOT EMIT IT. Measured, `dev/probe_typstsvg.mjs`: the whole
104 document through `svg_data` carries a 1.4 KB `<style>`; the same
105 document through `render_in_window` carries `<style></style>`,
106 empty. Everything in that sheet that MATTERS is therefore
107 missing from every page this view has ever drawn, and three
108 reported faults are the one omission:
109
110 * typst.ts lays an INVISIBLE TEXT LAYER over the glyph
111 outlines — a `<foreignObject>` per run holding a
112 `div.tsel` at `font-size: 62px` — so a reader can select
113 and search. `.tsel { color: transparent }` is the only
114 thing making it invisible. Without it every line is drawn
115 twice: GHOSTED, DOUBLE-STRUCK TEXT.
116 * every link becomes `<rect class="pseudo-link">`, and an SVG
117 rect with no fill is BLACK. `.pseudo-link { fill:
118 transparent }` is the only thing making it not. Without it
119 a contents entry is a SOLID BLACK BAR the width of the
120 line and an inline `#link` is a FILLED BLACK BOX.
121
122 So the rules are adopted into the shadow root once, at mount,
123 where `replaceChildren` cannot take them away again. Taken
124 from typst.ts's own sheet — `TYPST_CSS` below says which parts
125 and why the rest is left out.
126
127 7. A DOCUMENT IS A STACK OF SHEETS, NOT A SCROLL. The first
128 version drew the visible band as ONE SVG, so a page ran into
129 the next with nothing between them and the author said so:
130 "shows as a continuous page, not distinct pages". Each page
131 is now its own sheet — its own SVG, its own white paper, its
132 own shadow — with `PAGE_GAP` points of the panel's ground
133 between them, which is what Chrome's PDF viewer does and what
134 a reader expects of a document.
135
136 It costs LESS than what it replaced, because the band is
137 still ONE session and ONE call — the sheets are cut out of the
138 one answer afterwards, since the renderer hands back a
139 `<g class="typst-page">` per page and says in its transform
140 where each sits. A call PER PAGE was tried first and is the
141 trap: the session's diff is by CONTENT, not by window, so four
142 pages carrying the same `#lorem(60)` came back as 428, 5, 7
143 and 5 marks and three quarters of the document was simply
144 missing. `dev/TYPST_WATCH.md` §12 has that table and the two
145 other things the renderer does not say.
146
147 The glyph outlines are emitted once, into the first sheet, and
148 the later sheets `<use>` them — which works because every
149 sheet of one band goes into the one shadow root in the one
150 `replaceChildren`, so an `href="#g…"` never points outside the
151 tree it is in. The band is replaced whole or not at all, so a
152 def can never outlive its user.
153
154 8. THE SECTION RAIL IS THE COMPILER'S ANSWER, THE PAGE IS THE
155 RENDERER'S. The entries — the words, the level and the order
156 — come from `query('heading')` on the compiled document, which
157 is exact and costs 6 ms on the fixture here and 9-14 ms on the
158 author's 281-page book. THE PAGE CANNOT COME FROM THERE.
159 Measured on this vendored 0.14.2: `query('heading', 'location')`
160 and `query('heading', 'page')` both answer `[]`, and so does
161 the CLI (`dev/TYPST_WATCH.md` §6 tried it first). Typst does
162 not put an element's location among its fields.
163
164 So each heading is found in the LAID-OUT pages instead, by its
165 own words, page by page, in document order — see `locate`. It
166 runs only while the rail is open, it renders and never
167 compiles, and it yields every `SCAN_CHUNK` pages so a long
168 book fills the rail in rather than freezing it.
169
170 9. THE RENDERER'S COORDINATES ARE NOT OURS, AND THE DIFFERENCE
171 ACCUMULATES. `S.tops` is the exact running sum of the page
172 heights the compiler reports; typst.ts places each page at a
173 running sum of those heights ROUNDED TO A WHOLE POINT. On a
174 160 mm page — 453.5433 pt — that is 0.4567 pt a page and it
175 never comes back: 11 pt by page 26, 45 pt by page 100, 105 pt
176 by page 230. Cropping a sheet at `tops` therefore left the
177 paper exactly where it belonged and slid the INK down inside
178 it, which is what the author reported as a gradual violation
179 of the margins. Every page's own origin comes back in its
180 group's `transform`, so `S.rtops` keeps what the renderer
181 said and the sheets are cropped, and the windows asked, in
182 the renderer's own numbers. THE SEQUENCE NAMES THE PAGE AND
183 THE TRANSFORM PLACES IT — never the other way round.
184 ============================================================ */
185
186const VENDOR = new URL('../vendor/typst/', import.meta.url);
187const R_GLUE = new URL('typst_ts_renderer.mjs', VENDOR);
188const R_WASM = new URL('typst_ts_renderer_bg.wasm', VENDOR);
189const PKG = new URL('../pkg/oxedyne_daimond.js', import.meta.url);
190
191/// The app's string for `k`, or `en` where the catalogue has none yet.
192///
193/// Called `tOr` on purpose and not as a preference: `dev/i18nfallback.mjs` finds the
194/// English written beside a key by looking for `tOr(`, `tf(` and `tr(`, so a helper
195/// called anything else keeps its sentences OUT of the check that holds them to the
196/// catalogue — and a fallback nobody checks is a sentence free to drift from the one
197/// the reader is supposed to see. Thirty-nine of them drifted in one afternoon once.
198function tOr(k, en, v) {
199 const s = window.DaimondI18n ? window.DaimondI18n.t(k, v) : null;
200 if (s == null || s === k) {
201 return String(en).replace(/\{(\w+)\}/g, (whole, n) => (v && v[n] != null) ? String(v[n]) : whole);
202 }
203 return s;
204}
205
206// ── How long to wait, and why that long ─────────────────────────────────────
207//
208// A debounce longer than the rebuild is time the reader spends waiting for
209// nothing; one shorter than the rebuild queues work that is thrown away when the
210// next keystroke lands. So the wait is THE LAST REBUILD'S OWN DURATION, clamped,
211// which is the only figure that is right for both of the author's books at once:
212// the 281-page one rebuilds in about 0.4 s and the 665-page one in about 1.2 s,
213// and no single constant serves both.
214//
215// The floor is 400 ms because that is what the smaller book costs and there is no
216// point waiting less than a rebuild. The ceiling is 2 s because past that the
217// preview stops feeling connected to the typing, and a book that slow is better
218// served by pressing Rebuild than by a loop that fires every two seconds.
219
220/// The shortest wait after the last write before rebuilding.
221const DEBOUNCE_MIN = 400;
222
223/// The longest, however slow the last rebuild was.
224const DEBOUNCE_MAX = 2000;
225
226/// How often the watched files are asked whether they have changed.
227///
228/// Sixty-three files answered in 13 ms, three runs — so a second between polls is
229/// about one part in eighty of one core, and the loop feels immediate. The File
230/// System Access API has no change events, so for a real folder this is the only
231/// mechanism there is; a writer that KNOWS it wrote calls `touched` and skips it.
232const POLL_MS = 1000;
233
234/// The wasm heap, in MB, above which no further rebuild is started.
235///
236/// The wall is about 4 GB — a module with no declared maximum grew to 4081 MB and
237/// then refused — and one over-large document past it leaves the compiler dead for
238/// the life of the page. 2500 MB leaves room for the largest single document
239/// measured here and stays a whole gigabyte clear.
240const BUDGET_DEFAULT = 2500;
241
242/// The least headroom assumed for the next rebuild before any has been measured.
243const HEADROOM_MIN = 128;
244
245/// What a trapped compiler says. Either of these means the wasm-bindgen borrow is
246/// held and nothing short of a reload will free it.
247const TRAPPED = /recursive use of an object|unreachable/i;
248
249/// How many rebuilds may run in a row on a change nothing could confirm before the
250/// loop stops and says so.
251///
252/// Everything a page can read back is checked against its own contents, so this is
253/// the backstop for the one case that cannot be: a file over `DIGEST_MAX` reporting
254/// that it moved, again and again. Three of those in a row is a fault to report
255/// rather than a state to sit in, because the heap ceiling is what comes next.
256const SPIN_MAX = 3;
257
258/// The space left between two sheets of paper, in DOCUMENT POINTS.
259///
260/// In points rather than pixels so that it is part of the same geometry as
261/// everything else: the reader's place is a page and an offset in points, and a gap
262/// measured in pixels would make that arithmetic depend on the zoom. Twelve points
263/// is about four millimetres at 100% — enough that the eye reads two sheets rather
264/// than one long one, and little enough that a page turn is not a journey.
265const PAGE_GAP = 12;
266
267/// How many pages either side of the visible ones are drawn.
268///
269/// FOUR, BECAUSE THE COST OF A BAND IS THE SESSION AND NOT THE PAGES IN IT. A render
270/// builds a fresh session from the whole artifact — 27-32 ms on the author's 281-page
271/// book — and then answers a page in well under a millisecond, so nine pages cost
272/// about what three did. What one margin page bought was a redraw every second page
273/// of scrolling, and every one of those redraws was the whole 30 ms; four buys a
274/// redraw every fifth page, which an ordinary reader crossing a page never reaches at
275/// all.
276///
277/// It is not raised further because the band is also what is held in the DOM, and a
278/// document is read a page at a time: past a few pages either side the sheets are
279/// memory nobody is looking at.
280const MARGIN_PAGES = 4;
281
282/// How long the scroll must be still before the band under it is redrawn, in ms.
283///
284/// A BAND IS NEVER BUILT INSIDE THE SCROLL FRAME. It used to be, and that is what the
285/// author felt as "very slow and laboured": a dragged scrollbar fails the band's own
286/// guard on EVERY frame, so every frame built a session, rendered, parsed and swapped
287/// the whole band — and then threw those sheets away when the next frame did it
288/// again. A sheet that arrives a tenth of a second late is not noticeable; a frame
289/// that is blocked for thirty milliseconds is the only thing that is.
290///
291/// Long enough that a fling costs one render rather than one per frame, and shorter
292/// than the eye takes to settle on a page it has landed on.
293const SETTLE_MS = 120;
294
295/// How far inside its own edges a page is asked for, in points.
296///
297/// `render_in_window` TAKES A CLOSED RECTANGLE, which cost an afternoon: asking for
298/// exactly `[top, top + height]` returns the page whose top is exactly at `hi` as
299/// well, and since the session answers with a DIFF, the next page's call then had
300/// nothing left to give. Every sheet drew the page after it and the last one drew
301/// nothing — a whole document off by one, with a blank sheet at the end.
302///
303/// A twentieth of a point is a fiftieth of a millimetre, which is smaller than the
304/// resolution of anything that will ever be printed, and it is comfortably above the
305/// f32 rounding at the foot of a 281-page book.
306const PAGE_INSET = 0.05;
307
308/// How many pages the outline scan lays out before it hands the frame back.
309///
310/// THIRTY-TWO, BECAUSE THE CHUNK IS A SESSION AND THE SESSION IS THE COST. A page
311/// answers in well under a millisecond once the first one has emitted the glyphs, but
312/// the session in front of it costs 27-32 ms on the author's 281-page book — so eight
313/// pages a chunk spent thirty-six sessions and better than a second of session
314/// building on that book, in a task of at least thirty milliseconds in EVERY frame
315/// while the rail was open. Thirty-two pages is nine sessions for the same book, and
316/// what the chunk holds is still under about three megabytes of markup where the
317/// whole document is 23.7 MB.
318const SCAN_CHUNK = 32;
319
320/// How long the build loop must be quiet before the outline scan starts, in ms.
321///
322/// The scan reads every page of the document, and every good build makes the pages it
323/// read the wrong ones — `refreshToc` asks for it again, and on a book being typed in
324/// that is a walk of the whole document abandoned and restarted for every keystroke
325/// that compiles. So it waits for the typing to stop, which is the same reason the
326/// rebuild itself is debounced, and a rail entry that fills in a moment after the
327/// pages costs the reader nothing.
328const SCAN_IDLE = 800;
329
330// ── Where the last good pages live, and what bounds them ────────────────────
331//
332// A failed build keeps the document that was on screen, so something has to be
333// holding pages that the current source can no longer produce. Two things are,
334// and neither of them is the compiler's heap.
335//
336// * The MARKS are an SVG in the shadow root — ordinary browser DOM, reclaimed
337// when it is replaced, and never more than about three screens of it because
338// only the band in view is drawn.
339// * The DOCUMENT is the VECTOR ARTIFACT, kept as bytes on the JavaScript heap:
340// 11.1 MB for the author's 281-page book, one document's worth, replaced
341// whole by the next good build. It is kept because a scroll has to be able to
342// draw a page the current source may no longer produce.
343//
344// NO RENDER SESSION IS HELD BETWEEN DRAWS. Each render builds one from those bytes,
345// uses it and frees it, which is forced by `render_in_window` being a diff (see
346// below) and is the right thing anyway: the renderer's wasm heap is a SECOND
347// `WebAssembly.Memory`, in a second module, and it never shrinks either. Holding
348// nothing between draws settles it at one document's high-water mark instead of
349// letting it climb with the session. It also means a preview that grew unboundedly
350// still could not brick the COMPILER, which is the failure worth engineering
351// against. `state().rheap` reports it, so it is measured rather than assumed.
352let mod = null; // the wasm package namespace, imported once
353let renderer = null; // the typst.ts renderer, built lazily
354let vec = null; // the vector artifact on screen, kept so a scroll can draw
355let rinit = null; // the renderer's wasm exports, for `rheap`
356
357/// Everything the loop knows, in one object so `state()` cannot drift from it.
358const S = {
359 path: '', // the `.typ` being watched, or '' when idle
360 files: [], // every real path the last gather read
361 stamps: null, // what those files said last time, by path; null before any
362 mode: 'idle', // idle | live | held | dead
363 builds: 0, // rebuilds STARTED since `began`
364 drawn: 0, // rebuilds that reached the screen
365 failed: 0, // rebuilds the compiler refused
366 debounce: DEBOUNCE_MIN,
367 budget: BUDGET_DEFAULT,
368 headroom: HEADROOM_MIN, // the biggest heap growth one rebuild has cost
369 building: false,
370 queued: false,
371 seen: false, // the panel has been on screen at least once
372 error: '', // the compiler's own words, while a build is broken
373 reason: '', // why the loop is held or dead
374 cause: '', // 'write' or 'poll' or 'user': what asked for the rebuild
375 why: '', // and which file it named, for a report from the field
376 digests: {}, // what each watched file said, the last time it was read
377 blind: false, // the rebuild running now could not be confirmed from contents
378 same: 0, // unconfirmable rebuilds in a row, which is a spin nobody sees
379 bandErr: '', // why the last band render threw, so a swallowed one is visible
380 pages: 0,
381 scale: 1, // rendered px per pt, for putting the scroll back
382 docW: 0, // the document's own width, in points
383 docH: 0, // and its whole height, which the scroller is as tall as
384 tops: [], // each page's top IN THE DOCUMENT, in pt: the exact sum
385 rtops: [], // and where the RENDERER puts it, which is not the same number
386 drift: 0, // the renderer's own total height less the exact sum, in pt
387 heights: [], // each page's height, in pt
388 lays: [], // each page's top ON SCREEN, in pt, gaps included
389 laid: 0, // the whole stack's height on screen, in pt
390 zoom: 1, // how much bigger than fitting the panel's width
391 fit: 'width', // 'width' or 'page': what the fit button last did
392 dark: false, // the paper turned over, for reading at night
393 rail: false, // the section rail is open
394 toc: [], // { text, level, page } per heading; page 0 = not found yet
395 scanned: 0, // the build serial the pages in `toc` were found in
396};
397
398let timer = null; // the debounce
399let poller = null; // the interval that asks the files
400let host = null; // the live view's root element
401
402/// The wasm package, imported once.
403async function wasm() {
404 if (!mod) mod = await import(PKG.href);
405 return mod;
406}
407
408/// The renderer, built once, lazily.
409///
410/// A megabyte of wasm that a reader who never compiles anything should not pay
411/// for, so it is not touched until the first live view is drawn.
412async function getRenderer() {
413 if (renderer) return renderer;
414 const g = await import(R_GLUE.href);
415 rinit = await g.default(R_WASM.href);
416 renderer = await new g.TypstRendererBuilder().build();
417 return renderer;
418}
419
420/// The RENDERER's wasm heap in MB, which is not the compiler's.
421///
422/// Two modules, two `WebAssembly.Memory` objects, two ceilings. Worth reading
423/// separately: pages held for a failed build are held HERE, and confusing the two
424/// would have the budget guarding the wrong heap.
425function rheapMB() {
426 if (!rinit || !rinit.memory) return 0;
427 return rinit.memory.buffer.byteLength / 1048576;
428}
429
430
431/// The part of typst.ts's own stylesheet a windowed render needs and does not get.
432///
433/// Verbatim from the sheet `svg_data` emits, which is a string literal in
434/// `typst_ts_renderer_bg.wasm` and can be read out of it — `dev/probe_typstsvg.mjs`
435/// prints both. Only the rules that decide what is ON THE PAGE are here, and the
436/// three that are left out are left out on purpose:
437///
438/// * `.typst-text { pointer-events: bounding-box }` and `.hover .typst-text` are
439/// for the hover highlight the embedded `<script>` drives, and that script is
440/// cut out before anything parses it.
441/// * `.outline_glyph { fill: var(--glyph_fill) }` WOULD BLANK THE BOOK. A CSS
442/// declaration beats a presentation attribute, so that rule overrides the
443/// `fill="#000"` typst.ts puts on every text group; typst.ts's own host page
444/// defines `--glyph_fill`, and an undefined `var()` on an inherited property
445/// falls back to the inherited value, which the same sheet sets to `none`.
446/// Leaving both out keeps each run the colour the compiler gave it, which is
447/// also the only way a coloured heading stays coloured.
448/// * `svg { fill: none }` goes with it, for the same reason: it is that rule the
449/// glyph rule exists to undo.
450///
451/// `.pseudo-link` is `pointer-events: none` rather than typst.ts's `all`. Its
452/// anchors are `xlink:href="#"` plus an `onclick` naming a function this page does
453/// not have, and the external ones open a window from a document a daimon may have
454/// written. Inert matches the decision that cut the script out.
455const TYPST_CSS =
456 '.tsel span,\n'
457 + '.tsel {\n'
458 + ' left: 0;\n'
459 + ' position: fixed;\n'
460 + ' text-align: justify;\n'
461 + ' white-space: nowrap;\n'
462 + ' width: 100%;\n'
463 + ' height: 100%;\n'
464 + ' text-align-last: justify;\n'
465 + ' color: transparent;\n'
466 + ' white-space: pre;\n'
467 + '}\n'
468 + '.tsel span::-moz-selection,\n'
469 + '.tsel::-moz-selection {\n'
470 + ' color: transparent;\n'
471 + ' background: #7db9dea0;\n'
472 + '}\n'
473 + '.tsel span::selection,\n'
474 + '.tsel::selection {\n'
475 + ' color: transparent;\n'
476 + ' background: #7db9dea0;\n'
477 + '}\n'
478 + '.pseudo-link {\n'
479 + ' fill: transparent;\n'
480 + ' pointer-events: none;\n'
481 + '}\n';
482
483/// What makes a page look like a sheet of paper, inside the shadow root.
484///
485/// It lives HERE and not in `www/css/viewer.css` for the same reason the rules above
486/// do: the pages are in a shadow root, and the app's own stylesheet does not reach
487/// into one. The rest of the live view — the bar, the rail, the scroller — is in
488/// `viewer.css` where it belongs.
489///
490/// The sheet carries the paper and the shadow; the SVG inside it carries only the
491/// marks. Keeping the white on the CONTAINER rather than on the SVG is what makes a
492/// page that failed to draw look like a blank sheet instead of a hole.
493const SHEET_CSS =
494 '.tl-band { position: absolute; inset: 0; }\n'
495 + '.tl-sheet {\n'
496 + ' position: absolute;\n'
497 + ' left: 0;\n'
498 + ' background: #fff;\n'
499 + ' box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);\n'
500 + ' overflow: hidden;\n'
501 + '}\n'
502 + '.tl-sheet > svg { display: block; }\n';
503
504let sheetEl = null; // the fallback <style>, where sheets cannot be adopted
505
506/// Put `TYPST_CSS` into `root` so that `replaceChildren` cannot remove it.
507///
508/// `adoptedStyleSheets` is not a child, so it survives the swap that puts each new
509/// band up; where it is missing the same rules go in as a `<style>` element that
510/// `paint` re-inserts alongside the pages. Either way the rules are in place BEFORE
511/// the first band is drawn, because a first frame of black bars is still a frame of
512/// black bars.
513function adopt(root) {
514 try {
515 const s = new CSSStyleSheet();
516 s.replaceSync(TYPST_CSS + SHEET_CSS);
517 root.adoptedStyleSheets = [s];
518 sheetEl = null;
519 return;
520 } catch (e) { /* an engine without constructable sheets */ }
521 sheetEl = document.createElement('style');
522 sheetEl.textContent = TYPST_CSS + SHEET_CSS;
523}
524
525/// Put `node` on screen as the whole of the pages, rules included.
526///
527/// The one place the shadow root's children are replaced, so the fallback sheet
528/// cannot be forgotten by a caller that swaps the pages some other way.
529function paint(node) {
530 const pages = host && host.querySelector('.tl-pages');
531 if (!pages) return;
532 if (sheetEl) pages.shadowRoot.replaceChildren(sheetEl, node);
533 else pages.shadowRoot.replaceChildren(node);
534}
535
536
537// ── The view ────────────────────────────────────────────────────────────────
538
539/// The Preview panel's other two renderings, and the display they had before the
540/// live view stood in for them.
541///
542/// The panel holds an `<embed>` for a compiled PDF and `#pv-view` for anything the
543/// file viewer draws; whichever is showing, the other is hidden. The live view is a
544/// THIRD rendering of the same panel, so it takes its turn rather than sitting over
545/// the top — a sheet over the panel covered its own header, and with it the ✕ that
546/// closes it.
547///
548/// THE SOURCE IS NOT ONE OF THEM ANY MORE, and that is the whole reason the preview
549/// was split out of the Doc panel. While the two shared a panel, opening the `.typ`
550/// that this loop is FOLLOWING put the editor over the pages the loop was rebuilding
551/// — the panel had to decide whose turn it was, and every fix for it broke the other
552/// case. Now the source sits in the Doc panel and the pages sit here, side by side,
553/// and there is no turn to take.
554let stood = []; // [element, previous inline display]
555
556/// Build the live view's elements, or return the ones already there.
557///
558/// It sits INSIDE the Preview panel, in the same column as the `<embed>` and the
559/// viewer's own host, and takes nothing away: closing it puts them back exactly as
560/// they were, down to the inline `display` each one carried.
561function mount() {
562 if (host && host.isConnected) return host;
563 const panel = document.getElementById('panel-preview');
564 if (!panel) return null;
565 host = document.createElement('div');
566 host.className = 'typst-live';
567 host.id = 'typst-live';
568 host.innerHTML =
569 '<div class="tl-bar">'
570 + '<button type="button" class="tl-toc" aria-pressed="false" aria-controls="tl-rail">'
571 + '\u2261</button>'
572 + '<span class="tl-mark" aria-hidden="true"></span>'
573 + '<span class="tl-says" role="status" aria-live="polite"></span>'
574 + '<button type="button" class="tl-rebuild" style="display:none"></button>'
575 + '<span class="tl-set">'
576 + '<input class="tl-page" type="text" inputmode="numeric" autocomplete="off" size="3">'
577 + '<span class="tl-of"></span>'
578 + '<button type="button" class="tl-out">\u2212</button>'
579 + '<button type="button" class="tl-fit"></button>'
580 + '<button type="button" class="tl-in">+</button>'
581 + '<button type="button" class="tl-night" aria-pressed="false"></button>'
582 + '</span>'
583 + '</div>'
584 + '<div class="tl-body">'
585 + '<nav class="tl-rail" id="tl-rail" hidden><ol class="tl-toclist"></ol>'
586 + '<p class="tl-tocnone"></p></nav>'
587 + '<div class="tl-scroll"><div class="tl-pages"></div></div>'
588 + '</div>'
589 + '<pre class="tl-err" style="display:none"></pre>';
590 panel.appendChild(host);
591 host.querySelector('.tl-rebuild').addEventListener('click', function () { rebuild(); });
592 controls();
593 // Scrolling out of the drawn band draws the next one. Throttled to a frame,
594 // because a scroll fires far more often than a screen is painted and the work
595 // only has to be done once per frame to be invisible.
596 //
597 // AND WHAT RUNS IN THAT FRAME IS ONLY THE CHEAP HALF. `sayWhere` is arithmetic and
598 // a few writes that are skipped when nothing changed; `ensureWindow` decides
599 // whether a band is wanted and, if one is, waits for the scroll to settle before
600 // building it. The band used to be built here, and a dragged scrollbar therefore
601 // built one per frame and threw each away — see `SETTLE_MS`.
602 let pending = false;
603 host.querySelector('.tl-scroll').addEventListener('scroll', function () {
604 if (pending) return;
605 pending = true;
606 requestAnimationFrame(function () { pending = false; ensureWindow(); sayWhere(); });
607 }, { passive: true });
608 // A resized panel is a different scale, and the scale is the only thing a resize
609 // changes: the layout is the compiler's. So it repaints rather than rebuilding,
610 // and the reader's page and offset — which are in points — survive it.
611 if (window.ResizeObserver) {
612 let sizing = false;
613 new ResizeObserver(function () {
614 if (sizing) return;
615 sizing = true;
616 requestAnimationFrame(function () { sizing = false; resized(); });
617 }).observe(host.querySelector('.tl-scroll'));
618 }
619 // The pages get a shadow root of their own: typst.ts's stylesheet carries a
620 // bare `svg { fill: none; }` that would blank every icon in the app, and the
621 // app's own CSS must not reach in and change what the book looks like.
622 const pages = host.querySelector('.tl-pages');
623 adopt(pages.attachShadow({ mode: 'open' }));
624 return host;
625}
626
627// ── The settings the reader was not offered ─────────────────────────────────
628//
629// The live view shipped with a status bar and nothing else, which is one control
630// fewer than the `<embed>` it stands in for: Chrome's PDF viewer at least has a
631// zoom and a page box. So the same three, done properly, plus the one Chrome's
632// viewer cannot be given at all.
633//
634// THEY ARE ALL VIEWING, NOT TYPESETTING. The layout is the compiler's and does not
635// depend on any of them — the page count, the line breaks and where a footnote
636// falls are the same at 40% as at 400% — so none of these starts a compile. That is
637// why the zoom is a repaint (`resized`) and the reader's place, which is a page and
638// an offset in POINTS, survives every one of them untouched.
639//
640// DARK PAPER IS A FILTER, and deliberately not a recompile. Asking typst for a dark
641// document would change the document; inverting the drawn page changes only what
642// the reader is looking at, so what is exported, printed and read by anybody else
643// is unaffected. `hue-rotate(180deg)` after the inversion puts colours back roughly
644// where they were, so a red figure stays red rather than turning cyan.
645//
646// The `<embed>` cannot have this one: there is no CSS, no API and no message that
647// reaches inside Chrome's PDF plugin (measured — `dev/TYPST_WATCH.md` §6), which is
648// the whole reason these pages are drawn here rather than handed to it.
649
650/// The largest and smallest the pages may be drawn, as a multiple of fitting the
651/// panel's width. Past either the reader is no longer reading a page.
652const ZOOM_MIN = 0.4, ZOOM_MAX = 4;
653
654/// Whether the language hook has been registered. Once per page: `onChange` keeps
655/// what it is given for the life of the document, so registering it per mount would
656/// leave a hook for every document the reader has opened.
657let relabels = false;
658
659/// Put this language's words on the bar's controls.
660function labels() {
661 if (!host) return;
662 const q = (c) => host.querySelector(c);
663 const page = q('.tl-page'), out = q('.tl-out'), inn = q('.tl-in');
664 page.setAttribute('aria-label', tOr('typst.watch.page', 'Page'));
665 out.setAttribute('title', tOr('typst.watch.zoom_out', 'Smaller'));
666 out.setAttribute('aria-label', out.getAttribute('title'));
667 inn.setAttribute('title', tOr('typst.watch.zoom_in', 'Bigger'));
668 inn.setAttribute('aria-label', inn.getAttribute('title'));
669 fitLabel();
670 const toc = q('.tl-toc');
671 toc.setAttribute('title', tOr('typst.watch.sections', 'Sections'));
672 toc.setAttribute('aria-label', toc.getAttribute('title'));
673 q('.tl-tocnone').textContent = tOr('typst.watch.sections_none',
674 'This document has no headings to list.');
675 dark(S.dark); // the one whose LABEL is its state
676 offerRebuild(host.querySelector('.tl-rebuild').style.display !== 'none');
677 drawRail();
678}
679
680/// Wire the bar's controls, once, at mount.
681function controls() {
682 const q = (c) => host.querySelector(c);
683 const page = q('.tl-page'), out = q('.tl-out'), inn = q('.tl-in'),
684 fit = q('.tl-fit'), night = q('.tl-night');
685 labels();
686 out.addEventListener('click', function () { S.fit = 'width'; zoom(S.zoom / 1.25); });
687 inn.addEventListener('click', function () { S.fit = 'width'; zoom(S.zoom * 1.25); });
688 // One button for both fits, because they are one question — how much of the page
689 // do I want — and Chrome's PDF viewer asks it with one control too. It shows the
690 // percentage, so the title is what says which way it will go next.
691 fit.addEventListener('click', function () { fitPage(S.fit !== 'page'); });
692 night.addEventListener('click', function () { dark(!S.dark); });
693 q('.tl-toc').addEventListener('click', function () { rail(!S.rail); });
694 // Enter commits; blur commits too, because a number typed and then clicked away
695 // from is a number the reader meant.
696 const go = function () {
697 const n = parseInt(page.value, 10);
698 if (Number.isFinite(n)) goToPage(n);
699 sayWhere();
700 };
701 page.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
702 page.addEventListener('blur', go);
703 sayWhere();
704 // A SURFACE ALREADY ON SCREEN WHEN THE LANGUAGE CHANGES has to repaint itself:
705 // every label above was resolved once, at mount, and the reader may well have a
706 // document open while switching. `onChange` is the app's own hook for exactly
707 // that, and it only ever puts words on a view that is still standing.
708 if (!relabels && window.DaimondI18n && window.DaimondI18n.onChange) {
709 relabels = true;
710 window.DaimondI18n.onChange(function () {
711 if (host && host.isConnected) labels();
712 });
713 }
714}
715
716/// Draw the pages at `z` times the width that fits, and put the reader back.
717function zoom(z) {
718 S.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
719 resized();
720 fitLabel();
721 sayWhere();
722}
723
724/// Say which way the one fit button will go next.
725function fitLabel() {
726 if (!host) return;
727 const fit = host.querySelector('.tl-fit');
728 fit.setAttribute('title', S.fit === 'page'
729 ? tOr('typst.watch.fit_width', 'Fit the width')
730 : tOr('typst.watch.fit_page', 'Fit the whole page'));
731 fit.setAttribute('aria-label', fit.getAttribute('title'));
732}
733
734/// Fit a whole page in the view, or go back to fitting its width.
735///
736/// The zoom is already a multiple of "as wide as the panel", so fitting the page is
737/// arithmetic on the page the reader is looking at and nothing else — pages need not
738/// all be the same shape, and a landscape plate in a portrait book should fit as
739/// itself. Like every other control here it is a REPAINT: the layout is the
740/// compiler's and does not depend on how much of a page is on screen.
741function fitPage(on) {
742 S.fit = on ? 'page' : 'width';
743 const sc = scroller();
744 const w = where();
745 const i = w ? Math.min(w.page, S.heights.length - 1) : 0;
746 const h = S.heights[i] || 0;
747 if (!on || !sc || !h || !S.docW) {
748 zoom(1);
749 return;
750 }
751 const cs = getComputedStyle(sc);
752 const inner = sc.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
753 const deep = sc.clientHeight - (parseFloat(cs.paddingTop) || 0) - (parseFloat(cs.paddingBottom) || 0);
754 // `scale` is `(inner / docW) * zoom`, so the zoom that puts `h` points into `deep`
755 // pixels is this and no search is needed.
756 //
757 // AND NEVER PAST FITTING THE WIDTH, which is what `1` is. A page has two
758 // dimensions and the whole of it fits only at the SMALLER of the two fits; taking
759 // the height alone in a panel that is tall and narrow gave 330% and cut the page
760 // off down both sides, which is not a fit by any reading of the word.
761 zoom(inner > 0 ? Math.min(1, (deep * S.docW) / (h * inner)) : 1);
762}
763
764/// Dark paper on or off.
765///
766/// The pages are inverted where they are DRAWN, so the scroller keeps the app's own
767/// ground and only the paper turns over.
768function dark(on) {
769 S.dark = !!on;
770 if (!host) return;
771 host.setAttribute('data-night', S.dark ? '1' : '0');
772 const b = host.querySelector('.tl-night');
773 b.setAttribute('aria-pressed', S.dark ? 'true' : 'false');
774 // ONE WORD, because the bar is a strip and the word "paper" was doing no work in
775 // it: the button sits beside a page, and nothing else in the view is light or
776 // dark. The title still says what it turns over, for anybody who hovers.
777 b.textContent = S.dark ? tOr('typst.watch.paper_light', 'Light')
778 : tOr('typst.watch.paper_dark', 'Dark');
779 b.setAttribute('title', S.dark ? tOr('typst.watch.paper_light_why', 'Light paper')
780 : tOr('typst.watch.paper_dark_why', 'Dark paper, for reading at night'));
781 b.setAttribute('aria-label', b.getAttribute('title'));
782}
783
784/// Say which page the reader is on, and how big the pages are drawn.
785///
786/// NOTHING IS WRITTEN THAT DOES NOT DIFFER FROM WHAT IS THERE, and on a scroll almost
787/// nothing does: the zoom cannot change by scrolling, the page count cannot, and the
788/// page number changes once a page rather than once a frame. This is called from
789/// every scroll frame, and a write into the bar followed by `markHere` reading
790/// `scrollTop` again is a layout the browser is forced to do twice in one frame,
791/// sixty times a second, for a percentage that says the same thing every time.
792///
793/// The comparison is against THE ELEMENT rather than against a remembered value, so
794/// there is no second copy to drift from the bar — and reading `value`, `textContent`
795/// or an inline `display` costs no layout, which is the whole point. The reader's
796/// place is worked out once here and handed to `markHere`, so the frame reads
797/// `scrollTop` once.
798function sayWhere() {
799 if (!host) return;
800 const w = where();
801 const page = host.querySelector('.tl-page');
802 const at = S.pages ? String((w ? w.page : 0) + 1) : '';
803 if (page && document.activeElement !== page && page.value !== at) page.value = at;
804 const tot = S.pages ? '/ ' + S.pages : ''; // how many there are, beside it
805 const of = host.querySelector('.tl-of');
806 if (of.textContent !== tot) of.textContent = tot;
807 const pc = Math.round(S.zoom * 100) + '%';
808 const fit = host.querySelector('.tl-fit');
809 if (fit.textContent !== pc) fit.textContent = pc;
810 const set = host.querySelector('.tl-set');
811 const show = S.pages ? '' : 'none';
812 if (set.style.display !== show) set.style.display = show;
813 markHere(w);
814}
815
816
817/// Take the live view down, leaving the panel exactly as it was.
818function unmount() {
819 if (host && host.parentNode) host.parentNode.removeChild(host);
820 host = null;
821 band = null;
822 vec = null;
823 for (const [el, was] of stood) el.style.display = was;
824 stood = [];
825}
826
827/// Stand in for the panel's other two renderings, remembering what they had.
828///
829/// Only once the first pages are actually on screen: until then the PDF the button
830/// produced is what the reader is looking at, and taking it away to show nothing is
831/// the blank this whole file exists to avoid.
832function standIn() {
833 for (const id of ['pv-view', 'doc-embed']) {
834 const el = document.getElementById(id);
835 if (!el) continue;
836 // What it had is recorded ONCE, the first time; the hiding happens every
837 // time. Pressing ⚙ Compile again while the view is live writes a fresh PDF
838 // and shows the `<embed>` again, so a `standIn` that returned early left the
839 // panel holding the live pages AND the PDF, one under the other.
840 if (!stood.some(function (p) { return p[0] === el; })) stood.push([el, el.style.display]);
841 el.style.display = 'none';
842 }
843}
844
845/// The scroller, or null when nothing is mounted.
846function scroller() {
847 return host ? host.querySelector('.tl-scroll') : null;
848}
849
850/// Say what the loop is doing, in the bar over the document.
851///
852/// Deliberately quiet: a mark and a few words, because the thing worth looking at
853/// is the document underneath. It is never a spinner over the pages, and it never
854/// replaces them.
855function says(text, mark) {
856 if (!host) return;
857 host.querySelector('.tl-says').textContent = text || '';
858 host.querySelector('.tl-mark').className = 'tl-mark' + (mark ? ' tl-' + mark : '');
859 host.setAttribute('data-mode', S.mode + (S.building ? ' building' : ''));
860}
861
862/// Offer the Rebuild button, or take it away.
863function offerRebuild(on) {
864 if (!host) return;
865 const b = host.querySelector('.tl-rebuild');
866 b.style.display = on ? '' : 'none';
867 b.textContent = tOr('typst.watch.rebuild', 'Rebuild');
868}
869
870/// Put the compiler's own words over the document, or clear them.
871///
872/// THE DOCUMENT STAYS. A failed build is the ordinary state of a document being
873/// written, and losing the last good pages every time a brace is unbalanced would
874/// make the preview useless exactly when it is most wanted. The words are typst's,
875/// naming the file and the line, composed by `typst.js` — not reworded here, since
876/// the file and the line are the only part anybody acts on.
877function showError(text) {
878 if (!host) return;
879 const e = host.querySelector('.tl-err');
880 S.error = text || '';
881 e.textContent = S.error;
882 e.style.display = S.error ? '' : 'none';
883}
884
885
886// ── Where the reader was ────────────────────────────────────────────────────
887
888/// Which page the reader is on, and how far into it, in points.
889///
890/// A fraction of the scroll height would be the easy answer and the wrong one: a
891/// paragraph added to chapter two makes the whole book longer, and the same
892/// fraction of a longer book is a different page. A page and an offset within it
893/// survives the document growing above the reader, which is the case that actually
894/// happens while somebody is writing.
895/// TWO COORDINATE SYSTEMS, AND KEEPING THEM APART IS THE WHOLE OF THIS SECTION.
896/// `tops` is where a page sits IN THE DOCUMENT and is what the renderer is asked
897/// about; `lays` is where its sheet sits ON SCREEN, which is the same thing plus the
898/// gaps above it. The reader's place is a page and an offset in points, so it is the
899/// one quantity that means the same in both.
900function where() {
901 const sc = scroller();
902 if (!sc || !S.lays.length) return null;
903 const y = sc.scrollTop / (S.scale || 1); // px back into pt
904 // A HAIR OF SLACK AT THE PAGE EDGE, and it is not cosmetic. `goToPage` sets the
905 // scroll to a page's own top; the browser hands that number back rounded to a
906 // fraction of a pixel, which divided by the scale is a whisper BELOW the top —
907 // and without the slack the reader is told they are on the page before the one
908 // they were just taken to. A twentieth of a point is a fiftieth of a millimetre.
909 const EDGE = 0.05;
910 let i = 0;
911 while (i + 1 < S.lays.length && S.lays[i + 1] <= y + EDGE) i++;
912 return { page: i, into: Math.max(0, y - S.lays[i]) };
913}
914
915/// Where each sheet sits on screen, in points, and how tall the stack is.
916///
917/// Recomputed from `tops` and `heights` rather than carried alongside them, so the
918/// gap cannot end up counted twice or not at all.
919function layOut() {
920 S.lays = [];
921 let acc = 0;
922 for (let i = 0; i < S.heights.length; i++) {
923 S.lays.push(acc);
924 acc += S.heights[i] + PAGE_GAP;
925 }
926 S.laid = S.heights.length ? acc - PAGE_GAP : 0;
927}
928
929/// Put the reader back where `w` says, in the document now on screen.
930///
931/// A page that no longer exists — a chapter deleted while it was being read —
932/// clamps to the last one there is, rather than snapping to the top.
933function goTo(w) {
934 const sc = scroller();
935 if (!sc || !w || !S.lays.length) return;
936 const i = Math.min(w.page, S.lays.length - 1);
937 const into = Math.min(w.into, S.heights[i] || 0);
938 sc.scrollTop = (S.lays[i] + into) * (S.scale || 1);
939}
940
941
942// ── Drawing: only the pages the reader can see ──────────────────────────────
943//
944// THE WHOLE BOOK AT ONCE IS THE ONE THING THAT DOES NOT SCALE, AND IT IS NOT THE
945// COMPILER. Measured on the author's 281-page book, laying the pages out costs
946// 376-564 ms and everything after it costs seven seconds:
947//
948// gather + lay out 376-564 ms
949// session 27 ms
950// the whole book to SVG ~250 ms → 23.7 MB of markup
951// parsing that markup 1300-1900 ms
952// putting it in the DOM 5100-8400 ms ← the loop, dead
953//
954// So the pages are drawn a WINDOW at a time. `RenderSession.render_in_window` takes
955// a rectangle in document points and returns an SVG carrying only what falls inside
956// it. Measured on a 33-page document, whole against a window of six pages:
957//
958// whole: 47 ms to SVG, 3.01 MB, 202 ms to parse, 159 ms into the DOM
959// window: 7 ms to SVG, 0.65 MB, 1 ms to parse, 1 ms into the DOM
960//
961// AND THE ELEMENT MUST NOT BE THE HEIGHT OF THE BOOK EITHER, which is the second
962// half of the same lesson and cost a second measurement to find. `render_in_window`
963// hands back its window inside the WHOLE DOCUMENT'S viewBox, so the obvious thing —
964// keep the element as tall as the book, put only the visible band in it — leaves an
965// SVG 192,000 pixels tall. Chrome rasterises that far from its origin at a reduced
966// resolution, and from about page forty onward the page comes out as a PALE WASH:
967// measured on a 251-page document, page 1 was 5.6% dark pixels and page 40 was
968// 0.00%, with the words still legible in outline. It looks exactly like a font or a
969// colour bug and is neither.
970//
971// So the container carries the height and the SVGs carry only the band: a plain
972// div as tall as the whole stack, with an absolutely-positioned SHEET per page in
973// it, each cropped to its own page by its own viewBox. The scrollbar still measures
974// the book, the reader's place still means a page and an offset, and nothing is ever
975// asked to rasterise more than one page at a time.
976//
977// ONE SHEET PER PAGE RATHER THAN ONE SVG PER BAND, because a document is a stack of
978// sheets and the author reported the previous drawing as "a continuous page". A
979// SESSION MAY BE ASKED MORE THAN ONCE: measured on a six-page fixture, one session,
980// one call per page —
981//
982// page 1 6.0 ms 80 KB 55 glyph outlines ← the defs, once
983// page 2 1.5 ms 27 KB 0
984// page 3 0.7 ms 26 KB 0
985// page 4 0.3 ms 27 KB 0
986//
987// — so the whole band costs about what one call for the same range cost, and the
988// glyph outlines are emitted into whichever sheet needed them first. The later
989// sheets `<use href="#g…">` them across the SVG boundary, which resolves because
990// every sheet of one band goes into the ONE shadow root in the ONE `replaceChildren`
991// and an id lookup is per tree, not per element. The band is replaced whole or not at
992// all, so a def can never be taken away from a sheet still using it.
993
994// AND `render_in_window` IS A DIFF, WHICH IS THE THIRD THING THIS COST TO LEARN.
995// It answers with what has changed since THAT SESSION last drew, not with what is in
996// the window. Measured on a 12-page document, one session, four calls:
997//
998// first call, pages 1-3 381 KB, 7215 marks
999// the SAME window again 2 KB, 0 marks ← an empty document
1000// move to pages 11-13 114 KB, 2252 marks
1001// back to pages 1-3 354 KB, 7162 marks ← 53 marks it still held
1002// a FRESH session, pages 1-3 381 KB, 7215 marks
1003//
1004// So a repaint of an unchanged window replaces the pages with nothing, and a repaint
1005// of an overlapping one silently drops whatever the session thinks is already on
1006// screen. Both are the blank this file exists to prevent, arriving through the
1007// renderer rather than through the DOM.
1008//
1009// The fix is to give every render its own session, built from the vector bytes that
1010// are kept for exactly that purpose, and freed the moment the pages are up. A
1011// session costs 27-32 ms on the 281-page book, which is a scroll of one screenful,
1012// and the alternative — keeping one session and applying its diffs — means owning
1013// typst.ts's incremental DOM protocol to save thirty milliseconds nobody can feel.
1014// (`render_svg_diff` and `mount_dom` are that protocol, if it is ever worth it.)
1015
1016let band = null; // the pages currently drawn, as `{ p0, p1 }` inclusive
1017
1018/// Which sheet the point `y` — in LAID-OUT points — falls on or nearest to.
1019function pageAt(y) {
1020 if (!S.lays.length) return 0;
1021 let i = 0;
1022 while (i + 1 < S.lays.length && S.lays[i + 1] <= y) i++;
1023 return i;
1024}
1025
1026/// The markup for pages `p0` to `p1` inclusive, out of a session of its own.
1027///
1028/// ONE CALL PER SESSION AND NEVER TWO, which cost the afternoon that the inset above
1029/// only half explains. Asking one session for page after page LOOKED right — each
1030/// call did answer with its own page — until a document repeated itself. Measured on
1031/// four pages each holding the same `#lorem(60)`:
1032///
1033/// one session, a call per page 428, 5, 7, 5 `<use>` ← the body vanished
1034/// a session per page 428, 428, 430, 428
1035/// one session, ONE call for all 428, 428, 430, 428 ← and 7 ms for four
1036///
1037/// The diff is by CONTENT, not by page: a group the session has already drawn is not
1038/// drawn again, wherever it is. So the band is one call, and the pages are separated
1039/// afterwards out of the one answer — which is also the cheapest of the three, since
1040/// a session costs 27-32 ms on the author's 281-page book and this builds one.
1041///
1042/// AND IT IS ASKED IN THE RENDERER'S OWN COORDINATES, which are not the exact sum of
1043/// the page heights. The renderer rounds each height to a whole point before it
1044/// accumulates, so on a 453.543 pt page it is 0.457 pt further down every page: by
1045/// page 26 the window asked for begins a tenth of a page above where the renderer
1046/// thinks page 26 begins, so the band drew a page more than it kept, and by the point
1047/// where the drift passes a whole page height — about page 992 for this page size —
1048/// the window stopped reaching page `p1` at all and the last sheet of every band came
1049/// back blank. `S.rtops` is what the renderer said, learned from any band already
1050/// drawn; `S.tops` is the exact sum, and is the best that can be done before one has.
1051///
1052/// WHICH LEAVES EXACTLY ONE BAND ASKED WITHOUT THEM: the first of a new document,
1053/// drawn before anything has said where its pages are. That one is asked for a window
1054/// widened by the whole document's `drift` at both ends — the free measurement taken
1055/// in `draw`, which is the largest the accumulation can have reached anywhere in the
1056/// book — so it cannot fall short of page `p1` however deep in the document it is.
1057/// The pages that widening pulls in are dropped a few lines below, as the pages
1058/// outside the window always were, and the band it draws fills `rtops` in for every
1059/// page of the document, so no band after it is asking with the wrong number.
1060///
1061/// It is not simply re-asked once the origins are known, because a second session on
1062/// every rebuild is 30 ms of every rebuild spent redrawing sheets that are right.
1063function windowOf(ses, p0, p1) {
1064 const pad = (S.rtops[p0] != null && S.rtops[p1] != null) ? 0 : Math.abs(S.drift);
1065 const lo = ((S.rtops[p0] != null) ? S.rtops[p0] : S.tops[p0]) - pad;
1066 const hi = ((S.rtops[p1] != null) ? S.rtops[p1] : S.tops[p1]) + S.heights[p1] + pad;
1067 return ses.render_in_window(0, Math.max(0, lo) + PAGE_INSET, S.docW,
1068 hi - PAGE_INSET);
1069}
1070
1071/// The renderer's answer, parsed, with what must not run taken out of it first.
1072function parseSvg(svg) {
1073 // The script typst.ts embeds goes before anything parses it — it is not wanted,
1074 // and its minified `&&` is not well-formed XML either, so leaving it in makes
1075 // `DOMParser` refuse the whole document and draw a parser-error banner instead
1076 // of a book. (Measured, and it looked exactly like a rendering bug.)
1077 //
1078 // The `onclick="handleTypstLocation(…)"` on every internal link goes with it:
1079 // that function was DEFINED in the script just removed, so what is left is a
1080 // handler naming nothing, on an `<a xlink:href="#">` that would move the app's
1081 // own URL if it were ever reached. Removed rather than relied upon to be
1082 // blocked, because "the policy will stop it" is not a reason to ship it.
1083 const clean = svg
1084 .replace(/<script\b[\s\S]*?<\/script>/gi, '')
1085 .replace(/\sonclick="[^"]*"/gi, '');
1086 const doc = new DOMParser().parseFromString(clean, 'image/svg+xml');
1087 const el = doc.documentElement;
1088 if (!el || el.nodeName === 'parsererror' || el.querySelector('parsererror')) {
1089 throw new Error('The laid-out pages did not parse: '
1090 + String(el && el.textContent).slice(0, 200));
1091 }
1092 el.removeAttribute('style');
1093 return document.importNode(el, true);
1094}
1095
1096/// The sheets for the pages around the reader, as one node ready to insert.
1097///
1098/// # Arguments
1099/// * `bytes` - The `vector` artifact this document was laid out to.
1100/// * `top` - The top of the visible area, in LAID-OUT points.
1101/// * `deep` - How tall the visible area is, in points.
1102/// * `scale` - Rendered pixels per document point.
1103function bandNode(bytes, top, deep, scale) {
1104 const last = S.heights.length - 1;
1105 if (last < 0) throw new Error('There are no pages to draw.');
1106 const p0 = Math.max(0, pageAt(Math.max(0, top)) - MARGIN_PAGES);
1107 const p1 = Math.min(last, pageAt(Math.max(0, top + deep)) + MARGIN_PAGES);
1108 // A session of its own, so what comes back is the WINDOW and not a diff against
1109 // whatever was drawn last. Freed before anything is parsed, whatever happens.
1110 const ses = renderer.session_from_artifact(bytes, 'vector');
1111 let markup;
1112 try {
1113 markup = windowOf(ses, p0, p1);
1114 } finally {
1115 try { ses.free(); } catch (e) { /* already gone */ }
1116 }
1117 const root = parseSvg(markup);
1118 // The answer is `<defs class="glyph">`, `<defs class="clip-path">`, `<style>` and
1119 // then one `<g class="typst-page">` per page. The pages become sheets; everything
1120 // else is SHARED and rides in the first of them, where an `href="#g…"` from any
1121 // other sheet still finds it — an id is looked up per TREE, and every sheet of one
1122 // band goes into the one shadow root in the one `replaceChildren`.
1123 const shared = [], groups = [];
1124 for (const el of Array.from(root.children)) {
1125 const c = el.getAttribute('class') || '';
1126 if (el.nodeName === 'g' && c.indexOf('typst-page') >= 0) groups.push(el);
1127 else shared.push(el);
1128 }
1129 const wrap = document.createElement('div');
1130 wrap.className = 'tl-band';
1131 for (let k = 0; k < groups.length; k++) {
1132 const m = /translate\(\s*[-\d.]+\s*,\s*([-\d.]+)/
1133 .exec(groups[k].getAttribute('transform') || '');
1134 // THE SEQUENCE NAMES THE PAGE; THE TRANSFORM IS WHERE ITS INK IS. Two questions
1135 // that look like one, and answering the second with the first is the defect this
1136 // paragraph exists for. Which page a group is, is its position in the answer —
1137 // `pageOfGroup` says why, and why the y cannot be trusted for it. Where that
1138 // page's ink was PUT is the group's own `translate`, and nothing else knows.
1139 const gy = m ? parseFloat(m[1]) : NaN;
1140 const i = pageOfGroup(Number.isFinite(gy) ? gy : 0, k, groups.length, p0, p1);
1141 // AND WHAT IT SAYS IS KEPT FOR THE WHOLE DOCUMENT, WHICH IS WHY THIS IS ABOVE THE
1142 // LINE THAT DROPS THE REST. A window emits a group for EVERY page of the
1143 // document, not for the pages in it — the ones outside it come back empty, but
1144 // they still say where they are — so one band's answer names every page's origin
1145 // in the book and no later band, window or scan has to ask again.
1146 if (Number.isFinite(gy) && i >= 0 && i <= last) S.rtops[i] = gy;
1147 // The groups outside the window came back empty, and an empty sheet is a hole
1148 // in the document. They are dropped rather than drawn.
1149 if (i < p0 || i > p1 || i > last) continue;
1150 // A shallow clone of the root keeps its namespaces and its own class; the
1151 // viewBox is what crops it to this page and nothing else — no transform, no
1152 // second coordinate system to get wrong, and no element taller than a page for
1153 // Chrome to rasterise badly.
1154 //
1155 // CROPPED WHERE THE RENDERER PUT THE PAGE, NOT AT THE EXACT SUM OF THE HEIGHTS
1156 // ABOVE IT, and the two are not the same number. `S.tops` accumulates the page
1157 // heights as they are — 453.5433 pt on a 160 mm page — and typst.ts rounds each
1158 // one to a whole point and accumulates THAT, so the two separate by 0.4567 pt a
1159 // page and never meet again: 11 pt by page 26, 45 pt by page 100, 105 pt by page
1160 // 230. The sheet is placed and sized correctly either way, so the PAPER stays
1161 // right and the INK slides down inside it — a widening white band at the head and
1162 // the foot of the type walking off the bottom, which is the "gradual violation of
1163 // the margins" the author reported and which page one, where the drift is zero by
1164 // construction, cannot show.
1165 const svg = root.cloneNode(false);
1166 const y = Number.isFinite(gy) ? gy : S.tops[i];
1167 svg.setAttribute('viewBox', '0 ' + y + ' ' + S.docW + ' ' + S.heights[i]);
1168 svg.setAttribute('width', String(S.docW * scale));
1169 svg.setAttribute('height', String(S.heights[i] * scale));
1170 svg.setAttribute('preserveAspectRatio', 'xMidYMin meet');
1171 // The shared parts ride in THE FIRST SHEET MADE, which is not the first group
1172 // answered: the groups outside the window were dropped just above, and hanging
1173 // the glyph outlines on one of those would have thrown them away with it.
1174 if (!wrap.childElementCount) for (const sh of shared) svg.appendChild(sh);
1175 svg.appendChild(groups[k]);
1176 const sheet = document.createElement('div');
1177 sheet.className = 'tl-sheet';
1178 sheet.setAttribute('data-page', String(i + 1));
1179 sheet.style.top = (S.lays[i] * scale) + 'px';
1180 sheet.style.width = (S.docW * scale) + 'px';
1181 sheet.style.height = (S.heights[i] * scale) + 'px';
1182 sheet.appendChild(svg);
1183 wrap.appendChild(sheet);
1184 }
1185 band = { p0, p1 };
1186 return wrap;
1187}
1188
1189/// How wide the pages are drawn, and therefore how big everything is.
1190///
1191/// Read off the SCROLLER's content box rather than off the pages themselves, because
1192/// the pages now carry a width of their own: measuring the thing this sets would
1193/// compound, and one zoom in would become a zoom in per repaint. The panel is
1194/// resizable and its width is the app's business; `S.zoom` is the reader's.
1195function scaleFor(docW) {
1196 const sc = scroller();
1197 if (!sc || !docW) return S.scale || 1;
1198 const cs = getComputedStyle(sc);
1199 const w = sc.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
1200 return w > 0 ? (w / docW) * S.zoom : (S.scale || 1);
1201}
1202
1203let settling = null; // the timer that draws the band once the scroll has stopped
1204let sick = null; // the visible window whose render threw, until it is left
1205
1206/// Redraw the window if the reader has scrolled out of the one that is drawn.
1207///
1208/// IT IS NOT CHEAP AND IT DOES NOT RUN IN THE SCROLL FRAME. The note that used to be
1209/// here said it was a few milliseconds; a band is a fresh session out of the whole
1210/// artifact (27-32 ms on the author's 281-page book), a render, two regular
1211/// expressions over the markup, a `DOMParser`, a deep `importNode` and one group
1212/// examined per page OF THE WHOLE DOCUMENT. Deciding whether a band is wanted IS
1213/// cheap — it is arithmetic on `lays` — so that part stays here and the band itself
1214/// waits `SETTLE_MS` for the scroll to stop. A dragged scrollbar failed the guard
1215/// below on every frame, and every frame paid the whole of that and then threw the
1216/// sheets away.
1217///
1218/// # Arguments
1219/// * `now` - Draw it in this turn rather than when the scroll settles. For a jump the
1220/// reader asked for, where the wait would be a wait for nothing.
1221function ensureWindow(now) {
1222 const sc = scroller();
1223 if (!sc || !vec || !band || !renderer) return;
1224 const scale = S.scale || 1;
1225 const top = sc.scrollTop / scale, deep = sc.clientHeight / scale;
1226 // In PAGES, because a sheet is what gets drawn. Clamped to the stack, so a
1227 // viewport taller than the book cannot ask for a page past the end and repaint on
1228 // every frame for ever.
1229 const a = pageAt(Math.max(0, top)), b = pageAt(Math.max(0, top + deep));
1230 if (a >= band.p0 && b <= band.p1) return;
1231 // A WINDOW WHOSE RENDER THREW IS NOT ASKED AGAIN UNTIL THE READER LEAVES IT. The
1232 // guard above is only satisfied by a band that was drawn, so a render that failed
1233 // left it false for ever: every later scroll frame built another session, threw,
1234 // and swallowed it — the failure was invisible and it was paid for sixty times a
1235 // second. `S.bandErr` keeps the reason where `state()` will show it.
1236 if (sick && a >= sick.p0 && b <= sick.p1) return;
1237 if (now) { drawBand(top, deep, scale, a, b); return; }
1238 if (settling) clearTimeout(settling);
1239 settling = setTimeout(function () {
1240 settling = null;
1241 ensureWindow(true);
1242 }, SETTLE_MS);
1243}
1244
1245/// Draw the band around `top`, remembering a failure rather than repeating it.
1246///
1247/// # Arguments
1248/// * `a`, `b` - The first and last page actually in view, which is the window that is
1249/// marked as bad if this throws. Not the band: the band reaches further
1250/// either side, and a reader who has left the pages that failed should be
1251/// tried again.
1252function drawBand(top, deep, scale, a, b) {
1253 try {
1254 paint(bandNode(vec, top, deep, scale));
1255 sick = null;
1256 S.bandErr = '';
1257 } catch (e) {
1258 // The pages that are up stay up, and the reason is kept rather than swallowed.
1259 // Not `showError`: that strip is the COMPILER's words, and a render fault
1260 // underneath a document that compiled cleanly would be a lie about the source.
1261 sick = { p0: a, p1: b };
1262 S.bandErr = (e && e.message) ? e.message : String(e);
1263 console.warn('typstwatch: pages ' + (a + 1) + '-' + (b + 1)
1264 + ' could not be drawn: ' + S.bandErr);
1265 }
1266}
1267
1268/// Redraw at a new size, because the panel was resized.
1269///
1270/// The scale is the only thing that changes; the layout is the compiler's and does
1271/// not depend on how wide the panel is. So this is a repaint, not a rebuild — the
1272/// reader's page and offset are in points and survive it untouched.
1273function resized() {
1274 if (!vec || !host || !renderer) return;
1275 const was = where();
1276 S.scale = scaleFor(S.docW);
1277 const pages = host.querySelector('.tl-pages');
1278 pages.style.width = (S.docW * S.scale) + 'px';
1279 pages.style.height = (S.laid * S.scale) + 'px';
1280 band = null;
1281 // A new size is a new attempt: a band that could not be drawn at the old one is
1282 // asked for again rather than being held against the reader for ever.
1283 sick = null;
1284 const sc = scroller();
1285 const top = was ? (S.lays[Math.min(was.page, S.lays.length - 1)] + was.into)
1286 : (sc ? sc.scrollTop / S.scale : 0);
1287 const deep = sc ? sc.clientHeight / S.scale : S.laid;
1288 try {
1289 paint(bandNode(vec, top, deep, S.scale));
1290 } catch (e) { return; }
1291 if (was) goTo(was);
1292 sayWhere();
1293}
1294
1295/// Draw `bytes` — one `vector` artifact — into the live view.
1296///
1297/// Everything expensive happens on a DETACHED node, and the swap is a single
1298/// `replaceChildren` with the scroll put back in the same turn. There is no frame
1299/// in which the host is empty; `dev/verify_typstwatch.mjs` counts the marks on
1300/// screen on every animation frame across a rebuild to prove it.
1301async function draw(bytes) {
1302 await getRenderer();
1303 // One session, for the geometry only, and freed before anything is drawn: the
1304 // page tops and heights are all that is wanted out of the document, and every
1305 // render after this builds its own (see the note on the diff, above).
1306 const probe = renderer.session_from_artifact(bytes, 'vector');
1307 let n, docW, docH;
1308 const heights = [], tops = [];
1309 try {
1310 const info = probe.pages_info;
1311 n = info.page_count;
1312 docW = info.width();
1313 docH = info.height();
1314 let acc = 0;
1315 for (let i = 0; i < n; i++) {
1316 const h = info.page(i).height_pt;
1317 tops.push(acc);
1318 heights.push(h);
1319 acc += h;
1320 }
1321 } finally {
1322 try { probe.free(); } catch (e) { /* already gone */ }
1323 }
1324
1325 const sc = scroller();
1326 const was = where();
1327 const pages = host.querySelector('.tl-pages');
1328 // The geometry has to be in place before a band can be asked for, since
1329 // `bandNode` measures against it. ALL OF IT, `lays` included: the sheets are
1330 // placed from `lays` and the pages are rendered from `tops`, and a band built
1331 // with one of them stale would draw the right pages in the wrong places.
1332 const wasDoc = { w: S.docW, h: S.docH, s: S.scale, t: S.tops, hh: S.heights,
1333 l: S.lays, ld: S.laid, p: S.pages, rt: S.rtops, d: S.drift };
1334 S.docW = docW;
1335 S.docH = docH;
1336 S.tops = tops;
1337 // EMPTIED WITH THE REST OF THE GEOMETRY, and this is not tidiness. `rtops` is where
1338 // THE DOCUMENT ON SCREEN was put; an entry left over from the one before it would
1339 // crop this document's page at a position belonging to another book, which is worse
1340 // than the drift it exists to correct. The first band of this document fills it in
1341 // again for every page, in one answer.
1342 S.rtops = [];
1343 // And a band that could not be drawn out of the LAST document says nothing about
1344 // this one, so the window that failed is offered again and its reason goes with it.
1345 sick = null;
1346 S.bandErr = '';
1347 S.heights = heights;
1348 // THE DRIFT, FOR NOTHING, BEFORE A SINGLE PAGE IS DRAWN. `info.height()` is the
1349 // renderer's own total; `tops[n-1] + heights[n-1]` is the exact sum of the same
1350 // pages. Where the renderer accumulates whole points the two disagree by the whole
1351 // of the accumulated rounding — 105 pt on a 231-page book of 453.543 pt pages —
1352 // which is exactly the distance a page's ink was cropped adrift by. It costs two
1353 // subtractions and it is the cheapest thing in this file that says the crop is
1354 // following the right coordinates.
1355 S.drift = n ? (docH - (tops[n - 1] + heights[n - 1])) : 0;
1356 layOut();
1357 const scale = scaleFor(docW);
1358 // The band to draw is the one the reader is about to be put back at, not the one
1359 // they are looking at now: the document may have grown above them, and drawing
1360 // where they are and then scrolling elsewhere would show a screen of blank paper
1361 // until the repaint caught up.
1362 let top = sc ? sc.scrollTop / (wasDoc.s || scale) : 0;
1363 if (was) {
1364 const i = Math.min(was.page, S.lays.length - 1);
1365 top = S.lays[i] + Math.min(was.into, heights[i] || 0);
1366 }
1367 const deep = sc ? sc.clientHeight / scale : (S.laid || 1);
1368 let node;
1369 try {
1370 node = bandNode(bytes, top, deep, scale);
1371 } catch (e) {
1372 // Nothing has been touched on screen yet, so putting the geometry back
1373 // leaves the pages that are up exactly as they were.
1374 S.docW = wasDoc.w; S.docH = wasDoc.h;
1375 S.tops = wasDoc.t; S.heights = wasDoc.hh;
1376 S.lays = wasDoc.l; S.laid = wasDoc.ld;
1377 S.rtops = wasDoc.rt; S.drift = wasDoc.d;
1378 throw e;
1379 }
1380
1381 // The container carries the whole STACK's height so the scrollbar measures the
1382 // book and the gaps between its sheets; each sheet inside it carries one page.
1383 // Both are set in the same turn as the swap, so there is no frame where the two
1384 // disagree and the reader is bounced by a scroller that briefly forgot how long
1385 // the document was.
1386 pages.style.width = (docW * scale) + 'px';
1387 pages.style.height = (S.laid * scale) + 'px';
1388 paint(node);
1389 S.scale = scale;
1390 S.pages = n;
1391 if (was) goTo(was); else if (sc) sc.scrollTop = 0;
1392 sayWhere();
1393
1394 // THE LAST GOOD PAGES ARE THESE BYTES AND THE MARKS ON SCREEN, AND NOTHING ELSE.
1395 // No render session is held between draws — each one is built, used and freed —
1396 // so the renderer's heap settles at one document's worth rather than growing with
1397 // the session. What is kept is the vector artifact itself (11.1 MB for the
1398 // 281-page book), on the JavaScript heap, because a scroll has to be able to draw
1399 // a page the current source may no longer produce.
1400 vec = bytes;
1401 S.drawn++;
1402 // The scale may have moved a hair between builds, so the band is confirmed
1403 // against where the reader actually landed rather than where we aimed. In this
1404 // turn: the pages have just been replaced, and a band left for the scroll to
1405 // settle would be a band nobody is scrolling.
1406 ensureWindow(true);
1407}
1408
1409
1410// ── The sections, and which page each one is on ─────────────────────────────
1411//
1412// WHAT THE COMPILER WILL ANSWER, AND WHAT IT WILL NOT. `query('heading')` hands back
1413// every heading in document order with its level and its words, in 6 ms on the
1414// fixture and 9-14 ms on the author's 281-page book. That is the rail's contents, and
1415// it is exact: it is the compiled document's own answer, not a reading of the source
1416// and not a guess off the drawn page.
1417//
1418// IT WILL NOT ANSWER WHICH PAGE. Measured on this vendored typst 0.14.2 through
1419// `dev/probe_typstoutline.mjs`:
1420//
1421// query('heading') 6 headings, level and body, 5.8 ms
1422// query('heading', 'body') the words alone, 0.2 ms
1423// query('heading', 'location') []
1424// query('heading', 'page') []
1425//
1426// Typst does not put an element's location among its fields, so there is nothing to
1427// ask for. `dev/TYPST_WATCH.md` §6 reached the same wall from the CLI and concluded
1428// that a marker in the book was the way out; a rail that only worked for books that
1429// had been altered to carry markers is not a rail.
1430//
1431// So the page comes from the LAID-OUT PAGES instead, which is the other half of the
1432// same compile: each page is rendered on its own and its text read back, and each
1433// heading is matched to its own words, in order, going forward and never back.
1434//
1435// TWO THINGS MAKE THAT HONEST RATHER THAN A GUESS.
1436//
1437// * IN ORDER. A heading is looked for at or after where the previous one was found,
1438// so the rail cannot come out shuffled even when two chapters share a title.
1439// * BY SIZE. A document with an `#outline()` prints every heading's words on its
1440// first pages, and the naive match puts the whole rail on page 2. So each run
1441// carries the size it was set at — typst.ts writes it as the group's own
1442// `scale(0.0126,-0.0126)`, which is 12.6 pt — and where a heading's words appear
1443// more than once, THE LARGEST SETTING WINS. A contents entry is set at body size
1444// and the heading it points at is not.
1445//
1446// The one case this cannot separate is a heading set at exactly body size in a
1447// document that also lists it in a contents; that rail entry lands on the contents
1448// page. It is named here rather than hidden, and `dev/verify_typstwatch.mjs` proves
1449// the ordinary case against a fixture that has a contents in it.
1450
1451/// The text runs of one rendered page, as `{ text, size }` in the order drawn.
1452///
1453/// Read out of the markup with a regular expression rather than parsed: this is
1454/// called once per page of the document and a `DOMParser` per page is the cost that
1455/// made the whole-book draw untenable. The two things wanted are adjacent in the
1456/// markup — the group's `scale(…)` is the type size in thousandths, and the `div.tsel`
1457/// inside it is the run's words.
1458function runsOf(svg) {
1459 const out = [];
1460 const re = /class="typst-text"[^>]*transform="scale\(([-\d.]+)[^"]*"[\s\S]*?class="tsel"[^>]*>([^<]*)</g;
1461 let m;
1462 while ((m = re.exec(svg)) !== null) {
1463 out.push({ size: Math.abs(parseFloat(m[1]) || 0) * 1000, text: unxml(m[2]) });
1464 }
1465 return out;
1466}
1467
1468/// The rendered markup, cut into pages: `[{ page, runs }]` in the order drawn.
1469function pagesOfMarkup(markup, p0, p1) {
1470 const re = /<g[^>]*class="typst-page"[^>]*transform="translate\(\s*[-\d.]+\s*,\s*([-\d.]+)/g;
1471 const at = [];
1472 let m;
1473 while ((m = re.exec(markup)) !== null) at.push({ y: parseFloat(m[1]), from: m.index });
1474 const out = [];
1475 for (let k = 0; k < at.length; k++) {
1476 const to = (k + 1 < at.length) ? at[k + 1].from : markup.length;
1477 const page = pageOfGroup(at[k].y, k, at.length, p0, p1);
1478 if (page >= 0) out.push({ page: page, runs: runsOf(markup.slice(at[k].from, to)) });
1479 }
1480 return out;
1481}
1482
1483/// Which page the `k`th of `n` groups returned for the window `p0`-`p1` is.
1484///
1485/// BY ORDER, AND ONLY BY POSITION WHEN THE ORDER CANNOT BE TRUSTED.
1486///
1487/// Two things about the renderer's answer have to be known here and neither is
1488/// obvious. A WINDOW EMITS A GROUP FOR EVERY PAGE OF THE DOCUMENT, not for the pages
1489/// in it — the ones outside simply come back empty — so the count to expect is the
1490/// whole page count and a band of three pages arrives as two hundred and eighty-one
1491/// groups. And READING THE GROUP'S OWN `translate(0, y)` IS WRONG: the renderer
1492/// rounds that number to a whole point and then accumulates it, so on a page 453.543
1493/// pt tall the groups come back at 0, 454, 908, 1362 against tops of 0, 453.54,
1494/// 907.09, 1360.63 — adrift by 0.46 pt a page, which by page two hundred names a page
1495/// a fifth of a page away from the right one. That cost a rail every entry of which
1496/// pointed at the contents.
1497///
1498/// So the answer is the position in the sequence, which is exact for either count the
1499/// renderer might answer with; the y is used only to make the best of it when the
1500/// count says something has changed underfoot.
1501///
1502/// AND WHEN IT COMES TO THAT, THE COMPARISON IS AGAINST WHAT THE RENDERER SAID, not
1503/// against the exact sum: `S.rtops` holds the origin the renderer gave each page the
1504/// last time a band was drawn, and comparing one of the renderer's own numbers with
1505/// another of them is the only comparison that is not off by the accumulation
1506/// described above. Before any band has said — the first draw of a document — the
1507/// exact sum is all there is, and near the front of a book the two agree anyway.
1508function pageOfGroup(y, k, n, p0, p1) {
1509 if (n === S.tops.length) return k; // every page, which is what it does
1510 if (n === p1 - p0 + 1) return p0 + k; // or just the window, one day
1511 let best = -1, off = Infinity;
1512 for (let i = 0; i < S.tops.length; i++) {
1513 const at = (S.rtops[i] != null) ? S.rtops[i] : S.tops[i];
1514 const e = Math.abs(at - y);
1515 if (e < off) { off = e; best = i; }
1516 }
1517 return best;
1518}
1519
1520/// The five XML entities typst.ts's markup can carry, back as themselves.
1521function unxml(s) {
1522 return String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>')
1523 .replace(/&quot;/g, '"').replace(/&apos;/g, '\'').replace(/&amp;/g, '&');
1524}
1525
1526/// One string, for comparing what was typeset with what was asked for.
1527///
1528/// Typst breaks a heading across lines wherever the measure runs out, and hyphenates
1529/// while it is at it, so the words come back in pieces that do not line up with the
1530/// source. Spaces go, and so does everything that is not a letter or a digit: what is
1531/// left is the same for `Chapter one` and for `Chap- ter one` on two lines.
1532function fold(s) {
1533 return String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
1534}
1535
1536/// Pull the words out of whatever `query` answered for one heading.
1537///
1538/// A heading's `body` is content, not a string: `= *Bold* title` comes back as a tree
1539/// of `text` nodes. Everything with a `text` is taken, in order, and everything else
1540/// is walked through — which is the same answer typst would print and needs no
1541/// knowledge of which element types exist.
1542function wordsOf(v) {
1543 if (v == null) return '';
1544 if (typeof v === 'string') return v;
1545 if (Array.isArray(v)) return v.map(wordsOf).join('');
1546 if (typeof v === 'object') {
1547 if (typeof v.text === 'string') return v.text;
1548 let out = '';
1549 for (const k of ['body', 'children', 'child']) if (k in v) out += wordsOf(v[k]);
1550 return out;
1551 }
1552 return '';
1553}
1554
1555/// Ask the compiler what this document's headings are, and start finding their pages.
1556///
1557/// Called from `build`, in the same turn as the compile that produced these pages, so
1558/// the compiler still holds this project and answers out of the layout it has just
1559/// memoised. IT IS NOT CALLED FROM THE VIEW: nothing a reader does to the rail, the
1560/// zoom or the paper reaches the compiler, which is the rule the whole panel is built
1561/// on.
1562async function refreshToc() {
1563 const q = window.DaimondTypst && window.DaimondTypst.queryProject;
1564 if (!q) { S.toc = []; drawRail(); return; } // an older driver in this page
1565 let list = null;
1566 try {
1567 list = await q('heading');
1568 } catch (e) {
1569 list = null;
1570 }
1571 S.toc = Array.isArray(list) ? list.map(function (h) {
1572 return {
1573 text: wordsOf(h && h.body).replace(/\s+/g, ' ').trim(),
1574 level: Math.max(1, Math.min(6, Number(h && (h.level || h.depth)) || 1)),
1575 page: 0,
1576 };
1577 }).filter(function (e) { return e.text; }) : [];
1578 S.scanned = 0;
1579 drawRail();
1580 locate();
1581}
1582
1583/// Find the page each heading is on, a chunk of pages at a time.
1584///
1585/// ONLY WHILE THE RAIL IS OPEN, because it is the rail that wants the answer and a
1586/// reader who never opens it should not pay for one. It renders and never compiles:
1587/// on the compiler's side nothing happens at all, and on the renderer's side one
1588/// session is built, asked for each page in turn, and freed.
1589///
1590/// It abandons itself the moment another build lands — `S.drawn` moves — because the
1591/// pages it is halfway through reading are no longer the pages on screen.
1592///
1593/// AND IT WAITS FOR THE TYPING TO STOP FIRST. Every good build calls this again, and
1594/// every call throws away the walk in progress, so a book being edited with the rail
1595/// open scanned the whole document over and over and finished none of them — all of
1596/// it in `SCAN_CHUNK`-sized tasks in the animation frames the reader is scrolling
1597/// with. Re-arming a timer is what "the last one wins" costs here, and it is the same
1598/// shape as the rebuild's own debounce.
1599function locate() {
1600 if (waiting) clearTimeout(waiting);
1601 waiting = setTimeout(function () {
1602 waiting = null;
1603 startScan();
1604 }, SCAN_IDLE);
1605}
1606
1607/// Begin the walk, if there is one to begin.
1608async function startScan() {
1609 // ONE SCAN AT A TIME. `S.scanned` only says a scan has FINISHED, so without this
1610 // a reader who shuts the rail and opens it again starts a second walk of the book
1611 // beside the first — twice the renderer's work for one answer, and on a 281-page
1612 // document that is the difference between a rail that fills in and a tab that
1613 // stutters.
1614 if (scanning || !S.rail || !vec || !S.toc.length || S.scanned === S.drawn) return;
1615 scanning = true;
1616 try {
1617 await scan();
1618 } finally {
1619 scanning = false;
1620 }
1621}
1622
1623let waiting = null; // the timer that starts the scan once the builds stop
1624let scanning = false; // a page scan is walking the document right now
1625
1626/// The walk itself. `locate` owns the wait, `startScan` the guard; this owns the
1627/// answer.
1628async function scan() {
1629 const serial = S.drawn;
1630 await getRenderer();
1631 if (S.drawn !== serial || !vec) return;
1632 // Every occurrence of every heading's words, with the size it was set at, so the
1633 // contents entry and the heading itself can be told apart afterwards.
1634 const want = S.toc.map(function (e) { return fold(e.text); });
1635 const seen = S.toc.map(function () { return []; }); // [{ page, size }]
1636 // A CHUNK AT A TIME, ONE SESSION AND ONE CALL EACH. One call, because a session
1637 // asked twice answers with a diff and a repeated paragraph comes back empty; a
1638 // chunk rather than the whole book, because the whole of the author's 281-page
1639 // document is 23.7 MB of markup to hold at once and `SCAN_CHUNK` pages of it is
1640 // about three.
1641 try {
1642 for (let a = 0; a < S.pages; a += SCAN_CHUNK) {
1643 const b = Math.min(S.pages - 1, a + SCAN_CHUNK - 1);
1644 const ses = renderer.session_from_artifact(vec, 'vector');
1645 let markup;
1646 try {
1647 markup = windowOf(ses, a, b);
1648 } finally {
1649 try { ses.free(); } catch (e) { /* already gone */ }
1650 }
1651 for (const pg of pagesOfMarkup(markup, a, b)) {
1652 // A heading that broke across lines is several runs, so a page is
1653 // folded into one string PER SIZE and each heading looked for in each.
1654 const bySize = new Map();
1655 for (const r of pg.runs) {
1656 const k = Math.round(r.size * 10);
1657 bySize.set(k, (bySize.get(k) || '') + fold(r.text));
1658 }
1659 for (const [k, text] of bySize) {
1660 for (let j = 0; j < want.length; j++) {
1661 if (want[j] && text.indexOf(want[j]) >= 0) {
1662 seen[j].push({ page: pg.page + 1, size: k / 10 });
1663 }
1664 }
1665 }
1666 }
1667 await frame();
1668 if (S.drawn !== serial || !vec || !S.rail) return;
1669 }
1670 } catch (e) {
1671 return; // the rail keeps whatever it had; nothing on screen moved
1672 }
1673 if (S.drawn !== serial) return;
1674 // THE LARGEST SETTING WINS, AND NEVER BEFORE THE ONE ABOVE IT. The first rule
1675 // separates a heading from its own contents entry; the second keeps the rail in
1676 // the document's order when two chapters share a title.
1677 let floor = 0;
1678 for (let j = 0; j < S.toc.length; j++) {
1679 const forward = seen[j].filter(function (c) { return c.page >= floor; });
1680 const pool = forward.length ? forward : seen[j];
1681 let best = null;
1682 for (const c of pool) {
1683 if (!best || c.size > best.size + 0.05) best = c;
1684 }
1685 S.toc[j].page = best ? best.page : 0;
1686 if (best) floor = best.page;
1687 }
1688 S.scanned = serial;
1689 drawRail();
1690}
1691
1692/// The next animation frame, so a long scan gives the page back between chunks.
1693function frame() {
1694 return new Promise(function (res) {
1695 if (typeof requestAnimationFrame === 'function') requestAnimationFrame(function () { res(); });
1696 else setTimeout(res, 0);
1697 });
1698}
1699
1700/// Open or close the section rail.
1701///
1702/// A VIEW CHANGE AND NOTHING ELSE. It shows what the last build already answered; the
1703/// only work it can start is the page scan, which is the renderer's and never the
1704/// compiler's.
1705function rail(on) {
1706 S.rail = !!on;
1707 if (!host) return S.rail;
1708 const nav = host.querySelector('.tl-rail');
1709 const b = host.querySelector('.tl-toc');
1710 nav.hidden = !S.rail;
1711 b.setAttribute('aria-pressed', S.rail ? 'true' : 'false');
1712 b.setAttribute('aria-expanded', S.rail ? 'true' : 'false');
1713 host.setAttribute('data-rail', S.rail ? '1' : '0');
1714 if (S.rail) locate();
1715 // The pages are drawn to the scroller's width, and the rail just took some of it.
1716 resized();
1717 return S.rail;
1718}
1719
1720/// Put the sections in the rail, and mark the one the reader is in.
1721function drawRail() {
1722 if (!host) return;
1723 const ol = host.querySelector('.tl-toclist');
1724 const none = host.querySelector('.tl-tocnone');
1725 none.style.display = S.toc.length ? 'none' : '';
1726 if (ol.childElementCount !== S.toc.length) {
1727 const frag = document.createDocumentFragment();
1728 for (let i = 0; i < S.toc.length; i++) {
1729 const li = document.createElement('li');
1730 const a = document.createElement('button');
1731 a.type = 'button';
1732 a.className = 'tl-tocgo';
1733 a.appendChild(document.createTextNode(''));
1734 const n = document.createElement('span');
1735 n.className = 'tl-tocpage';
1736 a.appendChild(n);
1737 li.appendChild(a);
1738 frag.appendChild(li);
1739 a.addEventListener('click', function () {
1740 const e = S.toc[i];
1741 if (e && e.page) goToPage(e.page);
1742 });
1743 }
1744 ol.replaceChildren(frag);
1745 }
1746 const kids = ol.children;
1747 for (let i = 0; i < S.toc.length; i++) {
1748 const e = S.toc[i];
1749 const li = kids[i];
1750 const a = li.firstElementChild;
1751 li.setAttribute('data-level', String(e.level));
1752 a.firstChild.nodeValue = e.text;
1753 a.lastElementChild.textContent = e.page ? String(e.page) : '';
1754 a.disabled = !e.page;
1755 }
1756 hereNow = -2; // whatever was marked, the list under it is new
1757 markHere();
1758}
1759
1760let hereNow = -2; // the rail entry last marked, so a scroll costs nothing
1761
1762/// Mark the section the reader is inside, and only when it changes.
1763///
1764/// Called from every scroll, so it does the least work that says the truth: the
1765/// entry is the last one that STARTS at or before the page in view, and an entry
1766/// whose page is not known yet cannot be it.
1767///
1768/// # Arguments
1769/// * `w` - The reader's place, when the caller has already worked it out. Asking for
1770/// it again is a `scrollTop` read after the bar has been written, which makes
1771/// the browser lay the panel out a second time in the same frame.
1772function markHere(w) {
1773 if (!host || !S.rail) return;
1774 const at = (w === undefined) ? where() : w;
1775 const page = at ? at.page + 1 : 1;
1776 let cur = -1;
1777 for (let i = 0; i < S.toc.length; i++) if (S.toc[i].page && S.toc[i].page <= page) cur = i;
1778 if (cur === hereNow) return;
1779 const kids = host.querySelector('.tl-toclist').children;
1780 if (hereNow >= 0 && kids[hereNow]) {
1781 kids[hereNow].setAttribute('data-here', '0');
1782 kids[hereNow].firstElementChild.removeAttribute('aria-current');
1783 }
1784 if (cur >= 0 && kids[cur]) {
1785 kids[cur].setAttribute('data-here', '1');
1786 kids[cur].firstElementChild.setAttribute('aria-current', 'true');
1787 }
1788 hereNow = cur;
1789}
1790
1791
1792// ── The loop ────────────────────────────────────────────────────────────────
1793
1794// ── A stamp that moved is not yet a change ──────────────────────────────────
1795//
1796// A file says it was written; that is not the same as saying it says something
1797// different. The author watched the bar cycle `Live` / `Rebuilding` every one to
1798// two seconds with nothing edited, and by the end of it the compiler was holding
1799// 2427 MB of the 2500 it is allowed. A rebuild on bytes that have not changed is
1800// FREE — comemo hashes the text and hands back the layout it already has, measured
1801// at 0 MB on the author's 281-page book — so a spin costs nothing until the moment
1802// something really does differ, and then about 10 MB a time. Two hundred of those
1803// is the wall. `dev/probe_typstloop.mjs` has both figures.
1804//
1805// So a moved stamp is CONFIRMED against the file's contents before it counts. That
1806// is one small read of one file, and only of the file that moved.
1807//
1808// THE OUTPUT CANNOT BE USED FOR THIS, which was tried first and is worth writing
1809// down: compiling the same three-line document three times gives three vector
1810// artifacts of identical length in which 5,624 of 7,816 bytes differ, and the SVG
1811// rendered from them differs too. typst.ts's format carries per-item fingerprints
1812// that are not stable between compiles, so "the pages came out the same" is not a
1813// question this side can ask. The INPUT is, and it is the honest question anyway.
1814
1815/// The largest file whose contents are read back to confirm a change.
1816///
1817/// Sources are kilobytes and get checked. A font is eleven megabytes and does not
1818/// rewrite itself, so above this a moved stamp is taken at its word rather than
1819/// paying to read a typeface on every poll.
1820const DIGEST_MAX = 1048576;
1821
1822/// A cheap digest of a file's BYTES, for telling a rewrite from a rewording.
1823///
1824/// Bytes and not text, through `read_bytes` rather than `read_file`: the latter ends
1825/// in `from_utf8_lossy`, so every byte of a picture that is not valid UTF-8 becomes
1826/// the same replacement character and two different pictures can digest alike. A
1827/// watcher that stopped following a figure because two versions of it decoded to the
1828/// same mush would be a very hard afternoon.
1829function digest(bytes) {
1830 let h = bytes.length >>> 0;
1831 for (let i = 0; i < bytes.length; i++) h = (h * 31 + bytes[i]) >>> 0;
1832 return String(h) + ':' + bytes.length;
1833}
1834
1835/// Whether `path` still says exactly what it said when it was last read.
1836///
1837/// `false` whenever the answer is not known — the file is too big to check, it
1838/// could not be read, it is gone, or this is the first time it has been asked. An
1839/// unknown is a change, because the alternative is a preview that quietly stops
1840/// following a file.
1841///
1842/// # Arguments
1843/// * `path` - The workspace-relative path whose stamp moved.
1844/// * `stamp` - What it just said about itself, `"<lastModified>:<size>"`.
1845async function confirmed(path, stamp) {
1846 const size = Number(String(stamp).split(':')[1]);
1847 if (!(size >= 0) || size > DIGEST_MAX) return false;
1848 let bytes;
1849 try {
1850 const m = await wasm();
1851 bytes = await m.read_bytes(path, 0, size);
1852 } catch (e) {
1853 return false;
1854 }
1855 if (!bytes || bytes.length !== size) return false;
1856 const d = digest(bytes);
1857 const was = S.digests[path];
1858 S.digests[path] = d;
1859 return was !== undefined && was === d;
1860}
1861
1862/// Whether the heap has room for another rebuild, and the sentence if it has not.
1863///
1864/// The heap is MONOTONIC — a wasm32 `Memory` grows and never shrinks — so this is
1865/// a high-water mark, not a sample, and once it is over the budget it stays over.
1866/// That is why the honest remedy is a reload and not "wait a moment".
1867///
1868/// `headroom` is the biggest growth any one rebuild has cost so far, so the
1869/// question asked is "would ANOTHER one like the last fit", which is the question
1870/// that matters. Before anything has been measured it is `HEADROOM_MIN`.
1871function holdCheck() {
1872 const heap = (window.DaimondTypst && window.DaimondTypst.heapMB)
1873 ? window.DaimondTypst.heapMB() : 0;
1874 if (heap + S.headroom <= S.budget) return '';
1875 return tOr('typst.watch.heap',
1876 'The compiler is holding {heap} MB and another rebuild could need {more} MB more, '
1877 + 'which is past the {budget} MB it is allowed on this page. Rebuilding on every save '
1878 + 'has stopped, and the pages below are the last ones that built. The compiler cannot '
1879 + 'give that memory back — reload the page to start it fresh, or press Rebuild to try '
1880 + 'once anyway.',
1881 { heap: Math.round(heap), more: Math.round(S.headroom), budget: Math.round(S.budget) });
1882}
1883
1884/// Stop rebuilding on every save, and say why.
1885function hold(why) {
1886 S.mode = 'held';
1887 S.reason = why;
1888 if (timer) { clearTimeout(timer); timer = null; }
1889 S.queued = false;
1890 says(tOr('typst.watch.held', 'Rebuilding stopped'), 'held');
1891 showError(why);
1892 offerRebuild(true);
1893}
1894
1895/// The compiler is gone until the page is reloaded, and nothing pretends otherwise.
1896function dead(why) {
1897 S.mode = 'dead';
1898 S.reason = why;
1899 if (timer) { clearTimeout(timer); timer = null; }
1900 if (poller) { clearInterval(poller); poller = null; }
1901 S.queued = false;
1902 says(tOr('typst.watch.dead', 'The compiler has stopped'), 'dead');
1903 showError(tOr('typst.watch.dead_why',
1904 'The compiler ran out of memory on this document and cannot be restarted without '
1905 + 'reloading the page. The pages below are the last ones that built. Reload, and open '
1906 + 'the same file again.') + '\n\n' + why);
1907 offerRebuild(false);
1908}
1909
1910/// Rebuild now, coalescing anything already in flight.
1911///
1912/// ONE COMPILE AT A TIME AND ONE QUEUED, and a third edit replaces the queued one.
1913/// Without that a fast typist queues twenty compiles and the preview runs minutes
1914/// behind the text, which is worse than no preview at all because it looks like one.
1915///
1916/// # Arguments
1917/// * `force` - Skip the heap budget once, because the user pressed Rebuild knowing
1918/// what it said. The loop never does this to itself.
1919async function build(force) {
1920 if (S.mode === 'dead') return;
1921 if (S.building) { S.queued = true; return; }
1922 if (!force) {
1923 const why = holdCheck();
1924 if (why) { hold(why); return; }
1925 }
1926 S.building = true;
1927 S.builds++;
1928 says(tOr('typst.watch.building', 'Rebuilding…'), 'building');
1929 // The files as they stand NOW, taken before the compile reads them.
1930 //
1931 // Without this the poll counts the same edit twice: `touched` fires on the write
1932 // and rebuilds, then the next poll finds the stamps different from ITS last
1933 // reading and rebuilds again — one keystroke, two compiles, and a burst that
1934 // coalesced correctly still ran twice. Taken BEFORE rather than after, so a write
1935 // landing mid-compile is a change the next poll still sees.
1936 //
1937 // KEYED BY PATH, NOT BY POSITION. The list itself moves — an edit that adds an
1938 // `#import` brings a file in, one that removes it takes a file out — and a
1939 // comparison by index reads every entry after the change as changed, rebuilds,
1940 // records the new list against the OLD list's readings, and finds them all
1941 // changed again. That is a rebuild loop with nothing edited, and it costs about
1942 // 10 MB of wasm heap every time the bytes really do differ (measured, on the
1943 // author's 281-page book, in `dev/probe_typstloop.mjs`), so a spin nobody
1944 // notices walks the compiler into the ceiling in a few hundred rebuilds.
1945 S.stamps = (await stamps()) || S.stamps;
1946 const heapBefore = (window.DaimondTypst && window.DaimondTypst.heapMB)
1947 ? window.DaimondTypst.heapMB() : 0;
1948 const t0 = Date.now();
1949 let out = null;
1950 try {
1951 const m = await wasm();
1952 out = await m.typst_compile_project_vector(S.path);
1953 } catch (e) {
1954 out = { error: (e && e.message) ? e.message : String(e) };
1955 }
1956 const took = Date.now() - t0;
1957 const heapAfter = (window.DaimondTypst && window.DaimondTypst.heapMB)
1958 ? window.DaimondTypst.heapMB() : 0;
1959 if (heapAfter - heapBefore > S.headroom) S.headroom = heapAfter - heapBefore;
1960
1961 // The watch list is re-read from every compile, failed or not: an edit that adds
1962 // an `#import` brings a file into the project, and a watcher still polling
1963 // yesterday's list would never see it change.
1964 if (out && out.watch && out.watch.length) {
1965 S.files = Array.from(out.watch).map(String);
1966 }
1967
1968 if (out && out.vector && out.vector.length) {
1969 // A REBUILD NOBODY COULD CONFIRM is counted, because it is the only kind left
1970 // that can spin. Everything small enough to read back is checked against its
1971 // own contents and never gets here twice for nothing; what remains is a file
1972 // too big to check saying it moved, over and over, which is a loop with no
1973 // evidence behind it and the heap ceiling at the end of it.
1974 S.same = S.blind ? S.same + 1 : 0;
1975 try {
1976 await draw(out.vector);
1977 // The sections, asked of the compiler in the same turn as the compile that
1978 // produced these pages — see `refreshToc`. Not awaited on purpose: the
1979 // pages are up and the rail filling in a moment later costs the reader
1980 // nothing, whereas a rail that held the loop would.
1981 refreshToc();
1982 // A good build after a bad one clears the error SILENTLY. Nothing
1983 // announces the fix: the reader is looking at the page they were
1984 // trying to get back, and a banner saying so is in the way.
1985 showError('');
1986 // A HELD LOOP IS NOT UNHELD BY A BUILD SUCCEEDING. The user pressing
1987 // Rebuild proves the compile fits today, not that the heap has room for
1988 // the next one — and the heap only ever grows, so it usually has less.
1989 // Resuming on a success would quietly put the loop back on the path to
1990 // the wall, which is the one thing the budget exists to prevent. It
1991 // resumes only when the budget itself says there is room, which after a
1992 // reload it does.
1993 if (S.mode === 'held' && !holdCheck()) {
1994 S.mode = 'live';
1995 S.reason = '';
1996 offerRebuild(false);
1997 } else if (S.mode === 'held') {
1998 showError(S.reason);
1999 says(tOr('typst.watch.held', 'Rebuilding stopped'), 'held');
2000 }
2001 } catch (e) {
2002 S.failed++;
2003 showError((e && e.message) ? e.message : String(e));
2004 }
2005 } else {
2006 S.failed++;
2007 const why = (out && out.error) ? String(out.error) : tOr('typst.watch.nothing',
2008 'The compiler produced nothing and gave no reason.');
2009 showError(why);
2010 if (TRAPPED.test(why)) {
2011 S.building = false;
2012 dead(why);
2013 return;
2014 }
2015 }
2016
2017 // The wait is the last rebuild's own length, so it fits whatever is being
2018 // written rather than whatever was guessed when this was written.
2019 S.debounce = Math.max(DEBOUNCE_MIN, Math.min(DEBOUNCE_MAX, took));
2020 S.building = false;
2021 if (S.mode === 'live' && S.same >= SPIN_MAX) {
2022 S.same = 0;
2023 hold(tOr('typst.watch.spin',
2024 'A watched file keeps reporting that it has changed, and it is too large '
2025 + 'to read back and check: the pages have been laid out {n} times in a row '
2026 + 'for it. Rebuilding on every save has stopped, so that it cannot fill '
2027 + 'the compiler\u2019s memory. Press Rebuild when you want the pages '
2028 + 'again. The file was {what}.',
2029 { n: SPIN_MAX + 1, what: S.why.replace(/^(poll|write): /, '').split(' ')[0] }));
2030 return;
2031 }
2032 if (S.mode === 'live') {
2033 says(S.error
2034 ? tOr('typst.watch.stale', 'Showing the last build that worked')
2035 : tOr('typst.watch.live_preview', 'Live preview'), S.error ? 'stale' : 'live');
2036 }
2037 if (S.queued) { S.queued = false; if (S.mode === 'live') build(false); }
2038}
2039
2040/// Something was written; rebuild when the writing stops.
2041///
2042/// WHAT ASKED FOR THE REBUILD IS RECORDED, and comes back out through `state()`.
2043/// A loop that rebuilds when nothing has been edited is the one fault a reader
2044/// cannot diagnose from the screen — the bar says `Rebuilding…` and that is all —
2045/// so the answer to "what did it think had changed" is kept where it can be read
2046/// back and reported, instead of being worked out again from scratch each time.
2047///
2048/// # Arguments
2049/// * `cause` - `'write'` or `'poll'`: how the change was noticed.
2050/// * `detail` - The path, and for a poll the two readings that differ.
2051function nudge(cause, detail) {
2052 if (S.mode !== 'live') return;
2053 S.cause = cause || '';
2054 S.why = (cause || '') + ': ' + (detail || '');
2055 if (timer) clearTimeout(timer);
2056 timer = setTimeout(function () { timer = null; build(false); }, S.debounce);
2057}
2058
2059/// What every watched file says about itself right now, as `{ path: stamp }`.
2060///
2061/// One question, asked from two places — the poll, and the moment before a rebuild
2062/// — so the loop cannot end up with two ideas of what it has already seen.
2063///
2064/// BY PATH, because the list is not stable and the answer has to survive it moving.
2065/// `null` when the question could not be asked, which is not the same as "nothing
2066/// changed" and must not be recorded as a reading.
2067async function stamps() {
2068 if (!S.files.length) return null;
2069 try {
2070 const m = await wasm();
2071 const got = Array.from(await m.typst_watch_stamps(S.files)).map(String);
2072 const out = {};
2073 for (let i = 0; i < S.files.length && i < got.length; i++) out[S.files[i]] = got[i];
2074 return out;
2075 } catch (e) {
2076 return null; // a question that could not be asked is not an answer
2077 }
2078}
2079
2080/// Ask the watched files whether they have changed, and nudge if any has.
2081async function poll() {
2082 if (S.mode === 'dead' || !S.path || !S.files.length) return;
2083 // The view being gone is what ends the watch. There is no toggle and no second
2084 // concept: the reader closed the document, so nothing more is compiled for it.
2085 //
2086 // "Gone" and "not there yet" are different, and telling them apart is the whole
2087 // of `seen`. The watch is armed by a compile that finished BEFORE the panel was
2088 // shown -- `began` is called from Rust, and the page opens the panel a moment
2089 // later, in the same turn -- so a poll landing in that gap would find the panel
2090 // invisible and stop a watch that had never started.
2091 const shown = host && host.isConnected && visible(document.getElementById('panel-preview'));
2092 if (shown) S.seen = true;
2093 if (S.seen && !shown) {
2094 stop();
2095 return;
2096 }
2097 if (!shown) return;
2098 const before = S.stamps;
2099 const now = await stamps();
2100 if (!now) return; // the question could not be asked
2101 S.stamps = now;
2102 if (!before) return; // nothing to compare a first reading against
2103 // A path in ONE of the two readings is not a change. A file the last compile
2104 // brought into the project has never been read before, and one it dropped is no
2105 // longer part of the document; either would be reported as a change by a
2106 // comparison that walked positions, and neither is one. A watched file DELETED
2107 // from disk still answers, as `0:-1`, so a chapter removed under the reader is
2108 // still a rebuild — which is the case the old comparison was written for.
2109 for (const p in now) {
2110 if (!(p in before) || now[p] === before[p]) continue;
2111 // It said it was written. Whether it says anything DIFFERENT is another
2112 // question, and the one that decides whether there is anything to lay out.
2113 if (await confirmed(p, now[p])) continue;
2114 S.blind = (Number(String(now[p]).split(':')[1]) > DIGEST_MAX);
2115 nudge('poll', p + ' ' + before[p] + ' \u2192 ' + now[p]);
2116 return;
2117 }
2118}
2119
2120/// Whether an element is on screen at all.
2121function visible(el) {
2122 if (!el) return false;
2123 return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
2124}
2125
2126
2127// ── The doors ───────────────────────────────────────────────────────────────
2128
2129/// A document has just been compiled from the page: follow it from here.
2130///
2131/// Called by `src/wasm/typst.rs` at the page's own Compile door, after a compile
2132/// that WORKED. That is the whole of how the watch starts — no toggle, no setting,
2133/// and nothing that reaches for a compiler on its own. A vector compile does not
2134/// call this, or every rebuild would re-arm the loop that produced it.
2135///
2136/// # Arguments
2137/// * `path` - The `.typ` that was compiled, workspace-relative.
2138/// * `watch` - Every real path that compile read.
2139function began(path, watch) {
2140 const p = String(path || '');
2141 if (!p) return;
2142 const files = watch ? Array.from(watch).map(String) : [];
2143 if (S.path === p && S.mode !== 'idle' && host && host.isConnected) {
2144 S.files = files.length ? files : S.files;
2145 // Compiling again wrote a fresh PDF and put the `<embed>` back on screen, so
2146 // the live view takes its place again rather than sitting beside it.
2147 if (S.drawn) standIn();
2148 return; // already following this one
2149 }
2150 stop();
2151 S.path = p;
2152 S.files = files;
2153 S.stamps = null;
2154 S.cause = 'began';
2155 S.why = 'began: ' + p;
2156 S.digests = {};
2157 S.blind = false;
2158 S.same = 0;
2159 S.mode = 'live';
2160 S.builds = 0;
2161 S.drawn = 0;
2162 S.failed = 0;
2163 S.debounce = DEBOUNCE_MIN;
2164 S.headroom = HEADROOM_MIN;
2165 S.seen = false;
2166 S.toc = [];
2167 S.scanned = 0;
2168 if (!mount()) { S.mode = 'idle'; S.path = ''; return; }
2169 says(tOr('typst.watch.starting', 'Laying out the pages…'), 'building');
2170 // The first live view is drawn BEFORE anything is hidden, so the PDF the button
2171 // just produced stays on screen until there are pages to put in its place.
2172 build(false).then(function () {
2173 if (S.mode !== 'idle' && S.drawn) standIn();
2174 });
2175 poll();
2176 if (poller) clearInterval(poller);
2177 poller = setInterval(function () { poll(); }, POLL_MS);
2178}
2179
2180/// Stop watching and put the panel back as it was.
2181///
2182/// Called when the reader closes the document, and by `began` before it follows a
2183/// different one. Nothing is compiled after this: the poll is cleared, the pending
2184/// debounce is cleared, and a build already in flight finds `mode` no longer `live`.
2185function stop() {
2186 if (timer) { clearTimeout(timer); timer = null; }
2187 if (poller) { clearInterval(poller); poller = null; }
2188 // The two timers the VIEW owns go with them: a band waiting for the scroll to
2189 // settle and a scan waiting for the builds to stop are both work for a document
2190 // nobody is looking at any more.
2191 if (settling) { clearTimeout(settling); settling = null; }
2192 if (waiting) { clearTimeout(waiting); waiting = null; }
2193 unmount();
2194 S.path = '';
2195 S.files = [];
2196 S.stamps = null;
2197 S.mode = 'idle';
2198 S.queued = false;
2199 S.seen = false;
2200 S.error = '';
2201 S.reason = '';
2202 S.cause = '';
2203 S.why = '';
2204 S.digests = {};
2205 S.blind = false;
2206 S.same = 0;
2207 S.toc = [];
2208 S.scanned = 0;
2209 S.tops = [];
2210 // WITH `tops`, ALWAYS. An origin left behind by the document that was open is an
2211 // origin from another book, and the next document's pages would be cropped at it.
2212 S.rtops = [];
2213 S.drift = 0;
2214 S.heights = [];
2215 S.lays = [];
2216 S.laid = 0;
2217 S.pages = 0;
2218 S.bandErr = '';
2219 sick = null;
2220 hereNow = -2;
2221}
2222
2223/// A writer that KNOWS it wrote says so, rather than waiting to be polled.
2224///
2225/// Both actors drive this loop and they are known in different ways. A daimon's
2226/// write and the panel's own Save both go through the one Rust door for bytes, and
2227/// that door says so here; an external editor writing into a folder the user marked
2228/// in cannot say anything at all, and is caught by the poll. Naming a path that is
2229/// not in the project is not a change: a turn writing a log file beside a book must
2230/// not rebuild the book.
2231///
2232/// # Arguments
2233/// * `path` - The workspace-relative path just written.
2234async function touched(path) {
2235 if (S.mode !== 'live') return;
2236 const p = String(path || '');
2237 if (!p) return;
2238 if (p !== S.path && S.files.indexOf(p) < 0) return;
2239 // Told rather than polled, but the question is the same one: a Save that wrote
2240 // back exactly what was there is a write, not an edit, and laying the book out
2241 // again for it would cost the reader a rebuild to see what is already up.
2242 let stamp = '';
2243 try {
2244 const m = await wasm();
2245 stamp = String((await m.typst_watch_stamps([p]))[0] || '');
2246 } catch (e) { /* ask the file's contents anyway */ }
2247 if (stamp && await confirmed(p, stamp)) return;
2248 S.blind = !!stamp && Number(String(stamp).split(':')[1]) > DIGEST_MAX;
2249 nudge('write', p);
2250}
2251
2252/// Rebuild now, because the user asked.
2253///
2254/// The one place the heap budget is skipped, and only ever by a person who has just
2255/// read the sentence saying why it stopped. The loop never does this to itself.
2256function rebuild() {
2257 if (S.mode === 'dead' || !S.path) return;
2258 if (timer) { clearTimeout(timer); timer = null; }
2259 // The user asking is not the loop spinning, whatever the last few builds did.
2260 S.cause = 'user';
2261 S.why = 'user: Rebuild';
2262 S.blind = false;
2263 S.same = 0;
2264 build(true);
2265}
2266
2267/// Read or set the heap ceiling, in MB.
2268///
2269/// A real setting rather than a test hook: this machine is not every machine, and
2270/// an operator with headroom may want a bigger one. Setting it does not change the
2271/// wall, which is the browser's and is about 4 GB — it changes how far from the wall
2272/// this stops.
2273function budgetMB(n) {
2274 const v = Number(n);
2275 if (Number.isFinite(v) && v > 0) S.budget = v;
2276 // Raising the ceiling on a loop that stopped because of it starts it again.
2277 // Anything else would leave the setting looking broken: the number changed, the
2278 // sentence still says there is no room, and nothing moves until a reload.
2279 if (S.mode === 'held' && !holdCheck()) {
2280 S.mode = 'live';
2281 S.reason = '';
2282 showError('');
2283 offerRebuild(false);
2284 says(tOr('typst.watch.live_preview', 'Live preview'), 'live');
2285 }
2286 return S.budget;
2287}
2288
2289/// Where page `n` sits in the scroller, in CSS pixels: `{ top, height }`.
2290///
2291/// The scroller's own height is the whole document's and the SVG in it is only the
2292/// band in view, so nothing outside can work this out by measuring elements. It is
2293/// published because the answer is wanted — by a verifier photographing one page,
2294/// and by any control that jumps to a page.
2295///
2296/// # Arguments
2297/// * `n` - The 1-based page number.
2298function pageBox(n) {
2299 const i = Math.max(0, Math.min(Math.floor(n) - 1, S.lays.length - 1));
2300 if (!S.lays.length) return null;
2301 return { top: S.lays[i] * S.scale, height: S.heights[i] * S.scale };
2302}
2303
2304/// Scroll so that page `n` is at the top of the view, and draw it.
2305///
2306/// DRAWN IN THIS TURN, not when the scroll settles: the reader asked for this page by
2307/// name, and the wait a fling is worth waiting is a wait for nothing here — there is
2308/// no second jump coming to make this one's work wasted.
2309function goToPage(n) {
2310 const sc = scroller();
2311 const b = pageBox(n);
2312 if (!sc || !b) return;
2313 sc.scrollTop = Math.min(sc.scrollHeight - sc.clientHeight, b.top);
2314 ensureWindow(true);
2315}
2316
2317/// What the loop is doing, for the panel and for a verifier alike.
2318///
2319/// ONE answer, read out of the same object the loop acts on, so a check cannot
2320/// pass against a mirror of the state that has drifted from the state.
2321function state() {
2322 return {
2323 path: S.path,
2324 mode: S.mode,
2325 files: S.files.length,
2326 builds: S.builds,
2327 drawn: S.drawn,
2328 failed: S.failed,
2329 pages: S.pages,
2330 debounce: S.debounce,
2331 budget: S.budget,
2332 headroom: S.headroom,
2333 building: S.building,
2334 error: S.error,
2335 reason: S.reason,
2336 why: S.why,
2337 same: S.same,
2338 heap: (window.DaimondTypst && window.DaimondTypst.heapMB)
2339 ? window.DaimondTypst.heapMB() : 0,
2340 rheap: rheapMB(),
2341 scroll: scroller() ? scroller().scrollTop : 0,
2342 at: where(),
2343 zoom: S.zoom,
2344 fit: S.fit,
2345 dark: S.dark,
2346 rail: S.rail,
2347 gap: PAGE_GAP,
2348 sheets: band ? (band.p1 - band.p0 + 1) : 0,
2349 band: band ? { p0: band.p0, p1: band.p1 } : null,
2350 bandErr: S.bandErr,
2351 // THE TWO WAYS THE RENDERER'S ROUNDING SHOWS, both in points, so a check can
2352 // hold one against the other. `drift` is the renderer's own total height less
2353 // the exact sum of the page heights, taken in `draw` before anything is drawn;
2354 // `rdrift` is how far the origin it gave the LAST page has walked from the exact
2355 // sum above it, which is the same accumulation counted a second time and by a
2356 // different route. They can only disagree by the last page's own rounding.
2357 drift: S.drift,
2358 rdrift: (S.pages && S.rtops[S.pages - 1] != null && S.tops[S.pages - 1] != null)
2359 ? S.rtops[S.pages - 1] - S.tops[S.pages - 1] : 0,
2360 toc: S.toc.map(function (e) {
2361 return { text: e.text, level: e.level, page: e.page };
2362 }),
2363 located: S.scanned === S.drawn && S.drawn > 0,
2364 };
2365}
2366
2367/// The sections as the rail has them: `{ text, level, page }`, in document order.
2368function sections() {
2369 return state().toc;
2370}
2371
2372if (typeof window !== 'undefined' && !window.DaimondTypstWatch) {
2373 // A write that Daimond itself made is known the moment it lands: every byte the
2374 // app writes goes through one Rust door (`opfs::write_file`), and that door says
2375 // so on `window`. So a daimon editing a chapter refreshes the view immediately,
2376 // while an external editor writing into a marked folder waits for the next poll
2377 // — the File System Access API has no change events, so there is nothing else it
2378 // could wait for.
2379 window.addEventListener('daimond-file-written', function (ev) {
2380 touched(ev && ev.detail ? ev.detail.path : '');
2381 });
2382 window.DaimondTypstWatch = {
2383 began: began,
2384 stop: stop,
2385 touched: touched,
2386 rebuild: rebuild,
2387 budgetMB: budgetMB,
2388 zoom: zoom,
2389 dark: dark,
2390 state: state,
2391 pageBox: pageBox,
2392 goToPage: goToPage,
2393 rail: rail,
2394 sections: sections,
2395 fitPage: fitPage,
2396 };
2397}
2398
2399export { began, stop, touched, rebuild, budgetMB, zoom, dark, state, pageBox, goToPage,
2400 rail, sections, fitPage };