odf-kit 0.9.9 → 0.10.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/CHANGELOG.md +17 -0
- package/README.md +49 -2
- package/dist/docx/body-reader.d.ts +54 -0
- package/dist/docx/body-reader.d.ts.map +1 -0
- package/dist/docx/body-reader.js +1124 -0
- package/dist/docx/body-reader.js.map +1 -0
- package/dist/docx/converter.d.ts +51 -0
- package/dist/docx/converter.d.ts.map +1 -0
- package/dist/docx/converter.js +799 -0
- package/dist/docx/converter.js.map +1 -0
- package/dist/docx/index.d.ts +81 -0
- package/dist/docx/index.d.ts.map +1 -0
- package/dist/docx/index.js +69 -0
- package/dist/docx/index.js.map +1 -0
- package/dist/docx/numbering.d.ts +42 -0
- package/dist/docx/numbering.d.ts.map +1 -0
- package/dist/docx/numbering.js +236 -0
- package/dist/docx/numbering.js.map +1 -0
- package/dist/docx/reader.d.ts +38 -0
- package/dist/docx/reader.d.ts.map +1 -0
- package/dist/docx/reader.js +512 -0
- package/dist/docx/reader.js.map +1 -0
- package/dist/docx/relationships.d.ts +27 -0
- package/dist/docx/relationships.d.ts.map +1 -0
- package/dist/docx/relationships.js +89 -0
- package/dist/docx/relationships.js.map +1 -0
- package/dist/docx/styles.d.ts +46 -0
- package/dist/docx/styles.d.ts.map +1 -0
- package/dist/docx/styles.js +383 -0
- package/dist/docx/styles.js.map +1 -0
- package/dist/docx/types.d.ts +266 -0
- package/dist/docx/types.d.ts.map +1 -0
- package/dist/docx/types.js +38 -0
- package/dist/docx/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* odf-kit — DOCX → ODT converter
|
|
3
|
+
*
|
|
4
|
+
* Walks a DocxDocument model and drives the OdtDocument API to produce
|
|
5
|
+
* an equivalent ODT document.
|
|
6
|
+
*
|
|
7
|
+
* Design decisions:
|
|
8
|
+
* - Style inheritance: the basedOn chain is walked at conversion time so
|
|
9
|
+
* the reader stores only explicitly present properties.
|
|
10
|
+
* - List grouping: consecutive paragraphs sharing a numId are collected
|
|
11
|
+
* into a flat array then converted to a nested ListData tree before
|
|
12
|
+
* being passed to doc.addList().
|
|
13
|
+
* - Footnotes/endnotes: OdtDocument has no text:note API. References are
|
|
14
|
+
* rendered as superscript markers ([1], [2], …); all note content is
|
|
15
|
+
* appended as a "Footnotes" / "Endnotes" section at the document end.
|
|
16
|
+
* - Headers/footers: only the "default" type is mapped (first/even are
|
|
17
|
+
* out of scope for the current OdtDocument API).
|
|
18
|
+
* - Images: bytes and dimensions are taken directly from the DOCX model;
|
|
19
|
+
* EMU→cm conversion already done by the reader.
|
|
20
|
+
*/
|
|
21
|
+
import { OdtDocument } from "../odt/document.js";
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public entry point
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Convert a parsed DocxDocument into an OdtDocument and return its bytes.
|
|
27
|
+
*/
|
|
28
|
+
export async function convertDocxToOdt(docxDoc, options, warnings) {
|
|
29
|
+
const ctx = {
|
|
30
|
+
doc: docxDoc,
|
|
31
|
+
options,
|
|
32
|
+
warnings,
|
|
33
|
+
noteCounter: 0,
|
|
34
|
+
pendingFootnotes: [],
|
|
35
|
+
pendingEndnotes: [],
|
|
36
|
+
};
|
|
37
|
+
const odt = new OdtDocument();
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
// Metadata
|
|
40
|
+
// -------------------------------------------------------------------------
|
|
41
|
+
const meta = options.metadata ?? {};
|
|
42
|
+
const srcMeta = docxDoc.metadata;
|
|
43
|
+
odt.setMetadata({
|
|
44
|
+
title: meta.title ?? srcMeta.title ?? undefined,
|
|
45
|
+
creator: meta.creator ?? srcMeta.creator ?? undefined,
|
|
46
|
+
description: meta.description ?? srcMeta.description ?? undefined,
|
|
47
|
+
});
|
|
48
|
+
// -------------------------------------------------------------------------
|
|
49
|
+
// Page layout
|
|
50
|
+
// -------------------------------------------------------------------------
|
|
51
|
+
const preserveLayout = options.preservePageLayout !== false; // default true
|
|
52
|
+
const layout = docxDoc.pageLayout;
|
|
53
|
+
const pageLayout = {};
|
|
54
|
+
if (preserveLayout && layout.width)
|
|
55
|
+
pageLayout.width = `${layout.width}cm`;
|
|
56
|
+
if (preserveLayout && layout.height)
|
|
57
|
+
pageLayout.height = `${layout.height}cm`;
|
|
58
|
+
if (preserveLayout && layout.orientation)
|
|
59
|
+
pageLayout.orientation = layout.orientation;
|
|
60
|
+
if (preserveLayout && layout.marginTop)
|
|
61
|
+
pageLayout.marginTop = `${layout.marginTop}cm`;
|
|
62
|
+
if (preserveLayout && layout.marginBottom)
|
|
63
|
+
pageLayout.marginBottom = `${layout.marginBottom}cm`;
|
|
64
|
+
if (preserveLayout && layout.marginLeft)
|
|
65
|
+
pageLayout.marginLeft = `${layout.marginLeft}cm`;
|
|
66
|
+
if (preserveLayout && layout.marginRight)
|
|
67
|
+
pageLayout.marginRight = `${layout.marginRight}cm`;
|
|
68
|
+
// Explicit option overrides
|
|
69
|
+
if (options.orientation)
|
|
70
|
+
pageLayout.orientation = options.orientation;
|
|
71
|
+
if (options.pageFormat && !pageLayout.width) {
|
|
72
|
+
const dims = PAGE_FORMAT_DIMS[options.pageFormat];
|
|
73
|
+
if (dims) {
|
|
74
|
+
const isLandscape = (options.orientation ?? layout.orientation) === "landscape";
|
|
75
|
+
pageLayout.width = isLandscape ? dims[1] : dims[0];
|
|
76
|
+
pageLayout.height = isLandscape ? dims[0] : dims[1];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (Object.keys(pageLayout).length > 0) {
|
|
80
|
+
odt.setPageLayout(pageLayout);
|
|
81
|
+
}
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// Header and footer — "default" type only
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
const defaultHeader = docxDoc.headers.find((h) => h.headerType === "default");
|
|
86
|
+
const defaultFooter = docxDoc.footers.find((f) => f.headerType === "default");
|
|
87
|
+
if (defaultHeader) {
|
|
88
|
+
const text = extractPlainText(defaultHeader.body);
|
|
89
|
+
if (text)
|
|
90
|
+
odt.setHeader(text);
|
|
91
|
+
}
|
|
92
|
+
if (defaultFooter) {
|
|
93
|
+
const text = extractPlainText(defaultFooter.body);
|
|
94
|
+
if (text)
|
|
95
|
+
odt.setFooter(text);
|
|
96
|
+
}
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
// Body
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
const grouped = groupListItems(docxDoc.body, docxDoc);
|
|
101
|
+
convertGroupedElements(grouped, odt, ctx);
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// Footnotes section (appended at end)
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
if (ctx.pendingFootnotes.length > 0) {
|
|
106
|
+
odt.addParagraph(""); // spacer
|
|
107
|
+
odt.addHeading("Footnotes", 6);
|
|
108
|
+
for (const { marker, note } of ctx.pendingFootnotes) {
|
|
109
|
+
const bodyText = extractPlainText(note.body);
|
|
110
|
+
odt.addParagraph(`${marker} ${bodyText}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (ctx.pendingEndnotes.length > 0) {
|
|
114
|
+
odt.addParagraph("");
|
|
115
|
+
odt.addHeading("Endnotes", 6);
|
|
116
|
+
for (const { marker, note } of ctx.pendingEndnotes) {
|
|
117
|
+
const bodyText = extractPlainText(note.body);
|
|
118
|
+
odt.addParagraph(`${marker} ${bodyText}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return odt.save();
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Page format dimensions [portrait-width, portrait-height]
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
const PAGE_FORMAT_DIMS = {
|
|
127
|
+
A4: ["21cm", "29.7cm"],
|
|
128
|
+
letter: ["21.59cm", "27.94cm"],
|
|
129
|
+
legal: ["21.59cm", "35.56cm"],
|
|
130
|
+
A3: ["29.7cm", "42cm"],
|
|
131
|
+
A5: ["14.8cm", "21cm"],
|
|
132
|
+
};
|
|
133
|
+
function groupListItems(elements, docxDoc) {
|
|
134
|
+
const result = [];
|
|
135
|
+
let i = 0;
|
|
136
|
+
while (i < elements.length) {
|
|
137
|
+
const el = elements[i];
|
|
138
|
+
if (el.type === "paragraph" && el.props.list) {
|
|
139
|
+
const numId = el.props.list.numId;
|
|
140
|
+
const group = { kind: "listGroup", numId, items: [] };
|
|
141
|
+
while (i < elements.length) {
|
|
142
|
+
const cur = elements[i];
|
|
143
|
+
if (cur.type !== "paragraph" || !cur.props.list || cur.props.list.numId !== numId)
|
|
144
|
+
break;
|
|
145
|
+
const level = cur.props.list.level;
|
|
146
|
+
const numEntry = resolveNumberingLevel(numId, level, docxDoc);
|
|
147
|
+
group.items.push({
|
|
148
|
+
level,
|
|
149
|
+
runs: cur.runs,
|
|
150
|
+
isOrdered: numEntry?.isOrdered ?? false,
|
|
151
|
+
numFormat: numEntry?.numFormat ?? "bullet",
|
|
152
|
+
start: numEntry?.start ?? 1,
|
|
153
|
+
});
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
result.push(group);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
result.push(el);
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Body element conversion
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
function convertGroupedElements(elements, odt, ctx) {
|
|
169
|
+
for (const el of elements) {
|
|
170
|
+
if ("kind" in el && el.kind === "listGroup") {
|
|
171
|
+
convertListGroup(el, odt, ctx);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
convertBodyElement(el, odt, ctx);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function convertBodyElement(el, odt, ctx) {
|
|
179
|
+
switch (el.type) {
|
|
180
|
+
case "pageBreak":
|
|
181
|
+
odt.addPageBreak();
|
|
182
|
+
break;
|
|
183
|
+
case "paragraph":
|
|
184
|
+
convertParagraph(el, odt, ctx);
|
|
185
|
+
break;
|
|
186
|
+
case "table":
|
|
187
|
+
convertTable(el, odt, ctx);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Paragraph conversion
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
function convertParagraph(para, odt, ctx) {
|
|
195
|
+
// Resolve effective heading level
|
|
196
|
+
const headingLevel = resolveHeadingLevel(para, ctx);
|
|
197
|
+
// Resolve paragraph options from style chain + direct props
|
|
198
|
+
const paraOptions = resolveParaOptions(para, ctx);
|
|
199
|
+
// Build content callback
|
|
200
|
+
const content = (p) => buildParagraphContent(para.runs, p, ctx);
|
|
201
|
+
if (headingLevel !== null) {
|
|
202
|
+
odt.addHeading(content, headingLevel, paraOptions);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
odt.addParagraph(content, paraOptions);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Heading level resolution
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
function resolveHeadingLevel(para, ctx) {
|
|
212
|
+
// 1. Caller styleMap option — highest priority
|
|
213
|
+
if (ctx.options.styleMap && para.styleId) {
|
|
214
|
+
const styleName = ctx.doc.styles.get(para.styleId)?.name;
|
|
215
|
+
if (styleName && ctx.options.styleMap[styleName] !== undefined) {
|
|
216
|
+
return ctx.options.styleMap[styleName];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// 2. Paragraph-level heading from reader (outlineLvl or style name)
|
|
220
|
+
if (para.headingLevel !== null) {
|
|
221
|
+
return para.headingLevel;
|
|
222
|
+
}
|
|
223
|
+
// 3. Style chain — check each style in basedOn chain for heading level
|
|
224
|
+
if (para.styleId) {
|
|
225
|
+
let entry = ctx.doc.styles.get(para.styleId);
|
|
226
|
+
while (entry) {
|
|
227
|
+
if (entry.headingLevel !== null)
|
|
228
|
+
return entry.headingLevel;
|
|
229
|
+
entry = entry.basedOn ? (ctx.doc.styles.get(entry.basedOn) ?? undefined) : undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Paragraph options resolution (style chain + direct props)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
function resolveParaOptions(para, ctx) {
|
|
238
|
+
// Collect inherited props from style chain (root → leaf order)
|
|
239
|
+
const chain = getStyleChain(para.styleId, ctx.doc.styles);
|
|
240
|
+
const inherited = {};
|
|
241
|
+
for (const entry of chain) {
|
|
242
|
+
if (entry.pPr)
|
|
243
|
+
mergeParaProps(inherited, entry.pPr);
|
|
244
|
+
}
|
|
245
|
+
// Direct paragraph props override inherited
|
|
246
|
+
mergeParaProps(inherited, para.props);
|
|
247
|
+
return paraPropsToOptions(inherited);
|
|
248
|
+
}
|
|
249
|
+
function paraPropsToOptions(props) {
|
|
250
|
+
const opts = {};
|
|
251
|
+
let hasAny = false;
|
|
252
|
+
if (props.alignment) {
|
|
253
|
+
opts.align = props.alignment;
|
|
254
|
+
hasAny = true;
|
|
255
|
+
}
|
|
256
|
+
if (props.spaceBefore != null) {
|
|
257
|
+
opts.spaceBefore = `${props.spaceBefore}cm`;
|
|
258
|
+
hasAny = true;
|
|
259
|
+
}
|
|
260
|
+
if (props.spaceAfter != null) {
|
|
261
|
+
opts.spaceAfter = `${props.spaceAfter}cm`;
|
|
262
|
+
hasAny = true;
|
|
263
|
+
}
|
|
264
|
+
if (props.lineHeight != null) {
|
|
265
|
+
opts.lineHeight = props.lineHeight;
|
|
266
|
+
hasAny = true;
|
|
267
|
+
}
|
|
268
|
+
if (props.indentLeft != null) {
|
|
269
|
+
opts.indentLeft = `${props.indentLeft}cm`;
|
|
270
|
+
hasAny = true;
|
|
271
|
+
}
|
|
272
|
+
if (props.indentFirstLine != null) {
|
|
273
|
+
opts.indentFirst = `${props.indentFirstLine}cm`;
|
|
274
|
+
hasAny = true;
|
|
275
|
+
}
|
|
276
|
+
if (props.borderBottom) {
|
|
277
|
+
const b = props.borderBottom;
|
|
278
|
+
opts.borderBottom = `${b.widthPt}pt ${b.style} #${b.color}`;
|
|
279
|
+
hasAny = true;
|
|
280
|
+
}
|
|
281
|
+
return hasAny ? opts : undefined;
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Inline content builder
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
function buildParagraphContent(runs, p, ctx) {
|
|
287
|
+
for (const el of runs) {
|
|
288
|
+
switch (el.type) {
|
|
289
|
+
case "run":
|
|
290
|
+
convertRun(el, p, ctx);
|
|
291
|
+
break;
|
|
292
|
+
case "hyperlink": {
|
|
293
|
+
if (el.runs.length === 0)
|
|
294
|
+
break;
|
|
295
|
+
// Merge all run text into one link; use formatting of first run
|
|
296
|
+
const text = el.runs.map((r) => r.text).join("");
|
|
297
|
+
const fmt = el.runs[0] ? resolveRunFormatting(el.runs[0], ctx) : undefined;
|
|
298
|
+
p.addLink(text, el.url, fmt ?? undefined);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "inlineImage": {
|
|
302
|
+
const imgEntry = ctx.doc.images.get(el.rId);
|
|
303
|
+
if (!imgEntry) {
|
|
304
|
+
ctx.warnings.push(`Image rId "${el.rId}" not found in image map — skipped`);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
p.addImage(imgEntry.bytes, {
|
|
308
|
+
width: `${el.widthCm}cm`,
|
|
309
|
+
height: `${el.heightCm}cm`,
|
|
310
|
+
mimeType: imgEntry.mimeType,
|
|
311
|
+
anchor: "as-character",
|
|
312
|
+
alt: el.altText ?? undefined,
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "footnoteReference": {
|
|
317
|
+
ctx.noteCounter++;
|
|
318
|
+
const marker = `[${ctx.noteCounter}]`;
|
|
319
|
+
p.addText(marker, { superscript: true });
|
|
320
|
+
const note = ctx.doc.footnotes.get(el.id);
|
|
321
|
+
if (note)
|
|
322
|
+
ctx.pendingFootnotes.push({ marker, note });
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "endnoteReference": {
|
|
326
|
+
ctx.noteCounter++;
|
|
327
|
+
const marker = `[${ctx.noteCounter}]`;
|
|
328
|
+
p.addText(marker, { superscript: true });
|
|
329
|
+
const note = ctx.doc.endnotes.get(el.id);
|
|
330
|
+
if (note)
|
|
331
|
+
ctx.pendingEndnotes.push({ marker, note });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case "bookmark":
|
|
335
|
+
if (el.position === "start") {
|
|
336
|
+
p.addBookmark(el.name);
|
|
337
|
+
}
|
|
338
|
+
// "end" bookmarks have no ODT equivalent at the inline level — skip
|
|
339
|
+
break;
|
|
340
|
+
case "tab":
|
|
341
|
+
p.addTab();
|
|
342
|
+
break;
|
|
343
|
+
case "lineBreak":
|
|
344
|
+
p.addLineBreak();
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Run conversion
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
function convertRun(run, p, ctx) {
|
|
353
|
+
if (!run.text)
|
|
354
|
+
return;
|
|
355
|
+
const fmt = resolveRunFormatting(run, ctx);
|
|
356
|
+
p.addText(run.text, fmt ?? undefined);
|
|
357
|
+
}
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Run formatting resolution (style chain + direct props)
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
function resolveRunFormatting(run, ctx) {
|
|
362
|
+
// Collect inherited props from character style chain
|
|
363
|
+
const chain = getStyleChain(run.props.rStyleId, ctx.doc.styles);
|
|
364
|
+
const inherited = {};
|
|
365
|
+
for (const entry of chain) {
|
|
366
|
+
if (entry.rPr)
|
|
367
|
+
mergeRunProps(inherited, entry.rPr);
|
|
368
|
+
}
|
|
369
|
+
// Direct run props override inherited
|
|
370
|
+
mergeRunProps(inherited, run.props);
|
|
371
|
+
return runPropsToFormatting(inherited);
|
|
372
|
+
}
|
|
373
|
+
function runPropsToFormatting(props) {
|
|
374
|
+
const fmt = {};
|
|
375
|
+
let hasAny = false;
|
|
376
|
+
if (props.bold) {
|
|
377
|
+
fmt.bold = true;
|
|
378
|
+
hasAny = true;
|
|
379
|
+
}
|
|
380
|
+
if (props.italic) {
|
|
381
|
+
fmt.italic = true;
|
|
382
|
+
hasAny = true;
|
|
383
|
+
}
|
|
384
|
+
if (props.underline) {
|
|
385
|
+
fmt.underline = true;
|
|
386
|
+
hasAny = true;
|
|
387
|
+
}
|
|
388
|
+
if (props.strikethrough || props.doubleStrikethrough) {
|
|
389
|
+
fmt.strikethrough = true;
|
|
390
|
+
hasAny = true;
|
|
391
|
+
}
|
|
392
|
+
if (props.superscript) {
|
|
393
|
+
fmt.superscript = true;
|
|
394
|
+
hasAny = true;
|
|
395
|
+
}
|
|
396
|
+
if (props.subscript) {
|
|
397
|
+
fmt.subscript = true;
|
|
398
|
+
hasAny = true;
|
|
399
|
+
}
|
|
400
|
+
if (props.smallCaps) {
|
|
401
|
+
fmt.smallCaps = true;
|
|
402
|
+
hasAny = true;
|
|
403
|
+
}
|
|
404
|
+
if (props.allCaps) {
|
|
405
|
+
fmt.textTransform = "uppercase";
|
|
406
|
+
hasAny = true;
|
|
407
|
+
}
|
|
408
|
+
if (props.color) {
|
|
409
|
+
fmt.color = `#${props.color}`;
|
|
410
|
+
hasAny = true;
|
|
411
|
+
}
|
|
412
|
+
if (props.fontSize != null) {
|
|
413
|
+
fmt.fontSize = props.fontSize; // already in points from reader
|
|
414
|
+
hasAny = true;
|
|
415
|
+
}
|
|
416
|
+
if (props.fontFamily) {
|
|
417
|
+
fmt.fontFamily = props.fontFamily;
|
|
418
|
+
hasAny = true;
|
|
419
|
+
}
|
|
420
|
+
if (props.highlight) {
|
|
421
|
+
const hex = HIGHLIGHT_COLORS[props.highlight.toLowerCase()];
|
|
422
|
+
if (hex) {
|
|
423
|
+
fmt.highlightColor = hex;
|
|
424
|
+
hasAny = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return hasAny ? fmt : null;
|
|
428
|
+
}
|
|
429
|
+
// DOCX highlight color names → CSS hex values
|
|
430
|
+
const HIGHLIGHT_COLORS = {
|
|
431
|
+
yellow: "#FFFF00",
|
|
432
|
+
green: "#00FF00",
|
|
433
|
+
cyan: "#00FFFF",
|
|
434
|
+
magenta: "#FF00FF",
|
|
435
|
+
red: "#FF0000",
|
|
436
|
+
blue: "#0000FF",
|
|
437
|
+
darkblue: "#00008B",
|
|
438
|
+
darkcyan: "#008B8B",
|
|
439
|
+
darkgreen: "#006400",
|
|
440
|
+
darkmagenta: "#8B008B",
|
|
441
|
+
darkred: "#8B0000",
|
|
442
|
+
darkyellow: "#8B8B00",
|
|
443
|
+
darkgray: "#A9A9A9",
|
|
444
|
+
lightgray: "#D3D3D3",
|
|
445
|
+
black: "#000000",
|
|
446
|
+
white: "#FFFFFF",
|
|
447
|
+
};
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Table conversion
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
function convertTable(table, odt, ctx) {
|
|
452
|
+
const tableOptions = {};
|
|
453
|
+
if (table.columnWidths.length > 0) {
|
|
454
|
+
tableOptions.columnWidths = table.columnWidths.map((w) => `${w}cm`);
|
|
455
|
+
}
|
|
456
|
+
// Build rowspan map: track which cells are covered by vertical merges
|
|
457
|
+
// Key: "rowIndex:colIndex", value: remaining rows still covered
|
|
458
|
+
const coveredCells = new Map();
|
|
459
|
+
odt.addTable((t) => {
|
|
460
|
+
table.rows.forEach((row, rowIdx) => {
|
|
461
|
+
t.addRow((r) => {
|
|
462
|
+
let colIdx = 0;
|
|
463
|
+
for (const cell of row.cells) {
|
|
464
|
+
// Skip cells covered by a rowspan from a previous row
|
|
465
|
+
while (coveredCells.get(`${rowIdx}:${colIdx}`) ?? 0 > 0) {
|
|
466
|
+
colIdx++;
|
|
467
|
+
}
|
|
468
|
+
const cellOptions = buildCellOptions(cell);
|
|
469
|
+
// Register this cell's rowspan coverage in subsequent rows
|
|
470
|
+
if (cell.vMerge === "restart" && cell.colSpan >= 1) {
|
|
471
|
+
// We need to look ahead to count how many rows this spans
|
|
472
|
+
const rowsSpanned = countRowSpan(table.rows, rowIdx, colIdx);
|
|
473
|
+
if (rowsSpanned > 1) {
|
|
474
|
+
cellOptions.rowSpan = rowsSpanned;
|
|
475
|
+
for (let r2 = rowIdx + 1; r2 < rowIdx + rowsSpanned; r2++) {
|
|
476
|
+
for (let c2 = colIdx; c2 < colIdx + cell.colSpan; c2++) {
|
|
477
|
+
coveredCells.set(`${r2}:${c2}`, 1);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Skip continuation cells (covered by a vMerge restart)
|
|
483
|
+
if (cell.vMerge === "continue") {
|
|
484
|
+
colIdx += cell.colSpan;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const cellContent = buildCellContent(cell, ctx);
|
|
488
|
+
r.addCell(cellContent, cellOptions);
|
|
489
|
+
colIdx += cell.colSpan;
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
}, tableOptions);
|
|
494
|
+
}
|
|
495
|
+
function buildCellOptions(cell) {
|
|
496
|
+
const opts = {};
|
|
497
|
+
if (cell.colSpan > 1)
|
|
498
|
+
opts.colSpan = cell.colSpan;
|
|
499
|
+
if (cell.backgroundColor)
|
|
500
|
+
opts.backgroundColor = `#${cell.backgroundColor}`;
|
|
501
|
+
if (cell.verticalAlign) {
|
|
502
|
+
opts.verticalAlign = cell.verticalAlign === "center" ? "middle" : cell.verticalAlign;
|
|
503
|
+
}
|
|
504
|
+
return opts;
|
|
505
|
+
}
|
|
506
|
+
function buildCellContent(cell, ctx) {
|
|
507
|
+
return (c) => {
|
|
508
|
+
// Collect all text from all paragraphs in the cell
|
|
509
|
+
let first = true;
|
|
510
|
+
for (const bodyEl of cell.body) {
|
|
511
|
+
if (bodyEl.type !== "paragraph")
|
|
512
|
+
continue;
|
|
513
|
+
// Add a separator between multiple paragraphs in one cell
|
|
514
|
+
if (!first)
|
|
515
|
+
c.addText(" / ");
|
|
516
|
+
first = false;
|
|
517
|
+
for (const run of bodyEl.runs) {
|
|
518
|
+
if (run.type === "run" && run.text) {
|
|
519
|
+
const fmt = resolveRunFormatting(run, ctx);
|
|
520
|
+
if (fmt) {
|
|
521
|
+
c.addText(run.text, fmt);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
c.addText(run.text);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (run.type === "hyperlink") {
|
|
528
|
+
const text = run.runs.map((r) => r.text).join("");
|
|
529
|
+
if (text)
|
|
530
|
+
c.addText(text);
|
|
531
|
+
}
|
|
532
|
+
// Other inline types (images, bookmarks etc.) not supported in cells — skip
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Count how many consecutive rows a cell at (rowIdx, colIdx) spans,
|
|
539
|
+
* by looking for vMerge="continue" cells at the same column position
|
|
540
|
+
* in subsequent rows.
|
|
541
|
+
*/
|
|
542
|
+
function countRowSpan(rows, startRow, colIdx) {
|
|
543
|
+
let count = 1;
|
|
544
|
+
for (let r = startRow + 1; r < rows.length; r++) {
|
|
545
|
+
let col = 0;
|
|
546
|
+
let found = false;
|
|
547
|
+
for (const cell of rows[r].cells) {
|
|
548
|
+
if (col === colIdx && cell.vMerge === "continue") {
|
|
549
|
+
found = true;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
col += cell.colSpan;
|
|
553
|
+
}
|
|
554
|
+
if (!found)
|
|
555
|
+
break;
|
|
556
|
+
count++;
|
|
557
|
+
}
|
|
558
|
+
return count;
|
|
559
|
+
}
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// List conversion
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
function convertListGroup(group, odt, ctx) {
|
|
564
|
+
if (group.items.length === 0)
|
|
565
|
+
return;
|
|
566
|
+
// Determine list type from the level-0 items' format
|
|
567
|
+
const level0 = group.items.find((i) => i.level === 0);
|
|
568
|
+
const listOptions = buildListOptions(level0?.isOrdered ?? false, level0?.numFormat ?? "bullet", level0?.start ?? 1);
|
|
569
|
+
const listData = buildNestedListData(group.items, 0, 0, ctx);
|
|
570
|
+
listData.options = listOptions;
|
|
571
|
+
odt.addList((builder) => {
|
|
572
|
+
populateListBuilder(builder, listData, ctx);
|
|
573
|
+
}, listOptions);
|
|
574
|
+
}
|
|
575
|
+
function buildListOptions(isOrdered, numFormat, start) {
|
|
576
|
+
if (!isOrdered)
|
|
577
|
+
return { type: "bullet" };
|
|
578
|
+
const fmt = docxNumFormatToOdt(numFormat);
|
|
579
|
+
const opts = { type: "numbered", numFormat: fmt };
|
|
580
|
+
if (start !== 1)
|
|
581
|
+
opts.startValue = start;
|
|
582
|
+
return opts;
|
|
583
|
+
}
|
|
584
|
+
function docxNumFormatToOdt(numFormat) {
|
|
585
|
+
switch (numFormat) {
|
|
586
|
+
case "lowerLetter":
|
|
587
|
+
return "a";
|
|
588
|
+
case "upperLetter":
|
|
589
|
+
return "A";
|
|
590
|
+
case "lowerRoman":
|
|
591
|
+
return "i";
|
|
592
|
+
case "upperRoman":
|
|
593
|
+
return "I";
|
|
594
|
+
default:
|
|
595
|
+
return "1"; // decimal, ordinal, etc.
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Build a nested ListData tree from a flat array of ListGroupItems.
|
|
600
|
+
* Processes items starting at `startIdx`, at `currentLevel`, and
|
|
601
|
+
* returns the tree plus the index of the next unconsumed item.
|
|
602
|
+
*/
|
|
603
|
+
function buildNestedListData(items, startIdx, currentLevel, ctx) {
|
|
604
|
+
const listItems = [];
|
|
605
|
+
let i = startIdx;
|
|
606
|
+
while (i < items.length) {
|
|
607
|
+
const item = items[i];
|
|
608
|
+
if (item.level < currentLevel) {
|
|
609
|
+
// Return to parent level — stop processing here
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
if (item.level > currentLevel) {
|
|
613
|
+
// Higher level than expected — attach as nested to last item if possible
|
|
614
|
+
// (handles malformed DOCX where level jumps without a parent)
|
|
615
|
+
if (listItems.length === 0) {
|
|
616
|
+
listItems.push({ runs: [] });
|
|
617
|
+
}
|
|
618
|
+
const nested = buildNestedListData(items, i, item.level, ctx);
|
|
619
|
+
const lastItem = listItems[listItems.length - 1];
|
|
620
|
+
lastItem.nested = nested;
|
|
621
|
+
// Advance past all items consumed at this level
|
|
622
|
+
i = advancePastLevel(items, i, item.level);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// Same level — add this item
|
|
626
|
+
const runs = buildListItemRuns(item.runs, ctx);
|
|
627
|
+
const listItem = { runs };
|
|
628
|
+
// Check if next items are at a deeper level — if so, attach as nested
|
|
629
|
+
const nextIdx = i + 1;
|
|
630
|
+
if (nextIdx < items.length && items[nextIdx].level > currentLevel) {
|
|
631
|
+
const nestedOptions = buildListOptions(items[nextIdx].isOrdered, items[nextIdx].numFormat, items[nextIdx].start);
|
|
632
|
+
const nested = buildNestedListData(items, nextIdx, items[nextIdx].level, ctx);
|
|
633
|
+
nested.options = nestedOptions;
|
|
634
|
+
listItem.nested = nested;
|
|
635
|
+
i = advancePastLevel(items, nextIdx, items[nextIdx].level);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
i++;
|
|
639
|
+
}
|
|
640
|
+
listItems.push(listItem);
|
|
641
|
+
}
|
|
642
|
+
return { items: listItems };
|
|
643
|
+
}
|
|
644
|
+
function advancePastLevel(items, startIdx, level) {
|
|
645
|
+
let i = startIdx;
|
|
646
|
+
while (i < items.length && items[i].level >= level)
|
|
647
|
+
i++;
|
|
648
|
+
return i;
|
|
649
|
+
}
|
|
650
|
+
function buildListItemRuns(inlines, ctx) {
|
|
651
|
+
const runs = [];
|
|
652
|
+
for (const el of inlines) {
|
|
653
|
+
if (el.type === "run" && el.text) {
|
|
654
|
+
const fmt = resolveRunFormatting(el, ctx);
|
|
655
|
+
runs.push({ text: el.text, formatting: fmt ?? undefined });
|
|
656
|
+
}
|
|
657
|
+
else if (el.type === "hyperlink") {
|
|
658
|
+
const text = el.runs.map((r) => r.text).join("");
|
|
659
|
+
if (text)
|
|
660
|
+
runs.push({ text, link: el.url });
|
|
661
|
+
}
|
|
662
|
+
else if (el.type === "tab") {
|
|
663
|
+
runs.push({ text: "", field: "tab" });
|
|
664
|
+
}
|
|
665
|
+
else if (el.type === "lineBreak") {
|
|
666
|
+
runs.push({ text: "", lineBreak: true });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return runs;
|
|
670
|
+
}
|
|
671
|
+
function populateListBuilder(builder, listData, ctx) {
|
|
672
|
+
for (const item of listData.items) {
|
|
673
|
+
if (item.runs.length > 0) {
|
|
674
|
+
builder.addItem((p) => {
|
|
675
|
+
for (const run of item.runs) {
|
|
676
|
+
if (run.text)
|
|
677
|
+
p.addText(run.text, run.formatting);
|
|
678
|
+
else if (run.field === "tab")
|
|
679
|
+
p.addTab();
|
|
680
|
+
else if (run.lineBreak)
|
|
681
|
+
p.addLineBreak();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
builder.addItem("");
|
|
687
|
+
}
|
|
688
|
+
if (item.nested && item.nested.items.length > 0) {
|
|
689
|
+
builder.addNested((sub) => {
|
|
690
|
+
populateListBuilder(sub, item.nested, ctx);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Style chain utilities
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
/**
|
|
699
|
+
* Collect the style chain from the root basedOn style down to the given
|
|
700
|
+
* styleId (root-first order so child overrides parent).
|
|
701
|
+
*/
|
|
702
|
+
function getStyleChain(styleId, styles) {
|
|
703
|
+
if (!styleId)
|
|
704
|
+
return [];
|
|
705
|
+
const chain = [];
|
|
706
|
+
let id = styleId;
|
|
707
|
+
const visited = new Set();
|
|
708
|
+
while (id && !visited.has(id)) {
|
|
709
|
+
visited.add(id);
|
|
710
|
+
const entry = styles.get(id);
|
|
711
|
+
if (!entry)
|
|
712
|
+
break;
|
|
713
|
+
chain.unshift(entry); // prepend so chain is root-first
|
|
714
|
+
id = entry.basedOn;
|
|
715
|
+
}
|
|
716
|
+
return chain;
|
|
717
|
+
}
|
|
718
|
+
function mergeParaProps(base, override) {
|
|
719
|
+
if (override.alignment !== undefined)
|
|
720
|
+
base.alignment = override.alignment;
|
|
721
|
+
if (override.pageBreakBefore !== undefined)
|
|
722
|
+
base.pageBreakBefore = override.pageBreakBefore;
|
|
723
|
+
if (override.spaceBefore !== undefined)
|
|
724
|
+
base.spaceBefore = override.spaceBefore;
|
|
725
|
+
if (override.spaceAfter !== undefined)
|
|
726
|
+
base.spaceAfter = override.spaceAfter;
|
|
727
|
+
if (override.lineHeight !== undefined)
|
|
728
|
+
base.lineHeight = override.lineHeight;
|
|
729
|
+
if (override.indentLeft !== undefined)
|
|
730
|
+
base.indentLeft = override.indentLeft;
|
|
731
|
+
if (override.indentRight !== undefined)
|
|
732
|
+
base.indentRight = override.indentRight;
|
|
733
|
+
if (override.indentFirstLine !== undefined)
|
|
734
|
+
base.indentFirstLine = override.indentFirstLine;
|
|
735
|
+
if (override.list !== undefined)
|
|
736
|
+
base.list = override.list;
|
|
737
|
+
if (override.borderBottom !== undefined)
|
|
738
|
+
base.borderBottom = override.borderBottom;
|
|
739
|
+
}
|
|
740
|
+
function mergeRunProps(base, override) {
|
|
741
|
+
if (override.bold !== undefined)
|
|
742
|
+
base.bold = override.bold;
|
|
743
|
+
if (override.italic !== undefined)
|
|
744
|
+
base.italic = override.italic;
|
|
745
|
+
if (override.underline !== undefined)
|
|
746
|
+
base.underline = override.underline;
|
|
747
|
+
if (override.strikethrough !== undefined)
|
|
748
|
+
base.strikethrough = override.strikethrough;
|
|
749
|
+
if (override.doubleStrikethrough !== undefined)
|
|
750
|
+
base.doubleStrikethrough = override.doubleStrikethrough;
|
|
751
|
+
if (override.superscript !== undefined)
|
|
752
|
+
base.superscript = override.superscript;
|
|
753
|
+
if (override.subscript !== undefined)
|
|
754
|
+
base.subscript = override.subscript;
|
|
755
|
+
if (override.smallCaps !== undefined)
|
|
756
|
+
base.smallCaps = override.smallCaps;
|
|
757
|
+
if (override.allCaps !== undefined)
|
|
758
|
+
base.allCaps = override.allCaps;
|
|
759
|
+
if (override.color !== undefined)
|
|
760
|
+
base.color = override.color;
|
|
761
|
+
if (override.fontSize !== undefined)
|
|
762
|
+
base.fontSize = override.fontSize;
|
|
763
|
+
if (override.highlight !== undefined)
|
|
764
|
+
base.highlight = override.highlight;
|
|
765
|
+
if (override.fontFamily !== undefined)
|
|
766
|
+
base.fontFamily = override.fontFamily;
|
|
767
|
+
if (override.lang !== undefined)
|
|
768
|
+
base.lang = override.lang;
|
|
769
|
+
if (override.rStyleId !== undefined)
|
|
770
|
+
base.rStyleId = override.rStyleId;
|
|
771
|
+
}
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// Numbering lookup — resolves a numId + level to a NumberingLevel
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
function resolveNumberingLevel(numId, level, docxDoc) {
|
|
776
|
+
const levels = docxDoc.numbering?.get(numId);
|
|
777
|
+
if (!levels)
|
|
778
|
+
return null;
|
|
779
|
+
return levels[level] ?? levels[0] ?? null;
|
|
780
|
+
}
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
// Plain text extraction — used for headers/footers and footnote content
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
function extractPlainText(elements) {
|
|
785
|
+
const parts = [];
|
|
786
|
+
for (const el of elements) {
|
|
787
|
+
if (el.type !== "paragraph")
|
|
788
|
+
continue;
|
|
789
|
+
for (const run of el.runs) {
|
|
790
|
+
if (run.type === "run")
|
|
791
|
+
parts.push(run.text);
|
|
792
|
+
else if (run.type === "hyperlink") {
|
|
793
|
+
parts.push(run.runs.map((r) => r.text).join(""));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return parts.join("").trim();
|
|
798
|
+
}
|
|
799
|
+
//# sourceMappingURL=converter.js.map
|