@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -0,0 +1,121 @@
1
+ import type {
2
+ RuntimeBlockFragment,
3
+ RuntimePageAnchor,
4
+ RuntimePageGraph,
5
+ RuntimePageNode,
6
+ RuntimePageRegion,
7
+ RuntimePageRegions,
8
+ } from "../layout/page-graph.ts";
9
+
10
+ /**
11
+ * L7 Phase 2.5 Task 2.5.3 — graph canonicalization (determinism fix).
12
+ *
13
+ * `src/runtime/layout/page-graph.ts` increments a module-level
14
+ * `graphRevision` counter on every `buildPageGraph()` call and embeds it
15
+ * into `pageId = page-${revision}-${index}` and dependent
16
+ * `fragmentId = ${pageId}-<suffix>` strings. Two consecutive
17
+ * `prerenderDocument(buf)` calls in the same process would therefore emit
18
+ * different pageIds — the cacheBlob hash would flip on every call even
19
+ * though the document bytes were identical.
20
+ *
21
+ * This module rewrites all identifier strings to a canonical revision-free
22
+ * shape:
23
+ * `page-${index}` (canonical pageId)
24
+ * `page-${index}-<suffix>` (canonical fragmentId)
25
+ *
26
+ * The live runtime's graphRevision contract stays intact — only the
27
+ * serialized cache envelope is canonicalized.
28
+ */
29
+
30
+ const PAGE_PREFIX = "page-";
31
+
32
+ export function canonicalizeGraph(graph: RuntimePageGraph): RuntimePageGraph {
33
+ // Build a map from live pageId → canonical pageId ("page-<index>").
34
+ const rename = new Map<string, string>();
35
+ for (const page of graph.pages) {
36
+ rename.set(page.pageId, `${PAGE_PREFIX}${page.pageIndex}`);
37
+ }
38
+
39
+ const rewriteId = (id: string): string => {
40
+ // pageIds map directly.
41
+ const direct = rename.get(id);
42
+ if (direct !== undefined) return direct;
43
+ // fragmentIds have shape `${livePageId}-<suffix>`; find the matching
44
+ // live prefix and swap it for the canonical pageId.
45
+ for (const [livePageId, canonPageId] of rename) {
46
+ if (id.startsWith(`${livePageId}-`)) {
47
+ return `${canonPageId}${id.slice(livePageId.length)}`;
48
+ }
49
+ }
50
+ return id;
51
+ };
52
+
53
+ const canonicalPages: RuntimePageNode[] = graph.pages.map((page) => ({
54
+ ...page,
55
+ pageId: rewriteId(page.pageId),
56
+ regions: rewriteRegions(page.regions, rewriteId),
57
+ lineBoxes: page.lineBoxes.map((line) => ({
58
+ ...line,
59
+ fragmentId: rewriteId(line.fragmentId),
60
+ })),
61
+ noteAllocations: page.noteAllocations.map((note) =>
62
+ note.fragmentId === undefined
63
+ ? note
64
+ : { ...note, fragmentId: rewriteId(note.fragmentId) },
65
+ ),
66
+ }));
67
+
68
+ const canonicalFragments: RuntimeBlockFragment[] = graph.fragments.map((fragment) => ({
69
+ ...fragment,
70
+ fragmentId: rewriteId(fragment.fragmentId),
71
+ pageId: rewriteId(fragment.pageId),
72
+ }));
73
+
74
+ const canonicalAnchors: RuntimePageAnchor[] = graph.anchors.map((anchor) => ({
75
+ ...anchor,
76
+ pageId: rewriteId(anchor.pageId),
77
+ fragmentId: anchor.fragmentId === undefined ? undefined : rewriteId(anchor.fragmentId),
78
+ }));
79
+
80
+ return {
81
+ ...graph,
82
+ revision: 0,
83
+ pages: canonicalPages,
84
+ fragments: canonicalFragments,
85
+ anchors: canonicalAnchors,
86
+ };
87
+ }
88
+
89
+ function rewriteRegions(
90
+ regions: RuntimePageRegions,
91
+ rewriteId: (id: string) => string,
92
+ ): RuntimePageRegions {
93
+ const result: {
94
+ body: RuntimePageRegion;
95
+ header?: RuntimePageRegion;
96
+ footer?: RuntimePageRegion;
97
+ columns?: RuntimePageRegion[];
98
+ footnotes?: RuntimePageRegion[];
99
+ } = {
100
+ body: rewriteRegion(regions.body, rewriteId),
101
+ };
102
+ if (regions.header !== undefined) result.header = rewriteRegion(regions.header, rewriteId);
103
+ if (regions.footer !== undefined) result.footer = rewriteRegion(regions.footer, rewriteId);
104
+ if (regions.columns !== undefined) {
105
+ result.columns = regions.columns.map((region) => rewriteRegion(region, rewriteId));
106
+ }
107
+ if (regions.footnotes !== undefined) {
108
+ result.footnotes = regions.footnotes.map((region) => rewriteRegion(region, rewriteId));
109
+ }
110
+ return result;
111
+ }
112
+
113
+ function rewriteRegion(
114
+ region: RuntimePageRegion,
115
+ rewriteId: (id: string) => string,
116
+ ): RuntimePageRegion {
117
+ return {
118
+ ...region,
119
+ fragmentIds: region.fragmentIds.map(rewriteId),
120
+ };
121
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * L7 Phase 2.5 Task 2.5.2 — IndexedDB cache backend (Plan A).
3
+ *
4
+ * Validation-stage storage for the prerender cache. Stores the cache
5
+ * envelope (see cache-envelope.ts in Task 2.5.3) keyed by the SHA-256
6
+ * cacheKey from cache-key.ts. LRU eviction caps the store at
7
+ * LAYCACHE_MAX_ENTRIES so the cache never unbounds.
8
+ *
9
+ * Uses the Web IndexedDB API directly — no idb wrapper, keeping this
10
+ * module dependency-free. Tests use `fake-indexeddb/auto` which installs
11
+ * a spec-compliant in-memory IDB on globalThis.
12
+ *
13
+ * Plan B (customXml persistence) will layer on top of this: customXml is
14
+ * consulted first, IndexedDB second. For Plan A, this is the only backend.
15
+ */
16
+
17
+ const DB_NAME = "beyondwork-laycache";
18
+ const DB_VERSION = 1;
19
+ const STORE_NAME = "envelopes";
20
+ const ACCESSED_AT_INDEX = "accessedAt";
21
+
22
+ export const LAYCACHE_MAX_ENTRIES = 100;
23
+
24
+ interface CacheRecord<T> {
25
+ readonly cacheKey: string;
26
+ readonly envelope: T;
27
+ readonly accessedAt: number;
28
+ }
29
+
30
+ let dbPromise: Promise<IDBDatabase> | null = null;
31
+
32
+ /**
33
+ * Monotonic "logical timestamp" for LRU ordering. `Date.now()` returns the
34
+ * same millisecond for writes issued in rapid succession (common in tests
35
+ * and in our own LRU-eviction scan), which makes cursor-based eviction
36
+ * non-deterministic when entries collide. A process-lifetime counter
37
+ * produces strictly increasing values regardless of wall-clock resolution.
38
+ * Persisted across DB opens: the store's max stored `accessedAt` seeds the
39
+ * counter on first open so a reopen still respects prior recency order.
40
+ */
41
+ let accessedAtCounter = 0;
42
+
43
+ function nextAccessedAt(): number {
44
+ accessedAtCounter += 1;
45
+ return accessedAtCounter;
46
+ }
47
+
48
+ function requestIndexedDB(): IDBFactory {
49
+ const factory = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
50
+ if (!factory) {
51
+ throw new Error(
52
+ "indexeddb-cache: globalThis.indexedDB is undefined. In tests, import 'fake-indexeddb/auto'.",
53
+ );
54
+ }
55
+ return factory;
56
+ }
57
+
58
+ function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
59
+ return new Promise((resolve, reject) => {
60
+ request.onsuccess = () => resolve(request.result);
61
+ request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: request failed"));
62
+ });
63
+ }
64
+
65
+ function promisifyTransaction(tx: IDBTransaction): Promise<void> {
66
+ return new Promise((resolve, reject) => {
67
+ tx.oncomplete = () => resolve();
68
+ tx.onerror = () => reject(tx.error ?? new Error("indexeddb-cache: transaction failed"));
69
+ tx.onabort = () => reject(tx.error ?? new Error("indexeddb-cache: transaction aborted"));
70
+ });
71
+ }
72
+
73
+ function openDatabase(): Promise<IDBDatabase> {
74
+ if (dbPromise !== null) return dbPromise;
75
+ dbPromise = new Promise((resolve, reject) => {
76
+ const request = requestIndexedDB().open(DB_NAME, DB_VERSION);
77
+ request.onupgradeneeded = () => {
78
+ const db = request.result;
79
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
80
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "cacheKey" });
81
+ store.createIndex(ACCESSED_AT_INDEX, "accessedAt", { unique: false });
82
+ }
83
+ };
84
+ request.onsuccess = () => {
85
+ const db = request.result;
86
+ void seedCounterFromStore(db).then(() => resolve(db));
87
+ };
88
+ request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: open failed"));
89
+ });
90
+ return dbPromise;
91
+ }
92
+
93
+ async function seedCounterFromStore(db: IDBDatabase): Promise<void> {
94
+ // On first open, initialise the counter from the highest persisted
95
+ // `accessedAt`. On subsequent opens inside the same process we keep the
96
+ // existing counter — it's already ahead of anything in the store.
97
+ if (accessedAtCounter > 0) return;
98
+ const tx = db.transaction(STORE_NAME, "readonly");
99
+ const index = tx.objectStore(STORE_NAME).index(ACCESSED_AT_INDEX);
100
+ const request = index.openCursor(null, "prev");
101
+ await new Promise<void>((resolve, reject) => {
102
+ request.onsuccess = () => {
103
+ const cursor = request.result;
104
+ if (cursor) {
105
+ const value = cursor.value as CacheRecord<unknown>;
106
+ if (typeof value.accessedAt === "number" && value.accessedAt > accessedAtCounter) {
107
+ accessedAtCounter = value.accessedAt;
108
+ }
109
+ }
110
+ resolve();
111
+ };
112
+ request.onerror = () =>
113
+ reject(request.error ?? new Error("indexeddb-cache: seedCounterFromStore failed"));
114
+ });
115
+ }
116
+
117
+ export async function readCache<T>(cacheKey: string): Promise<T | null> {
118
+ const db = await openDatabase();
119
+ const tx = db.transaction(STORE_NAME, "readwrite");
120
+ const store = tx.objectStore(STORE_NAME);
121
+ const existing = (await promisifyRequest(store.get(cacheKey))) as CacheRecord<T> | undefined;
122
+ if (!existing) {
123
+ await promisifyTransaction(tx);
124
+ return null;
125
+ }
126
+ const touched: CacheRecord<T> = { ...existing, accessedAt: nextAccessedAt() };
127
+ store.put(touched);
128
+ await promisifyTransaction(tx);
129
+ return existing.envelope;
130
+ }
131
+
132
+ export async function writeCache<T>(cacheKey: string, envelope: T): Promise<void> {
133
+ const db = await openDatabase();
134
+ const tx = db.transaction(STORE_NAME, "readwrite");
135
+ const store = tx.objectStore(STORE_NAME);
136
+ const record: CacheRecord<T> = { cacheKey, envelope, accessedAt: nextAccessedAt() };
137
+ store.put(record);
138
+ const count = await promisifyRequest(store.count());
139
+ if (count > LAYCACHE_MAX_ENTRIES) {
140
+ const toDrop = count - LAYCACHE_MAX_ENTRIES;
141
+ await evictOldest(store, toDrop);
142
+ }
143
+ await promisifyTransaction(tx);
144
+ }
145
+
146
+ async function evictOldest(store: IDBObjectStore, count: number): Promise<void> {
147
+ if (count <= 0) return;
148
+ const index = store.index(ACCESSED_AT_INDEX);
149
+ let remaining = count;
150
+ await new Promise<void>((resolve, reject) => {
151
+ const request = index.openCursor();
152
+ request.onsuccess = () => {
153
+ const cursor = request.result;
154
+ if (!cursor || remaining === 0) {
155
+ resolve();
156
+ return;
157
+ }
158
+ cursor.delete();
159
+ remaining -= 1;
160
+ cursor.continue();
161
+ };
162
+ request.onerror = () =>
163
+ reject(request.error ?? new Error("indexeddb-cache: eviction cursor failed"));
164
+ });
165
+ }
166
+
167
+ export async function clearCache(): Promise<void> {
168
+ const db = await openDatabase();
169
+ const tx = db.transaction(STORE_NAME, "readwrite");
170
+ tx.objectStore(STORE_NAME).clear();
171
+ await promisifyTransaction(tx);
172
+ }
173
+
174
+ /**
175
+ * Close the cached connection. Used by tests to tear down between cases so
176
+ * the singleton dbPromise does not leak state across runs. Production code
177
+ * can ignore this — the connection is process-scoped and lives until exit.
178
+ */
179
+ export async function closeCacheDatabase(): Promise<void> {
180
+ if (dbPromise === null) return;
181
+ const db = await dbPromise;
182
+ db.close();
183
+ dbPromise = null;
184
+ }
@@ -0,0 +1,145 @@
1
+ import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
+ import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
3
+ import { loadDocxEditorSessionAsync } from "../../io/docx-session.ts";
4
+ import { createLoadScheduler } from "../../io/load-scheduler.ts";
5
+ import {
6
+ LAYCACHE_SCHEMA_VERSION,
7
+ LAYOUT_ENGINE_VERSION,
8
+ } from "../layout/layout-engine-version.ts";
9
+ import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
10
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
11
+ import type { CacheEnvelope } from "./cache-envelope.ts";
12
+ import {
13
+ computeStructuralHash,
14
+ deriveCacheKey,
15
+ type CacheKeyBlock,
16
+ } from "./cache-key.ts";
17
+ import { resolveFontFingerprint } from "./font-fingerprint.ts";
18
+ import { canonicalizeGraph } from "./graph-canonicalize.ts";
19
+
20
+ /**
21
+ * L7 Phase 2.5 Task 2.5.3 — prerenderDocument public API.
22
+ *
23
+ * Runs the parse + layout pipeline for a .docx buffer without mounting
24
+ * React, PM, or any DOM. Produces a CacheEnvelope suitable for writing to
25
+ * the IndexedDB backend (Plan A) or the `laycache` customXml namespace
26
+ * (Plan B, follow-up PR).
27
+ *
28
+ * Determinism contract:
29
+ * - Identical input bytes produce identical cacheKey on two sequential
30
+ * calls. Two sources of non-determinism are handled:
31
+ * (1) `page-graph.ts` graphRevision counter — stripped by
32
+ * `canonicalizeGraph`.
33
+ * (2) A fixed `documentId` ("prerender") is passed to the loader so
34
+ * `createCanonicalDocumentId` is deterministic. Timestamps in
35
+ * `canonicalDocument.createdAt/updatedAt` are loader-controlled
36
+ * but do not enter the envelope.
37
+ *
38
+ * Node-safety:
39
+ * - Uses `createLoadScheduler({ backendOverride: "sync" })` so the
40
+ * async loader runs without DOM scheduler.yield.
41
+ * - Layout engine is constructed with
42
+ * `autoUpgradeToCanvasBackend: false` so the empirical measurement
43
+ * backend is used (no Canvas2D, no node-canvas).
44
+ */
45
+
46
+ export interface PrerenderOptions {
47
+ readonly fontFingerprint?: string;
48
+ /**
49
+ * Plan B hook — when true, prerenderDocument will inject the cache
50
+ * envelope into the document's `laycache` customXml namespace and
51
+ * return the re-serialized bytes in `docWithCustomXml`. In Plan A
52
+ * (this task) the flag is accepted for API stability but ignored;
53
+ * `docWithCustomXml` returns the input unchanged.
54
+ */
55
+ readonly persistToCustomXml?: boolean;
56
+ }
57
+
58
+ export interface PrerenderCounters {
59
+ readonly blockCount: number;
60
+ readonly pageCount: number;
61
+ readonly prerenderMs: number;
62
+ }
63
+
64
+ export interface PrerenderResult {
65
+ readonly docWithCustomXml: Uint8Array;
66
+ readonly cacheBlob: CacheEnvelope;
67
+ readonly cacheKey: string;
68
+ readonly counters: PrerenderCounters;
69
+ }
70
+
71
+ const PRERENDER_DOCUMENT_ID = "prerender";
72
+
73
+ function toUint8Array(input: ArrayBuffer | Uint8Array): Uint8Array {
74
+ if (input instanceof Uint8Array) return input;
75
+ return new Uint8Array(input);
76
+ }
77
+
78
+ function blocksToCacheKeyBlocks(surface: EditorSurfaceSnapshot): CacheKeyBlock[] {
79
+ return surface.blocks.map((block) => ({ kind: block.kind, blockId: block.blockId }));
80
+ }
81
+
82
+ export async function prerenderDocument(
83
+ input: ArrayBuffer | Uint8Array,
84
+ options: PrerenderOptions = {},
85
+ ): Promise<PrerenderResult> {
86
+ const t0 = performance.now();
87
+ const bytes = toUint8Array(input);
88
+ const fontFingerprint = options.fontFingerprint ?? resolveFontFingerprint();
89
+
90
+ const scheduler = createLoadScheduler({ backendOverride: "sync" });
91
+ let session;
92
+ try {
93
+ session = await loadDocxEditorSessionAsync({
94
+ documentId: PRERENDER_DOCUMENT_ID,
95
+ bytes,
96
+ editorBuild: "prerender",
97
+ scheduler,
98
+ });
99
+ } finally {
100
+ scheduler.dispose();
101
+ }
102
+
103
+ if (session.fatalError) {
104
+ throw new Error(
105
+ `prerenderDocument: failed to load input — ${session.fatalError.message ?? "fatal error"}`,
106
+ );
107
+ }
108
+
109
+ const envelope = session.initialSessionState.canonicalDocument;
110
+ const surface = createEditorSurfaceSnapshot(envelope, createSelectionSnapshot(), { kind: "main" });
111
+
112
+ const engine = createLayoutEngine({ autoUpgradeToCanvasBackend: false });
113
+ const rawGraph = engine.getPageGraph({ document: envelope });
114
+ const graph = canonicalizeGraph(rawGraph);
115
+
116
+ const structuralHash = await computeStructuralHash(blocksToCacheKeyBlocks(surface));
117
+ const cacheKey = await deriveCacheKey({
118
+ blocks: blocksToCacheKeyBlocks(surface),
119
+ fontFingerprint,
120
+ engineVersion: LAYOUT_ENGINE_VERSION,
121
+ schemaVersion: LAYCACHE_SCHEMA_VERSION,
122
+ });
123
+
124
+ const cacheBlob: CacheEnvelope = {
125
+ schemaVersion: LAYCACHE_SCHEMA_VERSION,
126
+ engineVersion: LAYOUT_ENGINE_VERSION,
127
+ fontFingerprint,
128
+ structuralHash,
129
+ graph,
130
+ surface,
131
+ };
132
+
133
+ const prerenderMs = performance.now() - t0;
134
+
135
+ return {
136
+ docWithCustomXml: bytes,
137
+ cacheBlob,
138
+ cacheKey,
139
+ counters: {
140
+ blockCount: surface.blocks.length,
141
+ pageCount: graph.pages.length,
142
+ prerenderMs,
143
+ },
144
+ };
145
+ }
@@ -10,6 +10,7 @@
10
10
  * emitted by `src/runtime/surface-projection.ts`:
