@embedpdf/plugin-render 3.0.0-next.1 → 3.0.0-next.11

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