@beyondwork/docx-react-component 1.0.36 → 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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +83 -0
- package/src/core/commands/index.ts +18 -1
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +173 -11
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- 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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
...
|
|
1150
|
-
? { "xmlns:w14": "http://schemas.microsoft.com/office/word/2010/wordml" }
|
|
1151
|
-
: {}),
|
|
1391
|
+
...extensionXmlns,
|
|
1152
1392
|
...(attributes ?? {}),
|
|
1153
1393
|
};
|
|
1154
1394
|
|
|
1155
|
-
|
|
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="${
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
if (props.pageMargins.
|
|
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)
|
|
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)}"`;
|