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