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

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.
Files changed (59) hide show
  1. package/LICENSE +192 -21
  2. package/dist/index.cjs +709 -2
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +233 -0
  5. package/dist/index.d.ts +233 -1
  6. package/dist/index.js +703 -137
  7. package/dist/index.js.map +1 -1
  8. package/package.json +19 -58
  9. package/dist/lib/index.d.ts +0 -6
  10. package/dist/lib/manifest.d.ts +0 -4
  11. package/dist/lib/render-plugin.d.ts +0 -21
  12. package/dist/lib/types.d.ts +0 -46
  13. package/dist/preact/adapter.d.ts +0 -5
  14. package/dist/preact/core.d.ts +0 -1
  15. package/dist/preact/index.cjs +0 -2
  16. package/dist/preact/index.cjs.map +0 -1
  17. package/dist/preact/index.d.ts +0 -1
  18. package/dist/preact/index.js +0 -85
  19. package/dist/preact/index.js.map +0 -1
  20. package/dist/react/adapter.d.ts +0 -2
  21. package/dist/react/core.d.ts +0 -1
  22. package/dist/react/index.cjs +0 -2
  23. package/dist/react/index.cjs.map +0 -1
  24. package/dist/react/index.d.ts +0 -1
  25. package/dist/react/index.js +0 -84
  26. package/dist/react/index.js.map +0 -1
  27. package/dist/shared/components/index.d.ts +0 -1
  28. package/dist/shared/components/render-layer.d.ts +0 -35
  29. package/dist/shared/hooks/index.d.ts +0 -1
  30. package/dist/shared/hooks/use-render.d.ts +0 -11
  31. package/dist/shared/index.d.ts +0 -3
  32. package/dist/shared-preact/components/index.d.ts +0 -1
  33. package/dist/shared-preact/components/render-layer.d.ts +0 -35
  34. package/dist/shared-preact/hooks/index.d.ts +0 -1
  35. package/dist/shared-preact/hooks/use-render.d.ts +0 -11
  36. package/dist/shared-preact/index.d.ts +0 -3
  37. package/dist/shared-react/components/index.d.ts +0 -1
  38. package/dist/shared-react/components/render-layer.d.ts +0 -35
  39. package/dist/shared-react/hooks/index.d.ts +0 -1
  40. package/dist/shared-react/hooks/use-render.d.ts +0 -11
  41. package/dist/shared-react/index.d.ts +0 -3
  42. package/dist/svelte/components/RenderLayer.svelte.d.ts +0 -24
  43. package/dist/svelte/components/index.d.ts +0 -1
  44. package/dist/svelte/hooks/index.d.ts +0 -1
  45. package/dist/svelte/hooks/use-render.d.ts +0 -11
  46. package/dist/svelte/index.cjs +0 -2
  47. package/dist/svelte/index.cjs.map +0 -1
  48. package/dist/svelte/index.d.ts +0 -3
  49. package/dist/svelte/index.js +0 -121
  50. package/dist/svelte/index.js.map +0 -1
  51. package/dist/vue/components/index.d.ts +0 -1
  52. package/dist/vue/components/render-layer.vue.d.ts +0 -21
  53. package/dist/vue/hooks/index.d.ts +0 -1
  54. package/dist/vue/hooks/use-render.d.ts +0 -3
  55. package/dist/vue/index.cjs +0 -2
  56. package/dist/vue/index.cjs.map +0 -1
  57. package/dist/vue/index.d.ts +0 -3
  58. package/dist/vue/index.js +0 -112
  59. package/dist/vue/index.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,141 +1,707 @@
