@beyondwork/docx-react-component 1.0.48 → 1.0.50

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 (53) hide show
  1. package/README.md +19 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +103 -12
  4. package/src/core/commands/index.ts +30 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-comments.ts +50 -5
  12. package/src/io/export/serialize-main-document.ts +9 -0
  13. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  14. package/src/io/export/serialize-run-formatting.ts +10 -1
  15. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  16. package/src/io/ooxml/chart/color-palette.ts +101 -0
  17. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  18. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  19. package/src/io/ooxml/chart/parse-series.ts +76 -11
  20. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  21. package/src/io/ooxml/chart/types.ts +30 -11
  22. package/src/io/ooxml/parse-complex-content.ts +6 -3
  23. package/src/io/ooxml/parse-main-document.ts +41 -0
  24. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  25. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  26. package/src/io/ooxml/property-grab-bag.ts +211 -0
  27. package/src/io/paste/word-clipboard.ts +114 -0
  28. package/src/model/canonical-document.ts +69 -3
  29. package/src/runtime/collab/index.ts +7 -0
  30. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  31. package/src/runtime/collab/workflow-shared.ts +247 -0
  32. package/src/runtime/document-locations.ts +1 -9
  33. package/src/runtime/document-outline.ts +1 -9
  34. package/src/runtime/document-runtime.ts +98 -50
  35. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  36. package/src/runtime/layout/layout-engine-version.ts +11 -1
  37. package/src/runtime/layout/public-facet.ts +5 -12
  38. package/src/runtime/render/render-frame-types.ts +14 -0
  39. package/src/runtime/render/render-kernel.ts +40 -2
  40. package/src/runtime/structure-ops/fragment-insert.ts +134 -0
  41. package/src/runtime/surface-projection.ts +94 -36
  42. package/src/runtime/theme-color-resolver.ts +188 -0
  43. package/src/runtime/workflow-markup.ts +7 -18
  44. package/src/ui/WordReviewEditor.tsx +22 -4
  45. package/src/ui/editor-runtime-boundary.ts +37 -0
  46. package/src/ui/headless/selection-helpers.ts +10 -23
  47. package/src/ui/unsupported-previews-policy.ts +23 -0
  48. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  49. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  50. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
  51. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  52. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  53. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Lane 3 V7 — hyperlink color cascade.
