@beyondwork/docx-react-component 1.0.36 → 1.0.38

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/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -23,6 +23,7 @@ import {
23
23
  serializeTablePropertiesXml,
24
24
  serializeTableRowPropertiesXml,
25
25
  } from "./table-properties-xml.ts";
26
+ import { twip } from "./twip.ts";
26
27
 
27
28
  const HYPERLINK_RELATIONSHIP_TYPE =
28
29
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
@@ -46,6 +47,15 @@ interface SerializationState {
46
47
  retainedRelationshipIds: Set<string>;
47
48
  media: MediaCatalog;
48
49
  preservation: PreservationStore;
50
+ /**
51
+ * A.7: per-body-context dedupe sets for w14:paraId / w14:textId.
52
+ * Every paragraph emitted into the same document part must carry a
53
+ * unique paraId (the schema constraint). Colliding ids are minted fresh.
54
+ */
55
+ usedParaIds: Set<string>;
56
+ usedTextIds: Set<string>;
57
+ /** Deterministic PRNG-less counter used when we mint fresh ids. */
58
+ mintedParaIdCounter: number;
49
59
  }
50
60
 
51
61
  interface InlineSerializationResult {
@@ -86,13 +96,10 @@ export function serializeMainDocument(
86
96
  ),
87
97
  media: options.media ?? { items: {} },
88
98
  preservation,
99
+ usedParaIds: new Set<string>(),
100
+ usedTextIds: new Set<string>(),
101
+ mintedParaIdCounter: 0,
89
102
  };
90
- const documentOpen = `<w:document${serializeDocumentAttributes(options.documentAttributes, content)}>`;
91
- const prefix = [
92
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
93
- documentOpen,
94
- ` <w:body>`,
95
- ].join("\n");
96
103
  const suffix = `</w:body>\n</w:document>`;
97
104
  const bodyPieces: string[] = [];
98
105
  const paragraphBoundaries: RevisionParagraphBoundary[] = [];
@@ -117,11 +124,14 @@ export function serializeMainDocument(
117
124
  cursor,
118
125
  paragraphIndex,
119
126
  );
120
- const paragraphOffset = prefix.length + bodyLength;
127
+ // Capture body-relative offsets; we apply the final prefix length after
128
+ // the body is fully serialized and we know which namespace aliases the
129
+ // document opening tag must declare (see mc:Ignorable scan below).
130
+ const bodyRelativeOffset = bodyLength;
121
131
  bodyPieces.push(serializedParagraph.xml);
122
132
  bodyLength += serializedParagraph.xml.length;
123
133
  paragraphBoundaries.push(
124
- offsetParagraphBoundary(serializedParagraph.boundary, paragraphOffset),
134
+ offsetParagraphBoundary(serializedParagraph.boundary, bodyRelativeOffset),
125
135
  );
126
136
  cursor = serializedParagraph.nextCursor;
127
137
  previousWasParagraph = true;
@@ -194,12 +204,38 @@ export function serializeMainDocument(
194
204
  }
195
205
 
196
206
  const bodyXml = bodyPieces.join("");
