@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -3,7 +3,12 @@ import type {
3
3
  FootnoteCollection,
4
4
  FootnoteDefinition,
5
5
  InlineNode,
6
+ ParagraphIndentation,
6
7
  ParagraphNode,
8
+ ParagraphSpacing,
9
+ TableCellNode,
10
+ TableNode,
11
+ TableRowNode,
7
12
  TextMark,
8
13
  } from "../../model/canonical-document.ts";
9
14
 
@@ -137,16 +142,24 @@ function parseNoteElement(
137
142
  if (name === "p") {
138
143
  blocks.push(parseParagraphElement(child));
139
144
  } else if (name === "tbl") {
140
- blocks.push({
141
- type: "opaque_block",
142
- fragmentId: `fragment:note-tbl-${noteId}`,
143
- warningId: `warning:note-opaque-table`,
144
- });
145
+ // Simple tables (no revisions, fields, or nested tables) are promoted
146
+ // to supported-roundtrip; structurally risky tables stay opaque.
147
+ if (isSimpleSecondaryStoryTable(child)) {
148
+ blocks.push(parseSimpleTableElement(child));
149
+ } else {
150
+ blocks.push({
151
+ type: "opaque_block",
152
+ fragmentId: `fragment:note-tbl-${noteId}`,
153
+ warningId: `warning:note-opaque-table`,
154
+ rawXml: serializeElementToXml(child),
155
+ });
156
+ }
145
157
  } else {
146
158
  blocks.push({
147
159
  type: "opaque_block",
148
160
  fragmentId: `fragment:note-opaque-${noteId}`,
149
161
  warningId: `warning:note-opaque-block`,
162
+ rawXml: serializeElementToXml(child),
150
163
  });
151
164
  }
152
165
  }
