@embedpdf/plugin-render 3.0.0-next.14 → 3.0.0-next.15

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.
package/dist/index.js CHANGED
@@ -1,71 +1,254 @@
1
- import { t as RenderToken } from "./contract-BvwW7rrm.js";
2
- import { CONTINUOUS_RENDER_POLICY, PermissionDenied, definePlugin } from "@embedpdf/core";
1
+ import { a as samePageViewDemand, i as resolveRenderOptions, n as RenderToken, r as EMPTY_TILE_PLAN, t as RENDER_DEFAULTS } from "./contract-Dy5TiQDy.js";
2
+ import { RenderToken as RenderToken$1 } from "./contract/host.js";
3
+ import { CONTINUOUS_RENDER_POLICY, annotationKey, definePlugin, isPluginError, memo, resolvePageLayers, snapAppearanceScale, toPageRef, toPluginError, toPluginErrorInfo } from "@embedpdf/core";
3
4
  import { intersectRects } from "@embedpdf/core-geometry";
4
- //#region src/paint-plan.ts
5
- const EMPTY_TILE_PLAN = {
6
- engaged: false,
7
- paint: [],
8
- fetching: [],
9
- stamp: "empty"
5
+ //#region src/appearance-store.ts
6
+ /**
7
+ * The engine's pictures of a page's annotations or form widgets, shared
8
+ * between the views and plugins that draw them. One ask is one key (the
9
+ * page, the family, the scale and the owner's items at their versions):
10
+ * asks with the same key share one fetch.
11
+ *
12
+ * Consumer aborts are refcounted, as in the raster store: a view that leaves
13
+ * detaches without stopping the fetch for the others; the fetch stops only
14
+ * when the last one leaves before it resolved.
15
+ *
16
+ * Kept for undo:
17
+ * - the last {@link SETS_KEPT} sets of each page and family: an undo that
18
+ * brings a page back to an earlier set asks the engine for nothing;
19
+ * - each item's last pictures ({@link RecentPictures}), a while after it left
20
+ * its page: an undo that brings it back shows them at once.
21
+ */
22
+ var AppearanceStore = class {
23
+ constructor() {
24
+ this.entries = /* @__PURE__ */ new Map();
25
+ this.kept = /* @__PURE__ */ new Map();
26
+ }
27
+ /** `group` names the page and family; `key` the whole ask. */
28
+ acquire(group, key, fetch, signal) {
29
+ const existing = this.entries.get(key);
30
+ if (existing) {
31
+ if (existing.resolved) this.keep(group, key);
32
+ return this.attach(key, existing, signal);
33
+ }
34
+ const entry = {
35
+ group,
36
+ promise: void 0,
37
+ refs: 0,
38
+ abort: new AbortController(),
39
+ resolved: false
40
+ };
41
+ entry.promise = fetch(entry.abort.signal).then((pictures) => {
42
+ entry.resolved = true;
43
+ this.keep(group, key);
44
+ return pictures;
45
+ }, (error) => {
46
+ this.drop(key, entry);
47
+ throw error;
48
+ });
49
+ entry.promise.catch(() => {});
50
+ this.entries.set(key, entry);
51
+ return this.attach(key, entry, signal);
52
+ }
53
+ /** Keep `key` as the group's newest set, letting go of the oldest beyond the few kept. */
54
+ keep(group, key) {
55
+ const kept = (this.kept.get(group) ?? []).filter((each) => each !== key);
56
+ kept.push(key);
57
+ const gone = kept.splice(0, Math.max(0, kept.length - 4));
58
+ this.kept.set(group, kept);
59
+ for (const old of gone) {
60
+ const entry = this.entries.get(old);
61
+ if (entry && entry.refs <= 0) this.entries.delete(old);
62
+ }
63
+ }
64
+ isKept(group, key) {
65
+ return this.kept.get(group)?.includes(key) ?? false;
66
+ }
67
+ attach(key, entry, signal) {
68
+ if (signal?.aborted) return Promise.reject(abortReason$1(signal));
69
+ entry.refs += 1;
70
+ if (!signal) {
71
+ const release = () => this.release(key, entry);
72
+ entry.promise.then(release, release);
73
+ return entry.promise;
74
+ }
75
+ let settled = false;
76
+ return new Promise((resolve, reject) => {
77
+ const onAbort = () => {
78
+ if (settled) return;
79
+ settled = true;
80
+ entry.refs -= 1;
81
+ if (entry.refs <= 0 && !entry.resolved) {
82
+ entry.abort.abort(abortReason$1(signal));
83
+ this.drop(key, entry);
84
+ }
85
+ reject(abortReason$1(signal));
86
+ };
87
+ signal.addEventListener("abort", onAbort, { once: true });
88
+ entry.promise.then((pictures) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ this.release(key, entry);
92
+ signal.removeEventListener("abort", onAbort);
93
+ resolve(pictures);
94
+ }, (error) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ this.release(key, entry);
98
+ signal.removeEventListener("abort", onAbort);
99
+ reject(error);
100
+ });
101
+ });
102
+ }
103
+ /** A consumer let go: a set no longer kept goes with the last one. */
104
+ release(key, entry) {
105
+ entry.refs -= 1;
106
+ if (entry.refs <= 0 && entry.resolved && !this.isKept(entry.group, key)) this.drop(key, entry);
107
+ }
108
+ drop(key, entry) {
109
+ if (this.entries.get(key) === entry) this.entries.delete(key);
110
+ }
10
111
  };
11
- /** The ×2 client pyramid used for lattice deployments without a tiles block. */
12
- const DEFAULT_TILE_PYRAMID = [
13
- 1,
14
- 2,
15
- 4,
16
- 8,
17
- 16,
18
- 32
19
- ];
20
- function resolveRenderOptions(options) {
21
- const tiles = options.tiles === false ? void 0 : options.tiles;
22
- return {
23
- fullPage: {
24
- maxWidth: options.fullPage?.maxWidth ?? 640,
25
- maxWidthExplicit: options.fullPage?.maxWidth !== void 0,
26
- quantize: options.fullPage?.quantize ?? "exact"
27
- },
28
- tiles: {
29
- enabled: options.tiles !== false,
30
- size: tiles?.size ?? 512,
31
- quantize: tiles?.quantize ?? "exact",
32
- maxScale: tiles?.maxScale ?? 128,
33
- bleedPx: tiles?.bleed ?? 1,
34
- engageAt: tiles?.engageAt,
35
- prefetchMargin: tiles?.prefetch?.margin ?? .5,
36
- velocityBias: tiles?.prefetch?.velocityBias ?? true,
37
- settleMs: tiles?.settleMs ?? 150,
38
- fadeMs: tiles?.fadeMs ?? 0,
39
- fallbackPyramid: DEFAULT_TILE_PYRAMID
40
- },
41
- format: options.format,
42
- quality: options.quality,
43
- debug: options.debug ?? false
44
- };
112
+ /**
113
+ * The last pictures of each item (all its states), the newest {@link ITEMS_KEPT}
114
+ * items per family, kept after the item left its page.
115
+ */
116
+ var RecentPictures = class {
117
+ constructor() {
118
+ this.families = /* @__PURE__ */ new Map();
119
+ }
120
+ /** A fetch's pictures, grouped by item: each replaces what its item had. */
121
+ remember(family, pictures, keyOf) {
122
+ const items = this.families.get(family) ?? /* @__PURE__ */ new Map();
123
+ const byItem = /* @__PURE__ */ new Map();
124
+ for (const picture of pictures) {
125
+ const key = keyOf(picture);
126
+ byItem.set(key, [...byItem.get(key) ?? [], picture]);
127
+ }
128
+ for (const [key, itemPictures] of byItem) {
129
+ items.delete(key);
130
+ items.set(key, itemPictures);
131
+ }
132
+ while (items.size > 200) items.delete(items.keys().next().value);
133
+ this.families.set(family, items);
134
+ }
135
+ of(family, key) {
136
+ return this.families.get(family)?.get(key) ?? [];
137
+ }
138
+ };
139
+ function abortReason$1(signal) {
140
+ return signal.reason ?? new DOMException("aborted", "AbortError");
141
+ }
142
+ //#endregion
143
+ //#region src/invalidation.ts
144
+ const placedPages = (widgets) => widgets.flatMap((widget) => widget.page ? [widget.page.objectNumber] : []);
145
+ const changeOf = (pages, scope) => pages.length ? {
146
+ pages: [...new Set(pages)],
147
+ scope
148
+ } : null;
149
+ /**
150
+ * The pages a form fact repaints. Every form fact names the widgets whose
151
+ * look changed or that went (`meta.changedWidgets`), each with its page; the
152
+ * widget rows it carries, and a deleted widget's page, are counted too. An
153
+ * unplaced widget (`page: null`) has no pixels to repaint.
154
+ */
155
+ function formPagesOf(event) {
156
+ const pages = placedPages(event.meta.changedWidgets);
157
+ if ("widgets" in event) pages.push(...placedPages(event.widgets));
158
+ if ("page" in event && event.page) pages.push(event.page.objectNumber);
159
+ return pages;
160
+ }
161
+ function pixelChangeOf(event, allPageObjectNumbers) {
162
+ switch (event.type) {
163
+ case "annotations.created":
164
+ case "annotations.updated":
165
+ case "annotations.deleted":
166
+ case "annotations.restored":
167
+ case "annotations.reordered": return changeOf([event.page.objectNumber], "annotations");
168
+ case "forms.valueSet":
169
+ case "forms.created":
170
+ case "forms.updated":
171
+ case "forms.deleted":
172
+ case "forms.restored":
173
+ case "forms.widgetAdded":
174
+ case "forms.widgetRemoved":
175
+ case "forms.widgetDeleted":
176
+ case "forms.widgetRestored":
177
+ case "forms.widgetUpdated":
178
+ case "forms.widgetsReordered": return changeOf(formPagesOf(event), "fields");
179
+ case "forms.repaired": return changeOf(allPageObjectNumbers(), "fields");
180
+ case "signatures.completed": return changeOf(event.signature.widget ? placedPages([event.signature.widget]) : [], "fields");
181
+ case "redaction.applied":
182
+ case "pages.flattened": return changeOf(event.results.filter((result) => result.status === "applied").map((result) => result.page.objectNumber), "content");
183
+ case "annotations.flattened": return changeOf(event.results.some((result) => result.status === "applied") ? [event.page.objectNumber] : [], "content");
184
+ case "stream.desynced": return changeOf(allPageObjectNumbers(), "content");
185
+ default: return null;
186
+ }
187
+ }
188
+ //#endregion
189
+ //#region src/model.ts
190
+ const initialRenderState = () => ({
191
+ contentEpochs: {},
192
+ annotationEpochs: {},
193
+ fieldEpochs: {}
194
+ });
195
+ const bump = (epochs, pageObjectNumbers) => {
196
+ const next = { ...epochs };
197
+ for (const pageObjectNumber of pageObjectNumbers) next[pageObjectNumber] = (next[pageObjectNumber] ?? 0) + 1;
198
+ return next;
199
+ };
200
+ /**
201
+ * Bump the pages' versions in the ledger the scope names. A content fact
202
+ * bumps only the content ledger; every picture counts content (see
203
+ * {@link renderEpochOf}), which is how content invalidation reaches them all.
204
+ */
205
+ function invalidatePages(state, pageObjectNumbers, scope) {
206
+ if (pageObjectNumbers.length === 0) return state;
207
+ switch (scope) {
208
+ case "content": return {
209
+ ...state,
210
+ contentEpochs: bump(state.contentEpochs, pageObjectNumbers)
211
+ };
212
+ case "annotations": return {
213
+ ...state,
214
+ annotationEpochs: bump(state.annotationEpochs, pageObjectNumbers)
215
+ };
216
+ case "fields": return {
217
+ ...state,
218
+ fieldEpochs: bump(state.fieldEpochs, pageObjectNumbers)
219
+ };
220
+ }
221
+ }
222
+ /** The version of one picture of a page: its content, plus the annotations and form fields it draws. */
223
+ function renderEpochOf(state, pageObjectNumber, layers) {
224
+ return (state.contentEpochs[pageObjectNumber] ?? 0) + (layers.includeAnnotations ? state.annotationEpochs[pageObjectNumber] ?? 0 : 0) + (layers.includeFormFields ? state.fieldEpochs[pageObjectNumber] ?? 0 : 0);
45
225
  }
226
+ /** What a picture draws, as one key part: `a1f0` draws annotations and no form fields. */
227
+ const layersKeyOf = (layers) => `a${layers.includeAnnotations ? 1 : 0}f${layers.includeFormFields ? 1 : 0}`;
46
228
  //#endregion
47
229
  //#region src/raster-store.ts
48
230
  /**
49
- * Client-side mirror of the server's one-door read-through: per-key
50
- * SINGLEFLIGHT + cost-budgeted LRU over encoded page images.
231
+ * The client-side counterpart of the server's read-through cache: per-key
232
+ * singleflight + cost-budgeted LRU over encoded page images.
51
233
  *
52
- * The key is the raster's canonical identity (conformed viewport +
53
- * annotations flag + epoch — see `renderSourceKey`), so the render points do
234
+ * The key is the raster's canonical identity (for page rasters: width,
235
+ * annotations flag, epoch and format, built by `rasterKey` in the
236
+ * controller; for tiles: `TileManager`'s tile key), so the render points do
54
237
  * the caching work: every ask at one point is the same key, and
55
238
  * re-crossings (zoom 1280 → 2560 → back) resolve instantly from the LRU
56
239
  * instead of re-rendering. Epoch bumps mint new keys; stale-epoch entries
57
240
  * age out through the LRU (they are never re-requested).
58
241
  *
59
- * The budget is COST, approximately bytes: an entry costs its encoded byte
242
+ * The budget is cost, approximately bytes: an entry costs its encoded byte
60
243
  * length when the handle carries bytes (local), its pixel count as a
61
244
  * decoded-order estimate otherwise (URL-backed cloud handles), and 1 when
62
245
  * neither is known. Entry counts lie — a 512² webp tile and a BMP full page
63
246
  * differ by 100× — and long deep-zoom sessions cycle hundreds of tile keys,
64
247
  * so the budget is what actually bounds memory.
65
248
  *
66
- * Consumer aborts are REFCOUNTED: a caller abandoning a fetch (camera moved,
249
+ * Consumer aborts are refcounted: a caller abandoning a fetch (camera moved,
67
250
  * layer unmounted) detaches without killing it for other consumers; the
68
- * underlying engine call aborts only when the LAST in-flight consumer leaves.
251
+ * underlying engine call aborts only when the last in-flight consumer leaves.
69
252
  * Resolved entries are kept (evictable by LRU) — an abort after resolution
70
253
  * is a no-op by design.
71
254
  */
@@ -95,9 +278,9 @@ var RasterStore = class {
95
278
  this.total += entry.cost;
96
279
  this.evict();
97
280
  return handle;
98
- }, (err) => {
281
+ }, (error) => {
99
282
  this.drop(key, entry);
100
- throw err;
283
+ throw error;
101
284
  });
