@embedpdf/plugin-render 3.0.0-next.5 → 3.0.0-next.6

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