@@ -156,7 +169,15 @@ function parseNoteElement(
156
169
 
157
170
  function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
158
171
  let styleId: string | undefined;
172
+ let alignment: ParagraphNode["alignment"];
173
+ let spacing: ParagraphNode["spacing"];
174
+ let indentation: ParagraphNode["indentation"];
159
175
  const children: InlineNode[] = [];
176
+ let activeComplexField: {
177
+ instruction: string;
178
+ children: InlineNode[];
179
+ mode: "instruction" | "result";
180
+ } | null = null;
160
181
 
161
182
  for (const child of pElement.children) {
162
183
  if (child.type !== "element") {
@@ -168,24 +189,141 @@ function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
168
189
  if (name === "pPr") {
169
190
  const pStyle = findChildElementOptional(child, "pStyle");
170
191
  styleId = pStyle?.attributes["w:val"] ?? pStyle?.attributes.val;
192
+ const jc = findChildElementOptional(child, "jc");
193
+ const jcVal = jc?.attributes["w:val"] ?? jc?.attributes.val;
194
+ if (jcVal === "left" || jcVal === "center" || jcVal === "right" || jcVal === "both" || jcVal === "distribute") {
195
+ alignment = jcVal;
196
+ }
197
+ spacing = readParagraphSpacing(child);
198
+ indentation = readParagraphIndentation(child);
171
199
  } else if (name === "r") {
172
- children.push(...parseRunElement(child));
200
+ activeComplexField = appendRunNodes(child, children, activeComplexField);
173
201
  } else if (name === "hyperlink") {
202
+ if (activeComplexField && activeComplexField.instruction.trim().length > 0) {
203
+ children.push({
204
+ type: "field",
205
+ fieldType: "complex",
206
+ instruction: activeComplexField.instruction,
207
+ children: activeComplexField.children,
208
+ });
209
+ activeComplexField = null;
210
+ }
174
211
  children.push(parseHyperlinkElement(child));
175
212
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
176
- children.push(parseBookmarkElement(child));
213
+ const bookmarkNode = parseBookmarkElement(child);
214
+ if (activeComplexField?.mode === "result") {
215
+ activeComplexField.children.push(bookmarkNode);
216
+ } else {
217
+ if (activeComplexField && activeComplexField.instruction.trim().length > 0) {
218
+ children.push({
219
+ type: "field",
220
+ fieldType: "complex",
221
+ instruction: activeComplexField.instruction,
222
+ children: activeComplexField.children,
223
+ });
224
+ activeComplexField = null;
225
+ }
226
+ children.push(bookmarkNode);
227
+ }
177
228
  } else if (name === "fldSimple") {
178
- children.push(parseFieldElement(child));
229
+ if (activeComplexField && activeComplexField.instruction.trim().length > 0) {
230
+ children.push({
231
+ type: "field",
232
+ fieldType: "complex",
233
+ instruction: activeComplexField.instruction,
234
+ children: activeComplexField.children,
235
+ });
236
+ activeComplexField = null;
237
+ }
238
+ pushFieldNode(children, child, "simple");
179
239
  }
180
240
  }
181
241
 
242
+ if (activeComplexField && activeComplexField.instruction.trim().length > 0) {
243
+ children.push({
244
+ type: "field",
245
+ fieldType: "complex",
246
+ instruction: activeComplexField.instruction,
247
+ children: activeComplexField.children,
248
+ });
249
+ }
250
+
182
251
  return {
183
252
  type: "paragraph",
184
253
  ...(styleId ? { styleId } : {}),
254
+ ...(alignment ? { alignment } : {}),
255
+ ...(spacing ? { spacing } : {}),
256
+ ...(indentation ? { indentation } : {}),
185
257
  children,
186
258
  };
187
259
  }
188
260
 
261
+ function appendRunNodes(
262
+ rElement: XmlElementNode,
263
+ nodes: InlineNode[],
264
+ activeComplexField: {
265
+ instruction: string;
266
+ children: InlineNode[];
267
+ mode: "instruction" | "result";
268
+ } | null,
269
+ ): {
270
+ instruction: string;
271
+ children: InlineNode[];
272
+ mode: "instruction" | "result";
273
+ } | null {
274
+ const marks: TextMark[] = parseRunProperties(rElement);
275
+
276
+ for (const child of rElement.children) {
277
+ if (child.type !== "element") {
278
+ continue;
279
+ }
280
+
281
+ const name = localName(child.name);
282
+ if (name === "fldChar") {
283
+ const fldType = child.attributes["w:fldCharType"] ?? child.attributes.fldCharType;
284
+ if (fldType === "begin") {
285
+ activeComplexField = { instruction: "", children: [], mode: "instruction" };
286
+ } else if (fldType === "separate" && activeComplexField) {
287
+ activeComplexField.mode = "result";
288
+ } else if (fldType === "end" && activeComplexField) {
289
+ if (activeComplexField.instruction.trim().length > 0) {
290
+ nodes.push({
291
+ type: "field",
292
+ fieldType: "complex",
293
+ instruction: activeComplexField.instruction,
294
+ children: activeComplexField.children,
295
+ });
296
+ }
297
+ activeComplexField = null;
298
+ }
299
+ continue;
300
+ }
301
+
302
+ if (name === "instrText") {
303
+ if (activeComplexField) {
304
+ activeComplexField.instruction += extractTextContent(child);
305
+ } else {
306
+ pushFieldNode(nodes, child, "complex");
307
+ }
308
+ continue;
309
+ }
310
+
311
+ const inlineNode = parseRunChildNode(child, marks);
312
+ if (!inlineNode) {
313
+ continue;
314
+ }
315
+
316
+ if (activeComplexField?.mode === "result") {
317
+ activeComplexField.children.push(inlineNode);
318
+ continue;
319
+ }
320
+
321
+ nodes.push(inlineNode);
322
+ }
323
+
324
+ return activeComplexField;
325
+ }
326
+
189
327
  function parseRunElement(rElement: XmlElementNode): InlineNode[] {
190
328
  const nodes: InlineNode[] = [];
191
329
  const marks: TextMark[] = parseRunProperties(rElement);
@@ -226,14 +364,60 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
226
364
  }
227
365
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
228
366
  nodes.push(parseBookmarkElement(child));
229
- } else if (name === "fldChar" || name === "instrText") {
230
- nodes.push(parseFieldElement(child));
367
+ } else if (name === "instrText") {
368
+ pushFieldNode(nodes, child, "complex");
231
369
  }
232
370
  }
233
371
 
234
372
  return nodes;
235
373
  }
236
374
 