102
285
  entry.promise.catch(() => {});
103
286
  this.entries.set(key, entry);
@@ -146,12 +329,12 @@ var RasterStore = class {
146
329
  entry.refs -= 1;
147
330
  signal.removeEventListener("abort", onAbort);
148
331
  resolve(handle);
149
- }, (err) => {
332
+ }, (error) => {
150
333
  if (settled) return;
151
334
  settled = true;
152
335
  entry.refs -= 1;
153
336
  signal.removeEventListener("abort", onAbort);
154
- reject(err);
337
+ reject(error);
155
338
  });
156
339
  });
157
340
  }
@@ -163,7 +346,7 @@ var RasterStore = class {
163
346
  }
164
347
  evict() {
165
348
  if (this.total <= this.maxCost) return;
166
- const candidates = [...this.entries.entries()].filter(([, e]) => e.refs <= 0 && e.handle !== void 0).sort((a, b) => a[1].used - b[1].used);
349
+ const candidates = [...this.entries.entries()].filter(([, entry]) => entry.refs <= 0 && entry.handle !== void 0).sort((left, right) => left[1].used - right[1].used);
167
350
  for (const [key, entry] of candidates) {
168
351
  if (this.total <= this.maxCost) return;
169
352
  this.drop(key, entry);
@@ -182,11 +365,11 @@ function abortReason(signal) {
182
365
  }
183
366
  //#endregion
184
367
  //#region src/strategy.ts
185
- const ascending = (values) => [...values].sort((a, b) => a - b);
368
+ const ascending = (values) => [...values].sort((left, right) => left - right);
186
369
  /** Rung list capped by the budget — always keeps at least the smallest rung. */
187
370
  const capWidths = (widths, maxWidth) => {
188
371
  const sorted = ascending(widths);
189
- const kept = sorted.filter((w) => w <= maxWidth);
372
+ const kept = sorted.filter((width) => width <= maxWidth);
190
373
  return kept.length ? kept : sorted.slice(0, 1);
191
374
  };
192
375
  function resolveStrategy(policy, options) {
@@ -210,15 +393,16 @@ function resolveStrategy(policy, options) {
210
393
  };
211
394
  }
212
395
  /**
213
- * The ONE base-sizing function — four callers: `renderPage`,
214
- * `renderSourceKey`, `conformViewport`, and tile engagement. Exact mode
396
+ * The one base-sizing function. Its callers: the controller's
397
+ * `conformViewport` (which `renderSource` and `getSourceKey` go through)
398
+ * and tile engagement in the tile manager. Exact mode
215
399
  * returns the (settled) demand itself up to the budget; ladder mode snaps
216
- * UP and caps at the top rung.
400
+ * up and caps at the top rung.
217
401
  */
218
402
  function baseAskWidth(strategy, demandWidth) {
219
403
  const wanted = Math.max(1, Math.round(demandWidth));
220
404
  if (strategy.widths === null) return Math.min(wanted, strategy.maxWidth);
221
- return strategy.widths.find((w) => w >= wanted) ?? strategy.widths[strategy.widths.length - 1];
405
+ return strategy.widths.find((width) => width >= wanted) ?? strategy.widths[strategy.widths.length - 1];
222
406
  }
223
407
  //#endregion
224
408
  //#region src/tiles.ts
@@ -238,22 +422,22 @@ const tileSpanPt = (grid) => grid.tileSize / grid.scale;
238
422
  */
239
423
  function tilesInRect(grid, page, rect) {
240
424
  const span = tileSpanPt(grid);
241
- const x0 = Math.max(0, Math.floor(rect.x / span));
242
- const y0 = Math.max(0, Math.floor(rect.y / span));
243
- const x1 = Math.min(grid.cols - 1, Math.ceil((rect.x + rect.width) / span) - 1);
244
- const y1 = Math.min(grid.rows - 1, Math.ceil((rect.y + rect.height) / span) - 1);
245
- const out = [];
246
- for (let iy = y0; iy <= y1; iy++) for (let ix = x0; ix <= x1; ix++) out.push({
425
+ const firstColumn = Math.max(0, Math.floor(rect.x / span));
426
+ const firstRow = Math.max(0, Math.floor(rect.y / span));
427
+ const lastColumn = Math.min(grid.cols - 1, Math.ceil((rect.x + rect.width) / span) - 1);
428
+ const lastRow = Math.min(grid.rows - 1, Math.ceil((rect.y + rect.height) / span) - 1);
429
+ const coords = [];
430
+ for (let iy = firstRow; iy <= lastRow; iy++) for (let ix = firstColumn; ix <= lastColumn; ix++) coords.push({
247
431
  ix,
248
432
  iy
249
433
  });
250
- return out;
434
+ return coords;
251
435
  }
252
436
  /** Paint rect: y-down page points, clamped to the page edge. */
253
- function tilePaintRect(grid, page, c) {
437
+ function tilePaintRect(grid, page, coord) {
254
438
  const span = tileSpanPt(grid);
255
- const x = c.ix * span;
256
- const y = c.iy * span;
439
+ const x = coord.ix * span;
440
+ const y = coord.iy * span;
257
441
  return {
258
442
  x,
259
443
  y,
@@ -261,43 +445,33 @@ function tilePaintRect(grid, page, c) {
261
445
  height: Math.min(span, page.height - y)
262
446
  };
263
447
  }
264
- /** Engine target rect (y-UP PDF user space, top > bottom) for an arbitrary
265
- * y-down page-point rect. */
266
- function toEngineRect(page, rect) {
267
- return {
268
- left: rect.x,
269
- right: rect.x + rect.width,
270
- top: page.height - rect.y,
271
- bottom: page.height - (rect.y + rect.height)
272
- };
273
- }
274
448
  /**
275
- * Expand a paint rect by `pt` page points per side, clamped to the page —
276
- * the tile BLEED. Neighboring tiles rendered with bleed overlap by twice
449
+ * Expand a paint rect by `amountPt` page points per side, clamped to the page —
450
+ * the tile bleed. Neighboring tiles rendered with bleed overlap by twice
277
451
  * this amount, and the overlapping strips contain identical content (same
278
452
  * page region, same scale), so every img edge composites over its
279
453
  * neighbor's duplicated pixels instead of the backdrop: no AA hairlines,
280
454
  * no bilinear edge smear, at any CSS stretch.
281
455
  */
282
- function bleedRect(rect, pt, page) {
283
- const x = Math.max(0, rect.x - pt);
284
- const y = Math.max(0, rect.y - pt);
456
+ function bleedRect(rect, amountPt, page) {
457
+ const x = Math.max(0, rect.x - amountPt);
458
+ const y = Math.max(0, rect.y - amountPt);
285
459
  return {
286
460
  x,
287
461
  y,
288
- width: Math.min(page.width, rect.x + rect.width + pt) - x,
289
- height: Math.min(page.height, rect.y + rect.height + pt) - y
462
+ width: Math.min(page.width, rect.x + rect.width + amountPt) - x,
463
+ height: Math.min(page.height, rect.y + rect.height + amountPt) - y
290
464
  };
291
465
  }
292
466
  /**
293
- * Does a set of PAINTED want-level tiles cover `region` (y-down points)?
467
+ * Does a set of painted want-level tiles cover `region` (y-down points)?
294
468
  * The retention release check: a retained source may leave the paint list
295
469
  * once its visible footprint answers true here. Pure index arithmetic on
296
470
  * the aligned grid — the whole reason the pyramid is aligned.
297
471
  */
298
472
  function regionCovered(grid, page, region, isPainted) {
299
473
  if (region.width <= 0 || region.height <= 0) return true;
300
- for (const c of tilesInRect(grid, page, region)) if (!isPainted(c)) return false;
474
+ for (const coord of tilesInRect(grid, page, region)) if (!isPainted(coord)) return false;
301
475
  return true;
302
476
  }
303
477
  /**
@@ -307,26 +481,26 @@ function regionCovered(grid, page, region, isPainted) {
307
481
  * velocity is just data.
308
482
  */
309
483
  function inflateRect(rect, margin, velocity) {
310
- const mx = rect.width * margin;
311
- const my = rect.height * margin;
312
- let left = mx;
313
- let right = mx;
314
- let up = my;
315
- let down = my;
484
+ const marginX = rect.width * margin;
485
+ const marginY = rect.height * margin;
486
+ let left = marginX;
487
+ let right = marginX;
488
+ let up = marginY;
489
+ let down = marginY;
316
490
  if (velocity) {
317
491
  if (velocity.dx > 0) {
318
- right = mx * 2;
319
- left = mx / 2;
492
+ right = marginX * 2;
493
+ left = marginX / 2;
320
494
  } else if (velocity.dx < 0) {
321
- left = mx * 2;
322
- right = mx / 2;
495
+ left = marginX * 2;
496
+ right = marginX / 2;
323
497
  }
324
498
  if (velocity.dy > 0) {
325
- down = my * 2;
326
- up = my / 2;
499
+ down = marginY * 2;
500
+ up = marginY / 2;
327
501
  } else if (velocity.dy < 0) {
328
- up = my * 2;
329
- down = my / 2;
502
+ up = marginY * 2;
503
+ down = marginY / 2;
330
504
  }
331
505
  }
332
506
  return {
@@ -336,74 +510,70 @@ function inflateRect(rect, margin, velocity) {
336
510
  height: rect.height + up + down
337
511
  };
338
512
  }
339
- /** Snap UP through a pyramid's sorted scales; cap at the top. */
513
+ /** Snap up through a pyramid's sorted scales; cap at the top. */
340
514
  function snapToPyramid(scales, needed) {
341
- const sorted = [...scales].sort((a, b) => a - b);
342
- return sorted.find((s) => s >= needed) ?? sorted[sorted.length - 1];
515
+ const sorted = [...scales].sort((left, right) => left - right);
516
+ return sorted.find((scale) => scale >= needed) ?? sorted[sorted.length - 1];
343
517
  }
344
518
  //#endregion
345
519
  //#region src/tile-manager.ts
520
+ /** One state entry per view of a page (see {@link TileManager}'s `pages`). */
521
+ const stateKey = (view, pageObjectNumber) => `${view}\u0000${pageObjectNumber}`;
522
+ /** Backpressure: raw rasters in flight at once (render + encode transit). */
523
+ const MAX_IN_FLIGHT = 8;
524
+ /** Stage-less (no visibleRect) demand is capped to this many whole-page tiles. */
525
+ const STAGELESS_TILE_CAP = 64;
346
526
  /**
347
- * The tile retention state machine. One instance per
348
- * plugin instance (per document), all levels and pages in one place, over
349
- * the SAME RasterStore the base renders use.
527
+ * The tile retention state machine. One instance per plugin instance (per
528
+ * document), all levels and pages in one place, over the same RasterStore
529
+ * the base renders use.
350
530
  *
351
- * Invariant it enforces: every screen region paints the sharpest PAINTED
531
+ * Invariant it enforces: every screen region paints the sharpest painted
352
532
  * pixels available; quality per region only goes up until the want set
353
533
  * resolves. Mechanics:
354
- * - want vs paint: `plan()` schedules fetches for the want set (P0
355
- * visible first — center-out — then P1 prefetch when P0 is fully
356
- * resolved) and returns a paint list drawn ONLY from resolved entries —
357
- * current level AND retained older generations.
358
- * - release-on-occlusion: when a want-level tile reports PAINTED (after the
534
+ * - want vs paint: `plan()` queues the want set (visible tiles first,
535
+ * center-out, then the prefetch ring once every visible tile resolved)
536
+ * and returns a paint list drawn only from resolved entries: the
537
+ * current level and retained older generations.
538
+ * - one line for every view and page: a transit slot goes to an on-screen
539
+ * tile of any page before a tile of any prefetch ring, so a ring never
540
+ * takes the slots an on-screen tile needs. The engine ranks the tiles it
541
+ * is given by where each one is on screen.
542
+ * - release-on-occlusion: when a want-level tile reports painted (after the
359
543
  * layer's first presentation opportunity), retained sources whose
360
- * visible footprint is
361
- * covered by painted want tiles leave the paint list. Their bytes stay
362
- * in the RasterStore (demotion, not eviction) — zoom-back re-promotes
363
- * from cache.
544
+ * visible footprint is covered by painted want tiles leave the paint
545
+ * list. Their bytes stay in the RasterStore (demotion, not eviction), so
546
+ * zooming back re-promotes from cache.
364
547
  * - epoch exception: an invalidation bump means retained pixels are
365
- * WRONG — everything of the old epoch drops immediately.
548
+ * wrong, so everything of the old epoch drops immediately.
366
549
  *
367
- * Levels come from the resolved STRATEGY (policy ∧ options): a pyramid
368
- * under a lattice (or the opt-in client ladder), the EXACT settled scale
369
- * under exact mode — where the level identity is the demand's device width
550
+ * Levels come from the resolved strategy (policy ∧ options): a pyramid
551
+ * under a lattice (or the opt-in client ladder), the exact settled scale
552
+ * under exact mode, where the level identity is the demand's device width
370
553
  * across the page, so keys stay integer and stable. The retention/coverage
371
554
  * math is generic over any mix of retained scales.
372
555
  *
373
- * `plan()` is called from selectors: it MUST NOT dispatch. Fetch kickoff
374
- * is idempotent (the store singleflights); resolution handlers dispatch
375
- * the wake-up (`onAdvance`) that makes subscribed layers recompute.
556
+ * `plan()` never wakes readers itself: its callers do, once, when the
557
+ * returned plan changed. Fetch kickoff is idempotent (the store
558
+ * singleflights); resolution handlers call `onAdvance`, which re-plans the
559
+ * page and wakes subscribed layers, and hand the freed slot on.
376
560
  */
377
- /** One state entry per VIEW-of-page (see the class doc). */
378
- const stateKey = (view, pon) => `${view}\u0000${pon}`;
379
- /** Backpressure: raw rasters in flight at once (render + encode transit). */
380
- const MAX_IN_FLIGHT = 8;
381
- /** Stage-less (no visibleRect) demand is capped to this many whole-page tiles. */
382
- const STAGELESS_TILE_CAP = 64;
383
561
  var TileManager = class {
384
562
  constructor(deps) {
385
563
  this.deps = deps;
386
564
  this.pages = /* @__PURE__ */ new Map();
387
- this.strategyMemo = null;
565
+ this.strategy = memo(() => [this.deps.getPolicy(), this.deps.getOptions()], (policy, options) => resolveStrategy(policy, options));
388
566
  this.inFlight = 0;
389
567
  this.warnedStageless = false;
390
568
  }
391
- strategy() {
392
- const policy = this.deps.getPolicy();
393
- if (this.strategyMemo?.policy !== policy) this.strategyMemo = {
394
- policy,
395
- strategy: resolveStrategy(policy, this.deps.options)
396
- };
397
- return this.strategyMemo.strategy;
398
- }
399
- plan(view, pon, demand, includeAnnotations) {
400
- const { options } = this.deps;
569
+ plan(view, pageObjectNumber, demand, layers) {
570
+ const options = this.deps.getOptions();
401
571
  if (!options.tiles.enabled) return EMPTY_TILE_PLAN;
402
572
  const strategy = this.strategy();
403
- const page = this.deps.getPageSize(pon);
573
+ const page = this.deps.getPageSize(pageObjectNumber);
404
574
  if (!page) return EMPTY_TILE_PLAN;
405
- const epoch = this.deps.getEpoch(pon, includeAnnotations);
406
- const state = this.pageState(pon, view);
575
+ const epoch = this.deps.getEpoch(pageObjectNumber, layers);
576
+ const state = this.pageState(pageObjectNumber, view);
407
577
  if (state.epoch !== epoch) {
408
578
  this.abortAll(state);
409
579
  state.entries.clear();
@@ -417,13 +587,14 @@ var TileManager = class {
417
587
  const deficit = demand.desiredDeviceWidth / supplied;
418
588
  if (deficit <= strategy.engageAt) {
419
589
  if (state.entries.size || state.wantScale !== null) {
420
- this.deps.debug?.(`disengage pon=${pon} (deficit ${deficit.toFixed(2)})`);
590
+ this.deps.debug?.(`disengage page=${pageObjectNumber} (deficit ${deficit.toFixed(2)})`);
421
591
  this.abortAll(state);
422
592
  state.entries.clear();
423
593
  state.failedKeys.clear();
424
594
  state.wantScale = null;
425
595
  state.wantWidth = null;
426
596
  state.planCache = null;
597
+ this.pump();
427
598
  }
428
599
  return EMPTY_TILE_PLAN;
429
600
  }
@@ -444,7 +615,7 @@ var TileManager = class {
444
615
  console.warn(`[render] stage-less tile demand of ${Math.round(demand.desiredDeviceWidth)} device px would tile the whole page — clamped to ${maxStagelessWidth}. Supply a visibleRect (host the page in a Stage) for sharp deep zoom.`);
445
616
  }
446
617
  if (strategy.pyramid) {
447
- const fitting = strategy.pyramid.filter((s) => Math.round(s * page.width) <= maxStagelessWidth);
618
+ const fitting = strategy.pyramid.filter((scale) => Math.round(scale * page.width) <= maxStagelessWidth);
448
619
  wantScale = fitting.length ? fitting[fitting.length - 1] : strategy.pyramid[0];
449
620
  wantWidth = Math.round(wantScale * page.width);
450
621
  } else {
@@ -461,12 +632,12 @@ var TileManager = class {
461
632
  height: page.height
462
633
  };
463
634
  state.lastVisible = visible;
464
- const p0 = tilesInRect(grid, page, visible);
635
+ const visibleCoords = tilesInRect(grid, page, visible);
465
636
  const ring = inflateRect(visible, options.tiles.prefetchMargin, options.tiles.velocityBias ? demand.velocity : void 0);
466
- const p0Keys = new Set(p0.map((c) => coordKey(c)));
467
- const p1 = tilesInRect(grid, page, ring).filter((c) => !p0Keys.has(coordKey(c)));
468
- this.schedule(pon, state, page, grid, wantScale, wantWidth, includeAnnotations, epoch, p0, p1, visible);
469
- if (this.releaseCovered(pon, state, page, includeAnnotations)) {
637
+ const visibleKeys = new Set(visibleCoords.map((coord) => coordKey(coord)));
638
+ const prefetchCoords = tilesInRect(grid, page, ring).filter((coord) => !visibleKeys.has(coordKey(coord)));
639
+ this.schedule(pageObjectNumber, state, page, grid, wantScale, wantWidth, layers, epoch, visibleCoords, prefetchCoords, visible);
640
+ if (this.releaseCovered(pageObjectNumber, state, page, layers)) {
470
641
  state.version += 1;
471
642
  state.planCache = null;
472
643
  }
@@ -475,11 +646,11 @@ var TileManager = class {
475
646
  const paint = [];
476
647
  const fetching = [];
477
648
  const stale = [];
478
- const bleedPx = this.deps.options.tiles.bleedPx;
649
+ const bleedPx = this.deps.getOptions().tiles.bleedPx;
479
650
  for (const entry of state.entries.values()) {
480
- const visIntersect = intersectRects(entry.rect, visible);
651
+ const visiblePart = intersectRects(entry.rect, visible);
481
652
  if (entry.resolved) {
482
- if (visIntersect.width > 0 && visIntersect.height > 0) {
653
+ if (visiblePart.width > 0 && visiblePart.height > 0) {
483
654
  const handle = this.deps.store.peek(entry.key);
484
655
  if (!handle) {
485
656
  stale.push(entry.key);
@@ -497,9 +668,10 @@ var TileManager = class {
497
668
  }
498
669
  for (const key of stale) state.entries.delete(key);
499
670
  const rank = /* @__PURE__ */ new Map();
500
- for (const s of [...new Set(paint.map((p) => p.scale))].sort((a, b) => a - b)) rank.set(s, rank.size);
501
- for (const p of paint) p.z = rank.get(p.scale);
502
- paint.sort((a, b) => a.z - b.z || a.key.localeCompare(b.key));
671
+ const scales = [...new Set(paint.map((source) => source.scale))];
672
+ for (const scale of scales.sort((left, right) => left - right)) rank.set(scale, rank.size);
673
+ for (const source of paint) source.z = rank.get(source.scale);
674
+ paint.sort((left, right) => left.z - right.z || left.key.localeCompare(right.key));
503
675
  const plan = {
504
676
  engaged: true,
505
677
  paint,
@@ -514,41 +686,46 @@ var TileManager = class {
514
686
  return plan;
515
687
  }
516
688
  /** The layer's painted report: this key's pixels had a presentation opportunity. */
517
- sourcePainted(view, pon, key) {
518
- const state = this.pages.get(stateKey(view, pon));
689
+ sourcePainted(view, pageObjectNumber, key) {
690
+ const state = this.pages.get(stateKey(view, pageObjectNumber));
519
691
  const entry = state?.entries.get(key);
520
692
  if (!state || !entry || entry.painted) return;
521
693
  entry.painted = true;
522
- const page = this.deps.getPageSize(pon);
523
- if (page) this.releaseCovered(pon, state, page, annotationsOf(key));
694
+ const page = this.deps.getPageSize(pageObjectNumber);
695
+ if (page) this.releaseCovered(pageObjectNumber, state, page, layersOfKey(key));
524
696
  state.version += 1;
525
697
  state.planCache = null;
526
- this.deps.onAdvance(pon);
698
+ this.deps.onAdvance(pageObjectNumber);
527
699
  }
528
700
  /**
529
701
  * The inverse report: this key's <img> left the DOM (pan-away, plan drop),
530
- * so its pixels are NOT currently compositable. Without this, a tile that
702
+ * so its pixels are not currently compositable. Without this, a tile that
531
703
  * unmounts and later remounts is still counted as painted while its new
532
704
  * <img> re-decodes — and an adjacent fresh `sourcePainted` could release
533
705
  * retained coarse coverage over a region that momentarily has no sharp
534
- * pixels. Painted is a statement about the SCREEN, so it follows the DOM.
706
+ * pixels. Painted is a statement about the screen, so it follows the DOM.
535
707
  */
536
- sourceUnpainted(view, pon, key) {
537
- const entry = this.pages.get(stateKey(view, pon))?.entries.get(key);
708
+ sourceUnpainted(view, pageObjectNumber, key) {
709
+ const entry = this.pages.get(stateKey(view, pageObjectNumber))?.entries.get(key);
538
710
  if (entry) entry.painted = false;
539
711
  }
540
- /** A lens unmounted its tile plane: stop fetching, drop bookkeeping.
541
- * Resolved bytes stay in the RasterStore for a re-mount. */
542
- releasePage(view, pon) {
543
- const state = this.pages.get(stateKey(view, pon));
712
+ /** A lens stopped painting tiles for the page (it left the view, or its
713
+ * layer paints none): stop fetching, drop bookkeeping. Resolved bytes stay
714
+ * in the RasterStore for a re-mount. */
715
+ releasePage(view, pageObjectNumber) {
716
+ const state = this.pages.get(stateKey(view, pageObjectNumber));
544
717
  if (!state) return;
545
718
  this.abortAll(state);
546
- this.pages.delete(stateKey(view, pon));
719
+ this.pages.delete(stateKey(view, pageObjectNumber));
720
+ this.pump();
547
721
  }
548
- pageState(pon, view) {
549
- let state = this.pages.get(stateKey(view, pon));
722
+ pageState(pageObjectNumber, view) {
723
+ let state = this.pages.get(stateKey(view, pageObjectNumber));
550
724
  if (!state) {
551
725
  state = {
726
+ view,
727
+ pageObjectNumber,
728
+ queue: null,
552
729
  epoch: -1,
553
730
  wantScale: null,
554
731
  wantWidth: null,
@@ -556,22 +733,23 @@ var TileManager = class {
556
733
  failedKeys: /* @__PURE__ */ new Set(),
557
734
  version: 0,
558
735
  planCache: null,
559
- settleTimer: null,
736
+ cancelSettle: null,
560
737
  pendingLevel: null,
561
738
  lastVisible: null
562
739
  };
563
- this.pages.set(stateKey(view, pon), state);
740
+ this.pages.set(stateKey(view, pageObjectNumber), state);
564
741
  }
565
742
  return state;
566
743
  }
567
- schedule(pon, state, page, grid, wantScale, wantWidth, includeAnnotations, epoch, p0, p1, visible) {
568
- const { options } = this.deps;
744
+ schedule(pageObjectNumber, state, page, grid, wantScale, wantWidth, layers, epoch, visibleCoords, prefetchCoords, visible) {
745
+ const options = this.deps.getOptions();
569
746
  const firstEngage = state.wantWidth === null;
570
747
  const levelChanged = state.wantWidth !== null && state.wantWidth !== wantWidth;
571
748
  if (levelChanged) state.failedKeys.clear();
572
749
  state.wantScale = wantScale;
573
750
  state.wantWidth = wantWidth;
574
- const wanted = new Set([...p0, ...p1].map((c) => this.tileKey(pon, wantWidth, c, includeAnnotations, epoch)));
751
+ const keyOf = (coord) => this.tileKey(pageObjectNumber, wantWidth, coord, layers, epoch);
752
+ const wanted = new Set([...visibleCoords, ...prefetchCoords].map(keyOf));
575
753
  for (const [key, entry] of state.entries) {
576
754
  if (wanted.has(key)) continue;
577
755
  if (!entry.resolved) {
@@ -580,110 +758,154 @@ var TileManager = class {
580
758
  state.entries.delete(key);
581
759
  } else if (entry.scale === wantScale) state.entries.delete(key);
582
760
  }
583
- const cx = visible.x + visible.width / 2;
584
- const cy = visible.y + visible.height / 2;
761
+ const centerX = visible.x + visible.width / 2;
762
+ const centerY = visible.y + visible.height / 2;
585
763
  const span = grid.tileSize / grid.scale;
586
- const orderedP0 = [...p0].sort((a, b) => {
587
- const da = (a.ix + .5) * span - cx;
588
- const db = (b.ix + .5) * span - cx;
589
- const ea = (a.iy + .5) * span - cy;
590
- const eb = (b.iy + .5) * span - cy;
591
- return da * da + ea * ea - (db * db + eb * eb);
592
- });
764
+ const distanceToCenter = (coord) => {
765
+ const dx = (coord.ix + .5) * span - centerX;
766
+ const dy = (coord.iy + .5) * span - centerY;
767
+ return dx * dx + dy * dy;
768
+ };
769
+ const orderedVisible = [...visibleCoords].sort((left, right) => distanceToCenter(left) - distanceToCenter(right));
593
770
  const kickoff = () => {
594
- if (this.ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, orderedP0)) this.ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, p1);
771
+ state.queue = {
772
+ coords: orderedVisible.every((coord) => {
773
+ const key = keyOf(coord);
774
+ return state.failedKeys.has(key) || state.entries.get(key)?.resolved === true;
775
+ }) ? [...orderedVisible, ...prefetchCoords] : orderedVisible,
776
+ ringFrom: orderedVisible.length,
777
+ next: 0,
778
+ page,
779
+ grid,
780
+ wantWidth,
781
+ layers,
782
+ epoch
783
+ };
784
+ this.pump();
595
785
  };
596
786
  if (levelChanged && options.tiles.settleMs > 0) {
597
- this.deps.debug?.(`arm settle pon=${pon} level=${wantWidth}`);
787
+ this.deps.debug?.(`arm settle page=${pageObjectNumber} level=${wantWidth}`);
788
+ state.queue = null;
598
789
  state.pendingLevel = wantWidth;
599
- if (state.settleTimer !== null) clearTimeout(state.settleTimer);
600
- state.settleTimer = setTimeout(() => {
601
- state.settleTimer = null;
602
- this.deps.debug?.(`settle fired pon=${pon} level=${wantWidth} current=${state.pendingLevel === wantWidth}`);
790
+ state.cancelSettle?.();
791
+ state.cancelSettle = this.deps.after(options.tiles.settleMs, () => {
792
+ state.cancelSettle = null;
793
+ this.deps.debug?.(`settle fired page=${pageObjectNumber} level=${wantWidth} current=${state.pendingLevel === wantWidth}`);
603
794
  if (state.pendingLevel === wantWidth) kickoff();
604
- }, options.tiles.settleMs);
795
+ });
605
796
  return;
606
797
  }
607
- if (state.settleTimer !== null && !levelChanged && !firstEngage) {
608
- clearTimeout(state.settleTimer);
609
- state.settleTimer = null;
798
+ if (state.cancelSettle !== null && !levelChanged && !firstEngage) {
799
+ state.cancelSettle();
800
+ state.cancelSettle = null;
610
801
  state.pendingLevel = null;
611
802
  }
612
803
  kickoff();
613
804
  }
614
- /** Start missing fetches; true when every coord is already resolved. */
615
- ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, coords) {
616
- let allReady = true;
617
- let started = 0;
618
- const bleedPt = this.deps.options.tiles.bleedPx / grid.scale;
619
- for (const coord of coords) {
620
- const key = this.tileKey(pon, wantWidth, coord, includeAnnotations, epoch);
621
- if (state.failedKeys.has(key)) continue;
622
- const existing = state.entries.get(key);
623
- if (existing) {
624
- if (!existing.resolved) allReady = false;
625
- continue;
805
+ /**
806
+ * Starts queued tiles while transit slots are free: the first on-screen
807
+ * tile of any view and page, in the order their queues were set, before
808
+ * any tile of a prefetch ring. Within one view of a page, its queue's order
809
+ * holds. Backpressure bounds raw rasters in transit (render + encode); each
810
+ * resolution hands its slot on through here, so no other pump is needed.
811
+ */
812
+ pump() {
813
+ const started = /* @__PURE__ */ new Map();
814
+ while (this.inFlight < MAX_IN_FLIGHT) {
815
+ let next = null;
816
+ for (const state of this.pages.values()) {
817
+ const queued = this.nextToStart(state);
818
+ if (!queued || next && queued.prefetch) continue;
819
+ next = {
820
+ state,
821
+ coord: queued.coord
822
+ };
823
+ if (!queued.prefetch) break;
626
824
  }
627
- allReady = false;
628
- if (this.inFlight >= MAX_IN_FLIGHT) continue;
629
- this.inFlight += 1;
630
- started += 1;
631
- const abort = new AbortController();
632
- const logical = tilePaintRect(grid, page, coord);
633
- const entry = {
634
- key,
635
- scale: grid.scale,
825
+ if (!next) break;
826
+ this.start(next.state, next.coord);
827
+ started.set(next.state, (started.get(next.state) ?? 0) + 1);
828
+ }
829
+ for (const [state, count] of started) this.deps.debug?.(`fetch page=${state.pageObjectNumber} level=${state.queue?.wantWidth} +${count} tiles`);
830
+ }
831
+ /** The first tile of the state's queue not started yet, nor failed at this level. */
832
+ nextToStart(state) {
833
+ const queue = state.queue;
834
+ if (!queue) return null;
835
+ for (; queue.next < queue.coords.length; queue.next += 1) {
836
+ const coord = queue.coords[queue.next];
837
+ const key = this.tileKey(state.pageObjectNumber, queue.wantWidth, coord, queue.layers, queue.epoch);
838
+ if (state.failedKeys.has(key) || state.entries.has(key)) continue;
839
+ return {
636
840
  coord,
637
- rect: logical,
638
- resolved: false,
639
- painted: false,
640
- charged: true,
641
- abort
841
+ prefetch: queue.next >= queue.ringFrom
642
842
  };
643
- state.entries.set(key, entry);
644
- this.deps.store.acquire(key, (signal) => this.deps.fetchTile(pon, toEngineRect(page, bleedPt > 0 ? bleedRect(logical, bleedPt, page) : logical), grid.scale, includeAnnotations, signal), abort.signal).then(() => {
645
- this.releaseSlot(entry);
646
- if (state.entries.get(key) !== entry) return;
843
+ }
844
+ return null;
845
+ }
846
+ /** Starts one queued tile's fetch, in a transit slot. */
847
+ start(state, coord) {
848
+ const { page, grid, wantWidth, layers, epoch } = state.queue;
849
+ const pageObjectNumber = state.pageObjectNumber;
850
+ const key = this.tileKey(pageObjectNumber, wantWidth, coord, layers, epoch);
851
+ const bleedPt = this.deps.getOptions().tiles.bleedPx / grid.scale;
852
+ this.inFlight += 1;
853
+ const abort = new AbortController();
854
+ const logical = tilePaintRect(grid, page, coord);
855
+ const entry = {
856
+ key,
857
+ scale: grid.scale,
858
+ coord,
859
+ rect: logical,
860
+ resolved: false,
861
+ painted: false,
862
+ charged: true,
863
+ abort
864
+ };
865
+ state.entries.set(key, entry);
866
+ state.planCache = null;
867
+ this.deps.store.acquire(key, (signal) => this.deps.fetchTile(state.view, pageObjectNumber, bleedPt > 0 ? bleedRect(logical, bleedPt, page) : logical, grid.scale, layers, signal), abort.signal).then(() => {
868
+ this.releaseSlot(entry);
869
+ if (state.entries.get(key) === entry) {
647
870
  entry.resolved = true;
648
871
  state.version += 1;
649
872
  state.planCache = null;
650
- this.deps.onAdvance(pon);
651
- }, (err) => {
652
- this.releaseSlot(entry);
653
- if (state.entries.get(key) !== entry) return;
873
+ this.deps.onAdvance(pageObjectNumber);
874
+ }
875
+ this.pump();
876
+ }, (error) => {
877
+ this.releaseSlot(entry);
878
+ if (state.entries.get(key) === entry) {
654
879
  state.entries.delete(key);
655
880
  if (!abort.signal.aborted) {
656
881
  state.failedKeys.add(key);
657
- this.deps.debug?.(`tile failed ${key}: ${String(err)}`);
882
+ this.deps.debug?.(`tile failed ${key}: ${String(error)}`);
658
883
  state.version += 1;
659
884
  state.planCache = null;
660
- this.deps.onAdvance(pon);
885
+ this.deps.onAdvance(pageObjectNumber);
661
886
  }
662
- });
663
- }
664
- if (started > 0) this.deps.debug?.(`fetch pon=${pon} level=${wantWidth} +${started} tiles`);
665
- return allReady;
887
+ }
888
+ this.pump();
889
+ });
666
890
  }
667
891
  /**
668
- * The release rule, evaluated as a PURE FUNCTION of the current painted
669
- * set: retained sources (any non-want level) whose VISIBLE footprint is
670
- * covered by PAINTED want-level tiles leave the paint list. Runs on every
892
+ * The release rule, evaluated as a pure function of the current painted
893
+ * set: retained sources (any non-want level) whose visible footprint is
894
+ * covered by painted want-level tiles leave the paint list. Runs on every
671
895
  * `plan()` and on every painted report — never only "against the tile
672
- * that just painted": release must not depend on paint ORDER (center-out
896
+ * that just painted": release must not depend on paint order (center-out
673
897
  * scheduling means the report that completes a retained tile's coverage
674
898
  * routinely lands far away from it), and re-evaluating from current state
675
899
  * also self-heals any painted-flag transition the reports raced past.
676
- * Coverage is index arithmetic over the WANT grid — generic over whatever
900
+ * Coverage is index arithmetic over the want grid — generic over whatever
677
901
  * mix of retained scales exists (exact levels included).
678
902
  */
679
- releaseCovered(pon, state, page, includeAnnotations) {
903
+ releaseCovered(pageObjectNumber, state, page, layers) {
680
904
  const { wantScale, wantWidth } = state;
681
905
  if (wantScale === null || wantWidth === null) return false;
682
906
  const grid = tileGrid(page, wantScale, this.strategy().tileSize);
683
- const paintedAt = (c) => {
684
- const key = this.tileKey(pon, wantWidth, c, includeAnnotations, state.epoch);
685
- return state.entries.get(key)?.painted === true;
686
- };
907
+ const wantKeyOf = (coord) => this.tileKey(pageObjectNumber, wantWidth, coord, layers, state.epoch);
908
+ const paintedAt = (coord) => state.entries.get(wantKeyOf(coord))?.painted === true;
687
909
  let released = false;
688
910
  for (const [key, entry] of state.entries) {
689
911
  if (entry.scale === wantScale || !entry.resolved) continue;
@@ -691,13 +913,12 @@ var TileManager = class {
691
913
  if (regionCovered(grid, page, region, paintedAt)) {
692
914
  state.entries.delete(key);
693
915
  released = true;
694
- } else if (this.deps.debug) {
695
- const missing = tilesInRect(grid, page, region).filter((c) => !paintedAt(c));
696
- const detail = missing.slice(0, 3).map((c) => {
697
- const k = this.tileKey(pon, wantWidth, c, includeAnnotations, state.epoch);
698
- const e = state.entries.get(k);
699
- const st = !e ? "NO-ENTRY" : !e.resolved ? "PENDING" : "RESOLVED-unpainted";
700
- return `${c.ix},${c.iy}=${st}`;
916
+ } else if (this.deps.debug && this.deps.getOptions().debug) {
917
+ const missing = tilesInRect(grid, page, region).filter((coord) => !paintedAt(coord));
918
+ const detail = missing.slice(0, 3).map((coord) => {
919
+ const missingEntry = state.entries.get(wantKeyOf(coord));
920
+ const status = !missingEntry ? "NO-ENTRY" : !missingEntry.resolved ? "PENDING" : "RESOLVED-unpainted";
921
+ return `${coord.ix},${coord.iy}=${status}`;
701
922
  });
702
923
  this.deps.debug(`retained ${key} blocked by: ${detail.join(" ")} (${missing.length} missing)`);
703
924
  }
@@ -705,23 +926,22 @@ var TileManager = class {
705
926
  return released;
706
927
  }
707
928
  /** Test/diagnostic introspection: bookkeeping size for one page. */
708
- stats(view, pon) {
929
+ stats(view, pageObjectNumber) {
709
930
  return {
710
- entries: this.pages.get(stateKey(view, pon))?.entries.size ?? 0,
931
+ entries: this.pages.get(stateKey(view, pageObjectNumber))?.entries.size ?? 0,
711
932
  inFlight: this.inFlight
712
933
  };
713
934
  }
714
- /** Idempotent transit-slot release — the ONE place inFlight decrements. */
935
+ /** Idempotent transit-slot release — the one place inFlight decrements. */
715
936
  releaseSlot(entry) {
716
937
  if (!entry.charged) return;
717
938
  entry.charged = false;
718
939
  this.inFlight -= 1;
719
940
  }
720
941
  abortAll(state) {
721
- if (state.settleTimer !== null) {
722
- clearTimeout(state.settleTimer);
723
- state.settleTimer = null;
724
- }
942
+ state.queue = null;
943
+ state.cancelSettle?.();
944
+ state.cancelSettle = null;
725
945
  for (const entry of state.entries.values()) if (!entry.resolved) {
726
946
  entry.abort?.abort();
727
947
  this.releaseSlot(entry);
@@ -729,117 +949,306 @@ var TileManager = class {
729
949
  }
730
950
  /** Level identity is the integer device width across the page — integer
731
951
  * and stable for pyramid rungs and exact levels alike. The key also
732
- * carries everything that determines the fetched BITMAP for a coord
952
+ * carries everything that determines the fetched bitmap for a coord
733
953
  * (tile size, bleed, encode format): a raster's identity must include
734
954
  * its geometry, or a cached handle from one configuration could be
735
955
  * stretched into another's rect. Format genuinely varies at runtime —
736
956
  * the cloud policy arrives async after open and can change the resolved
737
957
  * format under a live store. */
738
- tileKey(pon, levelWidth, c, includeAnnotations, epoch) {
739
- const { tiles } = this.deps.options;
958
+ tileKey(pageObjectNumber, levelWidth, coord, layers, epoch) {
959
+ const { tiles } = this.deps.getOptions();
740
960
  const format = this.strategy().format;
741
961
  const geometry = `g${tiles.size}.${tiles.bleedPx}${format ? `.${format}` : ""}`;
742
- return `t:${pon}|w${levelWidth}|${c.ix},${c.iy}|a${includeAnnotations ? 1 : 0}|e${epoch}|${geometry}`;
962
+ return `t:${pageObjectNumber}|w${levelWidth}|${coord.ix},${coord.iy}|${layersKeyOf(layers)}|e${epoch}|${geometry}`;
743
963
  }
744
964
  };
745
- const coordKey = (c) => `${c.ix},${c.iy}`;
746
- const rectKey = (r) => `${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}`;
747
- const annotationsOf = (key) => key.includes("|a1|");
965
+ const coordKey = (coord) => `${coord.ix},${coord.iy}`;
966
+ const rectKey = (rect) => `${Math.round(rect.x)},${Math.round(rect.y)},${Math.round(rect.width)},${Math.round(rect.height)}`;
967
+ /** What a tile key's picture draws, as {@link layersKeyOf} wrote it into the key. */
968
+ const layersOfKey = (key) => {
969
+ const drawn = /\|a([01])f([01])\|/.exec(key);
970
+ return {
971
+ includeAnnotations: drawn?.[1] === "1",
972
+ includeFormFields: drawn?.[2] === "1"
973
+ };
974
+ };
748
975
  //#endregion
749
- //#region src/capability.ts
976
+ //#region src/controller.ts
977
+ const RENDER_SCOPE = "doc.render";
978
+ const FORMS_SCOPE = "doc.forms.read";
979
+ const ANNOTATIONS_SCOPE = "doc.annotate.read";
980
+ /** Every answer `getLayerRights()` gives, one object each, so the same rights are the same object. */
981
+ const LAYER_RIGHTS = [false, true].map((annotations) => [false, true].map((formFields) => Object.freeze({
982
+ annotations,
983
+ formFields
984
+ })));
985
+ /** An abort reason the engine task accepts (signals carry `unknown`). */
986
+ const reasonOf = (signal) => typeof signal.reason === "string" ? signal.reason : void 0;
987
+ const DEFAULT_BATCH_CONCURRENCY = 4;
750
988
  /**
751
- * The render capability: the ONE place STRATEGY (plugin options — what the
752
- * viewer spends) composes with POLICY (the engine fact — what the deployment
753
- * serves). The engine never snaps; framework layers never see either;
754
- * everything between — conforming demand to the resolved render points,
755
- * collapsing same-key asks in the raster store, exposing stable source
756
- * keys — happens here.
989
+ * The render controller: the one place strategy (config: what the viewer
990
+ * spends) composes with policy (the engine fact: what the deployment
991
+ * serves). The engine never snaps and layers never see either; everything
992
+ * between happens here: conforming demand to the resolved render points,
993
+ * collapsing same-key asks in the raster store, exposing stable source keys,
994
+ * and turning a view's demand into a retention-safe tile plan.
995
+ *
996
+ * Two doors for rasters: `renderPage` (public, exact size) and
997
+ * `renderSource` (host, conformed). Both share the raster store, so a
998
+ * developer's thumbnail and the rail's base plane never render twice.
999
+ *
1000
+ * Every render it asks for is the picture of a page itself, so it asks the
1001
+ * engine with `priority: 'high'`, naming the view it's for when it knows it:
1002
+ * the engine runs it before anything else about the same place (annotation
1003
+ * appearances, form fields, text geometry, links).
1004
+ *
1005
+ * Two doors for staleness: confirmed document events (see `connect`) and
1006
+ * the `invalidate` verb. Both go through `publishInvalidation`, the one
1007
+ * place the ledger bumps and `onInvalidated` fires.
1008
+ *
1009
+ * The tile manager and the per-view plans live outside state (they hold
1010
+ * live handles and abort controllers); a re-plan wakes readers with
1011
+ * `ctx.notify()`.
757
1012
  */
758
- function createRenderCapability(ctx, options = {}) {
759
- const resolved = resolveRenderOptions(options);
760
- const canRender = () => ctx.doc?.security.allows("doc.render") ?? false;
1013
+ function createRenderController(ctx) {
1014
+ const settings = ctx.settings();
1015
+ const resolvedOptions = memo(() => [settings.get()], (current) => resolveRenderOptions(current));
761
1016
  const store = new RasterStore();
762
- const policy = () => ctx.document()?.renderPolicy ?? CONTINUOUS_RENDER_POLICY;
763
- let strategyMemo = null;
764
- const strategy = () => {
765
- const p = policy();
766
- if (strategyMemo?.policy !== p) {
767
- strategyMemo = {
768
- policy: p,
769
- strategy: resolveStrategy(p, resolved)
770
- };
771
- warnDroppedFormat(strategyMemo.strategy);
772
- }
773
- return strategyMemo.strategy;
1017
+ const appearances = new AppearanceStore();
1018
+ const recentAppearances = new RecentPictures();
1019
+ const invalidated = ctx.events.source();
1020
+ const renderCompleted = ctx.events.source();
1021
+ const renderFailed = ctx.events.source();
1022
+ const canRender = () => ctx.allows(RENDER_SCOPE);
1023
+ const rights = () => LAYER_RIGHTS[ctx.allows("doc.annotate.read") ? 1 : 0][ctx.allows("doc.forms.read") ? 1 : 0];
1024
+ const layersOf = (options) => {
1025
+ const may = rights();
1026
+ return resolvePageLayers({
1027
+ ...options.includeAnnotations !== void 0 && (may.annotations || !options.includeAnnotations) ? { includeAnnotations: options.includeAnnotations } : {},
1028
+ ...options.includeFormFields !== void 0 && (may.formFields || !options.includeFormFields) ? { includeFormFields: options.includeFormFields } : {}
1029
+ }, may);
1030
+ };
1031
+ const pictureLayers = (options, operation) => {
1032
+ if (options.includeAnnotations === true) ctx.assertAllowed("doc.annotate.read", operation);
1033
+ if (options.includeFormFields === true) ctx.assertAllowed(FORMS_SCOPE, operation);
1034
+ return layersOf(options);
774
1035
  };
775
1036
  let warnedFormat = false;
776
- const warnDroppedFormat = (s) => {
777
- if (warnedFormat || resolved.format === void 0 || s.format === resolved.format) return;
1037
+ const warnDroppedFormat = (strategy, requested) => {
1038
+ if (warnedFormat || requested === void 0 || strategy.format === requested) return;
778
1039
  warnedFormat = true;
779
- console.warn(`[render] format '${resolved.format}' is not in the deployment's formats — using '${s.format}'.`);
1040
+ console.warn(`[render] format '${requested}' is not in the deployment's formats — using '${strategy.format}'.`);
780
1041
  };
781
1042
  let warnedBudget = false;
782
1043
  const warnPastBudgetNoTiles = (demandWidth, supplied) => {
783
- if (warnedBudget || resolved.tiles.enabled || demandWidth <= supplied * 2) return;
1044
+ if (warnedBudget || resolvedOptions().tiles.enabled || demandWidth <= supplied * 2) return;
784
1045
  warnedBudget = true;
785
1046
  console.warn(`[render] demand is ${(demandWidth / supplied).toFixed(1)}× over the base budget and the tile plane is disabled (tiles: false) — the page rests blurry. Raise fullPage.maxWidth or re-enable tiles.`);
786
1047
  };
787
- const pageOf = (pon) => (ctx.document()?.pages ?? []).find((p) => p.pageObjectNumber === pon);
788
- const pageWidthOf = (pon) => {
789
- const page = pageOf(pon);
790
- if (!page) throw new Error(`render: unknown page object number ${pon}`);
791
- return page.size.width;
1048
+ const renderPolicy = () => ctx.document()?.renderPolicy ?? CONTINUOUS_RENDER_POLICY;
1049
+ const currentStrategy = memo(() => [renderPolicy(), resolvedOptions()], (policy, options) => {
1050
+ const strategy = resolveStrategy(policy, options);
1051
+ warnDroppedFormat(strategy, options.format);
1052
+ return strategy;
1053
+ });
1054
+ const pageWidthOf = (page) => {
1055
+ const info = ctx.getPage(page);
1056
+ if (!info) ctx.assertPageRef(page);
1057
+ return info.size.width;
792
1058
  };
793
- const conformViewport = (pon, scale) => {
794
- const s = strategy();
795
- const demandWidth = scale * pageWidthOf(pon);
796
- const width = baseAskWidth(s, demandWidth);
1059
+ const allPageObjectNumbers = () => (ctx.document()?.pages ?? []).map((info) => info.ref.objectNumber);
1060
+ const conformViewport = (page, scale) => {
1061
+ const demandWidth = scale * pageWidthOf(page);
1062
+ const width = baseAskWidth(currentStrategy(), demandWidth);
797
1063
  warnPastBudgetNoTiles(demandWidth, width);
798
1064
  return {
799
1065
  kind: "width",
800
1066
  width
801
1067
  };
802
1068
  };
803
- const epochOf = (pon, includeAnnotations) => {
804
- const s = ctx.getState();
805
- const content = s.contentEpochs[pon] ?? 0;
806
- if (!includeAnnotations) return content;
807
- return content + (s.annotatedEpochs[pon] ?? 0);
808
- };
809
- const baseKey = (pon, viewport, annotations) => {
810
- const format = strategy().format;
811
- return `${pon}|w${viewport.kind === "width" ? viewport.width : 0}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}${format ? `|f${format}` : ""}`;
812
- };
1069
+ const epochOf = (pageObjectNumber, layers) => renderEpochOf(ctx.state.get(), pageObjectNumber, layers);
1070
+ const rasterKey = (pageObjectNumber, width, layers, format, quality) => `${pageObjectNumber}|w${width}|${layersKeyOf(layers)}|e${epochOf(pageObjectNumber, layers)}${format ? `|f${format}` : ""}${quality !== void 0 ? `|q${quality}` : ""}`;
1071
+ function acquireRaster(page, key, request, signal) {
1072
+ return store.acquire(key, (storeSignal) => {
1073
+ const task = ctx.doc.with({
1074
+ priority: "high",
1075
+ ...request.view !== void 0 ? { view: request.view } : {}
1076
+ }).page(page).render.image({
1077
+ viewport: request.viewport,
1078
+ ...request.layers,
1079
+ ...request.format !== void 0 ? { format: request.format } : {},
1080
+ ...request.quality !== void 0 ? { quality: request.quality } : {}
1081
+ });
1082
+ if (storeSignal.aborted) task.abort(reasonOf(storeSignal));
1083
+ else storeSignal.addEventListener("abort", () => task.abort(reasonOf(storeSignal)), { once: true });
1084
+ return task;
1085
+ }, signal).then((image) => {
1086
+ renderCompleted.emit({
1087
+ page,
1088
+ key
1089
+ });
1090
+ return image;
1091
+ }, (error) => {
1092
+ const mapped = toPluginError("render", error);
1093
+ if (!isPluginError(mapped, "operation-cancelled") && !isPluginError(mapped, "instance-closed")) renderFailed.emit({
1094
+ page,
1095
+ key,
1096
+ error: toPluginErrorInfo(mapped)
1097
+ });
1098
+ throw mapped;
1099
+ });
1100
+ }
1101
+ /** The host door: conformed to the render points. Async so refusals reject. */
1102
+ async function renderSource(page, { scale, view, signal, ...options }) {
1103
+ ctx.assertAllowed(RENDER_SCOPE, "render.renderSource");
1104
+ const layers = pictureLayers(options, "render.renderSource");
1105
+ const viewport = conformViewport(page, scale);
1106
+ const strategy = currentStrategy();
1107
+ return acquireRaster(page, rasterKey(page.objectNumber, viewport.kind === "width" ? viewport.width : 0, layers, strategy.format, void 0), {
1108
+ viewport,
1109
+ layers,
1110
+ format: strategy.format,
1111
+ quality: strategy.quality,
1112
+ view
1113
+ }, signal);
1114
+ }
1115
+ /** The public door: the requested size, exactly. Async so refusals reject. */
1116
+ async function renderPage(pageArgument, options = {}) {
1117
+ ctx.assertAllowed(RENDER_SCOPE, "render.renderPage");
1118
+ const { ref: page, size } = ctx.pageOf(pageArgument);
1119
+ const width = Math.max(1, Math.round(options.width ?? (options.scale ?? 1) * size.width));
1120
+ const layers = pictureLayers(options, "render.renderPage");
1121
+ const strategy = currentStrategy();
1122
+ const format = options.format ?? strategy.format;
1123
+ const quality = options.quality ?? strategy.quality;
1124
+ const key = rasterKey(page.objectNumber, width, layers, format, options.quality);
1125
+ return ctx.cancellable(options.signal, acquireRaster(page, key, {
1126
+ viewport: {
1127
+ kind: "width",
1128
+ width
1129
+ },
1130
+ layers,
1131
+ format,
1132
+ quality,
1133
+ view: void 0
1134
+ }, options.signal));
1135
+ }
1136
+ async function renderPages(pages, options = {}) {
1137
+ ctx.assertAllowed(RENDER_SCOPE, "render.renderPages");
1138
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_BATCH_CONCURRENCY);
1139
+ const applied = [];
1140
+ const failed = [];
1141
+ const queue = pages.map((page, index) => ({
1142
+ page,
1143
+ index
1144
+ }));
1145
+ const worker = async () => {
1146
+ for (let next = queue.shift(); next; next = queue.shift()) {
1147
+ const { page, index } = next;
1148
+ try {
1149
+ const image = await renderPage(page, options);
1150
+ applied.push({
1151
+ index,
1152
+ render: {
1153
+ page: ctx.pageOf(page).ref,
1154
+ image
1155
+ }
1156
+ });
1157
+ } catch (error) {
1158
+ failed.push({
1159
+ index,
1160
+ ref: page,
1161
+ error: toPluginErrorInfo(toPluginError("render", error))
1162
+ });
1163
+ }
1164
+ }
1165
+ };
1166
+ const batch = Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
1167
+ await ctx.cancellable(options.signal, batch);
1168
+ const byInput = (left, right) => left.index - right.index;
1169
+ return {
1170
+ applied: applied.sort(byInput).map(({ render }) => render),
1171
+ skipped: [],
1172
+ failed: failed.sort(byInput).map(({ ref, error }) => ({
1173
+ ref,
1174
+ error
1175
+ }))
1176
+ };
1177
+ }
1178
+ async function renderAppearances(page, { family, scale, items, signal }) {
1179
+ ctx.assertAllowed(family === "widgets" ? FORMS_SCOPE : ANNOTATIONS_SCOPE, "render.renderAppearances");
1180
+ const pageObjectNumber = ctx.pageOf(page).ref.objectNumber;
1181
+ const versions = new Map(items.map((item) => [annotationKey(item.ref), item.version]));
1182
+ const asked = [...versions].map(([item, version]) => `${item}@${version}`).sort();
1183
+ const group = `${family}|${pageObjectNumber}`;
1184
+ const pictures = appearances.acquire(group, `${group}|s${scale}|${asked.join(",")}`, async (fetchSignal) => {
1185
+ const pageHandle = ctx.doc.page(toPageRef(pageObjectNumber));
1186
+ const options = {
1187
+ viewport: {
1188
+ kind: "scale",
1189
+ scale
1190
+ },
1191
+ modes: ["normal"]
1192
+ };
1193
+ const task = family === "widgets" ? pageHandle.forms.renderAppearances(options) : pageHandle.annotations.renderAppearances(options);
1194
+ if (fetchSignal.aborted) task.abort(reasonOf(fetchSignal));
1195
+ else fetchSignal.addEventListener("abort", () => task.abort(reasonOf(fetchSignal)), { once: true });
1196
+ const drawn = (await task).appearances.flatMap((picture) => {
1197
+ const version = versions.get(annotationKey(picture.ref));
1198
+ return version === void 0 ? [] : [{
1199
+ ...picture,
1200
+ version
1201
+ }];
1202
+ });
1203
+ recentAppearances.remember(family, drawn, (picture) => annotationKey(picture.ref));
1204
+ return drawn;
1205
+ }, signal);
1206
+ return ctx.cancellable(signal, pictures.catch((error) => {
1207
+ throw toPluginError("render", error);
1208
+ }));
1209
+ }
1210
+ function publishInvalidation(pageObjectNumbers, scope, origin) {
1211
+ if (pageObjectNumbers.length === 0) return;
1212
+ ctx.state.update(invalidatePages, pageObjectNumbers, scope);
1213
+ for (const pageObjectNumber of pageObjectNumbers) replanPage(pageObjectNumber);
1214
+ invalidated.emit({
1215
+ pages: pageObjectNumbers.map((pageObjectNumber) => toPageRef(pageObjectNumber)),
1216
+ scope,
1217
+ origin
1218
+ });
1219
+ }
1220
+ function invalidate({ pages, scope = "content" } = {}) {
1221
+ const named = pages?.flatMap((page) => ctx.getPage(page)?.ref.objectNumber ?? []);
1222
+ publishInvalidation(named ?? allPageObjectNumbers(), scope, null);
1223
+ }
813
1224
  const pendingAdvance = /* @__PURE__ */ new Set();
814
1225
  let advanceScheduled = false;
815
- const raf = typeof requestAnimationFrame === "function" ? (cb) => requestAnimationFrame(() => cb()) : (cb) => void setTimeout(cb, 16);
816
- const wake = (pon) => {
817
- pendingAdvance.add(pon);
1226
+ const wake = (pageObjectNumber) => {
1227
+ pendingAdvance.add(pageObjectNumber);
818
1228
  if (advanceScheduled) return;
819
1229
  advanceScheduled = true;
820
- raf(() => {
1230
+ ctx.clock.nextFrame(() => {
821
1231
  advanceScheduled = false;
822
- const pons = [...pendingAdvance];
1232
+ const advanced = [...pendingAdvance];
823
1233
  pendingAdvance.clear();
824
- for (const p of pons) ctx.dispatch({
825
- type: "PAINT_ADVANCED",
826
- pon: p
827
- });
1234
+ for (const advancedPage of advanced) replanPage(advancedPage);
828
1235
  });
829
1236
  };
830
- const viewTiles = /* @__PURE__ */ new Map();
831
1237
  const tiles = new TileManager({
832
1238
  store,
833
- options: resolved,
834
- getPolicy: policy,
835
- getPageSize: (pon) => pageOf(pon)?.size,
1239
+ getOptions: resolvedOptions,
1240
+ getPolicy: renderPolicy,
1241
+ getPageSize: (pageObjectNumber) => ctx.getPage(toPageRef(pageObjectNumber))?.size,
836
1242
  getEpoch: epochOf,
837
- fetchTile: (pon, rect, scale, includeAnnotations, signal) => {
838
- const doc = ctx.doc;
839
- if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
840
- if (!canRender()) return Promise.reject(new PermissionDenied("doc.render", "render.tile"));
841
- const s = strategy();
842
- const task = doc.page(pon).render.image({
1243
+ after: ctx.clock.after,
1244
+ fetchTile: async (view, pageObjectNumber, rect, scale, layers, signal) => {
1245
+ ctx.assertAllowed(RENDER_SCOPE, "render.tile");
1246
+ const page = toPageRef(pageObjectNumber);
1247
+ const strategy = currentStrategy();
1248
+ const task = ctx.doc.with({
1249
+ priority: "high",
1250
+ view
1251
+ }).page(page).render.image({
843
1252
  target: {
844
1253
  kind: "rect",
845
1254
  rect
@@ -848,183 +1257,148 @@ function createRenderCapability(ctx, options = {}) {
848
1257
  kind: "scale",
849
1258
  scale
850
1259
  },
851
- includeAnnotations,
852
- ...s.format !== void 0 ? { format: s.format } : {},
853
- ...s.quality !== void 0 ? { quality: s.quality } : {}
1260
+ ...layers,
1261
+ ...strategy.format !== void 0 ? { format: strategy.format } : {},
1262
+ ...strategy.quality !== void 0 ? { quality: strategy.quality } : {}
854
1263
  });
855
- if (signal.aborted) task.abort(signal.reason);
856
- else signal.addEventListener("abort", () => task.abort(signal.reason), { once: true });
1264
+ if (signal.aborted) task.abort(reasonOf(signal));
1265
+ else signal.addEventListener("abort", () => task.abort(reasonOf(signal)), { once: true });
857
1266
  return task;
858
1267
  },
859
1268
  onAdvance: wake,
860
- ...resolved.debug ? { debug: (msg) => console.debug(`[render] ${msg}`) } : {}
1269
+ debug: (message) => {
1270
+ if (resolvedOptions().debug) console.debug(`[render] ${message}`);
1271
+ }
861
1272
  });
862
- return {
863
- canRender,
864
- renderPage(pon, { scale, includeAnnotations, signal }) {
865
- const doc = ctx.doc;
866
- if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
867
- if (!canRender()) return Promise.reject(new PermissionDenied("doc.render", "render.renderPage"));
868
- const annotations = includeAnnotations ?? true;
869
- const viewport = conformViewport(pon, scale);
870
- const key = baseKey(pon, viewport, annotations);
871
- return store.acquire(key, (storeSignal) => {
872
- const s = strategy();
873
- const task = doc.page(pon).render.image({
874
- viewport,
875
- includeAnnotations: annotations,
876
- ...s.format !== void 0 ? { format: s.format } : {},
877
- ...s.quality !== void 0 ? { quality: s.quality } : {}
878
- });
879
- if (storeSignal.aborted) task.abort(storeSignal.reason);
880
- else storeSignal.addEventListener("abort", () => task.abort(storeSignal.reason), { once: true });
881
- return task;
882
- }, signal);
883
- },
884
- renderSourceKey(pon, { scale, includeAnnotations }) {
885
- const annotations = includeAnnotations ?? true;
886
- return baseKey(pon, conformViewport(pon, scale), annotations);
887
- },
888
- conformViewport,
889
- paintSettings() {
890
- return {
891
- fadeMs: resolved.tiles.fadeMs,
892
- tiles: resolved.tiles.enabled
893
- };
894
- },
895
- renderPolicy: policy,
896
- tilesFor(view) {
897
- let handle = viewTiles.get(view);
898
- if (!handle) {
899
- handle = {
900
- plan: (pon, demand, opts) => tiles.plan(view, pon, demand, opts?.includeAnnotations ?? true),
901
- painted: (pon, key) => tiles.sourcePainted(view, pon, key),
902
- unpainted: (pon, key) => tiles.sourceUnpainted(view, pon, key),
903
- release: (pon) => tiles.releasePage(view, pon)
904
- };
905
- viewTiles.set(view, handle);
1273
+ /** One handle per view id, reference-counted; demands and plans per page. */
1274
+ const views = /* @__PURE__ */ new Map();
1275
+ /** Re-plan a page for every view that wants it (schedules the next want
1276
+ * set), and wake readers once when any plan changed. */
1277
+ function replanPage(pageObjectNumber) {
1278
+ let changed = false;
1279
+ for (const [viewId, view] of views) {
1280
+ const entry = view.pages.get(pageObjectNumber);
1281
+ if (!entry) continue;
1282
+ const plan = tiles.plan(viewId, pageObjectNumber, entry.demand, entry.layers);
1283
+ if (plan !== entry.plan) {
1284
+ entry.plan = plan;
1285
+ changed = true;
906
1286
  }
907
- return handle;
908
- },
909
- renderEpoch(pon, includeAnnotations = true) {
910
- return epochOf(pon, includeAnnotations);
911
- },
912
- invalidate({ pons, scope = "content" } = {}) {
913
- const target = pons ?? (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber);
914
- if (target.length) ctx.dispatch({
915
- type: "INVALIDATE",
916
- scope,
917
- pons: target
918
- });
919
1287
  }
920
- };
921
- }
922
- //#endregion
923
- //#region src/effects.ts
924
- /**
925
- * Which pages' ANNOTATED raster a confirmed mutation repaints — the whole
926
- * event→scope map. Everything the engine emits today is appearance-scoped;
927
- * when it grows content-mutation events (redaction.applied, content.edited),
928
- * they get a case here dispatching scope 'content'. Over-invalidation is
929
- * acceptable (a z-order move that changes nothing repaints one thumb);
930
- * under-invalidation is the bug.
931
- */
932
- function annotatedPons(event, allPons) {
933
- switch (event.type) {
934
- case "annotation.created":
935
- case "annotation.updated":
936
- case "annotation.deleted":
937
- case "annotation.moved": return [event.pageObjectNumber];
938
- case "form.valueChanged":
939
- case "form.effectsApplied": return event.changedWidgets.map((w) => w.pageObjectNumber);
940
- case "form.fieldDeleted": return event.removedWidgets.map((w) => w.pageObjectNumber);
941
- case "form.fieldCreated":
942
- case "form.fieldUpdated":
943
- case "form.widgetAttached":
944
- case "form.widgetDetached": return event.field.widgets.map((w) => w.pageObjectNumber);
945
- case "form.imported":
946
- case "form.repaired": return allPons();
947
- default: return [];
1288
+ if (changed) ctx.notify();
948
1289
  }
949
- }
950
- function registerRenderEffects(ctx) {
951
- const doc = ctx.doc;
952
- if (!doc) return;
953
- const allPons = () => (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber);
954
- const unsubscribe = doc.events.subscribe((event) => {
955
- if (event.type === "redaction.applied") {
956
- const pons = event.results.filter((r) => r.status === "applied").map((r) => r.pageObjectNumber);
957
- if (pons.length) ctx.dispatch({
958
- type: "INVALIDATE",
959
- scope: "content",
960
- pons
961
- });
962
- return;
1290
+ function createViewDemand(viewId) {
1291
+ const existing = views.get(viewId);
1292
+ if (existing) {
1293
+ existing.refs += 1;
1294
+ return existing.handle;
963
1295
  }
964
- const pons = annotatedPons(event, allPons);
965
- if (pons.length) ctx.dispatch({
966
- type: "INVALIDATE",
967
- scope: "annotations",
968
- pons
1296
+ const pages = /* @__PURE__ */ new Map();
1297
+ const handle = {
1298
+ setDemand: (page, demand, options) => {
1299
+ const pageObjectNumber = page.objectNumber;
1300
+ const layers = layersOf(options ?? {});
1301
+ const previous = pages.get(pageObjectNumber);
1302
+ const plan = tiles.plan(viewId, pageObjectNumber, demand, layers);
1303
+ pages.set(pageObjectNumber, {
1304
+ demand,
1305
+ layers,
1306
+ plan
1307
+ });
1308
+ if (plan !== previous?.plan) ctx.notify();
1309
+ },
1310
+ getPlan: (page) => pages.get(page.objectNumber)?.plan ?? EMPTY_TILE_PLAN,
1311
+ markPainted: (page, key) => tiles.sourcePainted(viewId, page.objectNumber, key),
1312
+ markUnpainted: (page, key) => tiles.sourceUnpainted(viewId, page.objectNumber, key),
1313
+ release: (page) => {
1314
+ pages.delete(page.objectNumber);
1315
+ tiles.releasePage(viewId, page.objectNumber);
1316
+ },
1317
+ dispose: () => {
1318
+ const view = views.get(viewId);
1319
+ if (!view || view.refs === 0) return;
1320
+ view.refs -= 1;
1321
+ if (view.refs > 0) return;
1322
+ for (const pageObjectNumber of [...view.pages.keys()]) tiles.releasePage(viewId, pageObjectNumber);
1323
+ view.pages.clear();
1324
+ }
1325
+ };
1326
+ views.set(viewId, {
1327
+ handle,
1328
+ refs: 1,
1329
+ pages
969
1330
  });
1331
+ return handle;
1332
+ }
1333
+ const paintSettings = memo(() => [resolvedOptions().tiles.enabled], (enabled) => Object.freeze({ tiles: enabled }));
1334
+ ctx.listen(settings.api.onSettingsChanged, () => {
1335
+ const shown = /* @__PURE__ */ new Set();
1336
+ for (const view of views.values()) for (const page of view.pages.keys()) shown.add(page);
1337
+ for (const page of shown) replanPage(page);
970
1338
  });
971
- ctx.cleanup(unsubscribe);
972
- }
973
- //#endregion
974
- //#region src/reducer.ts
975
- const initialRenderState = () => ({
976
- contentEpochs: {},
977
- annotatedEpochs: {},
978
- paintVersions: {}
979
- });
980
- const bump = (epochs, pons) => {
981
- const next = { ...epochs };
982
- for (const pon of pons) next[pon] = (next[pon] ?? 0) + 1;
983
- return next;
984
- };
985
- /** Pure. Two transitions: bump the touched pages' ledger for the fact's
986
- * scope (one action per fact, so a batch result bumps each pon once — a
987
- * 'content' fact bumps ONLY the content ledger; annotated readers sum both,
988
- * which is how content invalidation reaches them too), and the tile
989
- * wake-up counter. The render POLICY is deliberately NOT here — it's a
990
- * document fact on `DocumentMeta`, materialized by the kernel at open. */
991
- const renderReducer = (state, a) => {
992
- if (a.type === "PAINT_ADVANCED") return {
993
- ...state,
994
- paintVersions: bump(state.paintVersions, [a.pon])
995
- };
996
- if (a.type !== "INVALIDATE" || a.pons.length === 0) return state;
997
- return a.scope === "content" ? {
998
- ...state,
999
- contentEpochs: bump(state.contentEpochs, a.pons)
1000
- } : {
1001
- ...state,
1002
- annotatedEpochs: bump(state.annotatedEpochs, a.pons)
1339
+ return {
1340
+ api: {
1341
+ ...settings.api,
1342
+ canRender,
1343
+ renderPage,
1344
+ renderThumbnail: (page, { maxWidth, ...options }) => renderPage(page, {
1345
+ width: maxWidth,
1346
+ ...options
1347
+ }),
1348
+ renderPages,
1349
+ getRenderPolicy: renderPolicy,
1350
+ getRenderEpoch: (page, layers = {}) => {
1351
+ const info = ctx.getPage(page);
1352
+ return info ? epochOf(info.ref.objectNumber, layersOf(layers)) : 0;
1353
+ },
1354
+ invalidate,
1355
+ onInvalidated: invalidated.on,
1356
+ renderSource,
1357
+ getSourceKey: (page, { scale, view: _view, ...options }) => {
1358
+ const viewport = conformViewport(page, scale);
1359
+ return rasterKey(page.objectNumber, viewport.kind === "width" ? viewport.width : 0, layersOf(options), currentStrategy().format, void 0);
1360
+ },
1361
+ conformViewport,
1362
+ getPaintSettings: paintSettings,
1363
+ getLayerRights: rights,
1364
+ createViewDemand,
1365
+ renderAppearances,
1366
+ keptAppearances: (family, item) => recentAppearances.of(family, annotationKey(item)),
1367
+ getAppearanceScale: (renderScale) => snapAppearanceScale(renderPolicy(), renderScale),
1368
+ onRenderCompleted: renderCompleted.on,
1369
+ onRenderFailed: renderFailed.on
1370
+ },
1371
+ connect() {
1372
+ ctx.listen(ctx.doc.events, (event) => {
1373
+ const change = pixelChangeOf(event, allPageObjectNumbers);
1374
+ if (!change) return;
1375
+ publishInvalidation(change.pages, change.scope, "origin" in event ? event.origin : null);
1376
+ });
1377
+ }
1003
1378
  };
1004
- };
1379
+ }
1005
1380
  //#endregion
1006
1381
  //#region src/render.plugin.ts
1007
1382
  /**
1008
- * Document-scoped. The ONE policy consumer in the client stack: the doc-bind
1009
- * effect resolves the engine's advertised render
1010
- * policy; `renderPage` conforms desired scales to it and collapses
1011
- * same-rung asks in the raster store; the tile manager turns host-supplied
1012
- * demand into a retention-safe paint plan over the SAME store. State is the
1013
- * per-page ledger — raster versions (two doors: the document event stream's
1014
- * built-in map and the `invalidate` verb), the policy latch, and the tile
1015
- * wake-up counter. Layers key on `renderSourceKey`/`tilePlan` and refetch
1016
- * exactly when those change.
1383
+ * Document-scoped page rasters. The only render-policy consumer in the
1384
+ * client stack: the kernel materializes the engine's advertised render
1385
+ * policy on the document, the controller conforms desired scales to it and
1386
+ * collapses same-key asks in its raster store, and the tile manager turns
1387
+ * host-supplied demand into a retention-safe paint plan over the same store.
1388
+ * State is the per-page raster-version ledger.
1017
1389
  */
1018
- const renderPlugin = (options = {}) => definePlugin({
1390
+ const renderPlugin = (config) => definePlugin({
1019
1391
  id: "render",
1020
1392
  scope: "document",
1021
- token: RenderToken,
1022
- initialState: initialRenderState,
1023
- reduce: renderReducer,
1024
- capability: (ctx) => createRenderCapability(ctx, options),
1025
- effects: registerRenderEffects
1393
+ token: RenderToken$1,
1394
+ state: initialRenderState,
1395
+ settings: {
1396
+ defaults: RENDER_DEFAULTS,
1397
+ registered: config
1398
+ },
1399
+ create: createRenderController
1026
1400
  });
1027
1401
  //#endregion
1028
- export { RenderToken, renderPlugin };
1402
+ export { RENDER_DEFAULTS, RenderToken, renderPlugin, samePageViewDemand };
1029
1403
 
1030
1404
  //# sourceMappingURL=index.js.map