@quillmark/wasm 0.88.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ /* @ts-self-types="./runtime.d.ts" */
2
+ //
3
+ // @quillmark/wasm/runtime — the canonical consumer API.
4
+ //
5
+ // Consumers import `Quill`, `Document`, and `Engine` from here and never touch
6
+ // the build-specific subpaths. The package ships multiple WASM binaries with
7
+ // SEPARATE linear memories — a Typst-less `core` build (small, eager) that is
8
+ // the canonical home of `Quill`/`Document`, and one private backend binary per
9
+ // backend (`backends/typst/` today; more later) that carries an engine. A
10
+ // handle from one memory cannot be used by another. This module hides that seam
11
+ // and is exposed at the package root (`@quillmark/wasm`):
12
+ //
13
+ // - `Quill` and `Document` ARE the core build's classes, re-exported. They
14
+ // hold the canonical data and the full sync surface (schema / validate /
15
+ // seed / mutate / toJson / toTree). No backend is loaded to use them, so
16
+ // the editor/validation path never pays for a multi-MB backend binary.
17
+ //
18
+ // - `Engine` is the render dispatcher. It routes on `quill.backendId`, lazily
19
+ // imports that backend's build (so a consumer that never renders never
20
+ // loads it), clones the canonical `Quill`/`Document` into the backend's
21
+ // memory ON DEMAND as data (`toTree`→`fromTree`, `toJson`→`fromJson`),
22
+ // renders, and the backend handles never escape.
23
+ //
24
+ // CLONE LIFETIMES (not all transient): the per-call `Document` clone IS
25
+ // transient — built and freed inside each call, because documents are small
26
+ // and mutate freely. The `Quill` clone is CACHED instead: re-cloning a quill
27
+ // per call means re-serializing its whole file tree Rust→JS, copying it into
28
+ // backend memory, and re-parsing + re-validating the bundle every time — and
29
+ // quills are validated, effectively-immutable bundles. So each `Engine`
30
+ // memoizes the backend-memory quill per (engine, backendId, canonical quill
31
+ // instance) in a `WeakMap` keyed on the canonical `Quill`: when the consumer
32
+ // drops the core quill the cache entry becomes collectable and wasm-bindgen
33
+ // weak-refs (`--weak-refs`) free the backend handle. The CONTRACT this buys:
34
+ // a `Quill` instance's contents never change after construction — mutate by
35
+ // replacing the instance (the clone is dropped with it via WeakMap +
36
+ // weak-refs).
37
+ //
38
+ // The cross-memory crossing is therefore invisible: a consumer hands canonical
39
+ // `Quill`/`Document` to `engine.render(...)` and gets a `RenderResult` back.
40
+
41
+ // ── CANONICAL INVARIANT: re-export the core build, never wrap ───────────────
42
+ // The root re-exports the core build's `Quill`/`Document` classes verbatim —
43
+ // NOT subclasses or wrappers. There is exactly ONE public entry point (this
44
+ // module), so this identity is a structural fact: `Quill`/`Document` ARE the
45
+ // core classes, and the only boundary that needs crossing is core→backend (a
46
+ // separate WASM memory), which `Engine` does internally as data
47
+ // (`toTree`/`toJson`).
48
+ //
49
+ // Do NOT replace this with a wrapper class — that breaks the identity and turns
50
+ // a structural fact into a converted type (a breaking design change, not a
51
+ // refactor). Keep `Engine` duck-typed on `.toTree()`/`.backendId`/`.toJson()`
52
+ // (it is) so it tolerates handles from any core instance. The `runtime.test.js`
53
+ // "re-exports the internal core build classes verbatim" case
54
+ // (`Quill === CoreQuill`) is the executable guard for this invariant.
55
+ export { Quill, Document, init } from '../core/wasm.js';
56
+
57
+ /**
58
+ * Narrow an unknown caught value to a `QuillmarkError` — the error every
59
+ * fallible method in this package throws: a real `Error` with a non-empty
60
+ * `diagnostics` array attached (same entry shape as `RenderResult.warnings`).
61
+ *
62
+ * Structural by necessity AND by design: the WASM layer constructs a plain
63
+ * `Error` and attaches the property (there is no error class to `instanceof`),
64
+ * and a structural check works on errors from any build or WASM instance in
65
+ * the page — consistent with the duck-typed handling of handles elsewhere in
66
+ * this layer.
67
+ *
68
+ * @param {unknown} e
69
+ * @returns {e is Error & { diagnostics: import('../core/wasm.js').Diagnostic[] }}
70
+ */
71
+ export function isQuillmarkError(e) {
72
+ return e instanceof Error && Array.isArray(/** @type {any} */ (e).diagnostics);
73
+ }
74
+
75
+ // Backend builds are NEVER statically imported here — that would pull a
76
+ // multi-MB binary into the eager graph and defeat lazy loading. Each entry is a
77
+ // DESCRIPTOR: `load` is a thunk returning a dynamic `import()` (a backend's
78
+ // chunk is fetched only when something actually renders against that backend),
79
+ // and `formats`/`canvas` are the REQUIRED static capability manifest so the
80
+ // cheap probes (`supportedFormats`/`supportsCanvas`) ALWAYS answer without
81
+ // loading the binary or cloning the quill. The manifest values are verified
82
+ // against the backend's Rust source (`crates/backends/typst/src/lib.rs`
83
+ // `SUPPORTED_FORMATS` and `supports_canvas`) and pinned by the `runtime.test.js`
84
+ // drift-guard test, which renders once and asserts the loaded backend reports
85
+ // the same list.
86
+ const DEFAULT_BACKENDS = {
87
+ typst: {
88
+ load: () => import('../backends/typst/wasm.js'),
89
+ formats: ['pdf', 'svg', 'png'], // crates/backends/typst/src/lib.rs SUPPORTED_FORMATS
90
+ canvas: true // crates/backends/typst/src/lib.rs supports_canvas() == true
91
+ }
92
+ };
93
+
94
+ /**
95
+ * Validate a backend registry descriptor, throwing a clear error naming the
96
+ * backend id on any malformed entry. Descriptors are the ONLY accepted form:
97
+ * `{ load, formats, canvas }` with a callable `load`, a `formats` array, and a
98
+ * boolean `canvas`. Failing at construction (not deep inside a render) keeps the
99
+ * capability probes free — they can answer from the manifest unconditionally.
100
+ * @param {string} id
101
+ * @param {unknown} entry
102
+ * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
103
+ */
104
+ function validateBackend(id, entry) {
105
+ if (!entry || typeof entry !== 'object') {
106
+ throw new Error(
107
+ `Engine: backend '${id}' must be a descriptor { load, formats, canvas }.`
108
+ );
109
+ }
110
+ const { load, formats, canvas } = /** @type {any} */ (entry);
111
+ if (typeof load !== 'function') {
112
+ throw new Error(`Engine: backend '${id}' descriptor needs a callable 'load'.`);
113
+ }
114
+ if (!Array.isArray(formats)) {
115
+ throw new Error(`Engine: backend '${id}' descriptor needs a 'formats' array.`);
116
+ }
117
+ if (typeof canvas !== 'boolean') {
118
+ throw new Error(`Engine: backend '${id}' descriptor needs a boolean 'canvas'.`);
119
+ }
120
+ return { load, formats, canvas };
121
+ }
122
+
123
+ /**
124
+ * Render dispatcher over the canonical `Quill`/`Document`. One `Engine`
125
+ * instance can drive every backend; it resolves the right backend build from
126
+ * each quill's declared `backendId` and loads it lazily on first use.
127
+ */
128
+ export class Engine {
129
+ /** backendId → Promise<backend module>, memoized so each build loads once. */
130
+ #modules = new Map();
131
+ /** backendId → that backend's engine instance (the WASM backend registry). */
132
+ #engines = new Map();
133
+ /** backendId → descriptor `{ load, formats, canvas }`. */
134
+ #loaders;
135
+ /**
136
+ * backendId → WeakMap<canonical Quill, backend-memory Quill clone>. Caches
137
+ * the expensive quill materialization per (engine, backend, canonical quill
138
+ * instance). WeakMap so dropping the canonical quill makes its clone
139
+ * collectable; the backend handle is then freed by wasm-bindgen weak-refs.
140
+ * @type {Map<string, WeakMap<object, any>>}
141
+ */
142
+ #quillClones = new Map();
143
+
144
+ /**
145
+ * @param {{ backends?: Record<string, { load: () => Promise<unknown>, formats: string[], canvas: boolean }> }} [options]
146
+ * Extra or overriding backend descriptors, merged over the built-ins. Each
147
+ * entry is a descriptor (`{ load, formats, canvas }`) with `formats` and
148
+ * `canvas` REQUIRED — that static manifest is what makes
149
+ * `supportedFormats`/`supportsCanvas` always free (no binary load, no quill
150
+ * clone). Malformed entries throw here, at construction. The default
151
+ * registry maps `"typst"` to the bundled Typst build.
152
+ */
153
+ constructor(options) {
154
+ const merged = { ...DEFAULT_BACKENDS, ...(options?.backends ?? {}) };
155
+ /** @type {Record<string, { load: () => Promise<unknown>, formats: string[], canvas: boolean }>} */
156
+ const loaders = {};
157
+ for (const [id, entry] of Object.entries(merged)) {
158
+ loaders[id] = validateBackend(id, entry);
159
+ }
160
+ this.#loaders = loaders;
161
+ }
162
+
163
+ /**
164
+ * Look up the registered descriptor for `backendId`, throwing the canonical
165
+ * "no backend registered" error if none. Pure — touches no binary.
166
+ * @param {string} backendId
167
+ * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
168
+ */
169
+ #descriptorFor(backendId) {
170
+ const descriptor = this.#loaders[backendId];
171
+ if (!descriptor) {
172
+ throw new Error(
173
+ `Engine: no backend registered for '${backendId}'. ` +
174
+ `Known backends: ${Object.keys(this.#loaders).join(', ') || '(none)'}.`
175
+ );
176
+ }
177
+ return descriptor;
178
+ }
179
+
180
+ /**
181
+ * Resolve (and lazily load) the backend module + its engine for `backendId`.
182
+ * @param {string} backendId
183
+ * @returns {Promise<{ mod: any, engine: any }>}
184
+ */
185
+ async #resolveBackend(backendId) {
186
+ const descriptor = this.#descriptorFor(backendId);
187
+
188
+ let modPromise = this.#modules.get(backendId);
189
+ if (!modPromise) {
190
+ // Set the promise synchronously (before any await) so concurrent first
191
+ // renders share ONE import. Self-heal on failure so a transient load
192
+ // error doesn't poison every later attempt.
193
+ modPromise = Promise.resolve()
194
+ .then(descriptor.load)
195
+ .catch((err) => {
196
+ this.#modules.delete(backendId);
197
+ throw err;
198
+ });
199
+ this.#modules.set(backendId, modPromise);
200
+ }
201
+ const mod = await modPromise;
202
+
203
+ let engine = this.#engines.get(backendId);
204
+ if (!engine) {
205
+ engine = new mod.Quillmark();
206
+ this.#engines.set(backendId, engine);
207
+ }
208
+ return { mod, engine };
209
+ }
210
+
211
+ /**
212
+ * Get (or materialize-and-cache) the backend-memory `Quill` clone for
213
+ * `quill` under `backendId`. On a cache miss the clone is built via
214
+ * `toTree`→`fromTree` and stored in the per-backend `WeakMap` keyed on the
215
+ * canonical `Quill` instance, so a later call with the same instance reuses
216
+ * it (the hot-path fix: no re-serialize / re-copy / re-validate per call).
217
+ * @param {any} mod the backend build module
218
+ * @param {string} backendId
219
+ * @param {{ toTree(): Map<string, Uint8Array> }} quill
220
+ * @returns {any} the backend-memory quill clone
221
+ */
222
+ #cachedQuillClone(mod, backendId, quill) {
223
+ let perQuill = this.#quillClones.get(backendId);
224
+ if (!perQuill) {
225
+ perQuill = new WeakMap();
226
+ this.#quillClones.set(backendId, perQuill);
227
+ }
228
+ let backendQuill = perQuill.get(quill);
229
+ if (!backendQuill) {
230
+ backendQuill = mod.Quill.fromTree(quill.toTree());
231
+ perQuill.set(quill, backendQuill);
232
+ }
233
+ return backendQuill;
234
+ }
235
+
236
+ /**
237
+ * Materialize the backend-memory clones for `quill` + `doc` in `backendId`'s
238
+ * memory and run `fn` against the backend engine. Only `render`/`open` call
239
+ * this, so `doc` is always present.
240
+ *
241
+ * Clone lifetimes differ by design: the `doc` clone is TRANSIENT — freed in
242
+ * the `finally` of every call. The `quill` clone is CACHED per (engine,
243
+ * backend, canonical quill instance) and is NOT freed here; a `Quill`
244
+ * instance's contents never change after construction, so it is dropped with
245
+ * the canonical quill (WeakMap collection → wasm-bindgen weak-ref free) when
246
+ * the consumer replaces the instance. A cache miss materializes it once;
247
+ * subsequent calls reuse it.
248
+ * @param {string} backendId
249
+ * @param {{ toTree(): Map<string, Uint8Array> }} quill
250
+ * @param {{ toJson(): string }} doc
251
+ * @param {(ctx: { mod: any, engine: any, quill: any, doc: any }) => any} fn
252
+ */
253
+ async #withClones(backendId, quill, doc, fn) {
254
+ const { mod, engine } = await this.#resolveBackend(backendId);
255
+ // The quill clone is cached (see #cachedQuillClone); only the per-call doc
256
+ // clone is transient. Bring the doc clone + `fn` under one try so the doc
257
+ // clone is freed even if a later step throws (e.g. `Document.fromJson`
258
+ // rejecting a cross-version DTO). The cached quill clone is intentionally
259
+ // NOT freed here. `fn` MUST be synchronous — the doc clone is freed as soon
260
+ // as it returns, so an async `fn` would have it freed mid-flight.
261
+ const backendQuill = this.#cachedQuillClone(mod, backendId, quill);
262
+ let backendDoc = null;
263
+ try {
264
+ backendDoc = mod.Document.fromJson(doc.toJson());
265
+ return fn({ mod, engine, quill: backendQuill, doc: backendDoc });
266
+ } finally {
267
+ backendDoc?.free();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Render `doc` against `quill` in one shot, returning a `RenderResult`.
273
+ * @param {Quill} quill
274
+ * @param {Document} doc
275
+ * @param {object} [options] render options (`{ format, ppi, pages, producer }`)
276
+ * @returns {Promise<import('./runtime.js').RenderResult>}
277
+ */
278
+ async render(quill, doc, options) {
279
+ return this.#withClones(quill.backendId, quill, doc, ({ engine, quill: q, doc: d }) =>
280
+ engine.render(q, d, options ?? undefined)
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Open an iterative render session (for canvas preview / per-page paint).
286
+ * The session is a self-contained compiled snapshot, so the transient quill
287
+ * and document clones are freed before this returns; the caller owns the
288
+ * returned session and must `.free()` it.
289
+ * @experimental Ships ahead of its first production consumer (the designed
290
+ * canvas live-preview path); the session/paint surface may change in any
291
+ * 0.x release. `render()` is the stable path.
292
+ * @param {Quill} quill
293
+ * @param {Document} doc
294
+ * @returns {Promise<RenderSession>}
295
+ */
296
+ async open(quill, doc) {
297
+ return this.#withClones(
298
+ quill.backendId,
299
+ quill,
300
+ doc,
301
+ ({ engine, quill: q, doc: d }) => new RenderSession(engine.open(q, d))
302
+ );
303
+ }
304
+
305
+ /**
306
+ * The output formats `quill`'s backend can emit. A cheap, non-failing,
307
+ * ALWAYS-free pre-render probe: it answers from the descriptor's required
308
+ * `formats` manifest — NO binary load and NO quill clone — depending only on
309
+ * `quill.backendId`. Stays `async` for API stability (it never awaits a load).
310
+ * @param {Quill} quill
311
+ * @returns {Promise<import('./runtime.js').OutputFormat[]>}
312
+ */
313
+ async supportedFormats(quill) {
314
+ const descriptor = this.#descriptorFor(quill.backendId);
315
+ // Defensive copy so callers can't mutate the shared manifest.
316
+ return descriptor.formats.slice();
317
+ }
318
+
319
+ /**
320
+ * Whether `quill`'s backend can paint sessions to a canvas. Same ALWAYS-free
321
+ * probe as `supportedFormats`: answered from the descriptor's required
322
+ * `canvas` manifest, no load and no clone.
323
+ * @param {Quill} quill
324
+ * @returns {Promise<boolean>}
325
+ */
326
+ async supportsCanvas(quill) {
327
+ const descriptor = this.#descriptorFor(quill.backendId);
328
+ return descriptor.canvas;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Thin wrapper over a backend's iterative render session. Holds the compiled
334
+ * snapshot; the quill/document it was opened from have already been freed.
335
+ */
336
+ export class RenderSession {
337
+ /** @param {import('../backends/typst/wasm').RenderSession} inner */
338
+ constructor(inner) {
339
+ this.#inner = inner;
340
+ }
341
+ #inner;
342
+
343
+ get pageCount() {
344
+ return this.#inner.pageCount;
345
+ }
346
+ get backendId() {
347
+ return this.#inner.backendId;
348
+ }
349
+ get supportsCanvas() {
350
+ return this.#inner.supportsCanvas;
351
+ }
352
+ get warnings() {
353
+ return this.#inner.warnings;
354
+ }
355
+
356
+ /** @param {object} [options] */
357
+ render(options) {
358
+ return this.#inner.render(options ?? undefined);
359
+ }
360
+
361
+ /** @param {number} page */
362
+ pageSize(page) {
363
+ return this.#inner.pageSize(page);
364
+ }
365
+
366
+ /**
367
+ * @param {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D} ctx
368
+ * @param {number} page
369
+ * @param {object} [options]
370
+ */
371
+ paint(ctx, page, options) {
372
+ return this.#inner.paint(ctx, page, options);
373
+ }
374
+
375
+ free() {
376
+ this.#inner.free();
377
+ }
378
+ }
File without changes