@beyondwork/docx-react-component 1.0.60 → 1.0.62

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 (42) hide show
  1. package/package.json +33 -44
  2. package/src/api/public-types.ts +41 -0
  3. package/src/io/docx-session.ts +167 -8
  4. package/src/io/export/serialize-footnotes.ts +36 -5
  5. package/src/io/export/serialize-headers-footers.ts +7 -0
  6. package/src/io/export/serialize-main-document.ts +25 -18
  7. package/src/io/export/serialize-paragraph-formatting.ts +6 -0
  8. package/src/io/export/serialize-settings.ts +130 -3
  9. package/src/io/normalize/normalize-text.ts +8 -4
  10. package/src/io/ooxml/classify-embedding.ts +193 -0
  11. package/src/io/ooxml/parse-footnotes.ts +11 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  13. package/src/io/ooxml/parse-main-document.ts +20 -8
  14. package/src/io/ooxml/parse-object.ts +23 -0
  15. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  16. package/src/io/ooxml/parse-settings.ts +91 -1
  17. package/src/model/canonical-document.ts +36 -2
  18. package/src/runtime/document-runtime.ts +424 -0
  19. package/src/runtime/footnote-resolver.ts +32 -8
  20. package/src/runtime/layout/layout-engine-version.ts +7 -1
  21. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  22. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  23. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  24. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  25. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  26. package/src/runtime/numbering-prefix.ts +26 -2
  27. package/src/runtime/surface-projection.ts +75 -14
  28. package/src/runtime/table-schema.ts +26 -0
  29. package/src/ui/WordReviewEditor.tsx +25 -0
  30. package/src/ui/editor-runtime-boundary.ts +1 -0
  31. package/src/ui/editor-shell-view.tsx +8 -0
  32. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  33. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  34. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  35. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  36. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  37. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  38. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  39. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  40. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  42. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.60",
4
+ "version": "1.0.62",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
- "packageManager": "pnpm@10.30.3",
7
6
  "type": "module",
8
7
  "sideEffects": [
9
8
  "**/*.css"
@@ -93,38 +92,6 @@
93
92
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
94
93
  },
95
94
  "types": "./src/index.ts",
96
- "scripts": {
97
- "build": "tsup",
98
- "test": "bash scripts/run-workspace-tests.sh",
99
- "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
100
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
101
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
102
- "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
103
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
104
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
105
- "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
106
- "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
107
- "test:visual:redline": "VISUAL_SMOKE_PROFILE=redline-cycle pnpm exec playwright test --project=chromium",
108
- "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
109
- "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
110
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
111
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
112
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
113
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
114
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
115
- "generate:token-reference": "node scripts/generate-token-reference.mjs",
116
- "check:token-reference": "node scripts/generate-token-reference.mjs --check",
117
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
118
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
119
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
120
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
121
- "wave:launch:managed": "bash scripts/wave-launch.sh",
122
- "wave:status": "bash scripts/wave-status.sh",
123
- "wave:watch": "bash scripts/wave-watch.sh --follow",
124
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
125
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
126
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
127
- },
128
95
  "keywords": [
129
96
  "docx",
130
97
  "word",
@@ -208,14 +175,36 @@
208
175
  "y-protocols": "^1.0.7",
209
176
  "yjs": "^13.6.30"
210
177
  },
