@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -3,6 +3,9 @@ import type {
3
3
  FootnoteDefinition,
4
4
  InlineNode,
5
5
  ParagraphNode,
6
+ TableCellNode,
7
+ TableNode,
8
+ TableRowNode,
6
9
  TextMark,
7
10
  } from "../../model/canonical-document.ts";
8
11
 
@@ -71,6 +74,12 @@ function serializeNoteDefinition(
71
74
  if (block.type === "paragraph") {
72
75
  return serializeParagraph(block);
73
76
  }
77
+ if (block.type === "table") {
78
+ return serializeTable(block);
79
+ }
80
+ if (block.type === "opaque_block" && typeof block.rawXml === "string") {
81
+ return block.rawXml;
82
+ }
74
83
  throw new Error(`Cannot safely serialize ${block.type} content in note sub-parts.`);
75
84
  })
76
85
  .join("");
@@ -96,6 +105,54 @@ function serializeParagraph(paragraph: ParagraphNode): string {
96
105
  return xml;
97
106
  }
98
107
 
108
+ function serializeTable(table: TableNode): string {
109
+ let xml = "<w:tbl>";
110
+ if (table.propertiesXml) {
111
+ xml += table.propertiesXml;
112
+ }
113
+ if (table.gridColumns.length > 0) {
114
+ xml += "<w:tblGrid>";
115
+ for (const width of table.gridColumns) {
116
+ xml += `<w:gridCol w:w="${width}"/>`;
117
+ }
118
+ xml += "</w:tblGrid>";
119
+ }
120
+ for (const row of table.rows) {
121
+ xml += serializeTableRow(row);
122
+ }
123
+ xml += "</w:tbl>";
124
+ return xml;
125
+ }
126
+
127
+ function serializeTableRow(row: TableRowNode): string {
128
+ let xml = "<w:tr>";
129
+ if (row.propertiesXml) {
130
+ xml += wrapPropertiesXml("w:trPr", row.propertiesXml);
131
+ }
132
+ for (const cell of row.cells) {
133
+ xml += serializeTableCell(cell);
134
+ }
135
+ xml += "</w:tr>";
136
+ return xml;
137
+ }
138
+
139
+ function serializeTableCell(cell: TableCellNode): string {
140
+ let xml = "<w:tc>";
141
+ const propertiesXml = buildTableCellPropertiesXml(cell);
142
+ if (propertiesXml) {
143
+ xml += propertiesXml;
144
+ }
145
+ for (const child of cell.children) {
146
+ if (child.type === "paragraph") {
147
+ xml += serializeParagraph(child);
148
+ } else {
149
+ throw new Error(`Cannot safely serialize ${child.type} content in note table cells.`);
150
+ }
151
+ }
152
+ xml += "</w:tc>";
153
+ return xml;
154
+ }
155
+
99
156
  function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
100
157
  const parts: string[] = [];
101
158
 
@@ -105,6 +162,26 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
105
162
  if (paragraph.alignment) {
106
163
  parts.push(`<w:jc w:val="${escapeAttribute(paragraph.alignment)}"/>`);
107
164
  }
165
+ if (paragraph.spacing) {
166
+ const attrs: string[] = [];
167
+ if (paragraph.spacing.before !== undefined) attrs.push(`w:before="${paragraph.spacing.before}"`);
168
+ if (paragraph.spacing.after !== undefined) attrs.push(`w:after="${paragraph.spacing.after}"`);
169
+ if (paragraph.spacing.line !== undefined) attrs.push(`w:line="${paragraph.spacing.line}"`);
170
+ if (paragraph.spacing.lineRule) attrs.push(`w:lineRule="${escapeAttribute(paragraph.spacing.lineRule)}"`);
171
+ if (attrs.length > 0) {
172
+ parts.push(`<w:spacing ${attrs.join(" ")}/>`);
173
+ }
174
+ }
175
+ if (paragraph.indentation) {
176
+ const attrs: string[] = [];
177
+ if (paragraph.indentation.left !== undefined) attrs.push(`w:left="${paragraph.indentation.left}"`);
178
+ if (paragraph.indentation.right !== undefined) attrs.push(`w:right="${paragraph.indentation.right}"`);
179
+ if (paragraph.indentation.firstLine !== undefined) attrs.push(`w:firstLine="${paragraph.indentation.firstLine}"`);
180
+ if (paragraph.indentation.hanging !== undefined) attrs.push(`w:hanging="${paragraph.indentation.hanging}"`);
181
+ if (attrs.length > 0) {
182
+ parts.push(`<w:ind ${attrs.join(" ")}/>`);
183
+ }
184
+ }
108
185
 
