@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,183 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { EditorState, TextSelection } from "prosemirror-state";
|
|
3
|
+
import {
|
|
4
|
+
insertHorizontalRule,
|
|
5
|
+
insertLink,
|
|
6
|
+
insertPageBreak,
|
|
7
|
+
insertTable,
|
|
8
|
+
listEnter,
|
|
9
|
+
} from "./commands";
|
|
10
|
+
import { docxSchema } from "./schema";
|
|
11
|
+
import {
|
|
12
|
+
EMPTY_PARAGRAPH_PROPERTIES,
|
|
13
|
+
type ParagraphProperties,
|
|
14
|
+
} from "../model/properties";
|
|
15
|
+
import { pmToDocx } from "./pm_to_docx";
|
|
16
|
+
import { buildXml } from "@lotics/ooxml/xml";
|
|
17
|
+
|
|
18
|
+
function emptyState() {
|
|
19
|
+
const doc = docxSchema.nodes.doc.create({}, [
|
|
20
|
+
docxSchema.nodes.paragraph.create(),
|
|
21
|
+
]);
|
|
22
|
+
return EditorState.create({ doc, schema: docxSchema });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function applyAndRoundtrip(cmd: ReturnType<typeof insertTable>): string {
|
|
26
|
+
const state = emptyState();
|
|
27
|
+
let next = state;
|
|
28
|
+
const ok = cmd(state, (tr) => {
|
|
29
|
+
next = state.apply(tr);
|
|
30
|
+
});
|
|
31
|
+
expect(ok).toBe(true);
|
|
32
|
+
const docx = pmToDocx(next.doc, { parts: new Map(), documentAttrs: {} });
|
|
33
|
+
// Walk Block[] and produce a string for assertions.
|
|
34
|
+
const wrapped = {
|
|
35
|
+
"w:document": [{ "w:body": docx.body.children.map((b) => {
|
|
36
|
+
switch (b.kind) {
|
|
37
|
+
case "paragraph": {
|
|
38
|
+
const children: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
39
|
+
if (b.properties) children.push({ "w:pPr": [...b.properties] });
|
|
40
|
+
for (const inline of b.content) {
|
|
41
|
+
if (inline.kind === "run") {
|
|
42
|
+
const rChildren: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
43
|
+
if (inline.properties) rChildren.push({ "w:rPr": [...inline.properties] });
|
|
44
|
+
for (const c of inline.content) {
|
|
45
|
+
if (c.kind === "text") {
|
|
46
|
+
rChildren.push({ "w:t": c.value === "" ? [] : [{ "#text": c.value }] });
|
|
47
|
+
} else {
|
|
48
|
+
rChildren.push(c.xml);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
children.push({ "w:r": rChildren });
|
|
52
|
+
} else {
|
|
53
|
+
children.push(inline.xml);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { "w:p": children };
|
|
57
|
+
}
|
|
58
|
+
case "opaque_block":
|
|
59
|
+
return b.xml;
|
|
60
|
+
case "body_sect_pr":
|
|
61
|
+
return b.xml;
|
|
62
|
+
}
|
|
63
|
+
}) }],
|
|
64
|
+
} as import("@lotics/ooxml/xml").XmlElement;
|
|
65
|
+
return buildXml([wrapped]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("insertHorizontalRule", () => {
|
|
69
|
+
it("emits a paragraph with w:pBdr bottom border (Word's HR shape)", () => {
|
|
70
|
+
const xml = applyAndRoundtrip(insertHorizontalRule());
|
|
71
|
+
expect(xml).toContain("<w:pBdr>");
|
|
72
|
+
expect(xml).toContain("<w:bottom");
|
|
73
|
+
expect(xml).toContain('w:val="single"');
|
|
74
|
+
expect(xml).not.toContain("HorizontalRule");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("insertTable", () => {
|
|
79
|
+
it("emits tblPr (TableGrid) and tblGrid with the requested column count", () => {
|
|
80
|
+
const xml = applyAndRoundtrip(insertTable(2, 3));
|
|
81
|
+
expect(xml).toContain("<w:tblPr>");
|
|
82
|
+
expect(xml).toContain('w:val="TableGrid"');
|
|
83
|
+
expect(xml).toContain("<w:tblGrid>");
|
|
84
|
+
const gridCols = xml.match(/<w:gridCol/g) ?? [];
|
|
85
|
+
expect(gridCols.length).toBe(3);
|
|
86
|
+
const rows = xml.match(/<w:tr>/g) ?? [];
|
|
87
|
+
expect(rows.length).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("insertLink", () => {
|
|
92
|
+
it("accepts internal anchors", () => {
|
|
93
|
+
const state = emptyState();
|
|
94
|
+
let dispatched = false;
|
|
95
|
+
const ok = insertLink("#bookmark1", "see chapter 1")(state, () => {
|
|
96
|
+
dispatched = true;
|
|
97
|
+
});
|
|
98
|
+
expect(ok).toBe(true);
|
|
99
|
+
expect(dispatched).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects external URLs", () => {
|
|
103
|
+
const state = emptyState();
|
|
104
|
+
let dispatched = false;
|
|
105
|
+
const ok = insertLink("https://example.com")(state, () => {
|
|
106
|
+
dispatched = true;
|
|
107
|
+
});
|
|
108
|
+
expect(ok).toBe(false);
|
|
109
|
+
expect(dispatched).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects empty anchor names", () => {
|
|
113
|
+
const state = emptyState();
|
|
114
|
+
const ok = insertLink("#")(state, () => {});
|
|
115
|
+
expect(ok).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("insertPageBreak", () => {
|
|
120
|
+
it("inserts a paragraph with pageBreakBefore=true", () => {
|
|
121
|
+
const state = emptyState();
|
|
122
|
+
let next: EditorState = state;
|
|
123
|
+
insertPageBreak()(state, (tr) => {
|
|
124
|
+
next = state.apply(tr);
|
|
125
|
+
});
|
|
126
|
+
let found = false;
|
|
127
|
+
next.doc.descendants((node) => {
|
|
128
|
+
if (node.type.name !== "paragraph") return true;
|
|
129
|
+
const props = node.attrs.properties as ParagraphProperties | null;
|
|
130
|
+
if (props?.pageBreakBefore) found = true;
|
|
131
|
+
return false;
|
|
132
|
+
});
|
|
133
|
+
expect(found).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("listEnter", () => {
|
|
138
|
+
function listDoc(text: string) {
|
|
139
|
+
const para = docxSchema.nodes.paragraph.create(
|
|
140
|
+
{
|
|
141
|
+
properties: {
|
|
142
|
+
...EMPTY_PARAGRAPH_PROPERTIES,
|
|
143
|
+
numbering: { numId: 1, ilvl: 0 },
|
|
144
|
+
},
|
|
145
|
+
unknownProperties: [],
|
|
146
|
+
resolvedProperties: null,
|
|
147
|
+
resolvedBaseRun: null,
|
|
148
|
+
numberingLabel: null,
|
|
149
|
+
},
|
|
150
|
+
text.length === 0 ? undefined : [docxSchema.text(text)],
|
|
151
|
+
);
|
|
152
|
+
return docxSchema.nodes.doc.create({}, [para]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it("clears numbering when the cursor is in an empty list item", () => {
|
|
156
|
+
const doc = listDoc("");
|
|
157
|
+
const state = EditorState.create({
|
|
158
|
+
doc,
|
|
159
|
+
schema: docxSchema,
|
|
160
|
+
selection: TextSelection.create(doc, 1),
|
|
161
|
+
});
|
|
162
|
+
let next = state;
|
|
163
|
+
const ok = listEnter()(state, (tr) => {
|
|
164
|
+
next = state.apply(tr);
|
|
165
|
+
});
|
|
166
|
+
expect(ok).toBe(true);
|
|
167
|
+
const props = next.doc.firstChild?.attrs.properties as
|
|
168
|
+
| ParagraphProperties
|
|
169
|
+
| null;
|
|
170
|
+
expect(props?.numbering).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("yields control (returns false) when the list item is non-empty", () => {
|
|
174
|
+
const doc = listDoc("hello");
|
|
175
|
+
const state = EditorState.create({
|
|
176
|
+
doc,
|
|
177
|
+
schema: docxSchema,
|
|
178
|
+
selection: TextSelection.create(doc, 6),
|
|
179
|
+
});
|
|
180
|
+
const ok = listEnter()(state, () => {});
|
|
181
|
+
expect(ok).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseXml, type XmlElement } from "@lotics/ooxml/xml";
|
|
3
|
+
import {
|
|
4
|
+
createBlankDocx,
|
|
5
|
+
saveDocxToBuffer,
|
|
6
|
+
appendElement,
|
|
7
|
+
} from "@lotics/ooxml/document";
|
|
8
|
+
import { buildRun } from "@lotics/ooxml/builders";
|
|
9
|
+
import { parseDocx } from "../parse/parser";
|
|
10
|
+
import { docxToPm } from "./docx_to_pm";
|
|
11
|
+
import type { DocxDocument } from "../model/types";
|
|
12
|
+
|
|
13
|
+
const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
|
|
14
|
+
|
|
15
|
+
function paragraph(children: XmlElement[]): XmlElement {
|
|
16
|
+
return { "w:p": children };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function buildAndParse(
|
|
20
|
+
appendBlocks: (
|
|
21
|
+
doc: ReturnType<typeof createBlankDocx>,
|
|
22
|
+
paragraph: (children: XmlElement[]) => XmlElement,
|
|
23
|
+
) => void,
|
|
24
|
+
): Promise<DocxDocument> {
|
|
25
|
+
const doc = createBlankDocx();
|
|
26
|
+
appendBlocks(doc, paragraph);
|
|
27
|
+
const buffer = await saveDocxToBuffer(doc);
|
|
28
|
+
return parseDocx(new Uint8Array(buffer));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("docxToPm — basic structure", () => {
|
|
32
|
+
it("produces a doc node with at least one block", async () => {
|
|
33
|
+
const docx = await buildAndParse((doc, p) => {
|
|
34
|
+
appendElement(doc, p([buildRun("Hello")]));
|
|
35
|
+
});
|
|
36
|
+
const pm = docxToPm(docx);
|
|
37
|
+
expect(pm.type.name).toBe("doc");
|
|
38
|
+
expect(pm.childCount).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("synthesizes a paragraph when body produces no PM nodes", () => {
|
|
42
|
+
const docx: DocxDocument = {
|
|
43
|
+
parts: new Map(),
|
|
44
|
+
documentAttrs: {},
|
|
45
|
+
body: { children: [] },
|
|
46
|
+
};
|
|
47
|
+
const pm = docxToPm(docx);
|
|
48
|
+
expect(pm.childCount).toBe(1);
|
|
49
|
+
expect(pm.firstChild!.type.name).toBe("paragraph");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("docxToPm — runs and marks", () => {
|
|
54
|
+
it("emits text without marks for plain runs", async () => {
|
|
55
|
+
const docx = await buildAndParse((doc, p) => {
|
|
56
|
+
appendElement(doc, p([buildRun("plain text")]));
|
|
57
|
+
});
|
|
58
|
+
const pm = docxToPm(docx);
|
|
59
|
+
const para = pm.firstChild!;
|
|
60
|
+
expect(para.type.name).toBe("paragraph");
|
|
61
|
+
expect(para.firstChild!.text).toBe("plain text");
|
|
62
|
+
expect(para.firstChild!.marks).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("emits bold mark for <w:b/> runs", async () => {
|
|
66
|
+
const docx = await buildAndParse((doc, p) => {
|
|
67
|
+
appendElement(doc, p([buildRun("bold", { bold: true })]));
|
|
68
|
+
});
|
|
69
|
+
const pm = docxToPm(docx);
|
|
70
|
+
const text = pm.firstChild!.firstChild!;
|
|
71
|
+
const names = text.marks.map((m) => m.type.name);
|
|
72
|
+
expect(names).toContain("bold");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("preserves multiple marks on a single run (italic + bold)", async () => {
|
|
76
|
+
const docx = await buildAndParse((doc, p) => {
|
|
77
|
+
appendElement(
|
|
78
|
+
doc,
|
|
79
|
+
p([buildRun("both", { bold: true, italic: true })]),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
const pm = docxToPm(docx);
|
|
83
|
+
const text = pm.firstChild!.firstChild!;
|
|
84
|
+
const names = text.marks.map((m) => m.type.name).sort();
|
|
85
|
+
expect(names).toEqual(["bold", "italic"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("emits separate text segments for runs with different marks", async () => {
|
|
89
|
+
const docx = await buildAndParse((doc, p) => {
|
|
90
|
+
appendElement(
|
|
91
|
+
doc,
|
|
92
|
+
p([
|
|
93
|
+
buildRun("plain "),
|
|
94
|
+
buildRun("bold", { bold: true }),
|
|
95
|
+
buildRun(" plain again"),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
const pm = docxToPm(docx);
|
|
100
|
+
const para = pm.firstChild!;
|
|
101
|
+
expect(para.childCount).toBe(3);
|
|
102
|
+
expect(para.child(0).text).toBe("plain ");
|
|
103
|
+
expect(para.child(0).marks).toHaveLength(0);
|
|
104
|
+
expect(para.child(1).text).toBe("bold");
|
|
105
|
+
expect(para.child(1).marks.map((m) => m.type.name)).toEqual(["bold"]);
|
|
106
|
+
expect(para.child(2).text).toBe(" plain again");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("docxToPm — paragraph properties", () => {
|
|
111
|
+
it("captures recognized pPr into paragraph node attrs", () => {
|
|
112
|
+
const pPr = parseXml(
|
|
113
|
+
`<w:pPr ${NS}><w:pStyle w:val="Heading1"/><w:jc w:val="center"/></w:pPr>`,
|
|
114
|
+
)[0];
|
|
115
|
+
const docx: DocxDocument = {
|
|
116
|
+
parts: new Map(),
|
|
117
|
+
documentAttrs: {},
|
|
118
|
+
body: {
|
|
119
|
+
children: [
|
|
120
|
+
{
|
|
121
|
+
kind: "paragraph",
|
|
122
|
+
properties: pPr["w:pPr"] as XmlElement[],
|
|
123
|
+
content: [],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const pm = docxToPm(docx);
|
|
129
|
+
const para = pm.firstChild!;
|
|
130
|
+
expect(para.attrs.properties.styleId).toBe("Heading1");
|
|
131
|
+
expect(para.attrs.properties.alignment).toBe("center");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("partitions unrecognized pPr children into unknownProperties", () => {
|
|
135
|
+
const pPr = parseXml(
|
|
136
|
+
`<w:pPr ${NS}><w:pStyle w:val="Normal"/><w:divId w:val="123"/></w:pPr>`,
|
|
137
|
+
)[0];
|
|
138
|
+
const docx: DocxDocument = {
|
|
139
|
+
parts: new Map(),
|
|
140
|
+
documentAttrs: {},
|
|
141
|
+
body: {
|
|
142
|
+
children: [
|
|
143
|
+
{
|
|
144
|
+
kind: "paragraph",
|
|
145
|
+
properties: pPr["w:pPr"] as XmlElement[],
|
|
146
|
+
content: [],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const pm = docxToPm(docx);
|
|
152
|
+
const para = pm.firstChild!;
|
|
153
|
+
expect(para.attrs.properties.styleId).toBe("Normal");
|
|
154
|
+
expect(para.attrs.unknownProperties).toHaveLength(1);
|
|
155
|
+
expect(para.attrs.unknownProperties[0]).toMatchObject({
|
|
156
|
+
"w:divId": [],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("docxToPm — opaque pass-through", () => {
|
|
162
|
+
it("renders unknown body children as opaque_block", () => {
|
|
163
|
+
const sdtXml = parseXml(`<w:sdt ${NS}><w:sdtPr/></w:sdt>`)[0];
|
|
164
|
+
const docx: DocxDocument = {
|
|
165
|
+
parts: new Map(),
|
|
166
|
+
documentAttrs: {},
|
|
167
|
+
body: {
|
|
168
|
+
children: [
|
|
169
|
+
{ kind: "opaque_block", xml: sdtXml },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const pm = docxToPm(docx);
|
|
174
|
+
expect(pm.firstChild!.type.name).toBe("opaque_block");
|
|
175
|
+
expect(pm.firstChild!.attrs.xml).toEqual(sdtXml);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("converts <w:tbl> body children to a PM table node", () => {
|
|
179
|
+
const tblXml = parseXml(`<w:tbl ${NS}><w:tr><w:tc><w:p><w:r><w:t>x</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`)[0];
|
|
180
|
+
const docx: DocxDocument = {
|
|
181
|
+
parts: new Map(),
|
|
182
|
+
documentAttrs: {},
|
|
183
|
+
body: {
|
|
184
|
+
children: [
|
|
185
|
+
{ kind: "opaque_block", xml: tblXml },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const pm = docxToPm(docx);
|
|
190
|
+
expect(pm.firstChild!.type.name).toBe("table");
|
|
191
|
+
expect(pm.firstChild!.textContent).toContain("x");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("renders unknown inline children as opaque_inline carrying marks", async () => {
|
|
195
|
+
const fldSimple = parseXml(
|
|
196
|
+
`<w:fldSimple ${NS} w:instr=" PAGE "><w:r><w:t>3</w:t></w:r></w:fldSimple>`,
|
|
197
|
+
)[0];
|
|
198
|
+
const docx: DocxDocument = {
|
|
199
|
+
parts: new Map(),
|
|
200
|
+
documentAttrs: {},
|
|
201
|
+
body: {
|
|
202
|
+
children: [
|
|
203
|
+
{
|
|
204
|
+
kind: "paragraph",
|
|
205
|
+
properties: null,
|
|
206
|
+
content: [{ kind: "opaque_inline", xml: fldSimple }],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const pm = docxToPm(docx);
|
|
212
|
+
const para = pm.firstChild!;
|
|
213
|
+
expect(para.firstChild!.type.name).toBe("opaque_inline");
|
|
214
|
+
expect(para.firstChild!.attrs.xml).toEqual(fldSimple);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("renders unknown run children as opaque_inline", () => {
|
|
218
|
+
const sym = parseXml(
|
|
219
|
+
`<w:sym ${NS} w:font="Symbol" w:char="F0E0"/>`,
|
|
220
|
+
)[0];
|
|
221
|
+
const docx: DocxDocument = {
|
|
222
|
+
parts: new Map(),
|
|
223
|
+
documentAttrs: {},
|
|
224
|
+
body: {
|
|
225
|
+
children: [
|
|
226
|
+
{
|
|
227
|
+
kind: "paragraph",
|
|
228
|
+
properties: null,
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
kind: "run",
|
|
232
|
+
properties: null,
|
|
233
|
+
content: [{ kind: "opaque_run_child", xml: sym }],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
const pm = docxToPm(docx);
|
|
241
|
+
const para = pm.firstChild!;
|
|
242
|
+
expect(para.firstChild!.type.name).toBe("opaque_inline");
|
|
243
|
+
expect(para.firstChild!.attrs.xml).toEqual(sym);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("converts <w:br/> to a hard_break inline node", () => {
|
|
247
|
+
const br = parseXml(`<w:br ${NS}/>`)[0];
|
|
248
|
+
const docx: DocxDocument = {
|
|
249
|
+
parts: new Map(),
|
|
250
|
+
documentAttrs: {},
|
|
251
|
+
body: {
|
|
252
|
+
children: [
|
|
253
|
+
{
|
|
254
|
+
kind: "paragraph",
|
|
255
|
+
properties: null,
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
kind: "run",
|
|
259
|
+
properties: null,
|
|
260
|
+
content: [{ kind: "opaque_run_child", xml: br }],
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const pm = docxToPm(docx);
|
|
268
|
+
expect(pm.firstChild!.firstChild!.type.name).toBe("hard_break");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("docxToPm — sections", () => {
|
|
273
|
+
it("emits a final section_break for body-level sectPr", async () => {
|
|
274
|
+
const docx = await buildAndParse((doc, p) => {
|
|
275
|
+
appendElement(doc, p([buildRun("body")]));
|
|
276
|
+
});
|
|
277
|
+
const pm = docxToPm(docx);
|
|
278
|
+
let breakCount = 0;
|
|
279
|
+
let finalCount = 0;
|
|
280
|
+
pm.descendants((node) => {
|
|
281
|
+
if (node.type.name === "section_break") {
|
|
282
|
+
breakCount += 1;
|
|
283
|
+
if (node.attrs.isFinal === true) finalCount += 1;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
});
|
|
287
|
+
expect(breakCount).toBe(1);
|
|
288
|
+
expect(finalCount).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("emits an inline section_break when sectPr is nested in a paragraph", () => {
|
|
292
|
+
const inlineSect = parseXml(
|
|
293
|
+
`<w:sectPr ${NS}><w:type w:val="continuous"/></w:sectPr>`,
|
|
294
|
+
)[0];
|
|
295
|
+
const finalSect = parseXml(
|
|
296
|
+
`<w:sectPr ${NS}><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>`,
|
|
297
|
+
)[0];
|
|
298
|
+
const docx: DocxDocument = {
|
|
299
|
+
parts: new Map(),
|
|
300
|
+
documentAttrs: {},
|
|
301
|
+
body: {
|
|
302
|
+
children: [
|
|
303
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
304
|
+
{
|
|
305
|
+
kind: "paragraph",
|
|
306
|
+
properties: [inlineSect],
|
|
307
|
+
content: [],
|
|
308
|
+
},
|
|
309
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
310
|
+
{ kind: "body_sect_pr", xml: finalSect },
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
const pm = docxToPm(docx);
|
|
315
|
+
const breaks: { isFinal: boolean; type: string }[] = [];
|
|
316
|
+
pm.descendants((node) => {
|
|
317
|
+
if (node.type.name === "section_break") {
|
|
318
|
+
breaks.push({
|
|
319
|
+
isFinal: node.attrs.isFinal as boolean,
|
|
320
|
+
type: (node.attrs.properties as { type: string }).type,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
});
|
|
325
|
+
expect(breaks).toEqual([
|
|
326
|
+
{ isFinal: false, type: "continuous" },
|
|
327
|
+
{ isFinal: true, type: "nextPage" },
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
});
|