@beyondwork/docx-react-component 1.0.18 → 1.0.19

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 (74) 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 +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -4,6 +4,9 @@ import type {
4
4
  FootnoteDefinition,
5
5
  InlineNode,
6
6
  ParagraphNode,
7
+ TableCellNode,
8
+ TableNode,
9
+ TableRowNode,
7
10
  TextMark,
8
11
  } from "../../model/canonical-document.ts";
9
12
 
@@ -137,11 +140,17 @@ function parseNoteElement(
137
140
  if (name === "p") {
138
141
  blocks.push(parseParagraphElement(child));
139
142
  } else if (name === "tbl") {
140
- blocks.push({
141
- type: "opaque_block",
142
- fragmentId: `fragment:note-tbl-${noteId}`,
143
- warningId: `warning:note-opaque-table`,
144
- });
143
+ // Simple tables (no revisions, fields, or nested tables) are promoted
144
+ // to supported-roundtrip; structurally risky tables stay opaque.
145
+ if (isSimpleSecondaryStoryTable(child)) {
146
+ blocks.push(parseSimpleTableElement(child));
147
+ } else {
148
+ blocks.push({
149
+ type: "opaque_block",
150
+ fragmentId: `fragment:note-tbl-${noteId}`,
151
+ warningId: `warning:note-opaque-table`,
152
+ });
153
+ }
145
154
  } else {
146
155
  blocks.push({
147
156
  type: "opaque_block",
@@ -175,7 +184,7 @@ function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
175
184
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
176
185
  children.push(parseBookmarkElement(child));
177
186
  } else if (name === "fldSimple") {
178
- children.push(parseFieldElement(child));
187
+ pushFieldNode(children, child, "simple");
179
188
  }
180
189
  }
181
190
 
@@ -226,8 +235,8 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
226
235
  }
227
236
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
228
237
  nodes.push(parseBookmarkElement(child));
229
- } else if (name === "fldChar" || name === "instrText") {
230
- nodes.push(parseFieldElement(child));
238
+ } else if (name === "instrText") {
239
+ pushFieldNode(nodes, child, "complex");
231
240
  }
232
241
  }
233
242
 
@@ -275,22 +284,30 @@ function parseBookmarkElement(
275
284
  };
276
285
  }
277
286
 
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 {
287
+ function pushFieldNode(
288
+ nodes: InlineNode[],
289
+ element: XmlElementNode,
290
+ fieldType: "simple" | "complex",
291
+ ): void {
292
+ const instruction = readFieldInstruction(element);
293
+ if (!instruction) {
294
+ return;
295
+ }
296
+
297
+ nodes.push({
289
298
  type: "field",
290
299
  fieldType,
291
300
  instruction,
292
301
  children: [],
293
- };
302
+ });
303
+ }
304
+
305
+ function readFieldInstruction(element: XmlElementNode): string | undefined {
306
+ const instruction =
307
+ element.attributes["w:instr"] ??
308
+ element.attributes.instr ??
309
+ extractTextContent(element);
310
+ return instruction.trim().length > 0 ? instruction : undefined;
294
311
  }
295
312
 
