@bendyline/squisq-formats 1.3.0 → 1.3.1

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 (161) hide show
  1. package/dist/container/index.d.ts +7 -5
  2. package/dist/container/index.js +39 -70
  3. package/dist/container/index.js.map +1 -1
  4. package/dist/docx/index.d.ts +128 -15
  5. package/dist/docx/index.js +17 -22
  6. package/dist/docx/index.js.map +1 -1
  7. package/dist/epub/index.d.ts +66 -11
  8. package/dist/epub/index.js +10 -18
  9. package/dist/epub/index.js.map +1 -1
  10. package/dist/html/index.d.ts +278 -15
  11. package/dist/html/index.js +26 -165
  12. package/dist/html/index.js.map +1 -1
  13. package/dist/index.d.ts +9 -38
  14. package/dist/index.js +62 -37
  15. package/dist/index.js.map +1 -1
  16. package/dist/ooxml/index.d.ts +353 -14
  17. package/dist/ooxml/index.js +116 -22
  18. package/dist/ooxml/index.js.map +1 -1
  19. package/dist/pdf/index.d.ts +143 -17
  20. package/dist/pdf/index.js +16 -25
  21. package/dist/pdf/index.js.map +1 -1
  22. package/dist/pptx/index.d.ts +83 -8
  23. package/dist/pptx/index.js +14 -34
  24. package/dist/pptx/index.js.map +1 -1
  25. package/dist/xlsx/index.d.ts +12 -9
  26. package/dist/xlsx/index.js +12 -44
  27. package/dist/xlsx/index.js.map +1 -1
  28. package/package.json +2 -2
  29. package/dist/__tests__/container.test.d.ts +0 -8
  30. package/dist/__tests__/container.test.d.ts.map +0 -1
  31. package/dist/__tests__/container.test.js +0 -147
  32. package/dist/__tests__/container.test.js.map +0 -1
  33. package/dist/__tests__/docxExport.test.d.ts +0 -8
  34. package/dist/__tests__/docxExport.test.d.ts.map +0 -1
  35. package/dist/__tests__/docxExport.test.js +0 -383
  36. package/dist/__tests__/docxExport.test.js.map +0 -1
  37. package/dist/__tests__/docxImport.test.d.ts +0 -8
  38. package/dist/__tests__/docxImport.test.d.ts.map +0 -1
  39. package/dist/__tests__/docxImport.test.js +0 -309
  40. package/dist/__tests__/docxImport.test.js.map +0 -1
  41. package/dist/__tests__/epub.test.d.ts +0 -8
  42. package/dist/__tests__/epub.test.d.ts.map +0 -1
  43. package/dist/__tests__/epub.test.js +0 -472
  44. package/dist/__tests__/epub.test.js.map +0 -1
  45. package/dist/__tests__/html.test.d.ts +0 -8
  46. package/dist/__tests__/html.test.d.ts.map +0 -1
  47. package/dist/__tests__/html.test.js +0 -317
  48. package/dist/__tests__/html.test.js.map +0 -1
  49. package/dist/__tests__/ooxml.test.d.ts +0 -5
  50. package/dist/__tests__/ooxml.test.d.ts.map +0 -1
  51. package/dist/__tests__/ooxml.test.js +0 -152
  52. package/dist/__tests__/ooxml.test.js.map +0 -1
  53. package/dist/__tests__/pdfExport.test.d.ts +0 -8
  54. package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
  55. package/dist/__tests__/pdfExport.test.js +0 -275
  56. package/dist/__tests__/pdfExport.test.js.map +0 -1
  57. package/dist/__tests__/pdfImport.test.d.ts +0 -11
  58. package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
  59. package/dist/__tests__/pdfImport.test.js +0 -254
  60. package/dist/__tests__/pdfImport.test.js.map +0 -1
  61. package/dist/__tests__/plainHtml.test.d.ts +0 -5
  62. package/dist/__tests__/plainHtml.test.d.ts.map +0 -1
  63. package/dist/__tests__/plainHtml.test.js +0 -327
  64. package/dist/__tests__/plainHtml.test.js.map +0 -1
  65. package/dist/__tests__/plainHtmlBundle.test.d.ts +0 -11
  66. package/dist/__tests__/plainHtmlBundle.test.d.ts.map +0 -1
  67. package/dist/__tests__/plainHtmlBundle.test.js +0 -210
  68. package/dist/__tests__/plainHtmlBundle.test.js.map +0 -1
  69. package/dist/__tests__/roundTrip.test.d.ts +0 -7
  70. package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
  71. package/dist/__tests__/roundTrip.test.js +0 -173
  72. package/dist/__tests__/roundTrip.test.js.map +0 -1
  73. package/dist/container/index.d.ts.map +0 -1
  74. package/dist/docx/export.d.ts +0 -69
  75. package/dist/docx/export.d.ts.map +0 -1
  76. package/dist/docx/export.js +0 -985
  77. package/dist/docx/export.js.map +0 -1
  78. package/dist/docx/import.d.ts +0 -65
  79. package/dist/docx/import.d.ts.map +0 -1
  80. package/dist/docx/import.js +0 -779
  81. package/dist/docx/import.js.map +0 -1
  82. package/dist/docx/index.d.ts.map +0 -1
  83. package/dist/docx/styles.d.ts +0 -68
  84. package/dist/docx/styles.d.ts.map +0 -1
  85. package/dist/docx/styles.js +0 -125
  86. package/dist/docx/styles.js.map +0 -1
  87. package/dist/epub/export.d.ts +0 -72
  88. package/dist/epub/export.d.ts.map +0 -1
  89. package/dist/epub/export.js +0 -698
  90. package/dist/epub/export.js.map +0 -1
  91. package/dist/epub/index.d.ts.map +0 -1
  92. package/dist/html/docsHtmlBundle.d.ts +0 -53
  93. package/dist/html/docsHtmlBundle.d.ts.map +0 -1
  94. package/dist/html/docsHtmlBundle.js +0 -299
  95. package/dist/html/docsHtmlBundle.js.map +0 -1
  96. package/dist/html/htmlTemplate.d.ts +0 -70
  97. package/dist/html/htmlTemplate.d.ts.map +0 -1
  98. package/dist/html/htmlTemplate.js +0 -282
  99. package/dist/html/htmlTemplate.js.map +0 -1
  100. package/dist/html/imageUtils.d.ts +0 -29
  101. package/dist/html/imageUtils.d.ts.map +0 -1
  102. package/dist/html/imageUtils.js +0 -63
  103. package/dist/html/imageUtils.js.map +0 -1
  104. package/dist/html/index.d.ts.map +0 -1
  105. package/dist/html/plainHtml.d.ts +0 -79
  106. package/dist/html/plainHtml.d.ts.map +0 -1
  107. package/dist/html/plainHtml.js +0 -614
  108. package/dist/html/plainHtml.js.map +0 -1
  109. package/dist/html/plainHtmlBundle.d.ts +0 -93
  110. package/dist/html/plainHtmlBundle.d.ts.map +0 -1
  111. package/dist/html/plainHtmlBundle.js +0 -357
  112. package/dist/html/plainHtmlBundle.js.map +0 -1
  113. package/dist/index.d.ts.map +0 -1
  114. package/dist/ooxml/index.d.ts.map +0 -1
  115. package/dist/ooxml/namespaces.d.ts +0 -77
  116. package/dist/ooxml/namespaces.d.ts.map +0 -1
  117. package/dist/ooxml/namespaces.js +0 -104
  118. package/dist/ooxml/namespaces.js.map +0 -1
  119. package/dist/ooxml/reader.d.ts +0 -52
  120. package/dist/ooxml/reader.d.ts.map +0 -1
  121. package/dist/ooxml/reader.js +0 -190
  122. package/dist/ooxml/reader.js.map +0 -1
  123. package/dist/ooxml/types.d.ts +0 -82
  124. package/dist/ooxml/types.d.ts.map +0 -1
  125. package/dist/ooxml/types.js +0 -9
  126. package/dist/ooxml/types.js.map +0 -1
  127. package/dist/ooxml/writer.d.ts +0 -71
  128. package/dist/ooxml/writer.d.ts.map +0 -1
  129. package/dist/ooxml/writer.js +0 -200
  130. package/dist/ooxml/writer.js.map +0 -1
  131. package/dist/ooxml/xmlUtils.d.ts +0 -76
  132. package/dist/ooxml/xmlUtils.d.ts.map +0 -1
  133. package/dist/ooxml/xmlUtils.js +0 -108
  134. package/dist/ooxml/xmlUtils.js.map +0 -1
  135. package/dist/pdf/export.d.ts +0 -56
  136. package/dist/pdf/export.d.ts.map +0 -1
  137. package/dist/pdf/export.js +0 -768
  138. package/dist/pdf/export.js.map +0 -1
  139. package/dist/pdf/import.d.ts +0 -94
  140. package/dist/pdf/import.d.ts.map +0 -1
  141. package/dist/pdf/import.js +0 -796
  142. package/dist/pdf/import.js.map +0 -1
  143. package/dist/pdf/index.d.ts.map +0 -1
  144. package/dist/pdf/styles.d.ts +0 -142
  145. package/dist/pdf/styles.d.ts.map +0 -1
  146. package/dist/pdf/styles.js +0 -145
  147. package/dist/pdf/styles.js.map +0 -1
  148. package/dist/pptx/export.d.ts +0 -75
  149. package/dist/pptx/export.d.ts.map +0 -1
  150. package/dist/pptx/export.js +0 -724
  151. package/dist/pptx/export.js.map +0 -1
  152. package/dist/pptx/index.d.ts.map +0 -1
  153. package/dist/pptx/styles.d.ts +0 -54
  154. package/dist/pptx/styles.d.ts.map +0 -1
  155. package/dist/pptx/styles.js +0 -73
  156. package/dist/pptx/styles.js.map +0 -1
  157. package/dist/pptx/templates.d.ts +0 -24
  158. package/dist/pptx/templates.d.ts.map +0 -1
  159. package/dist/pptx/templates.js +0 -158
  160. package/dist/pptx/templates.js.map +0 -1
  161. package/dist/xlsx/index.d.ts.map +0 -1