109
186
  return parts.length > 0 ? `<w:pPr>${parts.join("")}</w:pPr>` : "";
110
187
  }
@@ -133,9 +210,62 @@ function serializeInlineNode(node: InlineNode): string {
133
210
  : "EndnoteReference";
134
211
  return `<w:r><w:rPr><w:rStyle w:val="${styleVal}"/></w:rPr>${refElement}</w:r>`;
135
212
  }
213
+ case "bookmark_start":
214
+ return `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}" w:name="${escapeAttribute(node.name)}"/>`;
215
+ case "bookmark_end":
216
+ return `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
217
+ case "field":
218
+ if (node.children && node.children.length > 0) {
219
+ const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
220
+ if (node.fieldType === "complex") {
221
+ return (
222
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
223
+ `<w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r>` +
224
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
225
+ childrenXml +
226
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
227
+ );
228
+ }
229
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
230
+ }
231
+ if (node.fieldType === "simple") {
232
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
233
+ }
234
+ return (
235
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
236
+ `<w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r>` +
237
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
238
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
239
+ );
240
+ case "hyperlink":
241
+ return serializeHyperlinkNode(node);
242
+ case "field":
243
+ if (node.children && node.children.length > 0) {
244
+ const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
245
+ if (node.fieldType === "complex") {
246
+ return (
247
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
248
+ `<w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r>` +
249
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
250
+ childrenXml +
251
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
252
+ );
253
+ }
254
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
255
+ }
256
+ if (node.fieldType === "simple") {
257
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
258
+ }
259
+ return (
260
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
261
+ `<w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r>` +
262
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
263
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
264
+ );
265
+ case "hyperlink":
266
+ return serializeHyperlinkNode(node);
136
267
  case "opaque_inline":
137
268
  throw new Error(`Cannot safely serialize ${node.type} content in note sub-parts.`);
138
- case "hyperlink":
139
269
  default:
140
270
  throw new Error(`Cannot safely serialize ${node.type} content in note sub-parts.`);
141
271
  }
@@ -164,6 +294,23 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
164
294
  case "doubleStrikethrough":
165
295
  parts.push("<w:dstrike/>");
166
296
  break;
297
+ case "fontFamily":
298
+ parts.push(
299
+ `<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`,
300
+ );
301
+ break;
302
+ case "fontSize":
303
+ parts.push(`<w:sz w:val="${mark.val}"/>`);
304
+ break;
305
+ case "textColor":
306
+ parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
307
+ break;
308
+ case "smallCaps":
309
+ parts.push("<w:smallCaps/>");
310
+ break;
311
+ case "allCaps":
312
+ parts.push("<w:caps/>");
313
+ break;
167
314
  default:
168
315
  throw new Error(`Cannot safely serialize ${mark.type} marks in note sub-parts.`);
169
316
  }
@@ -200,3 +347,53 @@ function escapeAttribute(value: string): string {
200
347
  .replace(/>/g, "&gt;")
201
348
  .replace(/"/g, "&quot;");
202
349
  }
350
+
351
+ function serializeHyperlinkNode(node: Extract<InlineNode, { type: "hyperlink" }>): string {
352
+ const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
353
+ if (node.href.startsWith("#")) {
354
+ return `<w:hyperlink w:anchor="${escapeAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
355
+ }
356
+ if (!/^rId[A-Za-z0-9._-]+$/u.test(node.href)) {
357
+ throw new Error("Cannot safely serialize URL-backed note hyperlinks without relationship context.");
358
+ }
359
+ return `<w:hyperlink r:id="${escapeAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
360
+ }
361
+
362
+ function wrapPropertiesXml(tagName: "w:trPr" | "w:tcPr", xml: string): string {
363
+ const trimmed = xml.trim();
364
+ if (trimmed.startsWith(`<${tagName}`)) {
365
+ return trimmed;
366
+ }
367
+ return `<${tagName}>${trimmed}</${tagName}>`;
368
+ }
369
+
370
+ function extractWrappedChildren(tagName: "w:tcPr", xml: string | undefined): string {
371
+ if (!xml) {
372
+ return "";
373
+ }
374
+ const trimmed = xml.trim();
375
+ const wrapped = new RegExp(`^<${tagName}\\b[^>]*>([\\s\\S]*)</${tagName}>$`, "u").exec(trimmed);
376
+ return wrapped?.[1] ?? trimmed;
377
+ }
378
+
379
+ function buildTableCellPropertiesXml(cell: TableCellNode): string {
380
+ const innerXml = extractWrappedChildren("w:tcPr", cell.propertiesXml)
381
+ .replace(/<w:gridSpan\b[^>]*\/>/gu, "")
382
+ .replace(/<w:vMerge\b[^>]*\/>/gu, "")
383
+ .trim();
384
+ const parts: string[] = [];
385
+ if (cell.gridSpan && cell.gridSpan > 1) {
386
+ parts.push(`<w:gridSpan w:val="${cell.gridSpan}"/>`);
387
+ }
388
+ if (cell.verticalMerge) {
389
+ parts.push(
390
+ cell.verticalMerge === "restart"
391
+ ? `<w:vMerge w:val="restart"/>`
392
+ : `<w:vMerge/>`,
393
+ );
394
+ }
395
+ if (innerXml.length > 0) {
396
+ parts.push(innerXml);
397
+ }
398
+ return parts.length > 0 ? `<w:tcPr>${parts.join("")}</w:tcPr>` : "";
399
+ }
@@ -1,8 +1,12 @@
1
1
  import type {
2
+ BlockNode,
2
3
  FooterDocument,
3
4
  HeaderDocument,
4
5
  InlineNode,
5
6
  ParagraphNode,
7
+ TableCellNode,
8
+ TableNode,
9
+ TableRowNode,
6
10
  TextMark,
7
11
  } from "../../model/canonical-document.ts";
8
12
 
@@ -13,6 +17,17 @@ export const WORD_FOOTER_CONTENT_TYPE =
13
17
 
14
18
  const W_NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
15
19
  const R_NS = `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