3
+ *
4
+ * OOXML convention: runs inside `<w:hyperlink>` inherit the `Hyperlink`
5
+ * character style implicitly, EVEN WHEN source XML does not declare an
6
+ * explicit `<w:rStyle w:val="Hyperlink"/>` on each run. Word applies the
7
+ * style based on the containing hyperlink element's context alone.
8
+ *
9
+ * Our existing cascade (`resolveEffectiveRunFormatting`) only applies the
10
+ * character-style chain when `input.characterStyleId` is populated — so
11
+ * runs inside hyperlinks that lacked explicit rStyle were inheriting
12
+ * whatever the paragraph style said (usually black body text).
13
+ *
14
+ * This module closes that gap by resolving hyperlink color via a
15
+ * four-tier fallback chain:
16
+ *
17
+ * 1. Direct color on the run (`colorHex !== "auto"`) — wins outright.
18
+ * 2. Character-style cascade — forces Hyperlink style participation.
19
+ * 3. Theme hlink slot (`ResolvedTheme.colors.hlink`).
20
+ * 4. Hardcoded Word default `#0563C1`.
21
+ *
22
+ * The resolver also honors `colorThemeSlot` + `colorThemeTint`/`colorThemeShade`
23
+ * from L2.c by delegating to `resolveThemeColorHex`.
24
+ *
25
+ * Contract: the returned `CanonicalRunFormatting` is the effective cascade
26
+ * result with `colorHex` concretized to a non-theme hex (or `"auto"`). The
27
+ * original `colorThemeSlot` / `colorThemeTint` / `colorThemeShade` fields
28
+ * are preserved on the returned object so downstream code (or re-export
29
+ * via the canonical document) still sees the theme reference.
30
+ */
31
+
32
+ import type {
33
+ CanonicalRunFormatting,
34
+ ResolvedTheme,
35
+ StylesCatalog,
36
+ } from "../model/canonical-document.ts";
37
+ import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
38
+ import { resolveThemeColorHex } from "./theme-color-resolver.ts";
39
+ import {
40
+ resolveEffectiveRunFormatting,
41
+ type RunResolveInput,
42
+ } from "./paragraph-style-resolver.ts";
43
+
44
+ export const HYPERLINK_CHARACTER_STYLE_ID = "Hyperlink";
45
+
46
+ /**
47
+ * Microsoft Word's default hyperlink color (applied when neither the
48
+ * Hyperlink character style nor the theme's `hlink` slot supplies one).
49
+ * Matches Word 2013+ fresh-document rendering.
50
+ */
51
+ export const DEFAULT_HYPERLINK_COLOR_HEX = "0563C1";
52
+
53
+ /**
54
+ * Resolve effective run formatting for a hyperlink-inner run. Honors the
55
+ * Hyperlink character style implicitly, resolves any theme-slot color
56
+ * references, and applies the Word-default fallback when upstream data
57
+ * is absent.
58
+ *
59
+ * `input.characterStyleId` is respected when the caller already passed
60
+ * one (e.g., source XML had an explicit `<w:rStyle>` overriding the
61
+ * implicit Hyperlink). Only when it is absent does the resolver inject
62
+ * `"Hyperlink"` itself.
63
+ */
64
+ export function resolveHyperlinkRunFormatting(
65
+ input: RunResolveInput,
66
+ catalog: StylesCatalog | undefined,
67
+ theme: ResolvedTheme | undefined,
68
+ ): CanonicalRunFormatting {
69
+ // V7a — auto-apply the Hyperlink character style when the caller did
70
+ // not supply one (runs inside <w:hyperlink> typically lack explicit
71
+ // rStyle; Word applies the style by context).
72
+ const augmentedInput: RunResolveInput =
73
+ input.characterStyleId === undefined
74
+ ? { ...input, characterStyleId: HYPERLINK_CHARACTER_STYLE_ID }
75
+ : input;
76
+
77
+ const cascade = resolveEffectiveRunFormatting(augmentedInput, catalog);
78
+
79
+ // V7b — concretize the color through the theme resolver + Word default.
80
+ const resolvedColor = resolveHyperlinkColorHex(cascade, theme);
81
+ if (resolvedColor && resolvedColor !== cascade.colorHex) {
82
+ return { ...cascade, colorHex: resolvedColor };
83
+ }
84
+ return cascade;
85
+ }
86
+
87
+ /**
88
+ * Four-tier hyperlink color fallback. Exported for targeted testing; use
89
+ * `resolveHyperlinkRunFormatting` as the primary entry point.
90
+ */
91
+ export function resolveHyperlinkColorHex(
92
+ cascade: Pick<
93
+ CanonicalRunFormatting,
94
+ "colorHex" | "colorThemeSlot" | "colorThemeTint" | "colorThemeShade"
95
+ >,
96
+ theme: ResolvedTheme | undefined,
97
+ ): string | undefined {
98
+ // Tier 1 — direct non-auto hex wins.
99
+ if (cascade.colorHex && cascade.colorHex !== "auto") {
100
+ return cascade.colorHex;
101
+ }
102
+ // Tier 2 — theme-slot reference from the cascade (which now includes the
103
+ // Hyperlink style's rPr — typically `<w:color w:themeColor="hlink"/>`).
104
+ if (cascade.colorThemeSlot) {
105
+ const viaTheme = resolveThemeColorHex(cascade, theme);
106
+ if (viaTheme && viaTheme !== "auto") {
107
+ return viaTheme;
108
+ }
109
+ }
110
+ // Tier 3 — theme hlink slot even when the cascade never wrote a slot
111
+ // reference. This catches docs whose Hyperlink style lacks a color
112
+ // declaration entirely but whose theme defines hlink.
113
+ const themeHlink = resolveThemeColor(theme, "hlink");
114
+ if (themeHlink) {
115
+ return themeHlink;
116
+ }
117
+ // Tier 4 — Word's hardcoded default.
118
+ return DEFAULT_HYPERLINK_COLOR_HEX;
119
+ }
@@ -42,8 +42,18 @@
42
42
  * file under `src/runtime/layout/**` changed. Safe to treat
43
43
  * versions 3, 4, and 5 as cache-compatible if a migration ever
44
44
  * needs to collapse them.