11
11
  * - `paragraph-*` → "paragraph"
12
12
  * - `table-*` → "table"
13
+ * - `placeholder-culled-*` → "opaque" (viewport-culled size-preserving stub)
13
14
  * - `opaque-*` → "opaque"
14
15
  * - `section-break-*` → "opaque" (read-only boundary marker)
15
16
  * - `sdt-*`, `sdt-wrapper-*` → "opaque"
@@ -25,6 +26,7 @@ import type { RenderBlock } from "./render-frame-types.ts";
25
26
  export function classifyBlockKind(blockId: string): RenderBlock["kind"] {
26
27
  if (blockId.startsWith("paragraph")) return "paragraph";
27
28
  if (blockId.startsWith("table")) return "table";
29
+ if (blockId.startsWith("placeholder-culled-")) return "opaque";
28
30
  if (blockId.startsWith("opaque")) return "opaque";
29
31
  if (blockId.startsWith("section-break")) return "opaque";
30
32
  if (blockId.startsWith("sdt")) return "opaque";
@@ -56,6 +56,23 @@ export interface RenderPageRegions {
56
56
  footer?: RenderStoryRegion;
57
57
  columns?: readonly RenderStoryRegion[];
58
58
  footnoteArea?: RenderStoryRegion;
59
+ /**
60
+ * P8.3 — footnote areas reserved at the bottom of the page (above the
61
+ * footer band). Mirrors the page graph's `RuntimePageRegions.footnotes`
62
+ * — one entry per allocated region (typically one per page today, but
63
+ * shape allows for multiple should allocation-splitting land).
64
+ * Populated only when `PublicPageRegions.footnotes` is non-empty.
65
+ * Additive — back-compat safe; consumers that ignore this field see no
66
+ * change.
67
+ */
68
+ footnotes?: readonly RenderStoryRegion[];
69
+ /**
70
+ * P8.3 — endnote regions. Reserved seam; per-page endnote projection is
71
+ * NOT populated today (endnotes use document-end placement via
72
+ * `facet.getDocumentEndnoteBlocks()`). Shape exists so a future
73
+ * per-section endnote renderer has a stable read surface.
74
+ */
75
+ endnotes?: readonly RenderStoryRegion[];
59
76
  }
60
77
 
61
78
  export interface RenderStoryRegion {