@beyondwork/docx-react-component 1.0.35 → 1.0.37

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 (65) 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 +84 -1
  5. package/src/core/commands/index.ts +19 -2
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +178 -16
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/session-capabilities.ts +7 -4
  44. package/src/runtime/surface-projection.ts +1 -0
  45. package/src/runtime/text-ack-range.ts +49 -0
  46. package/src/ui/WordReviewEditor.tsx +15 -0
  47. package/src/ui/editor-runtime-boundary.ts +10 -1
  48. package/src/ui/editor-surface-controller.tsx +3 -0
  49. package/src/ui/headless/chrome-registry.ts +235 -0
  50. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  51. package/src/ui/headless/selection-tool-context.ts +2 -0
  52. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  53. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  54. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  57. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  58. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  60. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  62. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  63. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  64. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  65. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -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,178 @@ function offsetParagraphBoundary(
1139
1263
  };
1140
1264
  }
1141
1265
 
1266
+ /**
1267
+ * Detect a production build without referencing the Node-only `process`
1268
+ * global directly (the production tsconfig excludes @types/node). Returns
1269
+ * true when NODE_ENV === "production"; otherwise false (browser or dev).
1270
+ */
1271
+ function isProductionEnvironment(): boolean {
1272
+ const proc = (globalThis as unknown as {
1273
+ process?: { env?: Record<string, string | undefined> };
1274
+ }).process;
1275
+ return proc?.env?.NODE_ENV === "production";
1276
+ }
1277
+
1278
+ function assertUniqueBookmarkIdsOrWarn(documentXml: string): void {
1279
+ if (isProductionEnvironment()) {
1280
+ return;
1281
+ }
1282
+ const seen = new Set<string>();
1283
+ const pattern = /<w:bookmarkStart\b[^>]*\bw:id="([^"]+)"/gu;
1284
+ let match: RegExpExecArray | null;
1285
+ while ((match = pattern.exec(documentXml)) !== null) {
1286
+ const id = match[1] ?? "";
1287
+ if (seen.has(id)) {
1288
+ console.warn(
1289
+ `[serialize-main-document] duplicate w:bookmarkStart w:id="${id}" — call detectDuplicateBookmarkIds() during import and re-key in-place before export (§2 A.4 guard).`,
1290
+ );
1291
+ return;
1292
+ }
1293
+ seen.add(id);
1294
+ }
1295
+ }
1296
+
1297
+ function assertNoNonFiniteNumbers(documentXml: string): void {
1298
+ // Match `="NaN"` / `="Infinity"` / `="-Infinity"` inside any attribute value.
1299
+ const pattern = /="(NaN|-?Infinity)"/u;
1300
+ const match = pattern.exec(documentXml);
1301
+ if (match) {
1302
+ const around = documentXml.slice(
1303
+ Math.max(0, (match.index ?? 0) - 60),
1304
+ Math.min(documentXml.length, (match.index ?? 0) + 120),
1305
+ );
1306
+ throw new Error(
1307
+ `serializeMainDocument: non-finite numeric (${match[1]}) leaked into the serialized XML near: ${around}`,
1308
+ );
1309
+ }
1310
+ }
1311
+
1312
+ const EXTENSION_NAMESPACE_URIS: Readonly<Record<string, string>> = {
1313
+ w14: "http://schemas.microsoft.com/office/word/2010/wordml",
1314
+ w15: "http://schemas.microsoft.com/office/word/2012/wordml",
1315
+ w16: "http://schemas.microsoft.com/office/word/2018/wordml",
1316
+ w16cex: "http://schemas.microsoft.com/office/word/2018/wordml/cex",
1317
+ w16cid: "http://schemas.microsoft.com/office/word/2016/wordml/cid",
1318
+ w16se: "http://schemas.microsoft.com/office/word/2015/wordml/symex",
1319
+ w16sdtdh: "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash",
1320
+ w16sdtfl: "http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock",
1321
+ w16du: "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
1322
+ wp14: "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
1323
+ };
1324
+
1325
+ const MC_IGNORABLE_PRIORITY: readonly string[] = [
1326
+ "w14",
1327
+ "w15",
1328
+ "w16se",
1329
+ "w16cid",
1330
+ "w16",
1331
+ "w16cex",
1332
+ "w16sdtdh",
1333
+ "w16sdtfl",
1334
+ "w16du",
1335
+ "wp14",
1336
+ ];
1337
+
1338
+ const NON_EXTENSION_PREFIXES = new Set([
1339
+ "w",
1340
+ "r",
1341
+ "mc",
1342
+ "xml",
1343
+ "xsi",
1344
+ "xmlns",
1345
+ "v",
1346
+ "o",
1347
+ "pic",
1348
+ "a",
1349
+ "a14",
1350
+ "ve",
1351
+ "m",
1352
+ ]);
1353
+
1354
+ function collectExtensionAliases(
1355
+ bodyAndSectionXml: string,
1356
+ content?: DocumentRootNode,
1357
+ ): readonly string[] {
1358
+ const discovered = new Set<string>();
1359
+ // Walk element / attribute prefixes. `<w14:paraId`, ` w14:paraId=`, etc.
1360
+ const prefixPattern = /(?:<|\s)([A-Za-z][A-Za-z0-9]*):[A-Za-z]/gu;
1361
+ let match: RegExpExecArray | null;
1362
+ while ((match = prefixPattern.exec(bodyAndSectionXml)) !== null) {
1363
+ const alias = match[1] ?? "";
1364
+ if (!alias || NON_EXTENSION_PREFIXES.has(alias)) continue;
1365
+ if (alias in EXTENSION_NAMESPACE_URIS) {
1366
+ discovered.add(alias);
1367
+ }
1368
+ }
1369
+ // Legacy textFill/sdt-checkbox signals emit w14 even when attribute text is absent
1370
+ // from the assembled body (defensive; safe to add when scanning missed it).
1371
+ if (content && documentNeedsW14Namespace(content)) {
1372
+ discovered.add("w14");
1373
+ }
1374
+ return MC_IGNORABLE_PRIORITY.filter((alias) => discovered.has(alias));
1375
+ }
1376
+
1142
1377
  function serializeDocumentAttributes(
1143
1378
  attributes: Record<string, string> | undefined,
1144
1379
  content?: DocumentRootNode,
1380
+ extensionAliases: readonly string[] = [],
1145
1381
  ): string {
1146
- const merged = {
1382
+ const extensionXmlns: Record<string, string> = {};
1383
+ for (const alias of extensionAliases) {
1384
+ const uri = EXTENSION_NAMESPACE_URIS[alias];
1385
+ if (uri) extensionXmlns[`xmlns:${alias}`] = uri;
1386
+ }
1387
+
1388
+ const merged: Record<string, string> = {
1147
1389
  "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
1148
1390
  "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
- : {}),
1391
+ ...extensionXmlns,
1152
1392
  ...(attributes ?? {}),
1153
1393
  };
1154
1394
 
1155
- return Object.entries(merged)
1395
+ // mc:Ignorable declares extension-namespace aliases that downstream readers
1396
+ // may skip without dropping the document. Only emit when aliases are in play.
1397
+ if (extensionAliases.length > 0) {
1398
+ merged["xmlns:mc"] =
1399
+ merged["xmlns:mc"] ??
1400
+ "http://schemas.openxmlformats.org/markup-compatibility/2006";
1401
+ merged["mc:Ignorable"] = extensionAliases.join(" ");
1402
+ }
1403
+
1404
+ return renderDeterministicRootAttributes(merged);
1405
+ }
1406
+
1407
+ /**
1408
+ * A.8: emit root attributes in a stable order so identical canonical
1409
+ * inputs produce byte-identical XML (fixture-stable diffs, deterministic
1410
+ * caching). Order:
1411
+ * 1. xmlns:* declarations, alphabetised by local name.
1412
+ * 2. mc:Ignorable (only emitted when extensionAliases was non-empty).
1413
+ * 3. Remaining attributes, alphabetised.
1414
+ */
1415
+ export function renderDeterministicRootAttributes(
1416
+ attrs: Record<string, string>,
1417
+ ): string {
1418
+ const xmlnsEntries: Array<[string, string]> = [];
1419
+ const ignorableEntries: Array<[string, string]> = [];
1420
+ const otherEntries: Array<[string, string]> = [];
1421
+ for (const [name, value] of Object.entries(attrs)) {
1422
+ if (name === "mc:Ignorable") {
1423
+ ignorableEntries.push([name, value]);
1424
+ } else if (name.startsWith("xmlns:") || name === "xmlns") {
1425
+ xmlnsEntries.push([name, value]);
1426
+ } else {
1427
+ otherEntries.push([name, value]);
1428
+ }
1429
+ }
1430
+ xmlnsEntries.sort((a, b) => a[0].localeCompare(b[0]));
1431
+ otherEntries.sort((a, b) => a[0].localeCompare(b[0]));
1432
+ const ordered: Array<[string, string]> = [
1433
+ ...xmlnsEntries,
1434
+ ...ignorableEntries,
1435
+ ...otherEntries,
1436
+ ];
1437
+ return ordered
1156
1438
  .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
1157
1439
  .join("");
1158
1440
  }
