@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -38,9 +38,14 @@ export interface RevisionImportDiagnostic {
38
38
  | "preserve_only_move_revision"
39
39
  | "preserve_only_formatting_revision"
40
40
  | "nested_revision_preserve_only"
41
- | "ambiguous_revision_anchor";
41
+ | "shallow_nested_revision_promoted"
42
+ | "linked_move_pair"
43
+ | "unlinked_move_revision"
44
+ | "ambiguous_revision_anchor"
45
+ | "table_structural_revision_preserve_only"
46
+ | "table_property_revision_parsed";
42
47
  message: string;
43
- featureClass: "preserve-only";
48
+ featureClass: "preserve-only" | "supported" | "informational";
44
49
  }
45
50
 
46
51
  export interface ParsedRevisionsResult {
@@ -69,6 +74,7 @@ interface RevisionMetadata {
69
74
  const SUPPORTED_CONTAINER_TYPES = new Set(["ins", "del"]);
70
75
  const PRESERVE_ONLY_CONTAINER_TYPES = new Set(["moveFrom", "moveTo"]);
71
76
  const PROPERTY_CHANGE_REVISION_TYPES = new Set(["rPrChange", "pPrChange"]);
77
+ const STRUCTURAL_TABLE_REVISION_TYPES = new Set(["cellIns", "cellDel", "cellMerge"]);
72
78
 
73
79
  export function parseRevisionsFromDocumentXml(
74
80
  documentXml: string,
@@ -124,6 +130,8 @@ export function parseRevisionsFromDocumentXml(
124
130
  previousWasParagraph = true;
125
131
  }
126
132
 
133
+ linkMoveRevisionPairs(state);
134
+
127
135
  return {
128
136
  revisions: state.revisions,
129
137
  preservedMarkup: state.preservedMarkup.sort((left, right) => left.xmlStart - right.xmlStart),
@@ -284,6 +292,46 @@ function walkContentNode(
284
292
 
285
293
  if (hasNestedRevision) {
286
294
  const nestedKind = type === "ins" ? "insertion" : type === "del" ? "deletion" : "move";
295
+ const isShallowNesting = isShallowPropertyChangeNesting(node);
296
+
297
+ if (isShallowNesting && SUPPORTED_CONTAINER_TYPES.has(type)) {
298
+ state.revisions.push(
299
+ createRevisionRecord({
300
+ revisionId: metadata.revisionId,
301
+ kind: nestedKind as "insertion" | "deletion",
302
+ anchor:
303
+ length > 0
304
+ ? createRevisionRangeAnchor(start, end)
305
+ : createDetachedAnchor({ from: start, to: end }, "importAmbiguity"),
306
+ authorId: metadata.authorId,
307
+ createdAt: metadata.createdAt,
308
+ metadata: {
309
+ source: "import",
310
+ importedRevisionForm:
311
+ type === "ins" ? "run-insertion" : "run-deletion",
312
+ originalRevisionType: type,
313
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
314
+ },
315
+ }),
316
+ );
317
+ state.preservedMarkup.push({
318
+ revisionId: metadata.revisionId,
319
+ rawXml: state.documentXml.slice(node.start, node.end),
320
+ xmlStart: node.start,
321
+ xmlEnd: node.end,
322
+ originalRevisionType: type,
323
+ });
324
+ state.diagnostics.push({
325
+ revisionId: metadata.revisionId,
326
+ code: "shallow_nested_revision_promoted",
327
+ message: "Shallow nested revision (property changes inside ins/del) promoted to supported rendering.",
328
+ featureClass: "supported",
329
+ });
330
+ parseNestedPropertyChangeRevisions(node, paragraphIndex, state, start);
331
+ advanceCursor(node, setCursor, getCursor);
332
+ return;
333
+ }
334
+
287
335
  state.revisions.push(
288
336
  createRevisionRecord({
289
337
  revisionId: metadata.revisionId,
@@ -353,7 +401,7 @@ function walkContentNode(
353
401
  state.diagnostics.push({
354
402
  revisionId: metadata.revisionId,
355
403
  code: "preserve_only_move_revision",
356
- message: "Tracked move revisions are preserve-only. Content is preserved for round-trip but cannot be accepted or rejected.",
404
+ message: "Move revision parsed as preserve-only. Content is preserved for round-trip; linking resolved in post-parse step.",
357
405
  featureClass: "preserve-only",
358
406
  });
359
407
  advanceCursor(node, setCursor, getCursor);
@@ -475,45 +523,169 @@ function parseTblPropertyRevisions(
475
523
  state: ParseState,
476
524
  ): void {
477
525
  const tblPr = findChildElement(table, "tblPr");
478
- if (!tblPr) {
479
- return;
480
- }
526
+ if (tblPr) {
527
+ const tblPrChange = findChildElement(tblPr, "tblPrChange");
528
+ if (tblPrChange) {
529
+ const metadata = readRevisionMetadata(tblPrChange, state, "property-change");
530
+ const innerTblPr = findChildElement(tblPrChange, "tblPr");
531
+ const beforeXml = innerTblPr ? state.documentXml.slice(innerTblPr.start, innerTblPr.end) : "";
532
+ const propertyChangeData: PropertyChangeData = { xmlTag: "tblPrChange", beforeXml };
481
533
 
482
- const tblPrChange = findChildElement(tblPr, "tblPrChange");
483
- if (!tblPrChange) {
484
- return;
534
+ state.revisions.push(
535
+ createRevisionRecord({
536
+ revisionId: metadata.revisionId,
537
+ kind: "property-change",
538
+ anchor: createRevisionRangeAnchor(position, position),
539
+ authorId: metadata.authorId,
540
+ createdAt: metadata.createdAt,
541
+ metadata: {
542
+ source: "import",
543
+ originalRevisionType: "tblPrChange",
544
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
545
+ propertyChangeData,
546
+ },
547
+ }),
548
+ );
549
+ state.preservedMarkup.push({
550
+ revisionId: metadata.revisionId,
551
+ rawXml: state.documentXml.slice(tblPrChange.start, tblPrChange.end),
552
+ xmlStart: tblPrChange.start,
553
+ xmlEnd: tblPrChange.end,
554
+ originalRevisionType: "tblPrChange",
555
+ containerXmlStart: tblPr.start,
556
+ containerXmlEnd: tblPr.end,
557
+ beforeContainerXml: beforeXml,
558
+ });
559
+ state.diagnostics.push({
560
+ revisionId: metadata.revisionId,
561
+ code: "table_property_revision_parsed",
562
+ message: "Table property change (tblPrChange) parsed as actionable property-change revision.",
563
+ featureClass: "supported",
564
+ });
565
+ }
485
566
  }
486
567
 
487
- const metadata = readRevisionMetadata(tblPrChange, state, "property-change");
488
- const innerTblPr = findChildElement(tblPrChange, "tblPr");
489
- const beforeXml = innerTblPr ? state.documentXml.slice(innerTblPr.start, innerTblPr.end) : "";
490
- const propertyChangeData: PropertyChangeData = { xmlTag: "tblPrChange", beforeXml };
568
+ parseTableRowAndCellRevisions(table, position, state);
569
+ }
491
570
 
492
- state.revisions.push(
493
- createRevisionRecord({
494
- revisionId: metadata.revisionId,
495
- kind: "property-change",
496
- anchor: createRevisionRangeAnchor(position, position),
497
- authorId: metadata.authorId,
498
- createdAt: metadata.createdAt,
499
- metadata: {
500
- source: "import",
501
- originalRevisionType: "tblPrChange",
502
- ooxmlRevisionId: metadata.ooxmlRevisionId,
503
- propertyChangeData,
504
- },
505
- }),
506
- );
507
- state.preservedMarkup.push({
508
- revisionId: metadata.revisionId,
509
- rawXml: state.documentXml.slice(tblPrChange.start, tblPrChange.end),
510
- xmlStart: tblPrChange.start,
511
- xmlEnd: tblPrChange.end,
512
- originalRevisionType: "tblPrChange",
513
- containerXmlStart: tblPr.start,
514
- containerXmlEnd: tblPr.end,
515
- beforeContainerXml: beforeXml,
516
- });
571
+ function parseTableRowAndCellRevisions(
572
+ table: XmlElementNode,
573
+ position: number,
574
+ state: ParseState,
575
+ ): void {
576
+ for (const child of table.children) {
577
+ if (child.type !== "element" || localName(child.name) !== "tr") {
578
+ continue;
579
+ }
580
+
581
+ const trPr = findChildElement(child, "trPr");
582
+ if (trPr) {
583
+ const trPrChange = findChildElement(trPr, "trPrChange");
584
+ if (trPrChange) {
585
+ const metadata = readRevisionMetadata(trPrChange, state, "property-change");
586
+ const innerTrPr = findChildElement(trPrChange, "trPr");
587
+ const beforeXml = innerTrPr ? state.documentXml.slice(innerTrPr.start, innerTrPr.end) : "";
588
+ const propertyChangeData: PropertyChangeData = { xmlTag: "tblPrChange", beforeXml };
589
+
590
+ state.revisions.push(
591
+ createRevisionRecord({
592
+ revisionId: metadata.revisionId,
593
+ kind: "property-change",
594
+ anchor: createRevisionRangeAnchor(position, position),
595
+ authorId: metadata.authorId,
596
+ createdAt: metadata.createdAt,
597
+ metadata: {
598
+ source: "import",
599
+ originalRevisionType: "trPrChange",
600
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
601
+ propertyChangeData,
602
+ preserveOnlyReason: "Row property changes remain preserve-only for export safety.",
603
+ },
604
+ }),
605
+ );
606
+ state.preservedMarkup.push({
607
+ revisionId: metadata.revisionId,
608
+ rawXml: state.documentXml.slice(trPrChange.start, trPrChange.end),
609
+ xmlStart: trPrChange.start,
610
+ xmlEnd: trPrChange.end,
611
+ originalRevisionType: "trPrChange",
612
+ containerXmlStart: trPr.start,
613
+ containerXmlEnd: trPr.end,
614
+ beforeContainerXml: beforeXml,
615
+ });
616
+ }
617
+ }
618
+
619
+ for (const tcNode of child.children) {
620
+ if (tcNode.type !== "element" || localName(tcNode.name) !== "tc") {
621
+ continue;
622
+ }
623
+
624
+ const tcPr = findChildElement(tcNode, "tcPr");
625
+ if (!tcPr) {
626
+ continue;
627
+ }
628
+
629
+ const tcPrChange = findChildElement(tcPr, "tcPrChange");
630
+ if (tcPrChange) {
631
+ const metadata = readRevisionMetadata(tcPrChange, state, "property-change");
632
+ const innerTcPr = findChildElement(tcPrChange, "tcPr");
633
+ const beforeXml = innerTcPr ? state.documentXml.slice(innerTcPr.start, innerTcPr.end) : "";
634
+ const propertyChangeData: PropertyChangeData = { xmlTag: "tblPrChange", beforeXml };
635
+
636
+ state.revisions.push(
637
+ createRevisionRecord({
638
+ revisionId: metadata.revisionId,
639
+ kind: "property-change",
640
+ anchor: createRevisionRangeAnchor(position, position),
641
+ authorId: metadata.authorId,
642
+ createdAt: metadata.createdAt,
643
+ metadata: {
644
+ source: "import",
645
+ originalRevisionType: "tcPrChange",
646
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
647
+ propertyChangeData,
648
+ preserveOnlyReason: "Cell property changes remain preserve-only for export safety.",
649
+ },
650
+ }),
651
+ );
652
+ state.preservedMarkup.push({
653
+ revisionId: metadata.revisionId,
654
+ rawXml: state.documentXml.slice(tcPrChange.start, tcPrChange.end),
655
+ xmlStart: tcPrChange.start,
656
+ xmlEnd: tcPrChange.end,
657
+ originalRevisionType: "tcPrChange",
658
+ containerXmlStart: tcPr.start,
659
+ containerXmlEnd: tcPr.end,
660
+ beforeContainerXml: beforeXml,
661
+ });
662
+ }
663
+
664
+ for (const tcChild of tcPr.children) {
665
+ if (tcChild.type !== "element") {
666
+ continue;
667
+ }
668
+
669
+ const tcChildName = localName(tcChild.name);
670
+ if (STRUCTURAL_TABLE_REVISION_TYPES.has(tcChildName)) {
671
+ const metadata = readRevisionMetadata(tcChild, state, tcChildName);
672
+ state.preservedMarkup.push({
673
+ revisionId: metadata.revisionId,
674
+ rawXml: state.documentXml.slice(tcChild.start, tcChild.end),
675
+ xmlStart: tcChild.start,
676
+ xmlEnd: tcChild.end,
677
+ originalRevisionType: tcChildName,
678
+ });
679
+ state.diagnostics.push({
680
+ revisionId: metadata.revisionId,
681
+ code: "table_structural_revision_preserve_only",
682
+ message: `Structural table revision (${tcChildName}) remains preserve-only; topology-altering changes cannot be safely applied.`,
683
+ featureClass: "preserve-only",
684
+ });
685
+ }
686
+ }
687
+ }
688
+ }
517
689
  }
518
690
 
519
691
  function parseSectPrRevisions(
@@ -604,6 +776,113 @@ function sanitizeRevisionToken(value: string): string {
604
776
  return sanitized.length > 0 ? sanitized : "unknown";
605
777
  }
606
778
 
779
+ function linkMoveRevisionPairs(state: ParseState): void {
780
+ const moveRevisions = state.revisions.filter(
781
+ (r) => r.kind === "move" && r.metadata.moveData,
782
+ );
783
+
784
+ const byMoveId = new Map<string, typeof moveRevisions>();
785
+ for (const revision of moveRevisions) {
786
+ const moveId = revision.metadata.moveData!.moveId;
787
+ const group = byMoveId.get(moveId) ?? [];
788
+ group.push(revision);
789
+ byMoveId.set(moveId, group);
790
+ }
791
+
792
+ for (const [moveId, group] of byMoveId) {
793
+ const fromRevision = group.find(
794
+ (r) => r.metadata.moveData!.direction === "from",
795
+ );
796
+ const toRevision = group.find(
797
+ (r) => r.metadata.moveData!.direction === "to",
798
+ );
799
+
800
+ if (fromRevision && toRevision) {
801
+ fromRevision.metadata.moveData!.linkedRevisionId = toRevision.revisionId;
802
+ toRevision.metadata.moveData!.linkedRevisionId = fromRevision.revisionId;
803
+
804
+ state.diagnostics.push({
805
+ revisionId: fromRevision.revisionId,
806
+ code: "linked_move_pair",
807
+ message: `Move-from revision linked to move-to revision ${toRevision.revisionId} via moveId ${moveId}.`,
808
+ featureClass: "informational",
809
+ });
810
+ state.diagnostics.push({
811
+ revisionId: toRevision.revisionId,
812
+ code: "linked_move_pair",
813
+ message: `Move-to revision linked to move-from revision ${fromRevision.revisionId} via moveId ${moveId}.`,
814
+ featureClass: "informational",
815
+ });
816
+ } else {
817
+ for (const revision of group) {
818
+ state.diagnostics.push({
819
+ revisionId: revision.revisionId,
820
+ code: "unlinked_move_revision",
821
+ message: `Move revision has no matching counterpart for moveId ${moveId}. Remains preserve-only.`,
822
+ featureClass: "preserve-only",
823
+ });
824
+ }
825
+ }
826
+ }
827
+ }
828
+
829
+ function isShallowPropertyChangeNesting(node: XmlElementNode): boolean {
830
+ for (const child of node.children) {
831
+ if (child.type !== "element") {
832
+ continue;
833
+ }
834
+
835
+ const name = localName(child.name);
836
+
837
+ if (SUPPORTED_CONTAINER_TYPES.has(name) || PRESERVE_ONLY_CONTAINER_TYPES.has(name)) {
838
+ return false;
839
+ }
840
+
841
+ if (name === "r") {
842
+ const runProperties = findChildElement(child, "rPr");
843
+ if (runProperties) {
844
+ for (const rpChild of runProperties.children) {
845
+ if (rpChild.type !== "element") {
846
+ continue;
847
+ }
848
+
849
+ const rpChildName = localName(rpChild.name);
850
+ if (
851
+ SUPPORTED_CONTAINER_TYPES.has(rpChildName) ||
852
+ PRESERVE_ONLY_CONTAINER_TYPES.has(rpChildName)
853
+ ) {
854
+ return false;
855
+ }
856
+ }
857
+ }
858
+ continue;
859
+ }
860
+
861
+ if (!isShallowPropertyChangeNesting(child)) {
862
+ return false;
863
+ }
864
+ }
865
+
866
+ return true;
867
+ }
868
+
869
+ function parseNestedPropertyChangeRevisions(
870
+ container: XmlElementNode,
871
+ paragraphIndex: number,
872
+ state: ParseState,
873
+ containerStart: number,
874
+ ): void {
875
+ for (const child of container.children) {
876
+ if (child.type !== "element") {
877
+ continue;
878
+ }
879
+
880
+ if (localName(child.name) === "r") {
881
+ parseRunFormattingRevisions(child, paragraphIndex, state, containerStart);
882
+ }
883
+ }
884
+ }
885
+
607
886
  function containsNestedRevision(node: XmlElementNode): boolean {
608
887
  return node.children.some(
609
888
  (child) =>
@@ -4,6 +4,8 @@ import type {
4
4
  BookmarkStartNode,
5
5
  CanonicalDocument,
6
6
  DocumentNode,
7
+ FieldRegistry,
8
+ FieldRegistryEntry,
7
9
  } from "../model/canonical-document.ts";
8
10
 
9
11
  export interface LegalBookmark {
@@ -210,6 +212,48 @@ function compareBookmarks(left: LegalBookmark, right: LegalBookmark): number {
210
212
  );
211
213
  }
212
214
 
215
+ /**
216
+ * Build a lookup map from bookmark name to bookmarkId for the document.
217
+ * Used by the field refresh runtime to resolve REF/PAGEREF/NOTEREF targets.
218
+ */
219
+ export function buildBookmarkNameMap(
220
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
221
+ ): Map<string, { bookmarkId: string; paragraphIndex: number }> {
222
+ const root = "content" in document ? document.content : document;
223
+ const map = new Map<string, { bookmarkId: string; paragraphIndex: number }>();
224
+ let paragraphIndex = -1;
225
+
226
+ walkDocument(root, (node) => {
227
+ if (node.type === "paragraph") {
228
+ paragraphIndex += 1;
229
+ }
230
+ if (node.type === "bookmark_start" && node.name) {
231
+ map.set(node.name, { bookmarkId: node.bookmarkId, paragraphIndex });
232
+ }
233
+ });
234
+
235
+ return map;
236
+ }
237
+
238
+ /**
239
+ * Resolve field-to-bookmark dependencies from a field registry.
240
+ * Returns a map of bookmark names to the field entries that reference them.
241
+ * This enables the runtime to determine which fields need refresh when
242
+ * a bookmark's content changes.
243
+ */
244
+ export function resolveBookmarkFieldDependencies(
245
+ registry: FieldRegistry,
246
+ ): Map<string, FieldRegistryEntry[]> {
247
+ const deps = new Map<string, FieldRegistryEntry[]>();
248
+ for (const entry of registry.supported) {
249
+ if (!entry.fieldTarget) continue;
250
+ const existing = deps.get(entry.fieldTarget) ?? [];
251
+ existing.push(entry);
252
+ deps.set(entry.fieldTarget, existing);
253
+ }
254
+ return deps;
255
+ }
256
+
213
257
  function walkDocument(node: DocumentNode, visit: (node: DocumentNode) => void): void {
214
258
  visit(node);
215
259
 
@@ -7,8 +7,10 @@ import type {
7
7
  CanonicalDocument,
8
8
  DocumentNode,
9
9
  FieldNode,
10
+ FieldRegistry,
10
11
  HyperlinkNode,
11
12
  ParagraphNode,
13
+ TocEntry,
12
14
  } from "../model/canonical-document.ts";
13
15
 
14
16
  export interface CrossReferencePattern {
@@ -33,6 +35,40 @@ interface FieldReference {
33
35
  instruction: string;
34
36
  }
35
37
 
38
+ /**
39
+ * Collect field references from a canonical document, leveraging the
40
+ * field family classification when available.
41
+ */
42
+ export function collectFieldReferencesFromCanonicalDocument(
43
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
44
+ ): Array<{ family: string; target: string; instruction: string; paragraphIndex: number; displayText: string }> {
45
+ const root = "content" in document ? document.content : document;
46
+ const results: Array<{ family: string; target: string; instruction: string; paragraphIndex: number; displayText: string }> = [];
47
+ let paragraphIndex = -1;
48
+
49
+ walkDocument(root, (node) => {
50
+ if (node.type === "paragraph") {
51
+ paragraphIndex += 1;
52
+ for (const child of node.children) {
53
+ if (child.type === "field" && child.fieldFamily && child.fieldTarget) {
54
+ const family = child.fieldFamily;
55
+ if (family === "REF" || family === "PAGEREF" || family === "NOTEREF") {
56
+ results.push({
57
+ family,
58
+ target: child.fieldTarget,
59
+ instruction: child.instruction,
60
+ paragraphIndex,
61
+ displayText: flattenInlineText(child.children).trim(),
62
+ });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ });
68
+
69
+ return results;
70
+ }
71
+
36
72
  const CROSS_REFERENCE_PATTERN =
37
73
  /\b(Section|Clause|Article|Schedule|Exhibit|Appendix)\s+(\d+(?:\.\d+)*|[A-Z]-\d+|[A-Z])(?=[^A-Za-z0-9]|$)/g;
38
74
  const W_REF_PATTERN = /<w:ref\b([^>]*)\/?>/g;
@@ -281,7 +317,7 @@ function flattenParagraphText(paragraph: ParagraphNode): string {
281
317
  case "hyperlink":
282
318
  return flattenInlineText(child.children);
283
319
  case "field":
284
- return flattenInlineText(child.children);
320
+ return child.children ? flattenInlineText(child.children) : "";
285
321
  case "tab":
286
322
  return "\t";
287
323
  case "hard_break":
@@ -335,6 +371,28 @@ function dedupeCrossReferences(references: CrossReference[]): CrossReference[] {
335
371
  return deduped;
336
372
  }
337
373
 
374
+ /**
375
+ * Collect TOC entries from a field registry's tocStructure.
376
+ * Returns the entries in document order with their heading text and levels.
377
+ * Returns an empty array if no TOC structure is available.
378
+ */
379
+ export function collectTocEntriesFromRegistry(
380
+ registry: FieldRegistry | undefined,
381
+ ): TocEntry[] {
382
+ return registry?.tocStructure?.entries ?? [];
383
+ }
384
+
385
+ /**
386
+ * Assess whether the TOC structure in a field registry is current
387
+ * with respect to the document's heading structure.
388
+ */
389
+ export function assessTocFreshness(
390
+ registry: FieldRegistry | undefined,
391
+ ): "current" | "stale" | "absent" {
392
+ if (!registry?.tocStructure) return "absent";
393
+ return registry.tocStructure.status;
394
+ }
395
+
338
396
  function walkDocument(node: DocumentNode, visit: (node: DocumentNode) => void): void {
339
397
  visit(node);
340
398