1
- import { BasePlugin } from "@embedpdf/core";
2
- const _RenderPlugin = class _RenderPlugin extends BasePlugin {
3
- constructor(id, registry, config) {
4
- super(id, registry);
5
- this.config = config;
6
- }
7
- // No onDocumentLoadingStarted or onDocumentClosed needed!
8
- buildCapability() {
9
- return {
10
- // Active document operations
11
- renderPage: (options) => this.renderPage(options),
12
- renderPageRect: (options) => this.renderPageRect(options),
13
- renderPageRaw: (options) => this.renderPageRaw(options),
14
- renderPageRectRaw: (options) => this.renderPageRectRaw(options),
15
- // Document-scoped operations
16
- forDocument: (documentId) => this.createRenderScope(documentId)
17
- };
18
- }
19
- // ─────────────────────────────────────────────────────────
20
- // Document Scoping
21
- // ─────────────────────────────────────────────────────────
22
- createRenderScope(documentId) {
23
- return {
24
- renderPage: (options) => this.renderPage(options, documentId),
25
- renderPageRect: (options) => this.renderPageRect(options, documentId),
26
- renderPageRaw: (options) => this.renderPageRaw(options, documentId),
27
- renderPageRectRaw: (options) => this.renderPageRectRaw(options, documentId)
28
- };
29
- }
30
- // ─────────────────────────────────────────────────────────
31
- // Core Operations
32
- // ─────────────────────────────────────────────────────────
33
- renderPage({ pageIndex, options }, documentId) {
34
- const id = documentId ?? this.getActiveDocumentId();
35
- const coreDoc = this.coreState.core.documents[id];
36
- if (!(coreDoc == null ? void 0 : coreDoc.document)) {
37
- throw new Error(`Document ${id} not loaded`);
38
- }
39
- const page = coreDoc.document.pages.find((p) => p.index === pageIndex);
40
- if (!page) {
41
- throw new Error(`Page ${pageIndex} not found in document ${id}`);
42
- }
43
- const mergedOptions = {
44
- ...options ?? {},
45
- withForms: (options == null ? void 0 : options.withForms) ?? this.config.withForms ?? false,
46
- withAnnotations: (options == null ? void 0 : options.withAnnotations) ?? this.config.withAnnotations ?? false,
47
- imageType: (options == null ? void 0 : options.imageType) ?? this.config.defaultImageType ?? "image/png",
48
- imageQuality: (options == null ? void 0 : options.imageQuality) ?? this.config.defaultImageQuality ?? 0.92
49
- };
50
- return this.engine.renderPage(coreDoc.document, page, mergedOptions);
51
- }
52
- renderPageRect({ pageIndex, rect, options }, documentId) {
53
- const id = documentId ?? this.getActiveDocumentId();
54
- const coreDoc = this.coreState.core.documents[id];
55
- if (!(coreDoc == null ? void 0 : coreDoc.document)) {
56
- throw new Error(`Document ${id} not loaded`);
57
- }
58
- const page = coreDoc.document.pages.find((p) => p.index === pageIndex);
59
- if (!page) {
60
- throw new Error(`Page ${pageIndex} not found in document ${id}`);
61
- }
62
- const mergedOptions = {
63
- ...options ?? {},
64
- withForms: (options == null ? void 0 : options.withForms) ?? this.config.withForms ?? false,
65
- withAnnotations: (options == null ? void 0 : options.withAnnotations) ?? this.config.withAnnotations ?? false,
66
- imageType: (options == null ? void 0 : options.imageType) ?? this.config.defaultImageType ?? "image/png",
67
- imageQuality: (options == null ? void 0 : options.imageQuality) ?? this.config.defaultImageQuality ?? 0.92
68
- };
69
- return this.engine.renderPageRect(coreDoc.document, page, rect, mergedOptions);
70
- }
71
- // ─────────────────────────────────────────────────────────
72
- // Raw Rendering (returns ImageDataLike, skips encoding)
73
- // ─────────────────────────────────────────────────────────
74
- renderPageRaw({ pageIndex, options }, documentId) {
75
- const id = documentId ?? this.getActiveDocumentId();
76
- const coreDoc = this.coreState.core.documents[id];
77
- if (!(coreDoc == null ? void 0 : coreDoc.document)) {
78
- throw new Error(`Document ${id} not loaded`);
79
- }
80
- const page = coreDoc.document.pages.find((p) => p.index === pageIndex);
81
- if (!page) {
82
- throw new Error(`Page ${pageIndex} not found in document ${id}`);
83
- }
84
- const mergedOptions = {
85
- ...options ?? {},
86
- withForms: (options == null ? void 0 : options.withForms) ?? this.config.withForms ?? false,
87
- withAnnotations: (options == null ? void 0 : options.withAnnotations) ?? this.config.withAnnotations ?? false
88
- };
89
- return this.engine.renderPageRaw(coreDoc.document, page, mergedOptions);
90
- }
91
- renderPageRectRaw({ pageIndex, rect, options }, documentId) {
92
- const id = documentId ?? this.getActiveDocumentId();
93
- const coreDoc = this.coreState.core.documents[id];
94
- if (!(coreDoc == null ? void 0 : coreDoc.document)) {
95
- throw new Error(`Document ${id} not loaded`);
96
- }
97
- const page = coreDoc.document.pages.find((p) => p.index === pageIndex);
98
- if (!page) {
99
- throw new Error(`Page ${pageIndex} not found in document ${id}`);
100
- }
101
- const mergedOptions = {
102
- ...options ?? {},
103
- withForms: (options == null ? void 0 : options.withForms) ?? this.config.withForms ?? false,
104
- withAnnotations: (options == null ? void 0 : options.withAnnotations) ?? this.config.withAnnotations ?? false
105
- };
106
- return this.engine.renderPageRectRaw(coreDoc.document, page, rect, mergedOptions);
107
- }
108
- // ─────────────────────────────────────────────────────────
109
- // Lifecycle
110
- // ─────────────────────────────────────────────────────────
111
- async initialize(_config) {
112
- this.logger.info("RenderPlugin", "Initialize", "Render plugin initialized");
113
- }
114
- async destroy() {
115
- super.destroy();
116
- }
1
+ import { CONTINUOUS_RENDER_POLICY, createCapabilityToken, definePlugin, snapFullPageViewport } from "@embedpdf/core";
2
+ import { intersectRects } from "@embedpdf/core-geometry";
3
+ //#region src/paint-plan.ts
4
+ const EMPTY_TILE_PLAN = {
5
+ engaged: false,
6
+ paint: [],
7
+ fetching: [],
8
+ stamp: "empty"
117
9
  };
118
- _RenderPlugin.id = "render";
119
- let RenderPlugin = _RenderPlugin;
120
- const RENDER_PLUGIN_ID = "render";
121
- const manifest = {
122
- id: RENDER_PLUGIN_ID,
123
- name: "Render Plugin",
124
- version: "1.0.0",
125
- provides: ["render"],
126
- requires: [],
127
- optional: [],
128
- defaultConfig: {}
10
+ function resolveTiling(config) {
11
+ 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
25
+ };
26
+ }
27
+ //#endregion
28
+ //#region src/raster-store.ts
29
+ /**
30
+ * Client-side mirror of the server's one-door read-through: per-key
31
+ * SINGLEFLIGHT + LRU over encoded page images.
32
+ *
33
+ * 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
36
+ * re-crossings (zoom 1280 → 2560 → back) resolve instantly from the LRU
37
+ * instead of re-rendering. Epoch bumps mint new keys; stale-epoch entries
38
+ * age out through the LRU (they are never re-requested).
39
+ *
40
+ * Consumer aborts are REFCOUNTED: a caller abandoning a fetch (camera moved,
41
+ * layer unmounted) detaches without killing it for other consumers; the
42
+ * underlying engine call aborts only when the LAST in-flight consumer leaves.
43
+ * Resolved entries are kept (evictable by LRU) — an abort after resolution
44
+ * is a no-op by design.
45
+ */
46
+ var RasterStore = class {
47
+ constructor(maxEntries = 48) {
48
+ this.maxEntries = maxEntries;
49
+ this.entries = /* @__PURE__ */ new Map();
50
+ this.tick = 0;
51
+ }
52
+ acquire(key, fetch, signal) {
53
+ const existing = this.entries.get(key);
54
+ if (existing) {
55
+ existing.used = ++this.tick;
56
+ return this.attach(key, existing, signal);
57
+ }
58
+ const abort = new AbortController();
59
+ const entry = {
60
+ promise: void 0,
61
+ refs: 0,
62
+ abort,
63
+ used: ++this.tick
64
+ };
65
+ entry.promise = fetch(abort.signal).then((handle) => {
66
+ entry.handle = handle;
67
+ return handle;
68
+ }, (err) => {
69
+ if (this.entries.get(key) === entry) this.entries.delete(key);
70
+ throw err;
71
+ });
72
+ entry.promise.catch(() => {});
73
+ this.entries.set(key, entry);
74
+ this.evict();
75
+ return this.attach(key, entry, signal);
76
+ }
77
+ /** Resolved handle if present — synchronous peek for paint-time reuse. */
78
+ peek(key) {
79
+ const entry = this.entries.get(key);
80
+ if (entry?.handle) entry.used = ++this.tick;
81
+ return entry?.handle;
82
+ }
83
+ get size() {
84
+ return this.entries.size;
85
+ }
86
+ attach(key, entry, signal) {
87
+ if (signal?.aborted) return Promise.reject(abortReason(signal));
88
+ entry.refs += 1;
89
+ if (!signal) {
90
+ const release = () => {
91
+ entry.refs -= 1;
92
+ };
93
+ entry.promise.then(release, release);
94
+ return entry.promise;
95
+ }
96
+ let settled = false;
97
+ return new Promise((resolve, reject) => {
98
+ const onAbort = () => {
99
+ if (settled) return;
100
+ settled = true;
101
+ entry.refs -= 1;
102
+ if (entry.refs <= 0 && entry.handle === void 0) {
103
+ entry.abort.abort(abortReason(signal));
104
+ if (this.entries.get(key) === entry) this.entries.delete(key);
105
+ }
106
+ reject(abortReason(signal));
107
+ };
108
+ signal.addEventListener("abort", onAbort, { once: true });
109
+ entry.promise.then((handle) => {
110
+ if (settled) return;
111
+ settled = true;
112
+ entry.refs -= 1;
113
+ signal.removeEventListener("abort", onAbort);
114
+ resolve(handle);
115
+ }, (err) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ entry.refs -= 1;
119
+ signal.removeEventListener("abort", onAbort);
120
+ reject(err);
121
+ });
122
+ });
123
+ }
124
+ evict() {
125
+ if (this.entries.size <= this.maxEntries) return;
126
+ 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);
130
+ }
131
+ }
129
132
  };
