@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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +402 -1
- package/src/core/commands/index.ts +18 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +815 -55
- 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 +328 -50
- package/src/io/export/serialize-numbering.ts +114 -24
- package/src/io/export/serialize-tables.ts +87 -11
- package/src/io/export/table-properties-xml.ts +174 -20
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +20 -0
- package/src/io/ooxml/parse-footnotes.ts +62 -1
- package/src/io/ooxml/parse-headers-footers.ts +62 -1
- package/src/io/ooxml/parse-main-document.ts +158 -1
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +45 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +2 -306
- package/src/runtime/document-runtime.ts +287 -11
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +233 -0
- package/src/runtime/layout/inert-layout-facet.ts +59 -0
- package/src/runtime/layout/layout-engine-instance.ts +628 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/margin-preset-catalog.ts +178 -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-format-catalog.ts +233 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +452 -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 +921 -0
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +1398 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/layout/table-render-plan.ts +229 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +755 -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/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +99 -15
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +501 -0
- package/src/ui/headless/scoped-chrome-policy.ts +183 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
- 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 +176 -6
- package/src/ui-tailwind/index.ts +33 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +505 -144
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
- 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
|
-
|
|
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,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
|
|
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
|
-
...
|
|
1150
|
-
? { "xmlns:w14": "http://schemas.microsoft.com/office/word/2010/wordml" }
|
|
1151
|
-
: {}),
|
|
1382
|
+
...extensionXmlns,
|
|
1152
1383
|
...(attributes ?? {}),
|
|
1153
1384
|
};
|
|
1154
1385
|
|
|
1155
|
-
|
|
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="${
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
if (props.pageMargins.
|
|
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)
|
|
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)}"`;
|