@@ -1,985 +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
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
20
- import { docToMarkdown } from '@bendyline/squisq/doc';
21
- import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
22
- import { createPackage } from '../ooxml/writer.js';
23
- import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
24
- import { NS_WML, NS_R, NS_MC, REL_OFFICE_DOCUMENT, REL_STYLES, REL_NUMBERING, REL_SETTINGS, REL_FONT_TABLE, REL_HYPERLINK, REL_IMAGE, REL_FOOTNOTES, CONTENT_TYPE_DOCX_DOCUMENT, CONTENT_TYPE_DOCX_STYLES, CONTENT_TYPE_DOCX_NUMBERING, CONTENT_TYPE_DOCX_SETTINGS, CONTENT_TYPE_DOCX_FONT_TABLE, CONTENT_TYPE_DOCX_FOOTNOTES, } from '../ooxml/namespaces.js';
25
- import { DEPTH_TO_STYLE_ID, HEADING_FONT_SIZES, DEFAULT_FONT, DEFAULT_HEADING_FONT, DEFAULT_FONT_SIZE_HALF_POINTS, DEFAULT_CODE_FONT, DEFAULT_CODE_FONT_SIZE, HYPERLINK_COLOR, pointsToTwips, } from './styles.js';
26
- /**
27
- * Convert a MarkdownDocument to a .docx Blob.
28
- *
29
- * @param doc - The parsed markdown document
30
- * @param options - Export options
31
- * @returns An ArrayBuffer containing the .docx file
32
- */
33
- export async function markdownDocToDocx(doc, options = {}) {
34
- // Mirror the PPTX export: fall back to the doc's frontmatter themeId
35
- // when the caller didn't pass one explicitly. Lets the editor's
36
- // `squisq-theme: …` frontmatter flow straight through without each
37
- // host wiring its own resolution.
38
- const resolvedOptions = options.themeId !== undefined
39
- ? options
40
- : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
41
- const ctx = new ExportContext(resolvedOptions);
42
- const bodyXml = convertBlocks(doc.children, ctx);
43
- return buildDocxPackage(bodyXml, ctx, resolvedOptions);
44
- }
45
- /**
46
- * Convert a squisq Doc to a .docx Blob.
47
- *
48
- * Convenience wrapper that converts Doc → MarkdownDocument → DOCX.
49
- *
50
- * @param doc - The squisq Doc
51
- * @param options - Export options
52
- * @returns An ArrayBuffer containing the .docx file
53
- */
54
- export async function docToDocx(doc, options = {}) {
55
- const markdownDoc = docToMarkdown(doc);
56
- return markdownDocToDocx(markdownDoc, options);
57
- }
58
- // ============================================
59
- // Export Context
60
- // ============================================
61
- /**
62
- * Tracks state during export: relationship IDs, numbering definitions,
63
- * footnote bodies, and embedded images.
64
- */
65
- class ExportContext {
66
- constructor(options) {
67
- this.nextRelId = 1;
68
- this.nextNumId = 1;
69
- this.nextFootnoteId = 1; // 0 is separator, start user footnotes at 1
70
- /** Relationships for word/_rels/document.xml.rels */
71
- this.relationships = [];
72
- /** Numbering definitions (abstract + num) */
73
- this.numberingDefs = [];
74
- /** Footnote XML bodies (keyed by footnote id) */
75
- this.footnotes = new Map();
76
- /** Footnote identifier → numeric id mapping */
77
- this.footnoteIdMap = new Map();
78
- /** Embedded images: rId → { path, data, contentType } */
79
- this.images = [];
80
- /** Whether we have any lists (determines if numbering.xml is needed) */
81
- this.hasLists = false;
82
- /** Whether we have any footnotes */
83
- this.hasFootnotes = false;
84
- this.nextDocPrId = 1;
85
- let themeFont;
86
- let themeTitleFont;
87
- let themeHeadingColor;
88
- let themeBodyColor;
89
- let themeMutedColor;
90
- let themeBackgroundColor;
91
- if (options.themeId) {
92
- const theme = resolveTheme(options.themeId);
93
- // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
94
- // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
95
- // single font name — passing the whole stack is treated as a
96
- // bogus literal name and Word falls through to Calibri. Take the
97
- // first concrete name from the stack so Word can resolve it (or
98
- // gracefully fall back to its own default if the font isn't
99
- // installed on the reader's machine).
100
- themeFont = firstFontFromStack(resolveFontFamily(theme.typography?.bodyFont, ''));
101
- themeTitleFont = firstFontFromStack(resolveFontFamily(theme.typography?.titleFont, ''));
102
- const stripHash = (c) => (c.startsWith('#') ? c.slice(1) : c);
103
- if (theme.colors?.primary)
104
- themeHeadingColor = stripHash(theme.colors.primary);
105
- if (theme.colors?.text)
106
- themeBodyColor = stripHash(theme.colors.text);
107
- if (theme.colors?.textMuted)
108
- themeMutedColor = stripHash(theme.colors.textMuted);
109
- if (theme.colors?.background)
110
- themeBackgroundColor = stripHash(theme.colors.background);
111
- }
112
- this.font = options.defaultFont ?? themeFont ?? DEFAULT_FONT;
113
- this.headingFont = themeTitleFont ?? this.font;
114
- this.fontSize = options.defaultFontSize
115
- ? options.defaultFontSize * 2
116
- : DEFAULT_FONT_SIZE_HALF_POINTS;
117
- this.headingColor = themeHeadingColor;
118
- this.bodyColor = themeBodyColor;
119
- this.mutedColor = themeMutedColor;
120
- this.backgroundColor = themeBackgroundColor;
121
- this.resolvedImages = options.images ?? new Map();
122
- }
123
- /** Allocate a new relationship ID */
124
- allocRelId() {
125
- return `rId${this.nextRelId++}`;
126
- }
127
- /** Add a hyperlink relationship and return the rId */
128
- addHyperlink(url) {
129
- const id = this.allocRelId();
130
- this.relationships.push({
131
- id,
132
- type: REL_HYPERLINK,
133
- target: url,
134
- targetMode: 'External',
135
- });
136
- return id;
137
- }
138
- /** Add an embedded image and return the rId and docPrId */
139
- addImage(data, contentType, filename) {
140
- const relId = this.allocRelId();
141
- const docPrId = this.nextDocPrId++;
142
- const path = `word/media/${filename}`;
143
- this.images.push({ relId, path, data, contentType });
144
- this.relationships.push({
145
- id: relId,
146
- type: REL_IMAGE,
147
- target: `media/${filename}`,
148
- });
149
- return { relId, docPrId };
150
- }
151
- /** Allocate a numbering definition for a list */
152
- allocNumbering(ordered) {
153
- const numId = this.nextNumId++;
154
- this.numberingDefs.push({ numId, ordered });
155
- this.hasLists = true;
156
- return numId;
157
- }
158
- /** Register or look up a footnote by its string identifier */
159
- getFootnoteId(identifier) {
160
- let id = this.footnoteIdMap.get(identifier);
161
- if (id === undefined) {
162
- id = this.nextFootnoteId++;
163
- this.footnoteIdMap.set(identifier, id);
164
- this.hasFootnotes = true;
165
- }
166
- return id;
167
- }
168
- }
169
- /**
170
- * Pull the first concrete font name out of a CSS-style stack.
171
- *
172
- * `resolveFontFamily` returns CSS values like `"Oswald", Impact,
173
- * "Arial Black", sans-serif`. Word's `w:ascii` / `w:hAnsi` attributes
174
- * expect a single font name — feeding the whole stack causes Word to
175
- * fall back to its default font instead of any of the named faces.
176
- * We pick the first non-generic name (skipping `sans-serif`, `serif`,
177
- * `monospace`, etc.) and strip the surrounding quotes that CSS lets
178
- * you wrap multi-word family names in.
179
- */
180
- function firstFontFromStack(stack) {
181
- if (!stack)
182
- return '';
183
- const generics = new Set([
184
- 'sans-serif',
185
- 'serif',
186
- 'monospace',
187
- 'system-ui',
188
- 'cursive',
189
- 'fantasy',
190
- 'ui-serif',
191
- 'ui-sans-serif',
192
- 'ui-monospace',
193
- 'ui-rounded',
194
- 'inherit',
195
- 'initial',
196
- ]);
197
- for (const raw of stack.split(',')) {
198
- const name = raw.trim().replace(/^["']|["']$/g, '');
199
- if (!name)
200
- continue;
201
- if (generics.has(name.toLowerCase()))
202
- continue;
203
- return name;
204
- }
205
- return '';
206
- }
207
- // ============================================
208
- // Block Conversion
209
- // ============================================
210
- function convertBlocks(nodes, ctx) {
211
- const parts = [];
212
- for (const node of nodes) {
213
- parts.push(convertBlock(node, ctx, 0));
214
- }
215
- return parts.join('');
216
- }
217
- function convertBlock(node, ctx, listDepth) {
218
- switch (node.type) {
219
- case 'heading':
220
- return convertHeading(node, ctx);
221
- case 'paragraph':
222
- return convertParagraph(node, ctx);
223
- case 'blockquote':
224
- return convertBlockquote(node, ctx);
225
- case 'list':
226
- return convertList(node, ctx, listDepth);
227
- case 'code':
228
- return convertCodeBlock(node);
229
- case 'table':
230
- return convertTable(node, ctx);
231
- case 'thematicBreak':
232
- return convertThematicBreak();
233
- case 'htmlBlock':
234
- return convertHtmlBlock(node);
235
- case 'math':
236
- return convertMathBlock(node);
237
- case 'footnoteDefinition':
238
- return convertFootnoteDefinition(node, ctx);
239
- default:
240
- // Definition lists, directives, link definitions — skip or emit as plain text
241
- return '';
242
- }
243
- }
244
- function convertHeading(node, ctx) {
245
- const styleId = DEPTH_TO_STYLE_ID[node.depth] ?? 'Heading1';
246
- const runs = convertInlines(node.children, ctx);
247
- return `<w:p>` + `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>` + runs + `</w:p>`;
248
- }
249
- function convertParagraph(node, ctx) {
250
- // Force body color on every run rather than relying on `Normal` /
251
- // `rPrDefault` style inheritance — see InlineFormat.color comment.
252
- // Headings render via `convertHeading` and skip this path so the
253
- // heading style's own color wins.
254
- const runs = convertInlines(node.children, ctx, bodyFormat(ctx));
255
- return `<w:p>${runs}</w:p>`;
256
- }
257
- /** Default inline format for body-text contexts (paragraphs, list items,
258
- * blockquote bodies, table cells). Carries the theme body color so runs
259
- * render in the right color regardless of style-resolution quirks. */
260
- function bodyFormat(ctx) {
261
- return ctx.bodyColor ? { color: ctx.bodyColor } : {};
262
- }
263
- function convertBlockquote(node, ctx) {
264
- // Render each child block as a paragraph with Quote style
265
- const parts = [];
266
- for (const child of node.children) {
267
- if (child.type === 'paragraph') {
268
- // Use the theme's muted color when set (Quote style already
269
- // declares it but explicit run-level color survives Word's
270
- // style overrides — see InlineFormat.color comment).
271
- const quoteFormat = ctx.mutedColor
272
- ? { color: ctx.mutedColor }
273
- : bodyFormat(ctx);
274
- const runs = convertInlines(child.children, ctx, quoteFormat);
275
- parts.push(`<w:p>` +
276
- `<w:pPr><w:pStyle w:val="Quote"/>` +
277
- `<w:ind w:left="${pointsToTwips(36)}"/>` +
278
- `<w:pBdr><w:left w:val="single" w:sz="12" w:space="4" w:color="CCCCCC"/></w:pBdr>` +
279
- `</w:pPr>` +
280
- runs +
281
- `</w:p>`);
282
- }
283
- else {
284
- // Nested non-paragraph (e.g., nested blockquote, list) — recurse
285
- parts.push(convertBlock(child, ctx, 0));
286
- }
287
- }
288
- return parts.join('');
289
- }
290
- function convertList(node, ctx, depth) {
291
- const numId = ctx.allocNumbering(node.ordered ?? false);
292
- const parts = [];
293
- for (const item of node.children) {
294
- parts.push(convertListItem(item, ctx, numId, depth));
295
- }
296
- return parts.join('');
297
- }
298
- function convertListItem(item, ctx, numId, depth) {
299
- const parts = [];
300
- for (const child of item.children) {
301
- if (child.type === 'paragraph') {
302
- const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
303
- parts.push(`<w:p>` +
304
- `<w:pPr>` +
305
- `<w:pStyle w:val="ListParagraph"/>` +
306
- `<w:numPr><w:ilvl w:val="${depth}"/><w:numId w:val="${numId}"/></w:numPr>` +
307
- `</w:pPr>` +
308
- runs +
309
- `</w:p>`);
310
- }
311
- else if (child.type === 'list') {
312
- // Nested list — increase depth
313
- parts.push(convertList(child, ctx, depth + 1));
314
- }
315
- else {
316
- parts.push(convertBlock(child, ctx, depth));
317
- }
318
- }
319
- return parts.join('');
320
- }
321
- function convertCodeBlock(node) {
322
- // Emit each line as a separate paragraph with code styling
323
- const lines = node.value.split('\n');
324
- const parts = [];
325
- for (const line of lines) {
326
- parts.push(`<w:p>` +
327
- `<w:pPr>` +
328
- `<w:pStyle w:val="Code"/>` +
329
- `<w:pBdr>` +
330
- `<w:top w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
331
- `<w:left w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
332
- `<w:bottom w:val="single" w:sz="4" w:space="1" w:color="CCCCCC"/>` +
333
- `<w:right w:val="single" w:sz="4" w:space="4" w:color="CCCCCC"/>` +
334
- `</w:pBdr>` +
335
- `<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>` +
336
- `</w:pPr>` +
337
- `<w:r>` +
338
- `<w:rPr><w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
339
- `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/></w:rPr>` +
340
- `<w:t xml:space="preserve">${escapeXml(line)}</w:t>` +
341
- `</w:r>` +
342
- `</w:p>`);
343
- }
344
- return parts.join('');
345
- }
346
- function convertTable(node, ctx) {
347
- const rows = [];
348
- for (let ri = 0; ri < node.children.length; ri++) {
349
- const row = node.children[ri];
350
- rows.push(convertTableRow(row, ctx, ri === 0, node.align));
351
- }
352
- return (`<w:tbl>` +
353
- `<w:tblPr>` +
354
- `<w:tblStyle w:val="TableGrid"/>` +
355
- `<w:tblW w:w="0" w:type="auto"/>` +
356
- `<w:tblBorders>` +
357
- `<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
358
- `<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
359
- `<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
360
- `<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
361
- `<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
362
- `<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
363
- `</w:tblBorders>` +
364
- `</w:tblPr>` +
365
- `<w:tblGrid/>` +
366
- rows.join('') +
367
- `</w:tbl>`);
368
- }
369
- function convertTableRow(row, ctx, isHeader, align) {
370
- const cells = [];
371
- for (let ci = 0; ci < row.children.length; ci++) {
372
- const cell = row.children[ci];
373
- const cellAlign = align?.[ci] ?? null;
374
- cells.push(convertTableCell(cell, ctx, isHeader, cellAlign));
375
- }
376
- const trPr = isHeader ? '<w:trPr><w:tblHeader/></w:trPr>' : '';
377
- return `<w:tr>${trPr}${cells.join('')}</w:tr>`;
378
- }
379
- function convertTableCell(cell, ctx, isHeader, align) {
380
- const runs = convertInlines(cell.children, ctx, bodyFormat(ctx));
381
- const rPr = isHeader ? '<w:rPr><w:b/></w:rPr>' : '';
382
- const jcMap = { left: 'left', center: 'center', right: 'right' };
383
- const jc = align ? `<w:jc w:val="${jcMap[align]}"/>` : '';
384
- const pPr = rPr || jc ? `<w:pPr>${jc}</w:pPr>` : '';
385
- return `<w:tc><w:p>${pPr}${runs}</w:p></w:tc>`;
386
- }
387
- function convertThematicBreak() {
388
- return (`<w:p>` +
389
- `<w:pPr>` +
390
- `<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr>` +
391
- `</w:pPr>` +
392
- `</w:p>`);
393
- }
394
- function convertHtmlBlock(node) {
395
- // Best-effort: extract text content from the HTML
396
- const text = stripHtmlTags(node.rawHtml);
397
- if (!text.trim())
398
- return '';
399
- return `<w:p><w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
400
- }
401
- function convertMathBlock(node) {
402
- // Emit as a styled paragraph with the raw LaTeX
403
- return (`<w:p>` +
404
- `<w:pPr><w:jc w:val="center"/></w:pPr>` +
405
- `<w:r>` +
406
- `<w:rPr><w:rFonts w:ascii="Cambria Math" w:hAnsi="Cambria Math"/><w:i/></w:rPr>` +
407
- `<w:t xml:space="preserve">${escapeXml(node.value)}</w:t>` +
408
- `</w:r>` +
409
- `</w:p>`);
410
- }
411
- function convertFootnoteDefinition(node, ctx) {
412
- const fnId = ctx.getFootnoteId(node.identifier);
413
- // Build the footnote body XML
414
- const bodyParts = [];
415
- for (const child of node.children) {
416
- if (child.type === 'paragraph') {
417
- const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
418
- bodyParts.push(`<w:p>${runs}</w:p>`);
419
- }
420
- }
421
- const footnoteXml = `<w:footnote w:id="${fnId}">` +
422
- (bodyParts.length > 0 ? bodyParts.join('') : `<w:p/>`) +
423
- `</w:footnote>`;
424
- ctx.footnotes.set(fnId, footnoteXml);
425
- // Don't emit anything in the main body for the definition
426
- return '';
427
- }
428
- function convertInlines(nodes, ctx, format = {}) {
429
- const parts = [];
430
- for (const node of nodes) {
431
- parts.push(convertInline(node, ctx, format));
432
- }
433
- return parts.join('');
434
- }
435
- function convertInline(node, ctx, format) {
436
- switch (node.type) {
437
- case 'text':
438
- return makeRun(node.value, format);
439
- case 'strong':
440
- return convertInlines(node.children, ctx, { ...format, bold: true });
441
- case 'emphasis':
442
- return convertInlines(node.children, ctx, { ...format, italic: true });
443
- case 'delete':
444
- return convertInlines(node.children, ctx, { ...format, strike: true });
445
- case 'inlineCode':
446
- return makeRun(node.value, { ...format, code: true });
447
- case 'link':
448
- return convertLink(node, ctx, format);
449
- case 'image':
450
- return convertImage(node, ctx);
451
- case 'break':
452
- return `<w:r><w:br/></w:r>`;
453
- case 'htmlInline':
454
- return makeRun(stripHtmlTags(node.rawHtml), format);
455
- case 'inlineMath':
456
- return makeRun(node.value, { ...format, code: true });
457
- case 'footnoteReference':
458
- return convertFootnoteRef(node, ctx);
459
- default:
460
- // linkReference, imageReference, textDirective — skip or emit plain
461
- return '';
462
- }
463
- }
464
- function makeRun(text, format) {
465
- if (!text)
466
- return '';
467
- const rPrParts = [];
468
- if (format.bold)
469
- rPrParts.push('<w:b/>');
470
- if (format.italic)
471
- rPrParts.push('<w:i/>');
472
- if (format.strike)
473
- rPrParts.push('<w:strike/>');
474
- if (format.color)
475
- rPrParts.push(`<w:color w:val="${format.color}"/>`);
476
- if (format.code) {
477
- rPrParts.push(`<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>`, `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>`);
478
- }
479
- const rPr = rPrParts.length > 0 ? `<w:rPr>${rPrParts.join('')}</w:rPr>` : '';
480
- // Use xml:space="preserve" to keep leading/trailing whitespace
481
- return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
482
- }
483
- function convertLink(node, ctx, format) {
484
- const rId = ctx.addHyperlink(node.url);
485
- const styledRuns = convertInlinesWithHyperlinkStyle(node.children, ctx, format);
486
- return `<w:hyperlink r:id="${rId}">${styledRuns}</w:hyperlink>`;
487
- }
488
- function convertInlinesWithHyperlinkStyle(nodes, ctx, format) {
489
- const parts = [];
490
- for (const node of nodes) {
491
- if (node.type === 'text') {
492
- parts.push(makeHyperlinkRun(node.value, format));
493
- }
494
- else {
495
- // For nested formatting inside links, add hyperlink style
496
- parts.push(convertInline(node, ctx, format));
497
- }
498
- }
499
- return parts.join('');
500
- }
501
- function makeHyperlinkRun(text, format) {
502
- if (!text)
503
- return '';
504
- const rPrParts = [
505
- '<w:rStyle w:val="Hyperlink"/>',
506
- `<w:color w:val="${HYPERLINK_COLOR}"/>`,
507
- '<w:u w:val="single"/>',
508
- ];
509
- if (format.bold)
510
- rPrParts.push('<w:b/>');
511
- if (format.italic)
512
- rPrParts.push('<w:i/>');
513
- return (`<w:r><w:rPr>${rPrParts.join('')}</w:rPr>` +
514
- `<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`);
515
- }
516
- function convertImage(node, ctx) {
517
- const imageEntry = ctx.resolvedImages.get(node.url);
518
- if (!imageEntry) {
519
- // No resolved data — emit placeholder text
520
- const alt = node.alt || node.url;
521
- return makeRun(`[Image: ${alt}]`, { italic: true });
522
- }
523
- const { data, contentType } = imageEntry;
524
- const ext = contentType.split('/')[1]?.replace('jpeg', 'jpg') || 'png';
525
- const filename = `image${ctx.images.length + 1}.${ext}`;
526
- const { relId, docPrId } = ctx.addImage(data, contentType, filename);
527
- // Read dimensions from binary header; fall back to 5×3 inches
528
- const dims = readImageDimensions(data);
529
- const EMU_PER_INCH = 914400;
530
- const MAX_WIDTH_EMU = 6 * EMU_PER_INCH; // 6 inch content width
531
- let cx;
532
- let cy;
533
- if (dims) {
534
- // Scale to fit within max width, assuming 96 DPI for pixel → inch
535
- const widthEmu = (dims.width / 96) * EMU_PER_INCH;
536
- const heightEmu = (dims.height / 96) * EMU_PER_INCH;
537
- if (widthEmu > MAX_WIDTH_EMU) {
538
- const scale = MAX_WIDTH_EMU / widthEmu;
539
- cx = MAX_WIDTH_EMU;
540
- cy = Math.round(heightEmu * scale);
541
- }
542
- else {
543
- cx = Math.round(widthEmu);
544
- cy = Math.round(heightEmu);
545
- }
546
- }
547
- else {
548
- cx = 5 * EMU_PER_INCH;
549
- cy = 3 * EMU_PER_INCH;
550
- }
551
- const name = escapeXml(node.alt || filename);
552
- const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
553
- const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
554
- return (`<w:r><w:drawing>` +
555
- `<wp:inline distT="0" distB="0" distL="0" distR="0">` +
556
- `<wp:extent cx="${cx}" cy="${cy}"/>` +
557
- `<wp:docPr id="${docPrId}" name="${name}"/>` +
558
- `<wp:cNvGraphicFramePr>` +
559
- `<a:graphicFrameLocks xmlns:a="${NS_A}" noChangeAspect="1"/>` +
560
- `</wp:cNvGraphicFramePr>` +
561
- `<a:graphic xmlns:a="${NS_A}">` +
562
- `<a:graphicData uri="${NS_PIC}">` +
563
- `<pic:pic xmlns:pic="${NS_PIC}">` +
564
- `<pic:nvPicPr>` +
565
- `<pic:cNvPr id="0" name="${name}"/>` +
566
- `<pic:cNvPicPr/>` +
567
- `</pic:nvPicPr>` +
568
- `<pic:blipFill>` +
569
- `<a:blip r:embed="${relId}"/>` +
570
- `<a:stretch><a:fillRect/></a:stretch>` +
571
- `</pic:blipFill>` +
572
- `<pic:spPr>` +
573
- `<a:xfrm>` +
574
- `<a:off x="0" y="0"/>` +
575
- `<a:ext cx="${cx}" cy="${cy}"/>` +
576
- `</a:xfrm>` +
577
- `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
578
- `</pic:spPr>` +
579
- `</pic:pic>` +
580
- `</a:graphicData>` +
581
- `</a:graphic>` +
582
- `</wp:inline>` +
583
- `</w:drawing></w:r>`);
584
- }
585
- /** Read width/height from PNG or JPEG binary headers. */
586
- function readImageDimensions(data) {
587
- const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
588
- if (bytes.length < 24)
589
- return null;
590
- // PNG: signature 0x89504E47, IHDR chunk at byte 16
591
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
592
- const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
593
- const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
594
- return { width, height };
595
- }
596
- // JPEG: search for SOF0 (0xFFC0) or SOF2 (0xFFC2) marker
597
- if (bytes[0] === 0xff && bytes[1] === 0xd8) {
598
- let offset = 2;
599
- while (offset < bytes.length - 9) {
600
- if (bytes[offset] !== 0xff)
601
- break;
602
- const marker = bytes[offset + 1];
603
- if (marker === 0xc0 || marker === 0xc2) {
604
- const height = (bytes[offset + 5] << 8) | bytes[offset + 6];
605
- const width = (bytes[offset + 7] << 8) | bytes[offset + 8];
606
- return { width, height };
607
- }
608
- const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
609
- offset += 2 + segLen;
610
- }
611
- }
612
- // GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
613
- if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
614
- const width = bytes[6] | (bytes[7] << 8);
615
- const height = bytes[8] | (bytes[9] << 8);
616
- return { width, height };
617
- }
618
- return null;
619
- }
620
- function convertFootnoteRef(node, ctx) {
621
- const fnId = ctx.getFootnoteId(node.identifier);
622
- return (`<w:r>` +
623
- `<w:rPr><w:rStyle w:val="FootnoteReference"/><w:vertAlign w:val="superscript"/></w:rPr>` +
624
- `<w:footnoteReference w:id="${fnId}"/>` +
625
- `</w:r>`);
626
- }
627
- // ============================================
628
- // Package Assembly
629
- // ============================================
630
- async function buildDocxPackage(bodyXml, ctx, options) {
631
- const pkg = createPackage();
632
- // --- Register fixed relationships ---
633
- let relCounter = 100; // Start high to avoid collisions with dynamic rels
634
- const stylesRelId = `rId${relCounter++}`;
635
- const numberingRelId = `rId${relCounter++}`;
636
- const settingsRelId = `rId${relCounter++}`;
637
- const fontTableRelId = `rId${relCounter++}`;
638
- const footnotesRelId = `rId${relCounter++}`;
639
- // --- word/document.xml ---
640
- const documentXml = buildDocumentXml(bodyXml, ctx.backgroundColor);
641
- pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
642
- // --- word/styles.xml ---
643
- const stylesXml = buildStylesXml(options, ctx);
644
- pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
645
- // --- word/settings.xml ---
646
- // `displayBackgroundShape` is what makes Word actually paint the
647
- // `<w:background>` element from document.xml on screen + print.
648
- // Without it the bg color is in the file but invisible.
649
- const settingsXml = buildSettingsXml(ctx.backgroundColor !== undefined);
650
- pkg.addPart('word/settings.xml', settingsXml, CONTENT_TYPE_DOCX_SETTINGS);
651
- // --- word/fontTable.xml ---
652
- const fontTableXml = buildFontTableXml(options);
653
- pkg.addPart('word/fontTable.xml', fontTableXml, CONTENT_TYPE_DOCX_FONT_TABLE);
654
- // --- word/numbering.xml (only if lists present) ---
655
- if (ctx.hasLists) {
656
- const numberingXml = buildNumberingXml(ctx);
657
- pkg.addPart('word/numbering.xml', numberingXml, CONTENT_TYPE_DOCX_NUMBERING);
658
- }
659
- // --- word/footnotes.xml (only if footnotes present) ---
660
- if (ctx.hasFootnotes) {
661
- const footnotesXml = buildFootnotesXml(ctx);
662
- pkg.addPart('word/footnotes.xml', footnotesXml, CONTENT_TYPE_DOCX_FOOTNOTES);
663
- }
664
- // --- Root relationship: this package contains a word document ---
665
- pkg.addRelationship('', {
666
- id: 'rId1',
667
- type: REL_OFFICE_DOCUMENT,
668
- target: 'word/document.xml',
669
- });
670
- // --- Document relationships ---
671
- pkg.addRelationship('word/document.xml', {
672
- id: stylesRelId,
673
- type: REL_STYLES,
674
- target: 'styles.xml',
675
- });
676
- pkg.addRelationship('word/document.xml', {
677
- id: settingsRelId,
678
- type: REL_SETTINGS,
679
- target: 'settings.xml',
680
- });
681
- pkg.addRelationship('word/document.xml', {
682
- id: fontTableRelId,
683
- type: REL_FONT_TABLE,
684
- target: 'fontTable.xml',
685
- });
686
- if (ctx.hasLists) {
687
- pkg.addRelationship('word/document.xml', {
688
- id: numberingRelId,
689
- type: REL_NUMBERING,
690
- target: 'numbering.xml',
691
- });
692
- }
693
- if (ctx.hasFootnotes) {
694
- pkg.addRelationship('word/document.xml', {
695
- id: footnotesRelId,
696
- type: REL_FOOTNOTES,
697
- target: 'footnotes.xml',
698
- });
699
- }
700
- // --- Dynamic relationships (hyperlinks, images) ---
701
- for (const rel of ctx.relationships) {
702
- pkg.addRelationship('word/document.xml', {
703
- id: rel.id,
704
- type: rel.type,
705
- target: rel.target,
706
- ...(rel.targetMode ? { targetMode: rel.targetMode } : {}),
707
- });
708
- }
709
- // --- Embedded images ---
710
- for (const img of ctx.images) {
711
- pkg.addBinaryPart(img.path, img.data, img.contentType);
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
- return pkg.toArrayBuffer();
724
- }
725
- // ============================================
726
- // XML Part Generators
727
- // ============================================
728
- function buildDocumentXml(bodyXml, backgroundColor) {
729
- // `<w:background>` must be the FIRST child of `<w:document>` per the
730
- // WordprocessingML schema. Word also requires `<w:displayBackgroundShape/>`
731
- // in settings.xml before the bg actually paints (see buildSettingsXml).
732
- const backgroundEl = backgroundColor ? `<w:background w:color="${backgroundColor}"/>` : '';
733
- return (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
- backgroundEl +
746
- `<w:body>` +
747
- bodyXml +
748
- `<w:sectPr>` +
749
- `<w:pgSz w:w="12240" w:h="15840"/>` +
750
- `<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>` +
751
- `<w:cols w:space="720"/>` +
752
- `</w:sectPr>` +
753
- `</w:body>` +
754
- `</w:document>`);
755
- }
756
- function buildStylesXml(options, ctx) {
757
- const font = ctx.font;
758
- const headingFont = ctx.headingFont;
759
- // Body color from the theme's `text`. We set it on BOTH the
760
- // `<w:rPrDefault>` (for any rogue run that doesn't inherit Normal) and
761
- // on the `Normal` style itself. Setting it only on rPrDefault was
762
- // experimentally insufficient — Word's resolved formatting in Print
763
- // Layout view ignored the docDefault color for direct-typed body
764
- // paragraphs even though headings (with explicit rPr) honored their
765
- // own color. Setting it on Normal puts the color one level higher in
766
- // the precedence chain (paragraph-style > docDefaults) so it sticks.
767
- const bodyColorXml = ctx.bodyColor ? `<w:color w:val="${ctx.bodyColor}"/>` : '';
768
- const quoteColor = ctx.mutedColor ?? '404040';
769
- return (xmlDeclaration() +
770
- `<w:styles xmlns:w="${NS_WML}">` +
771
- // Default run properties
772
- `<w:docDefaults>` +
773
- `<w:rPrDefault><w:rPr>` +
774
- `<w:rFonts w:ascii="${escapeXml(font)}" w:hAnsi="${escapeXml(font)}" w:eastAsia="${escapeXml(font)}" w:cs="${escapeXml(font)}"/>` +
775
- bodyColorXml +
776
- `<w:sz w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
777
- `<w:szCs w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
778
- `</w:rPr></w:rPrDefault>` +
779
- `<w:pPrDefault/>` +
780
- `</w:docDefaults>` +
781
- // Normal style — carries the body color explicitly so direct-typed
782
- // body paragraphs render in the theme's text color in every Word
783
- // view (not just Web Layout).
784
- `<w:style w:type="paragraph" w:default="1" w:styleId="Normal">` +
785
- `<w:name w:val="Normal"/>` +
786
- `<w:qFormat/>` +
787
- (bodyColorXml ? `<w:rPr>${bodyColorXml}</w:rPr>` : '') +
788
- `</w:style>` +
789
- // Heading styles
790
- buildHeadingStyles(headingFont, ctx.headingColor) +
791
- // Quote style — muted text color from theme.textMuted (falls back
792
- // to a neutral grey when no theme is supplied).
793
- `<w:style w:type="paragraph" w:styleId="Quote">` +
794
- `<w:name w:val="Quote"/>` +
795
- `<w:basedOn w:val="Normal"/>` +
796
- `<w:pPr><w:ind w:left="720"/></w:pPr>` +
797
- `<w:rPr><w:i/><w:color w:val="${quoteColor}"/></w:rPr>` +
798
- `</w:style>` +
799
- // Code style
800
- `<w:style w:type="paragraph" w:styleId="Code">` +
801
- `<w:name w:val="Code"/>` +
802
- `<w:basedOn w:val="Normal"/>` +
803
- `<w:rPr>` +
804
- `<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>` +
805
- `<w:sz w:val="${DEFAULT_CODE_FONT_SIZE}"/>` +
806
- `</w:rPr>` +
807
- `</w:style>` +
808
- // ListParagraph style
809
- `<w:style w:type="paragraph" w:styleId="ListParagraph">` +
810
- `<w:name w:val="List Paragraph"/>` +
811
- `<w:basedOn w:val="Normal"/>` +
812
- `<w:pPr><w:ind w:left="720"/></w:pPr>` +
813
- `</w:style>` +
814
- // Hyperlink character style
815
- `<w:style w:type="character" w:styleId="Hyperlink">` +
816
- `<w:name w:val="Hyperlink"/>` +
817
- `<w:rPr><w:color w:val="${HYPERLINK_COLOR}"/><w:u w:val="single"/></w:rPr>` +
818
- `</w:style>` +
819
- // FootnoteReference character style
820
- `<w:style w:type="character" w:styleId="FootnoteReference">` +
821
- `<w:name w:val="footnote reference"/>` +
822
- `<w:rPr><w:vertAlign w:val="superscript"/></w:rPr>` +
823
- `</w:style>` +
824
- // Table Grid style
825
- `<w:style w:type="table" w:styleId="TableGrid">` +
826
- `<w:name w:val="Table Grid"/>` +
827
- `<w:tblPr>` +
828
- `<w:tblBorders>` +
829
- `<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
830
- `<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
831
- `<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
832
- `<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
833
- `<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
834
- `<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
835
- `</w:tblBorders>` +
836
- `</w:tblPr>` +
837
- `</w:style>` +
838
- `</w:styles>`);
839
- }
840
- function buildHeadingStyles(headingFont, headingColor) {
841
- let result = '';
842
- for (let depth = 1; depth <= 6; depth++) {
843
- const styleId = DEPTH_TO_STYLE_ID[depth];
844
- const fontSize = HEADING_FONT_SIZES[depth] ?? 22;
845
- const colorXml = headingColor ? `<w:color w:val="${headingColor}"/>` : '';
846
- result +=
847
- `<w:style w:type="paragraph" w:styleId="${styleId}">` +
848
- `<w:name w:val="heading ${depth}"/>` +
849
- `<w:basedOn w:val="Normal"/>` +
850
- `<w:next w:val="Normal"/>` +
851
- `<w:qFormat/>` +
852
- `<w:pPr><w:outlineLvl w:val="${depth - 1}"/><w:spacing w:before="240" w:after="60"/></w:pPr>` +
853
- `<w:rPr>` +
854
- `<w:rFonts w:ascii="${escapeXml(headingFont)}" w:hAnsi="${escapeXml(headingFont)}"/>` +
855
- `<w:b/>` +
856
- colorXml +
857
- `<w:sz w:val="${fontSize}"/>` +
858
- `<w:szCs w:val="${fontSize}"/>` +
859
- `</w:rPr>` +
860
- `</w:style>`;
861
- }
862
- return result;
863
- }
864
- function buildSettingsXml(displayBackground) {
865
- // `<w:compat>` with `compatibilityMode=15` is what tells Word "this
866
- // is a Word 2016+ .docx, render with modern features." Without it,
867
- // Word opens the file in Compatibility Mode and silently disables
868
- // newer behaviors — including page-background rendering in Print
869
- // Layout view and several default style precedence rules. The other
870
- // compatSetting flags match what Word 2016+ writes by default.
871
- const compatXml = `<w:compat>` +
872
- `<w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/>` +
873
- `<w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
874
- `<w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
875
- `<w:compatSetting w:name="doNotFlipMirrorIndents" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
876
- `<w:compatSetting w:name="differentiateMultirowTableHeaders" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
877
- `</w:compat>`;
878
- // Element order inside `<w:settings>` matters — ECMA-376 defines a
879
- // strict sequence (`displayBackgroundShape` → `defaultTabStop` →
880
- // `characterSpacingControl` → … → `compat`). Word silently drops
881
- // out-of-order children, which is why `<w:background>` looks correct
882
- // in the file but doesn't paint when opened.
883
- return (xmlDeclaration() +
884
- `<w:settings xmlns:w="${NS_WML}">` +
885
- (displayBackground ? `<w:displayBackgroundShape/>` : '') +
886
- `<w:defaultTabStop w:val="720"/>` +
887
- `<w:characterSpacingControl w:val="doNotCompress"/>` +
888
- compatXml +
889
- `</w:settings>`);
890
- }
891
- function buildFontTableXml(options) {
892
- const font = options.defaultFont ?? DEFAULT_FONT;
893
- return (xmlDeclaration() +
894
- `<w:fonts xmlns:w="${NS_WML}">` +
895
- `<w:font w:name="${escapeXml(font)}">` +
896
- `<w:panose1 w:val="020F0502020204030204"/>` +
897
- `<w:charset w:val="00"/>` +
898
- `<w:family w:val="swiss"/>` +
899
- `<w:pitch w:val="variable"/>` +
900
- `</w:font>` +
901
- `<w:font w:name="${DEFAULT_HEADING_FONT}">` +
902
- `<w:panose1 w:val="020F0302020204030204"/>` +
903
- `<w:charset w:val="00"/>` +
904
- `<w:family w:val="swiss"/>` +
905
- `<w:pitch w:val="variable"/>` +
906
- `</w:font>` +
907
- `<w:font w:name="${DEFAULT_CODE_FONT}">` +
908
- `<w:charset w:val="00"/>` +
909
- `<w:family w:val="modern"/>` +
910
- `<w:pitch w:val="fixed"/>` +
911
- `</w:font>` +
912
- `</w:fonts>`);
913
- }
914
- function buildNumberingXml(ctx) {
915
- const abstract = [];
916
- const concrete = [];
917
- for (const def of ctx.numberingDefs) {
918
- const absId = def.numId;
919
- const levels = [];
920
- for (let lvl = 0; lvl < 9; lvl++) {
921
- if (def.ordered) {
922
- levels.push(`<w:lvl w:ilvl="${lvl}">` +
923
- `<w:start w:val="1"/>` +
924
- `<w:numFmt w:val="decimal"/>` +
925
- `<w:lvlText w:val="%${lvl + 1}."/>` +
926
- `<w:lvlJc w:val="left"/>` +
927
- `<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
928
- `</w:lvl>`);
929
- }
930
- else {
931
- const bullets = ['\u2022', '\u25E6', '\u25AA']; // •, ◦, ▪
932
- const bullet = bullets[lvl % bullets.length];
933
- levels.push(`<w:lvl w:ilvl="${lvl}">` +
934
- `<w:start w:val="1"/>` +
935
- `<w:numFmt w:val="bullet"/>` +
936
- `<w:lvlText w:val="${bullet}"/>` +
937
- `<w:lvlJc w:val="left"/>` +
938
- `<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
939
- // Bullet glyph renders in the document's body font, not
940
- // Word's legacy `Symbol` font. `Symbol` only maps codes
941
- // 0x20-0xFF to Greek/math glyphs, so the Unicode bullets
942
- // we use (U+2022, U+25E6, U+25AA) come out as "tofu"
943
- // empty boxes when forced into Symbol. Every modern OS
944
- // font has these codepoints, so omitting the font
945
- // override gets a real bullet.
946
- `</w:lvl>`);
947
- }
948
- }
949
- abstract.push(`<w:abstractNum w:abstractNumId="${absId}">` + levels.join('') + `</w:abstractNum>`);
950
- concrete.push(`<w:num w:numId="${def.numId}">` + `<w:abstractNumId w:val="${absId}"/>` + `</w:num>`);
951
- }
952
- return (xmlDeclaration() +
953
- `<w:numbering xmlns:w="${NS_WML}">` +
954
- abstract.join('') +
955
- concrete.join('') +
956
- `</w:numbering>`);
957
- }
958
- function buildFootnotesXml(ctx) {
959
- const footnotes = [];
960
- // Separator and continuation separator (required by Word)
961
- footnotes.push(`<w:footnote w:type="separator" w:id="-1">` +
962
- `<w:p><w:r><w:separator/></w:r></w:p>` +
963
- `</w:footnote>`);
964
- footnotes.push(`<w:footnote w:type="continuationSeparator" w:id="0">` +
965
- `<w:p><w:r><w:continuationSeparator/></w:r></w:p>` +
966
- `</w:footnote>`);
967
- // User footnotes
968
- for (const [_id, xml] of ctx.footnotes) {
969
- footnotes.push(xml);
970
- }
971
- return (xmlDeclaration() +
972
- `<w:footnotes xmlns:w="${NS_WML}" xmlns:r="${NS_R}">` +
973
- footnotes.join('') +
974
- `</w:footnotes>`);
975
- }
976
- // ============================================
977
- // Helpers
978
- // ============================================
979
- /**
980
- * Strip HTML tags from a string, keeping only text content.
981
- */
982
- function stripHtmlTags(html) {
983
- return html.replace(/<[^>]*>/g, '');
984
- }
985
- //# sourceMappingURL=export.js.map