211
- "pnpm": {
212
- "onlyBuiltDependencies": [
213
- "esbuild",
214
- "sharp"
215
- ],
216
- "overrides": {
217
- "react": "19.2.4",
218
- "react-dom": "19.2.4"
219
- }
178
+ "scripts": {
179
+ "build": "tsup",
180
+ "test": "bash scripts/run-workspace-tests.sh",
181
+ "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
182
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
183
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
184
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
185
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
186
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
187
+ "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
188
+ "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
189
+ "test:visual:redline": "VISUAL_SMOKE_PROFILE=redline-cycle pnpm exec playwright test --project=chromium",
190
+ "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
191
+ "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
192
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
193
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
194
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
195
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
196
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
197
+ "generate:token-reference": "node scripts/generate-token-reference.mjs",
198
+ "check:token-reference": "node scripts/generate-token-reference.mjs --check",
199
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
200
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
201
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
202
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
203
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
204
+ "wave:status": "bash scripts/wave-status.sh",
205
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
206
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
207
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
208
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
220
209
  }
221
- }
210
+ }
@@ -524,6 +524,26 @@ export interface SetSelectionOptions {
524
524
  silent?: boolean;
525
525
  }
526
526
 
527
+ export interface ClearHighlightOptions {
528
+ /**
529
+ * Range to clear. Defaults to the current document selection when omitted.
530
+ * Only `{ kind: "range" }` anchors produce a clear; other anchor kinds
531
+ * (`"node"`, `"detached"`) and collapsed selections are no-ops unless
532
+ * `expand` widens them into a real range.
533
+ */
534
+ range?: EditorAnchorProjection;
535
+ /**
536
+ * When `true`, the resolved range is grown outward to cover the full
537
+ * contiguous highlighted span that touches it before clearing. A text
538
+ * segment counts as highlighted when `markAttrs.backgroundColor` is a
539
+ * non-empty string. Expansion scans each paragraph that overlaps the
540
+ * resolved range independently and stops at paragraph and table-cell
541
+ * boundaries — it will never leap into a neighboring paragraph or a
542
+ * different table cell. Defaults to `false`.
543
+ */
544
+ expandToFullHighlight?: boolean;
545
+ }
546
+
527
547
  export interface SearchResultSnapshot {
528
548
  resultId: string;
529
549
  anchor: EditorAnchorProjection;
@@ -1043,6 +1063,14 @@ export interface SurfaceTableCellSnapshot {
1043
1063
  borderRight?: string | null;
1044
1064
  borderBottom?: string | null;
1045
1065
  borderLeft?: string | null;
1066
+ /**
1067
+ * Effective cell padding in twips after applying the cell's direct `tcMar`
1068
+ * and then falling back per-side to table-level `tblCellMar`.
1069
+ */
1070
+ paddingTop?: number | null;
1071
+ paddingRight?: number | null;
1072
+ paddingBottom?: number | null;
1073
+ paddingLeft?: number | null;
1046
1074
  /**
1047
1075
  * R3.a Phase 2: per-cell text-flow direction copied from
1048
1076
  * `TableCellNode.textDirection`. The node-view maps these to CSS
@@ -4058,6 +4086,19 @@ export interface WordReviewEditorRef {
4058
4086
  setFontSize(size: number | null): void;
4059
4087
  setTextColor(color: string | null): void;
4060
4088
  setHighlightColor(color: string | null): void;
4089
+ /**
4090
+ * Clears the highlight (background color) on a range in the currently
4091
+ * active story. The caller's selection is NOT moved — the cursor stays
4092
+ * where the user left it.
4093
+ *
4094
+ * When `options.range` is omitted, the current document selection is used.
4095
+ *
4096
+ * Tracked-changes / suggesting mode is honored: in suggesting mode the
4097
+ * clear is recorded as a property-change suggestion when the resolved
4098
+ * range is one bounded text segment, and is blocked (with a
4099
+ * `command_blocked` event) when the range spans multiple segments.
4100
+ */
4101
+ clearHighlight(options?: ClearHighlightOptions): void;
4061
4102
  setAlignment(alignment: FormattingAlignment): void;
4062
4103
  setParagraphStyle(styleId: string | null): void;
4063
4104
  setTableStyle(styleId: string | null): void;
@@ -130,6 +130,7 @@ import type {
130
130
  FootnoteCollection,
131
131
  HeaderDocument,
132
132
  FooterDocument,
133
+ InlineNode,
133
134
  MediaCatalog,
134
135
  NumberingCatalog,
135
136
  OpaqueFragmentRecord,
@@ -581,15 +582,27 @@ export function loadDocxEditorSession(
581
582
  }
582
583
 
583
584
  const partPath = resolveRelationshipTarget(mainDocumentPath, relationship);
584
- const partBytes = sourcePackage.parts.get(partPath)?.bytes;
585
+ const part = sourcePackage.parts.get(partPath);
586
+ const partBytes = part?.bytes;
585
587
  if (!partBytes) {
586
588
  continue;
587
589
  }
588
590
 
589
591
  const xml = decodeUtf8(partBytes);
592
+ const subPartRelationships = part?.relationships ?? [];
593
+ const subPartChartPartLookup = createChartPartLookup(
594
+ sourcePackage,
595
+ partPath,
596
+ subPartRelationships,
597
+ );
590
598
  if (ref.kind === "header") {
591
599
  const parsedHeaderRevisions = parseRevisionsFromStoryXml(xml);
592
- const parsed = parseHeaderXml(xml);
600
+ const parsed = parseHeaderXml(xml, {
601
+ relationships: subPartRelationships,
602
+ mediaParts,
603
+ sourcePartPath: partPath,
604
+ chartPartLookup: subPartChartPartLookup,
605
+ });
593
606
  parsedHeaders.push({
594
607
  variant: ref.variant,
595
608
  partPath,
@@ -621,7 +634,12 @@ export function loadDocxEditorSession(
621
634
  sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
622
635
  } else {
623
636
  const parsedFooterRevisions = parseRevisionsFromStoryXml(xml);
624
- const parsed = parseFooterXml(xml);
637
+ const parsed = parseFooterXml(xml, {
638
+ relationships: subPartRelationships,
639
+ mediaParts,
640
+ sourcePartPath: partPath,
641
+ chartPartLookup: subPartChartPartLookup,
642
+ });
625
643
  parsedFooters.push({
626
644
  variant: ref.variant,
627
645
  partPath,
@@ -775,6 +793,13 @@ export function loadDocxEditorSession(
775
793
  )
776
794
  : undefined;
777
795
 
796
+ const mergedMedia = mergeSecondaryStoryMediaCatalog(normalizedDocument.media, {
797
+ headers: parsedHeaders,
798
+ footers: parsedFooters,
799
+ footnoteCollection,
800
+ mediaParts,
801
+ });
802
+
778
803
  const subParts: SubPartsCatalog | undefined =
779
804
  parsedHeaders.length > 0 ||
780
805
  parsedFooters.length > 0 ||
@@ -809,7 +834,7 @@ export function loadDocxEditorSession(
809
834
  documentId: options.documentId,
810
835
  timestamp,
811
836
  numbering: parsedNumbering,
812
- media: normalizedDocument.media,
837
+ media: mergedMedia,
813
838
  content: normalizedDocument.content,
814
839
  subParts,
815
840
  parsedStyles,
@@ -1518,16 +1543,28 @@ export async function loadDocxEditorSessionAsync(
1518
1543
  }
1519
1544
 
1520
1545
  const partPath = resolveRelationshipTarget(mainDocumentPath, relationship);
1521
- const partBytes = sourcePackage.parts.get(partPath)?.bytes;
1546
+ const part = sourcePackage.parts.get(partPath);
1547
+ const partBytes = part?.bytes;
1522
1548
  if (!partBytes) {
1523
1549
  continue;
1524
1550
  }
1525
1551
 
1526
1552
  await scheduler.yield();
1527
1553
  const xml = decodeUtf8(partBytes);
1554
+ const subPartRelationships = part?.relationships ?? [];
1555
+ const subPartChartPartLookup = createChartPartLookup(
1556
+ sourcePackage,
1557
+ partPath,
1558
+ subPartRelationships,
1559
+ );
1528
1560
  if (ref.kind === "header") {
1529
1561
  const parsedHeaderRevisions = parseRevisionsFromStoryXml(xml);
1530
- const parsed = parseHeaderXml(xml);
1562
+ const parsed = parseHeaderXml(xml, {
1563
+ relationships: subPartRelationships,
1564
+ mediaParts,
1565
+ sourcePartPath: partPath,
1566
+ chartPartLookup: subPartChartPartLookup,
1567
+ });
1531
1568
  parsedHeaders.push({
1532
1569
  variant: ref.variant,
1533
1570
  partPath,
@@ -1559,7 +1596,12 @@ export async function loadDocxEditorSessionAsync(
1559
1596
  sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
1560
1597
  } else {
1561
1598
  const parsedFooterRevisions = parseRevisionsFromStoryXml(xml);
1562
- const parsed = parseFooterXml(xml);
1599
+ const parsed = parseFooterXml(xml, {
1600
+ relationships: subPartRelationships,
1601
+ mediaParts,
1602
+ sourcePartPath: partPath,
1603
+ chartPartLookup: subPartChartPartLookup,
1604
+ });
1563
1605
  parsedFooters.push({
1564
1606
  variant: ref.variant,
1565
1607
  partPath,
@@ -1715,6 +1757,13 @@ export async function loadDocxEditorSessionAsync(
1715
1757
  )
1716
1758
  : undefined;
1717
1759
 
1760
+ const mergedMedia = mergeSecondaryStoryMediaCatalog(normalizedDocument.media, {
1761
+ headers: parsedHeaders,
1762
+ footers: parsedFooters,
1763
+ footnoteCollection,
1764
+ mediaParts,
1765
+ });
1766
+
1718
1767
  const subParts: SubPartsCatalog | undefined =
1719
1768
  parsedHeaders.length > 0 ||
1720
1769
  parsedFooters.length > 0 ||
@@ -1749,7 +1798,7 @@ export async function loadDocxEditorSessionAsync(
1749
1798
  documentId: options.documentId,
1750
1799
  timestamp,
1751
1800
  numbering: parsedNumbering,
1752
- media: normalizedDocument.media,
1801
+ media: mergedMedia,
1753
1802
  content: normalizedDocument.content,
1754
1803
  subParts,
1755
1804
  parsedStyles,
@@ -2644,6 +2693,116 @@ function recordImportedOpaqueBlock(
2644
2693
  };
2645
2694
  }
2646
2695
 
2696
+ function mergeSecondaryStoryMediaCatalog(
2697
+ media: MediaCatalog,
2698
+ input: {
2699
+ headers: readonly HeaderDocument[];
2700
+ footers: readonly FooterDocument[];
2701
+ footnoteCollection?: FootnoteCollection;
2702
+ mediaParts: ReadonlyMap<string, { path: string; contentType: string }>;
2703
+ },
2704
+ ): MediaCatalog {
2705
+ const items = { ...media.items };
2706
+ let changed = false;
2707
+
2708
+ const registerMediaItem = (
2709
+ mediaId: string,
2710
+ record: Omit<NonNullable<MediaCatalog["items"][string]>, "mediaId">,
2711
+ ) => {
2712
+ const existing = items[mediaId];
2713
+ items[mediaId] = existing
2714
+ ? {
2715
+ ...existing,
2716
+ ...record,
2717
+ mediaId,
2718
+ }
2719
+ : {
2720
+ mediaId,
2721
+ ...record,
2722
+ };
2723
+ changed = true;
2724
+ };
2725
+
2726
+ const visitInline = (node: InlineNode) => {
2727
+ if (node.type === "image") {
2728
+ const packagePartName = `/${node.mediaId.slice("media:".length)}`;
2729
+ registerMediaItem(node.mediaId, {
2730
+ contentType:
2731
+ items[node.mediaId]?.contentType ??
2732
+ input.mediaParts.get(packagePartName)?.contentType ??
2733
+ "application/octet-stream",
2734
+ filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin",
2735
+ packagePartName,
2736
+ ...(node.altText ? { altText: node.altText } : {}),
2737
+ });
2738
+ return;
2739
+ }
2740
+ if (node.type === "drawing_frame" && node.content.type === "picture" && node.content.mediaId) {
2741
+ const packagePartName =
2742
+ typeof node.content.packagePartName === "string" && node.content.packagePartName.length > 0
2743
+ ? node.content.packagePartName
2744
+ : `/${node.content.mediaId.slice("media:".length)}`;
2745
+ registerMediaItem(node.content.mediaId, {
2746
+ contentType:
2747
+ items[node.content.mediaId]?.contentType ??
2748
+ input.mediaParts.get(packagePartName)?.contentType ??
2749
+ "application/octet-stream",
2750
+ filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin",
2751
+ packagePartName,
2752
+ relationshipId: node.content.blipRef,
2753
+ ...(node.anchor.docPr?.descr ? { altText: node.anchor.docPr.descr } : {}),
2754
+ widthEmu: node.anchor.extent.widthEmu,
2755
+ heightEmu: node.anchor.extent.heightEmu,
2756
+ });
2757
+ return;
2758
+ }
2759
+ if (node.type === "hyperlink" || node.type === "field") {
2760
+ for (const child of node.children) {
2761
+ visitInline(child);
2762
+ }
2763
+ }
2764
+ };
2765
+
2766
+ const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
2767
+ for (const block of blocks) {
2768
+ if (block.type === "paragraph") {
2769
+ for (const child of block.children) {
2770
+ visitInline(child);
2771
+ }
2772
+ continue;
2773
+ }
2774
+ if (block.type === "table") {
2775
+ for (const row of block.rows) {
2776
+ for (const cell of row.cells) {
2777
+ visitBlocks(cell.children);
2778
+ }
2779
+ }
2780
+ continue;
2781
+ }
2782
+ if (block.type === "sdt" || block.type === "custom_xml") {
2783
+ visitBlocks(block.children);
2784
+ }
2785
+ }
2786
+ };
2787
+
2788
+ for (const header of input.headers) {
2789
+ visitBlocks(header.blocks);
2790
+ }
2791
+ for (const footer of input.footers) {
2792
+ visitBlocks(footer.blocks);
2793
+ }
2794
+ if (input.footnoteCollection) {
2795
+ for (const note of Object.values(input.footnoteCollection.footnotes)) {
2796
+ visitBlocks(note.blocks);
2797
+ }
2798
+ for (const note of Object.values(input.footnoteCollection.endnotes)) {
2799
+ visitBlocks(note.blocks);
2800
+ }
2801
+ }
2802
+
2803
+ return changed ? { ...media, items } : media;
2804
+ }
2805
+
2647
2806
  // Canonical model styleId validation pattern — styleIds that don't match
2648
2807
  // are excluded from the catalog to avoid snapshot validation failures.
2649
2808
  const VALID_STYLE_ID = /^[A-Za-z_][A-Za-z0-9._-]{0,127}$/;
@@ -37,8 +37,20 @@ export function serializeFootnotesXml(
37
37
  ): string {
38
38
  const entries = Object.values(collection.footnotes).sort(compareNoteIds);
39
39
  const body = [
40
- serializeSeparatorStub("footnote", "-1", "separator"),
41
- serializeSeparatorStub("footnote", "0", "continuationSeparator"),
40
+ serializeSeparatorStub(
41
+ "footnote",
42
+ "-1",
43
+ "separator",
44
+ collection.footnoteSeparators?.separatorParagraphXml,
45
+ collection.footnoteSeparators?.separatorContent,
46
+ ),
47
+ serializeSeparatorStub(
48
+ "footnote",
49
+ "0",
50
+ "continuationSeparator",
51
+ collection.footnoteSeparators?.continuationSeparatorParagraphXml,
52
+ collection.footnoteSeparators?.continuationSeparatorContent,
53
+ ),
42
54
  ...entries.map((entry) =>
43
55
  serializeNoteDefinition("footnote", entry, revisionsByNoteId[entry.noteId] ?? [])),
44
56
  ].join("");
@@ -59,8 +71,20 @@ export function serializeEndnotesXml(
59
71
  ): string {
60
72
  const entries = Object.values(collection.endnotes).sort(compareNoteIds);
61
73
  const body = [
62
- serializeSeparatorStub("endnote", "-1", "separator"),
63
- serializeSeparatorStub("endnote", "0", "continuationSeparator"),
74
+ serializeSeparatorStub(
75
+ "endnote",
76
+ "-1",
77
+ "separator",
78
+ collection.endnoteSeparators?.separatorParagraphXml,
79
+ collection.endnoteSeparators?.separatorContent,
80
+ ),
81
+ serializeSeparatorStub(
82
+ "endnote",
83
+ "0",
84
+ "continuationSeparator",
85
+ collection.endnoteSeparators?.continuationSeparatorParagraphXml,
86
+ collection.endnoteSeparators?.continuationSeparatorContent,
87
+ ),
64
88
  ...entries.map((entry) =>
65
89
  serializeNoteDefinition("endnote", entry, revisionsByNoteId[entry.noteId] ?? [])),
66
90
  ].join("");
@@ -77,9 +101,16 @@ function serializeSeparatorStub(
77
101
  kind: "footnote" | "endnote",
78
102
  id: string,
79
103
  type: "separator" | "continuationSeparator",
104
+ paragraphXml?: string,
105
+ content?: string,
80
106
  ): string {
81
107
  const tag = kind === "footnote" ? "w:footnote" : "w:endnote";
82
- return `<${tag} w:type="${type}" w:id="${id}"><w:p/></${tag}>`;
108
+ const body = paragraphXml && paragraphXml.length > 0
109
+ ? paragraphXml
110
+ : content && content.length > 0
111
+ ? `<w:p>${content}</w:p>`
112
+ : "<w:p/>";
113
+ return `<${tag} w:type="${type}" w:id="${id}">${body}</${tag}>`;
83
114
  }
84
115
 
85
116
  function serializeNoteDefinition(
@@ -263,6 +263,13 @@ function serializeInlineNode(node: InlineNode): string {
263
263
  case "wordart":
264
264
  case "vml_shape":
265
265
  return wrapInlineRawXml(node.rawXml);
266
+ case "drawing_frame": {
267
+ const { content } = node;
268
+ if (!("rawXml" in content) || typeof content.rawXml !== "string" || content.rawXml.length === 0) {
269
+ throw new Error("Cannot safely serialize drawing_frame content in header/footer sub-parts without rawXml.");
270
+ }
271
+ return wrapInlineRawXml(content.rawXml);
272
+ }
266
273
  case "ole_embed":
267
274
  // OLE in header/footer is rare but legal in OOXML. Relationship
268
275
  // tracking for sub-parts is not wired; emit rawXml verbatim
@@ -675,18 +675,10 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
675
675
  if (paragraph.styleId) {
676
676
  children.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
677
677
  }
678
- if (paragraph.keepNext) {
679
- children.push("<w:keepNext/>");
680
- }
681
- if (paragraph.keepLines) {
682
- children.push("<w:keepLines/>");
683
- }
684
- if (paragraph.pageBreakBefore) {
685
- children.push("<w:pageBreakBefore/>");
686
- }
687
- if (paragraph.widowControl) {
688
- children.push("<w:widowControl/>");
689
- }
678
+ pushOnOffParagraphProperty(children, "keepNext", paragraph.keepNext);
679
+ pushOnOffParagraphProperty(children, "keepLines", paragraph.keepLines);
680
+ pushOnOffParagraphProperty(children, "pageBreakBefore", paragraph.pageBreakBefore);
681
+ pushOnOffParagraphProperty(children, "widowControl", paragraph.widowControl);
690
682
  if (paragraph.outlineLevel !== undefined) {
691
683
  children.push(`<w:outlineLvl w:val="${paragraph.outlineLevel}"/>`);
692
684
  }
@@ -738,12 +730,8 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
738
730
  children.push(shadingXml);
739
731
  }
740
732
  }
741
- if (paragraph.bidi) {
742
- children.push("<w:bidi/>");
743
- }
744
- if (paragraph.suppressLineNumbers) {
745
- children.push("<w:suppressLineNumbers/>");
746
- }
733
+ pushOnOffParagraphProperty(children, "bidi", paragraph.bidi);
734
+ pushOnOffParagraphProperty(children, "suppressLineNumbers", paragraph.suppressLineNumbers);
747
735
  if (paragraph.cnfStyle) {
748
736
  children.push(`<w:cnfStyle w:val="${escapeXmlAttribute(paragraph.cnfStyle)}"/>`);
749
737
  }
@@ -811,9 +799,28 @@ function serializeParagraphShading(shading: ParagraphNode["shading"]): string {
811
799
  } else if (shading.val === "clear") {
812
800
  attrs.push(`w:fill="auto"`);
813
801
  }
802
+ if (shading.themeFill) attrs.push(`w:themeFill="${escapeXmlAttribute(shading.themeFill)}"`);
803
+ if (shading.themeFillTint) attrs.push(`w:themeFillTint="${escapeXmlAttribute(shading.themeFillTint)}"`);
804
+ if (shading.themeFillShade) attrs.push(`w:themeFillShade="${escapeXmlAttribute(shading.themeFillShade)}"`);
805
+ if (shading.themeColor) attrs.push(`w:themeColor="${escapeXmlAttribute(shading.themeColor)}"`);
806
+ if (shading.themeColorTint) attrs.push(`w:themeColorTint="${escapeXmlAttribute(shading.themeColorTint)}"`);
807
+ if (shading.themeColorShade) attrs.push(`w:themeColorShade="${escapeXmlAttribute(shading.themeColorShade)}"`);
814
808
  return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
815
809
  }
816
810
 
811
+ function pushOnOffParagraphProperty(
812
+ children: string[],
813
+ localName: string,
814
+ value: boolean | undefined,
815
+ ): void {
816
+ if (value === undefined) return;
817
+ children.push(
818
+ value
819
+ ? `<w:${localName}/>`
820
+ : `<w:${localName} w:val="false"/>`,
821
+ );
822
+ }
823
+
817
824
  function serializeRunPropertiesFromMarks(marks: TextMark[] | undefined): string {
818
825
  return serializeRunProperties(marks);
819
826
  }
@@ -61,6 +61,12 @@ function buildShadingXml(s: ParagraphShading | undefined): string {
61
61
  if (s.val) attrs.push(`w:val="${escXml(s.val)}"`);
62
62
  if (s.color) attrs.push(`w:color="${escXml(s.color)}"`);
63
63
  if (s.fill) attrs.push(`w:fill="${escXml(s.fill)}"`);
64
+ if (s.themeFill) attrs.push(`w:themeFill="${escXml(s.themeFill)}"`);
65
+ if (s.themeFillTint) attrs.push(`w:themeFillTint="${escXml(s.themeFillTint)}"`);
66
+ if (s.themeFillShade) attrs.push(`w:themeFillShade="${escXml(s.themeFillShade)}"`);
67
+ if (s.themeColor) attrs.push(`w:themeColor="${escXml(s.themeColor)}"`);
68
+ if (s.themeColorTint) attrs.push(`w:themeColorTint="${escXml(s.themeColorTint)}"`);
69
+ if (s.themeColorShade) attrs.push(`w:themeColorShade="${escXml(s.themeColorShade)}"`);
64
70
  return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
65
71
  }
66
72