197
- const documentXml = `${prefix}${bodyXml || "<w:p><w:r><w:t></w:t></w:r></w:p>"}${sectionPropertiesXml}${suffix}`;
207
+ const bodyAndSectionXml = `${bodyXml || "<w:p><w:r><w:t></w:t></w:r></w:p>"}${sectionPropertiesXml}`;
208
+ const extensionAliases = collectExtensionAliases(bodyAndSectionXml, content);
209
+ const documentOpen = `<w:document${serializeDocumentAttributes(
210
+ options.documentAttributes,
211
+ content,
212
+ extensionAliases,
213
+ )}>`;
214
+ const prefix = [
215
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
216
+ documentOpen,
217
+ ` <w:body>`,
218
+ ].join("\n");
219
+ const documentXml = `${prefix}${bodyAndSectionXml}${suffix}`;
220
+ // A.9 dev guard — a NaN or -Infinity slipping into the serialized XML
221
+ // means an authoring path wrote a bad numeric without routing through
222
+ // twip() / requireTwip(). Fail early so the bug is caught at test time,
223
+ // not at validator-service time where the symptom is downstream.
224
+ assertNoNonFiniteNumbers(documentXml);
225
+ // A.4 dev guard — every emitted w:bookmarkStart id must be unique within
226
+ // a part. A collision means detectDuplicateBookmarkIds was not applied
227
+ // on the canonical document (or the caller wrote a custom path that
228
+ // bypassed it). Flag the first collision with a helpful console warning
229
+ // in non-production; production is a silent no-op.
230
+ assertUniqueBookmarkIdsOrWarn(documentXml);
231
+ const shiftedBoundaries = paragraphBoundaries.map((boundary) =>
232
+ offsetParagraphBoundary(boundary, prefix.length),
233
+ );
198
234
 
199
235
  return {
200
236
  documentXml,
201
237
  relationships: state.relationships,
202
- paragraphBoundaries,
238
+ paragraphBoundaries: shiftedBoundaries,
203
239
  };
204
240
  }
205
241
 
