@beyondwork/docx-react-component 1.0.58 → 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 (134) hide show
  1. package/README.md +2 -2
  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 +978 -10
  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 +72 -42
  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 +159 -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 +476 -34
  87. package/src/runtime/document-search.ts +115 -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 +5 -8
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/selection/post-edit-validator.ts +60 -6
  99. package/src/runtime/structure-ops/index.ts +20 -4
  100. package/src/runtime/surface-projection.ts +290 -21
  101. package/src/runtime/table-schema.ts +6 -0
  102. package/src/runtime/theme-color-resolver.ts +2 -2
  103. package/src/runtime/units.ts +9 -0
  104. package/src/runtime/workflow-rail-segments.ts +4 -0
  105. package/src/ui/WordReviewEditor.tsx +187 -43
  106. package/src/ui/editor-runtime-boundary.ts +10 -0
  107. package/src/ui/editor-shell-view.tsx +4 -1
  108. package/src/ui/headless/chrome-registry.ts +53 -0
  109. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  110. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  111. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  112. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  113. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  114. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  115. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  116. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  117. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  118. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  119. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  120. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  121. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  122. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  124. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  125. package/src/ui-tailwind/index.ts +9 -0
  126. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  127. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  128. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  129. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  130. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  131. package/src/ui-tailwind/theme/tokens.ts +14 -0
  132. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  133. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  134. package/src/validation/diagnostics.ts +1 -0
