@lotics/docx 0.1.0
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/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- package/src/serialize/serializer.ts +114 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
borderToCss,
|
|
4
|
+
parseBorders,
|
|
5
|
+
tableBordersFromTblPr,
|
|
6
|
+
DEFAULT_TABLE_BORDER,
|
|
7
|
+
} from "./table_borders";
|
|
8
|
+
|
|
9
|
+
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
10
|
+
|
|
11
|
+
function tblPrWithBorders(
|
|
12
|
+
borders: Record<string, { val?: string; sz?: string; color?: string }>,
|
|
13
|
+
): import("@lotics/ooxml/xml").XmlElement {
|
|
14
|
+
const borderEls = Object.entries(borders).map(([tag, attrs]) => ({
|
|
15
|
+
[`w:${tag}`]: [],
|
|
16
|
+
":@": {
|
|
17
|
+
"@_w:val": attrs.val ?? "single",
|
|
18
|
+
...(attrs.sz ? { "@_w:sz": attrs.sz } : {}),
|
|
19
|
+
...(attrs.color ? { "@_w:color": attrs.color } : {}),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
return {
|
|
23
|
+
"w:tblPr": [{ "w:tblBorders": borderEls }],
|
|
24
|
+
":@": { "@_xmlns:w": W_NS },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("parseBorders", () => {
|
|
29
|
+
it("returns empty object on null input", () => {
|
|
30
|
+
expect(parseBorders(null)).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses single-pt single-style top border", () => {
|
|
34
|
+
const el = {
|
|
35
|
+
"w:tblBorders": [
|
|
36
|
+
{
|
|
37
|
+
"w:top": [],
|
|
38
|
+
":@": { "@_w:val": "single", "@_w:sz": "8", "@_w:color": "FF0000" },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const borders = parseBorders(el as import("@lotics/ooxml/xml").XmlElement);
|
|
43
|
+
expect(borders.top).toEqual({ style: "single", color: "FF0000", sizePts: 1 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("treats nil/none as no border", () => {
|
|
47
|
+
const el = {
|
|
48
|
+
"w:tblBorders": [{ "w:top": [], ":@": { "@_w:val": "nil" } }],
|
|
49
|
+
};
|
|
50
|
+
const borders = parseBorders(el as import("@lotics/ooxml/xml").XmlElement);
|
|
51
|
+
expect(borders.top).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("normalizes start/end aliases to left/right", () => {
|
|
55
|
+
const el = {
|
|
56
|
+
"w:tblBorders": [
|
|
57
|
+
{ "w:start": [], ":@": { "@_w:val": "single" } },
|
|
58
|
+
{ "w:end": [], ":@": { "@_w:val": "single" } },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const borders = parseBorders(el as import("@lotics/ooxml/xml").XmlElement);
|
|
62
|
+
expect(borders.left).toBeDefined();
|
|
63
|
+
expect(borders.right).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("treats auto color as currentColor (null)", () => {
|
|
67
|
+
const el = {
|
|
68
|
+
"w:tblBorders": [
|
|
69
|
+
{ "w:top": [], ":@": { "@_w:val": "single", "@_w:color": "auto" } },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
const borders = parseBorders(el as import("@lotics/ooxml/xml").XmlElement);
|
|
73
|
+
expect(borders.top?.color).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clamps sizePts to a minimum of 0.25", () => {
|
|
77
|
+
const el = {
|
|
78
|
+
"w:tblBorders": [{ "w:top": [], ":@": { "@_w:val": "single", "@_w:sz": "0" } }],
|
|
79
|
+
};
|
|
80
|
+
const borders = parseBorders(el as import("@lotics/ooxml/xml").XmlElement);
|
|
81
|
+
expect(borders.top?.sizePts).toBeCloseTo(0.25);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("tableBordersFromTblPr", () => {
|
|
86
|
+
it("returns empty object when tblPr is null", () => {
|
|
87
|
+
expect(tableBordersFromTblPr(null)).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("extracts borders from a tblPr element", () => {
|
|
91
|
+
const el = tblPrWithBorders({
|
|
92
|
+
top: { val: "single", sz: "12", color: "00FF00" },
|
|
93
|
+
bottom: { val: "double", sz: "16" },
|
|
94
|
+
});
|
|
95
|
+
const borders = tableBordersFromTblPr(el);
|
|
96
|
+
expect(borders.top?.color).toBe("00FF00");
|
|
97
|
+
expect(borders.top?.sizePts).toBe(1.5);
|
|
98
|
+
expect(borders.bottom?.style).toBe("double");
|
|
99
|
+
expect(borders.bottom?.sizePts).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("borderToCss", () => {
|
|
104
|
+
it("emits px+style+color shorthand", () => {
|
|
105
|
+
expect(borderToCss(DEFAULT_TABLE_BORDER)).toBe("1px solid #000000");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("maps double / dotted / dashed styles", () => {
|
|
109
|
+
expect(borderToCss({ style: "double", color: "000000", sizePts: 1.5 })).toContain("double");
|
|
110
|
+
expect(borderToCss({ style: "dotted", color: "000000", sizePts: 1 })).toContain("dotted");
|
|
111
|
+
expect(borderToCss({ style: "dashed", color: "000000", sizePts: 1 })).toContain("dashed");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to currentColor when color is null", () => {
|
|
115
|
+
expect(borderToCss({ style: "single", color: null, sizePts: 1 })).toContain("currentColor");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAttr,
|
|
3
|
+
getChildren,
|
|
4
|
+
getTagName,
|
|
5
|
+
type XmlElement,
|
|
6
|
+
} from "@lotics/ooxml/xml";
|
|
7
|
+
|
|
8
|
+
export type Border = {
|
|
9
|
+
/**
|
|
10
|
+
* OOXML border style: single, double, dashed, dotted, etc. The CSS
|
|
11
|
+
* mapping is in `borderToCss` below.
|
|
12
|
+
*/
|
|
13
|
+
style: string;
|
|
14
|
+
/** Hex color (no leading #), or null for "auto" → CSS currentColor. */
|
|
15
|
+
color: string | null;
|
|
16
|
+
/** Border width in points. OOXML stores eighths-of-a-point; we convert. */
|
|
17
|
+
sizePts: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TableBorders = {
|
|
21
|
+
top?: Border;
|
|
22
|
+
left?: Border;
|
|
23
|
+
bottom?: Border;
|
|
24
|
+
right?: Border;
|
|
25
|
+
insideH?: Border;
|
|
26
|
+
insideV?: Border;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const NONE_STYLES = new Set(["nil", "none"]);
|
|
30
|
+
|
|
31
|
+
/** Word's default `TableGrid` style: single 0.5pt black on every edge. */
|
|
32
|
+
export const DEFAULT_TABLE_BORDER: Border = {
|
|
33
|
+
style: "single",
|
|
34
|
+
color: "000000",
|
|
35
|
+
sizePts: 0.5,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_TABLE_BORDERS: TableBorders = {
|
|
39
|
+
top: DEFAULT_TABLE_BORDER,
|
|
40
|
+
left: DEFAULT_TABLE_BORDER,
|
|
41
|
+
bottom: DEFAULT_TABLE_BORDER,
|
|
42
|
+
right: DEFAULT_TABLE_BORDER,
|
|
43
|
+
insideH: DEFAULT_TABLE_BORDER,
|
|
44
|
+
insideV: DEFAULT_TABLE_BORDER,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function parseBorderEl(el: XmlElement): Border | null {
|
|
48
|
+
const style = getAttr(el, "w:val") ?? "single";
|
|
49
|
+
if (NONE_STYLES.has(style)) return null;
|
|
50
|
+
const colorRaw = getAttr(el, "w:color");
|
|
51
|
+
const color = !colorRaw || colorRaw === "auto" ? null : colorRaw;
|
|
52
|
+
const sizeAttr = getAttr(el, "w:sz");
|
|
53
|
+
const sizePts = sizeAttr
|
|
54
|
+
? Math.max(0.25, Number.parseInt(sizeAttr, 10) / 8)
|
|
55
|
+
: 0.5;
|
|
56
|
+
return { style, color, sizePts };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse the set of borders from a `<w:tblBorders>` or `<w:tcBorders>` element.
|
|
61
|
+
* Returns an empty object when the element is null; partial coverage is
|
|
62
|
+
* preserved (e.g. only `top` set).
|
|
63
|
+
*/
|
|
64
|
+
export function parseBorders(borderEl: XmlElement | null): TableBorders {
|
|
65
|
+
if (!borderEl) return {};
|
|
66
|
+
const out: TableBorders = {};
|
|
67
|
+
for (const child of getChildren(borderEl)) {
|
|
68
|
+
const tag = getTagName(child);
|
|
69
|
+
const border = parseBorderEl(child);
|
|
70
|
+
if (!border) continue;
|
|
71
|
+
if (tag === "w:top") out.top = border;
|
|
72
|
+
else if (tag === "w:left" || tag === "w:start") out.left = border;
|
|
73
|
+
else if (tag === "w:bottom") out.bottom = border;
|
|
74
|
+
else if (tag === "w:right" || tag === "w:end") out.right = border;
|
|
75
|
+
else if (tag === "w:insideH") out.insideH = border;
|
|
76
|
+
else if (tag === "w:insideV") out.insideV = border;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Walk a `<w:tblPr>` element and pluck the `<w:tblBorders>` child. */
|
|
82
|
+
export function tableBordersFromTblPr(
|
|
83
|
+
tblPr: XmlElement | null,
|
|
84
|
+
): TableBorders {
|
|
85
|
+
if (!tblPr) return {};
|
|
86
|
+
for (const child of getChildren(tblPr)) {
|
|
87
|
+
if (getTagName(child) === "w:tblBorders") {
|
|
88
|
+
return parseBorders(child);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convert a parsed Border to a CSS `border-*: ...;` shorthand value. Maps
|
|
96
|
+
* common OOXML styles to their CSS equivalents; falls back to `solid`.
|
|
97
|
+
*/
|
|
98
|
+
export function borderToCss(b: Border): string {
|
|
99
|
+
const cssStyle = (() => {
|
|
100
|
+
switch (b.style) {
|
|
101
|
+
case "double":
|
|
102
|
+
case "doubleWave":
|
|
103
|
+
return "double";
|
|
104
|
+
case "dotted":
|
|
105
|
+
case "dotDash":
|
|
106
|
+
case "dotDotDash":
|
|
107
|
+
return "dotted";
|
|
108
|
+
case "dashed":
|
|
109
|
+
case "dashSmallGap":
|
|
110
|
+
case "dashLong":
|
|
111
|
+
return "dashed";
|
|
112
|
+
case "wave":
|
|
113
|
+
case "wavy":
|
|
114
|
+
case "wavyDouble":
|
|
115
|
+
case "wavyHeavy":
|
|
116
|
+
return "wavy";
|
|
117
|
+
case "thinThickSmallGap":
|
|
118
|
+
case "thickThinSmallGap":
|
|
119
|
+
return "ridge";
|
|
120
|
+
case "single":
|
|
121
|
+
case "thick":
|
|
122
|
+
default:
|
|
123
|
+
return "solid";
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
// 1pt = 1.333… px at 96dpi.
|
|
127
|
+
const widthPx = Math.max(1, Math.round(b.sizePts * (96 / 72)));
|
|
128
|
+
const color = b.color ? `#${b.color}` : "currentColor";
|
|
129
|
+
return `${widthPx}px ${cssStyle} ${color}`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseXml } from "@lotics/ooxml/xml";
|
|
3
|
+
import { docxToPm } from "./docx_to_pm";
|
|
4
|
+
import { pmToDocx } from "./pm_to_docx";
|
|
5
|
+
import type { DocxDocument } from "../model/types";
|
|
6
|
+
|
|
7
|
+
const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
|
|
8
|
+
|
|
9
|
+
function tableDocx(tableXml: string): DocxDocument {
|
|
10
|
+
const xml = parseXml(`<w:tbl ${NS}>${tableXml}</w:tbl>`)[0];
|
|
11
|
+
return {
|
|
12
|
+
parts: new Map(),
|
|
13
|
+
documentAttrs: {},
|
|
14
|
+
body: { children: [{ kind: "opaque_block", xml }] },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("xmlToTableNode — basic structure", () => {
|
|
19
|
+
it("produces a 2x2 PM table with cell text content", () => {
|
|
20
|
+
const docx = tableDocx(`
|
|
21
|
+
<w:tr>
|
|
22
|
+
<w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
|
|
23
|
+
<w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
|
|
24
|
+
</w:tr>
|
|
25
|
+
<w:tr>
|
|
26
|
+
<w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
|
|
27
|
+
<w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
|
|
28
|
+
</w:tr>
|
|
29
|
+
`);
|
|
30
|
+
const pm = docxToPm(docx);
|
|
31
|
+
const table = pm.firstChild!;
|
|
32
|
+
expect(table.type.name).toBe("table");
|
|
33
|
+
expect(table.childCount).toBe(2);
|
|
34
|
+
expect(table.firstChild!.childCount).toBe(2);
|
|
35
|
+
expect(table.firstChild!.firstChild!.textContent).toBe("A1");
|
|
36
|
+
expect(table.lastChild!.lastChild!.textContent).toBe("B2");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves marks inside cells", () => {
|
|
40
|
+
const docx = tableDocx(`
|
|
41
|
+
<w:tr><w:tc>
|
|
42
|
+
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r></w:p>
|
|
43
|
+
</w:tc></w:tr>
|
|
44
|
+
`);
|
|
45
|
+
const pm = docxToPm(docx);
|
|
46
|
+
const cell = pm.firstChild!.firstChild!.firstChild!;
|
|
47
|
+
const para = cell.firstChild!;
|
|
48
|
+
const text = para.firstChild!;
|
|
49
|
+
expect(text.marks.map((m) => m.type.name)).toContain("bold");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("xmlToTableNode — gridSpan / vMerge", () => {
|
|
54
|
+
it("converts gridSpan into colspan", () => {
|
|
55
|
+
const docx = tableDocx(`
|
|
56
|
+
<w:tr>
|
|
57
|
+
<w:tc>
|
|
58
|
+
<w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
|
|
59
|
+
<w:p><w:r><w:t>spanning</w:t></w:r></w:p>
|
|
60
|
+
</w:tc>
|
|
61
|
+
</w:tr>
|
|
62
|
+
`);
|
|
63
|
+
const pm = docxToPm(docx);
|
|
64
|
+
const cell = pm.firstChild!.firstChild!.firstChild!;
|
|
65
|
+
expect(cell.attrs.colspan).toBe(2);
|
|
66
|
+
expect(cell.attrs.gridSpan).toBe(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("converts vMerge restart + continue into rowspan", () => {
|
|
70
|
+
const docx = tableDocx(`
|
|
71
|
+
<w:tr>
|
|
72
|
+
<w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>top</w:t></w:r></w:p></w:tc>
|
|
73
|
+
<w:tc><w:p/></w:tc>
|
|
74
|
+
</w:tr>
|
|
75
|
+
<w:tr>
|
|
76
|
+
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
|
|
77
|
+
<w:tc><w:p/></w:tc>
|
|
78
|
+
</w:tr>
|
|
79
|
+
<w:tr>
|
|
80
|
+
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
|
|
81
|
+
<w:tc><w:p/></w:tc>
|
|
82
|
+
</w:tr>
|
|
83
|
+
`);
|
|
84
|
+
const pm = docxToPm(docx);
|
|
85
|
+
const table = pm.firstChild!;
|
|
86
|
+
const firstRow = table.firstChild!;
|
|
87
|
+
const originCell = firstRow.firstChild!;
|
|
88
|
+
expect(originCell.attrs.rowspan).toBe(3);
|
|
89
|
+
expect(originCell.attrs.vMerge).toBe("restart");
|
|
90
|
+
expect(originCell.textContent).toBe("top");
|
|
91
|
+
|
|
92
|
+
const secondRow = table.child(1);
|
|
93
|
+
expect(secondRow.childCount).toBe(1);
|
|
94
|
+
const thirdRow = table.child(2);
|
|
95
|
+
expect(thirdRow.childCount).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("xmlToTableNode — cell properties", () => {
|
|
100
|
+
it("captures borders + shading + vAlign as typed cell attrs", () => {
|
|
101
|
+
const docx = tableDocx(`
|
|
102
|
+
<w:tr><w:tc>
|
|
103
|
+
<w:tcPr>
|
|
104
|
+
<w:tcBorders>
|
|
105
|
+
<w:top w:val="single" w:sz="8" w:color="333333"/>
|
|
106
|
+
</w:tcBorders>
|
|
107
|
+
<w:shd w:val="clear" w:color="auto" w:fill="EEEEEE"/>
|
|
108
|
+
<w:vAlign w:val="center"/>
|
|
109
|
+
</w:tcPr>
|
|
110
|
+
<w:p><w:r><w:t>x</w:t></w:r></w:p>
|
|
111
|
+
</w:tc></w:tr>
|
|
112
|
+
`);
|
|
113
|
+
const pm = docxToPm(docx);
|
|
114
|
+
const cell = pm.firstChild!.firstChild!.firstChild!;
|
|
115
|
+
expect(cell.attrs.vAlign).toBe("center");
|
|
116
|
+
expect((cell.attrs.shading as { fill: string }).fill).toBe("EEEEEE");
|
|
117
|
+
const borders = cell.attrs.borders as { top: { color: string; sizePts: number } };
|
|
118
|
+
expect(borders.top.color).toBe("333333");
|
|
119
|
+
expect(borders.top.sizePts).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("xmlToTableNode — nested tables", () => {
|
|
124
|
+
it("recursively converts tables inside cells", () => {
|
|
125
|
+
const docx = tableDocx(`
|
|
126
|
+
<w:tr><w:tc>
|
|
127
|
+
<w:tbl>
|
|
128
|
+
<w:tr><w:tc><w:p><w:r><w:t>inner</w:t></w:r></w:p></w:tc></w:tr>
|
|
129
|
+
</w:tbl>
|
|
130
|
+
</w:tc></w:tr>
|
|
131
|
+
`);
|
|
132
|
+
const pm = docxToPm(docx);
|
|
133
|
+
const cell = pm.firstChild!.firstChild!.firstChild!;
|
|
134
|
+
const innerTable = cell.firstChild!;
|
|
135
|
+
expect(innerTable.type.name).toBe("table");
|
|
136
|
+
expect(innerTable.textContent).toContain("inner");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("tableNodeToXml — roundtrip", () => {
|
|
141
|
+
function reparse(docx: DocxDocument) {
|
|
142
|
+
const pm = docxToPm(docx);
|
|
143
|
+
return pmToDocx(pm, {
|
|
144
|
+
parts: docx.parts,
|
|
145
|
+
documentAttrs: docx.documentAttrs,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it("preserves text content through PM → OOXML → PM cycle", () => {
|
|
150
|
+
const docx = tableDocx(`
|
|
151
|
+
<w:tr>
|
|
152
|
+
<w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
|
|
153
|
+
<w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
|
|
154
|
+
</w:tr>
|
|
155
|
+
`);
|
|
156
|
+
const round = reparse(docx);
|
|
157
|
+
const pm = docxToPm(round);
|
|
158
|
+
expect(pm.firstChild!.textContent).toContain("A1");
|
|
159
|
+
expect(pm.firstChild!.textContent).toContain("B1");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("preserves rowspan via vMerge restart/continue chain", () => {
|
|
163
|
+
const docx = tableDocx(`
|
|
164
|
+
<w:tr>
|
|
165
|
+
<w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>top</w:t></w:r></w:p></w:tc>
|
|
166
|
+
<w:tc><w:p><w:r><w:t>r1c2</w:t></w:r></w:p></w:tc>
|
|
167
|
+
</w:tr>
|
|
168
|
+
<w:tr>
|
|
169
|
+
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
|
|
170
|
+
<w:tc><w:p><w:r><w:t>r2c2</w:t></w:r></w:p></w:tc>
|
|
171
|
+
</w:tr>
|
|
172
|
+
`);
|
|
173
|
+
const pm = docxToPm(docx);
|
|
174
|
+
expect(pm.firstChild!.firstChild!.firstChild!.attrs.rowspan).toBe(2);
|
|
175
|
+
|
|
176
|
+
const round = reparse(docx);
|
|
177
|
+
const pm2 = docxToPm(round);
|
|
178
|
+
expect(pm2.firstChild!.firstChild!.firstChild!.attrs.rowspan).toBe(2);
|
|
179
|
+
expect(pm2.firstChild!.firstChild!.firstChild!.textContent).toBe("top");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("preserves colspan via gridSpan", () => {
|
|
183
|
+
const docx = tableDocx(`
|
|
184
|
+
<w:tr>
|
|
185
|
+
<w:tc>
|
|
186
|
+
<w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
|
|
187
|
+
<w:p><w:r><w:t>spanning</w:t></w:r></w:p>
|
|
188
|
+
</w:tc>
|
|
189
|
+
</w:tr>
|
|
190
|
+
`);
|
|
191
|
+
const round = reparse(docx);
|
|
192
|
+
const pm = docxToPm(round);
|
|
193
|
+
const cell = pm.firstChild!.firstChild!.firstChild!;
|
|
194
|
+
expect(cell.attrs.colspan).toBe(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("preserves table properties (tblPr) verbatim through roundtrip", () => {
|
|
198
|
+
const docx = tableDocx(`
|
|
199
|
+
<w:tblPr>
|
|
200
|
+
<w:tblStyle w:val="GridTable1Light"/>
|
|
201
|
+
<w:tblW w:w="5000" w:type="pct"/>
|
|
202
|
+
</w:tblPr>
|
|
203
|
+
<w:tblGrid>
|
|
204
|
+
<w:gridCol w:w="2400"/>
|
|
205
|
+
<w:gridCol w:w="2400"/>
|
|
206
|
+
</w:tblGrid>
|
|
207
|
+
<w:tr>
|
|
208
|
+
<w:tc><w:p><w:r><w:t>x</w:t></w:r></w:p></w:tc>
|
|
209
|
+
<w:tc><w:p><w:r><w:t>y</w:t></w:r></w:p></w:tc>
|
|
210
|
+
</w:tr>
|
|
211
|
+
`);
|
|
212
|
+
const round = reparse(docx);
|
|
213
|
+
const block = round.body.children[0];
|
|
214
|
+
expect(block.kind).toBe("opaque_block");
|
|
215
|
+
if (block.kind !== "opaque_block") return;
|
|
216
|
+
const tblChildren = block.xml["w:tbl"] as Array<Record<string, unknown>>;
|
|
217
|
+
const tagsAtRoot = tblChildren.map((c) => Object.keys(c).find((k) => k !== ":@"));
|
|
218
|
+
expect(tagsAtRoot).toContain("w:tblPr");
|
|
219
|
+
expect(tagsAtRoot).toContain("w:tblGrid");
|
|
220
|
+
});
|
|
221
|
+
});
|