20
+ const MC_NS = `xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"`;
21
+ const O_NS = `xmlns:o="urn:schemas-microsoft-com:office:office"`;
22
+ const V_NS = `xmlns:v="urn:schemas-microsoft-com:vml"`;
23
+ const WP_NS = `xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"`;
24
+ const WPS_NS = `xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"`;
25
+ const A_NS = `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"`;
26
+ const W10_NS = `xmlns:w10="urn:schemas-microsoft-com:office:word"`;
27
+ const W14_NS = `xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"`;
28
+ const W15_NS = `xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"`;
29
+ const WP14_NS = `xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"`;
30
+ const EXTENDED_ROOT_ATTRS = `${MC_NS} ${O_NS} ${V_NS} ${WP_NS} ${WPS_NS} ${A_NS} ${W10_NS} ${W14_NS} ${W15_NS} ${WP14_NS} mc:Ignorable="wps wp14 w14 w15"`;
16
31
 
17
32
  /**
18
33
  * Serialize a HeaderDocument into a headerN.xml string.
@@ -21,7 +36,7 @@ export function serializeHeaderXml(header: HeaderDocument): string {
21
36
  const body = serializeBlocks(header.blocks);
22
37
  return [
23
38
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
24
- `<w:hdr ${W_NS} ${R_NS}>${body}</w:hdr>`,
39
+ `<w:hdr ${W_NS} ${R_NS} ${EXTENDED_ROOT_ATTRS}>${body}</w:hdr>`,
25
40
  ].join("\n");
26
41
  }
27
42
 
@@ -32,7 +47,7 @@ export function serializeFooterXml(footer: FooterDocument): string {
32
47
  const body = serializeBlocks(footer.blocks);
33
48
  return [
34
49
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
35
- `<w:ftr ${W_NS} ${R_NS}>${body}</w:ftr>`,
50
+ `<w:ftr ${W_NS} ${R_NS} ${EXTENDED_ROOT_ATTRS}>${body}</w:ftr>`,
36
51
  ].join("\n");
37
52
  }
38
53
 
@@ -51,6 +66,12 @@ function serializeBlocks(
51
66
  if (block.type === "paragraph") {
52
67
  return serializeParagraph(block);
53
68
  }
69
+ if (block.type === "table") {
70
+ return serializeTable(block);
71
+ }
72
+ if (block.type === "opaque_block" && typeof block.rawXml === "string") {
73
+ return block.rawXml;
74
+ }
54
75
  throw new Error(`Cannot safely serialize ${block.type} content in header/footer sub-parts.`);
55
76
  })
56
77
  .join("");
@@ -73,15 +94,88 @@ function serializeParagraph(paragraph: ParagraphNode): string {
73
94
  return xml;
74
95
  }
75
96
 
97
+ function serializeTable(table: TableNode): string {
98
+ let xml = "<w:tbl>";
99
+ if (table.propertiesXml) {
100
+ xml += table.propertiesXml;
101
+ }
102
+ if (table.gridColumns.length > 0) {
103
+ xml += "<w:tblGrid>";
104
+ for (const width of table.gridColumns) {
105
+ xml += `<w:gridCol w:w="${width}"/>`;
106
+ }
107
+ xml += "</w:tblGrid>";
108
+ }
109
+ for (const row of table.rows) {
110
+ xml += serializeTableRow(row);
111
+ }
112
+ xml += "</w:tbl>";
113
+ return xml;
114
+ }
115
+
116
+ function serializeTableRow(row: TableRowNode): string {
117
+ let xml = "<w:tr>";
118
+ if (row.propertiesXml) {
119
+ xml += wrapPropertiesXml("w:trPr", row.propertiesXml);
120
+ }
121
+ for (const cell of row.cells) {
122
+ xml += serializeTableCell(cell);
123
+ }
124
+ xml += "</w:tr>";
125
+ return xml;
126
+ }
127
+
128
+ function serializeTableCell(cell: TableCellNode): string {
129
+ let xml = "<w:tc>";
130
+ const propertiesXml = buildTableCellPropertiesXml(cell);
131
+ if (propertiesXml) {
132
+ xml += propertiesXml;
133
+ }
134
+ for (const child of cell.children) {
135
+ if (child.type === "paragraph") {
136
+ xml += serializeParagraph(child);
137
+ } else {
138
+ throw new Error(`Cannot safely serialize ${child.type} content in header/footer table cells.`);
139
+ }
140
+ }
141
+ xml += "</w:tc>";
142
+ return xml;
143
+ }
144
+
76
145
  function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
77
146
  const parts: string[] = [];
78
147
 
79
148
  if (paragraph.styleId) {
80
149
  parts.push(`<w:pStyle w:val="${escapeAttribute(paragraph.styleId)}"/>`);
81
150
  }
151
+ if (paragraph.spacing) {
152
+ const s = paragraph.spacing;
153
+ const attrs: string[] = [];
154
+ if (s.before !== undefined) attrs.push(`w:before="${s.before}"`);
155
+ if (s.after !== undefined) attrs.push(`w:after="${s.after}"`);
156
+ if (s.line !== undefined) attrs.push(`w:line="${s.line}"`);
157
+ if (s.lineRule) attrs.push(`w:lineRule="${escapeAttribute(s.lineRule)}"`);
158
+ if (attrs.length > 0) parts.push(`<w:spacing ${attrs.join(" ")}/>`);
159
+ }
160
+ if (paragraph.indentation) {
161
+ const ind = paragraph.indentation;
162
+ const attrs: string[] = [];
163
+ if (ind.left !== undefined) attrs.push(`w:left="${ind.left}"`);
164
+ if (ind.right !== undefined) attrs.push(`w:right="${ind.right}"`);
165
+ if (ind.firstLine !== undefined) attrs.push(`w:firstLine="${ind.firstLine}"`);
166
+ if (ind.hanging !== undefined) attrs.push(`w:hanging="${ind.hanging}"`);
167
+ if (attrs.length > 0) parts.push(`<w:ind ${attrs.join(" ")}/>`);
168
+ }
82
169
  if (paragraph.alignment) {
83
170
  parts.push(`<w:jc w:val="${escapeAttribute(paragraph.alignment)}"/>`);
84
171
  }
172
+ if (paragraph.tabStops && paragraph.tabStops.length > 0) {
173
+ const tabsXml = paragraph.tabStops.map((tab) => {
174
+ const leaderAttr = tab.leader ? ` w:leader="${escapeAttribute(tab.leader)}"` : "";
175
+ return `<w:tab w:val="${tab.align}" w:pos="${tab.position}"${leaderAttr}/>`;
176
+ }).join("");
177
+ parts.push(`<w:tabs>${tabsXml}</w:tabs>`);
178
+ }
85
179
 
86
180
  return parts.length > 0 ? `<w:pPr>${parts.join("")}</w:pPr>` : "";
87
181
  }
@@ -106,25 +200,51 @@ function serializeInlineNode(node: InlineNode): string {
106
200
  : `<w:endnoteReference w:id="${escapeAttribute(node.noteId)}"/>`;
107
201
  return `<w:r><w:rPr><w:rStyle w:val="${node.noteKind === "footnote" ? "FootnoteReference" : "EndnoteReference"}"/></w:rPr>${refElement}</w:r>`;
108
202
  }
109
- case "opaque_inline":
110
- throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
111
- case "hyperlink":
112
- case "image":
113
- case "field":
114
203
  case "bookmark_start":
204
+ return `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}" w:name="${escapeAttribute(node.name)}"/>`;
115
205
  case "bookmark_end":
116
- case "column_break":
117
- case "symbol":
206
+ return `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
207
+ case "field":
208
+ if (node.children && node.children.length > 0) {
209
+ const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
210
+ if (node.fieldType === "complex") {
211
+ return (
212
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
213
+ `<w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r>` +
214
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
215
+ childrenXml +
216
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
217
+ );
218
+ }
219
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
220
+ }
221
+ if (node.fieldType === "simple") {
222
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
223
+ }
224
+ return `<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>`;
225
+ case "hyperlink":
226
+ return serializeHyperlinkNode(node);
118
227
  case "chart_preview":
119
228
  case "smartart_preview":
120
229
  case "shape":
121
230
  case "wordart":
122
231
  case "vml_shape":
232
+ return wrapInlineRawXml(node.rawXml);
233
+ case "opaque_inline":
234
+ throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
235
+ case "image":
236
+ case "column_break":
237
+ case "symbol":
123
238
  default:
124
239
  throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
125
240
  }
126
241
  }
127
242
 
243
+ function wrapInlineRawXml(rawXml: string): string {
244
+ const trimmed = rawXml.trimStart();
245
+ return trimmed.startsWith("<w:r") ? rawXml : `<w:r>${rawXml}</w:r>`;
246
+ }
247
+
128
248
  function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
129
249
  if (!marks || marks.length === 0) {
130
250
  return "";
@@ -148,8 +268,31 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
148
268
  case "doubleStrikethrough":
149
269
  parts.push("<w:dstrike/>");
150
270
  break;
271
+ case "fontFamily":
272
+ parts.push(`<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`);
273
+ break;
274
+ case "fontSize":
275
+ parts.push(`<w:sz w:val="${mark.val}"/>`);
276
+ break;
277
+ case "textColor":
278
+ parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
279
+ break;
280
+ case "highlight":
281
+ parts.push(`<w:highlight w:val="${escapeAttribute(mark.val)}"/>`);
282
+ break;
283
+ case "backgroundColor":
284
+ parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttribute(mark.color)}"/>`);
285
+ break;
286
+ case "smallCaps":
287
+ parts.push("<w:smallCaps/>");
288
+ break;
289
+ case "allCaps":
290
+ parts.push("<w:caps/>");
291
+ break;
151
292
  default:
152
- throw new Error(`Cannot safely serialize ${mark.type} marks in header/footer sub-parts.`);
293
+ // Marks outside the secondary-story contract are silently dropped
294
+ // to avoid export failure on content the parser extracted safely.
295
+ break;
153
296
  }
154
297
  }
155
298
 
@@ -177,3 +320,53 @@ function escapeAttribute(value: string): string {
177
320
  .replace(/>/g, "&gt;")
178
321
  .replace(/"/g, "&quot;");
179
322
  }
323
+
324
+ function serializeHyperlinkNode(node: Extract<InlineNode, { type: "hyperlink" }>): string {
325
+ const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
326
+ if (node.href.startsWith("#")) {
327
+ return `<w:hyperlink w:anchor="${escapeAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
328
+ }
329
+ if (!/^rId[A-Za-z0-9._-]+$/u.test(node.href)) {
330
+ throw new Error("Cannot safely serialize URL-backed header/footer hyperlinks without relationship context.");
331
+ }
332
+ return `<w:hyperlink r:id="${escapeAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
333
+ }
334
+
335
+ function wrapPropertiesXml(tagName: "w:trPr" | "w:tcPr", xml: string): string {
336
+ const trimmed = xml.trim();
337
+ if (trimmed.startsWith(`<${tagName}`)) {
338
+ return trimmed;
339
+ }
340
+ return `<${tagName}>${trimmed}</${tagName}>`;
341
+ }
342
+
343
+ function extractWrappedChildren(tagName: "w:tcPr", xml: string | undefined): string {
344
+ if (!xml) {
345
+ return "";
346
+ }
347
+ const trimmed = xml.trim();
348
+ const wrapped = new RegExp(`^<${tagName}\\b[^>]*>([\\s\\S]*)</${tagName}>$`, "u").exec(trimmed);
349
+ return wrapped?.[1] ?? trimmed;
350
+ }
351
+
352
+ function buildTableCellPropertiesXml(cell: TableCellNode): string {
353
+ const innerXml = extractWrappedChildren("w:tcPr", cell.propertiesXml)
354
+ .replace(/<w:gridSpan\b[^>]*\/>/gu, "")
355
+ .replace(/<w:vMerge\b[^>]*\/>/gu, "")
356
+ .trim();
357
+ const parts: string[] = [];
358
+ if (cell.gridSpan && cell.gridSpan > 1) {
359
+ parts.push(`<w:gridSpan w:val="${cell.gridSpan}"/>`);
360
+ }
361
+ if (cell.verticalMerge) {
362
+ parts.push(
363
+ cell.verticalMerge === "restart"
364
+ ? `<w:vMerge w:val="restart"/>`
365
+ : `<w:vMerge/>`,
366
+ );
367
+ }
368
+ if (innerXml.length > 0) {
369
+ parts.push(innerXml);
370
+ }
371
+ return parts.length > 0 ? `<w:tcPr>${parts.join("")}</w:tcPr>` : "";
372
+ }
@@ -407,7 +407,7 @@ function serializeTableInlineNode(
407
407
  case "shape":
408
408
  case "wordart":
409
409
  case "vml_shape":
410
- return node.rawXml;
410
+ return wrapInlineRawXml(node.rawXml);
411
411
  case "hyperlink": {
412
412
  const hyperlinkOpen = node.href.startsWith("#")
413
413
  ? `<w:hyperlink w:anchor="${escapeAttribute(node.href.slice(1))}">`
@@ -426,8 +426,24 @@ function serializeTableInlineNode(
426
426
  const childrenXml = node.children.map((child) => serializeTableInlineNode(child, state)).join("");
427
427
  return `${hyperlinkOpen}${childrenXml}</w:hyperlink>`;
428
428
  }
429
- case "field":
429
+ case "field": {
430
+ if (node.children && node.children.length > 0) {
431
+ const childrenXml = node.children
432
+ .map((child) => serializeTableInlineNode(child, state))
433
+ .join("");
434
+ if (node.fieldType === "complex") {
435
+ return (
436
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
437
+ `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
438
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
439
+ childrenXml +
440
+ `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
441
+ );
442
+ }
443
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
444
+ }
430
445
  return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
446
+ }
431
447
  case "bookmark_start":
432
448
  return (
433
449
  `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}"` +
@@ -484,6 +500,13 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
484
500
  if (s.lineRule !== undefined) attrs.push(`w:lineRule="${s.lineRule}"`);
485
501
  if (attrs.length > 0) children.push(`<w:spacing ${attrs.join(" ")}/>`);
486
502
  }
503
+ if (paragraph.contextualSpacing !== undefined) {
504
+ children.push(
505
+ paragraph.contextualSpacing
506
+ ? "<w:contextualSpacing/>"
507
+ : `<w:contextualSpacing w:val="0"/>`,
508
+ );
509
+ }
487
510
  if (paragraph.indentation) {
488
511
  const ind = paragraph.indentation;
489
512
  const attrs: string[] = [];
@@ -806,7 +829,7 @@ function serializeInlineNode(
806
829
  case "wordart":
807
830
  case "vml_shape": {
808
831
  // Reattach original XML unchanged for lossless round-trip.
809
- const xml = node.rawXml;
832
+ const xml = wrapInlineRawXml(node.rawXml);
810
833
  const boundaries = new Map<number, number>();
811
834
  boundaries.set(cursor, xmlOffset);
812
835
  boundaries.set(cursor + 1, xmlOffset + xml.length);
@@ -856,6 +879,55 @@ function serializeInlineNode(
856
879
  };
857
880
  }
858
881
  case "field": {
882
+ if (node.children && node.children.length > 0) {
883
+ const boundaries = new Map<number, number>();
884
+ boundaries.set(cursor, xmlOffset);
885
+ let nextCursor = cursor;
886
+ let nextOffset = xmlOffset;
887
+
888
+ if (node.fieldType === "complex") {
889
+ const beginXml =
890
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
891
+ `<w:r><w:instrText xml:space="preserve"> ${escapeXml(node.instruction)} </w:instrText></w:r>` +
892
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>`;
893
+ nextOffset += beginXml.length;
894
+
895
+ const children: string[] = [beginXml];
896
+ for (const child of node.children) {
897
+ const result = serializeInlineNode(child, state, nextCursor, nextOffset);
898
+ children.push(result.xml);
899
+ for (const [position, index] of result.boundaries) {
900
+ boundaries.set(position, index);
901
+ }
902
+ nextCursor = result.cursor;
903
+ nextOffset += result.xml.length;
904
+ }
905
+ const endXml = `<w:r><w:fldChar w:fldCharType="end"/></w:r>`;
906
+ children.push(endXml);
907
+ nextOffset += endXml.length;
908
+ boundaries.set(nextCursor, nextOffset);
909
+ return { xml: children.join(""), cursor: nextCursor, boundaries };
910
+ }
911
+
912
+ // Simple field with children
913
+ const openXml = `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">`;
914
+ nextOffset += openXml.length;
915
+ const children: string[] = [openXml];
916
+ for (const child of node.children) {
917
+ const result = serializeInlineNode(child, state, nextCursor, nextOffset);
918
+ children.push(result.xml);
919
+ for (const [position, index] of result.boundaries) {
920
+ boundaries.set(position, index);
921
+ }
922
+ nextCursor = result.cursor;
923
+ nextOffset += result.xml.length;
924
+ }
925
+ children.push("</w:fldSimple>");
926
+ nextOffset += "</w:fldSimple>".length;
927
+ boundaries.set(nextCursor, nextOffset);
928
+ return { xml: children.join(""), cursor: nextCursor, boundaries };
929
+ }
930
+
859
931
  const xml = `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
860
932
  const boundaries = new Map<number, number>();
861
933
  boundaries.set(cursor, xmlOffset);
@@ -966,6 +1038,9 @@ function serializeRunProperties(marks: TextMark[] | undefined): string {
966
1038
  case "lang":
967
1039
  markParts.push(`<w:lang w:val="${escapeAttribute(mark.val)}"/>`);
968
1040
  break;
1041
+ case "highlight":
1042
+ markParts.push(`<w:highlight w:val="${escapeAttribute(mark.val)}"/>`);
1043
+ break;
969
1044
  case "backgroundColor":
970
1045
  markParts.push(
971
1046
  `<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttribute(mark.color)}"/>`,
@@ -1246,6 +1321,11 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1246
1321
  return `<w:sectPr>${children.join("")}</w:sectPr>`;
1247
1322
  }
1248
1323
 
1324
+ function wrapInlineRawXml(rawXml: string): string {
1325
+ const trimmed = rawXml.trimStart();
1326
+ return trimmed.startsWith("<w:r") ? rawXml : `<w:r>${rawXml}</w:r>`;
1327
+ }
1328
+
1249
1329
  function documentNeedsW14Namespace(content: DocumentRootNode): boolean {
1250
1330
  const blockQueue = [...content.children];
1251
1331
  while (blockQueue.length > 0) {