@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -148,10 +148,10 @@ import {
148
148
  parseFooterXml,
149
149
  } from "./ooxml/parse-headers-footers.ts";
150
150
  import { parseFootnotesXml, parseEndnotesXml } from "./ooxml/parse-footnotes.ts";
151
- import { parseThemeXml } from "./ooxml/parse-theme.ts";
152
- import { resolveTheme } from "./ooxml/parse-theme.ts";
151
+ import { materializeCanonicalTheme, parseThemeXml, resolveTheme } from "./ooxml/parse-theme.ts";
153
152
  import { parseSettingsXml } from "./ooxml/parse-settings.ts";
154
153
  import { parseStylesXml, type ParseStylesResult } from "./ooxml/parse-styles.ts";
154
+ import { parseFontTable } from "./ooxml/parse-font-table.ts";
155
155
  import {
156
156
  serializeHeaderXml,
157
157
  serializeHeaderXmlWithRevisions,
@@ -224,6 +224,9 @@ const SETTINGS_RELATIONSHIP_TYPE =
224
224
  const STYLES_RELATIONSHIP_TYPE =
225
225
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
226
226
  const STYLES_PART_PATH = "/word/styles.xml";
227
+ const FONT_TABLE_RELATIONSHIP_TYPE =
228
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
229
+ const FONT_TABLE_PART_PATH = "/word/fontTable.xml";
227
230
  const FOOTNOTES_PART_PATH = "/word/footnotes.xml";
228
231
  const ENDNOTES_PART_PATH = "/word/endnotes.xml";
229
232
  const SETTINGS_PART_PATH = "/word/settings.xml";
@@ -717,6 +720,13 @@ export function loadDocxEditorSession(
717
720
  decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
718
721
  )
719
722
  : undefined;
723
+ const canonicalTheme =
724
+ parsedTheme !== undefined
725
+ ? materializeCanonicalTheme(
726
+ parsedTheme,
727
+ parsedSettings?.clrSchemeMapping ?? {},
728
+ )
729
+ : undefined;
720
730
  const settingsXmlForProtection =
721
731
  settingsPartPath && sourcePackage.parts.has(settingsPartPath)
722
732
  ? decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array())
@@ -739,6 +749,21 @@ export function loadDocxEditorSession(
739
749
  )
740
750
  : parseStylesXml("");
741
751
 
752
+ // ---- Parse fontTable.xml for canonical font catalog ----
753
+ const fontTablePartPath = resolveDocumentRelatedPartPath(
754
+ sourcePackage,
755
+ mainDocumentPath,
756
+ documentPart.relationships,
757
+ FONT_TABLE_RELATIONSHIP_TYPE,
758
+ FONT_TABLE_PART_PATH,
759
+ );
760
+ const parsedFontTable =
761
+ fontTablePartPath && sourcePackage.parts.has(fontTablePartPath)
762
+ ? parseFontTable(
763
+ decodeUtf8(sourcePackage.parts.get(fontTablePartPath)?.bytes ?? new Uint8Array()),
764
+ )
765
+ : undefined;
766
+
742
767
  const subParts: SubPartsCatalog | undefined =
743
768
  parsedHeaders.length > 0 ||
744
769
  parsedFooters.length > 0 ||