45
+ * 6 — Lane 3a P9 Phase A. `RenderAnchorIndex` gains three chrome-kind
46
+ * resolvers (`byScopeId`, `byCommentId`, `byRevisionId`) sourced
47
+ * from the resolved `DecorationIndex`, and `buildAnchorIndex`
48
+ * runs in two phases inside the render kernel so the final index
49
+ * carries decoration-aware lookups. No cached-geometry change;
50
+ * consumers (Lane 1 R.1 SelectionLayer, Lane 6 P11 chrome rails)
51
+ * can now query one unified API instead of reaching into
52
+ * `frame.decorationIndex`. Cache envelopes from version 5 are
53
+ * invalidated on load because the anchor-index public shape
54
+ * changed even though pixel geometry did not.
45
55
  */
46
- export const LAYOUT_ENGINE_VERSION = 5 as const;
56
+ export const LAYOUT_ENGINE_VERSION = 6 as const;
47
57
 
48
58
  /**
49
59
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -1824,22 +1824,15 @@ function resolveAnchorRects(
1824
1824
  return rect ? [rect] : [];
1825
1825
  }
1826
1826
  case "scope-id": {
1827
- const id = String(query.value);
1828
- return frame.decorationIndex.workflow
1829
- .filter((decoration) => decoration.refId === id)
1830
- .map((decoration) => decoration.frame);
1827
+ return frame.anchorIndex.byScopeId(String(query.value));
1831
1828
  }
1832
1829
  case "comment-id": {
1833
- const id = String(query.value);
1834
- return frame.decorationIndex.comments
1835
- .filter((decoration) => decoration.refId === id)
1836
- .map((decoration) => decoration.frame);
1830
+ const rect = frame.anchorIndex.byCommentId(String(query.value));
1831
+ return rect ? [rect] : [];
1837
1832
  }
1838
1833
  case "revision-id": {
1839
- const id = String(query.value);
1840
- return frame.decorationIndex.revisions
1841
- .filter((decoration) => decoration.refId === id)
1842
- .map((decoration) => decoration.frame);
1834
+ const rect = frame.anchorIndex.byRevisionId(String(query.value));
1835
+ return rect ? [rect] : [];
1843
1836
  }
1844
1837
  default: {
1845
1838
  const exhaustive: never = query.kind;
@@ -230,6 +230,20 @@ export interface RenderAnchorIndex {
230
230
  tableBlockId: string,
231
231
  rowIndex: number,
232
232
  ): RenderFrameRect | null;
233
+ /**
234
+ * Chrome-kind resolvers (P9 Phase A). Read against the frame's
235
+ * `decorationIndex` so chrome surfaces (scope rails, comment balloons,
236
+ * revision margin bars, Lane 1 R.1 SelectionLayer) query one unified
237
+ * API instead of reaching into `frame.decorationIndex` directly.
238
+ *
239
+ * `byScopeId` returns every rect because a single workflow scope may
240
+ * cover multiple pages (one `RenderBlockDecoration` per page); chrome
241
+ * rails read the list. `byCommentId` and `byRevisionId` return a single
242
+ * rect — `resolveDecorationIndex` emits one entry per thread/revision.
243
+ */
244
+ byScopeId(scopeId: string): readonly RenderFrameRect[];
245
+ byCommentId(commentId: string): RenderFrameRect | null;
246
+ byRevisionId(revisionId: string): RenderFrameRect | null;
233
247
  }
234
248
 
235
249
  // ---------------------------------------------------------------------------
@@ -192,7 +192,16 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
192
192
  }
193
193
 
194
194
  const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
195
- const anchorIndex = buildAnchorIndex(renderPages, pendingDeltas, zoom.pxPerTwip);
195
+ // P9 Phase A two-phase anchor-index build. The decoration resolver
196
+ // reads the anchor index to map runtime ranges to frame rects; the
197
+ // final anchor index then exposes chrome-kind resolvers that read
198
+ // back from the resolved decoration index. Rebuilding the index with
199
+ // the resolved decoration data avoids a post-hoc mutation seam.
200
+ const baseAnchorIndex = buildAnchorIndex(
201
+ renderPages,
202
+ pendingDeltas,
203
+ zoom.pxPerTwip,
204
+ );
196
205
  const includeDecorations = options?.includeDecorations ?? true;
197
206
  const sources = input.getDecorationSources?.();