296
313
  function parseRunProperties(rElement: XmlElementNode): TextMark[] {
@@ -353,6 +370,181 @@ function localName(name: string): string {
353
370
  return idx >= 0 ? name.slice(idx + 1) : name;
354
371
  }
355
372
 
373
+ // ---- Simple secondary-story table support ----
374
+
375
+ const RISKY_TABLE_ELEMENT_NAMES = new Set([
376
+ "ins",
377
+ "del",
378
+ "moveFrom",
379
+ "moveTo",
380
+ "tblPrChange",
381
+ "trPrChange",
382
+ "tcPrChange",
383
+ "rPrChange",
384
+ "pPrChange",
385
+ "sectPrChange",
386
+ "fldSimple",
387
+ "fldChar",
388
+ "instrText",
389
+ "sdt",
390
+ "customXml",
391
+ ]);
392
+
393
+ function isSimpleSecondaryStoryTable(tblElement: XmlElementNode): boolean {
394
+ return !containsRiskyElement(tblElement);
395
+ }
396
+
397
+ function containsRiskyElement(element: XmlElementNode): boolean {
398
+ for (const child of element.children) {
399
+ if (child.type !== "element") {
400
+ continue;
401
+ }
402
+ const name = localName(child.name);
403
+ if (RISKY_TABLE_ELEMENT_NAMES.has(name)) {
404
+ return true;
405
+ }
406
+ if (name === "tbl") {
407
+ return true;
408
+ }
409
+ if (containsRiskyElement(child)) {
410
+ return true;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+
416
+ function parseSimpleTableElement(tblElement: XmlElementNode): TableNode {
417
+ let gridColumns: number[] = [];
418
+ const rows: TableRowNode[] = [];
419
+ let propertiesXml: string | undefined;
420
+ let styleId: string | undefined;
421
+
422
+ for (const child of tblElement.children) {
423
+ if (child.type !== "element") continue;
424
+ const name = localName(child.name);
425
+
426
+ if (name === "tblPr") {
427
+ propertiesXml = serializeElementToXml(child);
428
+ const pStyle = findChildElementOptional(child, "tblStyle");
429
+ styleId = pStyle?.attributes["w:val"] ?? pStyle?.attributes.val;
430
+ } else if (name === "tblGrid") {
431
+ gridColumns = readGridColumns(child);
432
+ } else if (name === "tr") {
433
+ rows.push(parseSimpleTableRow(child));
434
+ }
435
+ }
436
+
437
+ return {
438
+ type: "table",
439
+ ...(styleId ? { styleId } : {}),
440
+ ...(propertiesXml ? { propertiesXml } : {}),
441
+ gridColumns,
442
+ rows,
443
+ };
444
+ }
445
+
446
+ function readGridColumns(tblGrid: XmlElementNode): number[] {
447
+ const columns: number[] = [];
448
+ for (const child of tblGrid.children) {
449
+ if (child.type !== "element") continue;
450
+ if (localName(child.name) === "gridCol") {
451
+ const w = child.attributes["w:w"] ?? child.attributes.w ?? "0";
452
+ columns.push(Number.parseInt(w, 10) || 0);
453
+ }
454
+ }
455
+ return columns;
456
+ }
457
+
458
+ function parseSimpleTableRow(trElement: XmlElementNode): TableRowNode {
459
+ const cells: TableCellNode[] = [];
460
+ let propertiesXml: string | undefined;
461
+
462
+ for (const child of trElement.children) {
463
+ if (child.type !== "element") continue;
464
+ const name = localName(child.name);
465
+
466
+ if (name === "trPr") {
467
+ propertiesXml = serializeElementToXml(child);
468
+ } else if (name === "tc") {
469
+ cells.push(parseSimpleTableCell(child));
470
+ }
471
+ }
472
+
473
+ return {
474
+ type: "table_row",
475
+ ...(propertiesXml ? { propertiesXml } : {}),
476
+ cells,
477
+ };
478
+ }
479
+
480
+ function parseSimpleTableCell(tcElement: XmlElementNode): TableCellNode {
481
+ const children: BlockNode[] = [];
482
+ let propertiesXml: string | undefined;
483
+ let gridSpan: number | undefined;
484
+ let verticalMerge: "restart" | "continue" | undefined;
485
+
486
+ for (const child of tcElement.children) {
487
+ if (child.type !== "element") continue;
488
+ const name = localName(child.name);
489
+
490
+ if (name === "tcPr") {
491
+ propertiesXml = serializeElementToXml(child);
492
+ const gsEl = findChildElementOptional(child, "gridSpan");
493
+ const gsVal = gsEl?.attributes["w:val"] ?? gsEl?.attributes.val;
494
+ if (gsVal) gridSpan = Number.parseInt(gsVal, 10) || undefined;
495
+
496
+ const vmEl = findChildElementOptional(child, "vMerge");
497
+ if (vmEl) {
498
+ const vmVal = vmEl.attributes["w:val"] ?? vmEl.attributes.val ?? "continue";
499
+ verticalMerge = vmVal === "restart" ? "restart" : "continue";
500
+ }
501
+ } else if (name === "p") {
502
+ children.push(parseParagraphElement(child));
503
+ }
504
+ }
505
+
506
+ return {
507
+ type: "table_cell",
508
+ ...(propertiesXml ? { propertiesXml } : {}),
509
+ ...(gridSpan ? { gridSpan } : {}),
510
+ ...(verticalMerge ? { verticalMerge } : {}),
511
+ children: children.length > 0 ? children : [{ type: "paragraph", children: [] }],
512
+ };
513
+ }
514
+
515
+ function serializeElementToXml(element: XmlElementNode): string {
516
+ const attrs = Object.entries(element.attributes)
517
+ .map(([key, value]) => ` ${key}="${escapeXmlAttribute(value)}"`)
518
+ .join("");
519
+ const children = element.children
520
+ .map((child) => {
521
+ if (child.type === "text") {
522
+ return escapeXmlText(child.text);
523
+ }
524
+ return serializeElementToXml(child);
525
+ })
526
+ .join("");
527
+ if (children.length === 0) {
528
+ return `<${element.name}${attrs}/>`;
529
+ }
530
+ return `<${element.name}${attrs}>${children}</${element.name}>`;
531
+ }
532
+
533
+ function escapeXmlAttribute(text: string): string {
534
+ return text
535
+ .replace(/&/g, "&amp;")
536
+ .replace(/"/g, "&quot;")
537
+ .replace(/</g, "&lt;")
538
+ .replace(/>/g, "&gt;");
539
+ }
540
+
541
+ function escapeXmlText(text: string): string {
542
+ return text
543
+ .replace(/&/g, "&amp;")
544
+ .replace(/</g, "&lt;")
545
+ .replace(/>/g, "&gt;");
546
+ }
547
+
356
548
  // ---- Minimal XML parser (same pattern as parse-numbering.ts) ----
357
549
 
358
550
  function parseXml(xml: string): XmlElementNode {
@@ -4,6 +4,9 @@ import type {
4
4
  HeaderFooterVariant,
5
5
  InlineNode,
6
6
  ParagraphNode,
7
+ TableCellNode,
8
+ TableNode,
9
+ TableRowNode,
7
10
  TextMark,
8
11
  } from "../../model/canonical-document.ts";
9
12
 
@@ -13,6 +16,7 @@ export interface ParsedHeaderFooterReference {
13
16
  variant: HeaderFooterVariant;
14
17
  relationshipId: string;
15
18
  kind: "header" | "footer";
19
+ sectionIndex?: number;
16
20
  }
17
21
 
18
22
  export interface ParsedHeaderFooterDocument {
@@ -83,6 +87,7 @@ function collectSectPrReferences(
83
87
  element: XmlElementNode,
84
88
  refs: ParsedHeaderFooterReference[],
85
89
  ): void {
90
+ let sectionIndex = 0;
86
91
  for (const child of element.children) {
87
92
  if (child.type !== "element") {
88
93
  continue;
@@ -91,14 +96,16 @@ function collectSectPrReferences(
91
96
  const name = localName(child.name);
92
97
 
93
98
  if (name === "sectPr") {
94
- extractSectPrRefs(child, refs);
99
+ // Body-level sectPr is the final section
100
+ extractSectPrRefs(child, refs, sectionIndex);
95
101
  } else if (name === "p") {
96
- // Check paragraph properties for sectPr
102
+ // Check paragraph properties for sectPr (non-final section break)
97
103
  const pPr = findChildElementOptional(child, "pPr");
98
104
  if (pPr) {
99
105
  const sectPr = findChildElementOptional(pPr, "sectPr");
100
106
  if (sectPr) {
101
- extractSectPrRefs(sectPr, refs);
107
+ extractSectPrRefs(sectPr, refs, sectionIndex);
108
+ sectionIndex++;
102
109
  }
103
110
  }
104
111
  }
@@ -108,6 +115,7 @@ function collectSectPrReferences(
108
115
  function extractSectPrRefs(
109
116
  sectPr: XmlElementNode,
110
117
  refs: ParsedHeaderFooterReference[],
118
+ sectionIndex: number,
111
119
  ): void {
112
120
  for (const child of sectPr.children) {
113
121
  if (child.type !== "element") {
@@ -133,7 +141,7 @@ function extractSectPrRefs(
133
141
  (ref) => ref.relationshipId === relationshipId && ref.kind === kind,
134
142
  );
135
143
  if (!alreadyAdded) {
136
- refs.push({ variant, relationshipId, kind });
144
+ refs.push({ variant, relationshipId, kind, sectionIndex });
137
145
  }
138
146
  }
139
147
  }
@@ -172,12 +180,17 @@ function parseHdrFtrXml(
172
180
  if (name === "p") {
173
181
  blocks.push(parseParagraphElement(child));
174
182
  } else if (name === "tbl") {
175
- // Table in header/footer: store as opaque to preserve fidelity
176
- blocks.push({
177
- type: "opaque_block",
178
- fragmentId: "fragment:hdrftr-tbl",
179
- warningId: "warning:hdrftr-opaque-table",
180
- });
183
+ // Simple tables (no revisions, fields, or nested tables) are promoted
184
+ // to supported-roundtrip; structurally risky tables stay opaque.
185
+ if (isSimpleSecondaryStoryTable(child)) {
186
+ blocks.push(parseSimpleTableElement(child));
187
+ } else {
188
+ blocks.push({
189
+ type: "opaque_block",
190
+ fragmentId: "fragment:hdrftr-tbl",
191
+ warningId: "warning:hdrftr-opaque-table",
192
+ });
193
+ }
181
194
  } else {
182
195
  // Other block-level elements: treat as opaque
183
196
  blocks.push({
@@ -218,7 +231,7 @@ function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
218
231
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
219
232
  children.push(parseBookmarkElement(child));
220
233
  } else if (name === "fldSimple") {
221
- children.push(parseFieldElement(child));
234
+ pushFieldNode(children, child, "simple");
222
235
  }
223
236
  }
224
237
 
@@ -278,8 +291,8 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
278
291
  }
279
292
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
280
293
  nodes.push(parseBookmarkElement(child));
281
- } else if (name === "fldChar" || name === "instrText") {
282
- nodes.push(parseFieldElement(child));
294
+ } else if (name === "instrText") {
295
+ pushFieldNode(nodes, child, "complex");
283
296
  }
284
297
  }
285
298
 
@@ -327,22 +340,30 @@ function parseBookmarkElement(
327
340
  };
328
341
  }
329
342
 
330
- function parseFieldElement(element: XmlElementNode): Extract<InlineNode, { type: "field" }> {
331
- const rawFieldType =
332
- element.attributes["w:fldCharType"] ??
333
- element.attributes.fldCharType ??
334
- localName(element.name);
335
- const fieldType: "simple" | "complex" = rawFieldType === "complex" ? "complex" : "simple";
336
- const instruction =
337
- element.attributes["w:instr"] ??
338
- element.attributes.instr ??
339
- extractTextContent(element);
340
- return {
343
+ function pushFieldNode(
344
+ nodes: InlineNode[],
345
+ element: XmlElementNode,
346
+ fieldType: "simple" | "complex",
347
+ ): void {
348
+ const instruction = readFieldInstruction(element);
349
+ if (!instruction) {
350
+ return;
351
+ }
352
+
353
+ nodes.push({
341
354
  type: "field",
342
355
  fieldType,
343
356
  instruction,
344
357
  children: [],
345
- };
358
+ });
359
+ }
360
+
361
+ function readFieldInstruction(element: XmlElementNode): string | undefined {
362
+ const instruction =
363
+ element.attributes["w:instr"] ??
364
+ element.attributes.instr ??
365
+ extractTextContent(element);
366
+ return instruction.trim().length > 0 ? instruction : undefined;
346
367
  }
347
368
 
348
369
  function parseRunProperties(rElement: XmlElementNode): TextMark[] {
@@ -418,6 +439,189 @@ function localName(name: string): string {
418
439
  return separatorIndex >= 0 ? name.slice(separatorIndex + 1) : name;
419
440
  }
420
441
 
442
+ // ---- Simple secondary-story table support ----
443
+
444
+ /**
445
+ * Revision-bearing, field-bearing, or structurally risky elements that
446
+ * disqualify a secondary-story table from supported-roundtrip.
447
+ */
448
+ const RISKY_TABLE_ELEMENT_NAMES = new Set([
449
+ "ins",
450
+ "del",
451
+ "moveFrom",
452
+ "moveTo",
453
+ "tblPrChange",
454
+ "trPrChange",
455
+ "tcPrChange",
456
+ "rPrChange",
457
+ "pPrChange",
458
+ "sectPrChange",
459
+ "fldSimple",
460
+ "fldChar",
461
+ "instrText",
462
+ "sdt",
463
+ "customXml",
464
+ ]);
465
+
466
+ function isSimpleSecondaryStoryTable(tblElement: XmlElementNode): boolean {
467
+ return !containsRiskyElement(tblElement);
468
+ }
469
+
470
+ function containsRiskyElement(element: XmlElementNode): boolean {
471
+ for (const child of element.children) {
472
+ if (child.type !== "element") {
473
+ continue;
474
+ }
475
+ const name = localName(child.name);
476
+ if (RISKY_TABLE_ELEMENT_NAMES.has(name)) {
477
+ return true;
478
+ }
479
+ // Nested tables remain risky
480
+ if (name === "tbl") {
481
+ return true;
482
+ }
483
+ if (containsRiskyElement(child)) {
484
+ return true;
485
+ }
486
+ }
487
+ return false;
488
+ }
489
+
490
+ function parseSimpleTableElement(tblElement: XmlElementNode): TableNode {
491
+ let gridColumns: number[] = [];
492
+ const rows: TableRowNode[] = [];
493
+ let propertiesXml: string | undefined;
494
+ let styleId: string | undefined;
495
+
496
+ for (const child of tblElement.children) {
497
+ if (child.type !== "element") continue;
498
+ const name = localName(child.name);
499
+
500
+ if (name === "tblPr") {
501
+ propertiesXml = serializeElementToXml(child);
502
+ const pStyle = findChildElementOptional(child, "tblStyle");
503
+ styleId = pStyle?.attributes["w:val"] ?? pStyle?.attributes.val;
504
+ } else if (name === "tblGrid") {
505
+ gridColumns = readGridColumns(child);
506
+ } else if (name === "tr") {
507
+ rows.push(parseSimpleTableRow(child));
508
+ }
509
+ }
510
+
511
+ return {
512
+ type: "table",
513
+ ...(styleId ? { styleId } : {}),
514
+ ...(propertiesXml ? { propertiesXml } : {}),
515
+ gridColumns,
516
+ rows,
517
+ };
518
+ }
519
+
520
+ function readGridColumns(tblGrid: XmlElementNode): number[] {
521
+ const columns: number[] = [];
522
+ for (const child of tblGrid.children) {
523
+ if (child.type !== "element") continue;
524
+ if (localName(child.name) === "gridCol") {
525
+ const w = child.attributes["w:w"] ?? child.attributes.w ?? "0";
526
+ columns.push(Number.parseInt(w, 10) || 0);
527
+ }
528
+ }
529
+ return columns;
530
+ }
531
+
532
+ function parseSimpleTableRow(trElement: XmlElementNode): TableRowNode {
533
+ const cells: TableCellNode[] = [];
534
+ let propertiesXml: string | undefined;
535
+
536
+ for (const child of trElement.children) {
537
+ if (child.type !== "element") continue;
538
+ const name = localName(child.name);
539
+
540
+ if (name === "trPr") {
541
+ propertiesXml = serializeElementToXml(child);
542
+ } else if (name === "tc") {
543
+ cells.push(parseSimpleTableCell(child));
544
+ }
545
+ }
546
+
547
+ return {
548
+ type: "table_row",
549
+ ...(propertiesXml ? { propertiesXml } : {}),
550
+ cells,
551
+ };
552
+ }
553
+
554
+ function parseSimpleTableCell(tcElement: XmlElementNode): TableCellNode {
555
+ const children: BlockNode[] = [];
556
+ let propertiesXml: string | undefined;
557
+ let gridSpan: number | undefined;
558
+ let verticalMerge: "restart" | "continue" | undefined;
559
+
560
+ for (const child of tcElement.children) {
561
+ if (child.type !== "element") continue;
562
+ const name = localName(child.name);
563
+
564
+ if (name === "tcPr") {
565
+ propertiesXml = serializeElementToXml(child);
566
+ const gsEl = findChildElementOptional(child, "gridSpan");
567
+ const gsVal = gsEl?.attributes["w:val"] ?? gsEl?.attributes.val;
568
+ if (gsVal) gridSpan = Number.parseInt(gsVal, 10) || undefined;
569
+
570
+ const vmEl = findChildElementOptional(child, "vMerge");
571
+ if (vmEl) {
572
+ const vmVal = vmEl.attributes["w:val"] ?? vmEl.attributes.val ?? "continue";
573
+ verticalMerge = vmVal === "restart" ? "restart" : "continue";
574
+ }
575
+ } else if (name === "p") {
576
+ children.push(parseParagraphElement(child));
577
+ }
578
+ }
579
+
580
+ return {
581
+ type: "table_cell",
582
+ ...(propertiesXml ? { propertiesXml } : {}),
583
+ ...(gridSpan ? { gridSpan } : {}),
584
+ ...(verticalMerge ? { verticalMerge } : {}),
585
+ children: children.length > 0 ? children : [{ type: "paragraph", children: [] }],
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Serialize an XmlElementNode back to XML string for propertiesXml preservation.
591
+ */
592
+ function serializeElementToXml(element: XmlElementNode): string {
593
+ const attrs = Object.entries(element.attributes)
594
+ .map(([key, value]) => ` ${key}="${escapeXmlAttribute(value)}"`)
595
+ .join("");
596
+ const children = element.children
597
+ .map((child) => {
598
+ if (child.type === "text") {
599
+ return escapeXmlText(child.text);
600
+ }
601
+ return serializeElementToXml(child);
602
+ })
603
+ .join("");
604
+ if (children.length === 0) {
605
+ return `<${element.name}${attrs}/>`;
606
+ }
607
+ return `<${element.name}${attrs}>${children}</${element.name}>`;
608
+ }
609
+
610
+ function escapeXmlAttribute(text: string): string {
611
+ return text
612
+ .replace(/&/g, "&amp;")
613
+ .replace(/"/g, "&quot;")
614
+ .replace(/</g, "&lt;")
615
+ .replace(/>/g, "&gt;");
616
+ }
617
+
618
+ function escapeXmlText(text: string): string {
619
+ return text
620
+ .replace(/&/g, "&amp;")
621
+ .replace(/</g, "&lt;")
622
+ .replace(/>/g, "&gt;");
623
+ }
624
+
421
625
  // ---- Minimal XML parser (same pattern as parse-numbering.ts) ----
422
626
 
423
627
  function parseXml(xml: string): XmlElementNode {
@@ -14,6 +14,8 @@ export interface ParsedInlineMedia {
14
14
  contentType?: string;
15
15
  filename: string;
16
16
  altText?: string;
17
+ widthEmu?: number;
18
+ heightEmu?: number;
17
19
  display?: "inline" | "floating";
18
20
  floating?: {
19
21
  horizontalPosition?: {
@@ -85,6 +87,11 @@ export function parseInlineMediaXml(
85
87
  const altText = readAltText(docProperties);
86
88
  const floating = anchor ? readFloatingProperties(anchor) : undefined;
87
89
 
90
+ // Read extent dimensions (wp:extent cx/cy in EMUs)
91
+ const extent = findFirstDescendant(container, "extent");
92
+ const widthEmu = extent ? readEmuAttribute(extent, "cx") : undefined;
93
+ const heightEmu = extent ? readEmuAttribute(extent, "cy") : undefined;
94
+
88
95
  media.push({
89
96
  type: "image",
90
97
  mediaId: `media:${packagePartName.slice(1)}`,
@@ -93,6 +100,8 @@ export function parseInlineMediaXml(
93
100
  ...(mediaPart ? { contentType: mediaPart.contentType } : {}),
94
101
  filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1),
95
102
  ...(altText ? { altText } : {}),
103
+ ...(widthEmu !== undefined ? { widthEmu } : {}),
104
+ ...(heightEmu !== undefined ? { heightEmu } : {}),
96
105
  ...(anchor ? { display: "floating" as const } : {}),
97
106
  ...(floating ? { floating } : {}),
98
107
  });
@@ -238,6 +247,13 @@ function readOptionalAttribute(node: XmlElementNode, name: string): string | und
238
247
  ?? node.attributes[name];
239
248
  }
240
249
 
250
+ function readEmuAttribute(node: XmlElementNode, name: string): number | undefined {
251
+ const value = node.attributes[name] ?? node.attributes[`wp:${name}`];
252
+ if (value === undefined) return undefined;
253
+ const parsed = Number.parseInt(value, 10);
254
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
255
+ }
256
+
241
257
  function readBooleanAttribute(node: XmlElementNode, name: string): boolean | undefined {
242
258
  const value = readOptionalAttribute(node, name);
243
259
  if (value === undefined) {