375
+ function parseRunChildNode(
376
+ child: XmlElementNode,
377
+ marks: TextMark[],
378
+ ): InlineNode | null {
379
+ const name = localName(child.name);
380
+
381
+ if (name === "t") {
382
+ const text = extractTextContent(child);
383
+ if (text.length > 0) {
384
+ return {
385
+ type: "text",
386
+ text,
387
+ ...(marks.length > 0 ? { marks } : {}),
388
+ };
389
+ }
390
+ return null;
391
+ }
392
+ if (name === "br") {
393
+ return { type: "hard_break" };
394
+ }
395
+ if (name === "tab") {
396
+ return { type: "tab" };
397
+ }
398
+ if (name === "footnoteReference") {
399
+ const noteId =
400
+ child.attributes["w:id"] ?? child.attributes.id ?? "";
401
+ if (noteId) {
402
+ return { type: "footnote_ref", noteId, noteKind: "footnote" };
403
+ }
404
+ return null;
405
+ }
406
+ if (name === "endnoteReference") {
407
+ const noteId =
408
+ child.attributes["w:id"] ?? child.attributes.id ?? "";
409
+ if (noteId) {
410
+ return { type: "footnote_ref", noteId, noteKind: "endnote" };
411
+ }
412
+ return null;
413
+ }
414
+ if (name === "bookmarkStart" || name === "bookmarkEnd") {
415
+ return parseBookmarkElement(child);
416
+ }
417
+
418
+ return null;
419
+ }
420
+
237
421
  function parseHyperlinkElement(element: XmlElementNode): Extract<InlineNode, { type: "hyperlink" }> {
238
422
  const href = element.attributes["w:anchor"]
239
423
  ? `#${element.attributes["w:anchor"]}`
@@ -275,22 +459,30 @@ function parseBookmarkElement(
275
459
  };
276
460
  }
277
461
 
278
- function parseFieldElement(element: XmlElementNode): Extract<InlineNode, { type: "field" }> {
279
- const rawFieldType =
280
- element.attributes["w:fldCharType"] ??
281
- element.attributes.fldCharType ??
282
- localName(element.name);
283
- const fieldType: "simple" | "complex" = rawFieldType === "complex" ? "complex" : "simple";
284
- const instruction =
285
- element.attributes["w:instr"] ??
286
- element.attributes.instr ??
287
- extractTextContent(element);
288
- return {
462
+ function pushFieldNode(
463
+ nodes: InlineNode[],
464
+ element: XmlElementNode,
465
+ fieldType: "simple" | "complex",
466
+ ): void {
467
+ const instruction = readFieldInstruction(element);
468
+ if (!instruction) {
469
+ return;
470
+ }
471
+
472
+ nodes.push({
289
473
  type: "field",
290
474
  fieldType,
291
475
  instruction,
292
476
  children: [],
293
- };
477
+ });
478
+ }
479
+
480
+ function readFieldInstruction(element: XmlElementNode): string | undefined {
481
+ const instruction =
482
+ element.attributes["w:instr"] ??
483
+ element.attributes.instr ??
484
+ extractTextContent(element);
485
+ return instruction.trim().length > 0 ? instruction : undefined;
294
486
  }
295
487
 
296
488
  function parseRunProperties(rElement: XmlElementNode): TextMark[] {
@@ -322,12 +514,75 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
322
514
  case "strike":
323
515
  if (val !== "0" && val !== "false") marks.push({ type: "strikethrough" });
324
516
  break;
517
+ case "dstrike":
518
+ if (val !== "0" && val !== "false") marks.push({ type: "doubleStrikethrough" });
519
+ break;
520
+ case "rFonts": {
521
+ const family =
522
+ child.attributes["w:ascii"] ??
523
+ child.attributes["w:hAnsi"] ??
524
+ child.attributes.ascii ??
525
+ child.attributes.hAnsi;
526
+ if (family) marks.push({ type: "fontFamily", val: family });
527
+ break;
528
+ }
529
+ case "sz": {
530
+ const szVal = child.attributes["w:val"] ?? child.attributes.val;
531
+ if (szVal) {
532
+ const size = Number.parseInt(szVal, 10);
533
+ if (Number.isFinite(size) && size > 0) marks.push({ type: "fontSize", val: size });
534
+ }
535
+ break;
536
+ }
537
+ case "color": {
538
+ const colorVal = child.attributes["w:val"] ?? child.attributes.val;
539
+ if (colorVal && colorVal !== "auto") marks.push({ type: "textColor", color: colorVal });
540
+ break;
541
+ }
542
+ case "smallCaps":
543
+ if (val !== "0" && val !== "false") marks.push({ type: "smallCaps" });
544
+ break;
545
+ case "caps":
546
+ if (val !== "0" && val !== "false") marks.push({ type: "allCaps" });
547
+ break;
325
548
  }
326
549
  }
327
550
 
328
551
  return marks;
329
552
  }
330
553
 