130
- const RenderPluginPackage = {
131
- manifest,
132
- create: (registry, config) => new RenderPlugin(RENDER_PLUGIN_ID, registry, config),
133
- reducer: () => {
134
- },
135
- initialState: {}
133
+ function abortReason(signal) {
134
+ return signal.reason ?? new DOMException("aborted", "AbortError");
135
+ }
136
+ //#endregion
137
+ //#region src/tiles.ts
138
+ function tileGrid(page, scale, tileSize) {
139
+ return {
140
+ scale,
141
+ tileSize,
142
+ cols: Math.max(1, Math.ceil(page.width * scale / tileSize)),
143
+ rows: Math.max(1, Math.ceil(page.height * scale / tileSize))
144
+ };
145
+ }
146
+ /** One tile's edge in page points at its level. */
147
+ const tileSpanPt = (grid) => grid.tileSize / grid.scale;
148
+ /**
149
+ * The tiles of `grid` intersecting a y-down page-point rect, clamped to
150
+ * the page. An empty intersection yields an empty list.
151
+ */
152
+ function tilesInRect(grid, page, rect) {
153
+ const span = tileSpanPt(grid);
154
+ const x0 = Math.max(0, Math.floor(rect.x / span));
155
+ const y0 = Math.max(0, Math.floor(rect.y / span));
156
+ const x1 = Math.min(grid.cols - 1, Math.ceil((rect.x + rect.width) / span) - 1);
157
+ const y1 = Math.min(grid.rows - 1, Math.ceil((rect.y + rect.height) / span) - 1);
158
+ const out = [];
159
+ for (let iy = y0; iy <= y1; iy++) for (let ix = x0; ix <= x1; ix++) out.push({
160
+ ix,
161
+ iy
162
+ });
163
+ return out;
164
+ }
165
+ /** Paint rect: y-down page points, clamped to the page edge. */
166
+ function tilePaintRect(grid, page, c) {
167
+ const span = tileSpanPt(grid);
168
+ const x = c.ix * span;
169
+ const y = c.iy * span;
170
+ return {
171
+ x,
172
+ y,
173
+ width: Math.min(span, page.width - x),
174
+ height: Math.min(span, page.height - y)
175
+ };
176
+ }
177
+ /** Engine target rect: y-UP PDF user space (top > bottom). */
178
+ function tileEngineRect(grid, page, c) {
179
+ const paint = tilePaintRect(grid, page, c);
180
+ 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)
185
+ };
186
+ }
187
+ /**
188
+ * Does a set of PAINTED want-level tiles cover `region` (y-down points)?
189
+ * The retention release check: a retained source may leave the paint list
190
+ * once its visible footprint answers true here. Pure index arithmetic on
191
+ * the aligned grid — the whole reason the pyramid is aligned.
192
+ */
193
+ function regionCovered(grid, page, region, isPainted) {
194
+ if (region.width <= 0 || region.height <= 0) return true;
195
+ for (const c of tilesInRect(grid, page, region)) if (!isPainted(c)) return false;
196
+ return true;
197
+ }
198
+ /**
199
+ * Inflate a visible rect by a prefetch margin (fractions of the rect's own
200
+ * size per side), optionally velocity-biased: the margin stretches toward
201
+ * the direction of travel and shrinks behind it. Pure — the producer's
202
+ * velocity is just data.
203
+ */
204
+ function inflateRect(rect, margin, velocity) {
205
+ const mx = rect.width * margin;
206
+ const my = rect.height * margin;
207
+ let left = mx;
208
+ let right = mx;
209
+ let up = my;
210
+ let down = my;
211
+ if (velocity) {
212
+ if (velocity.dx > 0) {
213
+ right = mx * 2;
214
+ left = mx / 2;
215
+ } else if (velocity.dx < 0) {
216
+ left = mx * 2;
217
+ right = mx / 2;
218
+ }
219
+ if (velocity.dy > 0) {
220
+ down = my * 2;
221
+ up = my / 2;
222
+ } else if (velocity.dy < 0) {
223
+ up = my * 2;
224
+ down = my / 2;
225
+ }
226
+ }
227
+ return {
228
+ x: rect.x - left,
229
+ y: rect.y - up,
230
+ width: rect.width + left + right,
231
+ height: rect.height + up + down
232
+ };
233
+ }
234
+ /** Snap UP through a pyramid's sorted scales; cap at the top. */
235
+ function snapToPyramid(scales, needed) {
236
+ const sorted = [...scales].sort((a, b) => a - b);
237
+ return sorted.find((s) => s >= needed) ?? sorted[sorted.length - 1];
238
+ }
239
+ //#endregion
240
+ //#region src/tile-manager.ts
241
+ /**
242
+ * The tile retention state machine. One instance per
243
+ * plugin instance (per document), all levels and pages in one place, over
244
+ * the SAME RasterStore the base renders use.
245
+ *
246
+ * Invariant it enforces: every screen region paints the sharpest PAINTED
247
+ * pixels available; quality per region only goes up until the want set
248
+ * resolves. Mechanics:
249
+ * - 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.
258
+ * - epoch exception: an invalidation bump means retained pixels are
259
+ * WRONG — everything of the old epoch drops immediately.
260
+ *
261
+ * `plan()` is called from selectors: it MUST NOT dispatch. Fetch kickoff
262
+ * is idempotent (the store singleflights); resolution handlers dispatch
263
+ * the wake-up (`onAdvance`) that makes subscribed layers recompute.
264
+ */
265
+ var TileManager = class {
266
+ constructor(deps) {
267
+ this.deps = deps;
268
+ this.pages = /* @__PURE__ */ new Map();
269
+ }
270
+ plan(pon, demand, includeAnnotations) {
271
+ const { config } = this.deps;
272
+ const policy = this.deps.getPolicy();
273
+ const page = this.deps.getPageSize(pon);
274
+ if (!page) return EMPTY_TILE_PLAN;
275
+ const epoch = this.deps.getEpoch(pon, includeAnnotations);
276
+ const state = this.pageState(pon);
277
+ if (state.epoch !== epoch) {
278
+ this.abortAll(state);
279
+ state.entries.clear();
280
+ state.epoch = epoch;
281
+ state.wantScale = null;
282
+ state.planCache = null;
283
+ }
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) {
289
+ if (state.entries.size || state.wantScale !== null) {
290
+ this.abortAll(state);
291
+ state.entries.clear();
292
+ state.wantScale = null;
293
+ state.planCache = null;
294
+ }
295
+ return EMPTY_TILE_PLAN;
296
+ }
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);
300
+ const visible = demand.visibleRect ?? {
301
+ x: 0,
302
+ y: 0,
303
+ width: page.width,
304
+ height: page.height
305
+ };
306
+ state.lastVisible = visible;
307
+ const p0 = tilesInRect(grid, page, visible);
308
+ const ring = inflateRect(visible, config.prefetchMargin, config.velocityBias ? demand.velocity : void 0);
309
+ const p0Keys = new Set(p0.map((c) => coordKey(c)));
310
+ 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}`;
313
+ if (state.planCache && state.planCache.demandKey === demandKey && state.planCache.version === state.version) return state.planCache.plan;
314
+ const paint = [];
315
+ const fetching = [];
316
+ for (const entry of state.entries.values()) {
317
+ 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
+ });
326
+ } else if (entry.scale === wantScale) fetching.push(entry.key);
327
+ }
328
+ paint.sort((a, b) => a.z - b.z || a.key.localeCompare(b.key));
329
+ const plan = {
330
+ engaged: true,
331
+ paint,
332
+ fetching: fetching.sort(),
333
+ stamp: `${demandKey}|v${state.version}`
334
+ };
335
+ state.planCache = {
336
+ demandKey,
337
+ version: state.version,
338
+ plan
339
+ };
340
+ return plan;
341
+ }
342
+ /** The layer's decode-boundary report: this key's <img> actually painted. */
343
+ sourcePainted(pon, key) {
344
+ const state = this.pages.get(pon);
345
+ const entry = state?.entries.get(key);
346
+ if (!state || !entry || entry.painted) return;
347
+ entry.painted = true;
348
+ this.releaseOccluded(state, entry);
349
+ state.version += 1;
350
+ state.planCache = null;
351
+ this.deps.onAdvance(pon);
352
+ }
353
+ /** A lens unmounted its TileLayer: stop fetching, drop bookkeeping.
354
+ * Resolved bytes stay in the RasterStore for a re-mount. */
355
+ releasePage(pon) {
356
+ const state = this.pages.get(pon);
357
+ if (!state) return;
358
+ this.abortAll(state);
359
+ this.pages.delete(pon);
360
+ }
361
+ pageState(pon) {
362
+ let state = this.pages.get(pon);
363
+ if (!state) {
364
+ state = {
365
+ epoch: -1,
366
+ wantScale: null,
367
+ entries: /* @__PURE__ */ new Map(),
368
+ version: 0,
369
+ planCache: null,
370
+ settleTimer: null,
371
+ pendingLevel: null,
372
+ lastVisible: null
373
+ };
374
+ this.pages.set(pon, state);
375
+ }
376
+ return state;
377
+ }
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;
382
+ 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);
387
+ }
388
+ const kickoff = () => {
389
+ if (this.ensureFetches(pon, state, page, grid, includeAnnotations, epoch, p0)) this.ensureFetches(pon, state, page, grid, includeAnnotations, epoch, p1);
390
+ };
391
+ if (levelChanged && config.settleMs > 0) {
392
+ state.pendingLevel = wantScale;
393
+ if (state.settleTimer !== null) clearTimeout(state.settleTimer);
394
+ state.settleTimer = setTimeout(() => {
395
+ state.settleTimer = null;
396
+ if (state.pendingLevel === wantScale) kickoff();
397
+ }, config.settleMs);
398
+ return;
399
+ }
400
+ if (state.settleTimer !== null && !levelChanged && !firstEngage) {
401
+ clearTimeout(state.settleTimer);
402
+ state.settleTimer = null;
403
+ state.pendingLevel = null;
404
+ }
405
+ kickoff();
406
+ }
407
+ /** Start missing fetches; true when every coord is already resolved. */
408
+ ensureFetches(pon, state, page, grid, includeAnnotations, epoch, coords) {
409
+ let allReady = true;
410
+ for (const coord of coords) {
411
+ const key = this.tileKey(pon, grid.scale, coord, includeAnnotations, epoch);
412
+ const existing = state.entries.get(key);
413
+ if (existing) {
414
+ if (existing.handle === void 0) allReady = false;
415
+ continue;
416
+ }
417
+ allReady = false;
418
+ const abort = new AbortController();
419
+ const entry = {
420
+ key,
421
+ scale: grid.scale,
422
+ coord,
423
+ rect: tilePaintRect(grid, page, coord),
424
+ painted: false,
425
+ abort
426
+ };
427
+ 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) => {
429
+ if (state.entries.get(key) !== entry) return;
430
+ entry.handle = handle;
431
+ state.version += 1;
432
+ state.planCache = null;
433
+ this.deps.onAdvance(pon);
434
+ }, () => {
435
+ if (state.entries.get(key) === entry) state.entries.delete(key);
436
+ });
437
+ }
438
+ return allReady;
439
+ }
440
+ /**
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.
445
+ */
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);
452
+ const paintedAt = (c) => {
453
+ const key = this.tileKey(pagePonOf(painted.key), wantScale, c, annotationsOf(painted.key), state.epoch);
454
+ return state.entries.get(key)?.painted === true;
455
+ };
456
+ 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);
461
+ }
462
+ }
463
+ abortAll(state) {
464
+ if (state.settleTimer !== null) {
465
+ clearTimeout(state.settleTimer);
466
+ state.settleTimer = null;
467
+ }
468
+ for (const entry of state.entries.values()) if (entry.handle === void 0) entry.abort?.abort();
469
+ }
470
+ tileKey(pon, scale, c, includeAnnotations, epoch) {
471
+ return `t:${pon}|s${scale}|${c.ix},${c.iy}|a${includeAnnotations ? 1 : 0}|e${epoch}`;
472
+ }
136
473
  };
137
- export {
138
- RenderPlugin,
139
- RenderPluginPackage
474
+ const widthOf = (v) => v.kind === "width" && v.width !== void 0 ? v.width : Number.POSITIVE_INFINITY;
475
+ const coordKey = (c) => `${c.ix},${c.iy}`;
476
+ 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;
140
482
  };
141
- //# sourceMappingURL=index.js.map
483
+ const pagePonOf = (key) => Number(key.slice(2, key.indexOf("|")));
484
+ const annotationsOf = (key) => key.includes("|a1|");
485
+ //#endregion
486
+ //#region src/capability.ts
487
+ /**
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.
493
+ */
494
+ function createRenderCapability(ctx, options = {}) {
495
+ const store = new RasterStore();
496
+ const policy = () => ctx.document()?.renderPolicy ?? CONTINUOUS_RENDER_POLICY;
497
+ const pageOf = (pon) => (ctx.document()?.pages ?? []).find((p) => p.pageObjectNumber === pon);
498
+ const pageWidthOf = (pon) => {
499
+ const page = pageOf(pon);
500
+ if (!page) throw new Error(`render: unknown page object number ${pon}`);
501
+ return page.size.width;
502
+ };
503
+ const conformViewport = (pon, scale) => {
504
+ const p = policy();
505
+ if (p.kind !== "lattice") return {
506
+ kind: "scale",
507
+ scale
508
+ };
509
+ return snapFullPageViewport(p, {
510
+ kind: "scale",
511
+ scale
512
+ }, { pageWidth: pageWidthOf(pon) });
513
+ };
514
+ const viewportKey = (viewport) => viewport.kind === "width" ? `w${viewport.width}` : `s${viewport.scale ?? 1}`;
515
+ const epochOf = (pon, includeAnnotations) => {
516
+ const s = ctx.getState();
517
+ const content = s.contentEpochs[pon] ?? 0;
518
+ if (!includeAnnotations) return content;
519
+ return content + (s.annotatedEpochs[pon] ?? 0);
520
+ };
521
+ const tiles = new TileManager({
522
+ store,
523
+ config: resolveTiling(options.tiling),
524
+ getPolicy: policy,
525
+ getPageSize: (pon) => pageOf(pon)?.size,
526
+ getEpoch: epochOf,
527
+ fetchTile: (pon, rect, scale, includeAnnotations, signal) => {
528
+ const doc = ctx.doc;
529
+ if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
530
+ const task = doc.page(pon).render.image({
531
+ target: {
532
+ kind: "rect",
533
+ rect
534
+ },
535
+ viewport: {
536
+ kind: "scale",
537
+ scale
538
+ },
539
+ includeAnnotations
540
+ });
541
+ if (signal.aborted) task.abort(signal.reason);
542
+ else signal.addEventListener("abort", () => task.abort(signal.reason), { once: true });
543
+ return task;
544
+ },
545
+ onAdvance: (pon) => ctx.dispatch({
546
+ type: "PAINT_ADVANCED",
547
+ pon
548
+ })
549
+ });
550
+ return {
551
+ renderPage(pon, { scale, includeAnnotations, signal }) {
552
+ const doc = ctx.doc;
553
+ if (!doc) return Promise.reject(/* @__PURE__ */ new Error("render: no document bound"));
554
+ const annotations = includeAnnotations ?? true;
555
+ const viewport = conformViewport(pon, scale);
556
+ const key = `${pon}|${viewportKey(viewport)}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}`;
557
+ return store.acquire(key, (storeSignal) => {
558
+ const task = doc.page(pon).render.image({
559
+ viewport,
560
+ includeAnnotations: annotations
561
+ });
562
+ if (storeSignal.aborted) task.abort(storeSignal.reason);
563
+ else storeSignal.addEventListener("abort", () => task.abort(storeSignal.reason), { once: true });
564
+ return task;
565
+ }, signal);
566
+ },
567
+ renderSourceKey(pon, { scale, includeAnnotations }) {
568
+ const annotations = includeAnnotations ?? true;
569
+ const viewport = conformViewport(pon, scale);
570
+ return `${pon}|${viewportKey(viewport)}|a${annotations ? 1 : 0}|e${epochOf(pon, annotations)}`;
571
+ },
572
+ 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);
579
+ },
580
+ releaseTiles(pon) {
581
+ tiles.releasePage(pon);
582
+ },
583
+ renderEpoch(pon, includeAnnotations = true) {
584
+ return epochOf(pon, includeAnnotations);
585
+ },
586
+ invalidate({ pons, scope = "content" } = {}) {
587
+ const target = pons ?? (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber);
588
+ if (target.length) ctx.dispatch({
589
+ type: "INVALIDATE",
590
+ scope,
591
+ pons: target
592
+ });
593
+ }
594
+ };
595
+ }
596
+ //#endregion
597
+ //#region src/effects.ts
598
+ /**
599
+ * Which pages' ANNOTATED raster a confirmed mutation repaints — the whole
600
+ * event→scope map. Everything the engine emits today is appearance-scoped;
601
+ * when it grows content-mutation events (redaction.applied, content.edited),
602
+ * they get a case here dispatching scope 'content'. Over-invalidation is
603
+ * acceptable (a z-order move that changes nothing repaints one thumb);
604
+ * under-invalidation is the bug.
605
+ */
606
+ function annotatedPons(event, allPons) {
607
+ switch (event.type) {
608
+ case "annotation.created":
609
+ case "annotation.updated":
610
+ case "annotation.deleted":
611
+ case "annotation.moved": return [event.pageObjectNumber];
612
+ case "form.valueChanged":
613
+ case "form.effectsApplied": return event.changedWidgets.map((w) => w.pageObjectNumber);
614
+ case "form.fieldDeleted": return event.removedWidgets.map((w) => w.pageObjectNumber);
615
+ case "form.fieldCreated":
616
+ case "form.fieldUpdated":
617
+ case "form.widgetAttached":
618
+ case "form.widgetDetached": return event.field.widgets.map((w) => w.pageObjectNumber);
619
+ case "form.imported":
620
+ case "form.repaired": return allPons();
621
+ default: return [];
622
+ }
623
+ }
624
+ function registerRenderEffects(ctx) {
625
+ const doc = ctx.doc;
626
+ if (!doc) return;
627
+ const allPons = () => (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber);
628
+ const unsubscribe = doc.events.subscribe((event) => {
629
+ if (event.type === "redaction.applied") {
630
+ const pons = event.results.filter((r) => r.status === "applied").map((r) => r.pageObjectNumber);
631
+ if (pons.length) ctx.dispatch({
632
+ type: "INVALIDATE",
633
+ scope: "content",
634
+ pons
635
+ });
636
+ return;
637
+ }
638
+ const pons = annotatedPons(event, allPons);
639
+ if (pons.length) ctx.dispatch({
640
+ type: "INVALIDATE",
641
+ scope: "annotations",
642
+ pons
643
+ });
644
+ });
645
+ ctx.cleanup(unsubscribe);
646
+ }
647
+ //#endregion
648
+ //#region src/reducer.ts
649
+ const initialRenderState = () => ({
650
+ contentEpochs: {},
651
+ annotatedEpochs: {},
652
+ paintVersions: {}
653
+ });
654
+ const bump = (epochs, pons) => {
655
+ const next = { ...epochs };
656
+ for (const pon of pons) next[pon] = (next[pon] ?? 0) + 1;
657
+ return next;
658
+ };
659
+ /** Pure. Two transitions: bump the touched pages' ledger for the fact's
660
+ * scope (one action per fact, so a batch result bumps each pon once — a
661
+ * 'content' fact bumps ONLY the content ledger; annotated readers sum both,
662
+ * which is how content invalidation reaches them too), and the tile
663
+ * wake-up counter. The render POLICY is deliberately NOT here — it's a
664
+ * document fact on `DocumentMeta`, materialized by the kernel at open. */
665
+ const renderReducer = (state, a) => {
666
+ if (a.type === "PAINT_ADVANCED") return {
667
+ ...state,
668
+ paintVersions: bump(state.paintVersions, [a.pon])
669
+ };
670
+ if (a.type !== "INVALIDATE" || a.pons.length === 0) return state;
671
+ return a.scope === "content" ? {
672
+ ...state,
673
+ contentEpochs: bump(state.contentEpochs, a.pons)
674
+ } : {
675
+ ...state,
676
+ annotatedEpochs: bump(state.annotatedEpochs, a.pons)
677
+ };
678
+ };
679
+ //#endregion
680
+ //#region src/types.ts
681
+ const RenderToken = createCapabilityToken("render");
682
+ //#endregion
683
+ //#region src/render.plugin.ts
684
+ /**
685
+ * Document-scoped. The ONE policy consumer in the client stack: the doc-bind
686
+ * effect resolves the engine's advertised render
687
+ * policy; `renderPage` conforms desired scales to it and collapses
688
+ * same-rung asks in the raster store; the tile manager turns host-supplied
689
+ * demand into a retention-safe paint plan over the SAME store. State is the
690
+ * per-page ledger — raster versions (two doors: the document event stream's
691
+ * built-in map and the `invalidate` verb), the policy latch, and the tile
692
+ * wake-up counter. Layers key on `renderSourceKey`/`tilePlan` and refetch
693
+ * exactly when those change.
694
+ */
695
+ const renderPlugin = (options = {}) => definePlugin({
696
+ id: "render",
697
+ scope: "document",
698
+ token: RenderToken,
699
+ initialState: initialRenderState,
700
+ reduce: renderReducer,
701
+ capability: (ctx) => createRenderCapability(ctx, options),
702
+ effects: registerRenderEffects
703
+ });
704
+ //#endregion
705
+ export { RenderToken, renderPlugin };
706
+
707
+ //# sourceMappingURL=index.js.map