198
207
  const hasSources =
@@ -205,8 +214,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
205
214
  const decorationIndex: DecorationIndex = !includeDecorations
206
215
  ? EMPTY_DECORATION_INDEX
207
216
  : hasSources
208
- ? resolveDecorationIndex({ anchorIndex, ...sources })
217
+ ? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
209
218
  : buildDecorationIndex(renderPages);
219
+ const anchorIndex = buildAnchorIndex(
220
+ renderPages,
221
+ pendingDeltas,
222
+ zoom.pxPerTwip,
223
+ decorationIndex,
224
+ );
210
225
 
211
226
  // Revision: keyed off the engine's current page graph so repeated reads
212
227
  // at the same revision return the same cached frame. We derive it
@@ -634,6 +649,7 @@ function buildAnchorIndex(
634
649
  pages: readonly RenderPage[],
635
650
  pendingDeltas: readonly PendingOpDelta[] = [],
636
651
  pxPerTwip = 1,
652
+ decorationIndex: DecorationIndex = EMPTY_DECORATION_INDEX,
637
653
  ): RenderAnchorIndex {
638
654
  const byRuntimeOffset = new Map<number, RenderFrameRect>();
639
655
  const byFragmentId = new Map<string, RenderFrameRect>();
@@ -739,6 +755,28 @@ function buildAnchorIndex(
739
755
  byTableRowEdge(tableBlockId, rowIndex) {
740
756
  return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
741
757
  },
758
+ // P9 Phase A — chrome-kind resolvers sourced from the resolved
759
+ // decoration index. Empty by default (the initial frame build passes
760
+ // `EMPTY_DECORATION_INDEX` until decoration resolution runs); the
761
+ // kernel re-invokes `buildAnchorIndex` with the resolved index so the
762
+ // final anchor index carries chrome-aware lookups.
763
+ byScopeId(scopeId) {
764
+ return decorationIndex.workflow
765
+ .filter((decoration) => decoration.refId === scopeId)
766
+ .map((decoration) => decoration.frame);
767
+ },
768
+ byCommentId(commentId) {
769
+ const match = decorationIndex.comments.find(
770
+ (decoration) => decoration.refId === commentId,
771
+ );
772
+ return match?.frame ?? null;
773
+ },
774
+ byRevisionId(revisionId) {
775
+ const match = decorationIndex.revisions.find(
776
+ (decoration) => decoration.refId === revisionId,
777
+ );
778
+ return match?.frame ?? null;
779
+ },
742
780
  };
743
781
  }
