@beyondwork/docx-react-component 1.0.43 → 1.0.46

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 (50) hide show
  1. package/README.md +35 -1
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +156 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +351 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/event-refresh-hints.ts +1 -0
  26. package/src/runtime/layout/docx-font-loader.ts +30 -11
  27. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  28. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  29. package/src/runtime/layout/layout-engine-version.ts +41 -0
  30. package/src/runtime/layout/public-facet.ts +30 -0
  31. package/src/runtime/prerender/cache-envelope.ts +29 -0
  32. package/src/runtime/prerender/cache-key.ts +66 -0
  33. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  34. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  35. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  36. package/src/runtime/prerender/prerender-document.ts +145 -0
  37. package/src/runtime/render/block-fragment-projection.ts +2 -0
  38. package/src/runtime/selection/post-edit-validator.ts +77 -0
  39. package/src/runtime/surface-projection.ts +35 -2
  40. package/src/ui/WordReviewEditor.tsx +75 -192
  41. package/src/ui/editor-runtime-boundary.ts +5 -1
  42. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  44. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  45. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  46. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  47. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  48. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  49. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  50. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Layout engine version marker — bump when **any** file under
3
+ * `src/runtime/layout/**` or `src/runtime/render/**` changes, or when
4
+ * the page-break widget DOM shape under `src/ui-tailwind/editor-surface/`
5
+ * changes in a way that affects cached render-frame diffs or persisted
6
+ * layout caches.
7
+ *
8
+ * Enforcement: `scripts/ci-check-layout-engine-version.mjs` inspects the
9
+ * PR diff; if any file in the watched trees is touched without this
10
+ * constant being co-touched, CI fails. See CLAUDE.md → *Performance
11
+ * Invariants* for the full contract.
12
+ *
13
+ * Persisted caches should key their stored snapshots on this version so a
14
+ * bump automatically invalidates stale entries — no corruption path
15
+ * exists because the version is the cache's top-level discriminator.
16
+ *
17
+ * History:
18
+ * 1 — initial materialization (L8 Phase B).
19
+ * 2 — L8 Phase B retired the page-posture widget's inline band-gap-band
20
+ * DOM in favor of a transparent spacer. Widget DOM shape changed;
21
+ * cached render frames from version 1 are incompatible.
22
+ * 3 — L7 Phase 2.5 Plan A. Adds `seedCachedGraph(graph, document)` to
23
+ * `LayoutEngineInstance` so prerender-cache consumers can seed the
24
+ * internal cachedGraph + cachedKey without triggering fullRebuild.
25
+ * Does not change geometry — but the public interface changed, so
26
+ * persisted envelopes MUST re-derive their cacheKey under 3.
27
+ */
28
+ export const LAYOUT_ENGINE_VERSION = 3 as const;
29
+
30
+ /**
31
+ * Serialization schema version for the LayCache payload (the cache envelope
32
+ * stored in IndexedDB, and — post Plan B — the customXml editor-state
33
+ * namespace). Bump independently of LAYOUT_ENGINE_VERSION when the
34
+ * envelope shape changes but the layout engine itself has not.
35
+ *
36
+ * History:
37
+ * 1 — initial envelope shape: { schemaVersion, engineVersion,
38
+ * fontFingerprint, structuralHash, graph, surface }. Ships with
39
+ * L7 Phase 2.5 Plan A.
40
+ */
41
+ export const LAYCACHE_SCHEMA_VERSION = 1 as const;
@@ -564,6 +564,19 @@ export interface WordReviewEditorLayoutFacet {
564
564
  getDirtyFieldFamilies(): readonly string[];
565
565
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
566
566
 
567
+ // Viewport culling (L7 Phase 2) ----------------------------------------
568
+ /**
569
+ * Notifies the runtime that the visible block range changed. Delegates to
570
+ * `DocumentRuntime.setVisibleBlockRange`. Safe to call at any frequency;
571
+ * identical ranges are a no-op inside the runtime.
572
+ */
573
+ setVisibleBlockRange(range: { start: number; end: number }): void;
574
+ /**
575
+ * Triggers a surface-only refresh applying the latest visible block range.
576
+ * Delegates to `DocumentRuntime.requestViewportRefresh`.
577
+ */
578
+ requestViewportRefresh(): void;
579
+
567
580
  // Events ---------------------------------------------------------------
568
581
  subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
569
582
  }
