@beyondwork/docx-react-component 1.0.17 → 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.
- package/README.md +8 -2
- package/package.json +32 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +374 -4
- package/src/api/session-state.ts +58 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +5 -1
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +329 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +1 -1
- package/src/index.ts +30 -0
- package/src/io/docx-session.ts +260 -39
- package/src/io/export/serialize-main-document.ts +202 -5
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/normalize/normalize-text.ts +63 -25
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-footnotes.ts +212 -20
- package/src/io/ooxml/parse-headers-footers.ts +229 -25
- package/src/io/ooxml/parse-inline-media.ts +16 -0
- package/src/io/ooxml/parse-main-document.ts +411 -6
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/model/canonical-document.ts +133 -3
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +2 -1
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +564 -0
- package/src/runtime/document-runtime.ts +265 -35
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
- package/src/runtime/session-capabilities.ts +2 -0
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +239 -12
- package/src/runtime/table-schema.ts +87 -5
- package/src/runtime/view-state.ts +459 -0
- package/src/ui/WordReviewEditor.tsx +1902 -312
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
- package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
- package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
- package/src/ui-tailwind/theme/editor-theme.css +123 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
- package/src/validation/compatibility-engine.ts +92 -20
- package/src/validation/diagnostics.ts +1 -0
- 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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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 === "
|
|
230
|
-
nodes
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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, "&")
|
|
536
|
+
.replace(/"/g, """)
|
|
537
|
+
.replace(/</g, "<")
|
|
538
|
+
.replace(/>/g, ">");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function escapeXmlText(text: string): string {
|
|
542
|
+
return text
|
|
543
|
+
.replace(/&/g, "&")
|
|
544
|
+
.replace(/</g, "<")
|
|
545
|
+
.replace(/>/g, ">");
|
|
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
|
-
|
|
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
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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 === "
|
|
282
|
-
nodes
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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, "&")
|
|
613
|
+
.replace(/"/g, """)
|
|
614
|
+
.replace(/</g, "<")
|
|
615
|
+
.replace(/>/g, ">");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function escapeXmlText(text: string): string {
|
|
619
|
+
return text
|
|
620
|
+
.replace(/&/g, "&")
|
|
621
|
+
.replace(/</g, "<")
|
|
622
|
+
.replace(/>/g, ">");
|
|
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) {
|