@bendyline/squisq-formats 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/dist/chunk-532L4D5D.js +21 -0
- package/dist/chunk-532L4D5D.js.map +1 -0
- package/dist/chunk-67KIJHV2.js +21 -0
- package/dist/chunk-67KIJHV2.js.map +1 -0
- package/dist/chunk-KAK4V57E.js +387 -0
- package/dist/chunk-KAK4V57E.js.map +1 -0
- package/dist/chunk-MQHCXI56.js +1035 -0
- package/dist/chunk-MQHCXI56.js.map +1 -0
- package/dist/chunk-TBPD5PCU.js +1136 -0
- package/dist/chunk-TBPD5PCU.js.map +1 -0
- package/dist/chunk-ULLIPBEJ.js +271 -0
- package/dist/chunk-ULLIPBEJ.js.map +1 -0
- package/dist/docx/index.d.ts +108 -0
- package/dist/docx/index.js +14 -0
- package/dist/docx/index.js.map +1 -0
- package/dist/html/index.d.ts +166 -0
- package/dist/html/index.js +17 -0
- package/dist/html/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/ooxml/index.d.ts +351 -0
- package/dist/ooxml/index.js +99 -0
- package/dist/ooxml/index.js.map +1 -0
- package/dist/pdf/index.d.ts +130 -0
- package/dist/pdf/index.js +15 -0
- package/dist/pdf/index.js.map +1 -0
- package/dist/pptx/index.d.ts +58 -0
- package/dist/pptx/index.js +13 -0
- package/dist/pptx/index.js.map +1 -0
- package/dist/xlsx/index.d.ts +58 -0
- package/dist/xlsx/index.js +13 -0
- package/dist/xlsx/index.js.map +1 -0
- package/package.json +85 -0
- package/src/__tests__/docxExport.test.ts +457 -0
- package/src/__tests__/docxImport.test.ts +410 -0
- package/src/__tests__/html.test.ts +295 -0
- package/src/__tests__/ooxml.test.ts +197 -0
- package/src/__tests__/pdfExport.test.ts +322 -0
- package/src/__tests__/pdfImport.test.ts +290 -0
- package/src/__tests__/roundTrip.test.ts +201 -0
- package/src/docx/export.ts +978 -0
- package/src/docx/import.ts +909 -0
- package/src/docx/index.ts +26 -0
- package/src/docx/styles.ts +145 -0
- package/src/html/htmlTemplate.ts +307 -0
- package/src/html/imageUtils.ts +66 -0
- package/src/html/index.ts +168 -0
- package/src/index.ts +51 -0
- package/src/ooxml/index.ts +87 -0
- package/src/ooxml/namespaces.ts +160 -0
- package/src/ooxml/reader.ts +218 -0
- package/src/ooxml/types.ts +104 -0
- package/src/ooxml/writer.ts +321 -0
- package/src/ooxml/xmlUtils.ts +123 -0
- package/src/pdf/export.ts +1029 -0
- package/src/pdf/import.ts +835 -0
- package/src/pdf/index.ts +29 -0
- package/src/pdf/styles.ts +180 -0
- package/src/pptx/index.ts +78 -0
- package/src/xlsx/index.ts +78 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCX Export
|
|
3
|
+
*
|
|
4
|
+
* Converts a squisq MarkdownDocument (or Doc) into a .docx file
|
|
5
|
+
* by generating WordprocessingML XML and assembling the OOXML package.
|
|
6
|
+
*
|
|
7
|
+
* No third-party docx library — all XML is generated directly using
|
|
8
|
+
* the shared ooxml/ infrastructure of this package.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
13
|
+
* import { markdownDocToDocx } from '@bendyline/squisq-formats/docx';
|
|
14
|
+
*
|
|
15
|
+
* const md = parseMarkdown('# Hello\n\nWorld **bold** text');
|
|
16
|
+
* const blob = await markdownDocToDocx(md);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
21
|
+
import { docToMarkdown } from '@bendyline/squisq/doc';
|
|
22
|
+
import type {
|
|
23
|
+
MarkdownDocument,
|
|
24
|
+
MarkdownBlockNode,
|
|
25
|
+
MarkdownInlineNode,
|
|
26
|
+
MarkdownHeading,
|
|
27
|
+
MarkdownParagraph,
|
|
28
|
+
MarkdownBlockquote,
|
|
29
|
+
MarkdownList,
|
|
30
|
+
MarkdownListItem,
|
|
31
|
+
MarkdownCodeBlock,
|
|
32
|
+
MarkdownTable,
|
|
33
|
+
MarkdownTableRow,
|
|
34
|
+
MarkdownTableCell,
|
|
35
|
+
MarkdownHtmlBlock,
|
|
36
|
+
MarkdownMathBlock,
|
|
37
|
+
MarkdownFootnoteDefinition,
|
|
38
|
+
MarkdownLink,
|
|
39
|
+
MarkdownImage,
|
|
40
|
+
MarkdownFootnoteReference,
|
|
41
|
+
} from '@bendyline/squisq/markdown';
|
|
42
|
+
|
|
43
|
+
import { createPackage } from '../ooxml/writer.js';
|
|
44
|
+
import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
|
|
45
|
+
import {
|
|
46
|
+
NS_WML,
|
|
47
|
+
NS_R,
|
|
48
|
+
NS_MC,
|
|
49
|
+
REL_OFFICE_DOCUMENT,
|
|
50
|
+
REL_STYLES,
|
|
51
|
+
REL_NUMBERING,
|
|
52
|
+
REL_SETTINGS,
|
|
53
|
+
REL_FONT_TABLE,
|
|
54
|
+
REL_HYPERLINK,
|
|
55
|
+
REL_FOOTNOTES,
|
|
56
|
+
CONTENT_TYPE_DOCX_DOCUMENT,
|
|
57
|
+
CONTENT_TYPE_DOCX_STYLES,
|
|
58
|
+
CONTENT_TYPE_DOCX_NUMBERING,
|
|
59
|
+
CONTENT_TYPE_DOCX_SETTINGS,
|
|
60
|
+
CONTENT_TYPE_DOCX_FONT_TABLE,
|
|
61
|
+
CONTENT_TYPE_DOCX_FOOTNOTES,
|
|
62
|
+
} from '../ooxml/namespaces.js';
|
|
63
|
+
import {
|
|
64
|
+
DEPTH_TO_STYLE_ID,
|
|
65
|
+
HEADING_FONT_SIZES,
|
|
66
|
+
DEFAULT_FONT,
|
|
67
|
+
DEFAULT_HEADING_FONT,
|
|
68
|
+
DEFAULT_FONT_SIZE_HALF_POINTS,
|
|
69
|
+
DEFAULT_CODE_FONT,
|
|
70
|
+
DEFAULT_CODE_FONT_SIZE,
|
|
71
|
+
HYPERLINK_COLOR,
|
|
72
|
+
pointsToTwips,
|
|
73
|
+
} from './styles.js';
|
|
74
|
+
|
|
75
|
+
// ============================================
|
|
76
|
+
// Public API
|
|
77
|
+
// ============================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options for DOCX export.
|
|
81
|
+
*/
|
|
82
|
+
export interface DocxExportOptions {
|
|
83
|
+
/** Document title (appears in core properties) */
|
|
84
|
+
title?: string;
|
|
85
|
+
/** Document author */
|
|
86
|
+
author?: string;
|
|
87
|
+
/** Document description */
|
|
88
|
+
description?: string;
|
|
89
|
+
/** Default body font family. Default: "Calibri" */
|
|
90
|
+
defaultFont?: string;
|
|
91
|
+
/** Default body font size in points. Default: 11 */
|
|
92
|
+
defaultFontSize?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert a MarkdownDocument to a .docx Blob.
|
|
97
|
+
*
|
|
98
|
+
* @param doc - The parsed markdown document
|
|
99
|
+
* @param options - Export options
|
|
100
|
+
* @returns An ArrayBuffer containing the .docx file
|
|
101
|
+
*/
|
|
102
|
+
export async function markdownDocToDocx(
|
|
103
|
+
doc: MarkdownDocument,
|
|
104
|
+
options: DocxExportOptions = {},
|
|
105
|
+
): Promise<ArrayBuffer> {
|
|
106
|
+
const ctx = new ExportContext(options);
|
|
107
|
+
const bodyXml = convertBlocks(doc.children, ctx);
|
|
108
|
+
return buildDocxPackage(bodyXml, ctx, options);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a squisq Doc to a .docx Blob.
|
|
113
|
+
*
|
|
114
|
+
* Convenience wrapper that converts Doc → MarkdownDocument → DOCX.
|
|
115
|
+
*
|
|
116
|
+
* @param doc - The squisq Doc
|
|
117
|
+
* @param options - Export options
|
|
118
|
+
* @returns An ArrayBuffer containing the .docx file
|
|
119
|
+
*/
|
|
120
|
+
export async function docToDocx(doc: Doc, options: DocxExportOptions = {}): Promise<ArrayBuffer> {
|
|
121
|
+
const markdownDoc = docToMarkdown(doc);
|
|
122
|
+
return markdownDocToDocx(markdownDoc, options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================
|
|
126
|
+
// Export Context
|
|
127
|
+
// ============================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Tracks state during export: relationship IDs, numbering definitions,
|
|
131
|
+
* footnote bodies, and embedded images.
|
|
132
|
+
*/
|
|
133
|
+
class ExportContext {
|
|
134
|
+
private nextRelId = 1;
|
|
135
|
+
private nextNumId = 1;
|
|
136
|
+
private nextFootnoteId = 1; // 0 is separator, start user footnotes at 1
|
|
137
|
+
|
|
138
|
+
/** Relationships for word/_rels/document.xml.rels */
|
|
139
|
+
readonly relationships: Array<{
|
|
140
|
+
id: string;
|
|
141
|
+
type: string;
|
|
142
|
+
target: string;
|
|
143
|
+
targetMode?: 'External';
|
|
144
|
+
}> = [];
|
|
145
|
+
|
|
146
|
+
/** Numbering definitions (abstract + num) */
|
|
147
|
+
readonly numberingDefs: NumberingDef[] = [];
|
|
148
|
+
|
|
149
|
+
/** Footnote XML bodies (keyed by footnote id) */
|
|
150
|
+
readonly footnotes = new Map<number, string>();
|
|
151
|
+
|
|
152
|
+
/** Footnote identifier → numeric id mapping */
|
|
153
|
+
readonly footnoteIdMap = new Map<string, number>();
|
|
154
|
+
|
|
155
|
+
/** Embedded images: rId → { path, data, contentType } */
|
|
156
|
+
readonly images: Array<{
|
|
157
|
+
relId: string;
|
|
158
|
+
path: string;
|
|
159
|
+
data: ArrayBuffer | Uint8Array;
|
|
160
|
+
contentType: string;
|
|
161
|
+
}> = [];
|
|
162
|
+
|
|
163
|
+
/** Whether we have any lists (determines if numbering.xml is needed) */
|
|
164
|
+
hasLists = false;
|
|
165
|
+
|
|
166
|
+
/** Whether we have any footnotes */
|
|
167
|
+
hasFootnotes = false;
|
|
168
|
+
|
|
169
|
+
readonly font: string;
|
|
170
|
+
readonly fontSize: number;
|
|
171
|
+
|
|
172
|
+
constructor(options: DocxExportOptions) {
|
|
173
|
+
this.font = options.defaultFont ?? DEFAULT_FONT;
|
|
174
|
+
this.fontSize = options.defaultFontSize
|
|
175
|
+
? options.defaultFontSize * 2
|
|
176
|
+
: DEFAULT_FONT_SIZE_HALF_POINTS;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Allocate a new relationship ID */
|
|
180
|
+
allocRelId(): string {
|
|
181
|
+
return `rId${this.nextRelId++}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Add a hyperlink relationship and return the rId */
|
|
185
|
+
addHyperlink(url: string): string {
|
|
186
|
+
const id = this.allocRelId();
|
|
187
|
+
this.relationships.push({
|
|
188
|
+
id,
|
|
189
|
+
type: REL_HYPERLINK,
|
|
190
|
+
target: url,
|
|
191
|
+
targetMode: 'External',
|
|
192
|
+
});
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Allocate a numbering definition for a list */
|
|
197
|
+
allocNumbering(ordered: boolean): number {
|
|
198
|
+
const numId = this.nextNumId++;
|
|
199
|
+
this.numberingDefs.push({ numId, ordered });
|
|
200
|
+
this.hasLists = true;
|
|
201
|
+
return numId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Register or look up a footnote by its string identifier */
|
|
205
|
+
getFootnoteId(identifier: string): number {
|
|
206
|
+
let id = this.footnoteIdMap.get(identifier);
|
|
207
|
+
if (id === undefined) {
|
|
208
|
+
id = this.nextFootnoteId++;
|
|
209
|
+
this.footnoteIdMap.set(identifier, id);
|
|
210
|
+
this.hasFootnotes = true;
|
|
211
|
+
}
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface NumberingDef {
|
|
217
|
+
numId: number;
|
|
218
|
+
ordered: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================
|
|
222
|
+
// Block Conversion
|
|
223
|
+
// ============================================
|
|
224
|
+
|
|
225
|
+
function convertBlocks(nodes: MarkdownBlockNode[], ctx: ExportContext): string {
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
for (const node of nodes) {
|
|
228
|
+
parts.push(convertBlock(node, ctx, 0));
|
|
229
|
+
}
|
|
230
|
+
return parts.join('');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function convertBlock(node: MarkdownBlockNode, ctx: ExportContext, listDepth: number): string {
|
|
234
|
+
switch (node.type) {
|
|
235
|
+
case 'heading':
|
|
236
|
+
return convertHeading(node, ctx);
|
|
237
|
+
case 'paragraph':
|
|
238
|
+
return convertParagraph(node, ctx);
|
|
239
|
+
case 'blockquote':
|
|
240
|
+
return convertBlockquote(node, ctx);
|
|
241
|
+
case 'list':
|
|
242
|
+
return convertList(node, ctx, listDepth);
|
|
243
|
+
case 'code':
|
|
244
|
+
return convertCodeBlock(node);
|
|
245
|
+
case 'table':
|
|
246
|
+
return convertTable(node, ctx);
|
|
247
|
+
case 'thematicBreak':
|
|
248
|
+
return convertThematicBreak();
|
|
249
|
+
case 'htmlBlock':
|
|
250
|
+
return convertHtmlBlock(node);
|
|
251
|
+
case 'math':
|
|
252
|
+
return convertMathBlock(node);
|
|
253
|
+
case 'footnoteDefinition':
|
|
254
|
+
return convertFootnoteDefinition(node, ctx);
|
|
255
|
+
default:
|
|
256
|
+
// Definition lists, directives, link definitions — skip or emit as plain text
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function convertHeading(node: MarkdownHeading, ctx: ExportContext): string {
|
|
262
|
+
const styleId = DEPTH_TO_STYLE_ID[node.depth] ?? 'Heading1';
|
|
263
|
+
const runs = convertInlines(node.children, ctx);
|
|
264
|
+
return `<w:p>` + `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>` + runs + `</w:p>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function convertParagraph(node: MarkdownParagraph, ctx: ExportContext): string {
|
|
268
|
+
const runs = convertInlines(node.children, ctx);
|
|
269
|
+
return `<w:p>${runs}</w:p>`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function convertBlockquote(node: MarkdownBlockquote, ctx: ExportContext): string {
|
|
273
|
+
// Render each child block as a paragraph with Quote style
|
|
274
|
+
const parts: string[] = [];
|
|
275
|
+
for (const child of node.children) {
|
|
276
|
+
if (child.type === 'paragraph') {
|
|
277
|
+
const runs = convertInlines(child.children, ctx);
|
|
278
|
+
parts.push(
|
|
279
|
+
`<w:p>` +
|
|
280
|
+
`<w:pPr><w:pStyle w:val="Quote"/>` +
|
|
281
|
+
`<w:ind w:left="${pointsToTwips(36)}"/>` +
|
|
282
|
+
`<w:pBdr><w:left w:val="single" w:sz="12" w:space="4" w:color="CCCCCC"/></w:pBdr>` +
|
|
283
|
+
`</w:pPr>` +
|
|
284
|
+
runs +
|
|
285
|
+
`</w:p>`,
|
|
286
|
+
);
|
|
287
|
+
} else {
|
|
288
|
+
// Nested non-paragraph (e.g., nested blockquote, list) — recurse
|
|
289
|
+
parts.push(convertBlock(child, ctx, 0));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return parts.join('');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function convertList(node: MarkdownList, ctx: ExportContext, depth: number): string {
|
|
296
|
+
const numId = ctx.allocNumbering(node.ordered ?? false);
|
|
297
|
+
const parts: string[] = [];
|
|
298
|
+
for (const item of node.children) {
|
|
299
|
+
parts.push(convertListItem(item, ctx, numId, depth));
|
|
300
|
+
}
|
|
301
|
+
return parts.join('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function convertListItem(
|
|
305
|
+
item: MarkdownListItem,
|
|
306
|
+
ctx: ExportContext,
|
|
307
|
+
numId: number,
|
|
308
|
+
depth: number,
|
|
309
|
+
): string {
|
|
310
|
+
const parts: string[] = [];
|
|
311
|
+
for (const child of item.children) {
|
|
312
|
+
if (child.type === 'paragraph') {
|
|
313
|
+
const runs = convertInlines(child.children, ctx);
|
|
314
|
+
parts.push(
|
|
315
|
+
`<w:p>` +
|
|
316
|
+
`<w:pPr>` +
|
|
317
|
+
`<w:pStyle w:val="ListParagraph"/>` +
|
|
318
|
+
`<w:numPr><w:ilvl w:val="${depth}"/><w:numId w:val="${numId}"/></w:numPr>` +
|
|
319
|
+
`</w:pPr>` +
|
|
320
|
+
runs +
|
|
321
|
+
`</w:p>`,
|
|
322
|
+
);
|
|
323
|
+
} else if (child.type === 'list') {
|
|
324
|
+
// Nested list — increase depth
|
|
325
|
+
parts.push(convertList(child, ctx, depth + 1));
|
|
326
|
+
} else {
|
|
327
|
+
parts.push(convertBlock(child, ctx, depth));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return parts.join('');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function convertCodeBlock(node: MarkdownCodeBlock): string {
|
|
334
|
+
// Emit each line as a separate paragraph with code styling
|
|
335
|
+
const lines = node.value.split('\n');
|
|
336
|
+
const parts: string[] = [];
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
parts.push(
|
|
339
|
+
`<w:p>` +
|
|
340
|
+
`<w:pPr>` +
|
|
341
|
+
`<w:pStyle w:val="Code"/>` +
|
|
342
|
+
`<w:pBdr>` +
|
|
343
|
+
`<w:top w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
|
|
344
|
+
`<w:left w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
|
|
345
|
+
`<w:bottom w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
|
|
346
|
+
`<w:right w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
|
|
347
|
+
`</w:pBdr>` +
|
|
348
|
+
`<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>` +
|
|
349
|
+
`</w:pPr>` +
|
|
350
|
+
`<w:r>` +
|
|
351
|
+
`<w:rPr><w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
|
|
352
|
+
`<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/></w:rPr>` +
|
|
353
|
+
`<w:t xml:space="preserve">${escapeXml(line)}</w:t>` +
|
|
354
|
+
`</w:r>` +
|
|
355
|
+
`</w:p>`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return parts.join('');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function convertTable(node: MarkdownTable, ctx: ExportContext): string {
|
|
362
|
+
const rows: string[] = [];
|
|
363
|
+
for (let ri = 0; ri < node.children.length; ri++) {
|
|
364
|
+
const row = node.children[ri];
|
|
365
|
+
rows.push(convertTableRow(row, ctx, ri === 0, node.align));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
`<w:tbl>` +
|
|
370
|
+
`<w:tblPr>` +
|
|
371
|
+
`<w:tblStyle w:val="TableGrid"/>` +
|
|
372
|
+
`<w:tblW w:w="0" w:type="auto"/>` +
|
|
373
|
+
`<w:tblBorders>` +
|
|
374
|
+
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
375
|
+
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
376
|
+
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
377
|
+
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
378
|
+
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
379
|
+
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
380
|
+
`</w:tblBorders>` +
|
|
381
|
+
`</w:tblPr>` +
|
|
382
|
+
`<w:tblGrid/>` +
|
|
383
|
+
rows.join('') +
|
|
384
|
+
`</w:tbl>`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function convertTableRow(
|
|
389
|
+
row: MarkdownTableRow,
|
|
390
|
+
ctx: ExportContext,
|
|
391
|
+
isHeader: boolean,
|
|
392
|
+
align?: (('left' | 'right' | 'center') | null)[],
|
|
393
|
+
): string {
|
|
394
|
+
const cells: string[] = [];
|
|
395
|
+
for (let ci = 0; ci < row.children.length; ci++) {
|
|
396
|
+
const cell = row.children[ci];
|
|
397
|
+
const cellAlign = align?.[ci] ?? null;
|
|
398
|
+
cells.push(convertTableCell(cell, ctx, isHeader, cellAlign));
|
|
399
|
+
}
|
|
400
|
+
const trPr = isHeader ? '<w:trPr><w:tblHeader/></w:trPr>' : '';
|
|
401
|
+
return `<w:tr>${trPr}${cells.join('')}</w:tr>`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function convertTableCell(
|
|
405
|
+
cell: MarkdownTableCell,
|
|
406
|
+
ctx: ExportContext,
|
|
407
|
+
isHeader: boolean,
|
|
408
|
+
align: 'left' | 'right' | 'center' | null,
|
|
409
|
+
): string {
|
|
410
|
+
const runs = convertInlines(cell.children, ctx);
|
|
411
|
+
const rPr = isHeader ? '<w:rPr><w:b/></w:rPr>' : '';
|
|
412
|
+
const jcMap = { left: 'left', center: 'center', right: 'right' };
|
|
413
|
+
const jc = align ? `<w:jc w:val="${jcMap[align]}"/>` : '';
|
|
414
|
+
const pPr = rPr || jc ? `<w:pPr>${jc}</w:pPr>` : '';
|
|
415
|
+
return `<w:tc><w:p>${pPr}${runs}</w:p></w:tc>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function convertThematicBreak(): string {
|
|
419
|
+
return (
|
|
420
|
+
`<w:p>` +
|
|
421
|
+
`<w:pPr>` +
|
|
422
|
+
`<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr>` +
|
|
423
|
+
`</w:pPr>` +
|
|
424
|
+
`</w:p>`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function convertHtmlBlock(node: MarkdownHtmlBlock): string {
|
|
429
|
+
// Best-effort: extract text content from the HTML
|
|
430
|
+
const text = stripHtmlTags(node.rawHtml);
|
|
431
|
+
if (!text.trim()) return '';
|
|
432
|
+
return `<w:p><w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function convertMathBlock(node: MarkdownMathBlock): string {
|
|
436
|
+
// Emit as a styled paragraph with the raw LaTeX
|
|
437
|
+
return (
|
|
438
|
+
`<w:p>` +
|
|
439
|
+
`<w:pPr><w:jc w:val="center"/></w:pPr>` +
|
|
440
|
+
`<w:r>` +
|
|
441
|
+
`<w:rPr><w:rFonts w:ascii="Cambria Math" w:hAnsi="Cambria Math"/><w:i/></w:rPr>` +
|
|
442
|
+
`<w:t xml:space="preserve">${escapeXml(node.value)}</w:t>` +
|
|
443
|
+
`</w:r>` +
|
|
444
|
+
`</w:p>`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function convertFootnoteDefinition(node: MarkdownFootnoteDefinition, ctx: ExportContext): string {
|
|
449
|
+
const fnId = ctx.getFootnoteId(node.identifier);
|
|
450
|
+
|
|
451
|
+
// Build the footnote body XML
|
|
452
|
+
const bodyParts: string[] = [];
|
|
453
|
+
for (const child of node.children) {
|
|
454
|
+
if (child.type === 'paragraph') {
|
|
455
|
+
const runs = convertInlines(child.children, ctx);
|
|
456
|
+
bodyParts.push(`<w:p>${runs}</w:p>`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const footnoteXml =
|
|
461
|
+
`<w:footnote w:id="${fnId}">` +
|
|
462
|
+
(bodyParts.length > 0 ? bodyParts.join('') : `<w:p/>`) +
|
|
463
|
+
`</w:footnote>`;
|
|
464
|
+
|
|
465
|
+
ctx.footnotes.set(fnId, footnoteXml);
|
|
466
|
+
|
|
467
|
+
// Don't emit anything in the main body for the definition
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============================================
|
|
472
|
+
// Inline Conversion
|
|
473
|
+
// ============================================
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Formatting state passed down through nested inline elements.
|
|
477
|
+
*/
|
|
478
|
+
interface InlineFormat {
|
|
479
|
+
bold?: boolean;
|
|
480
|
+
italic?: boolean;
|
|
481
|
+
strike?: boolean;
|
|
482
|
+
code?: boolean;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function convertInlines(
|
|
486
|
+
nodes: MarkdownInlineNode[],
|
|
487
|
+
ctx: ExportContext,
|
|
488
|
+
format: InlineFormat = {},
|
|
489
|
+
): string {
|
|
490
|
+
const parts: string[] = [];
|
|
491
|
+
for (const node of nodes) {
|
|
492
|
+
parts.push(convertInline(node, ctx, format));
|
|
493
|
+
}
|
|
494
|
+
return parts.join('');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: InlineFormat): string {
|
|
498
|
+
switch (node.type) {
|
|
499
|
+
case 'text':
|
|
500
|
+
return makeRun(node.value, format);
|
|
501
|
+
case 'strong':
|
|
502
|
+
return convertInlines(node.children, ctx, { ...format, bold: true });
|
|
503
|
+
case 'emphasis':
|
|
504
|
+
return convertInlines(node.children, ctx, { ...format, italic: true });
|
|
505
|
+
case 'delete':
|
|
506
|
+
return convertInlines(node.children, ctx, { ...format, strike: true });
|
|
507
|
+
case 'inlineCode':
|
|
508
|
+
return makeRun(node.value, { ...format, code: true });
|
|
509
|
+
case 'link':
|
|
510
|
+
return convertLink(node, ctx, format);
|
|
511
|
+
case 'image':
|
|
512
|
+
return convertImage(node);
|
|
513
|
+
case 'break':
|
|
514
|
+
return `<w:r><w:br/></w:r>`;
|
|
515
|
+
case 'htmlInline':
|
|
516
|
+
return makeRun(stripHtmlTags(node.rawHtml), format);
|
|
517
|
+
case 'inlineMath':
|
|
518
|
+
return makeRun(node.value, { ...format, code: true });
|
|
519
|
+
case 'footnoteReference':
|
|
520
|
+
return convertFootnoteRef(node, ctx);
|
|
521
|
+
default:
|
|
522
|
+
// linkReference, imageReference, textDirective — skip or emit plain
|
|
523
|
+
return '';
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function makeRun(text: string, format: InlineFormat): string {
|
|
528
|
+
if (!text) return '';
|
|
529
|
+
|
|
530
|
+
const rPrParts: string[] = [];
|
|
531
|
+
if (format.bold) rPrParts.push('<w:b/>');
|
|
532
|
+
if (format.italic) rPrParts.push('<w:i/>');
|
|
533
|
+
if (format.strike) rPrParts.push('<w:strike/>');
|
|
534
|
+
if (format.code) {
|
|
535
|
+
rPrParts.push(
|
|
536
|
+
`<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>`,
|
|
537
|
+
`<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const rPr = rPrParts.length > 0 ? `<w:rPr>${rPrParts.join('')}</w:rPr>` : '';
|
|
542
|
+
|
|
543
|
+
// Use xml:space="preserve" to keep leading/trailing whitespace
|
|
544
|
+
return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function convertLink(node: MarkdownLink, ctx: ExportContext, format: InlineFormat): string {
|
|
548
|
+
const rId = ctx.addHyperlink(node.url);
|
|
549
|
+
const _runs = convertInlines(node.children, ctx, { ...format });
|
|
550
|
+
|
|
551
|
+
// Wrap each run's rPr with hyperlink styling
|
|
552
|
+
// For simplicity, emit inline runs with hyperlink color + underline
|
|
553
|
+
const styledRuns = convertInlinesWithHyperlinkStyle(node.children, ctx, format);
|
|
554
|
+
|
|
555
|
+
return `<w:hyperlink r:id="${rId}">${styledRuns}</w:hyperlink>`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function convertInlinesWithHyperlinkStyle(
|
|
559
|
+
nodes: MarkdownInlineNode[],
|
|
560
|
+
ctx: ExportContext,
|
|
561
|
+
format: InlineFormat,
|
|
562
|
+
): string {
|
|
563
|
+
const parts: string[] = [];
|
|
564
|
+
for (const node of nodes) {
|
|
565
|
+
if (node.type === 'text') {
|
|
566
|
+
parts.push(makeHyperlinkRun(node.value, format));
|
|
567
|
+
} else {
|
|
568
|
+
// For nested formatting inside links, add hyperlink style
|
|
569
|
+
parts.push(convertInline(node, ctx, format));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return parts.join('');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function makeHyperlinkRun(text: string, format: InlineFormat): string {
|
|
576
|
+
if (!text) return '';
|
|
577
|
+
|
|
578
|
+
const rPrParts: string[] = [
|
|
579
|
+
'<w:rStyle w:val="Hyperlink"/>',
|
|
580
|
+
`<w:color w:val="${HYPERLINK_COLOR}"/>`,
|
|
581
|
+
'<w:u w:val="single"/>',
|
|
582
|
+
];
|
|
583
|
+
if (format.bold) rPrParts.push('<w:b/>');
|
|
584
|
+
if (format.italic) rPrParts.push('<w:i/>');
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
`<w:r><w:rPr>${rPrParts.join('')}</w:rPr>` +
|
|
588
|
+
`<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function convertImage(_node: MarkdownImage): string {
|
|
593
|
+
// Image embedding requires fetching the image data and packing it as
|
|
594
|
+
// a binary part. For now, emit a placeholder text run.
|
|
595
|
+
// Full image support can be added by fetching the URL and creating
|
|
596
|
+
// a DrawingML inline + binary media part.
|
|
597
|
+
const alt = _node.alt || _node.url;
|
|
598
|
+
return makeRun(`[Image: ${alt}]`, { italic: true });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function convertFootnoteRef(node: MarkdownFootnoteReference, ctx: ExportContext): string {
|
|
602
|
+
const fnId = ctx.getFootnoteId(node.identifier);
|
|
603
|
+
return (
|
|
604
|
+
`<w:r>` +
|
|
605
|
+
`<w:rPr><w:rStyle w:val="FootnoteReference"/><w:vertAlign w:val="superscript"/></w:rPr>` +
|
|
606
|
+
`<w:footnoteReference w:id="${fnId}"/>` +
|
|
607
|
+
`</w:r>`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ============================================
|
|
612
|
+
// Package Assembly
|
|
613
|
+
// ============================================
|
|
614
|
+
|
|
615
|
+
async function buildDocxPackage(
|
|
616
|
+
bodyXml: string,
|
|
617
|
+
ctx: ExportContext,
|
|
618
|
+
options: DocxExportOptions,
|
|
619
|
+
): Promise<ArrayBuffer> {
|
|
620
|
+
const pkg = createPackage();
|
|
621
|
+
|
|
622
|
+
// --- Register fixed relationships ---
|
|
623
|
+
let relCounter = 100; // Start high to avoid collisions with dynamic rels
|
|
624
|
+
const stylesRelId = `rId${relCounter++}`;
|
|
625
|
+
const numberingRelId = `rId${relCounter++}`;
|
|
626
|
+
const settingsRelId = `rId${relCounter++}`;
|
|
627
|
+
const fontTableRelId = `rId${relCounter++}`;
|
|
628
|
+
const footnotesRelId = `rId${relCounter++}`;
|
|
629
|
+
|
|
630
|
+
// --- word/document.xml ---
|
|
631
|
+
const documentXml = buildDocumentXml(bodyXml);
|
|
632
|
+
pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
|
|
633
|
+
|
|
634
|
+
// --- word/styles.xml ---
|
|
635
|
+
const stylesXml = buildStylesXml(options);
|
|
636
|
+
pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
|
|
637
|
+
|
|
638
|
+
// --- word/settings.xml ---
|
|
639
|
+
const settingsXml = buildSettingsXml();
|
|
640
|
+
pkg.addPart('word/settings.xml', settingsXml, CONTENT_TYPE_DOCX_SETTINGS);
|
|
641
|
+
|
|
642
|
+
// --- word/fontTable.xml ---
|
|
643
|
+
const fontTableXml = buildFontTableXml(options);
|
|
644
|
+
pkg.addPart('word/fontTable.xml', fontTableXml, CONTENT_TYPE_DOCX_FONT_TABLE);
|
|
645
|
+
|
|
646
|
+
// --- word/numbering.xml (only if lists present) ---
|
|
647
|
+
if (ctx.hasLists) {
|
|
648
|
+
const numberingXml = buildNumberingXml(ctx);
|
|
649
|
+
pkg.addPart('word/numbering.xml', numberingXml, CONTENT_TYPE_DOCX_NUMBERING);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// --- word/footnotes.xml (only if footnotes present) ---
|
|
653
|
+
if (ctx.hasFootnotes) {
|
|
654
|
+
const footnotesXml = buildFootnotesXml(ctx);
|
|
655
|
+
pkg.addPart('word/footnotes.xml', footnotesXml, CONTENT_TYPE_DOCX_FOOTNOTES);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// --- Root relationship: this package contains a word document ---
|
|
659
|
+
pkg.addRelationship('', {
|
|
660
|
+
id: 'rId1',
|
|
661
|
+
type: REL_OFFICE_DOCUMENT,
|
|
662
|
+
target: 'word/document.xml',
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// --- Document relationships ---
|
|
666
|
+
pkg.addRelationship('word/document.xml', {
|
|
667
|
+
id: stylesRelId,
|
|
668
|
+
type: REL_STYLES,
|
|
669
|
+
target: 'styles.xml',
|
|
670
|
+
});
|
|
671
|
+
pkg.addRelationship('word/document.xml', {
|
|
672
|
+
id: settingsRelId,
|
|
673
|
+
type: REL_SETTINGS,
|
|
674
|
+
target: 'settings.xml',
|
|
675
|
+
});
|
|
676
|
+
pkg.addRelationship('word/document.xml', {
|
|
677
|
+
id: fontTableRelId,
|
|
678
|
+
type: REL_FONT_TABLE,
|
|
679
|
+
target: 'fontTable.xml',
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
if (ctx.hasLists) {
|
|
683
|
+
pkg.addRelationship('word/document.xml', {
|
|
684
|
+
id: numberingRelId,
|
|
685
|
+
type: REL_NUMBERING,
|
|
686
|
+
target: 'numbering.xml',
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (ctx.hasFootnotes) {
|
|
691
|
+
pkg.addRelationship('word/document.xml', {
|
|
692
|
+
id: footnotesRelId,
|
|
693
|
+
type: REL_FOOTNOTES,
|
|
694
|
+
target: 'footnotes.xml',
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// --- Dynamic relationships (hyperlinks, images) ---
|
|
699
|
+
for (const rel of ctx.relationships) {
|
|
700
|
+
pkg.addRelationship('word/document.xml', {
|
|
701
|
+
id: rel.id,
|
|
702
|
+
type: rel.type,
|
|
703
|
+
target: rel.target,
|
|
704
|
+
...(rel.targetMode ? { targetMode: rel.targetMode } : {}),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- Embedded images ---
|
|
709
|
+
for (const img of ctx.images) {
|
|
710
|
+
pkg.addBinaryPart(img.path, img.data, img.contentType);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// --- Core properties ---
|
|
714
|
+
if (options.title || options.author || options.description) {
|
|
715
|
+
pkg.setCoreProperties({
|
|
716
|
+
title: options.title,
|
|
717
|
+
creator: options.author,
|
|
718
|
+
description: options.description,
|
|
719
|
+
created: new Date().toISOString(),
|
|
720
|
+
modified: new Date().toISOString(),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return pkg.toArrayBuffer();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ============================================
|
|
728
|
+
// XML Part Generators
|
|
729
|
+
// ============================================
|
|
730
|
+
|
|
731
|
+
function buildDocumentXml(bodyXml: string): string {
|
|
732
|
+
return (
|
|
733
|
+
xmlDeclaration() +
|
|
734
|
+
`<w:document` +
|
|
735
|
+
` xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"` +
|
|
736
|
+
` xmlns:mc="${NS_MC}"` +
|
|
737
|
+
` xmlns:o="urn:schemas-microsoft-com:office:office"` +
|
|
738
|
+
` xmlns:r="${NS_R}"` +
|
|
739
|
+
` xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"` +
|
|
740
|
+
` xmlns:v="urn:schemas-microsoft-com:vml"` +
|
|
741
|
+
` xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"` +
|
|
742
|
+
` xmlns:w10="urn:schemas-microsoft-com:office:word"` +
|
|
743
|
+
` xmlns:w="${NS_WML}"` +
|
|
744
|
+
` xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">` +
|
|
745
|
+
`<w:body>` +
|
|
746
|
+
bodyXml +
|
|
747
|
+
`<w:sectPr>` +
|
|
748
|
+
`<w:pgSz w:w="12240" w:h="15840"/>` +
|
|
749
|
+
`<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>` +
|
|
750
|
+
`<w:cols w:space="720"/>` +
|
|
751
|
+
`</w:sectPr>` +
|
|
752
|
+
`</w:body>` +
|
|
753
|
+
`</w:document>`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function buildStylesXml(options: DocxExportOptions): string {
|
|
758
|
+
const font = options.defaultFont ?? DEFAULT_FONT;
|
|
759
|
+
const headingFont = DEFAULT_HEADING_FONT;
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
xmlDeclaration() +
|
|
763
|
+
`<w:styles xmlns:w="${NS_WML}">` +
|
|
764
|
+
// Default run properties
|
|
765
|
+
`<w:docDefaults>` +
|
|
766
|
+
`<w:rPrDefault><w:rPr>` +
|
|
767
|
+
`<w:rFonts w:ascii="${escapeXml(font)}" w:hAnsi="${escapeXml(font)}" w:eastAsia="${escapeXml(font)}" w:cs="${escapeXml(font)}"/>` +
|
|
768
|
+
`<w:sz w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
|
|
769
|
+
`<w:szCs w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
|
|
770
|
+
`</w:rPr></w:rPrDefault>` +
|
|
771
|
+
`<w:pPrDefault/>` +
|
|
772
|
+
`</w:docDefaults>` +
|
|
773
|
+
// Normal style
|
|
774
|
+
`<w:style w:type="paragraph" w:default="1" w:styleId="Normal">` +
|
|
775
|
+
`<w:name w:val="Normal"/>` +
|
|
776
|
+
`<w:qFormat/>` +
|
|
777
|
+
`</w:style>` +
|
|
778
|
+
// Heading styles
|
|
779
|
+
buildHeadingStyles(headingFont) +
|
|
780
|
+
// Quote style
|
|
781
|
+
`<w:style w:type="paragraph" w:styleId="Quote">` +
|
|
782
|
+
`<w:name w:val="Quote"/>` +
|
|
783
|
+
`<w:basedOn w:val="Normal"/>` +
|
|
784
|
+
`<w:pPr><w:ind w:left="720"/></w:pPr>` +
|
|
785
|
+
`<w:rPr><w:i/><w:color w:val="404040"/></w:rPr>` +
|
|
786
|
+
`</w:style>` +
|
|
787
|
+
// Code style
|
|
788
|
+
`<w:style w:type="paragraph" w:styleId="Code">` +
|
|
789
|
+
`<w:name w:val="Code"/>` +
|
|
790
|
+
`<w:basedOn w:val="Normal"/>` +
|
|
791
|
+
`<w:rPr>` +
|
|
792
|
+
`<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
|
|
793
|
+
`<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>` +
|
|
794
|
+
`</w:rPr>` +
|
|
795
|
+
`</w:style>` +
|
|
796
|
+
// ListParagraph style
|
|
797
|
+
`<w:style w:type="paragraph" w:styleId="ListParagraph">` +
|
|
798
|
+
`<w:name w:val="List Paragraph"/>` +
|
|
799
|
+
`<w:basedOn w:val="Normal"/>` +
|
|
800
|
+
`<w:pPr><w:ind w:left="720"/></w:pPr>` +
|
|
801
|
+
`</w:style>` +
|
|
802
|
+
// Hyperlink character style
|
|
803
|
+
`<w:style w:type="character" w:styleId="Hyperlink">` +
|
|
804
|
+
`<w:name w:val="Hyperlink"/>` +
|
|
805
|
+
`<w:rPr><w:color w:val="${HYPERLINK_COLOR}"/><w:u w:val="single"/></w:rPr>` +
|
|
806
|
+
`</w:style>` +
|
|
807
|
+
// FootnoteReference character style
|
|
808
|
+
`<w:style w:type="character" w:styleId="FootnoteReference">` +
|
|
809
|
+
`<w:name w:val="footnote reference"/>` +
|
|
810
|
+
`<w:rPr><w:vertAlign w:val="superscript"/></w:rPr>` +
|
|
811
|
+
`</w:style>` +
|
|
812
|
+
// Table Grid style
|
|
813
|
+
`<w:style w:type="table" w:styleId="TableGrid">` +
|
|
814
|
+
`<w:name w:val="Table Grid"/>` +
|
|
815
|
+
`<w:tblPr>` +
|
|
816
|
+
`<w:tblBorders>` +
|
|
817
|
+
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
818
|
+
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
819
|
+
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
820
|
+
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
821
|
+
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
822
|
+
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
823
|
+
`</w:tblBorders>` +
|
|
824
|
+
`</w:tblPr>` +
|
|
825
|
+
`</w:style>` +
|
|
826
|
+
`</w:styles>`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function buildHeadingStyles(headingFont: string): string {
|
|
831
|
+
let result = '';
|
|
832
|
+
for (let depth = 1; depth <= 6; depth++) {
|
|
833
|
+
const styleId = DEPTH_TO_STYLE_ID[depth];
|
|
834
|
+
const fontSize = HEADING_FONT_SIZES[depth] ?? 22;
|
|
835
|
+
result +=
|
|
836
|
+
`<w:style w:type="paragraph" w:styleId="${styleId}">` +
|
|
837
|
+
`<w:name w:val="heading ${depth}"/>` +
|
|
838
|
+
`<w:basedOn w:val="Normal"/>` +
|
|
839
|
+
`<w:next w:val="Normal"/>` +
|
|
840
|
+
`<w:qFormat/>` +
|
|
841
|
+
`<w:pPr><w:outlineLvl w:val="${depth - 1}"/><w:spacing w:before="240" w:after="60"/></w:pPr>` +
|
|
842
|
+
`<w:rPr>` +
|
|
843
|
+
`<w:rFonts w:ascii="${escapeXml(headingFont)}" w:hAnsi="${escapeXml(headingFont)}"/>` +
|
|
844
|
+
`<w:b/>` +
|
|
845
|
+
`<w:sz w:val="${fontSize}"/>` +
|
|
846
|
+
`<w:szCs w:val="${fontSize}"/>` +
|
|
847
|
+
`</w:rPr>` +
|
|
848
|
+
`</w:style>`;
|
|
849
|
+
}
|
|
850
|
+
return result;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function buildSettingsXml(): string {
|
|
854
|
+
return (
|
|
855
|
+
xmlDeclaration() +
|
|
856
|
+
`<w:settings xmlns:w="${NS_WML}">` +
|
|
857
|
+
`<w:defaultTabStop w:val="720"/>` +
|
|
858
|
+
`<w:characterSpacingControl w:val="doNotCompress"/>` +
|
|
859
|
+
`</w:settings>`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function buildFontTableXml(options: DocxExportOptions): string {
|
|
864
|
+
const font = options.defaultFont ?? DEFAULT_FONT;
|
|
865
|
+
return (
|
|
866
|
+
xmlDeclaration() +
|
|
867
|
+
`<w:fonts xmlns:w="${NS_WML}">` +
|
|
868
|
+
`<w:font w:name="${escapeXml(font)}">` +
|
|
869
|
+
`<w:panose1 w:val="020F0502020204030204"/>` +
|
|
870
|
+
`<w:charset w:val="00"/>` +
|
|
871
|
+
`<w:family w:val="swiss"/>` +
|
|
872
|
+
`<w:pitch w:val="variable"/>` +
|
|
873
|
+
`</w:font>` +
|
|
874
|
+
`<w:font w:name="${DEFAULT_HEADING_FONT}">` +
|
|
875
|
+
`<w:panose1 w:val="020F0302020204030204"/>` +
|
|
876
|
+
`<w:charset w:val="00"/>` +
|
|
877
|
+
`<w:family w:val="swiss"/>` +
|
|
878
|
+
`<w:pitch w:val="variable"/>` +
|
|
879
|
+
`</w:font>` +
|
|
880
|
+
`<w:font w:name="${DEFAULT_CODE_FONT}">` +
|
|
881
|
+
`<w:charset w:val="00"/>` +
|
|
882
|
+
`<w:family w:val="modern"/>` +
|
|
883
|
+
`<w:pitch w:val="fixed"/>` +
|
|
884
|
+
`</w:font>` +
|
|
885
|
+
`</w:fonts>`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function buildNumberingXml(ctx: ExportContext): string {
|
|
890
|
+
const abstract: string[] = [];
|
|
891
|
+
const concrete: string[] = [];
|
|
892
|
+
|
|
893
|
+
for (const def of ctx.numberingDefs) {
|
|
894
|
+
const absId = def.numId;
|
|
895
|
+
const levels: string[] = [];
|
|
896
|
+
|
|
897
|
+
for (let lvl = 0; lvl < 9; lvl++) {
|
|
898
|
+
if (def.ordered) {
|
|
899
|
+
levels.push(
|
|
900
|
+
`<w:lvl w:ilvl="${lvl}">` +
|
|
901
|
+
`<w:start w:val="1"/>` +
|
|
902
|
+
`<w:numFmt w:val="decimal"/>` +
|
|
903
|
+
`<w:lvlText w:val="%${lvl + 1}."/>` +
|
|
904
|
+
`<w:lvlJc w:val="left"/>` +
|
|
905
|
+
`<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
|
|
906
|
+
`</w:lvl>`,
|
|
907
|
+
);
|
|
908
|
+
} else {
|
|
909
|
+
const bullets = ['\u2022', '\u25E6', '\u25AA']; // •, ◦, ▪
|
|
910
|
+
const bullet = bullets[lvl % bullets.length];
|
|
911
|
+
levels.push(
|
|
912
|
+
`<w:lvl w:ilvl="${lvl}">` +
|
|
913
|
+
`<w:start w:val="1"/>` +
|
|
914
|
+
`<w:numFmt w:val="bullet"/>` +
|
|
915
|
+
`<w:lvlText w:val="${bullet}"/>` +
|
|
916
|
+
`<w:lvlJc w:val="left"/>` +
|
|
917
|
+
`<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
|
|
918
|
+
`<w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr>` +
|
|
919
|
+
`</w:lvl>`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
abstract.push(
|
|
925
|
+
`<w:abstractNum w:abstractNumId="${absId}">` + levels.join('') + `</w:abstractNum>`,
|
|
926
|
+
);
|
|
927
|
+
concrete.push(
|
|
928
|
+
`<w:num w:numId="${def.numId}">` + `<w:abstractNumId w:val="${absId}"/>` + `</w:num>`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return (
|
|
933
|
+
xmlDeclaration() +
|
|
934
|
+
`<w:numbering xmlns:w="${NS_WML}">` +
|
|
935
|
+
abstract.join('') +
|
|
936
|
+
concrete.join('') +
|
|
937
|
+
`</w:numbering>`
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function buildFootnotesXml(ctx: ExportContext): string {
|
|
942
|
+
const footnotes: string[] = [];
|
|
943
|
+
|
|
944
|
+
// Separator and continuation separator (required by Word)
|
|
945
|
+
footnotes.push(
|
|
946
|
+
`<w:footnote w:type="separator" w:id="-1">` +
|
|
947
|
+
`<w:p><w:r><w:separator/></w:r></w:p>` +
|
|
948
|
+
`</w:footnote>`,
|
|
949
|
+
);
|
|
950
|
+
footnotes.push(
|
|
951
|
+
`<w:footnote w:type="continuationSeparator" w:id="0">` +
|
|
952
|
+
`<w:p><w:r><w:continuationSeparator/></w:r></w:p>` +
|
|
953
|
+
`</w:footnote>`,
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// User footnotes
|
|
957
|
+
for (const [_id, xml] of ctx.footnotes) {
|
|
958
|
+
footnotes.push(xml);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return (
|
|
962
|
+
xmlDeclaration() +
|
|
963
|
+
`<w:footnotes xmlns:w="${NS_WML}" xmlns:r="${NS_R}">` +
|
|
964
|
+
footnotes.join('') +
|
|
965
|
+
`</w:footnotes>`
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ============================================
|
|
970
|
+
// Helpers
|
|
971
|
+
// ============================================
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Strip HTML tags from a string, keeping only text content.
|
|
975
|
+
*/
|
|
976
|
+
function stripHtmlTags(html: string): string {
|
|
977
|
+
return html.replace(/<[^>]*>/g, '');
|
|
978
|
+
}
|