@bendyline/squisq-formats 2.0.1 → 2.2.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-CRAVSMPZ.js → chunk-26ISNJ7Y.js} +356 -114
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-HTW2M27H.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-U32AG3G3.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-QRVN6A6E.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-XKUMNGBW.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-ABVI556T.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-LXYLOOST.js → chunk-JE6LSIHE.js} +83 -22
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-2KPARF2P.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-VSYHZECT.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-WC7WULGV.js → chunk-X2DEAXNK.js} +62 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -10
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +6 -3
  61. package/dist/chunk-2KPARF2P.js.map +0 -1
  62. package/dist/chunk-4VUWTSGM.js.map +0 -1
  63. package/dist/chunk-6M7Z25LA.js +0 -46
  64. package/dist/chunk-6M7Z25LA.js.map +0 -1
  65. package/dist/chunk-ABVI556T.js.map +0 -1
  66. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  67. package/dist/chunk-FE6OJV6O.js.map +0 -1
  68. package/dist/chunk-GVS2XXV6.js.map +0 -1
  69. package/dist/chunk-HTW2M27H.js.map +0 -1
  70. package/dist/chunk-LXYLOOST.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-O3GVVND4.js.map +0 -1
  74. package/dist/chunk-ODL3SSPT.js.map +0 -1
  75. package/dist/chunk-PN52A5AA.js.map +0 -1
  76. package/dist/chunk-QRVN6A6E.js.map +0 -1
  77. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  78. package/dist/chunk-SSUPBUF5.js.map +0 -1
  79. package/dist/chunk-U32AG3G3.js.map +0 -1
  80. package/dist/chunk-U4MRIFKL.js.map +0 -1
  81. package/dist/chunk-VJJM2SSH.js +0 -275
  82. package/dist/chunk-VJJM2SSH.js.map +0 -1
  83. package/dist/chunk-VSYHZECT.js.map +0 -1
  84. package/dist/chunk-WC7WULGV.js.map +0 -1
  85. package/dist/chunk-XKUMNGBW.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-H6RXJMHP.js +0 -15
  93. package/dist/extract-H6RXJMHP.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-QVPK3ZCU.js +0 -12
  100. package/dist/layouts-QVPK3ZCU.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -457
  111. package/src/__tests__/docxImport.test.ts +0 -410
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -435
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1267
  143. package/src/docx/import.ts +0 -995
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -196
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,1267 +0,0 @@
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, Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
21
- import { resolveFontFamily } from '@bendyline/squisq/schemas';
22
- import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
23
- import type {
24
- MarkdownDocument,
25
- MarkdownBlockNode,
26
- MarkdownInlineNode,
27
- MarkdownHeading,
28
- MarkdownParagraph,
29
- MarkdownBlockquote,
30
- MarkdownList,
31
- MarkdownListItem,
32
- MarkdownCodeBlock,
33
- MarkdownTable,
34
- MarkdownTableRow,
35
- MarkdownTableCell,
36
- MarkdownHtmlBlock,
37
- MarkdownMathBlock,
38
- MarkdownFootnoteDefinition,
39
- MarkdownLink,
40
- MarkdownImage,
41
- MarkdownFootnoteReference,
42
- } from '@bendyline/squisq/markdown';
43
- import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
44
-
45
- import { createPackage } from '../ooxml/writer.js';
46
- import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
47
- import { stripHtmlTags } from '../shared/text.js';
48
- import {
49
- inlineNodesToRuns,
50
- inlineNodeToRuns,
51
- type InlineRunHandlers,
52
- } from '../shared/inlineRuns.js';
53
- import {
54
- NS_WML,
55
- NS_R,
56
- NS_MC,
57
- REL_OFFICE_DOCUMENT,
58
- REL_STYLES,
59
- REL_NUMBERING,
60
- REL_SETTINGS,
61
- REL_FONT_TABLE,
62
- REL_HYPERLINK,
63
- REL_IMAGE,
64
- REL_FOOTNOTES,
65
- CONTENT_TYPE_DOCX_DOCUMENT,
66
- CONTENT_TYPE_DOCX_STYLES,
67
- CONTENT_TYPE_DOCX_NUMBERING,
68
- CONTENT_TYPE_DOCX_SETTINGS,
69
- CONTENT_TYPE_DOCX_FONT_TABLE,
70
- CONTENT_TYPE_DOCX_FOOTNOTES,
71
- } from '../ooxml/namespaces.js';
72
- import {
73
- DEPTH_TO_STYLE_ID,
74
- HEADING_FONT_SIZES,
75
- DEFAULT_FONT,
76
- DEFAULT_HEADING_FONT,
77
- DEFAULT_FONT_SIZE_HALF_POINTS,
78
- DEFAULT_CODE_FONT,
79
- DEFAULT_CODE_FONT_SIZE,
80
- HYPERLINK_COLOR,
81
- pointsToTwips,
82
- } from './styles.js';
83
-
84
- // ============================================
85
- // Public API
86
- // ============================================
87
-
88
- /**
89
- * Options for DOCX export.
90
- */
91
- export interface DocxExportOptions {
92
- /** Document title (appears in core properties) */
93
- title?: string;
94
- /** Document author */
95
- author?: string;
96
- /** Document description */
97
- description?: string;
98
- /** Default body font family. Default: "Calibri" */
99
- defaultFont?: string;
100
- /** Default body font size in points. Default: 11 */
101
- defaultFontSize?: number;
102
- /**
103
- * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
104
- * When set, overrides fonts with the theme's typography and applies
105
- * the theme's primary color to headings.
106
- */
107
- themeId?: string;
108
- /** Explicit caller-owned registry for non-document custom themes. */
109
- themeRegistry?: ThemeRegistry;
110
- /**
111
- * Pre-resolved image data keyed by image URL/path as it appears in the
112
- * markdown source. When provided, images are embedded in the .docx file
113
- * as binary parts instead of emitting placeholder text.
114
- */
115
- images?: Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>;
116
- }
117
-
118
- /**
119
- * Convert a MarkdownDocument to a .docx Blob.
120
- *
121
- * @param doc - The parsed markdown document
122
- * @param options - Export options
123
- * @returns An ArrayBuffer containing the .docx file
124
- */
125
- export async function markdownDocToDocx(
126
- doc: MarkdownDocument,
127
- options: DocxExportOptions = {},
128
- ): Promise<ArrayBuffer> {
129
- // Mirror the PPTX export: fall back to the doc's frontmatter themeId
130
- // when the caller didn't pass one explicitly. Lets the editor's
131
- // `squisq-theme: …` frontmatter flow straight through without each
132
- // host wiring its own resolution.
133
- const resolvedOptions: DocxExportOptions =
134
- options.themeId !== undefined
135
- ? options
136
- : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
137
- const ctx = new ExportContext(resolvedOptions, doc);
138
- const bodyXml = convertBlocks(doc.children, ctx);
139
- return buildDocxPackage(bodyXml, ctx, resolvedOptions);
140
- }
141
-
142
- /**
143
- * Convert a squisq Doc to a .docx Blob.
144
- *
145
- * Convenience wrapper that converts Doc → MarkdownDocument → DOCX.
146
- *
147
- * @param doc - The squisq Doc
148
- * @param options - Export options
149
- * @returns An ArrayBuffer containing the .docx file
150
- */
151
- export async function docToDocx(doc: Doc, options: DocxExportOptions = {}): Promise<ArrayBuffer> {
152
- const markdownDoc = docToMarkdown(doc);
153
- return markdownDocToDocx(markdownDoc, options);
154
- }
155
-
156
- // ============================================
157
- // Export Context
158
- // ============================================
159
-
160
- /**
161
- * Tracks state during export: relationship IDs, numbering definitions,
162
- * footnote bodies, and embedded images.
163
- */
164
- class ExportContext {
165
- private nextRelId = 1;
166
- private nextNumId = 1;
167
- private nextFootnoteId = 1; // 0 is separator, start user footnotes at 1
168
-
169
- /** Relationships for word/_rels/document.xml.rels */
170
- readonly relationships: Array<{
171
- id: string;
172
- type: string;
173
- target: string;
174
- targetMode?: 'External';
175
- }> = [];
176
-
177
- /** Numbering definitions (abstract + num) */
178
- readonly numberingDefs: NumberingDef[] = [];
179
-
180
- /** Footnote XML bodies (keyed by footnote id) */
181
- readonly footnotes = new Map<number, string>();
182
-
183
- /** Footnote identifier → numeric id mapping */
184
- readonly footnoteIdMap = new Map<string, number>();
185
-
186
- /** Embedded images: rId → { path, data, contentType } */
187
- readonly images: Array<{
188
- relId: string;
189
- path: string;
190
- data: ArrayBuffer | Uint8Array;
191
- contentType: string;
192
- }> = [];
193
-
194
- /** Whether we have any lists (determines if numbering.xml is needed) */
195
- hasLists = false;
196
-
197
- /** Whether we have any footnotes */
198
- hasFootnotes = false;
199
-
200
- readonly font: string;
201
- readonly headingFont: string;
202
- readonly fontSize: number;
203
- /** Heading text color (hex without #), or undefined for default */
204
- readonly headingColor: string | undefined;
205
- /** Body text color (hex without #), or undefined for default */
206
- readonly bodyColor: string | undefined;
207
- /** Muted text color (hex without #), used by Quote style. Undefined for default. */
208
- readonly mutedColor: string | undefined;
209
- /** Page background color (hex without #), or undefined for default white. */
210
- readonly backgroundColor: string | undefined;
211
-
212
- /** Pre-resolved image data keyed by markdown image URL */
213
- readonly resolvedImages: Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>;
214
-
215
- private nextDocPrId = 1;
216
-
217
- constructor(options: DocxExportOptions, doc?: MarkdownDocument) {
218
- let themeFont: string | undefined;
219
- let themeTitleFont: string | undefined;
220
- let themeHeadingColor: string | undefined;
221
- let themeBodyColor: string | undefined;
222
- let themeMutedColor: string | undefined;
223
- let themeBackgroundColor: string | undefined;
224
-
225
- if (options.themeId) {
226
- const theme: Theme = resolveThemeForDoc(doc, options.themeId, options.themeRegistry);
227
- // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
228
- // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
229
- // single font name — passing the whole stack is treated as a
230
- // bogus literal name and Word falls through to Calibri. Take the
231
- // first concrete name from the stack so Word can resolve it (or
232
- // gracefully fall back to its own default if the font isn't
233
- // installed on the reader's machine).
234
- themeFont = firstFontFromStack(resolveFontFamily(theme.typography?.bodyFont, ''));
235
- themeTitleFont = firstFontFromStack(resolveFontFamily(theme.typography?.titleFont, ''));
236
- const stripHash = (c: string) => (c.startsWith('#') ? c.slice(1) : c);
237
- if (theme.colors?.primary) themeHeadingColor = stripHash(theme.colors.primary);
238
- if (theme.colors?.text) themeBodyColor = stripHash(theme.colors.text);
239
- if (theme.colors?.textMuted) themeMutedColor = stripHash(theme.colors.textMuted);
240
- if (theme.colors?.background) themeBackgroundColor = stripHash(theme.colors.background);
241
- }
242
-
243
- this.font = options.defaultFont ?? themeFont ?? DEFAULT_FONT;
244
- this.headingFont = themeTitleFont ?? this.font;
245
- this.fontSize = options.defaultFontSize
246
- ? options.defaultFontSize * 2
247
- : DEFAULT_FONT_SIZE_HALF_POINTS;
248
- this.headingColor = themeHeadingColor;
249
- this.bodyColor = themeBodyColor;
250
- this.mutedColor = themeMutedColor;
251
- this.backgroundColor = themeBackgroundColor;
252
- this.resolvedImages = options.images ?? new Map();
253
- }
254
-
255
- /** Allocate a new relationship ID */
256
- allocRelId(): string {
257
- return `rId${this.nextRelId++}`;
258
- }
259
-
260
- /** Add a hyperlink relationship and return the rId */
261
- addHyperlink(url: string): string {
262
- const id = this.allocRelId();
263
- this.relationships.push({
264
- id,
265
- type: REL_HYPERLINK,
266
- target: url,
267
- targetMode: 'External',
268
- });
269
- return id;
270
- }
271
-
272
- /** Add an embedded image and return the rId and docPrId */
273
- addImage(
274
- data: ArrayBuffer | Uint8Array,
275
- contentType: string,
276
- filename: string,
277
- ): { relId: string; docPrId: number } {
278
- const relId = this.allocRelId();
279
- const docPrId = this.nextDocPrId++;
280
- const path = `word/media/${filename}`;
281
-
282
- this.images.push({ relId, path, data, contentType });
283
- this.relationships.push({
284
- id: relId,
285
- type: REL_IMAGE,
286
- target: `media/${filename}`,
287
- });
288
-
289
- return { relId, docPrId };
290
- }
291
-
292
- /** Allocate a numbering definition for a list */
293
- allocNumbering(ordered: boolean): number {
294
- const numId = this.nextNumId++;
295
- this.numberingDefs.push({ numId, ordered });
296
- this.hasLists = true;
297
- return numId;
298
- }
299
-
300
- /** Register or look up a footnote by its string identifier */
301
- getFootnoteId(identifier: string): number {
302
- let id = this.footnoteIdMap.get(identifier);
303
- if (id === undefined) {
304
- id = this.nextFootnoteId++;
305
- this.footnoteIdMap.set(identifier, id);
306
- this.hasFootnotes = true;
307
- }
308
- return id;
309
- }
310
- }
311
-
312
- interface NumberingDef {
313
- numId: number;
314
- ordered: boolean;
315
- }
316
-
317
- /**
318
- * Pull the first concrete font name out of a CSS-style stack.
319
- *
320
- * `resolveFontFamily` returns CSS values like `"Oswald", Impact,
321
- * "Arial Black", sans-serif`. Word's `w:ascii` / `w:hAnsi` attributes
322
- * expect a single font name — feeding the whole stack causes Word to
323
- * fall back to its default font instead of any of the named faces.
324
- * We pick the first non-generic name (skipping `sans-serif`, `serif`,
325
- * `monospace`, etc.) and strip the surrounding quotes that CSS lets
326
- * you wrap multi-word family names in.
327
- */
328
- function firstFontFromStack(stack: string): string {
329
- if (!stack) return '';
330
- const generics = new Set([
331
- 'sans-serif',
332
- 'serif',
333
- 'monospace',
334
- 'system-ui',
335
- 'cursive',
336
- 'fantasy',
337
- 'ui-serif',
338
- 'ui-sans-serif',
339
- 'ui-monospace',
340
- 'ui-rounded',
341
- 'inherit',
342
- 'initial',
343
- ]);
344
- for (const raw of stack.split(',')) {
345
- const name = raw.trim().replace(/^["']|["']$/g, '');
346
- if (!name) continue;
347
- if (generics.has(name.toLowerCase())) continue;
348
- return name;
349
- }
350
- return '';
351
- }
352
-
353
- // ============================================
354
- // Block Conversion
355
- // ============================================
356
-
357
- function convertBlocks(nodes: MarkdownBlockNode[], ctx: ExportContext): string {
358
- const parts: string[] = [];
359
- for (const node of nodes) {
360
- parts.push(convertBlock(node, ctx, 0));
361
- }
362
- return parts.join('');
363
- }
364
-
365
- function convertBlock(node: MarkdownBlockNode, ctx: ExportContext, listDepth: number): string {
366
- switch (node.type) {
367
- case 'heading':
368
- return convertHeading(node, ctx);
369
- case 'paragraph':
370
- return convertParagraph(node, ctx);
371
- case 'blockquote':
372
- return convertBlockquote(node, ctx);
373
- case 'list':
374
- return convertList(node, ctx, listDepth);
375
- case 'code':
376
- return convertCodeBlock(node);
377
- case 'table':
378
- return convertTable(node, ctx);
379
- case 'thematicBreak':
380
- return convertThematicBreak();
381
- case 'htmlBlock':
382
- return convertHtmlBlock(node);
383
- case 'math':
384
- return convertMathBlock(node);
385
- case 'footnoteDefinition':
386
- return convertFootnoteDefinition(node, ctx);
387
- default:
388
- // Definition lists, directives, link definitions — skip or emit as plain text
389
- return '';
390
- }
391
- }
392
-
393
- function convertHeading(node: MarkdownHeading, ctx: ExportContext): string {
394
- const styleId = DEPTH_TO_STYLE_ID[node.depth] ?? 'Heading1';
395
- const runs = convertInlines(node.children, ctx);
396
- return `<w:p>` + `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>` + runs + `</w:p>`;
397
- }
398
-
399
- function convertParagraph(node: MarkdownParagraph, ctx: ExportContext): string {
400
- // Force body color on every run rather than relying on `Normal` /
401
- // `rPrDefault` style inheritance — see InlineFormat.color comment.
402
- // Headings render via `convertHeading` and skip this path so the
403
- // heading style's own color wins.
404
- const runs = convertInlines(node.children, ctx, bodyFormat(ctx));
405
- return `<w:p>${runs}</w:p>`;
406
- }
407
-
408
- /** Default inline format for body-text contexts (paragraphs, list items,
409
- * blockquote bodies, table cells). Carries the theme body color so runs
410
- * render in the right color regardless of style-resolution quirks. */
411
- function bodyFormat(ctx: ExportContext): InlineFormat {
412
- return ctx.bodyColor ? { color: ctx.bodyColor } : {};
413
- }
414
-
415
- function convertBlockquote(node: MarkdownBlockquote, ctx: ExportContext): string {
416
- // Render each child block as a paragraph with Quote style
417
- const parts: string[] = [];
418
- for (const child of node.children) {
419
- if (child.type === 'paragraph') {
420
- // Use the theme's muted color when set (Quote style already
421
- // declares it but explicit run-level color survives Word's
422
- // style overrides — see InlineFormat.color comment).
423
- const quoteFormat: InlineFormat = ctx.mutedColor
424
- ? { color: ctx.mutedColor }
425
- : bodyFormat(ctx);
426
- const runs = convertInlines(child.children, ctx, quoteFormat);
427
- parts.push(
428
- `<w:p>` +
429
- `<w:pPr><w:pStyle w:val="Quote"/>` +
430
- `<w:ind w:left="${pointsToTwips(36)}"/>` +
431
- `<w:pBdr><w:left w:val="single" w:sz="12" w:space="4" w:color="CCCCCC"/></w:pBdr>` +
432
- `</w:pPr>` +
433
- runs +
434
- `</w:p>`,
435
- );
436
- } else {
437
- // Nested non-paragraph (e.g., nested blockquote, list) — recurse
438
- parts.push(convertBlock(child, ctx, 0));
439
- }
440
- }
441
- return parts.join('');
442
- }
443
-
444
- function convertList(node: MarkdownList, ctx: ExportContext, depth: number): string {
445
- const numId = ctx.allocNumbering(node.ordered ?? false);
446
- const parts: string[] = [];
447
- for (const item of node.children) {
448
- parts.push(convertListItem(item, ctx, numId, depth));
449
- }
450
- return parts.join('');
451
- }
452
-
453
- function convertListItem(
454
- item: MarkdownListItem,
455
- ctx: ExportContext,
456
- numId: number,
457
- depth: number,
458
- ): string {
459
- const parts: string[] = [];
460
- for (const child of item.children) {
461
- if (child.type === 'paragraph') {
462
- const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
463
- parts.push(
464
- `<w:p>` +
465
- `<w:pPr>` +
466
- `<w:pStyle w:val="ListParagraph"/>` +
467
- `<w:numPr><w:ilvl w:val="${depth}"/><w:numId w:val="${numId}"/></w:numPr>` +
468
- `</w:pPr>` +
469
- runs +
470
- `</w:p>`,
471
- );
472
- } else if (child.type === 'list') {
473
- // Nested list — increase depth
474
- parts.push(convertList(child, ctx, depth + 1));
475
- } else {
476
- parts.push(convertBlock(child, ctx, depth));
477
- }
478
- }
479
- return parts.join('');
480
- }
481
-
482
- function convertCodeBlock(node: MarkdownCodeBlock): string {
483
- // Emit each line as a separate paragraph with code styling
484
- const lines = node.value.split('\n');
485
- const parts: string[] = [];
486
- for (const line of lines) {
487
- parts.push(
488
- `<w:p>` +
489
- `<w:pPr>` +
490
- `<w:pStyle w:val="Code"/>` +
491
- `<w:pBdr>` +
492
- `<w:top w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
493
- `<w:left w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
494
- `<w:bottom w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
495
- `<w:right w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
496
- `</w:pBdr>` +
497
- `<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>` +
498
- `</w:pPr>` +
499
- `<w:r>` +
500
- `<w:rPr><w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
501
- `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/></w:rPr>` +
502
- `<w:t xml:space="preserve">${escapeXml(line)}</w:t>` +
503
- `</w:r>` +
504
- `</w:p>`,
505
- );
506
- }
507
- return parts.join('');
508
- }
509
-
510
- function convertTable(node: MarkdownTable, ctx: ExportContext): string {
511
- const rows: string[] = [];
512
- for (let ri = 0; ri < node.children.length; ri++) {
513
- const row = node.children[ri];
514
- rows.push(convertTableRow(row, ctx, ri === 0, node.align));
515
- }
516
-
517
- return (
518
- `<w:tbl>` +
519
- `<w:tblPr>` +
520
- `<w:tblStyle w:val="TableGrid"/>` +
521
- `<w:tblW w:w="0" w:type="auto"/>` +
522
- `<w:tblBorders>` +
523
- `<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
524
- `<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
525
- `<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
526
- `<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
527
- `<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
528
- `<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
529
- `</w:tblBorders>` +
530
- `</w:tblPr>` +
531
- `<w:tblGrid/>` +
532
- rows.join('') +
533
- `</w:tbl>`
534
- );
535
- }
536
-
537
- function convertTableRow(
538
- row: MarkdownTableRow,
539
- ctx: ExportContext,
540
- isHeader: boolean,
541
- align?: (('left' | 'right' | 'center') | null)[],
542
- ): string {
543
- const cells: string[] = [];
544
- for (let ci = 0; ci < row.children.length; ci++) {
545
- const cell = row.children[ci];
546
- const cellAlign = align?.[ci] ?? null;
547
- cells.push(convertTableCell(cell, ctx, isHeader, cellAlign));
548
- }
549
- const trPr = isHeader ? '<w:trPr><w:tblHeader/></w:trPr>' : '';
550
- return `<w:tr>${trPr}${cells.join('')}</w:tr>`;
551
- }
552
-
553
- function convertTableCell(
554
- cell: MarkdownTableCell,
555
- ctx: ExportContext,
556
- isHeader: boolean,
557
- align: 'left' | 'right' | 'center' | null,
558
- ): string {
559
- const runs = convertInlines(cell.children, ctx, bodyFormat(ctx));
560
- const rPr = isHeader ? '<w:rPr><w:b/></w:rPr>' : '';
561
- const jcMap = { left: 'left', center: 'center', right: 'right' };
562
- const jc = align ? `<w:jc w:val="${jcMap[align]}"/>` : '';
563
- const pPr = rPr || jc ? `<w:pPr>${jc}</w:pPr>` : '';
564
- return `<w:tc><w:p>${pPr}${runs}</w:p></w:tc>`;
565
- }
566
-
567
- function convertThematicBreak(): string {
568
- return (
569
- `<w:p>` +
570
- `<w:pPr>` +
571
- `<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr>` +
572
- `</w:pPr>` +
573
- `</w:p>`
574
- );
575
- }
576
-
577
- function convertHtmlBlock(node: MarkdownHtmlBlock): string {
578
- // Best-effort: extract text content from the HTML
579
- const text = stripHtmlTags(node.rawHtml);
580
- if (!text.trim()) return '';
581
- return `<w:p><w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
582
- }
583
-
584
- function convertMathBlock(node: MarkdownMathBlock): string {
585
- // Emit as a styled paragraph with the raw LaTeX
586
- return (
587
- `<w:p>` +
588
- `<w:pPr><w:jc w:val="center"/></w:pPr>` +
589
- `<w:r>` +
590
- `<w:rPr><w:rFonts w:ascii="Cambria Math" w:hAnsi="Cambria Math"/><w:i/></w:rPr>` +
591
- `<w:t xml:space="preserve">${escapeXml(node.value)}</w:t>` +
592
- `</w:r>` +
593
- `</w:p>`
594
- );
595
- }
596
-
597
- function convertFootnoteDefinition(node: MarkdownFootnoteDefinition, ctx: ExportContext): string {
598
- const fnId = ctx.getFootnoteId(node.identifier);
599
-
600
- // Build the footnote body XML
601
- const bodyParts: string[] = [];
602
- for (const child of node.children) {
603
- if (child.type === 'paragraph') {
604
- const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
605
- bodyParts.push(`<w:p>${runs}</w:p>`);
606
- }
607
- }
608
-
609
- const footnoteXml =
610
- `<w:footnote w:id="${fnId}">` +
611
- (bodyParts.length > 0 ? bodyParts.join('') : `<w:p/>`) +
612
- `</w:footnote>`;
613
-
614
- ctx.footnotes.set(fnId, footnoteXml);
615
-
616
- // Don't emit anything in the main body for the definition
617
- return '';
618
- }
619
-
620
- // ============================================
621
- // Inline Conversion
622
- // ============================================
623
-
624
- /**
625
- * Formatting state passed down through nested inline elements.
626
- */
627
- interface InlineFormat {
628
- bold?: boolean;
629
- italic?: boolean;
630
- strike?: boolean;
631
- code?: boolean;
632
- /**
633
- * Explicit run color (hex without `#`). When set, every emitted run
634
- * gets `<w:color>` directly in its `<w:rPr>` — bypassing Word's style
635
- * inheritance, which some Word configurations silently override.
636
- * Body paragraphs pass `ctx.bodyColor` here; headings leave it unset
637
- * so the heading style's own color wins.
638
- */
639
- color?: string;
640
- }
641
-
642
- /**
643
- * DOCX leaf handlers for the shared run-based inline walker. The traversal
644
- * (format threading) lives in `shared/inlineRuns.ts`; these emit WordprocessingML.
645
- */
646
- function docxRunHandlers(ctx: ExportContext): InlineRunHandlers {
647
- return {
648
- run: (text, format) => makeRun(text, format),
649
- link: (node, format) => convertLink(node, ctx, format),
650
- image: (node) => convertImage(node, ctx),
651
- lineBreak: () => `<w:r><w:br/></w:r>`,
652
- footnoteRef: (node) => convertFootnoteRef(node, ctx),
653
- };
654
- }
655
-
656
- function convertInlines(
657
- nodes: MarkdownInlineNode[],
658
- ctx: ExportContext,
659
- format: InlineFormat = {},
660
- ): string {
661
- return inlineNodesToRuns(nodes, docxRunHandlers(ctx), format);
662
- }
663
-
664
- function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: InlineFormat): string {
665
- return inlineNodeToRuns(node, docxRunHandlers(ctx), format);
666
- }
667
-
668
- function makeRun(text: string, format: InlineFormat): string {
669
- if (!text) return '';
670
-
671
- const rPrParts: string[] = [];
672
- if (format.bold) rPrParts.push('<w:b/>');
673
- if (format.italic) rPrParts.push('<w:i/>');
674
- if (format.strike) rPrParts.push('<w:strike/>');
675
- if (format.color) rPrParts.push(`<w:color w:val="${format.color}"/>`);
676
- if (format.code) {
677
- rPrParts.push(
678
- `<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>`,
679
- `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>`,
680
- );
681
- }
682
-
683
- const rPr = rPrParts.length > 0 ? `<w:rPr>${rPrParts.join('')}</w:rPr>` : '';
684
-
685
- // Use xml:space="preserve" to keep leading/trailing whitespace
686
- return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
687
- }
688
-
689
- function convertLink(node: MarkdownLink, ctx: ExportContext, format: InlineFormat): string {
690
- const rId = ctx.addHyperlink(node.url);
691
- const styledRuns = convertInlinesWithHyperlinkStyle(node.children, ctx, format);
692
-
693
- return `<w:hyperlink r:id="${rId}">${styledRuns}</w:hyperlink>`;
694
- }
695
-
696
- function convertInlinesWithHyperlinkStyle(
697
- nodes: MarkdownInlineNode[],
698
- ctx: ExportContext,
699
- format: InlineFormat,
700
- ): string {
701
- const parts: string[] = [];
702
- for (const node of nodes) {
703
- if (node.type === 'text') {
704
- parts.push(makeHyperlinkRun(node.value, format));
705
- } else {
706
- // For nested formatting inside links, add hyperlink style
707
- parts.push(convertInline(node, ctx, format));
708
- }
709
- }
710
- return parts.join('');
711
- }
712
-
713
- function makeHyperlinkRun(text: string, format: InlineFormat): string {
714
- if (!text) return '';
715
-
716
- const rPrParts: string[] = [
717
- '<w:rStyle w:val="Hyperlink"/>',
718
- `<w:color w:val="${HYPERLINK_COLOR}"/>`,
719
- '<w:u w:val="single"/>',
720
- ];
721
- if (format.bold) rPrParts.push('<w:b/>');
722
- if (format.italic) rPrParts.push('<w:i/>');
723
-
724
- return (
725
- `<w:r><w:rPr>${rPrParts.join('')}</w:rPr>` +
726
- `<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`
727
- );
728
- }
729
-
730
- function convertImage(node: MarkdownImage, ctx: ExportContext): string {
731
- const imageEntry = ctx.resolvedImages.get(node.url);
732
- if (!imageEntry) {
733
- // No resolved data — emit placeholder text
734
- const alt = node.alt || node.url;
735
- return makeRun(`[Image: ${alt}]`, { italic: true });
736
- }
737
-
738
- const { data, contentType } = imageEntry;
739
- const ext = contentType.split('/')[1]?.replace('jpeg', 'jpg') || 'png';
740
- const filename = `image${ctx.images.length + 1}.${ext}`;
741
- const { relId, docPrId } = ctx.addImage(data, contentType, filename);
742
-
743
- // Read dimensions from binary header; fall back to 5×3 inches
744
- const dims = readImageDimensions(data);
745
- const EMU_PER_INCH = 914400;
746
- const MAX_WIDTH_EMU = 6 * EMU_PER_INCH; // 6 inch content width
747
- let cx: number;
748
- let cy: number;
749
-
750
- if (dims) {
751
- // Scale to fit within max width, assuming 96 DPI for pixel → inch
752
- const widthEmu = (dims.width / 96) * EMU_PER_INCH;
753
- const heightEmu = (dims.height / 96) * EMU_PER_INCH;
754
- if (widthEmu > MAX_WIDTH_EMU) {
755
- const scale = MAX_WIDTH_EMU / widthEmu;
756
- cx = MAX_WIDTH_EMU;
757
- cy = Math.round(heightEmu * scale);
758
- } else {
759
- cx = Math.round(widthEmu);
760
- cy = Math.round(heightEmu);
761
- }
762
- } else {
763
- cx = 5 * EMU_PER_INCH;
764
- cy = 3 * EMU_PER_INCH;
765
- }
766
-
767
- const name = escapeXml(node.alt || filename);
768
- const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
769
- const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
770
-
771
- return (
772
- `<w:r><w:drawing>` +
773
- `<wp:inline distT="0" distB="0" distL="0" distR="0">` +
774
- `<wp:extent cx="${cx}" cy="${cy}"/>` +
775
- `<wp:docPr id="${docPrId}" name="${name}"/>` +
776
- `<wp:cNvGraphicFramePr>` +
777
- `<a:graphicFrameLocks xmlns:a="${NS_A}" noChangeAspect="1"/>` +
778
- `</wp:cNvGraphicFramePr>` +
779
- `<a:graphic xmlns:a="${NS_A}">` +
780
- `<a:graphicData uri="${NS_PIC}">` +
781
- `<pic:pic xmlns:pic="${NS_PIC}">` +
782
- `<pic:nvPicPr>` +
783
- `<pic:cNvPr id="0" name="${name}"/>` +
784
- `<pic:cNvPicPr/>` +
785
- `</pic:nvPicPr>` +
786
- `<pic:blipFill>` +
787
- `<a:blip r:embed="${relId}"/>` +
788
- `<a:stretch><a:fillRect/></a:stretch>` +
789
- `</pic:blipFill>` +
790
- `<pic:spPr>` +
791
- `<a:xfrm>` +
792
- `<a:off x="0" y="0"/>` +
793
- `<a:ext cx="${cx}" cy="${cy}"/>` +
794
- `</a:xfrm>` +
795
- `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
796
- `</pic:spPr>` +
797
- `</pic:pic>` +
798
- `</a:graphicData>` +
799
- `</a:graphic>` +
800
- `</wp:inline>` +
801
- `</w:drawing></w:r>`
802
- );
803
- }
804
-
805
- /** Read width/height from PNG or JPEG binary headers. */
806
- function readImageDimensions(
807
- data: ArrayBuffer | Uint8Array,
808
- ): { width: number; height: number } | null {
809
- const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
810
- if (bytes.length < 24) return null;
811
-
812
- // PNG: signature 0x89504E47, IHDR chunk at byte 16
813
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
814
- const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
815
- const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
816
- return { width, height };
817
- }
818
-
819
- // JPEG: search for SOF0 (0xFFC0) or SOF2 (0xFFC2) marker
820
- if (bytes[0] === 0xff && bytes[1] === 0xd8) {
821
- let offset = 2;
822
- while (offset < bytes.length - 9) {
823
- if (bytes[offset] !== 0xff) break;
824
- const marker = bytes[offset + 1];
825
- if (marker === 0xc0 || marker === 0xc2) {
826
- const height = (bytes[offset + 5] << 8) | bytes[offset + 6];
827
- const width = (bytes[offset + 7] << 8) | bytes[offset + 8];
828
- return { width, height };
829
- }
830
- const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
831
- offset += 2 + segLen;
832
- }
833
- }
834
-
835
- // GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
836
- if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
837
- const width = bytes[6] | (bytes[7] << 8);
838
- const height = bytes[8] | (bytes[9] << 8);
839
- return { width, height };
840
- }
841
-
842
- return null;
843
- }
844
-
845
- function convertFootnoteRef(node: MarkdownFootnoteReference, ctx: ExportContext): string {
846
- const fnId = ctx.getFootnoteId(node.identifier);
847
- return (
848
- `<w:r>` +
849
- `<w:rPr><w:rStyle w:val="FootnoteReference"/><w:vertAlign w:val="superscript"/></w:rPr>` +
850
- `<w:footnoteReference w:id="${fnId}"/>` +
851
- `</w:r>`
852
- );
853
- }
854
-
855
- // ============================================
856
- // Package Assembly
857
- // ============================================
858
-
859
- async function buildDocxPackage(
860
- bodyXml: string,
861
- ctx: ExportContext,
862
- options: DocxExportOptions,
863
- ): Promise<ArrayBuffer> {
864
- const pkg = createPackage();
865
-
866
- // --- Register fixed relationships ---
867
- let relCounter = 100; // Start high to avoid collisions with dynamic rels
868
- const stylesRelId = `rId${relCounter++}`;
869
- const numberingRelId = `rId${relCounter++}`;
870
- const settingsRelId = `rId${relCounter++}`;
871
- const fontTableRelId = `rId${relCounter++}`;
872
- const footnotesRelId = `rId${relCounter++}`;
873
-
874
- // --- word/document.xml ---
875
- const documentXml = buildDocumentXml(bodyXml, ctx.backgroundColor);
876
- pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
877
-
878
- // --- word/styles.xml ---
879
- const stylesXml = buildStylesXml(options, ctx);
880
- pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
881
-
882
- // --- word/settings.xml ---
883
- // `displayBackgroundShape` is what makes Word actually paint the
884
- // `<w:background>` element from document.xml on screen + print.
885
- // Without it the bg color is in the file but invisible.
886
- const settingsXml = buildSettingsXml(ctx.backgroundColor !== undefined);
887
- pkg.addPart('word/settings.xml', settingsXml, CONTENT_TYPE_DOCX_SETTINGS);
888
-
889
- // --- word/fontTable.xml ---
890
- const fontTableXml = buildFontTableXml(options);
891
- pkg.addPart('word/fontTable.xml', fontTableXml, CONTENT_TYPE_DOCX_FONT_TABLE);
892
-
893
- // --- word/numbering.xml (only if lists present) ---
894
- if (ctx.hasLists) {
895
- const numberingXml = buildNumberingXml(ctx);
896
- pkg.addPart('word/numbering.xml', numberingXml, CONTENT_TYPE_DOCX_NUMBERING);
897
- }
898
-
899
- // --- word/footnotes.xml (only if footnotes present) ---
900
- if (ctx.hasFootnotes) {
901
- const footnotesXml = buildFootnotesXml(ctx);
902
- pkg.addPart('word/footnotes.xml', footnotesXml, CONTENT_TYPE_DOCX_FOOTNOTES);
903
- }
904
-
905
- // --- Root relationship: this package contains a word document ---
906
- pkg.addRelationship('', {
907
- id: 'rId1',
908
- type: REL_OFFICE_DOCUMENT,
909
- target: 'word/document.xml',
910
- });
911
-
912
- // --- Document relationships ---
913
- pkg.addRelationship('word/document.xml', {
914
- id: stylesRelId,
915
- type: REL_STYLES,
916
- target: 'styles.xml',
917
- });
918
- pkg.addRelationship('word/document.xml', {
919
- id: settingsRelId,
920
- type: REL_SETTINGS,
921
- target: 'settings.xml',
922
- });
923
- pkg.addRelationship('word/document.xml', {
924
- id: fontTableRelId,
925
- type: REL_FONT_TABLE,
926
- target: 'fontTable.xml',
927
- });
928
-
929
- if (ctx.hasLists) {
930
- pkg.addRelationship('word/document.xml', {
931
- id: numberingRelId,
932
- type: REL_NUMBERING,
933
- target: 'numbering.xml',
934
- });
935
- }
936
-
937
- if (ctx.hasFootnotes) {
938
- pkg.addRelationship('word/document.xml', {
939
- id: footnotesRelId,
940
- type: REL_FOOTNOTES,
941
- target: 'footnotes.xml',
942
- });
943
- }
944
-
945
- // --- Dynamic relationships (hyperlinks, images) ---
946
- for (const rel of ctx.relationships) {
947
- pkg.addRelationship('word/document.xml', {
948
- id: rel.id,
949
- type: rel.type,
950
- target: rel.target,
951
- ...(rel.targetMode ? { targetMode: rel.targetMode } : {}),
952
- });
953
- }
954
-
955
- // --- Embedded images ---
956
- for (const img of ctx.images) {
957
- pkg.addBinaryPart(img.path, img.data, img.contentType);
958
- }
959
-
960
- // --- Core properties ---
961
- if (options.title || options.author || options.description) {
962
- pkg.setCoreProperties({
963
- title: options.title,
964
- creator: options.author,
965
- description: options.description,
966
- created: new Date().toISOString(),
967
- modified: new Date().toISOString(),
968
- });
969
- }
970
-
971
- return pkg.toArrayBuffer();
972
- }
973
-
974
- // ============================================
975
- // XML Part Generators
976
- // ============================================
977
-
978
- function buildDocumentXml(bodyXml: string, backgroundColor?: string): string {
979
- // `<w:background>` must be the FIRST child of `<w:document>` per the
980
- // WordprocessingML schema. Word also requires `<w:displayBackgroundShape/>`
981
- // in settings.xml before the bg actually paints (see buildSettingsXml).
982
- const backgroundEl = backgroundColor ? `<w:background w:color="${backgroundColor}"/>` : '';
983
- return (
984
- xmlDeclaration() +
985
- `<w:document` +
986
- ` xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"` +
987
- ` xmlns:mc="${NS_MC}"` +
988
- ` xmlns:o="urn:schemas-microsoft-com:office:office"` +
989
- ` xmlns:r="${NS_R}"` +
990
- ` xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"` +
991
- ` xmlns:v="urn:schemas-microsoft-com:vml"` +
992
- ` xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"` +
993
- ` xmlns:w10="urn:schemas-microsoft-com:office:word"` +
994
- ` xmlns:w="${NS_WML}"` +
995
- ` xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">` +
996
- backgroundEl +
997
- `<w:body>` +
998
- bodyXml +
999
- `<w:sectPr>` +
1000
- `<w:pgSz w:w="12240" w:h="15840"/>` +
1001
- `<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>` +
1002
- `<w:cols w:space="720"/>` +
1003
- `</w:sectPr>` +
1004
- `</w:body>` +
1005
- `</w:document>`
1006
- );
1007
- }
1008
-
1009
- function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string {
1010
- const font = ctx.font;
1011
- const headingFont = ctx.headingFont;
1012
- // Body color from the theme's `text`. We set it on BOTH the
1013
- // `<w:rPrDefault>` (for any rogue run that doesn't inherit Normal) and
1014
- // on the `Normal` style itself. Setting it only on rPrDefault was
1015
- // experimentally insufficient — Word's resolved formatting in Print
1016
- // Layout view ignored the docDefault color for direct-typed body
1017
- // paragraphs even though headings (with explicit rPr) honored their
1018
- // own color. Setting it on Normal puts the color one level higher in
1019
- // the precedence chain (paragraph-style > docDefaults) so it sticks.
1020
- const bodyColorXml = ctx.bodyColor ? `<w:color w:val="${ctx.bodyColor}"/>` : '';
1021
- const quoteColor = ctx.mutedColor ?? '404040';
1022
-
1023
- return (
1024
- xmlDeclaration() +
1025
- `<w:styles xmlns:w="${NS_WML}">` +
1026
- // Default run properties
1027
- `<w:docDefaults>` +
1028
- `<w:rPrDefault><w:rPr>` +
1029
- `<w:rFonts w:ascii="${escapeXml(font)}" w:hAnsi="${escapeXml(font)}" w:eastAsia="${escapeXml(font)}" w:cs="${escapeXml(font)}"/>` +
1030
- bodyColorXml +
1031
- `<w:sz w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
1032
- `<w:szCs w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
1033
- `</w:rPr></w:rPrDefault>` +
1034
- `<w:pPrDefault/>` +
1035
- `</w:docDefaults>` +
1036
- // Normal style — carries the body color explicitly so direct-typed
1037
- // body paragraphs render in the theme's text color in every Word
1038
- // view (not just Web Layout).
1039
- `<w:style w:type="paragraph" w:default="1" w:styleId="Normal">` +
1040
- `<w:name w:val="Normal"/>` +
1041
- `<w:qFormat/>` +
1042
- (bodyColorXml ? `<w:rPr>${bodyColorXml}</w:rPr>` : '') +
1043
- `</w:style>` +
1044
- // Heading styles
1045
- buildHeadingStyles(headingFont, ctx.headingColor) +
1046
- // Quote style — muted text color from theme.textMuted (falls back
1047
- // to a neutral grey when no theme is supplied).
1048
- `<w:style w:type="paragraph" w:styleId="Quote">` +
1049
- `<w:name w:val="Quote"/>` +
1050
- `<w:basedOn w:val="Normal"/>` +
1051
- `<w:pPr><w:ind w:left="720"/></w:pPr>` +
1052
- `<w:rPr><w:i/><w:color w:val="${quoteColor}"/></w:rPr>` +
1053
- `</w:style>` +
1054
- // Code style
1055
- `<w:style w:type="paragraph" w:styleId="Code">` +
1056
- `<w:name w:val="Code"/>` +
1057
- `<w:basedOn w:val="Normal"/>` +
1058
- `<w:rPr>` +
1059
- `<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
1060
- `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>` +
1061
- `</w:rPr>` +
1062
- `</w:style>` +
1063
- // ListParagraph style
1064
- `<w:style w:type="paragraph" w:styleId="ListParagraph">` +
1065
- `<w:name w:val="List Paragraph"/>` +
1066
- `<w:basedOn w:val="Normal"/>` +
1067
- `<w:pPr><w:ind w:left="720"/></w:pPr>` +
1068
- `</w:style>` +
1069
- // Hyperlink character style
1070
- `<w:style w:type="character" w:styleId="Hyperlink">` +
1071
- `<w:name w:val="Hyperlink"/>` +
1072
- `<w:rPr><w:color w:val="${HYPERLINK_COLOR}"/><w:u w:val="single"/></w:rPr>` +
1073
- `</w:style>` +
1074
- // FootnoteReference character style
1075
- `<w:style w:type="character" w:styleId="FootnoteReference">` +
1076
- `<w:name w:val="footnote reference"/>` +
1077
- `<w:rPr><w:vertAlign w:val="superscript"/></w:rPr>` +
1078
- `</w:style>` +
1079
- // Table Grid style
1080
- `<w:style w:type="table" w:styleId="TableGrid">` +
1081
- `<w:name w:val="Table Grid"/>` +
1082
- `<w:tblPr>` +
1083
- `<w:tblBorders>` +
1084
- `<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1085
- `<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1086
- `<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1087
- `<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1088
- `<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1089
- `<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
1090
- `</w:tblBorders>` +
1091
- `</w:tblPr>` +
1092
- `</w:style>` +
1093
- `</w:styles>`
1094
- );
1095
- }
1096
-
1097
- function buildHeadingStyles(headingFont: string, headingColor?: string): string {
1098
- let result = '';
1099
- for (let depth = 1; depth <= 6; depth++) {
1100
- const styleId = DEPTH_TO_STYLE_ID[depth];
1101
- const fontSize = HEADING_FONT_SIZES[depth] ?? 22;
1102
- const colorXml = headingColor ? `<w:color w:val="${headingColor}"/>` : '';
1103
- result +=
1104
- `<w:style w:type="paragraph" w:styleId="${styleId}">` +
1105
- `<w:name w:val="heading ${depth}"/>` +
1106
- `<w:basedOn w:val="Normal"/>` +
1107
- `<w:next w:val="Normal"/>` +
1108
- `<w:qFormat/>` +
1109
- `<w:pPr><w:outlineLvl w:val="${depth - 1}"/><w:spacing w:before="240" w:after="60"/></w:pPr>` +
1110
- `<w:rPr>` +
1111
- `<w:rFonts w:ascii="${escapeXml(headingFont)}" w:hAnsi="${escapeXml(headingFont)}"/>` +
1112
- `<w:b/>` +
1113
- colorXml +
1114
- `<w:sz w:val="${fontSize}"/>` +
1115
- `<w:szCs w:val="${fontSize}"/>` +
1116
- `</w:rPr>` +
1117
- `</w:style>`;
1118
- }
1119
- return result;
1120
- }
1121
-
1122
- function buildSettingsXml(displayBackground: boolean): string {
1123
- // `<w:compat>` with `compatibilityMode=15` is what tells Word "this
1124
- // is a Word 2016+ .docx, render with modern features." Without it,
1125
- // Word opens the file in Compatibility Mode and silently disables
1126
- // newer behaviors — including page-background rendering in Print
1127
- // Layout view and several default style precedence rules. The other
1128
- // compatSetting flags match what Word 2016+ writes by default.
1129
- const compatXml =
1130
- `<w:compat>` +
1131
- `<w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/>` +
1132
- `<w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1133
- `<w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1134
- `<w:compatSetting w:name="doNotFlipMirrorIndents" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1135
- `<w:compatSetting w:name="differentiateMultirowTableHeaders" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1136
- `</w:compat>`;
1137
- // Element order inside `<w:settings>` matters — ECMA-376 defines a
1138
- // strict sequence (`displayBackgroundShape` → `defaultTabStop` →
1139
- // `characterSpacingControl` → … → `compat`). Word silently drops
1140
- // out-of-order children, which is why `<w:background>` looks correct
1141
- // in the file but doesn't paint when opened.
1142
- return (
1143
- xmlDeclaration() +
1144
- `<w:settings xmlns:w="${NS_WML}">` +
1145
- (displayBackground ? `<w:displayBackgroundShape/>` : '') +
1146
- `<w:defaultTabStop w:val="720"/>` +
1147
- `<w:characterSpacingControl w:val="doNotCompress"/>` +
1148
- compatXml +
1149
- `</w:settings>`
1150
- );
1151
- }
1152
-
1153
- function buildFontTableXml(options: DocxExportOptions): string {
1154
- const font = options.defaultFont ?? DEFAULT_FONT;
1155
- return (
1156
- xmlDeclaration() +
1157
- `<w:fonts xmlns:w="${NS_WML}">` +
1158
- `<w:font w:name="${escapeXml(font)}">` +
1159
- `<w:panose1 w:val="020F0502020204030204"/>` +
1160
- `<w:charset w:val="00"/>` +
1161
- `<w:family w:val="swiss"/>` +
1162
- `<w:pitch w:val="variable"/>` +
1163
- `</w:font>` +
1164
- `<w:font w:name="${DEFAULT_HEADING_FONT}">` +
1165
- `<w:panose1 w:val="020F0302020204030204"/>` +
1166
- `<w:charset w:val="00"/>` +
1167
- `<w:family w:val="swiss"/>` +
1168
- `<w:pitch w:val="variable"/>` +
1169
- `</w:font>` +
1170
- `<w:font w:name="${DEFAULT_CODE_FONT}">` +
1171
- `<w:charset w:val="00"/>` +
1172
- `<w:family w:val="modern"/>` +
1173
- `<w:pitch w:val="fixed"/>` +
1174
- `</w:font>` +
1175
- `</w:fonts>`
1176
- );
1177
- }
1178
-
1179
- function buildNumberingXml(ctx: ExportContext): string {
1180
- const abstract: string[] = [];
1181
- const concrete: string[] = [];
1182
-
1183
- for (const def of ctx.numberingDefs) {
1184
- const absId = def.numId;
1185
- const levels: string[] = [];
1186
-
1187
- for (let lvl = 0; lvl < 9; lvl++) {
1188
- if (def.ordered) {
1189
- levels.push(
1190
- `<w:lvl w:ilvl="${lvl}">` +
1191
- `<w:start w:val="1"/>` +
1192
- `<w:numFmt w:val="decimal"/>` +
1193
- `<w:lvlText w:val="%${lvl + 1}."/>` +
1194
- `<w:lvlJc w:val="left"/>` +
1195
- `<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
1196
- `</w:lvl>`,
1197
- );
1198
- } else {
1199
- const bullets = ['\u2022', '\u25E6', '\u25AA']; // •, ◦, ▪
1200
- const bullet = bullets[lvl % bullets.length];
1201
- levels.push(
1202
- `<w:lvl w:ilvl="${lvl}">` +
1203
- `<w:start w:val="1"/>` +
1204
- `<w:numFmt w:val="bullet"/>` +
1205
- `<w:lvlText w:val="${bullet}"/>` +
1206
- `<w:lvlJc w:val="left"/>` +
1207
- `<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
1208
- // Bullet glyph renders in the document's body font, not
1209
- // Word's legacy `Symbol` font. `Symbol` only maps codes
1210
- // 0x20-0xFF to Greek/math glyphs, so the Unicode bullets
1211
- // we use (U+2022, U+25E6, U+25AA) come out as "tofu"
1212
- // empty boxes when forced into Symbol. Every modern OS
1213
- // font has these codepoints, so omitting the font
1214
- // override gets a real bullet.
1215
- `</w:lvl>`,
1216
- );
1217
- }
1218
- }
1219
-
1220
- abstract.push(
1221
- `<w:abstractNum w:abstractNumId="${absId}">` + levels.join('') + `</w:abstractNum>`,
1222
- );
1223
- concrete.push(
1224
- `<w:num w:numId="${def.numId}">` + `<w:abstractNumId w:val="${absId}"/>` + `</w:num>`,
1225
- );
1226
- }
1227
-
1228
- return (
1229
- xmlDeclaration() +
1230
- `<w:numbering xmlns:w="${NS_WML}">` +
1231
- abstract.join('') +
1232
- concrete.join('') +
1233
- `</w:numbering>`
1234
- );
1235
- }
1236
-
1237
- function buildFootnotesXml(ctx: ExportContext): string {
1238
- const footnotes: string[] = [];
1239
-
1240
- // Separator and continuation separator (required by Word)
1241
- footnotes.push(
1242
- `<w:footnote w:type="separator" w:id="-1">` +
1243
- `<w:p><w:r><w:separator/></w:r></w:p>` +
1244
- `</w:footnote>`,
1245
- );
1246
- footnotes.push(
1247
- `<w:footnote w:type="continuationSeparator" w:id="0">` +
1248
- `<w:p><w:r><w:continuationSeparator/></w:r></w:p>` +
1249
- `</w:footnote>`,
1250
- );
1251
-
1252
- // User footnotes
1253
- for (const [_id, xml] of ctx.footnotes) {
1254
- footnotes.push(xml);
1255
- }
1256
-
1257
- return (
1258
- xmlDeclaration() +
1259
- `<w:footnotes xmlns:w="${NS_WML}" xmlns:r="${NS_R}">` +
1260
- footnotes.join('') +
1261
- `</w:footnotes>`
1262
- );
1263
- }
1264
-
1265
- // ============================================
1266
- // Helpers
1267
- // ============================================