@@ -746,6 +771,7 @@ export function loadDocxEditorSession(
746
771
  parsedTheme !== undefined ||
747
772
  normalizedDocument.finalSectionProperties !== undefined ||
748
773
  resolvedTheme !== undefined ||
774
+ canonicalTheme !== undefined ||
749
775
  parsedSettings !== undefined
750
776
  ? {
751
777
  headers: parsedHeaders,
@@ -756,6 +782,7 @@ export function loadDocxEditorSession(
756
782
  ? { finalSectionProperties: normalizedDocument.finalSectionProperties }
757
783
  : {}),
758
784
  ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
785
+ ...(canonicalTheme !== undefined ? { canonicalTheme } : {}),
759
786
  ...(parsedSettings !== undefined ? { settings: parsedSettings } : {}),
760
787
  }
761
788
  : undefined;
@@ -775,6 +802,7 @@ export function loadDocxEditorSession(
775
802
  content: normalizedDocument.content,
776
803
  subParts,
777
804
  parsedStyles,
805
+ fontTable: parsedFontTable,
778
806
  preservation: {
779
807
  ...normalizedDocument.preservation,
780
808
  packageParts: {
@@ -1627,6 +1655,13 @@ export async function loadDocxEditorSessionAsync(
1627
1655
  decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
1628
1656
  )
1629
1657
  : undefined;
1658
+ const canonicalTheme =
1659
+ parsedTheme !== undefined
1660
+ ? materializeCanonicalTheme(
1661
+ parsedTheme,
1662
+ parsedSettings?.clrSchemeMapping ?? {},
1663
+ )
1664
+ : undefined;
1630
1665
  const settingsXmlForProtection =
1631
1666
  settingsPartPath && sourcePackage.parts.has(settingsPartPath)
1632
1667
  ? decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array())
@@ -1650,6 +1685,21 @@ export async function loadDocxEditorSessionAsync(
1650
1685
  : parseStylesXml("");
1651
1686
  await scheduler.yield();
1652
1687
 
1688
+ // ---- Parse fontTable.xml for canonical font catalog ----
1689
+ const fontTablePartPath = resolveDocumentRelatedPartPath(
1690
+ sourcePackage,
1691
+ mainDocumentPath,
1692
+ documentPart.relationships,
1693
+ FONT_TABLE_RELATIONSHIP_TYPE,
1694
+ FONT_TABLE_PART_PATH,
1695
+ );
1696
+ const parsedFontTable =
1697
+ fontTablePartPath && sourcePackage.parts.has(fontTablePartPath)
1698
+ ? parseFontTable(
1699
+ decodeUtf8(sourcePackage.parts.get(fontTablePartPath)?.bytes ?? new Uint8Array()),
1700
+ )
1701
+ : undefined;
1702
+
1653
1703
  const subParts: SubPartsCatalog | undefined =
1654
1704
  parsedHeaders.length > 0 ||
1655
1705
  parsedFooters.length > 0 ||
@@ -1657,6 +1707,7 @@ export async function loadDocxEditorSessionAsync(
1657
1707
  parsedTheme !== undefined ||
1658
1708
  normalizedDocument.finalSectionProperties !== undefined ||
1659
1709
  resolvedTheme !== undefined ||
1710
+ canonicalTheme !== undefined ||
1660
1711
  parsedSettings !== undefined
1661
1712
  ? {
1662
1713
  headers: parsedHeaders,
@@ -1667,6 +1718,7 @@ export async function loadDocxEditorSessionAsync(
1667
1718
  ? { finalSectionProperties: normalizedDocument.finalSectionProperties }
1668
1719
  : {}),
1669
1720
  ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
1721
+ ...(canonicalTheme !== undefined ? { canonicalTheme } : {}),
1670
1722
  ...(parsedSettings !== undefined ? { settings: parsedSettings } : {}),
1671
1723
  }
1672
1724
  : undefined;
@@ -1686,6 +1738,7 @@ export async function loadDocxEditorSessionAsync(
1686
1738
  content: normalizedDocument.content,
1687
1739
  subParts,
1688
1740
  parsedStyles,
1741
+ fontTable: parsedFontTable,
1689
1742
  preservation: {
1690
1743
  ...normalizedDocument.preservation,
1691
1744
  packageParts: {
@@ -2401,6 +2454,7 @@ function createImportedCanonicalDocument(input: {
2401
2454
  content: CanonicalDocumentEnvelope["content"];
2402
2455
  subParts?: SubPartsCatalog;
2403
2456
  parsedStyles?: ParseStylesResult;
2457
+ fontTable?: CanonicalDocumentEnvelope["fontTable"];
2404
2458
  preservation: CanonicalDocumentEnvelope["preservation"];
2405
2459
  diagnostics: CanonicalDocumentEnvelope["diagnostics"];
2406
2460
  review: CanonicalDocumentEnvelope["review"];
@@ -2431,6 +2485,7 @@ function createImportedCanonicalDocument(input: {
2431
2485
  preservation: input.preservation,
2432
2486
  diagnostics: input.diagnostics,
2433
2487
  ...(input.subParts !== undefined ? { subParts: input.subParts } : {}),
2488
+ ...(input.fontTable !== undefined ? { fontTable: input.fontTable } : {}),
2434
2489
  };
2435
2490
  }
2436
2491
 
@@ -3,6 +3,7 @@ import type {
3
3
  BorderSpec,
4
4
  CustomXmlNode,
5
5
  DocumentRootNode,
6
+ FootnoteProperties,
6
7
  InlineNode,
7
8
  MediaCatalog,
8
9
  ParagraphNode,
@@ -530,6 +531,8 @@ function serializeTableInlineNode(
530
531
  case "wordart":
531
532
  case "vml_shape":
532
533
  return wrapInlineRawXml(node.rawXml);
534
+ case "drawing_frame":
535
+ return serializeDrawingFrameNode(node);
533
536
  case "hyperlink": {
534
537
  const hyperlinkOpen = node.href.startsWith("#")
535
538
  ? `<w:hyperlink w:anchor="${escapeXmlAttribute(node.href.slice(1))}">`
@@ -962,6 +965,21 @@ function serializeInlineNode(
962
965
  boundaries,
963
966
  };
964
967
  }
968
+ case "drawing_frame": {
969
+ // CO4 F4.1 — emit preserved rawXml from content (every DrawingFrame
970
+ // variant retains the original w:drawing slice on its content or is
971
+ // a picture that also keeps the slice on its parent). Matches the
972
+ // same "rawXml preservation" contract as shape/chart/smartart above.
973
+ const xml = serializeDrawingFrameNode(node);
974
+ const boundaries = new Map<number, number>();
975
+ boundaries.set(cursor, xmlOffset);
976
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
977
+ return {
978
+ xml,
979
+ cursor: cursor + 1,
980
+ boundaries,
981
+ };
982
+ }
965
983
  case "hyperlink": {
966
984
  const hyperlinkOpen = node.href.startsWith("#")
967
985
  ? `<w:hyperlink w:anchor="${escapeXmlAttribute(node.href.slice(1))}">`
@@ -1125,6 +1143,42 @@ function serializeInlineNode(
1125
1143
  }
1126
1144
  }
1127
1145
 
1146
+ /**
1147
+ * CO4 F4.1 — serialize a DrawingFrameNode by emitting the preserved rawXml.
1148
+ *
1149
+ * Every DrawingFrame variant carries a lossless original-XML reference:
1150
+ * - shape, chart_preview, smartart_preview, opaque → `content.rawXml`
1151
+ * - picture → the parse layer stores the containing drawing XML on the outer
1152
+ * DrawingFrame's context; for the MVP we reconstruct from the anchor +
1153
+ * blipRef if needed. In practice picture content also originates from a
1154
+ * larger drawingXml slice — the parent `w:drawing` substring — which we
1155
+ * don't retain explicitly yet. When `content.rawXml` is absent we emit a
1156
+ * minimal wp:inline/wp:anchor envelope preserving extent + relationship id.
1157
+ *
1158
+ * For the round-trip v1 contract: if any `rawXml` is present, round-trip is
1159
+ * byte-stable modulo whitespace. If the picture-content path has no rawXml,
1160
+ * a reconstructed minimal drawing is emitted.
1161
+ */
1162
+ function serializeDrawingFrameNode(
1163
+ node: Extract<InlineNode, { type: "drawing_frame" }>,
1164
+ ): string {
1165
+ const content = node.content;
1166
+ if ("rawXml" in content && typeof content.rawXml === "string" && content.rawXml.length > 0) {
1167
+ return wrapInlineRawXml(content.rawXml);
1168
+ }
1169
+ // Picture content with no rawXml — reconstruct a minimal inline image so the
1170
+ // round-trip doesn't silently drop the image. Use the blipRef + anchor extent.
1171
+ if (content.type === "picture") {
1172
+ const { widthEmu, heightEmu } = node.anchor.extent;
1173
+ const embed = escapeXmlAttribute(content.blipRef);
1174
+ const envelope = node.anchor.display === "floating" ? "wp:anchor" : "wp:inline";
1175
+ return `<w:r><w:drawing><${envelope} xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="${widthEmu}" cy="${heightEmu}"/><wp:docPr id="1" name=""/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name=""/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${embed}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${widthEmu}" cy="${heightEmu}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></${envelope}></w:drawing></w:r>`;
1176
+ }
1177
+ // Shape/chart/smartart/opaque without rawXml — nothing to emit. Should not
1178
+ // happen in practice since the parser always sets rawXml.
1179
+ return "";
1180
+ }
1181
+
1128
1182
  function serializeImageNode(
1129
1183
  node: Extract<InlineNode, { type: "image" }>,
1130
1184
  state: SerializationState,
@@ -1482,6 +1536,15 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1482
1536
  }
1483
1537
  }
1484
1538
 
1539
+ // Per-section footnote + endnote configuration. ECMA-376 §17.6.18 places
1540
+ // these after the header/footer references and before <w:type>.
1541
+ if (props.footnotePr) {
1542
+ children.push(serializeFootnoteLikeProperties("w:footnotePr", props.footnotePr));
1543
+ }
1544
+ if (props.endnotePr) {
1545
+ children.push(serializeFootnoteLikeProperties("w:endnotePr", props.endnotePr));
1546
+ }
1547
+
1485
1548
  // Section type
1486
1549
  if (props.sectionType) {
1487
1550
  children.push(`<w:type w:val="${escapeXmlAttribute(props.sectionType)}"/>`);
@@ -1623,6 +1686,25 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1623
1686
  return `<w:sectPr>${children.join("")}</w:sectPr>`;
1624
1687
  }
1625
1688
 
1689
+ /**
1690
+ * Emit `<w:footnotePr>` or `<w:endnotePr>` from the typed
1691
+ * `FootnoteProperties` / `EndnoteProperties` shape. Child order follows
1692
+ * ECMA-376 §17.11.11–.18: pos → numFmt → numStart → numRestart.
1693
+ * Each child is emitted only when its typed field is present.
1694
+ */
1695
+ function serializeFootnoteLikeProperties(
1696
+ elementName: "w:footnotePr" | "w:endnotePr",
1697
+ props: FootnoteProperties,
1698
+ ): string {
1699
+ const parts: string[] = [];
1700
+ if (props.pos) parts.push(`<w:pos w:val="${escapeXmlAttribute(props.pos)}"/>`);
1701
+ if (props.numFmt) parts.push(`<w:numFmt w:val="${escapeXmlAttribute(props.numFmt)}"/>`);
1702
+ if (props.numStart !== undefined) parts.push(`<w:numStart w:val="${twip(props.numStart)}"/>`);
1703
+ if (props.numRestart) parts.push(`<w:numRestart w:val="${escapeXmlAttribute(props.numRestart)}"/>`);
1704
+ if (parts.length === 0) return `<${elementName}/>`;
1705
+ return `<${elementName}>${parts.join("")}</${elementName}>`;
1706
+ }
1707
+
1626
1708
  function wrapInlineRawXml(rawXml: string): string {
1627
1709
  const trimmed = rawXml.trimStart();
1628
1710
  return trimmed.startsWith("<w:r") ? rawXml : `<w:r>${rawXml}</w:r>`;
@@ -67,16 +67,18 @@ function buildParagraphStyleXml(
67
67
  ): string {
68
68
  const defaultAttr = style.isDefault ? ` w:default="1"` : "";
69
69
  const nameEl = `<w:name w:val="${escXml(style.displayName)}"/>`;
70
+ const aliasesEl = buildAliasesXml(style.aliases);
70
71
  const basedOnEl = style.basedOn
71
72
  ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
72
73
  : "";
73
74
  const nextEl = style.nextStyle
74
75
  ? `<w:next w:val="${escXml(style.nextStyle)}"/>`
75
76
  : "";
76
- // ECMA-376 §17.7 emit order: name → basedOn → next → link → ...
77
+ // ECMA-376 §17.7 emit order: name → aliases → basedOn → next → link → autoRedefine → ...
77
78
  const linkEl = style.linkedStyleId
78
79
  ? `<w:link w:val="${escXml(style.linkedStyleId)}"/>`
79
80
  : "";
81
+ const autoRedefineEl = buildOnOffEl("autoRedefine", style.autoRedefine);
80
82
 
81
83
  // Build pPr: may contain numPr (from numbering) and any canonical formatting.
82
84
  // We reconstruct the pPr children in canonical order:
@@ -97,15 +99,37 @@ function buildParagraphStyleXml(
97
99
  return (
98
100
  `<w:style w:type="paragraph" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
99
101
  nameEl +
102
+ aliasesEl +
100
103
  basedOnEl +
101
104
  nextEl +
102
105
  linkEl +
106
+ autoRedefineEl +
103
107
  pPrBodyXml +
104
108
  rPrXml +
105
109
  `</w:style>`
106
110
  );
107
111
  }
108
112
 
113
+ /**
114
+ * Emit an ST_OnOff element following A.3 discipline: undefined → "",
115
+ * true → `<w:tag/>`, false → `<w:tag w:val="false"/>`.
116
+ */
117
+ function buildOnOffEl(tag: string, value: boolean | undefined): string {
118
+ if (value === undefined) return "";
119
+ return value ? `<w:${tag}/>` : `<w:${tag} w:val="false"/>`;
120
+ }
121
+
122
+ /**
123
+ * Emit `<w:aliases w:val="A,B"/>` — ECMA-376 §17.7.4.2. Returns empty when
124
+ * `aliases` is undefined or the array is empty. Commas are preserved verbatim;
125
+ * callers are responsible for not embedding commas inside alias names (Word
126
+ * does not escape them either).
127
+ */
128
+ function buildAliasesXml(aliases: string[] | undefined): string {
129
+ if (!aliases || aliases.length === 0) return "";
130
+ return `<w:aliases w:val="${escXml(aliases.join(","))}"/>`;
131
+ }
132
+
109
133
  function buildStyleNumPrXml(
110
134
  numbering: NonNullable<StylesCatalog["paragraphs"][string]["numbering"]>,
111
135
  ): string {
@@ -172,15 +196,44 @@ function buildParagraphPropertiesXmlWithNumPr(
172
196
  return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
173
197
  }
174
198
 
199
+ function buildNumberingStyleXml(
200
+ style: NonNullable<StylesCatalog["numberingStyles"]>[string],
201
+ ): string {
202
+ const defaultAttr = style.isDefault ? ` w:default="1"` : "";
203
+ const nameEl = `<w:name w:val="${escXml(style.displayName)}"/>`;
204
+ const aliasesEl = buildAliasesXml(style.aliases);
205
+ const basedOnEl = style.basedOn
206
+ ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
207
+ : "";
208
+ // Emit `<w:pPr><w:numPr><w:numId/></w:numPr></w:pPr>` when an instance ref
209
+ // was captured. Strip canonical "num:" prefix per serialize-numbering pattern.
210
+ let numPrXml = "";
211
+ if (style.numberingInstanceId) {
212
+ const rawId = style.numberingInstanceId.startsWith("num:")
213
+ ? style.numberingInstanceId.slice(4)
214
+ : style.numberingInstanceId;
215
+ numPrXml = `<w:pPr><w:numPr><w:numId w:val="${escXml(rawId)}"/></w:numPr></w:pPr>`;
216
+ }
217
+ return (
218
+ `<w:style w:type="numbering" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
219
+ nameEl +
220
+ aliasesEl +
221
+ basedOnEl +
222
+ numPrXml +
223
+ `</w:style>`
224
+ );
225
+ }
226
+
175
227
  function buildCharacterStyleXml(
176
228
  style: StylesCatalog["characters"][string],
177
229
  ): string {
178
230
  const defaultAttr = style.isDefault ? ` w:default="1"` : "";
179
231
  const nameEl = `<w:name w:val="${escXml(style.displayName)}"/>`;
232
+ const aliasesEl = buildAliasesXml(style.aliases);
180
233
  const basedOnEl = style.basedOn
181
234
  ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
182
235
  : "";
183
- // ECMA-376 §17.7 emit order: name → basedOn → link → ... → rPr
236
+ // ECMA-376 §17.7 emit order: name → aliases → basedOn → link → ... → rPr
184
237
  const linkEl = style.linkedStyleId
185
238
  ? `<w:link w:val="${escXml(style.linkedStyleId)}"/>`
186
239
  : "";
@@ -189,6 +242,7 @@ function buildCharacterStyleXml(
189
242
  return (
190
243
  `<w:style w:type="character" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
191
244
  nameEl +
245
+ aliasesEl +
192
246
  basedOnEl +
193
247
  linkEl +
194
248
  rPrXml +
@@ -213,7 +267,11 @@ export function serializeStylesXml(catalog: StylesCatalog): string {
213
267
  .map((style) => buildCharacterStyleXml(style))
214
268
  .join("");
215
269
 
216
- const body = docDefaultsXml + paragraphStyles + characterStyles;
270
+ const numberingStyles = Object.values(catalog.numberingStyles ?? {})
271
+ .map((style) => buildNumberingStyleXml(style))
272
+ .join("");
273
+
274
+ const body = docDefaultsXml + paragraphStyles + characterStyles + numberingStyles;
217
275
 
218
276
  return [
219
277
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
@@ -1,5 +1,7 @@
1
1
  import { twip } from "./twip.ts";
2
2
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
3
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
4
+ import type { UnknownPropertyChild } from "../../model/canonical-document.ts";
3
5
 
4
6
  interface TableWidthLike {
5
7
  value: number;
@@ -66,6 +68,7 @@ interface TableFloatingPropertiesLike {
66
68
 
67
69
  interface TablePropertiesLike {
68
70
  propertiesXml?: string;
71
+ unknownPropertyChildren?: UnknownPropertyChild[];
69
72
  styleId?: string;
70
73
  width?: TableWidthLike;
71
74
  alignment?: string;
@@ -83,6 +86,7 @@ interface TablePropertiesLike {
83
86
 
84
87
  interface TableRowPropertiesLike {
85
88
  propertiesXml?: string;
89
+ unknownPropertyChildren?: UnknownPropertyChild[];
86
90
  gridBefore?: number;
87
91
  widthBefore?: TableWidthLike;
88
92
  gridAfter?: number;
@@ -97,6 +101,7 @@ interface TableRowPropertiesLike {
97
101
 
98
102
  interface TableCellPropertiesLike {
99
103
  propertiesXml?: string;
104
+ unknownPropertyChildren?: UnknownPropertyChild[];
100
105
  width?: TableWidthLike;
101
106
  gridSpan?: number;
102
107
  verticalMerge?: "restart" | "continue";
@@ -166,6 +171,7 @@ export function serializeTablePropertiesXml(table: TablePropertiesLike): string
166
171
  return mergePropertiesXml(
167
172
  "w:tblPr",
168
173
  table.propertiesXml,
174
+ table.unknownPropertyChildren,
169
175
  buildTablePropertiesInnerXml(table),
170
176
  TABLE_PROPERTY_STRIP_SPEC,
171
177
  );
@@ -175,6 +181,7 @@ export function serializeTableRowPropertiesXml(row: TableRowPropertiesLike): str
175
181
  return mergePropertiesXml(
176
182
  "w:trPr",
177
183
  row.propertiesXml,
184
+ row.unknownPropertyChildren,
178
185
  buildTableRowPropertiesInnerXml(row),
179
186
  ROW_PROPERTY_STRIP_SPEC,
180
187
  );
@@ -184,21 +191,29 @@ export function serializeTableCellPropertiesXml(cell: TableCellPropertiesLike):
184
191
  return mergePropertiesXml(
185
192
  "w:tcPr",
186
193
  cell.propertiesXml,
194
+ cell.unknownPropertyChildren,
187
195
  buildTableCellPropertiesInnerXml(cell),
188
196
  CELL_PROPERTY_STRIP_SPEC,
189
197
  );
190
198
  }
191
199
 
200
+ // Phase 7 Slice A — `unknownPropertyChildren` (typed grab-bag) is the
201
+ // preferred path. The legacy `existingXml` regex-strip fallback only fires
202
+ // for snapshots that pre-date the typed retrofit (no `unknownPropertyChildren`
203
+ // captured). Once persisted snapshots are migrated and tests no longer
204
+ // reference `propertiesXml`, the fallback branch and `stripKnownProperties`
205
+ // helper can be retired entirely.
192
206
  function mergePropertiesXml(
193
207
  tagName: "w:tblPr" | "w:trPr" | "w:tcPr",
194
208
  existingXml: string | undefined,
209
+ unknownPropertyChildren: readonly UnknownPropertyChild[] | undefined,
195
210
  supportedInnerXml: string,
196
211
  stripSpec: PropertyStripSpec,
197
212
  ): string {
198
- const preservedInnerXml = stripKnownProperties(
199
- extractWrappedChildren(tagName, existingXml),
200
- stripSpec,
201
- );
213
+ const preservedInnerXml =
214
+ unknownPropertyChildren !== undefined
215
+ ? emitPropertyGrabBag(unknownPropertyChildren)
216
+ : stripKnownProperties(extractWrappedChildren(tagName, existingXml), stripSpec);
202
217
  const mergedInnerXml = [supportedInnerXml, preservedInnerXml]
203
218
  .filter((part) => part.length > 0)
204
219
  .join("");
@@ -287,6 +287,7 @@ function normalizeTable(
287
287
  type: "table",
288
288
  ...(table.styleId ? { styleId: table.styleId } : {}),
289
289
  ...(table.propertiesXml ? { propertiesXml: table.propertiesXml } : {}),
290
+ ...(table.unknownPropertyChildren ? { unknownPropertyChildren: table.unknownPropertyChildren } : {}),
290
291
  gridColumns: table.gridColumns,
291
292
  rows,
292
293
  ...(table.width ? { width: table.width } : {}),
@@ -313,6 +314,7 @@ function normalizeTableRow(
313
314
  return {
314
315
  type: "table_row",
315
316
  ...(row.propertiesXml ? { propertiesXml: row.propertiesXml } : {}),
317
+ ...(row.unknownPropertyChildren ? { unknownPropertyChildren: row.unknownPropertyChildren } : {}),
316
318
  ...(row.gridBefore !== undefined ? { gridBefore: row.gridBefore } : {}),
317
319
  ...(row.widthBefore ? { widthBefore: row.widthBefore } : {}),
318
320
  ...(row.gridAfter !== undefined ? { gridAfter: row.gridAfter } : {}),
@@ -343,6 +345,7 @@ function normalizeTableCell(
343
345
  return {
344
346
  type: "table_cell",
345
347
  ...(cell.propertiesXml ? { propertiesXml: cell.propertiesXml } : {}),
348
+ ...(cell.unknownPropertyChildren ? { unknownPropertyChildren: cell.unknownPropertyChildren } : {}),
346
349
  ...(cell.gridSpan ? { gridSpan: cell.gridSpan } : {}),
347
350
  ...(cell.verticalMerge ? { verticalMerge: cell.verticalMerge } : {}),
348
351
  ...(cell.width ? { width: cell.width } : {}),
@@ -454,6 +457,10 @@ function normalizeInlineChildren(
454
457
  normalized.push(normalizeImageNode(node, state));
455
458
  state.cursor += 1;
456
459
  break;
460
+ case "drawing_frame":
461
+ normalized.push(normalizeDrawingFrameNode(node, state));
462
+ state.cursor += 1;
463
+ break;
457
464
  case "hyperlink":
458
465
  normalized.push(normalizeHyperlink(node));
459
466
  state.cursor += measureHyperlink(node);
@@ -617,6 +624,32 @@ function normalizeImageNode(
617
624
  };
618
625
  }
619
626
 
627
+ function normalizeDrawingFrameNode(
628
+ node: Extract<ParsedInlineNode, { type: "drawing_frame" }>,
629
+ state: NormalizationState,
630
+ ): InlineNode {
631
+ if (node.content.type === "picture" && node.content.mediaId) {
632
+ const existingMediaItem = state.media.items[node.content.mediaId];
633
+ const packagePartName =
634
+ typeof node.content.packagePartName === "string" && node.content.packagePartName.length > 0
635
+ ? node.content.packagePartName
636
+ : `/${node.content.mediaId.slice("media:".length)}`;
637
+ const filename = packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin";
638
+ state.media.items[node.content.mediaId] = {
639
+ mediaId: node.content.mediaId,
640
+ contentType: existingMediaItem?.contentType ?? "application/octet-stream",
641
+ filename,
642
+ packagePartName,
643
+ relationshipId: node.content.blipRef,
644
+ ...(node.anchor.docPr?.descr ? { altText: node.anchor.docPr.descr } : {}),
645
+ widthEmu: node.anchor.extent.widthEmu,
646
+ heightEmu: node.anchor.extent.heightEmu,
647
+ };
648
+ }
649
+
650
+ return node;
651
+ }
652
+
620
653
  /**
621
654
  * Register a chart/SmartArt preview bitmap in the media catalog so the
622
655
  * surface renderer can resolve `previewMediaId` → `previewSrc` the same