@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,179 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
createBlankDocx,
|
|
7
|
+
saveDocxToBuffer,
|
|
8
|
+
appendElement,
|
|
9
|
+
} from "@lotics/ooxml/document";
|
|
10
|
+
import { buildRun } from "@lotics/ooxml/builders";
|
|
11
|
+
import type { XmlElement } from "@lotics/ooxml/xml";
|
|
12
|
+
import { parseDocx } from "./parse/parser";
|
|
13
|
+
import { serializeDocx } from "./serialize/serializer";
|
|
14
|
+
import type { DocxDocument } from "./model/types";
|
|
15
|
+
|
|
16
|
+
const FIXTURES_DIR = path.join(
|
|
17
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
18
|
+
"fixtures",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function paragraph(children: XmlElement[]): XmlElement {
|
|
22
|
+
return { "w:p": children };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function buildSampleDocx(): Promise<Uint8Array> {
|
|
26
|
+
const doc = createBlankDocx();
|
|
27
|
+
|
|
28
|
+
appendElement(doc, paragraph([buildRun("Hello world")]));
|
|
29
|
+
appendElement(doc, paragraph([buildRun("Bold text", { bold: true })]));
|
|
30
|
+
appendElement(
|
|
31
|
+
doc,
|
|
32
|
+
paragraph([
|
|
33
|
+
buildRun("Mixed: "),
|
|
34
|
+
buildRun("italic ", { italic: true }),
|
|
35
|
+
buildRun("and "),
|
|
36
|
+
buildRun("bold", { bold: true }),
|
|
37
|
+
buildRun(" together"),
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
appendElement(doc, paragraph([]));
|
|
41
|
+
appendElement(doc, paragraph([buildRun(" leading and trailing spaces ")]));
|
|
42
|
+
|
|
43
|
+
const buffer = await saveDocxToBuffer(doc);
|
|
44
|
+
return new Uint8Array(buffer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadFixtures(): Promise<{ name: string; bytes: Uint8Array }[]> {
|
|
48
|
+
let entries: string[];
|
|
49
|
+
try {
|
|
50
|
+
entries = await fs.readdir(FIXTURES_DIR);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const out: { name: string; bytes: Uint8Array }[] = [];
|
|
55
|
+
for (const name of entries) {
|
|
56
|
+
if (!name.endsWith(".docx")) continue;
|
|
57
|
+
const bytes = await fs.readFile(path.join(FIXTURES_DIR, name));
|
|
58
|
+
out.push({ name, bytes: new Uint8Array(bytes) });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertModelStable(original: DocxDocument, roundtripped: DocxDocument): void {
|
|
64
|
+
expect(roundtripped.documentAttrs).toEqual(original.documentAttrs);
|
|
65
|
+
expect(roundtripped.body).toEqual(original.body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertNonDocumentPartsIdentical(
|
|
69
|
+
original: DocxDocument,
|
|
70
|
+
roundtripped: DocxDocument,
|
|
71
|
+
): void {
|
|
72
|
+
for (const [path, originalBytes] of original.parts) {
|
|
73
|
+
if (path === "word/document.xml") continue;
|
|
74
|
+
const newBytes = roundtripped.parts.get(path);
|
|
75
|
+
expect(newBytes, `missing part ${path} after roundtrip`).toBeDefined();
|
|
76
|
+
expect(
|
|
77
|
+
Buffer.from(newBytes!).equals(Buffer.from(originalBytes)),
|
|
78
|
+
`part ${path} differs after roundtrip`,
|
|
79
|
+
).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("docx roundtrip — programmatic fixture", () => {
|
|
84
|
+
it("preserves document model and non-document parts", async () => {
|
|
85
|
+
const bytes = await buildSampleDocx();
|
|
86
|
+
const original = await parseDocx(bytes);
|
|
87
|
+
|
|
88
|
+
const reEmitted = await serializeDocx(original);
|
|
89
|
+
const roundtripped = await parseDocx(reEmitted);
|
|
90
|
+
|
|
91
|
+
assertModelStable(original, roundtripped);
|
|
92
|
+
assertNonDocumentPartsIdentical(original, roundtripped);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("parses paragraphs, runs, and text with preserveSpace", async () => {
|
|
96
|
+
const bytes = await buildSampleDocx();
|
|
97
|
+
const doc = await parseDocx(bytes);
|
|
98
|
+
|
|
99
|
+
expect(doc.body.children).toHaveLength(6);
|
|
100
|
+
|
|
101
|
+
const para0 = doc.body.children[0];
|
|
102
|
+
expect(para0.kind).toBe("paragraph");
|
|
103
|
+
if (para0.kind !== "paragraph") return;
|
|
104
|
+
expect(para0.content).toHaveLength(1);
|
|
105
|
+
const run0 = para0.content[0];
|
|
106
|
+
expect(run0.kind).toBe("run");
|
|
107
|
+
if (run0.kind !== "run") return;
|
|
108
|
+
expect(run0.content[0]).toEqual({
|
|
109
|
+
kind: "text",
|
|
110
|
+
value: "Hello world",
|
|
111
|
+
preserveSpace: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const empty = doc.body.children[3];
|
|
115
|
+
expect(empty.kind).toBe("paragraph");
|
|
116
|
+
if (empty.kind !== "paragraph") return;
|
|
117
|
+
expect(empty.content).toHaveLength(0);
|
|
118
|
+
|
|
119
|
+
const last = doc.body.children[5];
|
|
120
|
+
expect(last.kind).toBe("body_sect_pr");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("docx roundtrip — file fixtures", () => {
|
|
125
|
+
it("roundtrips every .docx in src/fixtures/", async () => {
|
|
126
|
+
const fixtures = await loadFixtures();
|
|
127
|
+
if (fixtures.length === 0) {
|
|
128
|
+
// No fixtures yet — Phase 0 ships the harness; fixtures land as we
|
|
129
|
+
// collect real-world .docx files in later phases.
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const fixture of fixtures) {
|
|
133
|
+
const original = await parseDocx(fixture.bytes);
|
|
134
|
+
const reEmitted = await serializeDocx(original);
|
|
135
|
+
const roundtripped = await parseDocx(reEmitted);
|
|
136
|
+
|
|
137
|
+
assertModelStable(original, roundtripped);
|
|
138
|
+
assertNonDocumentPartsIdentical(original, roundtripped);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("never produces opaque #text nodes from pretty-printed XML whitespace", async () => {
|
|
143
|
+
const fixtures = await loadFixtures();
|
|
144
|
+
for (const fixture of fixtures) {
|
|
145
|
+
const doc = await parseDocx(fixture.bytes);
|
|
146
|
+
assertNoTextOnlyOpaques(doc, fixture.name);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function assertNoTextOnlyOpaques(doc: DocxDocument, fixtureName: string): void {
|
|
152
|
+
for (const block of doc.body.children) {
|
|
153
|
+
if (block.kind === "opaque_block") {
|
|
154
|
+
assertNotTextOnly(block.xml, `${fixtureName}: body block`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (block.kind !== "paragraph") continue;
|
|
158
|
+
for (const inline of block.content) {
|
|
159
|
+
if (inline.kind === "opaque_inline") {
|
|
160
|
+
assertNotTextOnly(inline.xml, `${fixtureName}: paragraph inline`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (inline.kind !== "run") continue;
|
|
164
|
+
for (const rc of inline.content) {
|
|
165
|
+
if (rc.kind === "opaque_run_child") {
|
|
166
|
+
assertNotTextOnly(rc.xml, `${fixtureName}: run child`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function assertNotTextOnly(xml: XmlElement, where: string): void {
|
|
174
|
+
const keys = Object.keys(xml).filter((k) => k !== ":@");
|
|
175
|
+
expect(
|
|
176
|
+
keys,
|
|
177
|
+
`${where}: #text-only opaque node — insignificant whitespace leaked into the model`,
|
|
178
|
+
).not.toEqual(["#text"]);
|
|
179
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fabricate the minimal set of OOXML parts so a from-scratch document
|
|
3
|
+
* (parts: empty Map) round-trips into a valid Word-openable .docx.
|
|
4
|
+
*
|
|
5
|
+
* Inserted only when the corresponding path is missing from the provided
|
|
6
|
+
* parts map — never overrides an explicit definition.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
const CONTENT_TYPES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
12
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
13
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
14
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
15
|
+
<Default Extension="png" ContentType="image/png"/>
|
|
16
|
+
<Default Extension="jpg" ContentType="image/jpeg"/>
|
|
17
|
+
<Default Extension="jpeg" ContentType="image/jpeg"/>
|
|
18
|
+
<Default Extension="gif" ContentType="image/gif"/>
|
|
19
|
+
<Default Extension="webp" ContentType="image/webp"/>
|
|
20
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
21
|
+
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
|
22
|
+
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
|
|
23
|
+
<Override PartName="/word/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
|
|
24
|
+
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
|
|
25
|
+
<Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>
|
|
26
|
+
</Types>`;
|
|
27
|
+
|
|
28
|
+
const PACKAGE_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
29
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
30
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
31
|
+
</Relationships>`;
|
|
32
|
+
|
|
33
|
+
const DOCUMENT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
34
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
35
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
36
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
|
|
37
|
+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
|
|
38
|
+
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
|
|
39
|
+
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/>
|
|
40
|
+
</Relationships>`;
|
|
41
|
+
|
|
42
|
+
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
43
|
+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
44
|
+
<w:docDefaults>
|
|
45
|
+
<w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:rPrDefault>
|
|
46
|
+
<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault>
|
|
47
|
+
</w:docDefaults>
|
|
48
|
+
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
|
49
|
+
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:spacing w:before="0" w:after="80"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light"/><w:sz w:val="56"/></w:rPr></w:style>
|
|
50
|
+
<w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:rPr><w:i/><w:color w:val="595959"/><w:sz w:val="28"/></w:rPr></w:style>
|
|
51
|
+
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="240" w:after="0"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light"/><w:b/><w:color w:val="2E74B5"/><w:sz w:val="32"/></w:rPr></w:style>
|
|
52
|
+
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="160" w:after="0"/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light"/><w:b/><w:color w:val="2E74B5"/><w:sz w:val="26"/></w:rPr></w:style>
|
|
53
|
+
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="160" w:after="0"/><w:outlineLvl w:val="2"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light"/><w:b/><w:color w:val="1F4E79"/><w:sz w:val="24"/></w:rPr></w:style>
|
|
54
|
+
<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:ind w:left="360" w:right="360"/><w:spacing w:before="160" w:after="160"/></w:pPr><w:rPr><w:i/><w:color w:val="404040"/></w:rPr></w:style>
|
|
55
|
+
<w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="20"/></w:rPr></w:style>
|
|
56
|
+
</w:styles>`;
|
|
57
|
+
|
|
58
|
+
const NUMBERING_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
59
|
+
<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
60
|
+
<w:abstractNum w:abstractNumId="0">
|
|
61
|
+
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
|
|
62
|
+
</w:abstractNum>
|
|
63
|
+
<w:abstractNum w:abstractNumId="1">
|
|
64
|
+
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
|
|
65
|
+
</w:abstractNum>
|
|
66
|
+
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
|
|
67
|
+
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
|
|
68
|
+
</w:numbering>`;
|
|
69
|
+
|
|
70
|
+
const SETTINGS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
71
|
+
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
72
|
+
<w:defaultTabStop w:val="720"/>
|
|
73
|
+
<w:characterSpacingControl w:val="doNotCompress"/>
|
|
74
|
+
</w:settings>`;
|
|
75
|
+
|
|
76
|
+
const FONT_TABLE_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
77
|
+
<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
78
|
+
<w:font w:name="Calibri"><w:family w:val="swiss"/></w:font>
|
|
79
|
+
<w:font w:name="Calibri Light"><w:family w:val="swiss"/></w:font>
|
|
80
|
+
<w:font w:name="Consolas"><w:family w:val="modern"/></w:font>
|
|
81
|
+
</w:fonts>`;
|
|
82
|
+
|
|
83
|
+
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
84
|
+
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">
|
|
85
|
+
<a:themeElements>
|
|
86
|
+
<a:clrScheme name="Office">
|
|
87
|
+
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
|
88
|
+
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
|
89
|
+
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
|
90
|
+
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
|
91
|
+
<a:accent1><a:srgbClr val="5B9BD5"/></a:accent1>
|
|
92
|
+
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
|
93
|
+
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
|
94
|
+
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
|
95
|
+
<a:accent5><a:srgbClr val="4472C4"/></a:accent5>
|
|
96
|
+
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
|
97
|
+
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
|
98
|
+
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
|
99
|
+
</a:clrScheme>
|
|
100
|
+
<a:fontScheme name="Office">
|
|
101
|
+
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
|
|
102
|
+
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
|
|
103
|
+
</a:fontScheme>
|
|
104
|
+
<a:fmtScheme name="Office"/>
|
|
105
|
+
</a:themeElements>
|
|
106
|
+
</a:theme>`;
|
|
107
|
+
|
|
108
|
+
export const DEFAULT_PARTS: ReadonlyArray<readonly [string, () => Uint8Array]> = [
|
|
109
|
+
["[Content_Types].xml", () => encoder.encode(CONTENT_TYPES_XML)],
|
|
110
|
+
["_rels/.rels", () => encoder.encode(PACKAGE_RELS_XML)],
|
|
111
|
+
["word/_rels/document.xml.rels", () => encoder.encode(DOCUMENT_RELS_XML)],
|
|
112
|
+
["word/styles.xml", () => encoder.encode(STYLES_XML)],
|
|
113
|
+
["word/numbering.xml", () => encoder.encode(NUMBERING_XML)],
|
|
114
|
+
["word/settings.xml", () => encoder.encode(SETTINGS_XML)],
|
|
115
|
+
["word/fontTable.xml", () => encoder.encode(FONT_TABLE_XML)],
|
|
116
|
+
["word/theme/theme1.xml", () => encoder.encode(THEME_XML)],
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export function fillDefaultParts(
|
|
120
|
+
parts: Map<string, Uint8Array>,
|
|
121
|
+
): Map<string, Uint8Array> {
|
|
122
|
+
for (const [path, makeBytes] of DEFAULT_PARTS) {
|
|
123
|
+
if (!parts.has(path)) {
|
|
124
|
+
parts.set(path, makeBytes());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return parts;
|
|
128
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
2
|
+
import { buildXml, type XmlElement } from "@lotics/ooxml/xml";
|
|
3
|
+
import type { Block, Paragraph, Run, RunChild, TextNode } from "../model/types";
|
|
4
|
+
import { pmDocToHeaderFooterBlocks } from "../pm/pm_to_docx";
|
|
5
|
+
|
|
6
|
+
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serialize a header/footer PM doc to the OOXML w:hdr / w:ftr part.
|
|
10
|
+
*
|
|
11
|
+
* Reuses the same Paragraph/Run/Table builders as the body via
|
|
12
|
+
* pmDocToHeaderFooterBlocks, so tables, marks, opaque pass-through, and run
|
|
13
|
+
* properties all round-trip with no fidelity loss compared to the body.
|
|
14
|
+
*/
|
|
15
|
+
export function pmDocToHeaderFooterXml(
|
|
16
|
+
pmDoc: PMNode,
|
|
17
|
+
kind: "hdr" | "ftr",
|
|
18
|
+
): string {
|
|
19
|
+
const blocks = pmDocToHeaderFooterBlocks(pmDoc);
|
|
20
|
+
const children = blocks.map(serializeBlock);
|
|
21
|
+
const root: XmlElement = {
|
|
22
|
+
[`w:${kind}`]: children,
|
|
23
|
+
":@": { "@_xmlns:w": W_NS },
|
|
24
|
+
};
|
|
25
|
+
return buildXml([root]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function serializeBlock(block: Block): XmlElement {
|
|
29
|
+
switch (block.kind) {
|
|
30
|
+
case "paragraph":
|
|
31
|
+
return serializeParagraph(block);
|
|
32
|
+
case "opaque_block":
|
|
33
|
+
return block.xml;
|
|
34
|
+
case "body_sect_pr":
|
|
35
|
+
return block.xml;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function serializeParagraph(p: Paragraph): XmlElement {
|
|
40
|
+
const children: XmlElement[] = [];
|
|
41
|
+
if (p.properties !== null) {
|
|
42
|
+
children.push({ "w:pPr": [...p.properties] });
|
|
43
|
+
}
|
|
44
|
+
for (const inline of p.content) {
|
|
45
|
+
if (inline.kind === "run") {
|
|
46
|
+
children.push(serializeRun(inline));
|
|
47
|
+
} else {
|
|
48
|
+
children.push(inline.xml);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { "w:p": children };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializeRun(r: Run): XmlElement {
|
|
55
|
+
const children: XmlElement[] = [];
|
|
56
|
+
if (r.properties !== null) {
|
|
57
|
+
children.push({ "w:rPr": [...r.properties] });
|
|
58
|
+
}
|
|
59
|
+
for (const child of r.content) {
|
|
60
|
+
children.push(serializeRunChild(child));
|
|
61
|
+
}
|
|
62
|
+
return { "w:r": children };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function serializeRunChild(child: RunChild): XmlElement {
|
|
66
|
+
switch (child.kind) {
|
|
67
|
+
case "text":
|
|
68
|
+
return serializeText(child);
|
|
69
|
+
case "opaque_run_child":
|
|
70
|
+
return child.xml;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serializeText(t: TextNode): XmlElement {
|
|
75
|
+
const el: XmlElement = {
|
|
76
|
+
"w:t": t.value === "" ? [] : [{ "#text": t.value }],
|
|
77
|
+
};
|
|
78
|
+
if (t.preserveSpace) {
|
|
79
|
+
el[":@"] = { "@_xml:space": "preserve" };
|
|
80
|
+
}
|
|
81
|
+
return el;
|
|
82
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import { buildXml, type XmlElement } from "@lotics/ooxml/xml";
|
|
3
|
+
import type {
|
|
4
|
+
Block,
|
|
5
|
+
Body,
|
|
6
|
+
DocxDocument,
|
|
7
|
+
Inline,
|
|
8
|
+
Paragraph,
|
|
9
|
+
Run,
|
|
10
|
+
RunChild,
|
|
11
|
+
TextNode,
|
|
12
|
+
} from "../model/types";
|
|
13
|
+
import { fillDefaultParts } from "./default_parts";
|
|
14
|
+
|
|
15
|
+
export async function serializeDocx(doc: DocxDocument): Promise<Uint8Array> {
|
|
16
|
+
const zip = new JSZip();
|
|
17
|
+
|
|
18
|
+
// Fabricate any missing structural parts so the resulting .docx is
|
|
19
|
+
// openable in Word even when the source is fully synthetic (e.g. a
|
|
20
|
+
// hand-built demo doc with parts: empty Map).
|
|
21
|
+
const parts = fillDefaultParts(new Map(doc.parts));
|
|
22
|
+
|
|
23
|
+
for (const [path, bytes] of parts) {
|
|
24
|
+
if (path === "word/document.xml") continue;
|
|
25
|
+
zip.file(path, bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const documentXml = serializeDocumentXml(doc);
|
|
29
|
+
zip.file("word/document.xml", documentXml);
|
|
30
|
+
|
|
31
|
+
const out = await zip.generateAsync({
|
|
32
|
+
type: "uint8array",
|
|
33
|
+
compression: "DEFLATE",
|
|
34
|
+
});
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeDocumentXml(doc: DocxDocument): string {
|
|
39
|
+
const bodyChildren = serializeBody(doc.body);
|
|
40
|
+
const documentEl: XmlElement = {
|
|
41
|
+
"w:document": [{ "w:body": bodyChildren }],
|
|
42
|
+
":@": { ...doc.documentAttrs },
|
|
43
|
+
};
|
|
44
|
+
return buildXml([documentEl]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function serializeBody(body: Body): XmlElement[] {
|
|
48
|
+
const out: XmlElement[] = [];
|
|
49
|
+
for (const block of body.children) {
|
|
50
|
+
out.push(serializeBlock(block));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serializeBlock(block: Block): XmlElement {
|
|
56
|
+
switch (block.kind) {
|
|
57
|
+
case "paragraph":
|
|
58
|
+
return serializeParagraph(block);
|
|
59
|
+
case "body_sect_pr":
|
|
60
|
+
return block.xml;
|
|
61
|
+
case "opaque_block":
|
|
62
|
+
return block.xml;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function serializeParagraph(p: Paragraph): XmlElement {
|
|
67
|
+
const children: XmlElement[] = [];
|
|
68
|
+
if (p.properties !== null) {
|
|
69
|
+
children.push({ "w:pPr": [...p.properties] });
|
|
70
|
+
}
|
|
71
|
+
for (const inline of p.content) {
|
|
72
|
+
children.push(serializeInline(inline));
|
|
73
|
+
}
|
|
74
|
+
return { "w:p": children };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serializeInline(inline: Inline): XmlElement {
|
|
78
|
+
switch (inline.kind) {
|
|
79
|
+
case "run":
|
|
80
|
+
return serializeRun(inline);
|
|
81
|
+
case "opaque_inline":
|
|
82
|
+
return inline.xml;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serializeRun(r: Run): XmlElement {
|
|
87
|
+
const children: XmlElement[] = [];
|
|
88
|
+
if (r.properties !== null) {
|
|
89
|
+
children.push({ "w:rPr": [...r.properties] });
|
|
90
|
+
}
|
|
91
|
+
for (const child of r.content) {
|
|
92
|
+
children.push(serializeRunChild(child));
|
|
93
|
+
}
|
|
94
|
+
return { "w:r": children };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function serializeRunChild(child: RunChild): XmlElement {
|
|
98
|
+
switch (child.kind) {
|
|
99
|
+
case "text":
|
|
100
|
+
return serializeText(child);
|
|
101
|
+
case "opaque_run_child":
|
|
102
|
+
return child.xml;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function serializeText(t: TextNode): XmlElement {
|
|
107
|
+
const el: XmlElement = {
|
|
108
|
+
"w:t": t.value === "" ? [] : [{ "#text": t.value }],
|
|
109
|
+
};
|
|
110
|
+
if (t.preserveSpace) {
|
|
111
|
+
el[":@"] = { "@_xml:space": "preserve" };
|
|
112
|
+
}
|
|
113
|
+
return el;
|
|
114
|
+
}
|