@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.
Files changed (61) hide show
  1. package/dist/chunk-532L4D5D.js +21 -0
  2. package/dist/chunk-532L4D5D.js.map +1 -0
  3. package/dist/chunk-67KIJHV2.js +21 -0
  4. package/dist/chunk-67KIJHV2.js.map +1 -0
  5. package/dist/chunk-KAK4V57E.js +387 -0
  6. package/dist/chunk-KAK4V57E.js.map +1 -0
  7. package/dist/chunk-MQHCXI56.js +1035 -0
  8. package/dist/chunk-MQHCXI56.js.map +1 -0
  9. package/dist/chunk-TBPD5PCU.js +1136 -0
  10. package/dist/chunk-TBPD5PCU.js.map +1 -0
  11. package/dist/chunk-ULLIPBEJ.js +271 -0
  12. package/dist/chunk-ULLIPBEJ.js.map +1 -0
  13. package/dist/docx/index.d.ts +108 -0
  14. package/dist/docx/index.js +14 -0
  15. package/dist/docx/index.js.map +1 -0
  16. package/dist/html/index.d.ts +166 -0
  17. package/dist/html/index.js +17 -0
  18. package/dist/html/index.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +54 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/ooxml/index.d.ts +351 -0
  23. package/dist/ooxml/index.js +99 -0
  24. package/dist/ooxml/index.js.map +1 -0
  25. package/dist/pdf/index.d.ts +130 -0
  26. package/dist/pdf/index.js +15 -0
  27. package/dist/pdf/index.js.map +1 -0
  28. package/dist/pptx/index.d.ts +58 -0
  29. package/dist/pptx/index.js +13 -0
  30. package/dist/pptx/index.js.map +1 -0
  31. package/dist/xlsx/index.d.ts +58 -0
  32. package/dist/xlsx/index.js +13 -0
  33. package/dist/xlsx/index.js.map +1 -0
  34. package/package.json +85 -0
  35. package/src/__tests__/docxExport.test.ts +457 -0
  36. package/src/__tests__/docxImport.test.ts +410 -0
  37. package/src/__tests__/html.test.ts +295 -0
  38. package/src/__tests__/ooxml.test.ts +197 -0
  39. package/src/__tests__/pdfExport.test.ts +322 -0
  40. package/src/__tests__/pdfImport.test.ts +290 -0
  41. package/src/__tests__/roundTrip.test.ts +201 -0
  42. package/src/docx/export.ts +978 -0
  43. package/src/docx/import.ts +909 -0
  44. package/src/docx/index.ts +26 -0
  45. package/src/docx/styles.ts +145 -0
  46. package/src/html/htmlTemplate.ts +307 -0
  47. package/src/html/imageUtils.ts +66 -0
  48. package/src/html/index.ts +168 -0
  49. package/src/index.ts +51 -0
  50. package/src/ooxml/index.ts +87 -0
  51. package/src/ooxml/namespaces.ts +160 -0
  52. package/src/ooxml/reader.ts +218 -0
  53. package/src/ooxml/types.ts +104 -0
  54. package/src/ooxml/writer.ts +321 -0
  55. package/src/ooxml/xmlUtils.ts +123 -0
  56. package/src/pdf/export.ts +1029 -0
  57. package/src/pdf/import.ts +835 -0
  58. package/src/pdf/index.ts +29 -0
  59. package/src/pdf/styles.ts +180 -0
  60. package/src/pptx/index.ts +78 -0
  61. 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
+ }