@beyondwork/docx-react-component 1.0.51 → 1.0.53

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 (30) hide show
  1. package/package.json +3 -1
  2. package/src/api/public-types.ts +41 -0
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +187 -17
  5. package/src/runtime/collab/runtime-collab-sync.ts +87 -6
  6. package/src/runtime/document-runtime.ts +159 -0
  7. package/src/runtime/layout/layout-engine-version.ts +40 -2
  8. package/src/runtime/layout/public-facet.ts +43 -1
  9. package/src/runtime/prerender/cache-envelope.ts +30 -0
  10. package/src/runtime/prerender/customxml-cache.ts +17 -3
  11. package/src/runtime/prerender/prerender-document.ts +17 -1
  12. package/src/runtime/render/render-kernel.ts +67 -19
  13. package/src/runtime/surface-projection.ts +28 -0
  14. package/src/runtime/table-schema.ts +27 -0
  15. package/src/runtime/table-style-resolver.ts +51 -0
  16. package/src/ui/WordReviewEditor.tsx +3 -0
  17. package/src/ui/editor-runtime-boundary.ts +39 -2
  18. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  19. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  20. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  21. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  22. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +224 -0
  23. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +2 -2
  24. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +2 -2
  25. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +11 -147
  26. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  27. package/src/ui-tailwind/theme/editor-theme.css +26 -24
  28. package/src/ui-tailwind/theme/tokens.css +345 -0
  29. package/src/ui-tailwind/theme/tokens.ts +313 -0
  30. package/src/ui-tailwind/theme/use-density.ts +60 -0
@@ -298,6 +298,27 @@ export interface DocumentRuntime {
298
298
  context: CommandExecutionContext,
299
299
  meta?: Partial<CommandAppliedMeta>,
300
300
  ): void;
301
+ /**
302
+ * R1 — coalesced remote-replay entry point. Applies N envelopes as ONE
303
+ * commit: per-command transactions are chained (`replayState = txN.nextState`),
304
+ * mapping steps concatenated, `markDirty` ORed, and a single `commitRemote`
305
+ * call drives `finalizeState` / `invalidate` / `refreshRenderSnapshot` /
306
+ * `notify` exactly once. Per-command `meta.activeStory` isolation is
307
+ * preserved (the remote story scopes its own replay; the local cursor
308
+ * stays put). Behavior matches `applyRemoteCommand` invoked N times for
309
+ * a single envelope; for empty arrays it's a no-op.
310
+ *
311
+ * Singular effect collisions (≥2 envelopes both emitting `commentAdded` /
312
+ * `changeAccepted` / etc.) fall back to per-envelope replay so observers
313
+ * still see every effect — typical text-burst replay (all warnings-only
314
+ * effects) takes the fast path.
315
+ *
316
+ * History commands (`history.undo` / `history.redo`) and runtime overlay
317
+ * commands (`workflow.set-overlay`, etc.) inside a batch are processed
318
+ * individually as in the per-event path; they don't participate in the
319
+ * chained commit.
320
+ */
321
+ applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
301
322
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
302
323
  undo(): void;
303
324
  redo(): void;
@@ -433,6 +454,17 @@ export interface CommandAppliedMeta {
433
454
  priorDocument: CanonicalDocumentEnvelope;
434
455
  }
435
456
 
457
+ /**
458
+ * Single envelope shape consumed by {@link DocumentRuntime.applyRemoteCommandBatch}.
459
+ * Mirrors the `applyRemoteCommand(command, context, meta)` signature so
460
+ * batch and per-event paths stay schema-compatible.
461
+ */
462
+ export interface RemoteCommandEnvelope {
463
+ command: EditorCommand;
464
+ context: CommandExecutionContext;
465
+ meta?: Partial<CommandAppliedMeta>;
466
+ }
467
+
436
468
  export interface CreateDocumentRuntimeOptions {
437
469
  documentId: string;
438
470
  initialSessionState?: EditorSessionState;
@@ -2264,6 +2296,133 @@ export function createDocumentRuntime(
2264
2296
  emitError(toRuntimeError(error));
2265
2297
  }
2266
2298
  },
