@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  DocumentNavigationSnapshot,
3
+ EditorAnchorProjection,
3
4
  EditorStoryTarget,
4
5
  SearchOptions,
5
6
  SearchResultSnapshot,
6
7
  SelectionSnapshot,
8
+ SurfaceBlockSnapshot,
9
+ SurfaceInlineSegment,
10
+ TextStyleFilter,
7
11
  } from "../api/public-types";
12
+ import { EditorApiError } from "../api/public-types.ts";
8
13
  import {
9
14
  MAIN_STORY_TARGET,
10
15
  storyTargetsEqual,
@@ -23,6 +28,7 @@ import {
23
28
  resolveSectionForStoryTarget,
24
29
  } from "./document-layout.ts";
25
30
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
31
+ import { resolveScope } from "./scope-resolver.ts";
26
32
 
27
33
  export function searchDocument(
28
34
  document: CanonicalDocumentEnvelope,
@@ -143,3 +149,173 @@ function getActiveSearchResultIndex(
143
149
 
144
150
  return activeIndex >= 0 ? activeIndex : 0;
145
151
  }
152
+
153
+ /**
154
+ * Phase C §C3 — find all text matches in the document, respecting the new
155
+ * `regex`, `inScope`, and `inStory` options. Throws `EditorApiError` with
156
+ * `code: "search_invalid_regex"` if `options.regex === true` and `query`
157
+ * is not a valid JavaScript regular expression pattern.
158
+ *
159
+ * Returns an array of `EditorAnchorProjection` values (range anchors) that
160
+ * compose directly with `setSelection`, `addScope`, `getLocationForAnchor`.
161
+ */
162
+ export function findTextMatches(
163
+ document: CanonicalDocumentEnvelope,
164
+ selection: SelectionSnapshot,
165
+ query: string,
166
+ options: SearchOptions = {},
167
+ ): EditorAnchorProjection[] {
168
+ const normalizedQuery = query.trim();
169
+ if (!normalizedQuery) return [];
170
+
171
+ if (options.regex) {
172
+ try {
173
+ const flags = options.matchCase ? "ug" : "uig";
174
+ new RegExp(normalizedQuery, flags);
175
+ } catch {
176
+ throw new EditorApiError({
177
+ code: "search_invalid_regex",
178
+ message: `Invalid regex pattern: ${normalizedQuery}`,
179
+ });
180
+ }
181
+ }
182
+
183
+ const storyTarget: EditorStoryTarget = options.inStory ?? MAIN_STORY_TARGET;
184
+ const surface = createEditorSurfaceSnapshot(
185
+ document,
186
+ createSelectionSnapshot(selection.anchor, selection.head),
187
+ storyTarget,
188
+ );
189
+
190
+ let results = searchSurfaceBlocks(surface.blocks, normalizedQuery, options);
191
+
192
+ if (options.inScope) {
193
+ const scopeAnchor = resolveScope(document, options.inScope);
194
+ if (!scopeAnchor || scopeAnchor.kind !== "range") {
195
+ return [];
196
+ }
197
+ const { from: scopeFrom, to: scopeTo } = scopeAnchor;
198
+ results = results.filter(
199
+ (r) => r.from >= scopeFrom && r.to <= scopeTo,
200
+ );
201
+ }
202
+
203
+ return results.map((r) => ({
204
+ kind: "range" as const,
205
+ from: r.from,
206
+ to: r.to,
207
+ assoc: { start: -1 as const, end: 1 as const },
208
+ }));
209
+ }
210
+
211
+ /**
212
+ * Phase C §C4 — `findTextMatches` + post-match style filtering.
213
+ * See `WordReviewEditorRef.findTextWithStyle` for the full contract.
214
+ */
215
+ export function findTextWithStyleMatches(
216
+ document: CanonicalDocumentEnvelope,
217
+ selection: SelectionSnapshot,
218
+ query: string,
219
+ filter: TextStyleFilter,
220
+ options: SearchOptions = {},
221
+ ): EditorAnchorProjection[] {
222
+ const anchors = findTextMatches(document, selection, query, options);
223
+ if (
224
+ filter.inHeading === undefined &&
225
+ !filter.hasFormatting &&
226
+ !filter.anyFormatting
227
+ ) {
228
+ return anchors;
229
+ }
230
+
231
+ const storyTarget: EditorStoryTarget = options.inStory ?? MAIN_STORY_TARGET;
232
+ const surface = createEditorSurfaceSnapshot(
233
+ document,
234
+ createSelectionSnapshot(selection.anchor, selection.head),
235
+ storyTarget,
236
+ );
237
+
238
+ return anchors.filter((anchor) => {
239
+ if (anchor.kind !== "range") return true;
240
+ const { from: matchFrom, to: matchTo } = anchor;
241
+ return matchPassesStyleFilter(surface.blocks, matchFrom, matchTo, filter);
242
+ });
243
+ }
244
+
245
+ function findParagraphContaining(
246
+ blocks: readonly SurfaceBlockSnapshot[],
247
+ pos: number,
248
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
249
+ for (const block of blocks) {
250
+ if (block.kind === "paragraph" && block.from <= pos && pos < block.to) {
251
+ return block;
252
+ }
253
+ if (block.kind === "table") {
254
+ for (const row of block.rows) {
255
+ for (const cell of row.cells) {
256
+ const found = findParagraphContaining(cell.content, pos);
257
+ if (found) return found;
258
+ }
259
+ }
260
+ }
261
+ if (block.kind === "sdt_block") {
262
+ const found = findParagraphContaining(block.children, pos);
263
+ if (found) return found;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+
269
+ function matchPassesStyleFilter(
270
+ blocks: readonly SurfaceBlockSnapshot[],
271
+ matchFrom: number,
272
+ matchTo: number,
273
+ filter: TextStyleFilter,
274
+ ): boolean {
275
+ // Find the paragraph block containing the match start (recursively — handles table cells)
276
+ const block = findParagraphContaining(blocks, matchFrom);
277
+ if (!block) return false;
278
+
279
+ if (filter.inHeading) {
280
+ const isHeading =
281
+ (block.outlineLevel !== undefined && block.outlineLevel !== null) ||
282
+ /^heading\d/iu.test(block.styleId ?? "");
283
+ if (!isHeading) return false;
284
+ }
285
+
286
+ if (!filter.hasFormatting && !filter.anyFormatting) return true;
287
+
288
+ // Collect text segments overlapping the match range
289
+ const textSegments = block.segments.filter(
290
+ (seg): seg is SurfaceInlineSegment & { kind: "text" } =>
291
+ seg.kind === "text" && seg.from < matchTo && seg.to > matchFrom,
292
+ );
293
+
294
+ if (textSegments.length === 0) return false;
295
+
296
+ if (filter.hasFormatting) {
297
+ const required = filter.hasFormatting;
298
+ for (const seg of textSegments) {
299
+ const marks = seg.marks ?? [];
300
+ if (required.bold && !marks.includes("bold")) return false;
301
+ if (required.italic && !marks.includes("italic")) return false;
302
+ if (required.underline && !marks.includes("underline")) return false;
303
+ if (required.strikethrough && !marks.includes("strikethrough")) return false;
304
+ }
305
+ }
306
+
307
+ if (filter.anyFormatting) {
308
+ const any = filter.anyFormatting;
309
+ const keys = (["bold", "italic", "underline", "strikethrough"] as const).filter(
310
+ (k) => any[k],
311
+ );
312
+ if (keys.length > 0) {
313
+ for (const seg of textSegments) {
314
+ const marks = seg.marks ?? [];
315
+ if (!keys.some((k) => marks.includes(k))) return false;
316
+ }
317
+ }
318
+ }
319
+
320
+ return true;
321
+ }
@@ -30,7 +30,11 @@ import type {
30
30
  SelectionSnapshot,
31
31
  } from "../../core/state/editor-state.ts";
32
32
  import type { TextTransactionResult } from "../../core/state/text-transaction.ts";
33
- import { validateSelectionAgainstDocument } from "../selection/post-edit-validator.ts";
33
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
34
+ import {
35
+ createSurfaceNodeSelectionProbe,
36
+ validateSelectionAgainstDocument,
37
+ } from "../selection/post-edit-validator.ts";
34
38
 
35
39
  export type EditResult = TextTransactionResult;
36
40
 
@@ -97,7 +101,19 @@ export interface EditLayer {
97
101
  */
98
102
  function validateResult(result: TextTransactionResult): TextTransactionResult {
99
103
  const maxOffset = result.storyText.length;
100
- const validated = validateSelectionAgainstDocument(result.document, result.selection, maxOffset);
104
+ const options = result.selection.activeRange.kind === "node"
105
+ ? {
106
+ isValidNodeTarget: createSurfaceNodeSelectionProbe(
107
+ createEditorSurfaceSnapshot(result.document, result.selection),
108
+ ),
109
+ }
110
+ : undefined;
111
+ const validated = validateSelectionAgainstDocument(
112
+ result.document,
113
+ result.selection,
114
+ maxOffset,
115
+ options,
116
+ );
101
117
  if (validated === result.selection) {
102
118
  return result;
103
119
  }
@@ -1,7 +1,10 @@
1
1
  import type {
2
+ BlockNode,
3
+ CanonicalDocument,
2
4
  EndnoteProperties,
3
5
  FootnoteCollection,
4
6
  FootnoteProperties,
7
+ InlineNode,
5
8
  SectionProperties,
6
9
  } from "../model/canonical-document.ts";
7
10
 
@@ -22,11 +25,51 @@ export interface FootnoteResolver {
22
25
  * as `getFootnoteProperties` but reads `endnotePr` from the section list.
23
26
  */
24
27
  getEndnoteProperties(sectionIndex?: number): EndnoteProperties | undefined;
28
+ /**
29
+ * Lane 3b Phase 8 P8.2 — return the endnote IDs that should render at
30
+ * the end of the given section (i.e. the section's `endnotePr.pos` is
31
+ * `"sectEnd"`, per ECMA-376 §17.11.4). Order matches document
32
+ * traversal of the endnote reference markers inside the section's
33
+ * block range.
34
+ *
35
+ * The walk recurses into tables (row → cell → cell-blocks), SDT, and
36
+ * custom_xml containers, and inside paragraphs it recurses into
37
+ * hyperlinks. `FootnoteRefNode`s with `noteKind: "footnote"` are
38
+ * ignored (only `"endnote"` is collected). Opaque inline/block
39
+ * preservation is not parsed, so endnote references hidden inside
40
+ * `OpaqueInlineNode.rawXml` will be missed — a known preservation
41
+ * limitation.
42
+ *
43
+ * Returns an empty array when any of:
44
+ * - the section's `endnotePr.pos` is `"docEnd"` or unset (default
45
+ * per ECMA-376 §17.11.4);
46
+ * - `sectionIndex` is out of range (negative or ≥ `sections.length`);
47
+ * - the section contains no endnote references;
48
+ * - the resolver was constructed without a `document` argument
49
+ * (the block walk requires the canonical document — pass it via
50
+ * `createFootnoteResolver(collection, sections, document)`).
51
+ *
52
+ * Complexity: O(N) where N is the number of top-level blocks + total
53
+ * inline children. No memoization — callers that invoke this
54
+ * per-section on large documents should cache results against the
55
+ * document revision themselves. Today's sole consumer is runtime
56
+ * bucketing data only; no render-kernel integration yet.
57
+ *
58
+ * Dedup contract with `facet.getDocumentEndnoteBlocks()`: the
59
+ * facet-level document-end pool still enumerates ALL endnotes
60
+ * regardless of section `pos` as of this slice. Consumers that
61
+ * render both per-section and document-end buckets MUST subtract
62
+ * the union of sectEnd results from the document-end pool to avoid
63
+ * double-rendering. A future slice will move the dedup into the
64
+ * facet itself.
65
+ */
66
+ getEndnotesForSection(sectionIndex: number): readonly string[];
25
67
  }
26
68
 
27
69
  export function createFootnoteResolver(
28
70
  collection: FootnoteCollection,
29
71
  sections?: readonly SectionProperties[],
72
+ document?: CanonicalDocument,
30
73
  ): FootnoteResolver {
31
74
  return {
32
75
  getContinuationSeparatorContent(kind) {
@@ -51,5 +94,92 @@ export function createFootnoteResolver(
51
94
  if (sectionIndex === undefined || !sections) return undefined;
52
95
  return sections[sectionIndex]?.endnotePr;
53
96
  },
97
+ getEndnotesForSection(sectionIndex) {
98
+ if (!sections || !document) return EMPTY_READONLY_STRING_ARRAY;
99
+ const section = sections[sectionIndex];
100
+ if (!section || section.endnotePr?.pos !== "sectEnd") {
101
+ return EMPTY_READONLY_STRING_ARRAY;
102
+ }
103
+ return collectEndnoteIdsInSection(document, sectionIndex);
104
+ },
54
105
  };
55
106
  }
107
+
108
+ const EMPTY_READONLY_STRING_ARRAY: readonly string[] = Object.freeze([]);
109
+
110
+ /**
111
+ * Walk the document's top-level block children and collect endnote IDs
112
+ * referenced from the block range belonging to `targetSectionIndex`.
113
+ *
114
+ * Sections are delimited by `SectionBreakNode` blocks: the first section
115
+ * owns blocks from the document start up to (and including) the first
116
+ * section_break, the next section owns blocks up to the next break, and
117
+ * so on. The final section (index = number of section_breaks) owns any
118
+ * remaining blocks and inherits from `subParts.finalSectionProperties`.
119
+ *
120
+ * We don't recurse into tables / SDT / custom_xml here — OOXML
121
+ * endnoteReference markers are inline runs and may appear nested inside
122
+ * table cells. We DO walk those.
123
+ */
124
+ function collectEndnoteIdsInSection(
125
+ document: CanonicalDocument,
126
+ targetSectionIndex: number,
127
+ ): readonly string[] {
128
+ const ids: string[] = [];
129
+ let currentSection = 0;
130
+ for (const block of document.content.children) {
131
+ if (currentSection === targetSectionIndex) {
132
+ collectEndnoteIdsFromBlock(block, ids);
133
+ }
134
+ if (block.type === "section_break") {
135
+ currentSection += 1;
136
+ }
137
+ }
138
+ return ids;
139
+ }
140
+
141
+ function collectEndnoteIdsFromBlock(block: BlockNode, out: string[]): void {
142
+ if (block.type === "paragraph") {
143
+ for (const child of block.children) {
144
+ collectEndnoteIdsFromInline(child, out);
145
+ }
146
+ return;
147
+ }
148
+ if (block.type === "table") {
149
+ for (const row of block.rows) {
150
+ for (const cell of row.cells) {
151
+ for (const cellBlock of cell.children) {
152
+ collectEndnoteIdsFromBlock(cellBlock, out);
153
+ }
154
+ }
155
+ }
156
+ return;
157
+ }
158
+ if (block.type === "sdt" || block.type === "custom_xml") {
159
+ for (const child of block.children) {
160
+ collectEndnoteIdsFromBlock(child, out);
161
+ }
162
+ return;
163
+ }
164
+ // Other block kinds (section_break, alt_chunk, opaque_block) don't
165
+ // carry endnote references inline.
166
+ }
167
+
168
+ function collectEndnoteIdsFromInline(node: InlineNode, out: string[]): void {
169
+ if ((node as { type: string }).type === "footnote_ref") {
170
+ const ref = node as { type: "footnote_ref"; noteId: string; noteKind: "footnote" | "endnote" };
171
+ if (ref.noteKind === "endnote") {
172
+ out.push(ref.noteId);
173
+ }
174
+ return;
175
+ }
176
+ // Hyperlinks carry inline children that may contain endnote refs.
177
+ if ((node as { type: string }).type === "hyperlink") {
178
+ const hyperlink = node as { children?: readonly InlineNode[] };
179
+ if (hyperlink.children) {
180
+ for (const child of hyperlink.children) {
181
+ collectEndnoteIdsFromInline(child, out);
182
+ }
183
+ }
184
+ }
185
+ }
@@ -447,22 +447,49 @@ export function createLayoutEngine(
447
447
  if (freshSnapshots.length === 0 && firstDirty > 0) {
448
448
  return null;
449
449
  }
450
- const freshStories = resolvePageStories(freshSnapshots);
450
+
451
+ // Phase A — end-offset convergence: the first fresh page whose endOffset
452
+ // equals the prior graph page at the same global index marks the point
453
+ // where re-pagination would produce identical page boundaries from here on
454
+ // (same startOffset → same blocks → same measurements → same breaks).
455
+ // Prior RuntimePageNodes beyond this point are reused by reference; we
456
+ // skip calling buildPageGraph for them. sectionIndex is checked alongside
457
+ // endOffset to guard against continuous-section-merge edge cases.
458
+ let convergenceIndex = freshSnapshots.length; // default: no convergence found
459
+ for (let i = 0; i < freshSnapshots.length; i++) {
460
+ const priorPage = priorGraph.pages[firstDirty + i];
461
+ if (
462
+ priorPage !== undefined &&
463
+ freshSnapshots[i]!.endOffset === priorPage.endOffset &&
464
+ freshSnapshots[i]!.sectionIndex === priorPage.sectionIndex
465
+ ) {
466
+ convergenceIndex = i + 1; // include this page; skip everything after
467
+ break;
468
+ }
469
+ }
470
+ const freshSnapshotsToRebuild = freshSnapshots.slice(0, convergenceIndex);
471
+ const convergedTailStart =
472
+ convergenceIndex < freshSnapshots.length
473
+ ? firstDirty + convergenceIndex
474
+ : undefined;
475
+
476
+ const freshStories = resolvePageStories(freshSnapshotsToRebuild);
451
477
  // Project fragments for the fresh tail pages, threading paragraph
452
478
  // line-range splits produced by intra-paragraph pagination.
453
479
  const freshBodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
454
480
  mainSurface,
455
- freshSnapshots,
481
+ freshSnapshotsToRebuild,
456
482
  freshResult.splits,
457
483
  );
458
484
  // P8.1b — merge per-note fragments into the fresh fragments map.
459
485
  const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
460
486
  for (const [pageIndex, noteFragments] of (freshResult.noteFragmentsByPageIndex ?? new Map())) {
487
+ if (pageIndex >= firstDirty + convergenceIndex) continue; // beyond convergence point
461
488
  const existing = freshFragmentsByPageIndex.get(pageIndex) ?? [];
462
489
  freshFragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
463
490
  }
464
491
  const freshGraph = buildPageGraph({
465
- pages: freshSnapshots,
492
+ pages: freshSnapshotsToRebuild,
466
493
  sections,
467
494
  stories: freshStories,
468
495
  fragmentsByPageIndex: freshFragmentsByPageIndex,
@@ -470,7 +497,7 @@ export function createLayoutEngine(
470
497
  });
471
498
  const freshNodes = freshGraph.pages;
472
499
 
473
- const splicedGraph = spliceGraph(priorGraph, freshNodes, firstDirty);
500
+ const splicedGraph = spliceGraph(priorGraph, freshNodes, firstDirty, convergedTailStart);
474
501
 
475
502
  // Field dirtiness diff and resolved-formatting update run against the
476
503
  // full spliced graph so NUMPAGES/PAGE tracking remains accurate.
@@ -179,8 +179,44 @@
179
179
  * leak stale field-family projections. No pixel-geometry change;
180
180
  * cache envelopes from v18 invalidate because the invalidation
181
181
  * classifier's contract corrected.
182
+ * 20 — Phase E.1 gutter parity: `page-graph.ts::buildRegions` now subtracts
183
+ * `layout.gutter` from body width, matching the pre-existing
184
+ * subtraction in `src/runtime/page-layout-estimation.ts:153`. Prior
185
+ * to v20 body regions, multi-column gaps, and header/footer widths
186
+ * on documents with a non-zero `w:gutter` (mirrored/bound layouts)
187
+ * overran the intended body by `gutter` twips. Zero effect on docs
188
+ * without `w:gutter`; cache envelopes from v19 invalidate because
189
+ * body-width geometry changed for gutter-bearing documents.
190
+ * 21 — Phase A paginator resume: `incrementalRelayout` detects end-offset
191
+ * convergence after `buildPageStackFromWithSplits` returns and stops
192
+ * rebuilding `RuntimePageNode`s once `freshSnapshots[i].endOffset ===
193
+ * priorGraph.pages[firstDirty + i].endOffset`. Pages beyond the
194
+ * convergence point are taken from `priorGraph` by reference via the
195
+ * new `convergedTailStart` parameter on `spliceGraph`. Eliminates
196
+ * O(N) `buildPageGraph` work for the stable document tail on bounded
197
+ * invalidations. Cache envelopes from v20 invalidate because the
198
+ * spliceGraph contract (and pageId stability semantics) changed.
199
+ * 22 — N1 (L8 Phase D): `TwPageStackOverlayLayer` moved from
200
+ * `TwChromeOverlay` (z-30 context) to a direct child of
201
+ * `wre-page-surface` at z-0, before the z-10 PM wrapper. Per-page
202
+ * card `backgroundColor` changed from transparent to
203
+ * `var(--color-page-bg)` so each page gets its own opaque paper
204
+ * background behind PM text. `pageShellMetrics.pageFrameStyle`
205
+ * (bg/border/shadow) no longer spread on `[data-paper-frame]` —
206
+ * paper chrome now lives on overlay cards. DOM structure of the
207
+ * page-break widget unchanged; overlay placement and stacking
208
+ * context changed. Cache envelopes from v21 invalidate because
209
+ * the overlay placement contract changed.
210
+ * 23 — CO3 T1/T4: numbering marker lane wiring. `pm-schema.ts` and
211
+ * `tw-page-block-view.helpers.ts` now emit `margin-left: -(width/20)pt`
212
+ * on the marker span (layout-computed gutter from `markerLane.start`)
213
+ * and use `text-align` instead of `justify-content` for decimal
214
+ * right-alignment. `resolved-numbering-geometry.ts` replaces the
215
+ * CO3.8 `isStaleParaInd` heuristic with a broader `isDegenerateParaInd`
216
+ * guard (hanging===left → use level geometry) that fixes the APS
217
+ * Supply paragraph pattern. Cache envelopes from v22 invalidate.
182
218
  */
183
- export const LAYOUT_ENGINE_VERSION = 19 as const;
219
+ export const LAYOUT_ENGINE_VERSION = 23 as const;
184
220
 
185
221
  /**
186
222
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -356,8 +356,13 @@ function buildRegions(
356
356
  stories: ResolvedPageStories,
357
357
  noteAllocations: readonly RuntimeNoteAllocation[] = [],
358
358
  ): RuntimePageRegions {
359
+ // Gutter (w:gutter / pgMar @gutter) reduces usable body width on the bound
360
+ // edge. Parity fix with `src/runtime/page-layout-estimation.ts:153`, which
361
+ // already subtracts `layout.gutter`; prior to this edit the graph path
362
+ // ignored it and consumers computed regions + columns against a body that
363
+ // was `gutter` twips too wide. Defaults to 0 when no gutter is declared.
359
364
  const bodyWidth =
360
- layout.pageWidth - layout.marginLeft - layout.marginRight;
365
+ layout.pageWidth - layout.marginLeft - layout.marginRight - layout.gutter;
361
366
  let bodyHeight =
362
367
  layout.pageHeight - layout.marginTop - layout.marginBottom;
363
368
 
@@ -530,6 +535,7 @@ export function spliceGraph(
530
535
  prior: RuntimePageGraph,
531
536
  freshPages: readonly RuntimePageNode[],
532
537
  firstDirtyIndex: number,
538
+ convergedTailStart?: number,
533
539
  ): RuntimePageGraph {
534
540
  graphRevision += 1;
535
541
  const clampedFirst = Math.max(0, Math.min(firstDirtyIndex, prior.pages.length));
@@ -557,10 +563,17 @@ export function spliceGraph(
557
563
  }
558
564
  const stableTailPrefix = priorTail.slice(0, reusedCount);
559
565
  const divergentTail = freshPages.slice(reusedCount);
566
+ // Phase A — convergedTailStart: pages in the prior graph from this index
567
+ // onward are known-identical to what fresh re-pagination would produce
568
+ // (detected via end-offset convergence in incrementalRelayout). Take them
569
+ // by reference without rebuilding RuntimePageNodes.
570
+ const convergedTail =
571
+ convergedTailStart !== undefined ? prior.pages.slice(convergedTailStart) : [];
560
572
  const nextPages: RuntimePageNode[] = [
561
573
  ...preserved,
562
574
  ...stableTailPrefix,
563
575
  ...divergentTail,
576
+ ...convergedTail,
564
577
  ];
565
578
 
566
579
  const survivingPageIds = new Set(nextPages.map((page) => page.pageId));
@@ -110,6 +110,17 @@ export interface ResolvedParagraphFormatting {
110
110
  widowControl: boolean;
111
111
  /** Contextual spacing — suppress before/after when adjacent styles match. */
112
112
  contextualSpacing: boolean;
113
+ /**
114
+ * Numbering marker lane geometry in twips. Present when the block has a
115
+ * resolved numbering instance with a non-zero hanging indent. Derived from
116
+ * `resolvedNumbering.geometry.markerLane`; consumers emit
117
+ * `margin-left: -(widthTwips/20)pt` on the marker span.
118
+ */
119
+ numberingMarkerBox?: {
120
+ startTwips: number;
121
+ widthTwips: number;
122
+ textStartTwips: number;
123
+ };
113
124
  }
114
125
 
115
126
  export interface ResolvedTableRowFormatting {
@@ -138,6 +149,7 @@ export function resolveBlockFormatting(
138
149
  const indent = resolveIndentation(block);
139
150
  const fontInfo = resolveDominantFont(block);
140
151
  const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
152
+ const markerLane = block.resolvedNumbering?.geometry?.markerLane;
141
153
 
142
154
  return {
143
155
  spacingBefore: spacing.before ?? 0,
@@ -156,6 +168,15 @@ export function resolveBlockFormatting(
156
168
  pageBreakBefore: Boolean(block.pageBreakBefore ?? block.resolvedParagraphFormatting?.pageBreakBefore),
157
169
  widowControl: (block.widowControl ?? block.resolvedParagraphFormatting?.widowControl) !== false, // default true in Word
158
170
  contextualSpacing: Boolean(block.contextualSpacing ?? block.resolvedParagraphFormatting?.contextualSpacing),
171
+ ...(markerLane && markerLane.width > 0
172
+ ? {
173
+ numberingMarkerBox: {
174
+ startTwips: markerLane.start,
175
+ widthTwips: markerLane.width,
176
+ textStartTwips: markerLane.textStart,
177
+ },
178
+ }
179
+ : {}),
159
180
  };
160
181
  }
161
182
 
@@ -25,6 +25,8 @@ export interface NumberingPrefixResult {
25
25
  isLegalNumbering?: boolean;
26
26
  geometry: ResolvedNumberingGeometry;
27
27
  markerRunProperties?: CanonicalRunFormatting;
28
+ /** Resolved media-catalog ID for picture-bullet images. Set when the numbering level has `picBulletId` and the catalog resolves it to a `NumPicBullet` entry with a `mediaId`. */
29
+ picBulletMediaId?: string;
28
30
  }
29
31
 
30
32
  export interface NumberingPrefixResolver {
@@ -82,6 +84,12 @@ export function createNumberingPrefixResolver(
82
84
  }
83
85
  const visibleText = resolved.effectiveLevel.format === "none" ? null : text;
84
86
 
87
+ const picBulletId = resolved.effectiveLevel.picBulletId;
88
+ const picBulletMediaId =
89
+ picBulletId != null
90
+ ? catalog.numPicBullets?.[picBulletId]?.mediaId
91
+ : undefined;
92
+
85
93
  return {
86
94
  text: visibleText,
87
95
  level: resolved.effectiveLevel.level,
@@ -96,6 +104,7 @@ export function createNumberingPrefixResolver(
96
104
  ? { markerRunProperties: resolved.geometry.markerRunProperties }
97
105
  : {}),
98
106
  geometry: resolved.geometry,
107
+ ...(picBulletMediaId != null ? { picBulletMediaId } : {}),
99
108
  };
100
109
  }
101
110
 
@@ -139,6 +148,14 @@ function advanceSequence(
139
148
  state.counters.length = currentLevel + 1;
140
149
  }
141
150
 
151
+ // Initialize any skipped parent levels so legal outline %1.%2.%3. patterns
152
+ // don't produce empty segments on a level jump.
153
+ for (let i = 0; i < currentLevel; i++) {
154
+ if (state.counters[i] === undefined) {
155
+ state.counters[i] = getLevelStartAt(i, levelDefinitions);
156
+ }
157
+ }
158
+
142
159
  const startAt = getLevelStartAt(currentLevel, levelDefinitions);
143
160
  const currentValue = state.counters[currentLevel];
144
161
  state.counters[currentLevel] =