@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -147,6 +147,11 @@ function readLevels(abstractNode: XmlElementNode): NumberingLevelDefinition[] {
147
147
  const text = textNode?.attributes["w:val"] ?? textNode?.attributes.val ?? `%${level + 1}.`;
148
148
  const paragraphStyleId =
149
149
  paragraphStyleNode?.attributes["w:val"] ?? paragraphStyleNode?.attributes.val;
150
+ const isLegalNode = findChildElementOptional(child, "isLgl");
151
+ const isLegalNumbering = isLegalNode !== undefined;
152
+ const suffixNode = findChildElementOptional(child, "suff");
153
+ const suffixVal = suffixNode?.attributes["w:val"] ?? suffixNode?.attributes.val;
154
+ const suffix = suffixVal === "space" || suffixVal === "nothing" ? suffixVal : suffixVal === "tab" ? "tab" : undefined;
150
155
 
151
156
  levels.push({
152
157
  level,
@@ -154,6 +159,8 @@ function readLevels(abstractNode: XmlElementNode): NumberingLevelDefinition[] {
154
159
  text,
155
160
  ...(startAt !== undefined ? { startAt } : {}),
156
161
  ...(paragraphStyleId ? { paragraphStyleId } : {}),
162
+ ...(isLegalNumbering ? { isLegalNumbering } : {}),
163
+ ...(suffix ? { suffix } : {}),
157
164
  });
158
165
  }
159
166
 
@@ -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) =>
@@ -0,0 +1,184 @@
1
+ import type { DocumentSettings } from "../../model/canonical-document.ts";
2
+
3
+ interface XmlElementNode {
4
+ type: "element";
5
+ name: string;
6
+ attributes: Record<string, string>;
7
+ children: XmlNode[];
8
+ }
9
+
10
+ interface XmlTextNode {
11
+ type: "text";
12
+ text: string;
13
+ }
14
+
15
+ type XmlNode = XmlElementNode | XmlTextNode;
16
+
17
+ export function parseSettingsXml(xml: string): DocumentSettings {
18
+ if (!xml.trim()) {
19
+ return {};
20
+ }
21
+
22
+ const root = parseXml(xml);
23
+ const settingsElement = findChildElementOptional(root, "settings");
24
+ if (!settingsElement) {
25
+ return {};
26
+ }
27
+
28
+ const evenAndOddHeaders = findChildElementOptional(settingsElement, "evenAndOddHeaders");
29
+ const zoom = findChildElementOptional(settingsElement, "zoom");
30
+
31
+ return {
32
+ ...(evenAndOddHeaders
33
+ ? {
34
+ evenAndOddHeaders: readOnOffValue(evenAndOddHeaders, true),
35
+ }
36
+ : {}),
37
+ ...(zoom ? readZoomLevel(zoom) : {}),
38
+ };
39
+ }
40
+
41
+ function findChildElementOptional(
42
+ node: XmlElementNode,
43
+ childLocalName: string,
44
+ ): XmlElementNode | undefined {
45
+ return node.children.find(
46
+ (entry): entry is XmlElementNode =>
47
+ entry.type === "element" && localName(entry.name) === childLocalName,
48
+ );
49
+ }
50
+
51
+ function localName(name: string): string {
52
+ const idx = name.indexOf(":");
53
+ return idx >= 0 ? name.slice(idx + 1) : name;
54
+ }
55
+
56
+ function readOnOffValue(
57
+ element: XmlElementNode,
58
+ defaultValue: boolean,
59
+ ): boolean {
60
+ const value =
61
+ element.attributes["w:val"] ??
62
+ element.attributes.val ??
63
+ (defaultValue ? "true" : "false");
64
+ return value !== "0" && value !== "false";
65
+ }
66
+
67
+ function readZoomLevel(
68
+ element: XmlElementNode,
69
+ ): Pick<DocumentSettings, "zoomLevel"> {
70
+ const rawValue = element.attributes["w:val"] ?? element.attributes.val;
71
+ if (rawValue === "bestFit") {
72
+ return { zoomLevel: "pageWidth" };
73
+ }
74
+ if (rawValue === "fullPage") {
75
+ return { zoomLevel: "onePage" };
76
+ }
77
+
78
+ const rawPercent =
79
+ element.attributes["w:percent"] ??
80
+ element.attributes.percent;
81
+ if (!rawPercent) {
82
+ return {};
83
+ }
84
+
85
+ const normalizedPercent = rawPercent.trim().replace(/%$/u, "");
86
+ const parsed = Number.parseInt(normalizedPercent, 10);
87
+ if (!Number.isFinite(parsed)) {
88
+ return {};
89
+ }
90
+
91
+ return { zoomLevel: parsed };
92
+ }
93
+
94
+ function parseXml(xml: string): XmlElementNode {
95
+ const root: XmlElementNode = {
96
+ type: "element",
97
+ name: "__root__",
98
+ attributes: {},
99
+ children: [],
100
+ };
101
+ const stack: XmlElementNode[] = [root];
102
+ let cursor = 0;
103
+
104
+ while (cursor < xml.length) {
105
+ if (xml.startsWith("<!--", cursor)) {
106
+ const end = xml.indexOf("-->", cursor);
107
+ cursor = end >= 0 ? end + 3 : xml.length;
108
+ continue;
109
+ }
110
+
111
+ if (xml.startsWith("<?", cursor)) {
112
+ const end = xml.indexOf("?>", cursor);
113
+ cursor = end >= 0 ? end + 2 : xml.length;
114
+ continue;
115
+ }
116
+
117
+ const nextLt = xml.indexOf("<", cursor);
118
+ if (nextLt < 0) {
119
+ pushText(xml.slice(cursor));
120
+ break;
121
+ }
122
+
123
+ if (nextLt > cursor) {
124
+ pushText(xml.slice(cursor, nextLt));
125
+ }
126
+
127
+ if (xml.startsWith("</", nextLt)) {
128
+ const end = xml.indexOf(">", nextLt);
129
+ if (end < 0) break;
130
+ if (stack.length > 1) {
131
+ stack.pop();
132
+ }
133
+ cursor = end + 1;
134
+ continue;
135
+ }
136
+
137
+ const end = xml.indexOf(">", nextLt);
138
+ if (end < 0) {
139
+ break;
140
+ }
141
+
142
+ const rawTag = xml.slice(nextLt + 1, end).trim();
143
+ const selfClosing = rawTag.endsWith("/");
144
+ const tagBody = selfClosing ? rawTag.slice(0, -1).trim() : rawTag;
145
+ const spaceIndex = tagBody.search(/\s/u);
146
+ const name =
147
+ spaceIndex >= 0 ? tagBody.slice(0, spaceIndex) : tagBody;
148
+ const attrs = spaceIndex >= 0 ? tagBody.slice(spaceIndex + 1) : "";
149
+ const element: XmlElementNode = {
150
+ type: "element",
151
+ name,
152
+ attributes: parseAttributes(attrs),
153
+ children: [],
154
+ };
155
+ stack[stack.length - 1]?.children.push(element);
156
+ if (!selfClosing) {
157
+ stack.push(element);
158
+ }
159
+ cursor = end + 1;
160
+ }
161
+
162
+ return root;
163
+
164
+ function pushText(raw: string): void {
165
+ const normalized = raw.replace(/\r\n?/gu, "\n");
166
+ if (!normalized.trim()) {
167
+ return;
168
+ }
169
+ stack[stack.length - 1]?.children.push({
170
+ type: "text",
171
+ text: normalized,
172
+ });
173
+ }
174
+ }
175
+
176
+ function parseAttributes(raw: string): Record<string, string> {
177
+ const attributes: Record<string, string> = {};
178
+ const pattern = /([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/gu;
179
+ for (const match of raw.matchAll(pattern)) {
180
+ const [, name, , dq, sq] = match;
181
+ attributes[name] = dq ?? sq ?? "";
182
+ }
183
+ return attributes;
184
+ }
@@ -15,8 +15,12 @@ const WPS_SHAPE_GRAPHIC_URI =
15
15
 
16
16
  export interface ParsedWpsShape {
17
17
  type: "shape";
18
+ /** True if this shape is a text box (geometry=rect with txbx content). */
19
+ isTextBox?: boolean;
18
20
  /** Extracted text content from wps:txbx for display. */
19
21
  text?: string;
22
+ /** Raw txbxContent XML for structured re-rendering. */
23
+ txbxContentXml?: string;
20
24
  /** DrawML geometry preset, e.g. "rect", "roundRect". */
21
25
  geometry?: string;
22
26
  /** Original drawing XML for lossless round-trip export. */
@@ -81,9 +85,17 @@ export function parseShapeXml(drawingXml: string): ParsedWpsShape | ParsedWordAr
81
85
  };
82
86
  }
83
87
 
88
+ // Text box detection: rect or no geometry with text content
89
+ const isTextBox = Boolean(txbxContent && (!prst || prst === "rect"));
90
+
91
+ // Extract raw txbxContent XML for structured re-rendering of text boxes
92
+ const txbxContentXml = txbxContent ? extractRawXml(txbxContent, drawingXml) : undefined;
93
+
84
94
  return {
85
95
  type: "shape",
96
+ ...(isTextBox ? { isTextBox: true } : {}),
86
97
  ...(text ? { text } : {}),
98
+ ...(txbxContentXml ? { txbxContentXml } : {}),
87
99
  ...(prst ? { geometry: prst } : {}),
88
100
  rawXml: drawingXml,
89
101
  };
@@ -131,6 +143,19 @@ export function parseVmlXml(pictXml: string): ParsedVmlShape | null {
131
143
  };
132
144
  }
133
145
 
146
+ // ---- Raw XML extraction helpers ----
147
+
148
+ function extractRawXml(node: XmlElementNode, sourceXml: string): string | undefined {
149
+ // Find the txbxContent element boundaries in the source XML by tag name
150
+ const tagName = node.name;
151
+ const openIdx = sourceXml.indexOf(`<${tagName}`);
152
+ if (openIdx < 0) return undefined;
153
+ const closeTag = `</${tagName}>`;
154
+ const closeIdx = sourceXml.indexOf(closeTag, openIdx);
155
+ if (closeIdx < 0) return undefined;
156
+ return sourceXml.slice(openIdx, closeIdx + closeTag.length);
157
+ }
158
+
134
159
  // ---- Text extraction helpers ----
135
160
 
136
161
  function extractAllText(node: XmlElementNode): string {