@@ -0,0 +1,89 @@
1
+ import type { LegacyFormFieldNode } from "../../model/canonical-document.ts";
2
+
3
+ /**
4
+ * Phase V.a — Regenerate `<w:ffData>…</w:ffData>` XML from a typed
5
+ * `LegacyFormFieldNode`.
6
+ *
7
+ * Used when typed-field mutations (via `setLegacyFormFieldValue`) need to be
8
+ * reflected in the serialized output. When no mutation has occurred, the
9
+ * preserved `legacyFormField.rawXml` is emitted verbatim for lossless
10
+ * round-trip — this helper is only invoked on the mutation path.
11
+ *
12
+ * Output is intentionally minimal: only the fields present on the typed
13
+ * node are emitted. Unknown-shape preservation (e.g. w:format, w:helpText,
14
+ * w:statusText) is handled by the rawXml passthrough path; once a consumer
15
+ * mutates the field, those unknown fields are lost unless the host
16
+ * re-populates them. This is the documented trade-off.
17
+ */
18
+ export function regenerateFFDataRawXml(node: LegacyFormFieldNode): string {
19
+ const parts: string[] = ["<w:ffData>"];
20
+
21
+ if (node.name !== undefined) {
22
+ parts.push(`<w:name w:val="${escapeXmlAttribute(node.name)}"/>`);
23
+ }
24
+ if (node.enabled !== undefined) {
25
+ parts.push(node.enabled ? "<w:enabled/>" : `<w:enabled w:val="0"/>`);
26
+ }
27
+ if (node.calcOnExit !== undefined) {
28
+ parts.push(node.calcOnExit ? "<w:calcOnExit/>" : `<w:calcOnExit w:val="0"/>`);
29
+ }
30
+
31
+ switch (node.kind) {
32
+ case "textInput": {
33
+ const ti = node.textInput;
34
+ parts.push("<w:textInput>");
35
+ if (ti?.default !== undefined) {
36
+ parts.push(`<w:default w:val="${escapeXmlAttribute(ti.default)}"/>`);
37
+ }
38
+ if (ti?.maxLength !== undefined) {
39
+ parts.push(`<w:maxLength w:val="${ti.maxLength}"/>`);
40
+ }
41
+ if (ti?.format !== undefined) {
42
+ parts.push(`<w:format w:val="${escapeXmlAttribute(ti.format)}"/>`);
43
+ }
44
+ parts.push("</w:textInput>");
45
+ break;
46
+ }
47
+ case "checkBox": {
48
+ const cb = node.checkBox;
49
+ parts.push("<w:checkBox>");
50
+ if (cb?.size !== undefined) {
51
+ parts.push(`<w:size w:val="${cb.size}"/>`);
52
+ }
53
+ if (cb?.default !== undefined) {
54
+ parts.push(`<w:default w:val="${cb.default ? "1" : "0"}"/>`);
55
+ }
56
+ if (cb?.checked !== undefined) {
57
+ parts.push(`<w:checked w:val="${cb.checked ? "1" : "0"}"/>`);
58
+ }
59
+ parts.push("</w:checkBox>");
60
+ break;
61
+ }
62
+ case "ddList": {
63
+ const dd = node.ddList;
64
+ parts.push("<w:ddList>");
65
+ if (dd?.default !== undefined) {
66
+ parts.push(`<w:default w:val="${dd.default}"/>`);
67
+ }
68
+ if (dd?.listEntry) {
69
+ for (const entry of dd.listEntry) {
70
+ parts.push(`<w:listEntry w:val="${escapeXmlAttribute(entry)}"/>`);
71
+ }
72
+ }
73
+ parts.push("</w:ddList>");
74
+ break;
75
+ }
76
+ }
77
+
78
+ parts.push("</w:ffData>");
79
+ return parts.join("");
80
+ }
81
+
82
+ function escapeXmlAttribute(value: string): string {
83
+ return value
84
+ .replace(/&/g, "&amp;")
85
+ .replace(/</g, "&lt;")
86
+ .replace(/>/g, "&gt;")
87
+ .replace(/"/g, "&quot;")
88
+ .replace(/'/g, "&apos;");
89
+ }
@@ -263,6 +263,11 @@ function serializeInlineNode(node: InlineNode): string {
263
263
  case "wordart":
264
264
  case "vml_shape":
265
265
  return wrapInlineRawXml(node.rawXml);
266
+ case "ole_embed":
267
+ // OLE in header/footer is rare but legal in OOXML. Relationship
268
+ // tracking for sub-parts is not wired; emit rawXml verbatim
269
+ // (preserve-only contract — same fallback as vml_shape above).
270
+ return wrapInlineRawXml(node.rawXml);
266
271
  case "opaque_inline":
267
272
  throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
268
273
  case "image":
@@ -5,6 +5,7 @@ import type {
5
5
  DocumentRootNode,
6
6
  FootnoteProperties,
7
7
  InlineNode,
8
+ LegacyFormFieldNode,
8
9
  MediaCatalog,
9
10
  ParagraphNode,
10
11
  PreservationStore,
@@ -14,6 +15,7 @@ import type {
14
15
  TableCellNode,
15
16
  TextMark,
16
17
  } from "../../model/canonical-document.ts";
18
+ import { regenerateFFDataRawXml } from "./serialize-ffdata.ts";
17
19
  import type { OpcRelationship } from "../ooxml/part-manifest.ts";
18
20
  import type { RevisionParagraphBoundary } from "../ooxml/revision-boundaries.ts";
19
21
  import { SCOPE_MARKER_BOOKMARK_PREFIX } from "../ooxml/parse-scope-markers.ts";
@@ -28,10 +30,27 @@ import {
28
30
  import { twip } from "./twip.ts";
29
31
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
30
32
  import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
33
+ import { type NamespaceFlavor, nsUris } from "./ooxml-namespaces.ts";
31
34
 
32
35
  const HYPERLINK_RELATIONSHIP_TYPE =
33
36
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
34
37
 
38
+ /**
39
+ * Phase V.a — decide which ffData XML to emit for a FieldNode's legacyFormField.
40
+ *
41
+ * - Un-mutated (or missing): emit preserved `rawXml` verbatim for lossless
42
+ * round-trip (Phase O contract).
43
+ * - Mutated (after `setLegacyFormFieldValue`): regenerate fresh XML from
44
+ * the typed fields via `regenerateFFDataRawXml`. Unknown-shape fields
45
+ * (helpText/statusText/format) that the typed model doesn't cover are
46
+ * dropped on the mutation path — documented tradeoff.
47
+ */
48
+ function resolveFFDataXml(legacyFormField: LegacyFormFieldNode | undefined): string {
49
+ if (!legacyFormField) return "";
50
+ if (legacyFormField.mutated) return regenerateFFDataRawXml(legacyFormField);
51
+ return legacyFormField.rawXml ?? "";
52
+ }
53
+
35
54
  export interface SerializedMainDocument {
36
55
  documentXml: string;
37
56
  relationships: OpcRelationship[];
@@ -42,6 +61,8 @@ export interface SerializeMainDocumentOptions {
42
61
  documentAttributes?: Record<string, string>;
43
62
  media?: MediaCatalog;
44
63
  finalSectionProperties?: SectionProperties;
64
+ /** Defaults to "transitional". Pass "strict" to emit ISO 29500 Strict namespace URIs. */
65
+ namespaceFlavor?: NamespaceFlavor;
45
66
  }
46
67
 
47
68
  interface SerializationState {
@@ -51,6 +72,13 @@ interface SerializationState {
51
72
  retainedRelationshipIds: Set<string>;
52
73
  media: MediaCatalog;
53
74
  preservation: PreservationStore;
75
+ namespaceFlavor: NamespaceFlavor;
76
+ /**
77
+ * Phase 3.1 B2 — monotonic counter for minted `wp:docPr` ids on reconstructed
78
+ * DrawingFrameNode pictures that lack `content.rawXml`. Starts at 10000 to
79
+ * avoid collision with Word-authored ids (which are typically small ints).
80
+ */
81
+ nextDrawingFrameReconstructId: number;
54
82
  /**
55
83
  * A.7: per-body-context dedupe sets for w14:paraId / w14:textId.
56
84
  * Every paragraph emitted into the same document part must carry a
@@ -100,6 +128,8 @@ export function serializeMainDocument(
100
128
  ),
101
129
  media: options.media ?? { items: {} },
102
130
  preservation,
131
+ namespaceFlavor: options.namespaceFlavor ?? "transitional",
132
+ nextDrawingFrameReconstructId: 10000,
103
133
  usedParaIds: new Set<string>(),
104
134
  usedTextIds: new Set<string>(),
105
135
  mintedParaIdCounter: 0,
@@ -214,6 +244,7 @@ export function serializeMainDocument(
214
244
  options.documentAttributes,
215
245
  content,
216
246
  extensionAliases,
247
+ options.namespaceFlavor ?? "transitional",
217
248
  )}>`;
218
249
  const prefix = [
219
250
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
@@ -532,7 +563,17 @@ function serializeTableInlineNode(
532
563
  case "vml_shape":
533
564
  return wrapInlineRawXml(node.rawXml);
534
565
  case "drawing_frame":
535
- return serializeDrawingFrameNode(node);
566
+ return serializeDrawingFrameNode(node, state);
567
+ case "ole_embed":
568
+ // Lane 7c Slice 7c.1 — emit rawXml verbatim and retain the
569
+ // embedding relationship. Defensive: the serialize-state
570
+ // constructor pre-seeds retainedRelationshipIds with every
571
+ // pre-existing non-hyperlink relationship, so the embedding
572
+ // binary part is already preserved via collectPreservedPackageParts.
573
+ // This .add() guards against a future constructor tightening
574
+ // that narrows the pre-seed set.
575
+ state.retainedRelationshipIds.add(node.relationshipId);
576
+ return wrapInlineRawXml(node.rawXml);
536
577
  case "hyperlink": {
537
578
  const hyperlinkOpen = node.href.startsWith("#")
538
579
  ? `<w:hyperlink w:anchor="${escapeXmlAttribute(node.href.slice(1))}">`
@@ -557,8 +598,17 @@ function serializeTableInlineNode(
557
598
  .map((child) => serializeTableInlineNode(child, state))
558
599
  .join("");
559
600
  if (node.fieldType === "complex") {
601
+ // Invariant (Phase T): legacyFormField.rawXml is the authoritative
602
+ // form-field markup. Mutating legacyFormField.{textInput.default |
603
+ // checkBox.checked | ddList.selected} without regenerating rawXml
604
+ // will desync the serialized output. Phase V will add a regeneration
605
+ // path via `setLegacyFormFieldValue` + `regenerateFFDataRawXml`.
606
+ const ffDataXml = resolveFFDataXml(node.legacyFormField);
607
+ const beginRun = ffDataXml
608
+ ? `<w:r><w:fldChar w:fldCharType="begin">${ffDataXml}</w:fldChar></w:r>`
609
+ : `<w:r><w:fldChar w:fldCharType="begin"/></w:r>`;
560
610
  return (
561
- `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
611
+ beginRun +
562
612
  `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
563
613
  `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
564
614
  childrenXml +
@@ -567,6 +617,18 @@ function serializeTableInlineNode(
567
617
  }
568
618
  return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
569
619
  }
620
+ if (node.fieldType === "complex") {
621
+ const ffDataXml = resolveFFDataXml(node.legacyFormField);
622
+ const beginRun = ffDataXml
623
+ ? `<w:r><w:fldChar w:fldCharType="begin">${ffDataXml}</w:fldChar></w:r>`
624
+ : `<w:r><w:fldChar w:fldCharType="begin"/></w:r>`;
625
+ return (
626
+ beginRun +
627
+ `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
628
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
629
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
630
+ );
631
+ }
570
632
  return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}"/>`;
571
633
  }
572
634
  case "bookmark_start":
@@ -970,7 +1032,22 @@ function serializeInlineNode(
970
1032
  // variant retains the original w:drawing slice on its content or is
971
1033
  // a picture that also keeps the slice on its parent). Matches the
972
1034
  // same "rawXml preservation" contract as shape/chart/smartart above.
973
- const xml = serializeDrawingFrameNode(node);
1035
+ const xml = serializeDrawingFrameNode(node, state);
1036
+ const boundaries = new Map<number, number>();
1037
+ boundaries.set(cursor, xmlOffset);
1038
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
1039
+ return {
1040
+ xml,
1041
+ cursor: cursor + 1,
1042
+ boundaries,
1043
+ };
1044
+ }
1045
+ case "ole_embed": {
1046
+ // Lane 7c Slice 7c.1 — emit rawXml verbatim and retain the
1047
+ // embedding relationship. Defensive — see the matching branch
1048
+ // in the run dispatcher above for the pre-seed rationale.
1049
+ state.retainedRelationshipIds.add(node.relationshipId);
1050
+ const xml = wrapInlineRawXml(node.rawXml);
974
1051
  const boundaries = new Map<number, number>();
975
1052
  boundaries.set(cursor, xmlOffset);
976
1053
  boundaries.set(cursor + 1, xmlOffset + xml.length);
@@ -1027,8 +1104,12 @@ function serializeInlineNode(
1027
1104
  let nextOffset = xmlOffset;
1028
1105
 
1029
1106
  if (node.fieldType === "complex") {
1107
+ const ffDataXml = resolveFFDataXml(node.legacyFormField);
1108
+ const beginRun = ffDataXml
1109
+ ? `<w:r><w:fldChar w:fldCharType="begin">${ffDataXml}</w:fldChar></w:r>`
1110
+ : `<w:r><w:fldChar w:fldCharType="begin"/></w:r>`;
1030
1111
  const beginXml =
1031
- `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
1112
+ beginRun +
1032
1113
  `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
1033
1114
  `<w:r><w:fldChar w:fldCharType="separate"/></w:r>`;
1034
1115
  nextOffset += beginXml.length;
@@ -1069,6 +1150,22 @@ function serializeInlineNode(
1069
1150
  return { xml: children.join(""), cursor: nextCursor, boundaries };
1070
1151
  }
1071
1152
 
1153
+ if (node.fieldType === "complex") {
1154
+ const ffDataXml = resolveFFDataXml(node.legacyFormField);
1155
+ const beginRun = ffDataXml
1156
+ ? `<w:r><w:fldChar w:fldCharType="begin">${ffDataXml}</w:fldChar></w:r>`
1157
+ : `<w:r><w:fldChar w:fldCharType="begin"/></w:r>`;
1158
+ const xml =
1159
+ beginRun +
1160
+ `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
1161
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
1162
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`;
1163
+ const boundaries = new Map<number, number>();
1164
+ boundaries.set(cursor, xmlOffset);
1165
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
1166
+ return { xml, cursor: cursor + 1, boundaries };
1167
+ }
1168
+
1072
1169
  const xml = `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}"/>`;
1073
1170
  const boundaries = new Map<number, number>();
1074
1171
  boundaries.set(cursor, xmlOffset);
@@ -1144,41 +1241,128 @@ function serializeInlineNode(
1144
1241
  }
1145
1242
 
1146
1243
  /**
1147
- * CO4 F4.1 — serialize a DrawingFrameNode by emitting the preserved rawXml.
1244
+ * Serialize a DrawingFrameNode.
1245
+ *
1246
+ * Primary path: emit preserved `content.rawXml` unchanged — byte-stable
1247
+ * round-trip. Every typed content variant (picture, shape, chart_preview,
1248
+ * smartart_preview, opaque) carries rawXml when the parser produced it.
1148
1249
  *
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.
1250
+ * Reconstruction fallback (picture only): when a DrawingFrameNode lacks
1251
+ * rawXml (produced programmatically in memory not yet a supported author
1252
+ * path, but possible), synthesise a schema-valid wp:inline or wp:anchor
1253
+ * envelope from the parsed AnchorGeometry + PictureContent fields.
1157
1254
  *
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.
1255
+ * Schema-validity rules for reconstruction (Phase 3.1 B2+B3):
1256
+ * - Every `wp:docPr` gets a unique id either the parsed `anchor.docPr.id`
1257
+ * or a fresh id minted from `state.nextDrawingFrameReconstructId`.
1258
+ * - Floating reconstruction (wp:anchor) emits the required children:
1259
+ * `wp:simplePos`, `wp:positionH`, `wp:positionV`, `wp:extent`,
1260
+ * `wp:effectExtent`, `wp:wrap*` — refuses reconstruction if positionH
1261
+ * or positionV is missing (returns empty string = defensive fail-loud).
1262
+ * - Inline reconstruction (wp:inline) needs only `wp:extent`, `wp:docPr`,
1263
+ * `wp:cNvGraphicFramePr`, `a:graphic`.
1264
+ *
1265
+ * Non-picture content (shape/chart/smartart/opaque) without rawXml returns
1266
+ * empty string — no reconstructor for those variants. In practice the parser
1267
+ * always sets rawXml for those; this is a defensive fail-loud path.
1161
1268
  */
1162
1269
  function serializeDrawingFrameNode(
1163
1270
  node: Extract<InlineNode, { type: "drawing_frame" }>,
1271
+ state: SerializationState,
1164
1272
  ): string {
1165
1273
  const content = node.content;
1166
1274
  if ("rawXml" in content && typeof content.rawXml === "string" && content.rawXml.length > 0) {
1167
1275
  return wrapInlineRawXml(content.rawXml);
1168
1276
  }
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
1277
  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>`;
1278
+ return reconstructPictureFrame(node, content, state);
1176
1279
  }
1177
- // Shape/chart/smartart/opaque without rawXml — nothing to emit. Should not
1178
- // happen in practice since the parser always sets rawXml.
1280
+ // Shape/chart/smartart/opaque without rawXml — no reconstructor; emit nothing.
1179
1281
  return "";
1180
1282
  }
1181
1283
 
1284
+ function reconstructPictureFrame(
1285
+ node: Extract<InlineNode, { type: "drawing_frame" }>,
1286
+ content: Extract<AnchorGeometryContent, { type: "picture" }>,
1287
+ state: SerializationState,
1288
+ ): string {
1289
+ const { widthEmu, heightEmu } = node.anchor.extent;
1290
+ const embed = escapeXmlAttribute(content.blipRef);
1291
+
1292
+ // docPr uniqueness (B2): use parsed id; otherwise mint a fresh one.
1293
+ const parsedDocPr = node.anchor.docPr;
1294
+ const docPrId = parsedDocPr?.id ?? String(state.nextDrawingFrameReconstructId++);
1295
+ const docPrNameAttr = parsedDocPr?.name
1296
+ ? ` name="${escapeXmlAttribute(parsedDocPr.name)}"`
1297
+ : ` name=""`;
1298
+ const docPrDescrAttr = parsedDocPr?.descr
1299
+ ? ` descr="${escapeXmlAttribute(parsedDocPr.descr)}"`
1300
+ : "";
1301
+ const docPrXml = `<wp:docPr id="${escapeXmlAttribute(docPrId)}"${docPrNameAttr}${docPrDescrAttr}/>`;
1302
+
1303
+ const ns = nsUris(state.namespaceFlavor);
1304
+ const graphicXml = `<a:graphic><a:graphicData uri="${ns.pic}"><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>`;
1305
+
1306
+ const drawingNs = `xmlns:a="${ns.a}" xmlns:pic="${ns.pic}" xmlns:wp="${ns.wp}"`;
1307
+
1308
+ if (node.anchor.display === "inline") {
1309
+ const extent = `<wp:extent cx="${widthEmu}" cy="${heightEmu}"/>`;
1310
+ return `<w:r><w:drawing><wp:inline ${drawingNs}>${extent}${docPrXml}<wp:cNvGraphicFramePr/>${graphicXml}</wp:inline></w:drawing></w:r>`;
1311
+ }
1312
+
1313
+ // Floating (wp:anchor): refuse reconstruction if required children missing.
1314
+ // Fail-loud: return empty string so consumer sees the picture was dropped
1315
+ // rather than Word seeing schema-invalid XML and rejecting / renumbering.
1316
+ if (!node.anchor.positionH || !node.anchor.positionV) return "";
1317
+
1318
+ const simplePos = `<wp:simplePos x="0" y="0"/>`;
1319
+ const positionH = buildPositionAxisXml("positionH", node.anchor.positionH);
1320
+ const positionV = buildPositionAxisXml("positionV", node.anchor.positionV);
1321
+ const extent = `<wp:extent cx="${widthEmu}" cy="${heightEmu}"/>`;
1322
+ const distMargins = node.anchor.distMargins ?? { top: 0, bottom: 0, left: 0, right: 0 };
1323
+ const effectExtent = `<wp:effectExtent l="${distMargins.left ?? 0}" t="${distMargins.top ?? 0}" r="${distMargins.right ?? 0}" b="${distMargins.bottom ?? 0}"/>`;
1324
+ const wrap = buildWrapElementXml(node.anchor.wrapMode);
1325
+
1326
+ const anchorAttrs = [
1327
+ `distT="${distMargins.top ?? 0}"`,
1328
+ `distB="${distMargins.bottom ?? 0}"`,
1329
+ `distL="${distMargins.left ?? 0}"`,
1330
+ `distR="${distMargins.right ?? 0}"`,
1331
+ `simplePos="${node.anchor.simplePos ? 1 : 0}"`,
1332
+ `relativeHeight="${node.anchor.relativeHeight ?? 0}"`,
1333
+ `behindDoc="${node.anchor.behindDoc ? 1 : 0}"`,
1334
+ `locked="0"`,
1335
+ `layoutInCell="${(node.anchor.layoutInCell ?? true) ? 1 : 0}"`,
1336
+ `allowOverlap="${(node.anchor.allowOverlap ?? true) ? 1 : 0}"`,
1337
+ ].join(" ");
1338
+
1339
+ return `<w:r><w:drawing><wp:anchor ${drawingNs} ${anchorAttrs}>${simplePos}${positionH}${positionV}${extent}${effectExtent}${wrap}${docPrXml}<wp:cNvGraphicFramePr/>${graphicXml}</wp:anchor></w:drawing></w:r>`;
1340
+ }
1341
+
1342
+ function buildPositionAxisXml(
1343
+ kind: "positionH" | "positionV",
1344
+ axis: { relativeFrom: string; align?: string; offset?: number },
1345
+ ): string {
1346
+ const body = axis.align
1347
+ ? `<wp:align>${escapeXmlAttribute(axis.align)}</wp:align>`
1348
+ : `<wp:posOffset>${axis.offset ?? 0}</wp:posOffset>`;
1349
+ return `<wp:${kind} relativeFrom="${escapeXmlAttribute(axis.relativeFrom || "column")}">${body}</wp:${kind}>`;
1350
+ }
1351
+
1352
+ function buildWrapElementXml(mode: "none" | "square" | "tight" | "through" | "topAndBottom"): string {
1353
+ switch (mode) {
1354
+ case "none": return `<wp:wrapNone/>`;
1355
+ case "square": return `<wp:wrapSquare wrapText="bothSides"/>`;
1356
+ case "tight": return `<wp:wrapTight wrapText="bothSides"/>`;
1357
+ case "through": return `<wp:wrapThrough wrapText="bothSides"/>`;
1358
+ case "topAndBottom": return `<wp:wrapTopAndBottom/>`;
1359
+ }
1360
+ }
1361
+
1362
+ // Narrow helper type used only in the reconstruction signature.
1363
+ type AnchorGeometryContent =
1364
+ Extract<InlineNode, { type: "drawing_frame" }>["content"];
1365
+
1182
1366
  function serializeImageNode(
1183
1367
  node: Extract<InlineNode, { type: "image" }>,
1184
1368
  state: SerializationState,
@@ -1191,7 +1375,8 @@ function serializeImageNode(
1191
1375
  const mediaItem = state.media.items[node.mediaId];
1192
1376
  if (mediaItem?.relationshipId && state.existingRelationshipMap.has(mediaItem.relationshipId)) {
1193
1377
  const altText = node.altText ?? mediaItem.altText ?? mediaItem.filename;
1194
- return `<w:r><w:drawing><wp:inline 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="9525" cy="9525"/><wp:docPr id="1" name="${escapeXmlAttribute(mediaItem.filename)}" descr="${escapeXmlAttribute(altText ?? "")}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${escapeXmlAttribute(mediaItem.filename)}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${escapeXmlAttribute(mediaItem.relationshipId)}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9525" cy="9525"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
1378
+ const ins = nsUris(state.namespaceFlavor);
1379
+ return `<w:r><w:drawing><wp:inline xmlns:a="${ins.a}" xmlns:pic="${ins.pic}" xmlns:wp="${ins.wp}"><wp:extent cx="9525" cy="9525"/><wp:docPr id="1" name="${escapeXmlAttribute(mediaItem.filename)}" descr="${escapeXmlAttribute(altText ?? "")}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="${ins.pic}"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${escapeXmlAttribute(mediaItem.filename)}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${escapeXmlAttribute(mediaItem.relationshipId)}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9525" cy="9525"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
1195
1380
  }
1196
1381
 
1197
1382
  return serializeRun({
@@ -1458,26 +1643,31 @@ function serializeDocumentAttributes(
1458
1643
  attributes: Record<string, string> | undefined,
1459
1644
  content?: DocumentRootNode,
1460
1645
  extensionAliases: readonly string[] = [],
1646
+ flavor: NamespaceFlavor = "transitional",
1461
1647
  ): string {
1648
+ const ns = nsUris(flavor);
1649
+ // Extension namespaces are Transitional-only (Microsoft-specific w14/w15/w16*).
1650
+ // In Strict mode they are omitted — any w14/w15 content in preserved XML
1651
+ // is technically non-conformant to ISO 29500 Strict anyway.
1462
1652
  const extensionXmlns: Record<string, string> = {};
1463
- for (const alias of extensionAliases) {
1464
- const uri = EXTENSION_NAMESPACE_URIS[alias];
1465
- if (uri) extensionXmlns[`xmlns:${alias}`] = uri;
1653
+ if (flavor !== "strict") {
1654
+ for (const alias of extensionAliases) {
1655
+ const uri = EXTENSION_NAMESPACE_URIS[alias];
1656
+ if (uri) extensionXmlns[`xmlns:${alias}`] = uri;
1657
+ }
1466
1658
  }
1467
1659
 
1468
1660
  const merged: Record<string, string> = {
1469
- "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
1470
- "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
1661
+ "xmlns:r": ns.r,
1662
+ "xmlns:w": ns.w,
1471
1663
  ...extensionXmlns,
1472
1664
  ...(attributes ?? {}),
1473
1665
  };
1474
1666
 
1475
1667
  // mc:Ignorable declares extension-namespace aliases that downstream readers
1476
1668
  // may skip without dropping the document. Only emit when aliases are in play.
1477
- if (extensionAliases.length > 0) {
1478
- merged["xmlns:mc"] =
1479
- merged["xmlns:mc"] ??
1480
- "http://schemas.openxmlformats.org/markup-compatibility/2006";
1669
+ if (flavor !== "strict" && extensionAliases.length > 0) {
1670
+ merged["xmlns:mc"] = merged["xmlns:mc"] ?? ns.mc;
1481
1671
  merged["mc:Ignorable"] = extensionAliases.join(" ");
1482
1672
  }
1483
1673
 
@@ -11,6 +11,16 @@ export const WORD_NUMBERING_CONTENT_TYPE =
11
11
  "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
12
12
 
13
13
  export function serializeNumberingXml(catalog: NumberingCatalog): string {
14
+ // ECMA-376 §17.9.19 — <w:numPicBullet> entries precede <w:abstractNum> and
15
+ // <w:num> in the numbering part. Emit verbatim rawXml so picture bullet data
16
+ // round-trips byte-for-byte regardless of VML vs DrawingML variant.
17
+ const picBullets = catalog.numPicBullets
18
+ ? Object.values(catalog.numPicBullets)
19
+ .sort((a, b) => a.numPicBulletId.localeCompare(b.numPicBulletId, "en", { numeric: true }))
20
+ .map((b) => b.rawXml)
21
+ .join("")
22
+ : "";
23
+
14
24
  const abstractDefinitions = Object.values(catalog.abstractDefinitions)
15
25
  .filter((definition) => !isSyntheticDocxNullAbstractDefinition(definition))
16
26
  .sort((left, right) =>
@@ -23,6 +33,7 @@ export function serializeNumberingXml(catalog: NumberingCatalog): string {
23
33
  );
24
34
 
25
35
  const body = [
36
+ picBullets,
26
37
  ...abstractDefinitions.map((definition) => serializeAbstractDefinition(definition)),
27
38
  ...instances.map((instance) => serializeInstance(instance)),
28
39
  ].join("");
@@ -35,6 +46,7 @@ export function serializeNumberingXml(catalog: NumberingCatalog): string {
35
46
 
36
47
  export function hasSerializableNumberingEntries(catalog: NumberingCatalog): boolean {
37
48
  return (
49
+ (catalog.numPicBullets !== undefined && Object.keys(catalog.numPicBullets).length > 0) ||
38
50
  Object.values(catalog.abstractDefinitions).some(
39
51
  (definition) => !isSyntheticDocxNullAbstractDefinition(definition),
40
52
  ) ||
@@ -112,13 +124,17 @@ function serializeLevel(
112
124
  const isLegal = level.isLegalNumbering ? "<w:isLgl/>" : "";
113
125
  const suffix = level.suffix ? `<w:suff w:val="${escapeXmlAttribute(level.suffix)}"/>` : "";
114
126
  const lvlText = `<w:lvlText w:val="${escapeXmlAttribute(level.text)}"/>`;
127
+ // ECMA-376 CT_Lvl: lvlPicBulletId follows lvlText, precedes lvlJc.
128
+ const picBulletId = level.picBulletId
129
+ ? `<w:lvlPicBulletId w:val="${escapeXmlAttribute(level.picBulletId)}"/>`
130
+ : "";
115
131
  const justification = level.paragraphGeometry?.justification
116
132
  ? `<w:lvlJc w:val="${escapeXmlAttribute(level.paragraphGeometry.justification)}"/>`
117
133
  : "";
118
134
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
119
135
  const runProperties = buildRunPropertiesXml(level.runProperties);
120
136
 
121
- return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}${numFmt}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${lvlText}${justification}${paragraphProperties}${runProperties}</w:lvl>`;
137
+ return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}${numFmt}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${lvlText}${picBulletId}${justification}${paragraphProperties}${runProperties}</w:lvl>`;
122
138
  }
123
139
 
124
140
  function serializeLevelOverride(
@@ -158,12 +174,16 @@ function serializeLevelOverride(
158
174
  const text = level.text !== undefined
159
175
  ? `<w:lvlText w:val="${escapeXmlAttribute(level.text)}"/>`
160
176
  : "";
177
+ // ECMA-376 CT_Lvl: lvlPicBulletId follows lvlText, precedes lvlJc.
178
+ const picBulletId = level.picBulletId
179
+ ? `<w:lvlPicBulletId w:val="${escapeXmlAttribute(level.picBulletId)}"/>`
180
+ : "";
161
181
  const justification = level.paragraphGeometry?.justification
162
182
  ? `<w:lvlJc w:val="${escapeXmlAttribute(level.paragraphGeometry.justification)}"/>`
163
183
  : "";
164
184
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
165
185
  const runProperties = buildRunPropertiesXml(level.runProperties);
166
- const body = `${start}${format}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${text}${justification}${paragraphProperties}${runProperties}`;
186
+ const body = `${start}${format}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${text}${picBulletId}${justification}${paragraphProperties}${runProperties}`;
167
187
 
168
188
  return body.length > 0
169
189
  ? `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${body}</w:lvl>`