2299
+ applyRemoteCommandBatch(envelopes) {
2300
+ if (envelopes.length === 0) return;
2301
+ if (envelopes.length === 1) {
2302
+ const e = envelopes[0]!;
2303
+ this.applyRemoteCommand(e.command, e.context, e.meta);
2304
+ return;
2305
+ }
2306
+
2307
+ try {
2308
+ // Chain per-command transactions before ONE commitRemote at the end.
2309
+ // Each command runs through executeEditorCommand on the replayed
2310
+ // state; resulting nextState becomes the input to the next command.
2311
+ // History/overlay commands are processed individually as in the
2312
+ // per-event path (they don't participate in the chained commit).
2313
+ let replayState: typeof state = state;
2314
+ const stepsAccumulator: import("../core/selection/mapping.ts").MappingStep[] = [];
2315
+ let anyDirty = false;
2316
+ let lastNextState: typeof state | null = null;
2317
+ let lastCrossStoryReplay = false;
2318
+ const warningsAdded: import("../core/state/editor-state.ts").EditorWarning[] = [];
2319
+ const warningsCleared: Array<{ warningId: string; code: import("../core/state/editor-state.ts").EditorWarning["code"] }> = [];
2320
+ const SINGULAR_EFFECT_KEYS = [
2321
+ "commentAdded",
2322
+ "commentResolved",
2323
+ "commentReopened",
2324
+ "commentReplyAdded",
2325
+ "commentBodyEdited",
2326
+ "changeAccepted",
2327
+ "changeRejected",
2328
+ "revisionAuthored",
2329
+ "commandBlocked",
2330
+ ] as const;
2331
+ const singularEffectsCounts = new Map<string, number>();
2332
+ const aggregatedSingular: Record<string, unknown> = {};
2333
+
2334
+ for (const env of envelopes) {
2335
+ const { command, context, meta } = env;
2336
+ if (command.type === "history.undo" || command.type === "history.redo") {
2337
+ // Match per-event path: silently skipped on remote replay.
2338
+ continue;
2339
+ }
2340
+ if (isRuntimeStateOverlayCommand(command)) {
2341
+ // Overlays bypass commitRemote in the per-event path; mirror that
2342
+ // here without disturbing the chained replayState.
2343
+ applyRuntimeStateOverlayCommand(command);
2344
+ continue;
2345
+ }
2346
+ const replayStory = meta?.activeStory ?? activeStory;
2347
+ const crossStoryReplay = Boolean(
2348
+ meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
2349
+ );
2350
+ let stateForCommand: typeof state;
2351
+ let snapshotForCommand: typeof cachedRenderSnapshot;
2352
+ if (meta?.preSelection) {
2353
+ // Reuse the R5 scratch (avoids per-command allocation across the
2354
+ // burst). Seed from the CURRENT chain state so each command sees
2355
+ // the prior command's nextState as its starting point.
2356
+ Object.assign(r5ScratchReplayState, replayState);
2357
+ r5ScratchReplayState.selection = meta.preSelection;
2358
+ Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
2359
+ r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
2360
+ stateForCommand = r5ScratchReplayState;
2361
+ snapshotForCommand = r5ScratchReplaySnapshot;
2362
+ } else {
2363
+ stateForCommand = replayState;
2364
+ snapshotForCommand = cachedRenderSnapshot;
2365
+ }
2366
+ const replayContext = {
2367
+ ...context,
2368
+ renderSnapshot: snapshotForCommand,
2369
+ };
2370
+ const transaction = executeEditorCommand(stateForCommand, command, replayContext);
2371
+ replayState = transaction.nextState;
2372
+ stepsAccumulator.push(...transaction.mapping.steps);
2373
+ anyDirty = anyDirty || transaction.markDirty;
2374
+ warningsAdded.push(...transaction.effects.warningsAdded);
2375
+ warningsCleared.push(...transaction.effects.warningsCleared);
2376
+ for (const key of SINGULAR_EFFECT_KEYS) {
2377
+ const v = (transaction.effects as unknown as Record<string, unknown>)[key];
2378
+ if (v !== undefined) {
2379
+ singularEffectsCounts.set(key, (singularEffectsCounts.get(key) ?? 0) + 1);
2380
+ aggregatedSingular[key] = v;
2381
+ }
2382
+ }
2383
+ lastNextState = transaction.nextState;
2384
+ lastCrossStoryReplay = crossStoryReplay;
2385
+ }
2386
+
2387
+ // Singular-effect collision (≥2 envelopes both setting the same
2388
+ // singular field): fall back to per-envelope replay so observers
2389
+ // see every effect. Common burst (all text.insert with empty
2390
+ // effects) takes the fast path above.
2391
+ let singularCollision = false;
2392
+ for (const count of singularEffectsCounts.values()) {
2393
+ if (count > 1) {
2394
+ singularCollision = true;
2395
+ break;
2396
+ }
2397
+ }
2398
+ if (singularCollision) {
2399
+ for (const env of envelopes) {
2400
+ this.applyRemoteCommand(env.command, env.context, env.meta);
2401
+ }
2402
+ return;
2403
+ }
2404
+ if (lastNextState === null) {
2405
+ // All envelopes were skip/overlay — nothing to commit.
2406
+ return;
2407
+ }
2408
+ const consolidated = {
2409
+ nextState: lastCrossStoryReplay
2410
+ ? { ...lastNextState, selection: state.selection }
2411
+ : lastNextState,
2412
+ mapping: { steps: stepsAccumulator },
2413
+ effects: {
2414
+ warningsAdded,
2415
+ warningsCleared,
2416
+ ...aggregatedSingular,
2417
+ } as import("../core/commands/index.ts").TransactionEffects,
2418
+ historyBoundary: "skip" as const,
2419
+ markDirty: anyDirty,
2420
+ };
2421
+ commitRemote(consolidated);
2422
+ } catch (error) {
2423
+ emitError(toRuntimeError(error));
2424
+ }
2425
+ },
2267
2426
  undo() {
2268
2427
  this.dispatch({
2269
2428
  type: "history.undo",
@@ -93,8 +93,37 @@
93
93
  * pixel-geometry change; cache envelopes from v9 invalidate
94
94
  * because page-node identity semantics on bounded splices
95
95
  * changed.
96
+ * 11 — Lane 3a P10 Phase B2. `toPublicPageNode` memoizes its
97
+ * `RuntimePageNode → PublicPageNode` projection via WeakMap so
98
+ * Phase D1's stable runtime-node references cascade into stable
99
+ * public-facet return values. `facet.getPage(pageIndex)` now
100
+ * returns reference-equal `PublicPageNode`s across bounded
101
+ * relayouts where the underlying runtime node was reused.
102
+ * Cascades into downstream consumers (React.memo-gated page
103
+ * subtrees, per-page test hooks) and closes the Phase B → D1
104
+ * stable-reference chain. No pixel-geometry change; cache
105
+ * envelopes from v10 invalidate because the public-facet
106
+ * contract changed from "fresh object per call" to "stable ref
107
+ * per underlying runtime node".
108
+ * 12 — Lane 3a R3 table row-split: `getTableRenderPlan` now derives
109
+ * `isContinuationPage` from the page graph's fragment slice
110
+ * metadata (`kind === "table-slice" && tableRowRange.from > 0`)
111
+ * and passes it to `buildTableRenderPlan`. Tables split across
112
+ * pages now carry non-empty `repeatedHeaderRows` on continuation
113
+ * pages so chrome can prepend header rows visually. No
114
+ * pixel-geometry change; cache envelopes from v11 invalidate
115
+ * because the table-render-plan contract changed.
116
+ * 13 — Lane 3a P14.c: render-kernel gains a single-slot `DecorationIndex`
117
+ * cache keyed on (revision, activeStory.kind, zoom.pxPerTwip, and
118
+ * reference equality on each decoration source). When layout
119
+ * changes but decoration sources are unchanged, `resolveDecorationIndex`
120
+ * is skipped and the prior result is reused. The frame-level cache
121
+ * already handles repeat reads; this slot targets the post-invalidate
122
+ * rebuild path (on every keystroke that triggers a layout event).
123
+ * No pixel-geometry change; cache envelopes from v12 invalidate
124
+ * because the render-kernel source changed.
96
125
  */
97
- export const LAYOUT_ENGINE_VERSION = 10 as const;
126
+ export const LAYOUT_ENGINE_VERSION = 13 as const;
98
127
 
99
128
  /**
100
129
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -113,5 +142,14 @@ export const LAYOUT_ENGINE_VERSION = 10 as const;
113
142
  * correctly invalidates. v1 envelopes are rejected on load under v2 —
114
143
  * no corruption path exists because schemaVersion is the top-level
115
144
  * discriminator.
145
+ * 3 — L7 Phase 2 Finale C3: adds optional `compatibilityReport` field so
146
+ * the warm-Plan-B short-circuit can skip `buildCompatibilityReport`
147
+ * (~60–100 ms on extra-large). `generatedAt` inside the cached report
148
+ * is pinned to a fixed sentinel (`"__cache_normalized__"`) at
149
+ * prerender write time so two sequential prerenders produce
150
+ * byte-identical envelopes. The field is optional — v3 envelopes
151
+ * written without the report are still valid; readers fall through
152
+ * to the live `buildCompatibilityReport` call. v2 envelopes are
153
+ * rejected cleanly on v3 readers (schemaVersion mismatch).
116
154
  */
117
- export const LAYCACHE_SCHEMA_VERSION = 2 as const;
155
+ export const LAYCACHE_SCHEMA_VERSION = 3 as const;
@@ -1231,12 +1231,27 @@ export function createLayoutFacet(
1231
1231
  const tableHeightTwips = graph.fragments
1232
1232
  .filter((f) => f.blockId === blockId)
1233
1233
  .reduce((total, f) => total + f.heightTwips, 0);
1234
+ // Determine if this page holds a continuation slice of the table
1235
+ // (rows after the first slice). Fragment kind "table-slice" with
1236
+ // tableRowRange.from > 0 means the table was split across pages and
1237
+ // this page carries a non-first slice — header rows should repeat.
1238
+ const pageNode = graph.pages.find((p) => p.pageIndex === pageIndex);
1239
+ const sliceFragment = pageNode
1240
+ ? graph.fragments.find(
1241
+ (f) => f.blockId === blockId && f.pageId === pageNode.pageId,
1242
+ )
1243
+ : undefined;
1244
+ const isContinuationPage =
1245
+ sliceFragment?.kind === "table-slice" &&
1246
+ (sliceFragment.tableRowRange?.from ?? 0) > 0;
1247
+
1234
1248
  return buildTableRenderPlan({
1235
1249
  blockId,
1236
1250
  pageIndex,
1237
1251
  block: tableBlock,
1238
1252
  resolved,
1239
1253
  tableHeightTwips,
1254
+ isContinuationPage,
1240
1255
  });
1241
1256
  },
1242
1257
 
@@ -1274,11 +1289,36 @@ export function createLayoutFacet(
1274
1289
  // Internal: graph → public clones
1275
1290
  // ---------------------------------------------------------------------------
1276
1291
 
1292
+ /**
1293
+ * Per-runtime-node cache for `toPublicPageNode`.
1294
+ *
1295
+ * P10 Phase B2. When `spliceGraph` preserves a `RuntimePageNode`
1296
+ * reference across a bounded relayout (Phase D1 tail-convergence
1297
+ * reuse), this cache preserves the derived `PublicPageNode` reference
1298
+ * too — so downstream consumers (React.memo-gated page subtrees in
1299
+ * `TwPageStackChromeLayer`, per-page test hooks) observe stable props
1300
+ * and can skip reconciliation.
1301
+ *
1302
+ * The map is a WeakMap keyed on the runtime node. When a fresh runtime
1303
+ * node replaces a prior one (divergent-tail case), the prior cache
1304
+ * entry is garbage-collected with the prior node; no manual eviction
1305
+ * is required. Structural contract: consumers MUST NOT mutate the
1306
+ * returned `PublicPageNode`, which has always been a read-only clone.
1307
+ */
1308
+ const publicPageNodeCache = new WeakMap<RuntimePageNode, PublicPageNode>();
1309
+
1277
1310
  function toPublicPageNode(
1278
1311
  node: RuntimePageNode,
1279
1312
  graph: RuntimePageGraph,
1280
1313
  ): PublicPageNode {
1281
- return {
1314
+ const cached = publicPageNodeCache.get(node);
1315
+ // Safety guard: reuse only when the runtime node's index still
1316
+ // matches the cached projection. Under normal D1 tail-convergence the
1317
+ // index is preserved, but this belt-and-braces check guarantees we
1318
+ // never hand out a stale clone if a runtime node is ever reused at a
1319
+ // different position by a future splice strategy.
1320
+ if (cached && cached.pageIndex === node.pageIndex) return cached;
1321
+ const built: PublicPageNode = {
1282
1322
  pageId: node.pageId,
1283
1323
  pageIndex: node.pageIndex,
1284
1324
  sectionIndex: node.sectionIndex,
@@ -1295,7 +1335,9 @@ function toPublicPageNode(
1295
1335
  lineBoxCount: node.lineBoxes.length,
1296
1336
  noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
1297
1337
  };
1338
+ publicPageNodeCache.set(node, built);
1298
1339
  void graph; // reserved for future cross-page derivations
1340
+ return built;
1299
1341
  }
1300
1342
 
1301
1343
  function toPublicResolvedPageStories(
@@ -1,5 +1,6 @@
1
1
  import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
2
  import type { CanonicalDocument } from "../../model/canonical-document.ts";
3
+ import type { CompatibilityReport } from "../../core/state/editor-state.ts";
3
4
  import type { RuntimePageGraph } from "../layout/page-graph.ts";
4
5
 
5
6
  /**
@@ -28,6 +29,17 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
28
29
  * - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
29
30
  * input to `deriveCacheKey`, so style/metadata/comment/preservation
30
31
  * mutations correctly invalidate the cache.
32
+ *
33
+ * Phase 2 Finale C3 addition (schema v3):
34
+ * - `compatibilityReport?` — pre-computed `CompatibilityReport`. The
35
+ * report is a pure function of `canonicalDocument` (plus the
36
+ * `generatedAt` timestamp, which is pinned to the fixed sentinel
37
+ * `"__cache_normalized__"` at prerender write time so envelopes are
38
+ * byte-identical across two prerender calls). When present on the
39
+ * Plan B short-circuit path, `loadDocxEditorSessionAsync` skips the
40
+ * live `buildCompatibilityReport` call (60–100 ms on extra-large).
41
+ * The field is optional so v3 writers can ship envelopes without it
42
+ * (graceful degradation to the existing live-computation path).
31
43
  */
32
44
  export interface CacheEnvelope {
33
45
  readonly schemaVersion: number;
@@ -38,4 +50,22 @@ export interface CacheEnvelope {
38
50
  readonly graph: RuntimePageGraph;
39
51
  readonly surface: EditorSurfaceSnapshot;
40
52
  readonly canonicalDocument: CanonicalDocument;
53
+ /**
54
+ * v3+. Optional even at v3 — absence means "compute live on read".
55
+ * See the Phase 2 Finale C3 banner above.
56
+ */
57
+ readonly compatibilityReport?: CompatibilityReport;
41
58
  }
59
+
60
+ /**
61
+ * Fixed sentinel used in place of `new Date().toISOString()` when
62
+ * pre-computing `compatibilityReport` for the envelope. Ensures two
63
+ * sequential `prerenderDocument` calls on identical bytes produce
64
+ * byte-identical cache envelopes.
65
+ *
66
+ * Downstream consumers of `compatibilityReport.generatedAt` are
67
+ * diagnostic-only — the field has never been user-visible in the editor
68
+ * UX. Cache-hit paths observe the sentinel; cache-miss paths observe a
69
+ * live ISO8601 timestamp.
70
+ */
71
+ export const CACHE_NORMALIZED_GENERATED_AT = "__cache_normalized__" as const;
@@ -194,7 +194,7 @@ function extractInlineCdata(rawXml: string): string | null {
194
194
  function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
195
195
  if (typeof value !== "object" || value === null) return false;
196
196
  const v = value as Record<string, unknown>;
197
- return (
197
+ const requiredFieldsOk =
198
198
  v.schemaVersion === LAYCACHE_SCHEMA_VERSION &&
199
199
  v.engineVersion === LAYOUT_ENGINE_VERSION &&
200
200
  typeof v.fontFingerprint === "string" &&
@@ -205,7 +205,21 @@ function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
205
205
  typeof v.surface === "object" &&
206
206
  v.surface !== null &&
207
207
  typeof v.canonicalDocument === "object" &&
208
- v.canonicalDocument !== null
209
- );
208
+ v.canonicalDocument !== null;
209
+ if (!requiredFieldsOk) return false;
210
+ // C3 (schema v3): `compatibilityReport` is optional — if present it must
211
+ // be a structured object with the expected shape markers. Envelopes
212
+ // without the field degrade gracefully to live-computation on read.
213
+ if (v.compatibilityReport !== undefined) {
214
+ const cr = v.compatibilityReport as Record<string, unknown> | null;
215
+ if (typeof cr !== "object" || cr === null) return false;
216
+ if (cr.reportVersion !== "compatibility-report/1") return false;
217
+ if (typeof cr.generatedAt !== "string") return false;
218
+ if (typeof cr.blockExport !== "boolean") return false;
219
+ if (!Array.isArray(cr.featureEntries)) return false;
220
+ if (!Array.isArray(cr.warnings)) return false;
221
+ if (!Array.isArray(cr.errors)) return false;
222
+ }
223
+ return true;
210
224
  }
211
225
 
@@ -10,7 +10,11 @@ import {
10
10
  } from "../layout/layout-engine-version.ts";
11
11
  import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
12
12
  import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
13
- import type { CacheEnvelope } from "./cache-envelope.ts";
13
+ import { buildCompatibilityReport } from "../../validation/compatibility-engine.ts";
14
+ import {
15
+ CACHE_NORMALIZED_GENERATED_AT,
16
+ type CacheEnvelope,
17
+ } from "./cache-envelope.ts";
14
18
  import {
15
19
  computeStructuralHash,
16
20
  deriveCacheKey,
@@ -167,6 +171,17 @@ export async function prerenderDocument(
167
171
  canonicalDocumentHash,
168
172
  });
169
173
 
174
+ // Phase 2 Finale C3: pre-compute `compatibilityReport` so the warm Plan B
175
+ // short-circuit in `loadDocxEditorSessionAsync` can skip the live
176
+ // `buildCompatibilityReport` call (~60–100 ms on extra-large). The report
177
+ // is a pure function of `canonicalDocument` + `generatedAt`; pinning
178
+ // `generatedAt` to `CACHE_NORMALIZED_GENERATED_AT` keeps the envelope
179
+ // byte-identical across two sequential prerenders on identical input.
180
+ const compatibilityReport = buildCompatibilityReport({
181
+ document: envelope,
182
+ generatedAt: CACHE_NORMALIZED_GENERATED_AT,
183
+ });
184
+
170
185
  const cacheBlob: CacheEnvelope = {
171
186
  schemaVersion: LAYCACHE_SCHEMA_VERSION,
172
187
  engineVersion: LAYOUT_ENGINE_VERSION,
@@ -176,6 +191,7 @@ export async function prerenderDocument(
176
191
  graph,
177
192
  surface,
178
193
  canonicalDocument: envelope,
194
+ compatibilityReport,
179
195
  };
180
196
 
181
197
  // Plan B B.5 — persistToCustomXml: inject the envelope into the docx's
@@ -23,12 +23,8 @@ import type {
23
23
  PublicRegionBlock,
24
24
  WordReviewEditorLayoutFacet,
25
25
  } from "../layout/public-facet.ts";
26
- import type {
27
- CommentDecorationModel,
28
- } from "../../ui/headless/comment-decoration-model.ts";
29
- import type {
30
- RevisionDecorationModel,
31
- } from "../../ui/headless/revision-decoration-model.ts";
26
+ import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model.ts";
27
+ import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model.ts";
32
28
  import type { ScopeRailSegment } from "../workflow-rail-segments.ts";
33
29
  import {
34
30
  resolveDecorationIndex,
@@ -148,6 +144,24 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
148
144
  // is an optimizer for repeat reads at the same revision.
149
145
  let lastEmittedFrame: RenderFrame | null = null;
150
146
 
147
+ // P14.c — single-slot DecorationIndex cache. `resolveDecorationIndex`
148
+ // is O(n × m) in decorations × offsets; it is safe to reuse the prior
149
+ // result when the layout revision, active story, zoom, and every source
150
+ // reference are all unchanged from the previous build. The frame-level
151
+ // `cache` already prevents rebuilds for repeat reads; this slot covers
152
+ // the post-invalidate rebuild where layout changed but decorations did not.
153
+ let diCacheKey: {
154
+ revision: number;
155
+ activeStoryKind: string;
156
+ zoomPxPerTwip: number;
157
+ workflowSegments: readonly ScopeRailSegment[] | undefined;
158
+ comments: CommentDecorationModel | null | undefined;
159
+ revisions: RevisionDecorationModel | null | undefined;
160
+ searchMatches: readonly SearchMatchRange[] | undefined;
161
+ lockedRanges: readonly LockedRangeInput[] | undefined;
162
+ } | null = null;
163
+ let diCacheValue: DecorationIndex | null = null;
164
+
151
165
  const listeners = new Set<(event: RenderKernelEvent) => void>();
152
166
  const unsubscribeFacet = facet.subscribe((event) => {
153
167
  // Any layout-changing event invalidates the cached frame. We rely on
@@ -208,6 +222,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
208
222
  pendingDeltas,
209
223
  zoom.pxPerTwip,
210
224
  );
225
+ // Revision: keyed off the engine's current page graph so repeated reads
226
+ // at the same revision return the same cached frame. We derive it
227
+ // from the first page since the engine stamps every page with the
228
+ // graph's revision indirectly via pageId; fall back to 0 if empty.
229
+ const revision = filteredPages[0]
230
+ ? Number(extractRevisionFromPageId(filteredPages[0].pageId))
231
+ : 0;
232
+
211
233
  const includeDecorations = options?.includeDecorations ?? true;
212
234
  const sources = input.getDecorationSources?.();
213
235
  const hasSources =
@@ -217,11 +239,45 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
217
239
  (sources.revisions?.revisions?.length ?? 0) > 0 ||
218
240
  (sources.searchMatches && sources.searchMatches.length > 0) ||
219
241
  (sources.lockedRanges && sources.lockedRanges.length > 0));
220
- const decorationIndex: DecorationIndex = !includeDecorations
221
- ? EMPTY_DECORATION_INDEX
222
- : hasSources
223
- ? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
224
- : buildDecorationIndex(renderPages);
242
+ // P14.c DecorationIndex single-slot cache. Check reference equality
243
+ // on every source before calling the O(n × m) resolver.
244
+ const newDIKey = {
245
+ revision: Number.isFinite(revision) ? revision : 0,
246
+ activeStoryKind: activeStory.kind,
247
+ zoomPxPerTwip: zoom.pxPerTwip,
248
+ workflowSegments: sources?.workflowSegments,
249
+ comments: sources?.comments,
250
+ revisions: sources?.revisions,
251
+ searchMatches: sources?.searchMatches,
252
+ lockedRanges: sources?.lockedRanges,
253
+ };
254
+ const diCacheHit =
255
+ hasSources &&
256
+ diCacheKey !== null &&
257
+ diCacheValue !== null &&
258
+ diCacheKey.revision === newDIKey.revision &&
259
+ diCacheKey.activeStoryKind === newDIKey.activeStoryKind &&
260
+ diCacheKey.zoomPxPerTwip === newDIKey.zoomPxPerTwip &&
261
+ diCacheKey.workflowSegments === newDIKey.workflowSegments &&
262
+ diCacheKey.comments === newDIKey.comments &&
263
+ diCacheKey.revisions === newDIKey.revisions &&
264
+ diCacheKey.searchMatches === newDIKey.searchMatches &&
265
+ diCacheKey.lockedRanges === newDIKey.lockedRanges;
266
+
267
+ let decorationIndex: DecorationIndex;
268
+ if (!includeDecorations) {
269
+ decorationIndex = EMPTY_DECORATION_INDEX;
270
+ } else if (hasSources) {
271
+ if (diCacheHit) {
272
+ decorationIndex = diCacheValue!;
273
+ } else {
274
+ decorationIndex = resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources });
275
+ diCacheKey = newDIKey;
276
+ diCacheValue = decorationIndex;
277
+ }
278
+ } else {
279
+ decorationIndex = buildDecorationIndex(renderPages);
280
+ }
225
281
  const anchorIndex = buildAnchorIndex(
226
282
  renderPages,
227
283
  pendingDeltas,
@@ -229,14 +285,6 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
229
285
  decorationIndex,
230
286
  );
231
287
 
232
- // Revision: keyed off the engine's current page graph so repeated reads
233
- // at the same revision return the same cached frame. We derive it
234
- // from the first page since the engine stamps every page with the
235
- // graph's revision indirectly via pageId; fall back to 0 if empty.
236
- const revision = filteredPages[0]
237
- ? Number(extractRevisionFromPageId(filteredPages[0].pageId))
238
- : 0;
239
-
240
288
  const frame: RenderFrame = {
241
289
  revision: Number.isFinite(revision) ? revision : 0,
242
290
  measurementFidelity,
@@ -419,6 +419,9 @@ function createTableBlock(
419
419
  ...(cellBorders.borderRight ? { borderRight: cellBorders.borderRight } : {}),
420
420
  ...(cellBorders.borderBottom ? { borderBottom: cellBorders.borderBottom } : {}),
421
421
  ...(cellBorders.borderLeft ? { borderLeft: cellBorders.borderLeft } : {}),
422
+ // R3.a Phase 2: per-cell text-flow direction copied from canonical
423
+ // TableCellNode. Node-view renders `tbRl` / `btLr` as CSS writing-mode.
424
+ ...(cell.textDirection ? { textDirection: cell.textDirection } : {}),
422
425
  ...(bandClasses ? { bandClasses } : {}),
423
426
  content: cellContent,
424
427
  });
@@ -437,6 +440,30 @@ function createTableBlock(
437
440
  });
438
441
  }
439
442
 
443
+ // R3.a Phase 2 — fold the resolver's table-level bundle onto the surface
444
+ // block when any field is populated. Borders carry the typed BorderSpec per
445
+ // side; the node-view converts the four outer sides to CSS shorthand at
446
+ // render time. insideH / insideV flow through for round-trip + future use,
447
+ // but their visual effect is realized via per-cell borders today.
448
+ const tr = resolvedTable.tableResolved;
449
+ const trHasContent =
450
+ tr.width !== undefined ||
451
+ tr.layoutMode !== undefined ||
452
+ tr.cellSpacing !== undefined ||
453
+ tr.borders !== undefined;
454
+ const tableResolvedAttr = trHasContent
455
+ ? {
456
+ ...(tr.width !== undefined ? { width: tr.width } : {}),
457
+ ...(tr.widthType !== undefined ? { widthType: tr.widthType } : {}),
458
+ ...(tr.layoutMode !== undefined ? { layoutMode: tr.layoutMode } : {}),
459
+ ...(tr.cellSpacing !== undefined ? { cellSpacing: tr.cellSpacing } : {}),
460
+ ...(tr.cellSpacingType !== undefined
461
+ ? { cellSpacingType: tr.cellSpacingType }
462
+ : {}),
463
+ ...(tr.borders ? { borders: tr.borders } : {}),
464
+ }
465
+ : undefined;
466
+
440
467
  return {
441
468
  block: {
442
469
  blockId: `table-${tableIndex}`,
@@ -447,6 +474,7 @@ function createTableBlock(
447
474
  gridColumns: table.gridColumns,
448
475
  ...(resolvedTable.table?.alignment ? { alignment: resolvedTable.table.alignment } : {}),
449
476
  tblLook: resolvedTable.effectiveTblLook,
477
+ ...(tableResolvedAttr ? { tableResolved: tableResolvedAttr } : {}),
450
478
  rows,
451
479
  },
452
480
  lockedFragmentIds,
@@ -42,6 +42,8 @@ type TableCellAttrs = {
42
42
  borderRight?: string | null;
43
43
  borderBottom?: string | null;
44
44
  borderLeft?: string | null;
45
+ /** R3.a Phase 2 — per-cell text-flow direction. */
46
+ textDirection?: "lrTb" | "tbRl" | "btLr" | null;
45
47
  bandClasses?: string | null;
46
48
  };
47
49
 
@@ -188,6 +190,13 @@ const tableCellSpecAttrs = {
188
190
  borderRight: { default: null },
189
191
  borderBottom: { default: null },
190
192
  borderLeft: { default: null },
193
+ /**
194
+ * R3.a Phase 2 — per-cell text-flow direction copied from
195
+ * `TableCellNode.textDirection` ("lrTb" | "tbRl" | "btLr"). Node-view maps
196
+ * `tbRl` → `writing-mode: vertical-rl`, `btLr` → `writing-mode: vertical-lr`,
197
+ * `lrTb` (or null) → unset.
198
+ */
199
+ textDirection: { default: null },
191
200
  /** R2b: space-joined band classes ("band-firstRow band-band1Horz") from the resolved style. */
192
201
  bandClasses: { default: null },
193
202
  } as const;
@@ -210,6 +219,24 @@ export const tableNodeSpec: NodeSpec = {
210
219
  tblLookNoVBand: { default: false },
211
220
  /** R2d: raw `w:tblLook/@w:val` hex preserved verbatim so vendor-extended bits survive round-trip. */
212
221
  tblLookVal: { default: null },
222
+ // R3.a Phase 2 — table-level resolved properties projected from
223
+ // ResolvedTableLevelProperties. Each is nullable so PM's null-vs-undefined
224
+ // preference is satisfied. Width is in twips (dxa) or fiftieths-of-percent
225
+ // (pct); spacing is twips (dxa). Borders are CSS shorthand strings (e.g.
226
+ // "1px solid #000000") for the four outer sides; insideH / insideV are
227
+ // realized through per-cell border attrs (CSS has no native
228
+ // table-inside-border property — see applyTableAttrs comment).
229
+ tableWidth: { default: null },
230
+ tableWidthType: { default: null },
231
+ tableLayoutMode: { default: null },
232
+ tableCellSpacing: { default: null },
233
+ tableCellSpacingType: { default: null },
234
+ tableBorderTop: { default: null },
235
+ tableBorderRight: { default: null },
236
+ tableBorderBottom: { default: null },
237
+ tableBorderLeft: { default: null },
238
+ tableBorderInsideH: { default: null },
239
+ tableBorderInsideV: { default: null },
213
240
  },
214
241
  parseDOM: [{ tag: "table" }],
215
242
  toDOM(node) {