@@ -614,6 +627,14 @@ export interface CreateLayoutFacetInput {
614
627
  | readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
615
628
  | null
616
629
  | undefined;
630
+ /**
631
+ * L7 Phase 2 — optional viewport culling hooks wired from the owning
632
+ * `DocumentRuntime`. When supplied, `facet.setVisibleBlockRange` /
633
+ * `facet.requestViewportRefresh` delegate directly to these; when omitted
634
+ * (e.g. tests, inert facet, standalone use) both methods are no-ops.
635
+ */
636
+ setVisibleBlockRange?: (range: { start: number; end: number }) => void;
637
+ requestViewportRefresh?: () => void;
617
638
  /**
618
639
  * R3 — optional suggestions snapshot accessor. Used by
619
640
  * `getAllScopeCardModels` to attach `SuggestionGroup` entries whose
@@ -1231,6 +1252,15 @@ export function createLayoutFacet(
1231
1252
  };
1232
1253
  },
1233
1254
 
1255
+ // L7 Phase 2 — viewport culling delegates
1256
+ setVisibleBlockRange(range) {
1257
+ input.setVisibleBlockRange?.(range);
1258
+ },
1259
+
1260
+ requestViewportRefresh() {
1261
+ input.requestViewportRefresh?.();
1262
+ },
1263
+
1234
1264
  subscribe(listener) {
1235
1265
  listeners.add(listener);
1236
1266
  return () => {
@@ -0,0 +1,29 @@
1
+ import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
+ import type { RuntimePageGraph } from "../layout/page-graph.ts";
3
+
4
+ /**
5
+ * L7 Phase 2.5 Task 2.5.3 — prerender cache envelope shape.
6
+ *
7
+ * The envelope is the unit written to IndexedDB (Plan A) and — after Plan B
8
+ * ships — to the `laycache` customXml editor-state namespace. Two consumers
9
+ * must agree on this shape: the prerender pipeline that populates it, and
10
+ * the warm-path loader that rehydrates it.
11
+ *
12
+ * Load-time invariants checked by consumers before trusting the envelope:
13
+ * - `schemaVersion === LAYCACHE_SCHEMA_VERSION` — bump invalidates
14
+ * - `engineVersion === LAYOUT_ENGINE_VERSION` — bump invalidates
15
+ * - `graph.revision === 0` — canonical marker
16
+ *
17
+ * The envelope MUST be structured-clone-safe because IndexedDB and Plan B's
18
+ * customXml path both rely on structured-clone semantics. Keep fields as
19
+ * JSON-serializable primitives, plain objects, or arrays — no class
20
+ * instances, functions, or symbols.
21
+ */
22
+ export interface CacheEnvelope {
23
+ readonly schemaVersion: number;
24
+ readonly engineVersion: number;
25
+ readonly fontFingerprint: string;
26
+ readonly structuralHash: string;
27
+ readonly graph: RuntimePageGraph;
28
+ readonly surface: EditorSurfaceSnapshot;
29
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * L7 Phase 2.5 Task 2.5.1 — prerender cache-key derivation.
3
+ *
4
+ * The cache key is the composite identity the IndexedDB (Plan A) and
5
+ * customXml (Plan B) backends index on. It has five inputs:
6
+ *
7
+ * 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
8
+ * Stable across text-only edits; changes on
9
+ * insert/delete/reorder because blockIds are
10
+ * kind-counter pairs (paragraph-5 stays 5
11
+ * under typing, shifts to paragraph-6 after
12
+ * an insert).
13
+ * 2. fontFingerprint — identifies the measurement-backend + font-
14
+ * metric source. "empirical-backend" in Plan A;
15
+ * a real font-derived string after Phase 8.
16
+ * 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
17
+ * layout/layout-engine-version.ts. Bumped by
18
+ * CI gate on any layout/render shape change.
19
+ * 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope format.
20
+ *
21
+ * Returns a 64-char lower-case hex digest. Uses the Web Crypto API
22
+ * (globalThis.crypto.subtle), available in Node 18+ and all target browsers —
23
+ * keeping src/** free of node: builtins so the shipped browser bundle stays
24
+ * clean.
25
+ */
26
+ export interface CacheKeyBlock {
27
+ readonly kind: string;
28
+ readonly blockId: string;
29
+ }
30
+
31
+ export interface CacheKeyInputs {
32
+ readonly blocks: readonly CacheKeyBlock[];
33
+ readonly fontFingerprint: string;
34
+ readonly engineVersion: string | number;
35
+ readonly schemaVersion: number;
36
+ }
37
+
38
+ const BLOCK_SEPARATOR = "\u0000";
39
+ const FIELD_SEPARATOR = "|";
40
+ const textEncoder = new TextEncoder();
41
+
42
+ async function sha256Hex(input: string): Promise<string> {
43
+ const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
44
+ const bytes = new Uint8Array(digest);
45
+ let hex = "";
46
+ for (let i = 0; i < bytes.length; i++) {
47
+ hex += bytes[i]!.toString(16).padStart(2, "0");
48
+ }
49
+ return hex;
50
+ }
51
+
52
+ export async function computeStructuralHash(blocks: readonly CacheKeyBlock[]): Promise<string> {
53
+ const payload = blocks.map((block) => `${block.kind}:${block.blockId}`).join(BLOCK_SEPARATOR);
54
+ return sha256Hex(payload);
55
+ }
56
+
57
+ export async function deriveCacheKey(inputs: CacheKeyInputs): Promise<string> {
58
+ const structuralHash = await computeStructuralHash(inputs.blocks);
59
+ const composite = [
60
+ structuralHash,
61
+ inputs.fontFingerprint,
62
+ String(inputs.engineVersion),
63
+ String(inputs.schemaVersion),
64
+ ].join(FIELD_SEPARATOR);
65
+ return sha256Hex(composite);
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * L7 Phase 2.5 Task 2.5.1 — font fingerprint stub for the prerender cache.
3
+ *
4
+ * The cache key composition includes a font fingerprint so cache entries
5
+ * invalidate when the set of fonts available to the measurement backend
6
+ * changes. In Plan A the prerender pipeline forces the empirical
7
+ * measurement backend (see `createLayoutEngine({ autoUpgradeToCanvasBackend:
8
+ * false })`), which is deterministic and font-metric-agnostic — so the
9
+ * fingerprint is a constant literal.
10
+ *
11
+ * Phase 8 (font-metrics precompute via fontkit) replaces this stub with a
12
+ * real font-derived fingerprint that makes Plan B customXml caches portable
13
+ * across machines with different installed fonts.
14
+ */
15
+ export function resolveFontFingerprint(): string {
16
+ return "empirical-backend";
17
+ }
@@ -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";