@@ -218,7 +254,7 @@ function serializeTableNode(
218
254
  const gridXml =
219
255
  table.gridColumns.length > 0
220
256
  ? `<w:tblGrid>${table.gridColumns
221
- .map((width) => `<w:gridCol w:w="${width}"/>`)
257
+ .map((width) => `<w:gridCol w:w="${twip(width)}"/>`)
222
258
  .join("")}</w:tblGrid>`
223
259
  : "";
224
260
  const rowsXml = table.rows
@@ -335,7 +371,9 @@ function buildSdtPropertiesXml(block: SdtNode): string {
335
371
  if (block.properties.checkbox) {
336
372
  const cb = block.properties.checkbox;
337
373
  const cbParts: string[] = [];
338
- cbParts.push(`<w14:checked w14:val="${cb.checked ? "1" : "0"}"/>`);
374
+ // ST_OnOff (A.3): w14:val is typed as ST_OnOff; emit "true"/"false"
375
+ // instead of "1"/"0" to satisfy DocumentFormat.OpenXml validation.
376
+ cbParts.push(`<w14:checked w14:val="${cb.checked ? "true" : "false"}"/>`);
339
377
  if (cb.checkedChar) cbParts.push(`<w14:checkedState w14:val="${escapeAttribute(cb.checkedChar)}"/>`);
340
378
  if (cb.uncheckedChar) cbParts.push(`<w14:uncheckedState w14:val="${escapeAttribute(cb.uncheckedChar)}"/>`);
341
379
  children.push(`<w14:checkbox>${cbParts.join("")}</w14:checkbox>`);
@@ -372,7 +410,7 @@ function serializeTableCellParagraph(
372
410
  paragraph: ParagraphNode,
373
411
  state: SerializationState,
374
412
  ): string {
375
- let xml = "<w:p>";
413
+ let xml = buildParagraphOpenTag(paragraph, state);
376
414
  const paragraphPropertiesXml = buildParagraphPropertiesXml(paragraph);
377
415
  if (paragraphPropertiesXml.length > 0) {
378
416
  xml += paragraphPropertiesXml;
@@ -383,6 +421,80 @@ function serializeTableCellParagraph(
383
421
  return xml;
384
422
  }
385
423
 
424
+ /**
425
+ * A.7: build the paragraph opening tag. When the canonical paragraph
426
+ * carries a preserved `wordExtensionIds`, re-emit `w14:paraId` /
427
+ * `w14:textId` and register them in the per-document dedupe set.
428
+ * If the incoming paraId collides with a previously-emitted one,
429
+ * mint a fresh 8-hex uppercase id (deterministic via a state counter).
430
+ * Paragraphs without an id are left unchanged — A.1 + A.7 only force
431
+ * the namespace to declare when at least one paragraph actually carries
432
+ * the attribute.
433
+ */
434
+ function buildParagraphOpenTag(
435
+ paragraph: ParagraphNode,
436
+ state: SerializationState,
437
+ ): string {
438
+ const preserved = paragraph.wordExtensionIds;
439
+ if (!preserved || (!preserved.paraId && !preserved.textId)) {
440
+ return "<w:p>";
441
+ }
442
+ const attrs: string[] = [];
443
+ if (preserved.paraId) {
444
+ const paraId = ensureUniqueExtensionId(
445
+ preserved.paraId,
446
+ state.usedParaIds,
447
+ state,
448
+ );
449
+ state.usedParaIds.add(paraId);
450
+ attrs.push(` w14:paraId="${paraId}"`);
451
+ }
452
+ if (preserved.textId) {
453
+ const textId = ensureUniqueExtensionId(
454
+ preserved.textId,
455
+ state.usedTextIds,
456
+ state,
457
+ );
458
+ state.usedTextIds.add(textId);
459
+ attrs.push(` w14:textId="${textId}"`);
460
+ }
461
+ return `<w:p${attrs.join("")}>`;
462
+ }
463
+
464
+ function ensureUniqueExtensionId(
465
+ candidate: string,
466
+ used: Set<string>,
467
+ state: SerializationState,
468
+ ): string {
469
+ const normalized = normalizeExtensionId(candidate);
470
+ if (normalized && !used.has(normalized)) {
471
+ return normalized;
472
+ }
473
+ return mintExtensionId(state);
474
+ }
475
+
476
+ function normalizeExtensionId(raw: string): string {
477
+ // ECMA-376 expects 8 uppercase hex chars. Be generous on import (Word has
478
+ // been known to emit 7-char ids) but re-canonicalise on export.
479
+ const hex = raw.trim().replace(/[^0-9A-Fa-f]/gu, "").toUpperCase();
480
+ if (hex.length === 8) return hex;
481
+ if (hex.length === 0) return "";
482
+ if (hex.length < 8) return hex.padStart(8, "0");
483
+ return hex.slice(0, 8);
484
+ }
485
+
486
+ function mintExtensionId(state: SerializationState): string {
487
+ // Deterministic — keeps snapshot tests stable. The seed is the counter
488
+ // shifted into the upper nibble so collisions with the canonical
489
+ // monotonic id space used by authoring tools stay rare.
490
+ state.mintedParaIdCounter += 1;
491
+ const hex = ((0xC0DE0000 + state.mintedParaIdCounter) >>> 0)
492
+ .toString(16)
493
+ .toUpperCase()
494
+ .padStart(8, "0");
495
+ return hex;
496
+ }
497
+
386
498
  function serializeTableInlineNode(
387
499
  node: InlineNode,
388
500
  state: SerializationState,
@@ -501,26 +613,31 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
501
613
  if (paragraph.spacing) {
502
614
  const s = paragraph.spacing;
503
615
  const attrs: string[] = [];
504
- if (s.before !== undefined) attrs.push(`w:before="${s.before}"`);
505
- if (s.after !== undefined) attrs.push(`w:after="${s.after}"`);
506
- if (s.line !== undefined) attrs.push(`w:line="${s.line}"`);
616
+ if (s.before !== undefined) attrs.push(`w:before="${twip(s.before)}"`);
617
+ if (s.after !== undefined) attrs.push(`w:after="${twip(s.after)}"`);
618
+ if (s.line !== undefined) attrs.push(`w:line="${twip(s.line)}"`);
507
619
  if (s.lineRule !== undefined) attrs.push(`w:lineRule="${s.lineRule}"`);
508
620
  if (attrs.length > 0) children.push(`<w:spacing ${attrs.join(" ")}/>`);
509
621
  }
510
- if (paragraph.contextualSpacing !== undefined) {
511
- children.push(
512
- paragraph.contextualSpacing
513
- ? "<w:contextualSpacing/>"
514
- : `<w:contextualSpacing w:val="0"/>`,
515
- );
622
+ // ST_OnOff element (A.3):
623
+ // - true → <w:contextualSpacing/>.
624
+ // - false → <w:contextualSpacing w:val="false"/> (explicit override —
625
+ // paragraph-level false overrides a potentially-true style
626
+ // default; canonical false is preserved byte-for-byte with
627
+ // "false" not "0" per A.3).
628
+ // - undefined → omit.
629
+ if (paragraph.contextualSpacing === true) {
630
+ children.push("<w:contextualSpacing/>");
631
+ } else if (paragraph.contextualSpacing === false) {
632
+ children.push(`<w:contextualSpacing w:val="false"/>`);
516
633
  }
517
634
  if (paragraph.indentation) {
518
635
  const ind = paragraph.indentation;
519
636
  const attrs: string[] = [];
520
- if (ind.left !== undefined) attrs.push(`w:left="${ind.left}"`);
521
- if (ind.right !== undefined) attrs.push(`w:right="${ind.right}"`);
522
- if (ind.firstLine !== undefined) attrs.push(`w:firstLine="${ind.firstLine}"`);
523
- if (ind.hanging !== undefined) attrs.push(`w:hanging="${ind.hanging}"`);
637
+ if (ind.left !== undefined) attrs.push(`w:left="${twip(ind.left)}"`);
638
+ if (ind.right !== undefined) attrs.push(`w:right="${twip(ind.right)}"`);
639
+ if (ind.firstLine !== undefined) attrs.push(`w:firstLine="${twip(ind.firstLine)}"`);
640
+ if (ind.hanging !== undefined) attrs.push(`w:hanging="${twip(ind.hanging)}"`);
524
641
  if (attrs.length > 0) children.push(`<w:ind ${attrs.join(" ")}/>`);
525
642
  }
526
643
  if (paragraph.alignment) {
@@ -550,7 +667,7 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
550
667
  if (paragraph.tabStops && paragraph.tabStops.length > 0) {
551
668
  const tabsXml = paragraph.tabStops.map((tab) => {
552
669
  const leaderAttr = tab.leader ? ` w:leader="${tab.leader}"` : "";
553
- return `<w:tab w:val="${tab.align}" w:pos="${tab.position}"${leaderAttr}/>`;
670
+ return `<w:tab w:val="${tab.align}" w:pos="${twip(tab.position)}"${leaderAttr}/>`;
554
671
  }).join("");
555
672
  children.push(`<w:tabs>${tabsXml}</w:tabs>`);
556
673
  }
@@ -590,8 +707,8 @@ function serializeBorder(name: string, border: BorderSpec | undefined): string {
590
707
  }
591
708
  const attrs: string[] = [];
592
709
  if (border.value) attrs.push(`w:val="${escapeAttribute(border.value)}"`);
593
- if (border.size !== undefined) attrs.push(`w:sz="${border.size}"`);
594
- if (border.space !== undefined) attrs.push(`w:space="${border.space}"`);
710
+ if (border.size !== undefined) attrs.push(`w:sz="${twip(border.size)}"`);
711
+ if (border.space !== undefined) attrs.push(`w:space="${twip(border.space)}"`);
595
712
  if (border.color) attrs.push(`w:color="${escapeAttribute(border.color)}"`);
596
713
  return attrs.length > 0 ? `<w:${name} ${attrs.join(" ")}/>` : "";
597
714
  }
@@ -603,7 +720,14 @@ function serializeParagraphShading(shading: ParagraphNode["shading"]): string {
603
720
  const attrs: string[] = [];
604
721
  if (shading.val) attrs.push(`w:val="${escapeAttribute(shading.val)}"`);
605
722
  if (shading.color) attrs.push(`w:color="${escapeAttribute(shading.color)}"`);
606
- if (shading.fill) attrs.push(`w:fill="${escapeAttribute(shading.fill)}"`);
723
+ // A.9: w:shd w:val="clear" must always carry w:fill. Emit w:fill="auto"
724
+ // when the canonical model has no explicit fill; otherwise the SDK
725
+ // flags the shading as incomplete.
726
+ if (shading.fill !== undefined) {
727
+ attrs.push(`w:fill="${escapeAttribute(shading.fill)}"`);
728
+ } else if (shading.val === "clear") {
729
+ attrs.push(`w:fill="auto"`);
730
+ }
607
731
  return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
608
732
  }
609
733
 
@@ -617,7 +741,7 @@ function serializeParagraph(
617
741
  cursor: number,
618
742
  paragraphIndex: number,
619
743
  ): ParagraphSerializationResult {
620
- let xml = "<w:p>";
744
+ let xml = buildParagraphOpenTag(paragraph, state);
621
745
  const boundaries = new Map<number, number>();
622
746
  const paragraphStart = 0;
623
747
  const paragraphStartTagEnd = xml.length;
@@ -1139,20 +1263,169 @@ function offsetParagraphBoundary(
1139
1263
  };
1140
1264
  }
1141
1265
 
1266
+ function assertUniqueBookmarkIdsOrWarn(documentXml: string): void {
1267
+ const proc = (globalThis as unknown as {
1268
+ process?: { env?: Record<string, string | undefined> };
1269
+ }).process;
1270
+ if (proc?.env?.NODE_ENV === "production") {
1271
+ return;
1272
+ }
1273
+ const seen = new Set<string>();
1274
+ const pattern = /<w:bookmarkStart\b[^>]*\bw:id="([^"]+)"/gu;
1275
+ let match: RegExpExecArray | null;
1276
+ while ((match = pattern.exec(documentXml)) !== null) {
1277
+ const id = match[1] ?? "";
1278
+ if (seen.has(id)) {
1279
+ console.warn(
1280
+ `[serialize-main-document] duplicate w:bookmarkStart w:id="${id}" — call detectDuplicateBookmarkIds() during import and re-key in-place before export (§2 A.4 guard).`,
1281
+ );
1282
+ return;
1283
+ }
1284
+ seen.add(id);
1285
+ }
1286
+ }
1287
+
1288
+ function assertNoNonFiniteNumbers(documentXml: string): void {
1289
+ // Match `="NaN"` / `="Infinity"` / `="-Infinity"` inside any attribute value.
1290
+ const pattern = /="(NaN|-?Infinity)"/u;
1291
+ const match = pattern.exec(documentXml);
1292
+ if (match) {
1293
+ const around = documentXml.slice(
1294
+ Math.max(0, (match.index ?? 0) - 60),
1295
+ Math.min(documentXml.length, (match.index ?? 0) + 120),
1296
+ );
1297
+ throw new Error(
1298
+ `serializeMainDocument: non-finite numeric (${match[1]}) leaked into the serialized XML near: ${around}`,
1299
+ );
1300
+ }
1301
+ }
1302
+
1303
+ const EXTENSION_NAMESPACE_URIS: Readonly<Record<string, string>> = {
1304
+ w14: "http://schemas.microsoft.com/office/word/2010/wordml",
1305
+ w15: "http://schemas.microsoft.com/office/word/2012/wordml",
1306
+ w16: "http://schemas.microsoft.com/office/word/2018/wordml",
1307
+ w16cex: "http://schemas.microsoft.com/office/word/2018/wordml/cex",
1308
+ w16cid: "http://schemas.microsoft.com/office/word/2016/wordml/cid",
1309
+ w16se: "http://schemas.microsoft.com/office/word/2015/wordml/symex",
1310
+ w16sdtdh: "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash",
1311
+ w16sdtfl: "http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock",
1312
+ w16du: "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
1313
+ wp14: "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
1314
+ };
1315
+
1316
+ const MC_IGNORABLE_PRIORITY: readonly string[] = [
1317
+ "w14",
1318
+ "w15",
1319
+ "w16se",
1320
+ "w16cid",
1321
+ "w16",
1322
+ "w16cex",
1323
+ "w16sdtdh",
1324
+ "w16sdtfl",
1325
+ "w16du",
1326
+ "wp14",
1327
+ ];
1328
+
1329
+ const NON_EXTENSION_PREFIXES = new Set([
1330
+ "w",
1331
+ "r",
1332
+ "mc",
1333
+ "xml",
1334
+ "xsi",
1335
+ "xmlns",
1336
+ "v",
1337
+ "o",
1338
+ "pic",
1339
+ "a",
1340
+ "a14",
1341
+ "ve",
1342
+ "m",
1343
+ ]);
1344
+
1345
+ function collectExtensionAliases(
1346
+ bodyAndSectionXml: string,
1347
+ content?: DocumentRootNode,
1348
+ ): readonly string[] {
1349
+ const discovered = new Set<string>();
1350
+ // Walk element / attribute prefixes. `<w14:paraId`, ` w14:paraId=`, etc.
1351
+ const prefixPattern = /(?:<|\s)([A-Za-z][A-Za-z0-9]*):[A-Za-z]/gu;
1352
+ let match: RegExpExecArray | null;
1353
+ while ((match = prefixPattern.exec(bodyAndSectionXml)) !== null) {
1354
+ const alias = match[1] ?? "";
1355
+ if (!alias || NON_EXTENSION_PREFIXES.has(alias)) continue;
1356
+ if (alias in EXTENSION_NAMESPACE_URIS) {
1357
+ discovered.add(alias);
1358
+ }
1359
+ }
1360
+ // Legacy textFill/sdt-checkbox signals emit w14 even when attribute text is absent
1361
+ // from the assembled body (defensive; safe to add when scanning missed it).
1362
+ if (content && documentNeedsW14Namespace(content)) {
1363
+ discovered.add("w14");
1364
+ }
1365
+ return MC_IGNORABLE_PRIORITY.filter((alias) => discovered.has(alias));
1366
+ }
1367
+
1142
1368
  function serializeDocumentAttributes(
1143
1369
  attributes: Record<string, string> | undefined,
1144
1370
  content?: DocumentRootNode,
1371
+ extensionAliases: readonly string[] = [],
1145
1372
  ): string {
1146
- const merged = {
1373
+ const extensionXmlns: Record<string, string> = {};
1374
+ for (const alias of extensionAliases) {
1375
+ const uri = EXTENSION_NAMESPACE_URIS[alias];
1376
+ if (uri) extensionXmlns[`xmlns:${alias}`] = uri;
1377
+ }
1378
+
1379
+ const merged: Record<string, string> = {
1147
1380
  "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
1148
1381
  "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
1149
- ...(content && documentNeedsW14Namespace(content)
1150
- ? { "xmlns:w14": "http://schemas.microsoft.com/office/word/2010/wordml" }
1151
- : {}),
1382
+ ...extensionXmlns,
1152
1383
  ...(attributes ?? {}),
1153
1384
  };
1154
1385
 
1155
- return Object.entries(merged)
1386
+ // mc:Ignorable declares extension-namespace aliases that downstream readers
1387
+ // may skip without dropping the document. Only emit when aliases are in play.
1388
+ if (extensionAliases.length > 0) {
1389
+ merged["xmlns:mc"] =
1390
+ merged["xmlns:mc"] ??
1391
+ "http://schemas.openxmlformats.org/markup-compatibility/2006";
1392
+ merged["mc:Ignorable"] = extensionAliases.join(" ");
1393
+ }
1394
+
1395
+ return renderDeterministicRootAttributes(merged);
1396
+ }
1397
+
1398
+ /**
1399
+ * A.8: emit root attributes in a stable order so identical canonical
1400
+ * inputs produce byte-identical XML (fixture-stable diffs, deterministic
1401
+ * caching). Order:
1402
+ * 1. xmlns:* declarations, alphabetised by local name.
1403
+ * 2. mc:Ignorable (only emitted when extensionAliases was non-empty).
1404
+ * 3. Remaining attributes, alphabetised.
1405
+ */
1406
+ export function renderDeterministicRootAttributes(
1407
+ attrs: Record<string, string>,
1408
+ ): string {
1409
+ const xmlnsEntries: Array<[string, string]> = [];
1410
+ const ignorableEntries: Array<[string, string]> = [];
1411
+ const otherEntries: Array<[string, string]> = [];
1412
+ for (const [name, value] of Object.entries(attrs)) {
1413
+ if (name === "mc:Ignorable") {
1414
+ ignorableEntries.push([name, value]);
1415
+ } else if (name.startsWith("xmlns:") || name === "xmlns") {
1416
+ xmlnsEntries.push([name, value]);
1417
+ } else {
1418
+ otherEntries.push([name, value]);
1419
+ }
1420
+ }
1421
+ xmlnsEntries.sort((a, b) => a[0].localeCompare(b[0]));
1422
+ otherEntries.sort((a, b) => a[0].localeCompare(b[0]));
1423
+ const ordered: Array<[string, string]> = [
1424
+ ...xmlnsEntries,
1425
+ ...ignorableEntries,
1426
+ ...otherEntries,
1427
+ ];
1428
+ return ordered
1156
1429
  .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
1157
1430
  .join("");
1158
1431
  }
@@ -1181,7 +1454,7 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1181
1454
 
1182
1455
  // Page size
1183
1456
  if (props.pageSize) {
1184
- let pgSz = `<w:pgSz w:w="${props.pageSize.width}" w:h="${props.pageSize.height}"`;
1457
+ let pgSz = `<w:pgSz w:w="${twip(props.pageSize.width)}" w:h="${twip(props.pageSize.height)}"`;
1185
1458
  if (props.pageSize.orientation) {
1186
1459
  pgSz += ` w:orient="${props.pageSize.orientation}"`;
1187
1460
  }
@@ -1191,10 +1464,12 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1191
1464
 
1192
1465
  // Page margins
1193
1466
  if (props.pageMargins) {
1194
- let pgMar = `<w:pgMar w:top="${props.pageMargins.top}" w:right="${props.pageMargins.right}" w:bottom="${props.pageMargins.bottom}" w:left="${props.pageMargins.left}"`;
1195
- if (props.pageMargins.header !== undefined) pgMar += ` w:header="${props.pageMargins.header}"`;
1196
- if (props.pageMargins.footer !== undefined) pgMar += ` w:footer="${props.pageMargins.footer}"`;
1197
- if (props.pageMargins.gutter !== undefined) pgMar += ` w:gutter="${props.pageMargins.gutter}"`;
1467
+ let pgMar = `<w:pgMar w:top="${twip(props.pageMargins.top)}" w:right="${twip(
1468
+ props.pageMargins.right,
1469
+ )}" w:bottom="${twip(props.pageMargins.bottom)}" w:left="${twip(props.pageMargins.left)}"`;
1470
+ if (props.pageMargins.header !== undefined) pgMar += ` w:header="${twip(props.pageMargins.header)}"`;
1471
+ if (props.pageMargins.footer !== undefined) pgMar += ` w:footer="${twip(props.pageMargins.footer)}"`;
1472
+ if (props.pageMargins.gutter !== undefined) pgMar += ` w:gutter="${twip(props.pageMargins.gutter)}"`;
1198
1473
  pgMar += "/>";
1199
1474
  children.push(pgMar);
1200
1475
  }
@@ -1202,15 +1477,18 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1202
1477
  // Columns
1203
1478
  if (props.columns) {
1204
1479
  let cols = "<w:cols";
1205
- if (props.columns.count !== undefined) cols += ` w:num="${props.columns.count}"`;
1206
- if (props.columns.space !== undefined) cols += ` w:space="${props.columns.space}"`;
1207
- if (props.columns.equalWidth !== undefined) cols += ` w:equalWidth="${props.columns.equalWidth ? "1" : "0"}"`;
1480
+ if (props.columns.count !== undefined) cols += ` w:num="${twip(props.columns.count)}"`;
1481
+ if (props.columns.space !== undefined) cols += ` w:space="${twip(props.columns.space)}"`;
1482
+ if (props.columns.equalWidth !== undefined) {
1483
+ // ST_OnOff (A.3): emit "true"/"false", never "1"/"0".
1484
+ cols += ` w:equalWidth="${props.columns.equalWidth ? "true" : "false"}"`;
1485
+ }
1208
1486
  if (props.columns.separator) cols += ` w:sep="1"`;
1209
1487
  if (props.columns.columns && props.columns.columns.length > 0) {
1210
1488
  cols += ">";
1211
1489
  for (const col of props.columns.columns) {
1212
- let colXml = `<w:col w:w="${col.width}"`;
1213
- if (col.space !== undefined) colXml += ` w:space="${col.space}"`;
1490
+ let colXml = `<w:col w:w="${twip(col.width)}"`;
1491
+ if (col.space !== undefined) colXml += ` w:space="${twip(col.space)}"`;
1214
1492
  colXml += "/>";
1215
1493
  cols += colXml;
1216
1494
  }
@@ -1225,7 +1503,7 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1225
1503
  if (props.pageNumbering) {
1226
1504
  let pgNum = "<w:pgNumType";
1227
1505
  if (props.pageNumbering.format) pgNum += ` w:fmt="${escapeAttribute(props.pageNumbering.format)}"`;
1228
- if (props.pageNumbering.start !== undefined) pgNum += ` w:start="${props.pageNumbering.start}"`;
1506
+ if (props.pageNumbering.start !== undefined) pgNum += ` w:start="${twip(props.pageNumbering.start)}"`;
1229
1507
  if (props.pageNumbering.chapStyle) pgNum += ` w:chapStyle="${escapeAttribute(props.pageNumbering.chapStyle)}"`;
1230
1508
  if (props.pageNumbering.chapSep) pgNum += ` w:chapSep="${escapeAttribute(props.pageNumbering.chapSep)}"`;
1231
1509
  pgNum += "/>";
@@ -1235,13 +1513,13 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1235
1513
  if (props.lineNumbering) {
1236
1514
  let lineNumbering = "<w:lnNumType";
1237
1515
  if (props.lineNumbering.countBy !== undefined) {
1238
- lineNumbering += ` w:countBy="${props.lineNumbering.countBy}"`;
1516
+ lineNumbering += ` w:countBy="${twip(props.lineNumbering.countBy)}"`;
1239
1517
  }
1240
1518
  if (props.lineNumbering.start !== undefined) {
1241
- lineNumbering += ` w:start="${props.lineNumbering.start}"`;
1519
+ lineNumbering += ` w:start="${twip(props.lineNumbering.start)}"`;
1242
1520
  }
1243
1521
  if (props.lineNumbering.distance !== undefined) {
1244
- lineNumbering += ` w:distance="${props.lineNumbering.distance}"`;
1522
+ lineNumbering += ` w:distance="${twip(props.lineNumbering.distance)}"`;
1245
1523
  }
1246
1524
  if (props.lineNumbering.restart) {
1247
1525
  lineNumbering += ` w:restart="${escapeAttribute(props.lineNumbering.restart)}"`;