@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,271 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
3
|
+
import { parseXml } from "@lotics/ooxml/xml";
|
|
4
|
+
import { parseDrawing } from "../parse/drawing";
|
|
5
|
+
import { parseFootnoteRegistry } from "../parse/footnotes";
|
|
6
|
+
import { docxToPm } from "./docx_to_pm";
|
|
7
|
+
import {
|
|
8
|
+
collectReferencedFootnotes,
|
|
9
|
+
renderFootnotesSection,
|
|
10
|
+
} from "../render/footnotes_view";
|
|
11
|
+
import { createReadOnlyView } from "../render/view";
|
|
12
|
+
import { buildFontRegistry } from "../fonts/registry";
|
|
13
|
+
import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
|
|
14
|
+
import { docxSchema } from "./schema";
|
|
15
|
+
import type { DocxDocument } from "../model/types";
|
|
16
|
+
import type { Section } from "../model/sections";
|
|
17
|
+
|
|
18
|
+
const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"`;
|
|
19
|
+
|
|
20
|
+
describe("anchored image parsing", () => {
|
|
21
|
+
it("detects wrapSquare + left float side", () => {
|
|
22
|
+
const xml = parseXml(`
|
|
23
|
+
<w:drawing ${NS}>
|
|
24
|
+
<wp:anchor>
|
|
25
|
+
<wp:positionH relativeFrom="margin"><wp:align>left</wp:align></wp:positionH>
|
|
26
|
+
<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
|
|
27
|
+
<wp:extent cx="914400" cy="914400"/>
|
|
28
|
+
<wp:wrapSquare wrapText="bothSides"/>
|
|
29
|
+
<wp:docPr id="1" name="img"/>
|
|
30
|
+
<a:graphic><a:graphicData>
|
|
31
|
+
<pic:pic><pic:blipFill><a:blip r:embed="rId5"/></pic:blipFill></pic:pic>
|
|
32
|
+
</a:graphicData></a:graphic>
|
|
33
|
+
</wp:anchor>
|
|
34
|
+
</w:drawing>`)[0];
|
|
35
|
+
const info = parseDrawing(xml);
|
|
36
|
+
expect(info).toMatchObject({
|
|
37
|
+
relationshipId: "rId5",
|
|
38
|
+
inline: false,
|
|
39
|
+
wrap: "square",
|
|
40
|
+
floatSide: "left",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("detects wrapTopAndBottom", () => {
|
|
45
|
+
const xml = parseXml(`
|
|
46
|
+
<w:drawing ${NS}>
|
|
47
|
+
<wp:anchor>
|
|
48
|
+
<wp:extent cx="100" cy="100"/>
|
|
49
|
+
<wp:wrapTopAndBottom/>
|
|
50
|
+
<wp:docPr id="1" name="x"/>
|
|
51
|
+
<a:graphic><a:graphicData>
|
|
52
|
+
<pic:pic><pic:blipFill><a:blip r:embed="rId9"/></pic:blipFill></pic:pic>
|
|
53
|
+
</a:graphicData></a:graphic>
|
|
54
|
+
</wp:anchor>
|
|
55
|
+
</w:drawing>`)[0];
|
|
56
|
+
const info = parseDrawing(xml);
|
|
57
|
+
expect(info?.wrap).toBe("topAndBottom");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("inline drawings remain inline=true with no wrap", () => {
|
|
61
|
+
const xml = parseXml(`
|
|
62
|
+
<w:drawing ${NS}>
|
|
63
|
+
<wp:inline>
|
|
64
|
+
<wp:extent cx="100" cy="100"/>
|
|
65
|
+
<wp:docPr id="1" name="x"/>
|
|
66
|
+
<a:graphic><a:graphicData>
|
|
67
|
+
<pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill></pic:pic>
|
|
68
|
+
</a:graphicData></a:graphic>
|
|
69
|
+
</wp:inline>
|
|
70
|
+
</w:drawing>`)[0];
|
|
71
|
+
const info = parseDrawing(xml);
|
|
72
|
+
expect(info?.inline).toBe(true);
|
|
73
|
+
expect(info?.wrap).toBeNull();
|
|
74
|
+
expect(info?.floatSide).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("anchored image rendering", () => {
|
|
79
|
+
let host: HTMLElement;
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
document.body.innerHTML = "";
|
|
82
|
+
host = document.createElement("div");
|
|
83
|
+
document.body.appendChild(host);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("applies float CSS to anchored images with wrap=square", () => {
|
|
87
|
+
const xml = parseXml(`
|
|
88
|
+
<w:drawing ${NS}>
|
|
89
|
+
<wp:anchor>
|
|
90
|
+
<wp:positionH><wp:align>right</wp:align></wp:positionH>
|
|
91
|
+
<wp:extent cx="914400" cy="914400"/>
|
|
92
|
+
<wp:wrapSquare/>
|
|
93
|
+
<wp:docPr id="1" name="x"/>
|
|
94
|
+
<a:graphic><a:graphicData>
|
|
95
|
+
<pic:pic><pic:blipFill><a:blip r:embed="rId5"/></pic:blipFill></pic:pic>
|
|
96
|
+
</a:graphicData></a:graphic>
|
|
97
|
+
</wp:anchor>
|
|
98
|
+
</w:drawing>`)[0];
|
|
99
|
+
|
|
100
|
+
const docx: DocxDocument = {
|
|
101
|
+
parts: new Map(),
|
|
102
|
+
documentAttrs: {},
|
|
103
|
+
body: {
|
|
104
|
+
children: [
|
|
105
|
+
{
|
|
106
|
+
kind: "paragraph",
|
|
107
|
+
properties: null,
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
kind: "run",
|
|
111
|
+
properties: null,
|
|
112
|
+
content: [{ kind: "opaque_run_child", xml }],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const pm = docxToPm(docx);
|
|
120
|
+
const view = createReadOnlyView(host, pm, {
|
|
121
|
+
fontRegistry: buildFontRegistry({
|
|
122
|
+
embeddedFonts: [],
|
|
123
|
+
workspaceFonts: [],
|
|
124
|
+
}),
|
|
125
|
+
sections: [
|
|
126
|
+
{
|
|
127
|
+
properties: { ...DEFAULT_SECTION_PROPERTIES },
|
|
128
|
+
blockStartIndex: 0,
|
|
129
|
+
blockEndIndex: 0,
|
|
130
|
+
} satisfies Section,
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const wrapper = host.querySelector(".docx-image-inline") as HTMLElement;
|
|
135
|
+
expect(wrapper).not.toBeNull();
|
|
136
|
+
expect(host.innerHTML).toContain("docx-image-inline");
|
|
137
|
+
const node = pm.firstChild!.firstChild!;
|
|
138
|
+
expect(node.attrs.wrap).toBe("square");
|
|
139
|
+
expect(node.attrs.floatSide).toBe("right");
|
|
140
|
+
view.destroy();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const FOOTNOTES_XML = `<?xml version="1.0"?>
|
|
145
|
+
<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
146
|
+
<w:footnote w:id="0" w:type="separator"><w:p/></w:footnote>
|
|
147
|
+
<w:footnote w:id="1"><w:p><w:r><w:t>First note</w:t></w:r></w:p></w:footnote>
|
|
148
|
+
<w:footnote w:id="2"><w:p><w:r><w:t>Second note</w:t></w:r></w:p></w:footnote>
|
|
149
|
+
</w:footnotes>`;
|
|
150
|
+
|
|
151
|
+
describe("footnotes parser", () => {
|
|
152
|
+
it("extracts only normal footnotes by id", () => {
|
|
153
|
+
const reg = parseFootnoteRegistry(
|
|
154
|
+
new Map([["word/footnotes.xml", new TextEncoder().encode(FOOTNOTES_XML)]]),
|
|
155
|
+
);
|
|
156
|
+
expect(reg.byId.size).toBe(2);
|
|
157
|
+
expect(reg.byId.get("1")?.blocks).toHaveLength(1);
|
|
158
|
+
expect(reg.byId.has("0")).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns empty when footnotes.xml missing", () => {
|
|
162
|
+
expect(parseFootnoteRegistry(new Map()).byId.size).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("footnote_ref node + render", () => {
|
|
167
|
+
it("converts <w:footnoteReference> to a footnote_ref PM node with auto-numbered counter", () => {
|
|
168
|
+
const docx: DocxDocument = {
|
|
169
|
+
parts: new Map(),
|
|
170
|
+
documentAttrs: {},
|
|
171
|
+
body: {
|
|
172
|
+
children: [
|
|
173
|
+
{
|
|
174
|
+
kind: "paragraph",
|
|
175
|
+
properties: null,
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
kind: "run",
|
|
179
|
+
properties: null,
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
kind: "opaque_run_child",
|
|
183
|
+
xml: {
|
|
184
|
+
"w:footnoteReference": [],
|
|
185
|
+
":@": { "@_w:id": "1" },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const pm = docxToPm(docx);
|
|
196
|
+
const node = pm.firstChild!.firstChild!;
|
|
197
|
+
expect(node.type.name).toBe("footnote_ref");
|
|
198
|
+
expect(node.attrs.footnoteId).toBe("1");
|
|
199
|
+
expect(node.attrs.number).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("renderFootnotesSection emits an aside with each referenced footnote", () => {
|
|
203
|
+
const reg = parseFootnoteRegistry(
|
|
204
|
+
new Map([["word/footnotes.xml", new TextEncoder().encode(FOOTNOTES_XML)]]),
|
|
205
|
+
);
|
|
206
|
+
const { doc, paragraph, footnote_ref } = docxSchema.nodes;
|
|
207
|
+
const pmDoc = doc.create({}, [
|
|
208
|
+
paragraph.create({}, [
|
|
209
|
+
docxSchema.text("body"),
|
|
210
|
+
footnote_ref.create({ footnoteId: "1", number: 1 }),
|
|
211
|
+
]),
|
|
212
|
+
]);
|
|
213
|
+
const aside = renderFootnotesSection(reg, pmDoc);
|
|
214
|
+
expect(aside).not.toBeNull();
|
|
215
|
+
expect(aside!.querySelector("li[data-footnote-id='1']")?.textContent).toContain(
|
|
216
|
+
"First note",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("collectReferencedFootnotes deduplicates by id", () => {
|
|
221
|
+
const { doc, paragraph, footnote_ref } = docxSchema.nodes;
|
|
222
|
+
const pmDoc = doc.create({}, [
|
|
223
|
+
paragraph.create({}, [
|
|
224
|
+
footnote_ref.create({ footnoteId: "1", number: 1 }),
|
|
225
|
+
footnote_ref.create({ footnoteId: "1", number: 1 }),
|
|
226
|
+
footnote_ref.create({ footnoteId: "2", number: 2 }),
|
|
227
|
+
]),
|
|
228
|
+
]);
|
|
229
|
+
expect(collectReferencedFootnotes(pmDoc)).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("multi-column section", () => {
|
|
234
|
+
let host: HTMLElement;
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
document.body.innerHTML = "";
|
|
237
|
+
host = document.createElement("div");
|
|
238
|
+
document.body.appendChild(host);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("applies CSS column-count on the editor mount when columns.count > 1", () => {
|
|
242
|
+
const sections: Section[] = [
|
|
243
|
+
{
|
|
244
|
+
properties: {
|
|
245
|
+
...DEFAULT_SECTION_PROPERTIES,
|
|
246
|
+
columns: {
|
|
247
|
+
count: 2,
|
|
248
|
+
space: 720,
|
|
249
|
+
equalWidth: true,
|
|
250
|
+
separator: false,
|
|
251
|
+
columns: [],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
blockStartIndex: 0,
|
|
255
|
+
blockEndIndex: 0,
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
const { doc, paragraph } = docxSchema.nodes;
|
|
259
|
+
const pmDoc = doc.create({}, [paragraph.create({}, [docxSchema.text("x")])]);
|
|
260
|
+
const view = createReadOnlyView(host, pmDoc, {
|
|
261
|
+
fontRegistry: buildFontRegistry({
|
|
262
|
+
embeddedFonts: [],
|
|
263
|
+
workspaceFonts: [],
|
|
264
|
+
}),
|
|
265
|
+
sections,
|
|
266
|
+
});
|
|
267
|
+
const mount = host.querySelector(".docx-editor-mount") as HTMLElement;
|
|
268
|
+
expect(mount.style.columnCount).toBe("2");
|
|
269
|
+
view.destroy();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseXml } from "@lotics/ooxml/xml";
|
|
3
|
+
import { parseBlockXml } from "../parse/parser";
|
|
4
|
+
import { docxToPm } from "./docx_to_pm";
|
|
5
|
+
import { pmToDocx } from "./pm_to_docx";
|
|
6
|
+
import type { Block } from "../model/types";
|
|
7
|
+
import type { DocxDocument } from "../model/types";
|
|
8
|
+
|
|
9
|
+
const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
|
|
10
|
+
|
|
11
|
+
function paragraphFromXml(...inlineXml: string[]): Block {
|
|
12
|
+
const wrapped = `<w:p ${NS}>${inlineXml.join("")}</w:p>`;
|
|
13
|
+
const parsed = parseXml(wrapped);
|
|
14
|
+
return parseBlockXml(parsed[0]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("bookmark boundary atoms", () => {
|
|
18
|
+
it("converts <w:bookmarkStart> / <w:bookmarkEnd> to atom inline nodes", () => {
|
|
19
|
+
const doc: DocxDocument = {
|
|
20
|
+
parts: new Map(),
|
|
21
|
+
documentAttrs: {},
|
|
22
|
+
body: {
|
|
23
|
+
children: [
|
|
24
|
+
paragraphFromXml(
|
|
25
|
+
`<w:bookmarkStart w:id="0" w:name="intro"/>`,
|
|
26
|
+
`<w:r><w:t>content</w:t></w:r>`,
|
|
27
|
+
`<w:bookmarkEnd w:id="0"/>`,
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const pm = docxToPm(doc);
|
|
33
|
+
const para = pm.firstChild!;
|
|
34
|
+
expect(para.childCount).toBeGreaterThanOrEqual(3);
|
|
35
|
+
expect(para.firstChild!.type.name).toBe("bookmark_start");
|
|
36
|
+
expect(para.firstChild!.attrs.bookmarkId).toBe("0");
|
|
37
|
+
expect(para.firstChild!.attrs.name).toBe("intro");
|
|
38
|
+
expect(para.lastChild!.type.name).toBe("bookmark_end");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("roundtrips bookmark atoms back to OOXML", () => {
|
|
42
|
+
const doc: DocxDocument = {
|
|
43
|
+
parts: new Map(),
|
|
44
|
+
documentAttrs: {},
|
|
45
|
+
body: {
|
|
46
|
+
children: [
|
|
47
|
+
paragraphFromXml(
|
|
48
|
+
`<w:bookmarkStart w:id="2" w:name="ref1"/>`,
|
|
49
|
+
`<w:r><w:t>x</w:t></w:r>`,
|
|
50
|
+
`<w:bookmarkEnd w:id="2"/>`,
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const pm = docxToPm(doc);
|
|
56
|
+
const round = pmToDocx(pm, { parts: doc.parts, documentAttrs: doc.documentAttrs });
|
|
57
|
+
const para = round.body.children[0];
|
|
58
|
+
if (para.kind !== "paragraph") throw new Error();
|
|
59
|
+
const tags = para.content.map((inline) =>
|
|
60
|
+
inline.kind === "opaque_inline"
|
|
61
|
+
? Object.keys(inline.xml).find((k) => k !== ":@")
|
|
62
|
+
: inline.kind,
|
|
63
|
+
);
|
|
64
|
+
expect(tags).toContain("w:bookmarkStart");
|
|
65
|
+
expect(tags).toContain("w:bookmarkEnd");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("complex field preprocessing", () => {
|
|
70
|
+
it("collapses <w:fldChar begin>...<w:instrText>...<w:fldChar separate>...<w:fldChar end> into a single field_inline", () => {
|
|
71
|
+
const doc: DocxDocument = {
|
|
72
|
+
parts: new Map(),
|
|
73
|
+
documentAttrs: {},
|
|
74
|
+
body: {
|
|
75
|
+
children: [
|
|
76
|
+
paragraphFromXml(
|
|
77
|
+
`<w:r><w:fldChar w:fldCharType="begin"/></w:r>`,
|
|
78
|
+
`<w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \\h </w:instrText></w:r>`,
|
|
79
|
+
`<w:r><w:fldChar w:fldCharType="separate"/></w:r>`,
|
|
80
|
+
`<w:r><w:t>Chapter 1</w:t></w:r>`,
|
|
81
|
+
`<w:r><w:fldChar w:fldCharType="end"/></w:r>`,
|
|
82
|
+
),
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const pm = docxToPm(doc);
|
|
87
|
+
const para = pm.firstChild!;
|
|
88
|
+
expect(para.childCount).toBe(1);
|
|
89
|
+
expect(para.firstChild!.type.name).toBe("field_inline");
|
|
90
|
+
expect(para.firstChild!.attrs.instruction).toContain("PAGEREF");
|
|
91
|
+
expect(para.firstChild!.attrs.cachedResult).toBe("Chapter 1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("roundtrips field_inline back through fldChar chain", () => {
|
|
95
|
+
const doc: DocxDocument = {
|
|
96
|
+
parts: new Map(),
|
|
97
|
+
documentAttrs: {},
|
|
98
|
+
body: {
|
|
99
|
+
children: [
|
|
100
|
+
paragraphFromXml(
|
|
101
|
+
`<w:r><w:fldChar w:fldCharType="begin"/></w:r>`,
|
|
102
|
+
`<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>`,
|
|
103
|
+
`<w:r><w:fldChar w:fldCharType="separate"/></w:r>`,
|
|
104
|
+
`<w:r><w:t>3</w:t></w:r>`,
|
|
105
|
+
`<w:r><w:fldChar w:fldCharType="end"/></w:r>`,
|
|
106
|
+
),
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const pm = docxToPm(doc);
|
|
111
|
+
const round = pmToDocx(pm, { parts: doc.parts, documentAttrs: doc.documentAttrs });
|
|
112
|
+
const reparsed = docxToPm(round);
|
|
113
|
+
expect(reparsed.firstChild!.firstChild!.type.name).toBe("field_inline");
|
|
114
|
+
expect(reparsed.firstChild!.firstChild!.attrs.instruction).toBe("PAGE");
|
|
115
|
+
expect(reparsed.firstChild!.firstChild!.attrs.cachedResult).toBe("3");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("tracked changes", () => {
|
|
120
|
+
it("recognizes <w:ins> wrapping a run; emits insertion mark on its text", () => {
|
|
121
|
+
const doc: DocxDocument = {
|
|
122
|
+
parts: new Map(),
|
|
123
|
+
documentAttrs: {},
|
|
124
|
+
body: {
|
|
125
|
+
children: [
|
|
126
|
+
paragraphFromXml(
|
|
127
|
+
`<w:ins w:id="1" w:author="Alice" w:date="2024-01-15T10:00:00Z"><w:r><w:t>added</w:t></w:r></w:ins>`,
|
|
128
|
+
),
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const pm = docxToPm(doc);
|
|
133
|
+
const para = pm.firstChild!;
|
|
134
|
+
const text = para.firstChild!;
|
|
135
|
+
const insertion = text.marks.find((m) => m.type.name === "insertion");
|
|
136
|
+
expect(insertion).toBeDefined();
|
|
137
|
+
expect(insertion!.attrs.author).toBe("Alice");
|
|
138
|
+
expect(insertion!.attrs.date).toBe("2024-01-15T10:00:00Z");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("recognizes <w:del> wrapping a run; emits deletion mark", () => {
|
|
142
|
+
const doc: DocxDocument = {
|
|
143
|
+
parts: new Map(),
|
|
144
|
+
documentAttrs: {},
|
|
145
|
+
body: {
|
|
146
|
+
children: [
|
|
147
|
+
paragraphFromXml(
|
|
148
|
+
`<w:del w:id="2" w:author="Bob"><w:r><w:t>removed</w:t></w:r></w:del>`,
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const pm = docxToPm(doc);
|
|
154
|
+
const text = pm.firstChild!.firstChild!;
|
|
155
|
+
const deletion = text.marks.find((m) => m.type.name === "deletion");
|
|
156
|
+
expect(deletion).toBeDefined();
|
|
157
|
+
expect(deletion!.attrs.author).toBe("Bob");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("roundtrips tracked insertions back to <w:ins> wrapping runs", () => {
|
|
161
|
+
const doc: DocxDocument = {
|
|
162
|
+
parts: new Map(),
|
|
163
|
+
documentAttrs: {},
|
|
164
|
+
body: {
|
|
165
|
+
children: [
|
|
166
|
+
paragraphFromXml(
|
|
167
|
+
`<w:ins w:id="1" w:author="Alice" w:date="2024-01-15T10:00:00Z"><w:r><w:t>added</w:t></w:r></w:ins>`,
|
|
168
|
+
),
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
const pm = docxToPm(doc);
|
|
173
|
+
const round = pmToDocx(pm, {
|
|
174
|
+
parts: doc.parts,
|
|
175
|
+
documentAttrs: doc.documentAttrs,
|
|
176
|
+
});
|
|
177
|
+
const para = round.body.children[0];
|
|
178
|
+
if (para.kind !== "paragraph") throw new Error();
|
|
179
|
+
const inline = para.content[0];
|
|
180
|
+
expect(inline.kind).toBe("opaque_inline");
|
|
181
|
+
if (inline.kind === "opaque_inline") {
|
|
182
|
+
const tag = Object.keys(inline.xml).find((k) => k !== ":@");
|
|
183
|
+
expect(tag).toBe("w:ins");
|
|
184
|
+
const attrs = inline.xml[":@"] as Record<string, string>;
|
|
185
|
+
expect(attrs["@_w:author"]).toBe("Alice");
|
|
186
|
+
expect(attrs["@_w:date"]).toBe("2024-01-15T10:00:00Z");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("roundtrips tracked deletions back to <w:del> wrapping runs", () => {
|
|
191
|
+
const doc: DocxDocument = {
|
|
192
|
+
parts: new Map(),
|
|
193
|
+
documentAttrs: {},
|
|
194
|
+
body: {
|
|
195
|
+
children: [
|
|
196
|
+
paragraphFromXml(
|
|
197
|
+
`<w:del w:id="2" w:author="Bob"><w:r><w:rPr><w:b/></w:rPr><w:t>gone</w:t></w:r></w:del>`,
|
|
198
|
+
),
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const pm = docxToPm(doc);
|
|
203
|
+
const round = pmToDocx(pm, {
|
|
204
|
+
parts: doc.parts,
|
|
205
|
+
documentAttrs: doc.documentAttrs,
|
|
206
|
+
});
|
|
207
|
+
const para = round.body.children[0];
|
|
208
|
+
if (para.kind !== "paragraph") throw new Error();
|
|
209
|
+
const inline = para.content[0];
|
|
210
|
+
expect(inline.kind).toBe("opaque_inline");
|
|
211
|
+
if (inline.kind === "opaque_inline") {
|
|
212
|
+
expect(Object.keys(inline.xml)).toContain("w:del");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Plugin } from "prosemirror-state";
|
|
2
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
3
|
+
|
|
4
|
+
export function placeholderPlugin(text: string): Plugin {
|
|
5
|
+
return new Plugin({
|
|
6
|
+
props: {
|
|
7
|
+
decorations(state) {
|
|
8
|
+
const { doc, selection } = state;
|
|
9
|
+
if (doc.childCount !== 1) return null;
|
|
10
|
+
const first = doc.firstChild;
|
|
11
|
+
if (!first || first.type.name !== "paragraph" || first.content.size > 0) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (!selection.empty || selection.from !== 1) return null;
|
|
15
|
+
return DecorationSet.create(doc, [
|
|
16
|
+
Decoration.node(0, first.nodeSize, {
|
|
17
|
+
class: "docx-placeholder-empty",
|
|
18
|
+
"data-placeholder": text,
|
|
19
|
+
}),
|
|
20
|
+
]);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { history, redo, undo } from "prosemirror-history";
|
|
2
|
+
import { keymap } from "prosemirror-keymap";
|
|
3
|
+
import { baseKeymap, toggleMark } from "prosemirror-commands";
|
|
4
|
+
import { dropCursor } from "prosemirror-dropcursor";
|
|
5
|
+
import { gapCursor } from "prosemirror-gapcursor";
|
|
6
|
+
import {
|
|
7
|
+
columnResizing,
|
|
8
|
+
deleteTable,
|
|
9
|
+
goToNextCell,
|
|
10
|
+
tableEditing,
|
|
11
|
+
} from "prosemirror-tables";
|
|
12
|
+
import type { Plugin } from "prosemirror-state";
|
|
13
|
+
import { docxSchema } from "./schema";
|
|
14
|
+
import { numberingDecorationPlugin } from "./numbering_plugin";
|
|
15
|
+
import { templateMarkerPlugin } from "./template_plugin";
|
|
16
|
+
import { searchPlugin, searchCommands } from "./search";
|
|
17
|
+
import { listEnter } from "./commands";
|
|
18
|
+
import { bubbleMenuPlugin } from "./bubble_menu";
|
|
19
|
+
import { tableHandlesPlugin } from "./table_handles";
|
|
20
|
+
import { tableDecorationsPlugin } from "./table_decorations";
|
|
21
|
+
import { linkPopoverPlugin } from "./link_popover";
|
|
22
|
+
import { dragHandlePlugin } from "./drag_handle";
|
|
23
|
+
import type { NumberingTable } from "../model/numbering_table";
|
|
24
|
+
|
|
25
|
+
const markBindings = (): Record<string, ReturnType<typeof toggleMark>> => ({
|
|
26
|
+
"Mod-b": toggleMark(docxSchema.marks.bold),
|
|
27
|
+
"Mod-i": toggleMark(docxSchema.marks.italic),
|
|
28
|
+
"Mod-u": toggleMark(docxSchema.marks.underline),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const historyBindings = {
|
|
32
|
+
"Mod-z": undo,
|
|
33
|
+
"Shift-Mod-z": redo,
|
|
34
|
+
"Mod-y": redo,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const tableBindings = {
|
|
38
|
+
Tab: goToNextCell(1),
|
|
39
|
+
"Shift-Tab": goToNextCell(-1),
|
|
40
|
+
"Mod-Backspace": deleteTable,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const listBindings = {
|
|
44
|
+
Enter: listEnter(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type EditorPluginOptions = {
|
|
48
|
+
numberingTable?: NumberingTable | null;
|
|
49
|
+
templateMarkers?: boolean;
|
|
50
|
+
bubbleMenu?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const searchKeymap = {
|
|
54
|
+
"Mod-g": searchCommands.findNext,
|
|
55
|
+
"Shift-Mod-g": searchCommands.findPrev,
|
|
56
|
+
F3: searchCommands.findNext,
|
|
57
|
+
"Shift-F3": searchCommands.findPrev,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function buildEditorPlugins(opts: EditorPluginOptions = {}): Plugin[] {
|
|
61
|
+
const plugins: Plugin[] = [
|
|
62
|
+
history(),
|
|
63
|
+
keymap(historyBindings),
|
|
64
|
+
keymap(markBindings()),
|
|
65
|
+
keymap(tableBindings),
|
|
66
|
+
keymap(listBindings),
|
|
67
|
+
keymap(searchKeymap),
|
|
68
|
+
keymap(baseKeymap),
|
|
69
|
+
dropCursor(),
|
|
70
|
+
gapCursor(),
|
|
71
|
+
searchPlugin(),
|
|
72
|
+
numberingDecorationPlugin(opts.numberingTable ?? null),
|
|
73
|
+
];
|
|
74
|
+
if (opts.templateMarkers !== false) plugins.push(templateMarkerPlugin());
|
|
75
|
+
plugins.push(columnResizing({ handleWidth: 6, cellMinWidth: 32 }));
|
|
76
|
+
plugins.push(tableEditing());
|
|
77
|
+
plugins.push(tableDecorationsPlugin());
|
|
78
|
+
plugins.push(tableHandlesPlugin());
|
|
79
|
+
plugins.push(linkPopoverPlugin());
|
|
80
|
+
plugins.push(dragHandlePlugin());
|
|
81
|
+
if (opts.bubbleMenu !== false) plugins.push(bubbleMenuPlugin());
|
|
82
|
+
return plugins;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildReadOnlyPlugins(opts: EditorPluginOptions = {}): Plugin[] {
|
|
86
|
+
const plugins: Plugin[] = [
|
|
87
|
+
numberingDecorationPlugin(opts.numberingTable ?? null),
|
|
88
|
+
];
|
|
89
|
+
if (opts.templateMarkers !== false) plugins.push(templateMarkerPlugin());
|
|
90
|
+
return plugins;
|
|
91
|
+
}
|
|
Binary file
|