554
+ function readParagraphSpacing(pPr: XmlElementNode): ParagraphSpacing | undefined {
555
+ const spacingNode = findChildElementOptional(pPr, "spacing");
556
+ if (!spacingNode) return undefined;
557
+ const result: ParagraphSpacing = {};
558
+ const before = spacingNode.attributes["w:before"] ?? spacingNode.attributes.before;
559
+ if (before) result.before = Number.parseInt(before, 10);
560
+ const after = spacingNode.attributes["w:after"] ?? spacingNode.attributes.after;
561
+ if (after) result.after = Number.parseInt(after, 10);
562
+ const line = spacingNode.attributes["w:line"] ?? spacingNode.attributes.line;
563
+ if (line) result.line = Number.parseInt(line, 10);
564
+ const lineRule = spacingNode.attributes["w:lineRule"] ?? spacingNode.attributes.lineRule;
565
+ if (lineRule === "auto" || lineRule === "exact" || lineRule === "atLeast") {
566
+ result.lineRule = lineRule;
567
+ }
568
+ return Object.keys(result).length > 0 ? result : undefined;
569
+ }
570
+
571
+ function readParagraphIndentation(pPr: XmlElementNode): ParagraphIndentation | undefined {
572
+ const indNode = findChildElementOptional(pPr, "ind");
573
+ if (!indNode) return undefined;
574
+ const result: ParagraphIndentation = {};
575
+ const left = indNode.attributes["w:left"] ?? indNode.attributes.left;
576
+ if (left) result.left = Number.parseInt(left, 10);
577
+ const right = indNode.attributes["w:right"] ?? indNode.attributes.right;
578
+ if (right) result.right = Number.parseInt(right, 10);
579
+ const firstLine = indNode.attributes["w:firstLine"] ?? indNode.attributes.firstLine;
580
+ if (firstLine) result.firstLine = Number.parseInt(firstLine, 10);
581
+ const hanging = indNode.attributes["w:hanging"] ?? indNode.attributes.hanging;
582
+ if (hanging) result.hanging = Number.parseInt(hanging, 10);
583
+ return Object.keys(result).length > 0 ? result : undefined;
584
+ }
585
+
331
586
  function extractTextContent(tElement: XmlElementNode): string {
332
587
  let text = "";
333
588
  for (const child of tElement.children) {
@@ -353,6 +608,181 @@ function localName(name: string): string {
353
608
  return idx >= 0 ? name.slice(idx + 1) : name;
354
609
  }
355
610
 
611
+ // ---- Simple secondary-story table support ----
612
+
613
+ const RISKY_TABLE_ELEMENT_NAMES = new Set([
614
+ "ins",
615
+ "del",
616
+ "moveFrom",
617
+ "moveTo",
618
+ "tblPrChange",
619
+ "trPrChange",
620
+ "tcPrChange",
621
+ "rPrChange",
622
+ "pPrChange",
623
+ "sectPrChange",
624
+ "fldSimple",
625
+ "fldChar",
626
+ "instrText",
627
+ "sdt",
628
+ "customXml",
629
+ ]);
630
+
631
+ function isSimpleSecondaryStoryTable(tblElement: XmlElementNode): boolean {
632
+ return !containsRiskyElement(tblElement);
633
+ }
634
+
635
+ function containsRiskyElement(element: XmlElementNode): boolean {
636
+ for (const child of element.children) {
637
+ if (child.type !== "element") {
638
+ continue;
639
+ }
640
+ const name = localName(child.name);
641
+ if (RISKY_TABLE_ELEMENT_NAMES.has(name)) {
642
+ return true;
643
+ }
644
+ if (name === "tbl") {
645
+ return true;
646
+ }
647
+ if (containsRiskyElement(child)) {
648
+ return true;
649
+ }
650
+ }
651
+ return false;
652
+ }
653
+
654
+ function parseSimpleTableElement(tblElement: XmlElementNode): TableNode {
655
+ let gridColumns: number[] = [];
656
+ const rows: TableRowNode[] = [];
657
+ let propertiesXml: string | undefined;
658
+ let styleId: string | undefined;
659
+
660
+ for (const child of tblElement.children) {
661
+ if (child.type !== "element") continue;
662
+ const name = localName(child.name);
663
+
664
+ if (name === "tblPr") {
665
+ propertiesXml = serializeElementToXml(child);
666
+ const pStyle = findChildElementOptional(child, "tblStyle");
667
+ styleId = pStyle?.attributes["w:val"] ?? pStyle?.attributes.val;
668
+ } else if (name === "tblGrid") {
669
+ gridColumns = readGridColumns(child);
670
+ } else if (name === "tr") {
671
+ rows.push(parseSimpleTableRow(child));
672
+ }
673
+ }
674
+
675
+ return {
676
+ type: "table",
677
+ ...(styleId ? { styleId } : {}),
678
+ ...(propertiesXml ? { propertiesXml } : {}),
679
+ gridColumns,
680
+ rows,
681
+ };
682
+ }
683
+
684
+ function readGridColumns(tblGrid: XmlElementNode): number[] {
685
+ const columns: number[] = [];
686
+ for (const child of tblGrid.children) {
687
+ if (child.type !== "element") continue;
688
+ if (localName(child.name) === "gridCol") {
689
+ const w = child.attributes["w:w"] ?? child.attributes.w ?? "0";
690
+ columns.push(Number.parseInt(w, 10) || 0);
691
+ }
692
+ }
693
+ return columns;
694
+ }
695
+
696
+ function parseSimpleTableRow(trElement: XmlElementNode): TableRowNode {
697
+ const cells: TableCellNode[] = [];
698
+ let propertiesXml: string | undefined;
699
+
700
+ for (const child of trElement.children) {
701
+ if (child.type !== "element") continue;
702
+ const name = localName(child.name);
703
+
704
+ if (name === "trPr") {
705
+ propertiesXml = serializeElementToXml(child);
706
+ } else if (name === "tc") {
707
+ cells.push(parseSimpleTableCell(child));
708
+ }
709
+ }
710
+
711
+ return {
712
+ type: "table_row",
713
+ ...(propertiesXml ? { propertiesXml } : {}),
714
+ cells,
715
+ };
716
+ }
717
+
718
+ function parseSimpleTableCell(tcElement: XmlElementNode): TableCellNode {
719
+ const children: BlockNode[] = [];
720
+ let propertiesXml: string | undefined;
721
+ let gridSpan: number | undefined;
722
+ let verticalMerge: "restart" | "continue" | undefined;
723
+
724
+ for (const child of tcElement.children) {
725
+ if (child.type !== "element") continue;
726
+ const name = localName(child.name);
727
+
728
+ if (name === "tcPr") {
729
+ propertiesXml = serializeElementToXml(child);
730
+ const gsEl = findChildElementOptional(child, "gridSpan");
731
+ const gsVal = gsEl?.attributes["w:val"] ?? gsEl?.attributes.val;
732
+ if (gsVal) gridSpan = Number.parseInt(gsVal, 10) || undefined;
733
+
734
+ const vmEl = findChildElementOptional(child, "vMerge");
735
+ if (vmEl) {
736
+ const vmVal = vmEl.attributes["w:val"] ?? vmEl.attributes.val ?? "continue";
737
+ verticalMerge = vmVal === "restart" ? "restart" : "continue";
738
+ }
739
+ } else if (name === "p") {
740
+ children.push(parseParagraphElement(child));
741
+ }
742
+ }
743
+
744
+ return {
745
+ type: "table_cell",
746
+ ...(propertiesXml ? { propertiesXml } : {}),
747
+ ...(gridSpan ? { gridSpan } : {}),
748
+ ...(verticalMerge ? { verticalMerge } : {}),
749
+ children: children.length > 0 ? children : [{ type: "paragraph", children: [] }],
750
+ };
751
+ }
752
+
753
+ function serializeElementToXml(element: XmlElementNode): string {
754
+ const attrs = Object.entries(element.attributes)
755
+ .map(([key, value]) => ` ${key}="${escapeXmlAttribute(value)}"`)
756
+ .join("");
757
+ const children = element.children
758
+ .map((child) => {
759
+ if (child.type === "text") {
760
+ return escapeXmlText(child.text);
761
+ }
762
+ return serializeElementToXml(child);
763
+ })
764
+ .join("");
765
+ if (children.length === 0) {
766
+ return `<${element.name}${attrs}/>`;
767
+ }
768
+ return `<${element.name}${attrs}>${children}</${element.name}>`;
769
+ }
770
+
771
+ function escapeXmlAttribute(text: string): string {
772
+ return text
773
+ .replace(/&/g, "&amp;")
774
+ .replace(/"/g, "&quot;")
775
+ .replace(/</g, "&lt;")
776
+ .replace(/>/g, "&gt;");
777
+ }
778
+
779
+ function escapeXmlText(text: string): string {
780
+ return text
781
+ .replace(/&/g, "&amp;")
782
+ .replace(/</g, "&lt;")
783
+ .replace(/>/g, "&gt;");
784
+ }
785
+
356
786
  // ---- Minimal XML parser (same pattern as parse-numbering.ts) ----
357
787
 
358
788
  function parseXml(xml: string): XmlElementNode {