@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -2,6 +2,8 @@ import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
2
  import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
3
3
  import { loadDocxEditorSessionAsync } from "../../io/docx-session.ts";
4
4
  import { createLoadScheduler } from "../../io/load-scheduler.ts";
5
+ import { readOpcPackage } from "../../io/opc/package-reader.ts";
6
+ import { writeOpcPackage } from "../../io/opc/package-writer.ts";
5
7
  import {
6
8
  LAYCACHE_SCHEMA_VERSION,
7
9
  LAYOUT_ENGINE_VERSION,
@@ -14,6 +16,8 @@ import {
14
16
  deriveCacheKey,
15
17
  type CacheKeyBlock,
16
18
  } from "./cache-key.ts";
19
+ import { computeCanonicalDocumentHash } from "./canonical-document-hash.ts";
20
+ import { writeEnvelopeToOpcPackage } from "./customxml-cache.ts";
17
21
  import { resolveFontFingerprint } from "./font-fingerprint.ts";
18
22
  import { canonicalizeGraph } from "./graph-canonicalize.ts";
19
23
 
@@ -46,11 +50,18 @@ import { canonicalizeGraph } from "./graph-canonicalize.ts";
46
50
  export interface PrerenderOptions {
47
51
  readonly fontFingerprint?: string;
48
52
  /**
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.
53
+ * Plan B — when true, prerenderDocument injects the cache envelope into
54
+ * the document's workflow-payload customXml part under a `laycache`
55
+ * unknown-namespace entry, and returns the re-serialized bytes in
56
+ * `docWithCustomXml`. Default: `false`, in which case `docWithCustomXml`
57
+ * equals the input bytes.
58
+ *
59
+ * Scope caveat: requires the input docx to already have a
60
+ * `/customXml/item1.xml` part. If the part is absent, the flag is a
61
+ * no-op (documented via `counters.persistedToCustomXml`) and the caller
62
+ * falls back to IndexedDB-only caching. Fresh/minimal docs that lack
63
+ * the part are already fast-opening; Plan B targets CCEP-scale
64
+ * templates which reliably carry the part.
54
65
  */
55
66
  readonly persistToCustomXml?: boolean;
56
67
  }
@@ -59,6 +70,13 @@ export interface PrerenderCounters {
59
70
  readonly blockCount: number;
60
71
  readonly pageCount: number;
61
72
  readonly prerenderMs: number;
73
+ /**
74
+ * Plan B signal: `true` when `persistToCustomXml` was requested AND the
75
+ * docx had an existing workflow-payload part to mutate. `false` when
76
+ * the flag was off OR the docx had no such part (caller should fall
77
+ * back to IndexedDB caching).
78
+ */
79
+ readonly persistedToCustomXml: boolean;
62
80
  }
63
81
 
64
82
  export interface PrerenderResult {
@@ -70,6 +88,15 @@ export interface PrerenderResult {
70
88
 
71
89
  const PRERENDER_DOCUMENT_ID = "prerender";
72
90
 
91
+ /**
92
+ * Fixed ISO8601 timestamp used to override session-birth `createdAt` /
93
+ * `updatedAt` on the prerendered envelope. Epoch zero — a valid ISO8601
94
+ * value that downstream validators accept — replacing `Date.now()`-driven
95
+ * values that would otherwise defeat byte-identical `docWithCustomXml`
96
+ * output across two prerender calls on identical bytes.
97
+ */
98
+ const PRERENDER_NORMALIZED_TIMESTAMP = "1970-01-01T00:00:00.000Z";
99
+
73
100
  function toUint8Array(input: ArrayBuffer | Uint8Array): Uint8Array {
74
101
  if (input instanceof Uint8Array) return input;
75
102
  return new Uint8Array(input);
@@ -106,7 +133,20 @@ export async function prerenderDocument(
106
133
  );
107
134
  }
108
135
 
109
- const envelope = session.initialSessionState.canonicalDocument;
136
+ // Normalize session-birth timestamps. `loadDocxEditorSessionAsync` sets
137
+ // `createdAt`/`updatedAt` from `new Date().toISOString()`; without this
138
+ // override, two sequential prerender calls on identical bytes would
139
+ // produce different envelopes → different customXml bytes → determinism
140
+ // failure on the B.5 byte-identical gate. Using the epoch keeps the
141
+ // value a valid ISO8601 string (downstream validators accept it) while
142
+ // eliminating the only remaining source of non-determinism. The live
143
+ // session's updatedAt is re-populated by runtime mutations anyway, so
144
+ // the normalized value is irrelevant at runtime.
145
+ const envelope: typeof session.initialSessionState.canonicalDocument = {
146
+ ...session.initialSessionState.canonicalDocument,
147
+ createdAt: PRERENDER_NORMALIZED_TIMESTAMP,
148
+ updatedAt: PRERENDER_NORMALIZED_TIMESTAMP,
149
+ };
110
150
  const surface = createEditorSurfaceSnapshot(envelope, createSelectionSnapshot(), { kind: "main" });
111
151
 
112
152
  const engine = createLayoutEngine({ autoUpgradeToCanvasBackend: false });
@@ -114,11 +154,17 @@ export async function prerenderDocument(
114
154
  const graph = canonicalizeGraph(rawGraph);
115
155
 
116
156
  const structuralHash = await computeStructuralHash(blocksToCacheKeyBlocks(surface));
157
+ // L7 Phase 2.5 Plan B B.2 — sha256 of stable-stringified canonical document.
158
+ // Enters the cache key as the 5th input so style / metadata / comments /
159
+ // preservation mutations correctly invalidate (structuralHash alone misses
160
+ // them because the block-id list is unchanged).
161
+ const canonicalDocumentHash = await computeCanonicalDocumentHash(envelope);
117
162
  const cacheKey = await deriveCacheKey({
118
163
  blocks: blocksToCacheKeyBlocks(surface),
119
164
  fontFingerprint,
120
165
  engineVersion: LAYOUT_ENGINE_VERSION,
121
166
  schemaVersion: LAYCACHE_SCHEMA_VERSION,
167
+ canonicalDocumentHash,
122
168
  });
123
169
 
124
170
  const cacheBlob: CacheEnvelope = {
@@ -126,20 +172,41 @@ export async function prerenderDocument(
126
172
  engineVersion: LAYOUT_ENGINE_VERSION,
127
173
  fontFingerprint,
128
174
  structuralHash,
175
+ canonicalDocumentHash,
129
176
  graph,
130
177
  surface,
178
+ canonicalDocument: envelope,
131
179
  };
132
180
 
181
+ // Plan B B.5 — persistToCustomXml: inject the envelope into the docx's
182
+ // workflow-payload part. Re-parses the OPC package from bytes (~17 ms on
183
+ // extra-large) because `LoadedDocxEditorSession` does not expose
184
+ // `sourcePackage` publicly. Acceptable cost on the one-shot ingest path.
185
+ let docWithCustomXml = bytes;
186
+ let persistedToCustomXml = false;
187
+ if (options.persistToCustomXml === true) {
188
+ const opcPackage = readOpcPackage(bytes);
189
+ const writeResult = writeEnvelopeToOpcPackage(opcPackage, cacheBlob);
190
+ if (writeResult.written) {
191
+ docWithCustomXml = writeOpcPackage(opcPackage);
192
+ persistedToCustomXml = true;
193
+ }
194
+ // writeResult.written === false → docx had no customXml part; silently
195
+ // fall through with docWithCustomXml = input bytes. Caller observes via
196
+ // counters.persistedToCustomXml.
197
+ }
198
+
133
199
  const prerenderMs = performance.now() - t0;
134
200
 
135
201
  return {
136
- docWithCustomXml: bytes,
202
+ docWithCustomXml,
137
203
  cacheBlob,
138
204
  cacheKey,
139
205
  counters: {
140
206
  blockCount: surface.blocks.length,
141
207
  pageCount: graph.pages.length,
142
208
  prerenderMs,
209
+ persistedToCustomXml,
143
210
  },
144
211
  };
145
212
  }
@@ -0,0 +1,148 @@
1
+ import type {
2
+ CanonicalDocument,
3
+ DocumentRootNode,
4
+ InlineNode,
5
+ ParagraphNode,
6
+ } from "../model/canonical-document.ts";
7
+ import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
8
+ import type { EditorAnchorProjection } from "../api/public-types.ts";
9
+
10
+ export interface ResolvedScopeLocation {
11
+ scopeId: string;
12
+ startPos: number;
13
+ endPos: number;
14
+ }
15
+
16
+ function inlineLength(node: InlineNode): number {
17
+ switch (node.type) {
18
+ case "text":
19
+ return Array.from(node.text).length;
20
+ case "hyperlink":
21
+ case "field":
22
+ return node.children.reduce(
23
+ (total, child) => total + inlineLength(child as InlineNode),
24
+ 0,
25
+ );
26
+ case "bookmark_start":
27
+ case "bookmark_end":
28
+ case "scope_marker_start":
29
+ case "scope_marker_end":
30
+ return 0;
31
+ default:
32
+ return 1;
33
+ }
34
+ }
35
+
36
+ function walkParagraphs(
37
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
38
+ ): { paragraph: ParagraphNode; from: number }[] {
39
+ const envelope = document as CanonicalDocumentEnvelope;
40
+ const root =
41
+ "content" in envelope
42
+ ? (envelope.content as DocumentRootNode)
43
+ : (document as unknown as DocumentRootNode);
44
+ const out: { paragraph: ParagraphNode; from: number }[] = [];
45
+ let cursor = 0;
46
+ for (let index = 0; index < root.children.length; index += 1) {
47
+ const block = root.children[index];
48
+ if (block && block.type === "paragraph") {
49
+ out.push({ paragraph: block, from: cursor });
50
+ cursor += block.children.reduce(
51
+ (total, child) => total + inlineLength(child as InlineNode),
52
+ 0,
53
+ );
54
+ } else if (block && block.type === "table") {
55
+ cursor += 1;
56
+ } else {
57
+ cursor += 1;
58
+ }
59
+ if (index < root.children.length - 1) {
60
+ cursor += 1;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Walk all paragraphs in the document and return the absolute positions of the
68
+ * start and end markers for each scope that has either side present. Used by
69
+ * `resolveScope` + `findScopeAt`; exported so test code can inspect the
70
+ * marker-to-position mapping directly.
71
+ */
72
+ export function collectScopeLocations(
73
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
74
+ ): Map<string, { startPos?: number; endPos?: number }> {
75
+ const locations = new Map<string, { startPos?: number; endPos?: number }>();
76
+ const paragraphs = walkParagraphs(document);
77
+ for (const { paragraph, from } of paragraphs) {
78
+ let cursor = from;
79
+ for (const child of paragraph.children) {
80
+ if (child.type === "scope_marker_start") {
81
+ const prior = locations.get(child.scopeId) ?? {};
82
+ locations.set(child.scopeId, { ...prior, startPos: cursor });
83
+ } else if (child.type === "scope_marker_end") {
84
+ const prior = locations.get(child.scopeId) ?? {};
85
+ locations.set(child.scopeId, { ...prior, endPos: cursor });
86
+ }
87
+ cursor += inlineLength(child as InlineNode);
88
+ }
89
+ }
90
+ return locations;
91
+ }
92
+
93
+ /**
94
+ * Resolve a scopeId to a live public range anchor derived from the marker
95
+ * positions currently in the document. Returns:
96
+ * - A range anchor when both markers are present
97
+ * - A detached anchor when one or zero markers survive
98
+ * - `null` when neither marker is in the document
99
+ */
100
+ export function resolveScope(
101
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
102
+ scopeId: string,
103
+ ): EditorAnchorProjection | null {
104
+ const locations = collectScopeLocations(document);
105
+ const loc = locations.get(scopeId);
106
+ if (!loc) return null;
107
+
108
+ if (loc.startPos !== undefined && loc.endPos !== undefined) {
109
+ const from = Math.min(loc.startPos, loc.endPos);
110
+ const to = Math.max(loc.startPos, loc.endPos);
111
+ return {
112
+ kind: "range",
113
+ from,
114
+ to,
115
+ assoc: { start: -1, end: 1 },
116
+ };
117
+ }
118
+
119
+ return {
120
+ kind: "detached",
121
+ reason: "deleted",
122
+ lastKnownRange: {
123
+ from: loc.startPos ?? loc.endPos ?? 0,
124
+ to: loc.endPos ?? loc.startPos ?? 0,
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Given a position, return the innermost enclosing scope (by document order).
131
+ * Used by the chrome overlay hit-test and by the edit-dispatch guard that
132
+ * routes delete-through-marker to `removeScope`.
133
+ */
134
+ export function findScopeAt(
135
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
136
+ position: number,
137
+ ): { scopeId: string; startPos: number; endPos: number } | null {
138
+ const locations = collectScopeLocations(document);
139
+ let best: { scopeId: string; startPos: number; endPos: number } | null = null;
140
+ for (const [scopeId, loc] of locations) {
141
+ if (loc.startPos === undefined || loc.endPos === undefined) continue;
142
+ if (position < loc.startPos || position > loc.endPos) continue;
143
+ if (!best || loc.startPos > best.startPos) {
144
+ best = { scopeId, startPos: loc.startPos, endPos: loc.endPos };
145
+ }
146
+ }
147
+ return best;
148
+ }
@@ -53,6 +53,16 @@ export const DEFAULT_REGISTRY_ENTRIES: Readonly<Record<string, ScopeTagBehavior>
53
53
  trimOnDelete: true,
54
54
  bailIfCrossed: false,
55
55
  },
56
+ // S1 scope-marker anchoring. `trimOnDelete: false` is the load-bearing
57
+ // difference vs. `bookmark` — a delete that crosses a scope marker routes
58
+ // through `removeScope` instead of silently trimming the marker, so
59
+ // half-scope states (orphaned metadata in customXml) never appear.
60
+ "workflow-scope-marker": {
61
+ extendOnInsertLeft: true,
62
+ extendOnInsertRight: true,
63
+ trimOnDelete: false,
64
+ bailIfCrossed: false,
65
+ },
56
66
  sdt: {
57
67
  extendOnInsertLeft: false,
58
68
  extendOnInsertRight: false,
@@ -51,6 +51,7 @@ import {
51
51
  resolveEffectiveRunFormatting,
52
52
  resolveNumberingMarkerRunFormatting,
53
53
  } from "./paragraph-style-resolver.ts";
54
+ import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
54
55
  import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
55
56
 
56
57
  interface ParagraphAccumulator {
@@ -97,6 +98,17 @@ export function createEditorSurfaceSnapshot(
97
98
  };
98
99
 
99
100
  for (let index = 0; index < root.children.length; index += 1) {
101
+ const isInViewport =
102
+ viewportBlockRange === null ||
103
+ (index >= viewportBlockRange.start && index < viewportBlockRange.end);
104
+ // L7 Phase 2.9 — viewport bail on the style-cascade work. When the
105
+ // block is outside the viewport, the surface block produced below is
106
+ // immediately discarded in favor of a `placeholder-culled` entry (we
107
+ // only consume `nextCursor`). Pass `cullBuild: true` so the paragraph
108
+ // + inline walkers skip `resolveEffectiveParagraphFormatting`,
109
+ // `resolveNumberingMarkerRunFormatting`, and per-segment
110
+ // `resolveEffectiveRunFormatting` — the expensive style-catalog walks
111
+ // that dominate surface-projection cost on large docs.
100
112
  const surfaceBlock = createSurfaceBlock(
101
113
  root.children[index],
102
114
  document,
@@ -104,10 +116,8 @@ export function createEditorSurfaceSnapshot(
104
116
  counters,
105
117
  numberingPrefixResolver,
106
118
  activeStory.kind !== "main",
119
+ !isInViewport,
107
120
  );
108
- const isInViewport =
109
- viewportBlockRange === null ||
110
- (index >= viewportBlockRange.start && index < viewportBlockRange.end);
111
121
 
112
122
  if (isInViewport) {
113
123
  blocks.push(surfaceBlock.block);
@@ -165,6 +175,7 @@ function createSurfaceBlock(
165
175
  },
166
176
  numberingPrefixResolver: NumberingPrefixResolver,
167
177
  promoteSecondaryStoryTextBoxes: boolean,
178
+ cullBuild: boolean = false,
168
179
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
169
180
  if (block.type === "opaque_block") {
170
181
  const fragment = getOpaqueFragment(document.preservation as never, block.fragmentId);
@@ -205,6 +216,7 @@ function createSurfaceBlock(
205
216
  counters,
206
217
  numberingPrefixResolver,
207
218
  promoteSecondaryStoryTextBoxes,
219
+ cullBuild,
208
220
  );
209
221
  }
210
222
 
@@ -244,6 +256,7 @@ function createSurfaceBlock(
244
256
  counters,
245
257
  numberingPrefixResolver,
246
258
  promoteSecondaryStoryTextBoxes,
259
+ cullBuild,
247
260
  );
248
261
  }
249
262
 
@@ -336,6 +349,7 @@ function createSurfaceBlock(
336
349
  cursor,
337
350
  numberingPrefixResolver,
338
351
  promoteSecondaryStoryTextBoxes,
352
+ cullBuild,
339
353
  );
340
354
  }
341
355
 
@@ -354,6 +368,7 @@ function createTableBlock(
354
368
  },
355
369
  numberingPrefixResolver: NumberingPrefixResolver,
356
370
  promoteSecondaryStoryTextBoxes: boolean,
371
+ cullBuild: boolean = false,
357
372
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
358
373
  const lockedFragmentIds: string[] = [];
359
374
  let innerCursor = cursor;
@@ -374,6 +389,7 @@ function createTableBlock(
374
389
  counters,
375
390
  numberingPrefixResolver,
376
391
  promoteSecondaryStoryTextBoxes,
392
+ cullBuild,
377
393
  );
378
394
  cellContent.push(result.block);
379
395
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -518,6 +534,7 @@ function createSdtBlock(
518
534
  },
519
535
  numberingPrefixResolver: NumberingPrefixResolver,
520
536
  promoteSecondaryStoryTextBoxes: boolean,
537
+ cullBuild: boolean = false,
521
538
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
522
539
  const children: SurfaceBlockSnapshot[] = [];
523
540
  const lockedFragmentIds: string[] = [];
@@ -531,6 +548,7 @@ function createSdtBlock(
531
548
  counters,
532
549
  numberingPrefixResolver,
533
550
  promoteSecondaryStoryTextBoxes,
551
+ cullBuild,
534
552
  );
535
553
  children.push(result.block);
536
554
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -566,34 +584,52 @@ function createParagraphBlock(
566
584
  start: number,
567
585
  numberingPrefixResolver: NumberingPrefixResolver,
568
586
  promoteSecondaryStoryTextBoxes: boolean,
587
+ cullBuild: boolean = false,
569
588
  ): {
570
589
  block: SurfaceBlockSnapshot;
571
590
  nextCursor: number;
572
591
  lockedFragmentIds: string[];
573
592
  } {
574
- const effectiveNumbering = resolveEffectiveParagraphNumbering(document, paragraph);
575
- const resolvedNumbering = effectiveNumbering
576
- ? numberingPrefixResolver.resolveDetailed(effectiveNumbering, paragraph)
577
- : null;
578
-
579
- // Task 11: compute cascaded paragraph formatting
593
+ // L7 Phase 2.9 — viewport bail. When the paragraph is outside the
594
+ // viewport, the returned block is discarded (the outer caller in
595
+ // `createEditorSurfaceSnapshot` replaces it with a placeholder-culled
596
+ // entry and only consumes `nextCursor`). Skip the two style-catalog
597
+ // walks (`resolveEffectiveParagraphFormatting`,
598
+ // `resolveNumberingMarkerRunFormatting`) their results are not read
599
+ // by the placeholder path. Segment-level work inside
600
+ // `appendInlineSegments` is suppressed symmetrically via the same
601
+ // `cullBuild` flag, preserving cursor arithmetic.
602
+ const effectiveNumbering = cullBuild
603
+ ? undefined
604
+ : resolveEffectiveParagraphNumbering(document, paragraph);
605
+ const resolvedNumbering =
606
+ !cullBuild && effectiveNumbering
607
+ ? numberingPrefixResolver.resolveDetailed(effectiveNumbering, paragraph)
608
+ : null;
609
+
610
+ // Task 11: compute cascaded paragraph formatting (expensive — styles-catalog walk).
580
611
  const stylesCatalog = document.styles;
581
- const directParagraphFormatting = buildDirectParagraphFormattingFromNode(paragraph);
582
- const resolvedParagraphFormatting = resolveEffectiveParagraphFormatting(
583
- { styleId: paragraph.styleId, direct: directParagraphFormatting },
584
- stylesCatalog,
585
- );
586
-
587
- // Task 11: compute cascaded marker run formatting
588
- const markerRunProperties = effectiveNumbering
589
- ? resolveNumberingMarkerRunFormatting(
590
- {
591
- paragraphStyleId: paragraph.styleId,
592
- levelRunProperties: resolvedNumbering?.markerRunProperties,
593
- },
612
+ const directParagraphFormatting = cullBuild
613
+ ? undefined
614
+ : buildDirectParagraphFormattingFromNode(paragraph);
615
+ const resolvedParagraphFormatting = cullBuild
616
+ ? undefined
617
+ : resolveEffectiveParagraphFormatting(
618
+ { styleId: paragraph.styleId, direct: directParagraphFormatting },
594
619
  stylesCatalog,
595
- )
596
- : undefined;
620
+ );
621
+
622
+ // Task 11: compute cascaded marker run formatting (expensive).
623
+ const markerRunProperties =
624
+ !cullBuild && effectiveNumbering
625
+ ? resolveNumberingMarkerRunFormatting(
626
+ {
627
+ paragraphStyleId: paragraph.styleId,
628
+ levelRunProperties: resolvedNumbering?.markerRunProperties,
629
+ },
630
+ stylesCatalog,
631
+ )
632
+ : undefined;
597
633
 
598
634
  const accumulator: ParagraphAccumulator = {
599
635
  blockId: `paragraph-${paragraphIndex}`,
@@ -650,6 +686,8 @@ function createParagraphBlock(
650
686
  document,
651
687
  cursor,
652
688
  promoteSecondaryStoryTextBoxes,
689
+ undefined,
690
+ cullBuild,
653
691
  );
654
692
  cursor = result.nextCursor;
655
693
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -812,22 +850,39 @@ function appendInlineSegments(
812
850
  start: number,
813
851
  promoteSecondaryStoryTextBoxes: boolean,
814
852
  hyperlinkHref?: string,
853
+ cullBuild: boolean = false,
815
854
  ): { nextCursor: number; lockedFragmentIds: string[] } {
816
855
  switch (node.type) {
817
856
  case "text": {
818
857
  const cloned = node.marks ? cloneMarks(node.marks) : { marks: [] as SurfaceTextMark[] };
819
- const directRunFormatting = buildDirectRunFormattingFromMarks(
820
- cloned.marks.length > 0 ? cloned.marks : undefined,
821
- cloned.markAttrs,
822
- );
823
- const resolvedRunFormatting = resolveEffectiveRunFormatting(
824
- {
825
- paragraphStyleId: paragraph.styleId,
826
- characterStyleId: undefined,
827
- direct: directRunFormatting,
828
- },
829
- document.styles,
830
- );
858
+ // L7 Phase 2.9 — skip the styles-catalog run-cascade walk when
859
+ // the block will be culled. `resolveEffectiveRunFormatting`
860
+ // dominates per-text-segment cost on style-heavy docs; the
861
+ // placeholder path does not read `resolvedRunFormatting`.
862
+ const directRunFormatting = cullBuild
863
+ ? undefined
864
+ : buildDirectRunFormattingFromMarks(
865
+ cloned.marks.length > 0 ? cloned.marks : undefined,
866
+ cloned.markAttrs,
867
+ );
868
+ // V7 — runs inside <w:hyperlink> go through the hyperlink color
869
+ // cascade (auto-applies the Hyperlink character style + resolves
870
+ // theme hlink slot with Word-default fallback). Non-hyperlink
871
+ // runs take the unchanged cascade path.
872
+ const runResolveInput = {
873
+ paragraphStyleId: paragraph.styleId,
874
+ characterStyleId: undefined,
875
+ direct: directRunFormatting,
876
+ };
877
+ const resolvedRunFormatting = cullBuild
878
+ ? {}
879
+ : hyperlinkHref
880
+ ? resolveHyperlinkRunFormatting(
881
+ runResolveInput,
882
+ document.styles,
883
+ document.subParts?.resolvedTheme,
884
+ )
885
+ : resolveEffectiveRunFormatting(runResolveInput, document.styles);
831
886
  paragraph.segments.push({
832
887
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
833
888
  kind: "text",
@@ -869,6 +924,7 @@ function appendInlineSegments(
869
924
  cursor,
870
925
  promoteSecondaryStoryTextBoxes,
871
926
  node.href,
927
+ cullBuild,
872
928
  );
873
929
  cursor = result.nextCursor;
874
930
  }
@@ -1003,6 +1059,8 @@ function appendInlineSegments(
1003
1059
  document,
1004
1060
  cursor,
1005
1061
  promoteSecondaryStoryTextBoxes,
1062
+ undefined,
1063
+ cullBuild,
1006
1064
  );
1007
1065
  cursor = result.nextCursor;
1008
1066
  lockedIds.push(...result.lockedFragmentIds);
@@ -1047,7 +1105,11 @@ function appendInlineSegments(
1047
1105
  }
1048
1106
  case "bookmark_start":
1049
1107
  case "bookmark_end":
1050
- // Zero-width markers — no visual, no cursor advancement
1108
+ case "scope_marker_start":
1109
+ case "scope_marker_end":
1110
+ // Zero-width markers — no visual, no cursor advancement. Scope markers
1111
+ // (S1) follow the bookmark precedent: structural anchors whose positions
1112
+ // track with surrounding text but which don't occupy cursor positions.
1051
1113
  return { nextCursor: start, lockedFragmentIds: [] };
1052
1114
  default:
1053
1115
  return { nextCursor: start + 1, lockedFragmentIds: [] };
@@ -1466,6 +1528,9 @@ function summarizePreviewInline(node: InlineNode): string {
1466
1528
  return node.name ? `[Bookmark: ${node.name}]` : "[Bookmark]";
1467
1529
  case "bookmark_end":
1468
1530
  return "";
1531
+ case "scope_marker_start":
1532
+ case "scope_marker_end":
1533
+ return "";
1469
1534
  case "image":
1470
1535
  return node.altText ? `[Image: ${node.altText}]` : "[Image]";
1471
1536
  case "opaque_inline":