@@ -1181,7 +1463,7 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1181
1463
 
1182
1464
  // Page size
1183
1465
  if (props.pageSize) {
1184
- let pgSz = `<w:pgSz w:w="${props.pageSize.width}" w:h="${props.pageSize.height}"`;
1466
+ let pgSz = `<w:pgSz w:w="${twip(props.pageSize.width)}" w:h="${twip(props.pageSize.height)}"`;
1185
1467
  if (props.pageSize.orientation) {
1186
1468
  pgSz += ` w:orient="${props.pageSize.orientation}"`;
1187
1469
  }
@@ -1191,10 +1473,12 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1191
1473
 
1192
1474
  // Page margins
1193
1475
  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}"`;
1476
+ let pgMar = `<w:pgMar w:top="${twip(props.pageMargins.top)}" w:right="${twip(
1477
+ props.pageMargins.right,
1478
+ )}" w:bottom="${twip(props.pageMargins.bottom)}" w:left="${twip(props.pageMargins.left)}"`;
1479
+ if (props.pageMargins.header !== undefined) pgMar += ` w:header="${twip(props.pageMargins.header)}"`;
1480
+ if (props.pageMargins.footer !== undefined) pgMar += ` w:footer="${twip(props.pageMargins.footer)}"`;
1481
+ if (props.pageMargins.gutter !== undefined) pgMar += ` w:gutter="${twip(props.pageMargins.gutter)}"`;
1198
1482
  pgMar += "/>";
