@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,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseXml,
|
|
3
|
+
getAttr,
|
|
4
|
+
getChildren,
|
|
5
|
+
getTagName,
|
|
6
|
+
type XmlElement,
|
|
7
|
+
} from "@lotics/ooxml/xml";
|
|
8
|
+
import {
|
|
9
|
+
EMPTY_NUMBERING_TABLE,
|
|
10
|
+
type AbstractNum,
|
|
11
|
+
type Justification,
|
|
12
|
+
type LvlOverride,
|
|
13
|
+
type Num,
|
|
14
|
+
type NumberFormat,
|
|
15
|
+
type NumberingLevel,
|
|
16
|
+
type NumberingTable,
|
|
17
|
+
} from "../model/numbering_table";
|
|
18
|
+
import {
|
|
19
|
+
extractParagraphProperties,
|
|
20
|
+
extractRunProperties,
|
|
21
|
+
} from "../model/properties";
|
|
22
|
+
|
|
23
|
+
function isNumberFormat(value: string): value is NumberFormat {
|
|
24
|
+
return (
|
|
25
|
+
value === "decimal" ||
|
|
26
|
+
value === "decimalZero" ||
|
|
27
|
+
value === "lowerLetter" ||
|
|
28
|
+
value === "upperLetter" ||
|
|
29
|
+
value === "lowerRoman" ||
|
|
30
|
+
value === "upperRoman" ||
|
|
31
|
+
value === "bullet" ||
|
|
32
|
+
value === "none"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isJustification(value: string): value is Justification {
|
|
37
|
+
return value === "left" || value === "center" || value === "right";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseIntOrDefault(v: string | undefined, fallback: number): number {
|
|
41
|
+
if (v === undefined) return fallback;
|
|
42
|
+
const n = Number.parseInt(v, 10);
|
|
43
|
+
return Number.isFinite(n) ? n : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseIntOrNull(v: string | undefined): number | null {
|
|
47
|
+
if (v === undefined) return null;
|
|
48
|
+
const n = Number.parseInt(v, 10);
|
|
49
|
+
return Number.isFinite(n) ? n : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseLevel(el: XmlElement): NumberingLevel {
|
|
53
|
+
const ilvl = parseIntOrDefault(getAttr(el, "w:ilvl"), 0);
|
|
54
|
+
let start = 1;
|
|
55
|
+
let numFmt: NumberFormat = "decimal";
|
|
56
|
+
let lvlText = "";
|
|
57
|
+
let lvlJc: Justification = "left";
|
|
58
|
+
let lvlRestart: number | null = null;
|
|
59
|
+
let paragraphProperties: NumberingLevel["paragraphProperties"] = null;
|
|
60
|
+
let runProperties: NumberingLevel["runProperties"] = null;
|
|
61
|
+
let suff: "tab" | "space" | "nothing" = "tab";
|
|
62
|
+
|
|
63
|
+
for (const child of getChildren(el)) {
|
|
64
|
+
const tag = getTagName(child);
|
|
65
|
+
switch (tag) {
|
|
66
|
+
case "w:start":
|
|
67
|
+
start = parseIntOrDefault(getAttr(child, "w:val"), 1);
|
|
68
|
+
break;
|
|
69
|
+
case "w:numFmt": {
|
|
70
|
+
const v = getAttr(child, "w:val");
|
|
71
|
+
if (v && isNumberFormat(v)) numFmt = v;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "w:lvlText":
|
|
75
|
+
lvlText = getAttr(child, "w:val") ?? "";
|
|
76
|
+
break;
|
|
77
|
+
case "w:lvlJc": {
|
|
78
|
+
const v = getAttr(child, "w:val");
|
|
79
|
+
if (v && isJustification(v)) lvlJc = v;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "w:lvlRestart":
|
|
83
|
+
lvlRestart = parseIntOrNull(getAttr(child, "w:val"));
|
|
84
|
+
break;
|
|
85
|
+
case "w:pPr":
|
|
86
|
+
paragraphProperties = extractParagraphProperties(getChildren(child));
|
|
87
|
+
break;
|
|
88
|
+
case "w:rPr":
|
|
89
|
+
runProperties = extractRunProperties(getChildren(child));
|
|
90
|
+
break;
|
|
91
|
+
case "w:suff": {
|
|
92
|
+
const v = getAttr(child, "w:val");
|
|
93
|
+
if (v === "tab" || v === "space" || v === "nothing") suff = v;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ilvl,
|
|
101
|
+
start,
|
|
102
|
+
numFmt,
|
|
103
|
+
lvlText,
|
|
104
|
+
lvlJc,
|
|
105
|
+
lvlRestart,
|
|
106
|
+
paragraphProperties,
|
|
107
|
+
runProperties,
|
|
108
|
+
suff,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseAbstractNum(el: XmlElement): AbstractNum | null {
|
|
113
|
+
const idAttr = getAttr(el, "w:abstractNumId");
|
|
114
|
+
const id = parseIntOrNull(idAttr);
|
|
115
|
+
if (id === null) return null;
|
|
116
|
+
|
|
117
|
+
const levels = new Map<number, NumberingLevel>();
|
|
118
|
+
for (const child of getChildren(el)) {
|
|
119
|
+
if (getTagName(child) !== "w:lvl") continue;
|
|
120
|
+
const level = parseLevel(child);
|
|
121
|
+
levels.set(level.ilvl, level);
|
|
122
|
+
}
|
|
123
|
+
return { abstractNumId: id, levels };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseLvlOverride(el: XmlElement): LvlOverride | null {
|
|
127
|
+
const ilvl = parseIntOrNull(getAttr(el, "w:ilvl"));
|
|
128
|
+
if (ilvl === null) return null;
|
|
129
|
+
let startOverride: number | null = null;
|
|
130
|
+
let level: NumberingLevel | null = null;
|
|
131
|
+
for (const child of getChildren(el)) {
|
|
132
|
+
const tag = getTagName(child);
|
|
133
|
+
if (tag === "w:startOverride") {
|
|
134
|
+
startOverride = parseIntOrNull(getAttr(child, "w:val"));
|
|
135
|
+
} else if (tag === "w:lvl") {
|
|
136
|
+
level = parseLevel(child);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ilvl, startOverride, level };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseNum(el: XmlElement): Num | null {
|
|
143
|
+
const numIdAttr = getAttr(el, "w:numId");
|
|
144
|
+
const numId = parseIntOrNull(numIdAttr);
|
|
145
|
+
if (numId === null) return null;
|
|
146
|
+
|
|
147
|
+
let abstractNumId: number | null = null;
|
|
148
|
+
const overrides = new Map<number, LvlOverride>();
|
|
149
|
+
for (const child of getChildren(el)) {
|
|
150
|
+
const tag = getTagName(child);
|
|
151
|
+
if (tag === "w:abstractNumId") {
|
|
152
|
+
abstractNumId = parseIntOrNull(getAttr(child, "w:val"));
|
|
153
|
+
} else if (tag === "w:lvlOverride") {
|
|
154
|
+
const ovr = parseLvlOverride(child);
|
|
155
|
+
if (ovr) overrides.set(ovr.ilvl, ovr);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (abstractNumId === null) return null;
|
|
159
|
+
return { numId, abstractNumId, overrides };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseNumberingTable(
|
|
163
|
+
parts: ReadonlyMap<string, Uint8Array>,
|
|
164
|
+
): NumberingTable {
|
|
165
|
+
const bytes = parts.get("word/numbering.xml");
|
|
166
|
+
if (!bytes) return EMPTY_NUMBERING_TABLE;
|
|
167
|
+
|
|
168
|
+
const xml = new TextDecoder("utf-8").decode(bytes);
|
|
169
|
+
const parsed = parseXml(xml);
|
|
170
|
+
const numberingEl = parsed.find((el) => getTagName(el) === "w:numbering");
|
|
171
|
+
if (!numberingEl) return EMPTY_NUMBERING_TABLE;
|
|
172
|
+
|
|
173
|
+
const abstractNums = new Map<number, AbstractNum>();
|
|
174
|
+
const nums = new Map<number, Num>();
|
|
175
|
+
for (const child of getChildren(numberingEl)) {
|
|
176
|
+
const tag = getTagName(child);
|
|
177
|
+
if (tag === "w:abstractNum") {
|
|
178
|
+
const a = parseAbstractNum(child);
|
|
179
|
+
if (a) abstractNums.set(a.abstractNumId, a);
|
|
180
|
+
} else if (tag === "w:num") {
|
|
181
|
+
const n = parseNum(child);
|
|
182
|
+
if (n) nums.set(n.numId, n);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { abstractNums, nums };
|
|
187
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import {
|
|
3
|
+
parseXml,
|
|
4
|
+
getTagName,
|
|
5
|
+
getChildren,
|
|
6
|
+
findChild,
|
|
7
|
+
getAttr,
|
|
8
|
+
getTextContent,
|
|
9
|
+
isTextNode,
|
|
10
|
+
type XmlElement,
|
|
11
|
+
} from "@lotics/ooxml/xml";
|
|
12
|
+
import type {
|
|
13
|
+
Block,
|
|
14
|
+
Body,
|
|
15
|
+
BodySectPr,
|
|
16
|
+
DocxDocument,
|
|
17
|
+
Inline,
|
|
18
|
+
OpaqueBlock,
|
|
19
|
+
OpaqueInline,
|
|
20
|
+
OpaqueRunChild,
|
|
21
|
+
Paragraph,
|
|
22
|
+
Run,
|
|
23
|
+
RunChild,
|
|
24
|
+
TextNode,
|
|
25
|
+
} from "../model/types";
|
|
26
|
+
|
|
27
|
+
export async function parseDocx(buffer: Uint8Array): Promise<DocxDocument> {
|
|
28
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
29
|
+
|
|
30
|
+
const parts = new Map<string, Uint8Array>();
|
|
31
|
+
const filePromises: Promise<void>[] = [];
|
|
32
|
+
zip.forEach((path, file) => {
|
|
33
|
+
if (file.dir) return;
|
|
34
|
+
filePromises.push(
|
|
35
|
+
file.async("uint8array").then((bytes) => {
|
|
36
|
+
parts.set(path, bytes);
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
await Promise.all(filePromises);
|
|
41
|
+
|
|
42
|
+
const documentBytes = parts.get("word/document.xml");
|
|
43
|
+
if (!documentBytes) {
|
|
44
|
+
throw new Error("Invalid .docx: missing word/document.xml");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const documentXml = new TextDecoder("utf-8").decode(documentBytes);
|
|
48
|
+
const parsed = parseXml(documentXml);
|
|
49
|
+
|
|
50
|
+
const docEl = parsed.find((el) => getTagName(el) === "w:document");
|
|
51
|
+
if (!docEl) {
|
|
52
|
+
throw new Error("Invalid .docx: missing <w:document>");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const documentAttrs: Record<string, string> = {};
|
|
56
|
+
const rawAttrs = docEl[":@"] as Record<string, string> | undefined;
|
|
57
|
+
if (rawAttrs) {
|
|
58
|
+
for (const [k, v] of Object.entries(rawAttrs)) {
|
|
59
|
+
documentAttrs[k] = v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bodyEl = findChild(docEl, "w:body");
|
|
64
|
+
if (!bodyEl) {
|
|
65
|
+
throw new Error("Invalid .docx: missing <w:body>");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = parseBody(bodyEl);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
parts,
|
|
72
|
+
documentAttrs,
|
|
73
|
+
body,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseBody(bodyEl: XmlElement): Body {
|
|
78
|
+
const children: Block[] = [];
|
|
79
|
+
for (const childEl of getChildren(bodyEl)) {
|
|
80
|
+
if (isTextNode(childEl)) continue;
|
|
81
|
+
children.push(parseBlock(childEl));
|
|
82
|
+
}
|
|
83
|
+
return { children };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseBlock(el: XmlElement): Block {
|
|
87
|
+
const tag = getTagName(el);
|
|
88
|
+
if (tag === "w:p") return parseParagraph(el);
|
|
89
|
+
if (tag === "w:sectPr") return parseBodySectPr(el);
|
|
90
|
+
return parseOpaqueBlock(el);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseBlockXml(el: XmlElement): Block {
|
|
94
|
+
return parseBlock(el);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseParagraph(el: XmlElement): Paragraph {
|
|
98
|
+
const properties: XmlElement[] = [];
|
|
99
|
+
const content: Inline[] = [];
|
|
100
|
+
let propertiesSeen = false;
|
|
101
|
+
|
|
102
|
+
for (const child of getChildren(el)) {
|
|
103
|
+
if (isTextNode(child)) continue;
|
|
104
|
+
const tag = getTagName(child);
|
|
105
|
+
if (tag === "w:pPr") {
|
|
106
|
+
propertiesSeen = true;
|
|
107
|
+
for (const propEl of getChildren(child)) {
|
|
108
|
+
if (isTextNode(propEl)) continue;
|
|
109
|
+
properties.push(propEl);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
content.push(parseInline(child));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
kind: "paragraph",
|
|
118
|
+
properties: propertiesSeen ? properties : null,
|
|
119
|
+
content,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseInline(el: XmlElement): Inline {
|
|
124
|
+
const tag = getTagName(el);
|
|
125
|
+
if (tag === "w:r") return parseRun(el);
|
|
126
|
+
return parseOpaqueInline(el);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseRun(el: XmlElement): Run {
|
|
130
|
+
const properties: XmlElement[] = [];
|
|
131
|
+
const content: RunChild[] = [];
|
|
132
|
+
let propertiesSeen = false;
|
|
133
|
+
|
|
134
|
+
for (const child of getChildren(el)) {
|
|
135
|
+
if (isTextNode(child)) continue;
|
|
136
|
+
const tag = getTagName(child);
|
|
137
|
+
if (tag === "w:rPr") {
|
|
138
|
+
propertiesSeen = true;
|
|
139
|
+
for (const propEl of getChildren(child)) {
|
|
140
|
+
if (isTextNode(propEl)) continue;
|
|
141
|
+
properties.push(propEl);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
content.push(parseRunChild(child));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
kind: "run",
|
|
150
|
+
properties: propertiesSeen ? properties : null,
|
|
151
|
+
content,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseRunChild(el: XmlElement): RunChild {
|
|
156
|
+
const tag = getTagName(el);
|
|
157
|
+
if (tag === "w:t") return parseText(el);
|
|
158
|
+
return parseOpaqueRunChild(el);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseText(el: XmlElement): TextNode {
|
|
162
|
+
const preserveSpace = getAttr(el, "xml:space") === "preserve";
|
|
163
|
+
return {
|
|
164
|
+
kind: "text",
|
|
165
|
+
value: getTextContent(el),
|
|
166
|
+
preserveSpace,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseBodySectPr(el: XmlElement): BodySectPr {
|
|
171
|
+
return { kind: "body_sect_pr", xml: el };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseOpaqueBlock(el: XmlElement): OpaqueBlock {
|
|
175
|
+
return { kind: "opaque_block", xml: el };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseOpaqueInline(el: XmlElement): OpaqueInline {
|
|
179
|
+
return { kind: "opaque_inline", xml: el };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseOpaqueRunChild(el: XmlElement): OpaqueRunChild {
|
|
183
|
+
return { kind: "opaque_run_child", xml: el };
|
|
184
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseXml,
|
|
3
|
+
getAttr,
|
|
4
|
+
getChildren,
|
|
5
|
+
getTagName,
|
|
6
|
+
} from "@lotics/ooxml/xml";
|
|
7
|
+
|
|
8
|
+
export type Relationship = {
|
|
9
|
+
id: string;
|
|
10
|
+
type: string;
|
|
11
|
+
target: string;
|
|
12
|
+
targetMode: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RelationshipMap = ReadonlyMap<string, Relationship>;
|
|
16
|
+
|
|
17
|
+
const REL_TYPE_HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
18
|
+
const REL_TYPE_IMAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
|
19
|
+
|
|
20
|
+
export function isHyperlinkRelationship(rel: Relationship | undefined): boolean {
|
|
21
|
+
return rel?.type === REL_TYPE_HYPERLINK;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isImageRelationship(rel: Relationship | undefined): boolean {
|
|
25
|
+
return rel?.type === REL_TYPE_IMAGE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseDocumentRelationships(
|
|
29
|
+
parts: ReadonlyMap<string, Uint8Array>,
|
|
30
|
+
): RelationshipMap {
|
|
31
|
+
const bytes = parts.get("word/_rels/document.xml.rels");
|
|
32
|
+
if (!bytes) return new Map();
|
|
33
|
+
|
|
34
|
+
const xml = new TextDecoder("utf-8").decode(bytes);
|
|
35
|
+
const parsed = parseXml(xml);
|
|
36
|
+
const relsEl = parsed.find((el) => getTagName(el) === "Relationships");
|
|
37
|
+
if (!relsEl) return new Map();
|
|
38
|
+
|
|
39
|
+
const out = new Map<string, Relationship>();
|
|
40
|
+
for (const child of getChildren(relsEl)) {
|
|
41
|
+
if (getTagName(child) !== "Relationship") continue;
|
|
42
|
+
const id = getAttr(child, "Id");
|
|
43
|
+
const type = getAttr(child, "Type");
|
|
44
|
+
const target = getAttr(child, "Target");
|
|
45
|
+
if (!id || !type || !target) continue;
|
|
46
|
+
out.set(id, {
|
|
47
|
+
id,
|
|
48
|
+
type,
|
|
49
|
+
target,
|
|
50
|
+
targetMode: getAttr(child, "TargetMode") ?? null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a relationship target into the absolute part path within the ZIP,
|
|
58
|
+
* given that the relationship file lives at `word/_rels/document.xml.rels`.
|
|
59
|
+
* Internal targets are relative to the document's base directory (`word/`).
|
|
60
|
+
*/
|
|
61
|
+
export function resolveInternalPartPath(target: string): string {
|
|
62
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
63
|
+
return `word/${target}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveHyperlinkUrl(
|
|
67
|
+
relationships: RelationshipMap,
|
|
68
|
+
relId: string,
|
|
69
|
+
): string | null {
|
|
70
|
+
const rel = relationships.get(relId);
|
|
71
|
+
if (!rel || !isHyperlinkRelationship(rel)) return null;
|
|
72
|
+
return rel.target;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveImagePartPath(
|
|
76
|
+
relationships: RelationshipMap,
|
|
77
|
+
relId: string,
|
|
78
|
+
): string | null {
|
|
79
|
+
const rel = relationships.get(relId);
|
|
80
|
+
if (!rel || !isImageRelationship(rel)) return null;
|
|
81
|
+
return resolveInternalPartPath(rel.target);
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseXml, getTagName, findChild } from "@lotics/ooxml/xml";
|
|
3
|
+
import {
|
|
4
|
+
parseBodySections,
|
|
5
|
+
parseSectionProperties,
|
|
6
|
+
} from "./sections";
|
|
7
|
+
import type { Body } 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 sectPr(xml: string) {
|
|
12
|
+
return parseXml(`<w:sectPr ${NS}>${xml}</w:sectPr>`)[0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("parseSectionProperties", () => {
|
|
16
|
+
it("returns Letter portrait defaults when sectPr is empty", () => {
|
|
17
|
+
const props = parseSectionProperties(sectPr(""));
|
|
18
|
+
expect(props.pageSize).toEqual({
|
|
19
|
+
width: 12240,
|
|
20
|
+
height: 15840,
|
|
21
|
+
orientation: "portrait",
|
|
22
|
+
});
|
|
23
|
+
expect(props.margins).toEqual({
|
|
24
|
+
top: 1440,
|
|
25
|
+
right: 1440,
|
|
26
|
+
bottom: 1440,
|
|
27
|
+
left: 1440,
|
|
28
|
+
header: 720,
|
|
29
|
+
footer: 720,
|
|
30
|
+
gutter: 0,
|
|
31
|
+
});
|
|
32
|
+
expect(props.columns.count).toBe(1);
|
|
33
|
+
expect(props.type).toBe("nextPage");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("parses A4 landscape with custom margins", () => {
|
|
37
|
+
const props = parseSectionProperties(
|
|
38
|
+
sectPr(`
|
|
39
|
+
<w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>
|
|
40
|
+
<w:pgMar w:top="720" w:right="1080" w:bottom="720" w:left="1080"
|
|
41
|
+
w:header="360" w:footer="360" w:gutter="0"/>
|
|
42
|
+
`),
|
|
43
|
+
);
|
|
44
|
+
expect(props.pageSize).toEqual({
|
|
45
|
+
width: 16838,
|
|
46
|
+
height: 11906,
|
|
47
|
+
orientation: "landscape",
|
|
48
|
+
});
|
|
49
|
+
expect(props.margins.top).toBe(720);
|
|
50
|
+
expect(props.margins.left).toBe(1080);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("parses two equal-width columns with separator", () => {
|
|
54
|
+
const props = parseSectionProperties(
|
|
55
|
+
sectPr(`<w:cols w:num="2" w:space="720" w:sep="1"/>`),
|
|
56
|
+
);
|
|
57
|
+
expect(props.columns).toEqual({
|
|
58
|
+
count: 2,
|
|
59
|
+
space: 720,
|
|
60
|
+
equalWidth: true,
|
|
61
|
+
separator: true,
|
|
62
|
+
columns: [],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("parses unequal columns with explicit widths", () => {
|
|
67
|
+
const props = parseSectionProperties(
|
|
68
|
+
sectPr(`
|
|
69
|
+
<w:cols w:num="2" w:equalWidth="0">
|
|
70
|
+
<w:col w:w="3000" w:space="720"/>
|
|
71
|
+
<w:col w:w="6000"/>
|
|
72
|
+
</w:cols>
|
|
73
|
+
`),
|
|
74
|
+
);
|
|
75
|
+
expect(props.columns.equalWidth).toBe(false);
|
|
76
|
+
expect(props.columns.columns).toEqual([
|
|
77
|
+
{ width: 3000, space: 720 },
|
|
78
|
+
{ width: 6000, space: null },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("parses header/footer references with types", () => {
|
|
83
|
+
const props = parseSectionProperties(
|
|
84
|
+
sectPr(`
|
|
85
|
+
<w:headerReference w:type="default" r:id="rId7"/>
|
|
86
|
+
<w:headerReference w:type="first" r:id="rId8"/>
|
|
87
|
+
<w:footerReference w:type="default" r:id="rId9"/>
|
|
88
|
+
<w:titlePg/>
|
|
89
|
+
`),
|
|
90
|
+
);
|
|
91
|
+
expect(props.titlePage).toBe(true);
|
|
92
|
+
expect(props.headerRefs).toEqual([
|
|
93
|
+
{ type: "default", relationshipId: "rId7" },
|
|
94
|
+
{ type: "first", relationshipId: "rId8" },
|
|
95
|
+
]);
|
|
96
|
+
expect(props.footerRefs).toEqual([
|
|
97
|
+
{ type: "default", relationshipId: "rId9" },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("parses section type", () => {
|
|
102
|
+
const props = parseSectionProperties(
|
|
103
|
+
sectPr(`<w:type w:val="continuous"/>`),
|
|
104
|
+
);
|
|
105
|
+
expect(props.type).toBe("continuous");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("parseBodySections", () => {
|
|
110
|
+
it("synthesizes a default section when body has no sectPr", () => {
|
|
111
|
+
const body: Body = {
|
|
112
|
+
children: [
|
|
113
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
114
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
const sections = parseBodySections(body);
|
|
118
|
+
expect(sections).toHaveLength(1);
|
|
119
|
+
expect(sections[0].blockStartIndex).toBe(0);
|
|
120
|
+
expect(sections[0].blockEndIndex).toBe(1);
|
|
121
|
+
expect(sections[0].properties.pageSize.orientation).toBe("portrait");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("captures the body-level final sectPr", () => {
|
|
125
|
+
const body: Body = {
|
|
126
|
+
children: [
|
|
127
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
128
|
+
{
|
|
129
|
+
kind: "body_sect_pr",
|
|
130
|
+
xml: sectPr(`<w:pgSz w:w="11906" w:h="16838"/>`),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const sections = parseBodySections(body);
|
|
135
|
+
expect(sections).toHaveLength(1);
|
|
136
|
+
expect(sections[0].blockStartIndex).toBe(0);
|
|
137
|
+
expect(sections[0].blockEndIndex).toBe(0);
|
|
138
|
+
expect(sections[0].properties.pageSize.width).toBe(11906);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("captures multi-section docs (sectPr nested in paragraph pPr)", () => {
|
|
142
|
+
const inlineSect = sectPr(`<w:type w:val="continuous"/>`);
|
|
143
|
+
const finalSect = sectPr(`<w:pgSz w:w="11906" w:h="16838"/>`);
|
|
144
|
+
|
|
145
|
+
const body: Body = {
|
|
146
|
+
children: [
|
|
147
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
148
|
+
{
|
|
149
|
+
kind: "paragraph",
|
|
150
|
+
properties: [inlineSect],
|
|
151
|
+
content: [],
|
|
152
|
+
},
|
|
153
|
+
{ kind: "paragraph", properties: null, content: [] },
|
|
154
|
+
{ kind: "body_sect_pr", xml: finalSect },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const sections = parseBodySections(body);
|
|
159
|
+
expect(sections).toHaveLength(2);
|
|
160
|
+
expect(sections[0]).toMatchObject({
|
|
161
|
+
blockStartIndex: 0,
|
|
162
|
+
blockEndIndex: 1,
|
|
163
|
+
properties: { type: "continuous" },
|
|
164
|
+
});
|
|
165
|
+
expect(sections[1]).toMatchObject({
|
|
166
|
+
blockStartIndex: 2,
|
|
167
|
+
blockEndIndex: 2,
|
|
168
|
+
});
|
|
169
|
+
expect(sections[1].properties.pageSize.width).toBe(11906);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("ignores non-sectPr children inside paragraph properties", () => {
|
|
173
|
+
const props = parseXml(
|
|
174
|
+
`<w:pPr ${NS}><w:pStyle w:val="Heading1"/></w:pPr>`,
|
|
175
|
+
)[0];
|
|
176
|
+
expect(getTagName(props)).toBe("w:pPr");
|
|
177
|
+
const innerChildren = findChild(props, "w:pStyle");
|
|
178
|
+
expect(innerChildren).toBeDefined();
|
|
179
|
+
|
|
180
|
+
const body: Body = {
|
|
181
|
+
children: [
|
|
182
|
+
{
|
|
183
|
+
kind: "paragraph",
|
|
184
|
+
properties: [findChild(props, "w:pStyle")!],
|
|
185
|
+
content: [],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
const sections = parseBodySections(body);
|
|
190
|
+
expect(sections).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
});
|