744
782
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Slice 1 of the I2 Tier B rich-paste sub-plan (`docs/plans/lane-1-i2-tier-b-rich-paste.md`).
3
+ *
4
+ * `applyFragmentInsert` is the canonical splicer for `CanonicalDocumentFragment` —
5
+ * the block-level payload that the HTML and Word-clipboard paste parsers (Slices 2+3)
6
+ * will produce. Slice 1 ships the shape and a baseline "split-and-splice" semantic
7
+ * without any parser in front of it, so the public `insertFragment` method can be
8
+ * driven directly from tests + future hosts.
9
+ *
10
+ * Baseline semantics:
11
+ * 1. Empty fragment → no-op (no revisionToken bump).
12
+ * 2. Range selection → the range is deleted first via `applyTextTransaction`, then
13
+ * the caret is the range start.
14
+ * 3. Caret paragraph is split via `splitParagraph`; fragment blocks are spliced
15
+ * between the two halves. Empty halves at document boundaries are preserved —
16
+ * callers can trim them if desired.
17
+ *
18
+ * What Slice 1 deliberately does NOT do:
19
+ * - Merge-intent (pre-Slice-1 drafts had `firstParagraphMergeIntent` on the type).
20
+ * Merge semantics will land in a follow-up slice once we have a paste fixture
21
+ * that demonstrates the need.
22
+ * - Fragment insertion inside table cells beyond the trivial case. Table-cell
23
+ * splicing is currently best-effort: the target paragraph within the cell is
24
+ * split, but cross-cell fragments are rejected as a no-op.
25
+ * - Comment/revision remapping across the fragment boundary — the splicer returns
26
+ * an empty mapping; follow-up slices will produce richer mappings.
27
+ */
28
+
29
+ import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
30
+ import {
31
+ type CanonicalDocumentEnvelope,
32
+ type SelectionSnapshot,
33
+ createSelectionSnapshot,
34
+ } from "../../core/state/editor-state.ts";
35
+ import { applyTextTransaction } from "../../core/state/text-transaction.ts";
36
+ import { splitParagraph, type TextCommandContext } from "../../core/commands/text-commands.ts";
37
+ import { resolveParagraphScope, type StructuralMutationResult } from "../../core/commands/structural-helpers.ts";
38
+ import type { BlockNode, DocumentRootNode, ParagraphNode } from "../../model/canonical-document.ts";
39
+ import { createEmptyMapping } from "../../core/selection/mapping.ts";
40
+
41
+ export function applyFragmentInsert(
42
+ document: CanonicalDocumentEnvelope,
43
+ selection: SelectionSnapshot,
44
+ fragment: CanonicalDocumentFragment,
45
+ context: TextCommandContext,
46
+ ): StructuralMutationResult {
47
+ if (fragment.blocks.length === 0) {
48
+ return {
49
+ changed: false,
50
+ document,
51
+ selection,
52
+ };
53
+ }
54
+
55
+ // Collapse any range selection by first deleting the selected content. The
56
+ // resulting caret is at `min(anchor, head)`.
57
+ let workingDocument = document;
58
+ let workingSelection = selection;
59
+ if (selection.anchor !== selection.head) {
60
+ const collapseResult = applyTextTransaction(
61
+ workingDocument,
62
+ workingSelection,
63
+ { type: "replace", insertion: [] },
64
+ context,
65
+ );
66
+ workingDocument = collapseResult.document;
67
+ workingSelection = collapseResult.selection;
68
+ }
69
+
70
+ // Split the caret paragraph; the fragment blocks go between the two halves.
71
+ const splitResult = splitParagraph(workingDocument, workingSelection, context);
72
+ const splitRoot = splitResult.document.content;
73
+ if (!splitRoot || splitRoot.type !== "doc") {
74
+ return {
75
+ changed: false,
76
+ document,
77
+ selection,
78
+ };
79
+ }
80
+
81
+ // Locate the split boundary by re-resolving the paragraph scope against the
82
+ // pre-split snapshot. The right-half index = scope.blockIndex + 1.
83
+ const scope = resolveParagraphScope(workingDocument, workingSelection);
84
+ if (!scope || scope.kind !== "top-level") {
85
+ // Table-cell fragment insert is out of scope for Slice 1.
86
+ return {
87
+ changed: false,
88
+ document,
89
+ selection,
90
+ };
91
+ }
92
+
93
+ const rightHalfIndex = scope.blockIndex + 1;
94
+ const splicedChildren: BlockNode[] = [
95
+ ...splitRoot.children.slice(0, rightHalfIndex),
96
+ ...fragment.blocks.map((block) => cloneBlock(block)),
97
+ ...splitRoot.children.slice(rightHalfIndex),
98
+ ];
99
+
100
+ const nextRoot: DocumentRootNode = {
101
+ ...splitRoot,
102
+ children: splicedChildren,
103
+ };
104
+
105
+ const nextDocument: CanonicalDocumentEnvelope = {
106
+ ...splitResult.document,
107
+ updatedAt: context.timestamp,
108
+ content: nextRoot,
109
+ };
110
+
111
+ // Caret lands at the end of the last fragment block. For Slice 1 we approximate
112
+ // this with a collapsed selection at position 0 of the new document — richer
113
+ // caret placement follows once parsers drive this.
114
+ const nextSelection = createSelectionSnapshot(0, 0);
115
+
116
+ return {
117
+ changed: true,
118
+ document: nextDocument,
119
+ selection: nextSelection,
120
+ mapping: createEmptyMapping(),
121
+ };
122
+ }
123
+
124
+ function cloneBlock(block: BlockNode): BlockNode {
125
+ // Slice 1 uses a structural clone; no need to deep-clone formatting attrs since
126
+ // fragment blocks are presumed freshly minted by the caller.
127
+ if (block.type === "paragraph") {
128
+ return {
129
+ ...block,
130
+ children: block.children.map((child) => ({ ...child })),
131
+ } as ParagraphNode;
132
+ }
133
+ return JSON.parse(JSON.stringify(block)) as BlockNode;
134
+ }
@@ -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);