1199
1483
  children.push(pgMar);
1200
1484
  }
@@ -1202,15 +1486,18 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1202
1486
  // Columns
1203
1487
  if (props.columns) {
1204
1488
  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"}"`;
1489
+ if (props.columns.count !== undefined) cols += ` w:num="${twip(props.columns.count)}"`;
1490
+ if (props.columns.space !== undefined) cols += ` w:space="${twip(props.columns.space)}"`;
1491
+ if (props.columns.equalWidth !== undefined) {
1492
+ // ST_OnOff (A.3): emit "true"/"false", never "1"/"0".
1493
+ cols += ` w:equalWidth="${props.columns.equalWidth ? "true" : "false"}"`;
1494
+ }
1208
1495
  if (props.columns.separator) cols += ` w:sep="1"`;
1209
1496
  if (props.columns.columns && props.columns.columns.length > 0) {
1210
1497
  cols += ">";
1211
1498
  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}"`;
1499
+ let colXml = `<w:col w:w="${twip(col.width)}"`;
1500
+ if (col.space !== undefined) colXml += ` w:space="${twip(col.space)}"`;
1214
1501
  colXml += "/>";
1215
1502
  cols += colXml;
1216
1503
  }
@@ -1225,7 +1512,7 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1225
1512
  if (props.pageNumbering) {
1226
1513
  let pgNum = "<w:pgNumType";
1227
1514
  if (props.pageNumbering.format) pgNum += ` w:fmt="${escapeAttribute(props.pageNumbering.format)}"`;
1228
- if (props.pageNumbering.start !== undefined) pgNum += ` w:start="${props.pageNumbering.start}"`;
1515
+ if (props.pageNumbering.start !== undefined) pgNum += ` w:start="${twip(props.pageNumbering.start)}"`;
1229
1516
  if (props.pageNumbering.chapStyle) pgNum += ` w:chapStyle="${escapeAttribute(props.pageNumbering.chapStyle)}"`;
1230
1517
  if (props.pageNumbering.chapSep) pgNum += ` w:chapSep="${escapeAttribute(props.pageNumbering.chapSep)}"`;
1231
1518
  pgNum += "/>";
@@ -1235,13 +1522,13 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1235
1522
  if (props.lineNumbering) {
1236
1523
  let lineNumbering = "<w:lnNumType";
1237
1524
  if (props.lineNumbering.countBy !== undefined) {
1238
- lineNumbering += ` w:countBy="${props.lineNumbering.countBy}"`;
1525
+ lineNumbering += ` w:countBy="${twip(props.lineNumbering.countBy)}"`;
1239
1526
  }
1240
1527
  if (props.lineNumbering.start !== undefined) {
1241
- lineNumbering += ` w:start="${props.lineNumbering.start}"`;
1528
+ lineNumbering += ` w:start="${twip(props.lineNumbering.start)}"`;
1242
1529
  }
1243
1530
  if (props.lineNumbering.distance !== undefined) {
1244
- lineNumbering += ` w:distance="${props.lineNumbering.distance}"`;
1531
+ lineNumbering += ` w:distance="${twip(props.lineNumbering.distance)}"`;
1245
1532
  }
1246
1533
  if (props.lineNumbering.restart) {
1247
1534
  lineNumbering += ` w:restart="${escapeAttribute(props.lineNumbering.restart)}"`;