@embedpdf/plugin-render 3.0.0-next.0 → 3.0.0-next.10

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,4 +1,4 @@
1
- import { CONTINUOUS_RENDER_POLICY, createCapabilityToken, definePlugin, snapFullPageViewport } from "@embedpdf/core";
1
+ import { CONTINUOUS_RENDER_POLICY, PermissionDenied, createCapabilityToken, definePlugin } from "@embedpdf/core";
2
2
  import { intersectRects } from "@embedpdf/core-geometry";
3
3
  //#region src/paint-plan.ts
4
4
  const EMPTY_TILE_PLAN = {
@@ -7,36 +7,61 @@ const EMPTY_TILE_PLAN = {
7
7
  fetching: [],
8
8
  stamp: "empty"
9
9
  };
10
- function resolveTiling(config) {
10
+ /** The ×2 client pyramid used for lattice deployments without a tiles block. */
11
+ const DEFAULT_TILE_PYRAMID = [
12
+ 1,
13
+ 2,
14
+ 4,
15
+ 8,
16
+ 16,
17
+ 32
18
+ ];
19
+ function resolveRenderOptions(options) {
20
+ const tiles = options.tiles === false ? void 0 : options.tiles;
11
21
  return {
12
- tileSize: config?.tileSize ?? 512,
13
- scales: config?.scales ?? [
14
- 1,
15
- 2,
16
- 4,
17
- 8,
18
- 16,
19
- 32
20
- ],
21
- engageAt: config?.engageAt ?? 1.25,
22
- prefetchMargin: config?.prefetch?.margin ?? .5,
23
- velocityBias: config?.prefetch?.velocityBias ?? true,
24
- settleMs: config?.settleMs ?? 150
22
+ fullPage: {
23
+ maxWidth: options.fullPage?.maxWidth ?? 640,
24
+ maxWidthExplicit: options.fullPage?.maxWidth !== void 0,
25
+ quantize: options.fullPage?.quantize ?? "exact"
26
+ },
27
+ tiles: {
28
+ enabled: options.tiles !== false,
29
+ size: tiles?.size ?? 512,
30
+ quantize: tiles?.quantize ?? "exact",
31
+ maxScale: tiles?.maxScale ?? 128,
32
+ bleedPx: tiles?.bleed ?? 1,
33
+ engageAt: tiles?.engageAt,
34
+ prefetchMargin: tiles?.prefetch?.margin ?? .5,
35
+ velocityBias: tiles?.prefetch?.velocityBias ?? true,
36
+ settleMs: tiles?.settleMs ?? 150,
37
+ fadeMs: tiles?.fadeMs ?? 0,
38
+ fallbackPyramid: DEFAULT_TILE_PYRAMID
39
+ },
40
+ format: options.format,
41
+ quality: options.quality,
42
+ debug: options.debug ?? false
25
43
  };
26
44
  }
27
45
  //#endregion
28
46
  //#region src/raster-store.ts
29
47
  /**
30
48
  * Client-side mirror of the server's one-door read-through: per-key
31
- * SINGLEFLIGHT + LRU over encoded page images.
49
+ * SINGLEFLIGHT + cost-budgeted LRU over encoded page images.
32
50
  *
33
51
  * The key is the raster's canonical identity (conformed viewport +
34
- * annotations flag + epoch — see `renderSourceKey`), so the lattice does the
35
- * caching work: every ask inside one rung is the same key, and rung
52
+ * annotations flag + epoch — see `renderSourceKey`), so the render points do
53
+ * the caching work: every ask at one point is the same key, and
36
54
  * re-crossings (zoom 1280 → 2560 → back) resolve instantly from the LRU
37
55
  * instead of re-rendering. Epoch bumps mint new keys; stale-epoch entries
38
56
  * age out through the LRU (they are never re-requested).
39
57
  *
58
+ * The budget is COST, approximately bytes: an entry costs its encoded byte
59
+ * length when the handle carries bytes (local), its pixel count as a
60
+ * decoded-order estimate otherwise (URL-backed cloud handles), and 1 when
61
+ * neither is known. Entry counts lie — a 512² webp tile and a BMP full page
62
+ * differ by 100× — and long deep-zoom sessions cycle hundreds of tile keys,
63
+ * so the budget is what actually bounds memory.
64
+ *
40
65
  * Consumer aborts are REFCOUNTED: a caller abandoning a fetch (camera moved,
41
66
  * layer unmounted) detaches without killing it for other consumers; the
42
67
  * underlying engine call aborts only when the LAST in-flight consumer leaves.
@@ -44,10 +69,11 @@ function resolveTiling(config) {
44
69
  * is a no-op by design.
45
70
  */
46
71
  var RasterStore = class {
47
- constructor(maxEntries = 48) {
48
- this.maxEntries = maxEntries;
72
+ constructor(maxCost = 128 * 1024 * 1024) {
73
+ this.maxCost = maxCost;
49
74
  this.entries = /* @__PURE__ */ new Map();
50
75
  this.tick = 0;
76
+ this.total = 0;
51
77
  }
52
78
  acquire(key, fetch, signal) {
53
79
  const existing = this.entries.get(key);
@@ -64,9 +90,12 @@ var RasterStore = class {
64
90
  };
65
91
  entry.promise = fetch(abort.signal).then((handle) => {
66
92
  entry.handle = handle;
93
+ entry.cost = costOf(handle);
94
+ this.total += entry.cost;
95
+ this.evict();
67
96
  return handle;
68
97
  }, (err) => {
69
- if (this.entries.get(key) === entry) this.entries.delete(key);
98
+ this.drop(key, entry);
70
99
  throw err;
71
100
  });
72
101
  entry.promise.catch(() => {});
@@ -83,6 +112,10 @@ var RasterStore = class {
83
112
  get size() {
84
113
  return this.entries.size;
85
114
  }
115
+ /** Total cost (~bytes) of resolved entries — the budgeted quantity. */
116
+ get costUsed() {
117
+ return this.total;
118
+ }
86
119
  attach(key, entry, signal) {
87
120
  if (signal?.aborted) return Promise.reject(abortReason(signal));
88
121
  entry.refs += 1;
@@ -101,7 +134,7 @@ var RasterStore = class {
101
134
  entry.refs -= 1;
102
135
  if (entry.refs <= 0 && entry.handle === void 0) {
103
136
  entry.abort.abort(abortReason(signal));
104
- if (this.entries.get(key) === entry) this.entries.delete(key);
137
+ this.drop(key, entry);
105
138
  }
106
139
  reject(abortReason(signal));
107
140
  };
@@ -121,19 +154,72 @@ var RasterStore = class {
121
154
  });
122
155
  });
123
156
  }
157
+ /** Remove an entry, keeping the cost ledger honest. */
158
+ drop(key, entry) {
159
+ if (this.entries.get(key) !== entry) return;
160
+ this.entries.delete(key);
161
+ if (entry.cost !== void 0) this.total -= entry.cost;
162
+ }
124
163
  evict() {
125
- if (this.entries.size <= this.maxEntries) return;
164
+ if (this.total <= this.maxCost) return;
126
165
  const candidates = [...this.entries.entries()].filter(([, e]) => e.refs <= 0 && e.handle !== void 0).sort((a, b) => a[1].used - b[1].used);
127
- for (const [key] of candidates) {
128
- if (this.entries.size <= this.maxEntries) return;
129
- this.entries.delete(key);
166
+ for (const [key, entry] of candidates) {
167
+ if (this.total <= this.maxCost) return;
168
+ this.drop(key, entry);
130
169
  }
131
170
  }
132
171
  };
172
+ /** Approximate memory cost: encoded bytes when known, pixel count as a
173
+ * decoded-order estimate for URL handles, 1 when nothing is known. */
174
+ function costOf(handle) {
175
+ if (handle.source?.kind === "bytes") return handle.source.bytes.byteLength;
176
+ if (handle.width !== void 0 && handle.height !== void 0) return handle.width * handle.height;
177
+ return 1;
178
+ }
133
179
  function abortReason(signal) {
134
180
  return signal.reason ?? new DOMException("aborted", "AbortError");
135
181
  }
136
182
  //#endregion
183
+ //#region src/strategy.ts
184
+ const ascending = (values) => [...values].sort((a, b) => a - b);
185
+ /** Rung list capped by the budget — always keeps at least the smallest rung. */
186
+ const capWidths = (widths, maxWidth) => {
187
+ const sorted = ascending(widths);
188
+ const kept = sorted.filter((w) => w <= maxWidth);
189
+ return kept.length ? kept : sorted.slice(0, 1);
190
+ };
191
+ function resolveStrategy(policy, options) {
192
+ const lattice = policy.kind === "lattice";
193
+ const widths = lattice ? options.fullPage.maxWidthExplicit ? capWidths(policy.fullPage.widths, options.fullPage.maxWidth) : ascending(policy.fullPage.widths) : options.fullPage.quantize === "exact" ? null : capWidths(options.fullPage.quantize, options.fullPage.maxWidth);
194
+ const clientPyramid = options.tiles.quantize === "exact" ? null : ascending(options.tiles.quantize);
195
+ const pyramid = lattice ? policy.tiles?.scales ? ascending(policy.tiles.scales) : clientPyramid ?? options.tiles.fallbackPyramid : clientPyramid;
196
+ const tileSize = lattice && policy.tiles?.tileSizes.length ? policy.tiles.tileSizes.includes(options.tiles.size) ? options.tiles.size : policy.tiles.tileSizes[0] : options.tiles.size;
197
+ const engageAt = options.tiles.engageAt ?? (widths === null ? 1 : 1.25);
198
+ const requested = options.format;
199
+ const format = requested === void 0 ? void 0 : !lattice || policy.formats.includes(requested) ? requested : policy.formats[0];
200
+ return {
201
+ widths,
202
+ maxWidth: options.fullPage.maxWidth,
203
+ pyramid,
204
+ tileSize,
205
+ engageAt,
206
+ tileMaxScale: options.tiles.maxScale,
207
+ format,
208
+ quality: options.quality
209
+ };
210
+ }
211
+ /**
212
+ * The ONE base-sizing function — four callers: `renderPage`,
213
+ * `renderSourceKey`, `conformViewport`, and tile engagement. Exact mode
214
+ * returns the (settled) demand itself up to the budget; ladder mode snaps
215
+ * UP and caps at the top rung.
216
+ */
217
+ function baseAskWidth(strategy, demandWidth) {
218
+ const wanted = Math.max(1, Math.round(demandWidth));
219
+ if (strategy.widths === null) return Math.min(wanted, strategy.maxWidth);
220
+ return strategy.widths.find((w) => w >= wanted) ?? strategy.widths[strategy.widths.length - 1];
221
+ }
222
+ //#endregion
137
223
  //#region src/tiles.ts
138
224
  function tileGrid(page, scale, tileSize) {
139
225
  return {
@@ -174,14 +260,32 @@ function tilePaintRect(grid, page, c) {
174
260
  height: Math.min(span, page.height - y)
175
261
  };
176
262
  }
177
- /** Engine target rect: y-UP PDF user space (top > bottom). */
178
- function tileEngineRect(grid, page, c) {
179
- const paint = tilePaintRect(grid, page, c);
263
+ /** Engine target rect (y-UP PDF user space, top > bottom) for an arbitrary
264
+ * y-down page-point rect. */
265
+ function toEngineRect(page, rect) {
266
+ return {
267
+ left: rect.x,
268
+ right: rect.x + rect.width,
269
+ top: page.height - rect.y,
270
+ bottom: page.height - (rect.y + rect.height)
271
+ };
272
+ }
273
+ /**
274
+ * Expand a paint rect by `pt` page points per side, clamped to the page —
275
+ * the tile BLEED. Neighboring tiles rendered with bleed overlap by twice
276
+ * this amount, and the overlapping strips contain identical content (same
277
+ * page region, same scale), so every img edge composites over its
278
+ * neighbor's duplicated pixels instead of the backdrop: no AA hairlines,
279
+ * no bilinear edge smear, at any CSS stretch.
280
+ */
281
+ function bleedRect(rect, pt, page) {
282
+ const x = Math.max(0, rect.x - pt);
283
+ const y = Math.max(0, rect.y - pt);
180
284
  return {
181
- left: paint.x,
182
- right: paint.x + paint.width,
183
- top: page.height - paint.y,
184
- bottom: page.height - (paint.y + paint.height)
285
+ x,
286
+ y,
287
+ width: Math.min(page.width, rect.x + rect.width + pt) - x,
288
+ height: Math.min(page.height, rect.y + rect.height + pt) - y
185
289
  };
186
290
  }
187
291
  /**
@@ -247,56 +351,108 @@ function snapToPyramid(scales, needed) {
247
351
  * pixels available; quality per region only goes up until the want set
248
352
  * resolves. Mechanics:
249
353
  * - want vs paint: `plan()` schedules fetches for the want set (P0
250
- * visible first, P1 prefetch when P0 is fully resolved) and returns a
251
- * paint list drawn ONLY from resolved entries — current level AND
252
- * retained older generations.
253
- * - release-on-occlusion: when a want-level tile reports PAINTED (the
254
- * layer's img onload — the decode boundary), retained sources whose
255
- * visible footprint is covered by painted want tiles leave the paint
256
- * list. Their bytes stay in the RasterStore (demotion, not eviction) —
257
- * zoom-back re-promotes from cache.
354
+ * visible first — center-out — then P1 prefetch when P0 is fully
355
+ * resolved) and returns a paint list drawn ONLY from resolved entries —
356
+ * current level AND retained older generations.
357
+ * - release-on-occlusion: when a want-level tile reports PAINTED (after the
358
+ * layer's first presentation opportunity), retained sources whose
359
+ * visible footprint is
360
+ * covered by painted want tiles leave the paint list. Their bytes stay
361
+ * in the RasterStore (demotion, not eviction) — zoom-back re-promotes
362
+ * from cache.
258
363
  * - epoch exception: an invalidation bump means retained pixels are
259
364
  * WRONG — everything of the old epoch drops immediately.
260
365
  *
366
+ * Levels come from the resolved STRATEGY (policy ∧ options): a pyramid
367
+ * under a lattice (or the opt-in client ladder), the EXACT settled scale
368
+ * under exact mode — where the level identity is the demand's device width
369
+ * across the page, so keys stay integer and stable. The retention/coverage
370
+ * math is generic over any mix of retained scales.
371
+ *
261
372
  * `plan()` is called from selectors: it MUST NOT dispatch. Fetch kickoff
262
373
  * is idempotent (the store singleflights); resolution handlers dispatch
263
374
  * the wake-up (`onAdvance`) that makes subscribed layers recompute.
264
375
  */
376
+ /** One state entry per VIEW-of-page (see the class doc). */
377
+ const stateKey = (view, pon) => `${view}\u0000${pon}`;
378
+ /** Backpressure: raw rasters in flight at once (render + encode transit). */
379
+ const MAX_IN_FLIGHT = 8;
380
+ /** Stage-less (no visibleRect) demand is capped to this many whole-page tiles. */
381
+ const STAGELESS_TILE_CAP = 64;
265
382
  var TileManager = class {
266
383
  constructor(deps) {
267
384
  this.deps = deps;
268
385
  this.pages = /* @__PURE__ */ new Map();
386
+ this.strategyMemo = null;
387
+ this.inFlight = 0;
388
+ this.warnedStageless = false;
269
389
  }
270
- plan(pon, demand, includeAnnotations) {
271
- const { config } = this.deps;
390
+ strategy() {
272
391
  const policy = this.deps.getPolicy();
392
+ if (this.strategyMemo?.policy !== policy) this.strategyMemo = {
393
+ policy,
394
+ strategy: resolveStrategy(policy, this.deps.options)
395
+ };
396
+ return this.strategyMemo.strategy;
397
+ }
398
+ plan(view, pon, demand, includeAnnotations) {
399
+ const { options } = this.deps;
400
+ if (!options.tiles.enabled) return EMPTY_TILE_PLAN;
401
+ const strategy = this.strategy();
273
402
  const page = this.deps.getPageSize(pon);
274
403
  if (!page) return EMPTY_TILE_PLAN;
275
404
  const epoch = this.deps.getEpoch(pon, includeAnnotations);
276
- const state = this.pageState(pon);
405
+ const state = this.pageState(pon, view);
277
406
  if (state.epoch !== epoch) {
278
407
  this.abortAll(state);
279
408
  state.entries.clear();
409
+ state.failedKeys.clear();
280
410
  state.epoch = epoch;
281
411
  state.wantScale = null;
412
+ state.wantWidth = null;
282
413
  state.planCache = null;
283
414
  }
284
- const baseSupplied = policy.kind === "lattice" ? widthOf(snapFullPageViewport(policy, {
285
- kind: "width",
286
- width: demand.desiredDeviceWidth
287
- })) : demand.desiredDeviceWidth;
288
- if (demand.desiredDeviceWidth / baseSupplied <= config.engageAt) {
415
+ const supplied = baseAskWidth(strategy, demand.desiredDeviceWidth);
416
+ const deficit = demand.desiredDeviceWidth / supplied;
417
+ if (deficit <= strategy.engageAt) {
289
418
  if (state.entries.size || state.wantScale !== null) {
419
+ this.deps.debug?.(`disengage pon=${pon} (deficit ${deficit.toFixed(2)})`);
290
420
  this.abortAll(state);
291
421
  state.entries.clear();
422
+ state.failedKeys.clear();
292
423
  state.wantScale = null;
424
+ state.wantWidth = null;
293
425
  state.planCache = null;
294
426
  }
295
427
  return EMPTY_TILE_PLAN;
296
428
  }
297
- const pyramid = policy.kind === "lattice" && policy.tiles ? policy.tiles.scales : config.scales;
298
- const wantScale = snapToPyramid(pyramid, demand.desiredDeviceWidth / page.width);
299
- const grid = tileGrid(page, wantScale, config.tileSize);
429
+ let wantWidth;
430
+ let wantScale;
431
+ if (strategy.pyramid) {
432
+ wantScale = snapToPyramid(strategy.pyramid, demand.desiredDeviceWidth / page.width);
433
+ wantWidth = Math.round(wantScale * page.width);
434
+ } else {
435
+ wantWidth = Math.min(Math.max(1, Math.round(demand.desiredDeviceWidth)), Math.round(strategy.tileMaxScale * page.width));
436
+ wantScale = wantWidth / page.width;
437
+ }
438
+ if (!demand.visibleRect) {
439
+ const maxStagelessWidth = Math.max(strategy.tileSize, Math.floor(strategy.tileSize * Math.sqrt(STAGELESS_TILE_CAP * page.width / page.height)));
440
+ if (wantWidth > maxStagelessWidth) {
441
+ if (!this.warnedStageless) {
442
+ this.warnedStageless = true;
443
+ 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.`);
444
+ }
445
+ if (strategy.pyramid) {
446
+ const fitting = strategy.pyramid.filter((s) => Math.round(s * page.width) <= maxStagelessWidth);
447
+ wantScale = fitting.length ? fitting[fitting.length - 1] : strategy.pyramid[0];
448
+ wantWidth = Math.round(wantScale * page.width);
449
+ } else {
450
+ wantWidth = maxStagelessWidth;
451
+ wantScale = wantWidth / page.width;
452
+ }
453
+ }
454
+ }
455
+ const grid = tileGrid(page, wantScale, strategy.tileSize);
300
456
  const visible = demand.visibleRect ?? {
301
457
  x: 0,
302
458
  y: 0,
@@ -305,26 +461,43 @@ var TileManager = class {
305
461
  };
306
462
  state.lastVisible = visible;
307
463
  const p0 = tilesInRect(grid, page, visible);
308
- const ring = inflateRect(visible, config.prefetchMargin, config.velocityBias ? demand.velocity : void 0);
464
+ const ring = inflateRect(visible, options.tiles.prefetchMargin, options.tiles.velocityBias ? demand.velocity : void 0);
309
465
  const p0Keys = new Set(p0.map((c) => coordKey(c)));
310
466
  const p1 = tilesInRect(grid, page, ring).filter((c) => !p0Keys.has(coordKey(c)));
311
- this.schedule(pon, state, page, grid, wantScale, includeAnnotations, epoch, p0, p1);
312
- const demandKey = `${wantScale}|${rectKey(visible)}|e${epoch}`;
467
+ this.schedule(pon, state, page, grid, wantScale, wantWidth, includeAnnotations, epoch, p0, p1, visible);
468
+ if (this.releaseCovered(pon, state, page, includeAnnotations)) {
469
+ state.version += 1;
470
+ state.planCache = null;
471
+ }
472
+ const demandKey = `w${wantWidth}|${rectKey(visible)}|e${epoch}`;
313
473
  if (state.planCache && state.planCache.demandKey === demandKey && state.planCache.version === state.version) return state.planCache.plan;
314
474
  const paint = [];
315
475
  const fetching = [];
476
+ const stale = [];
477
+ const bleedPx = this.deps.options.tiles.bleedPx;
316
478
  for (const entry of state.entries.values()) {
317
479
  const visIntersect = intersectRects(entry.rect, visible);
318
- if (entry.handle) {
319
- if (visIntersect.width > 0 && visIntersect.height > 0) paint.push({
320
- key: entry.key,
321
- scale: entry.scale,
322
- rect: entry.rect,
323
- z: zOf(pyramid, entry.scale),
324
- handle: entry.handle
325
- });
480
+ if (entry.resolved) {
481
+ if (visIntersect.width > 0 && visIntersect.height > 0) {
482
+ const handle = this.deps.store.peek(entry.key);
483
+ if (!handle) {
484
+ stale.push(entry.key);
485
+ continue;
486
+ }
487
+ paint.push({
488
+ key: entry.key,
489
+ scale: entry.scale,
490
+ rect: bleedPx > 0 ? bleedRect(entry.rect, bleedPx / entry.scale, page) : entry.rect,
491
+ z: 0,
492
+ handle
493
+ });
494
+ }
326
495
  } else if (entry.scale === wantScale) fetching.push(entry.key);
327
496
  }
497
+ for (const key of stale) state.entries.delete(key);
498
+ const rank = /* @__PURE__ */ new Map();
499
+ for (const s of [...new Set(paint.map((p) => p.scale))].sort((a, b) => a - b)) rank.set(s, rank.size);
500
+ for (const p of paint) p.z = rank.get(p.scale);
328
501
  paint.sort((a, b) => a.z - b.z || a.key.localeCompare(b.key));
329
502
  const plan = {
330
503
  engaged: true,
@@ -339,62 +512,95 @@ var TileManager = class {
339
512
  };
340
513
  return plan;
341
514
  }
342
- /** The layer's decode-boundary report: this key's <img> actually painted. */
343
- sourcePainted(pon, key) {
344
- const state = this.pages.get(pon);
515
+ /** The layer's painted report: this key's pixels had a presentation opportunity. */
516
+ sourcePainted(view, pon, key) {
517
+ const state = this.pages.get(stateKey(view, pon));
345
518
  const entry = state?.entries.get(key);
346
519
  if (!state || !entry || entry.painted) return;
347
520
  entry.painted = true;
348
- this.releaseOccluded(state, entry);
521
+ const page = this.deps.getPageSize(pon);
522
+ if (page) this.releaseCovered(pon, state, page, annotationsOf(key));
349
523
  state.version += 1;
350
524
  state.planCache = null;
351
525
  this.deps.onAdvance(pon);
352
526
  }
353
- /** A lens unmounted its TileLayer: stop fetching, drop bookkeeping.
527
+ /**
528
+ * The inverse report: this key's <img> left the DOM (pan-away, plan drop),
529
+ * so its pixels are NOT currently compositable. Without this, a tile that
530
+ * unmounts and later remounts is still counted as painted while its new
531
+ * <img> re-decodes — and an adjacent fresh `sourcePainted` could release
532
+ * retained coarse coverage over a region that momentarily has no sharp
533
+ * pixels. Painted is a statement about the SCREEN, so it follows the DOM.
534
+ */
535
+ sourceUnpainted(view, pon, key) {
536
+ const entry = this.pages.get(stateKey(view, pon))?.entries.get(key);
537
+ if (entry) entry.painted = false;
538
+ }
539
+ /** A lens unmounted its tile plane: stop fetching, drop bookkeeping.
354
540
  * Resolved bytes stay in the RasterStore for a re-mount. */
355
- releasePage(pon) {
356
- const state = this.pages.get(pon);
541
+ releasePage(view, pon) {
542
+ const state = this.pages.get(stateKey(view, pon));
357
543
  if (!state) return;
358
544
  this.abortAll(state);
359
- this.pages.delete(pon);
545
+ this.pages.delete(stateKey(view, pon));
360
546
  }
361
- pageState(pon) {
362
- let state = this.pages.get(pon);
547
+ pageState(pon, view) {
548
+ let state = this.pages.get(stateKey(view, pon));
363
549
  if (!state) {
364
550
  state = {
365
551
  epoch: -1,
366
552
  wantScale: null,
553
+ wantWidth: null,
367
554
  entries: /* @__PURE__ */ new Map(),
555
+ failedKeys: /* @__PURE__ */ new Set(),
368
556
  version: 0,
369
557
  planCache: null,
370
558
  settleTimer: null,
371
559
  pendingLevel: null,
372
560
  lastVisible: null
373
561
  };
374
- this.pages.set(pon, state);
562
+ this.pages.set(stateKey(view, pon), state);
375
563
  }
376
564
  return state;
377
565
  }
378
- schedule(pon, state, page, grid, wantScale, includeAnnotations, epoch, p0, p1) {
379
- const { config } = this.deps;
380
- const firstEngage = state.wantScale === null;
381
- const levelChanged = state.wantScale !== null && state.wantScale !== wantScale;
566
+ schedule(pon, state, page, grid, wantScale, wantWidth, includeAnnotations, epoch, p0, p1, visible) {
567
+ const { options } = this.deps;
568
+ const firstEngage = state.wantWidth === null;
569
+ const levelChanged = state.wantWidth !== null && state.wantWidth !== wantWidth;
570
+ if (levelChanged) state.failedKeys.clear();
382
571
  state.wantScale = wantScale;
383
- const wanted = new Set([...p0, ...p1].map((c) => this.tileKey(pon, wantScale, c, includeAnnotations, epoch)));
384
- for (const [key, entry] of state.entries) if (entry.handle === void 0 && !wanted.has(key)) {
385
- entry.abort?.abort();
386
- state.entries.delete(key);
572
+ state.wantWidth = wantWidth;
573
+ const wanted = new Set([...p0, ...p1].map((c) => this.tileKey(pon, wantWidth, c, includeAnnotations, epoch)));
574
+ for (const [key, entry] of state.entries) {
575
+ if (wanted.has(key)) continue;
576
+ if (!entry.resolved) {
577
+ entry.abort?.abort();
578
+ this.releaseSlot(entry);
579
+ state.entries.delete(key);
580
+ } else if (entry.scale === wantScale) state.entries.delete(key);
387
581
  }
582
+ const cx = visible.x + visible.width / 2;
583
+ const cy = visible.y + visible.height / 2;
584
+ const span = grid.tileSize / grid.scale;
585
+ const orderedP0 = [...p0].sort((a, b) => {
586
+ const da = (a.ix + .5) * span - cx;
587
+ const db = (b.ix + .5) * span - cx;
588
+ const ea = (a.iy + .5) * span - cy;
589
+ const eb = (b.iy + .5) * span - cy;
590
+ return da * da + ea * ea - (db * db + eb * eb);
591
+ });
388
592
  const kickoff = () => {
389
- if (this.ensureFetches(pon, state, page, grid, includeAnnotations, epoch, p0)) this.ensureFetches(pon, state, page, grid, includeAnnotations, epoch, p1);
593
+ if (this.ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, orderedP0)) this.ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, p1);
390
594
  };
391
- if (levelChanged && config.settleMs > 0) {
392
- state.pendingLevel = wantScale;
595
+ if (levelChanged && options.tiles.settleMs > 0) {
596
+ this.deps.debug?.(`arm settle pon=${pon} level=${wantWidth}`);
597
+ state.pendingLevel = wantWidth;
393
598
  if (state.settleTimer !== null) clearTimeout(state.settleTimer);
394
599
  state.settleTimer = setTimeout(() => {
395
600
  state.settleTimer = null;
396
- if (state.pendingLevel === wantScale) kickoff();
397
- }, config.settleMs);
601
+ this.deps.debug?.(`settle fired pon=${pon} level=${wantWidth} current=${state.pendingLevel === wantWidth}`);
602
+ if (state.pendingLevel === wantWidth) kickoff();
603
+ }, options.tiles.settleMs);
398
604
  return;
399
605
  }
400
606
  if (state.settleTimer !== null && !levelChanged && !firstEngage) {
@@ -405,95 +611,178 @@ var TileManager = class {
405
611
  kickoff();
406
612
  }
407
613
  /** Start missing fetches; true when every coord is already resolved. */
408
- ensureFetches(pon, state, page, grid, includeAnnotations, epoch, coords) {
614
+ ensureFetches(pon, state, page, grid, wantWidth, includeAnnotations, epoch, coords) {
409
615
  let allReady = true;
616
+ let started = 0;
617
+ const bleedPt = this.deps.options.tiles.bleedPx / grid.scale;
410
618
  for (const coord of coords) {
411
- const key = this.tileKey(pon, grid.scale, coord, includeAnnotations, epoch);
619
+ const key = this.tileKey(pon, wantWidth, coord, includeAnnotations, epoch);
620
+ if (state.failedKeys.has(key)) continue;
412
621
  const existing = state.entries.get(key);
413
622
  if (existing) {
414
- if (existing.handle === void 0) allReady = false;
623
+ if (!existing.resolved) allReady = false;
415
624
  continue;
416
625
  }
417
626
  allReady = false;
627
+ if (this.inFlight >= MAX_IN_FLIGHT) continue;
628
+ this.inFlight += 1;
629
+ started += 1;
418
630
  const abort = new AbortController();
631
+ const logical = tilePaintRect(grid, page, coord);
419
632
  const entry = {
420
633
  key,
421
634
  scale: grid.scale,
422
635
  coord,
423
- rect: tilePaintRect(grid, page, coord),
636
+ rect: logical,
637
+ resolved: false,
424
638
  painted: false,
639
+ charged: true,
425
640
  abort
426
641
  };
427
642
  state.entries.set(key, entry);
428
- this.deps.store.acquire(key, (signal) => this.deps.fetchTile(pon, tileEngineRect(grid, page, coord), grid.scale, includeAnnotations, signal), abort.signal).then((handle) => {
643
+ 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(() => {
644
+ this.releaseSlot(entry);
429
645
  if (state.entries.get(key) !== entry) return;
430
- entry.handle = handle;
646
+ entry.resolved = true;
431
647
  state.version += 1;
432
648
  state.planCache = null;
433
649
  this.deps.onAdvance(pon);
434
- }, () => {
435
- if (state.entries.get(key) === entry) state.entries.delete(key);
650
+ }, (err) => {
651
+ this.releaseSlot(entry);
652
+ if (state.entries.get(key) !== entry) return;
653
+ state.entries.delete(key);
654
+ if (!abort.signal.aborted) {
655
+ state.failedKeys.add(key);
656
+ this.deps.debug?.(`tile failed ${key}: ${String(err)}`);
657
+ state.version += 1;
658
+ state.planCache = null;
659
+ this.deps.onAdvance(pon);
660
+ }
436
661
  });
437
662
  }
663
+ if (started > 0) this.deps.debug?.(`fetch pon=${pon} level=${wantWidth} +${started} tiles`);
438
664
  return allReady;
439
665
  }
440
666
  /**
441
- * The release rule: after `painted` lands on a want-level tile, retained
442
- * sources (any other level) whose footprint ∩ painted-tile region is
443
- * covered by PAINTED want-level tiles leave the paint list. Index
444
- * arithmetic via the aligned grid — no rectangle geometry.
667
+ * The release rule, evaluated as a PURE FUNCTION of the current painted
668
+ * set: retained sources (any non-want level) whose VISIBLE footprint is
669
+ * covered by PAINTED want-level tiles leave the paint list. Runs on every
670
+ * `plan()` and on every painted report — never only "against the tile
671
+ * that just painted": release must not depend on paint ORDER (center-out
672
+ * scheduling means the report that completes a retained tile's coverage
673
+ * routinely lands far away from it), and re-evaluating from current state
674
+ * also self-heals any painted-flag transition the reports raced past.
675
+ * Coverage is index arithmetic over the WANT grid — generic over whatever
676
+ * mix of retained scales exists (exact levels included).
445
677
  */
446
- releaseOccluded(state, painted) {
447
- const wantScale = state.wantScale;
448
- if (wantScale === null || painted.scale !== wantScale) return;
449
- const page = this.deps.getPageSize(pagePonOf(painted.key));
450
- if (!page) return;
451
- const grid = tileGrid(page, wantScale, this.deps.config.tileSize);
678
+ releaseCovered(pon, state, page, includeAnnotations) {
679
+ const { wantScale, wantWidth } = state;
680
+ if (wantScale === null || wantWidth === null) return false;
681
+ const grid = tileGrid(page, wantScale, this.strategy().tileSize);
452
682
  const paintedAt = (c) => {
453
- const key = this.tileKey(pagePonOf(painted.key), wantScale, c, annotationsOf(painted.key), state.epoch);
683
+ const key = this.tileKey(pon, wantWidth, c, includeAnnotations, state.epoch);
454
684
  return state.entries.get(key)?.painted === true;
455
685
  };
686
+ let released = false;
456
687
  for (const [key, entry] of state.entries) {
457
- if (entry.scale === wantScale || entry.handle === void 0) continue;
458
- const overlap = intersectRects(entry.rect, painted.rect);
459
- if (overlap.width <= 0 || overlap.height <= 0) continue;
460
- if (regionCovered(grid, page, state.lastVisible ? intersectRects(entry.rect, state.lastVisible) : entry.rect, paintedAt)) state.entries.delete(key);
688
+ if (entry.scale === wantScale || !entry.resolved) continue;
689
+ const region = state.lastVisible ? intersectRects(entry.rect, state.lastVisible) : entry.rect;
690
+ if (regionCovered(grid, page, region, paintedAt)) {
691
+ state.entries.delete(key);
692
+ released = true;
693
+ } else if (this.deps.debug) {
694
+ const missing = tilesInRect(grid, page, region).filter((c) => !paintedAt(c));
695
+ const detail = missing.slice(0, 3).map((c) => {
696
+ const k = this.tileKey(pon, wantWidth, c, includeAnnotations, state.epoch);
697
+ const e = state.entries.get(k);
698
+ const st = !e ? "NO-ENTRY" : !e.resolved ? "PENDING" : "RESOLVED-unpainted";
699
+ return `${c.ix},${c.iy}=${st}`;
700
+ });
701
+ this.deps.debug(`retained ${key} blocked by: ${detail.join(" ")} (${missing.length} missing)`);
702
+ }
461
703
  }
704
+ return released;
705
+ }
706
+ /** Test/diagnostic introspection: bookkeeping size for one page. */
707
+ stats(view, pon) {
708
+ return {
709
+ entries: this.pages.get(stateKey(view, pon))?.entries.size ?? 0,
710
+ inFlight: this.inFlight
711
+ };
712
+ }
713
+ /** Idempotent transit-slot release — the ONE place inFlight decrements. */
714
+ releaseSlot(entry) {
715
+ if (!entry.charged) return;
716
+ entry.charged = false;
717
+ this.inFlight -= 1;
462
718
  }
463
719
  abortAll(state) {
464
720
  if (state.settleTimer !== null) {
465
721
  clearTimeout(state.settleTimer);
466
722
  state.settleTimer = null;
467
723
  }
468
- for (const entry of state.entries.values()) if (entry.handle === void 0) entry.abort?.abort();
724
+ for (const entry of state.entries.values()) if (!entry.resolved) {
725
+ entry.abort?.abort();
726
+ this.releaseSlot(entry);
727
+ }
469
728
  }
470
- tileKey(pon, scale, c, includeAnnotations, epoch) {
471
- return `t:${pon}|s${scale}|${c.ix},${c.iy}|a${includeAnnotations ? 1 : 0}|e${epoch}`;
729
+ /** Level identity is the integer device width across the page — integer
730
+ * and stable for pyramid rungs and exact levels alike. The key also
731
+ * carries everything that determines the fetched BITMAP for a coord
732
+ * (tile size, bleed, encode format): a raster's identity must include
733
+ * its geometry, or a cached handle from one configuration could be
734
+ * stretched into another's rect. Format genuinely varies at runtime —
735
+ * the cloud policy arrives async after open and can change the resolved
736
+ * format under a live store. */
737
+ tileKey(pon, levelWidth, c, includeAnnotations, epoch) {
738
+ const { tiles } = this.deps.options;
739
+ const format = this.strategy().format;
740
+ const geometry = `g${tiles.size}.${tiles.bleedPx}${format ? `.${format}` : ""}`;
741
+ return `t:${pon}|w${levelWidth}|${c.ix},${c.iy}|a${includeAnnotations ? 1 : 0}|e${epoch}|${geometry}`;
472
742
  }
473
743
  };
474
- const widthOf = (v) => v.kind === "width" && v.width !== void 0 ? v.width : Number.POSITIVE_INFINITY;
475
744
  const coordKey = (c) => `${c.ix},${c.iy}`;
476
745
  const rectKey = (r) => `${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}`;
477
- /** Stacking: position in the ascending pyramid (coarse under fine). */
478
- const zOf = (pyramid, scale) => {
479
- const sorted = [...pyramid].sort((a, b) => a - b);
480
- const idx = sorted.indexOf(scale);
481
- return idx === -1 ? sorted.findIndex((s) => s > scale) : idx;
482
- };
483
- const pagePonOf = (key) => Number(key.slice(2, key.indexOf("|")));
484
746
  const annotationsOf = (key) => key.includes("|a1|");
485
747
  //#endregion
486
748
  //#region src/capability.ts
487
749
  /**
488
- * The render capability: the ONE policy consumer in the client stack
489
- * (the three-layer rule). The engine never snaps; framework
490
- * layers never see the policy; everything between — conforming a desired
491
- * scale to the deployment lattice, collapsing same-rung asks in the raster
492
- * store, exposing stable source keys — happens here.
750
+ * The render capability: the ONE place STRATEGY (plugin options — what the
751
+ * viewer spends) composes with POLICY (the engine fact — what the deployment
752
+ * serves). The engine never snaps; framework layers never see either;
753
+ * everything between — conforming demand to the resolved render points,
754
+ * collapsing same-key asks in the raster store, exposing stable source
755
+ * keys — happens here.
493
756
  */
494
757
  function createRenderCapability(ctx, options = {}) {
758
+ const resolved = resolveRenderOptions(options);
759
+ const canRender = () => ctx.doc?.security.allows("doc.render") ?? false;
495
760
  const store = new RasterStore();
496
761
  const policy = () => ctx.document()?.renderPolicy ?? CONTINUOUS_RENDER_POLICY;
762
+ let strategyMemo = null;
763
+ const strategy = () => {
764
+ const p = policy();
765
+ if (strategyMemo?.policy !== p) {
766
+ strategyMemo = {
767
+ policy: p,
768
+ strategy: resolveStrategy(p, resolved)
769
+ };
770
+ warnDroppedFormat(strategyMemo.strategy);
771
+ }
772
+ return strategyMemo.strategy;
773
+ };
774
+ let warnedFormat = false;
775
+ const warnDroppedFormat = (s) => {
776
+ if (warnedFormat || resolved.format === void 0 || s.format === resolved.format) return;
777
+ warnedFormat = true;
778
+ console.warn(`[render] format '${resolved.format}' is not in the deployment's formats — using '${s.format}'.`);
779
+ };
780
+ let warnedBudget = false;
781
+ const warnPastBudgetNoTiles = (demandWidth, supplied) => {
782
+ if (warnedBudget || resolved.tiles.enabled || demandWidth <= supplied * 2) return;
783
+ warnedBudget = true;
784
+ 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.`);
785
+ };
497
786
  const pageOf = (pon) => (ctx.document()?.pages ?? []).find((p) => p.pageObjectNumber === pon);
498
787
  const pageWidthOf = (pon) => {
499
788
  const page = pageOf(pon);
@@ -501,32 +790,54 @@ function createRenderCapability(ctx, options = {}) {
501
790
  return page.size.width;
502
791
  };
503
792
  const conformViewport = (pon, scale) => {
504
- const p = policy();
505
- if (p.kind !== "lattice") return {
506
- kind: "scale",
507
- scale
793
+ const s = strategy();
794
+ const demandWidth = scale * pageWidthOf(pon);
795
+ const width = baseAskWidth(s, demandWidth);
796
+ warnPastBudgetNoTiles(demandWidth, width);
797
+ return {
798
+ kind: "width",
799
+ width
508
800
  };
509
- return snapFullPageViewport(p, {
510
- kind: "scale",
511
- scale
512
- }, { pageWidth: pageWidthOf(pon) });
513
801
  };
514
- const viewportKey = (viewport) => viewport.kind === "width" ? `w${viewport.width}` : `s${viewport.scale ?? 1}`;
515
802
  const epochOf = (pon, includeAnnotations) => {
516
803
  const s = ctx.getState();
517
804
  const content = s.contentEpochs[pon] ?? 0;
518
805
  if (!includeAnnotations) return content;
519
806
  return content + (s.annotatedEpochs[pon] ?? 0);
520
807
  };
808
+ const baseKey = (pon, viewport, annotations) => {
809
+ const format = strategy().format;
810
+ return `${pon}|w${viewport.kind === "width" ? viewport.width : 0}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}${format ? `|f${format}` : ""}`;
811
+ };
812
+ const pendingAdvance = /* @__PURE__ */ new Set();
813
+ let advanceScheduled = false;
814
+ const raf = typeof requestAnimationFrame === "function" ? (cb) => requestAnimationFrame(() => cb()) : (cb) => void setTimeout(cb, 16);
815
+ const wake = (pon) => {
816
+ pendingAdvance.add(pon);
817
+ if (advanceScheduled) return;
818
+ advanceScheduled = true;
819
+ raf(() => {
820
+ advanceScheduled = false;
821
+ const pons = [...pendingAdvance];
822
+ pendingAdvance.clear();
823
+ for (const p of pons) ctx.dispatch({
824
+ type: "PAINT_ADVANCED",
825
+ pon: p
826
+ });
827
+ });
828
+ };
829
+ const viewTiles = /* @__PURE__ */ new Map();
521
830
  const tiles = new TileManager({
522
831
  store,
523
- config: resolveTiling(options.tiling),
832
+ options: resolved,
524
833
  getPolicy: policy,
525
834
  getPageSize: (pon) => pageOf(pon)?.size,
526
835
  getEpoch: epochOf,
527
836
  fetchTile: (pon, rect, scale, includeAnnotations, signal) => {
528
837
  const doc = ctx.doc;
529
838
  if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
839
+ if (!canRender()) return Promise.reject(new PermissionDenied("doc.render", "render.tile"));
840
+ const s = strategy();
530
841
  const task = doc.page(pon).render.image({
531
842
  target: {
532
843
  kind: "rect",
@@ -536,28 +847,33 @@ function createRenderCapability(ctx, options = {}) {
536
847
  kind: "scale",
537
848
  scale
538
849
  },
539
- includeAnnotations
850
+ includeAnnotations,
851
+ ...s.format !== void 0 ? { format: s.format } : {},
852
+ ...s.quality !== void 0 ? { quality: s.quality } : {}
540
853
  });
541
854
  if (signal.aborted) task.abort(signal.reason);
542
855
  else signal.addEventListener("abort", () => task.abort(signal.reason), { once: true });
543
856
  return task;
544
857
  },
545
- onAdvance: (pon) => ctx.dispatch({
546
- type: "PAINT_ADVANCED",
547
- pon
548
- })
858
+ onAdvance: wake,
859
+ ...resolved.debug ? { debug: (msg) => console.debug(`[render] ${msg}`) } : {}
549
860
  });
550
861
  return {
862
+ canRender,
551
863
  renderPage(pon, { scale, includeAnnotations, signal }) {
552
864
  const doc = ctx.doc;
553
865
  if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
866
+ if (!canRender()) return Promise.reject(new PermissionDenied("doc.render", "render.renderPage"));
554
867
  const annotations = includeAnnotations ?? true;
555
868
  const viewport = conformViewport(pon, scale);
556
- const key = `${pon}|${viewportKey(viewport)}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}`;
869
+ const key = baseKey(pon, viewport, annotations);
557
870
  return store.acquire(key, (storeSignal) => {
871
+ const s = strategy();
558
872
  const task = doc.page(pon).render.image({
559
873
  viewport,
560
- includeAnnotations: annotations
874
+ includeAnnotations: annotations,
875
+ ...s.format !== void 0 ? { format: s.format } : {},
876
+ ...s.quality !== void 0 ? { quality: s.quality } : {}
561
877
  });
562
878
  if (storeSignal.aborted) task.abort(storeSignal.reason);
563
879
  else storeSignal.addEventListener("abort", () => task.abort(storeSignal.reason), { once: true });
@@ -566,19 +882,28 @@ function createRenderCapability(ctx, options = {}) {
566
882
  },
567
883
  renderSourceKey(pon, { scale, includeAnnotations }) {
568
884
  const annotations = includeAnnotations ?? true;
569
- const viewport = conformViewport(pon, scale);
570
- return `${pon}|${viewportKey(viewport)}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}`;
885
+ return baseKey(pon, conformViewport(pon, scale), annotations);
571
886
  },
572
887
  conformViewport,
573
- renderPolicy: policy,
574
- tilePlan(pon, demand, opts) {
575
- return tiles.plan(pon, demand, opts?.includeAnnotations ?? true);
576
- },
577
- tilePainted(pon, key) {
578
- tiles.sourcePainted(pon, key);
888
+ paintSettings() {
889
+ return {
890
+ fadeMs: resolved.tiles.fadeMs,
891
+ tiles: resolved.tiles.enabled
892
+ };
579
893
  },
580
- releaseTiles(pon) {
581
- tiles.releasePage(pon);
894
+ renderPolicy: policy,
895
+ tilesFor(view) {
896
+ let handle = viewTiles.get(view);
897
+ if (!handle) {
898
+ handle = {
899
+ plan: (pon, demand, opts) => tiles.plan(view, pon, demand, opts?.includeAnnotations ?? true),
900
+ painted: (pon, key) => tiles.sourcePainted(view, pon, key),
901
+ unpainted: (pon, key) => tiles.sourceUnpainted(view, pon, key),
902
+ release: (pon) => tiles.releasePage(view, pon)
903
+ };
904
+ viewTiles.set(view, handle);
905
+ }
906
+ return handle;
582
907
  },
583
908
  renderEpoch(pon, includeAnnotations = true) {
584
909
  return epochOf(pon, includeAnnotations);