@bendyline/squisq-formats 1.2.3 → 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 (166) hide show
  1. package/dist/chunk-33YRFXZZ.js +1187 -0
  2. package/dist/chunk-33YRFXZZ.js.map +1 -0
  3. package/dist/{chunk-EUQQYBZZ.js → chunk-ERZ627GR.js} +4 -4
  4. package/dist/chunk-ERZ627GR.js.map +1 -0
  5. package/dist/{chunk-MEZF76JA.js → chunk-UDS45KUJ.js} +68 -25
  6. package/dist/chunk-UDS45KUJ.js.map +1 -0
  7. package/dist/{chunk-NGWHV77G.js → chunk-VN2KEOYB.js} +7 -6
  8. package/dist/chunk-VN2KEOYB.js.map +1 -0
  9. package/dist/container/index.d.ts +7 -5
  10. package/dist/container/index.js +39 -70
  11. package/dist/container/index.js.map +1 -1
  12. package/dist/docx/index.d.ts +128 -15
  13. package/dist/docx/index.js +17 -22
  14. package/dist/docx/index.js.map +1 -1
  15. package/dist/epub/index.d.ts +66 -11
  16. package/dist/epub/index.js +10 -18
  17. package/dist/epub/index.js.map +1 -1
  18. package/dist/html/index.d.ts +278 -9
  19. package/dist/html/index.js +26 -137
  20. package/dist/html/index.js.map +1 -1
  21. package/dist/index.d.ts +9 -38
  22. package/dist/index.js +62 -37
  23. package/dist/index.js.map +1 -1
  24. package/dist/ooxml/index.d.ts +353 -14
  25. package/dist/ooxml/index.js +116 -22
  26. package/dist/ooxml/index.js.map +1 -1
  27. package/dist/pdf/index.d.ts +143 -17
  28. package/dist/pdf/index.js +16 -25
  29. package/dist/pdf/index.js.map +1 -1
  30. package/dist/pptx/index.d.ts +83 -8
  31. package/dist/pptx/index.js +14 -34
  32. package/dist/pptx/index.js.map +1 -1
  33. package/dist/xlsx/index.d.ts +12 -9
  34. package/dist/xlsx/index.js +12 -44
  35. package/dist/xlsx/index.js.map +1 -1
  36. package/package.json +3 -2
  37. package/src/__tests__/html.test.ts +82 -4
  38. package/src/__tests__/pdfExport.test.ts +1 -1
  39. package/src/__tests__/plainHtml.test.ts +372 -0
  40. package/src/__tests__/plainHtmlBundle.test.ts +235 -0
  41. package/src/docx/export.ts +163 -22
  42. package/src/epub/export.ts +3 -3
  43. package/src/html/docsHtmlBundle.ts +369 -0
  44. package/src/html/htmlTemplate.ts +40 -0
  45. package/src/html/index.ts +32 -3
  46. package/src/html/plainHtml.ts +736 -0
  47. package/src/html/plainHtmlBundle.ts +419 -0
  48. package/src/pptx/export.ts +10 -9
  49. package/dist/__tests__/container.test.d.ts +0 -8
  50. package/dist/__tests__/container.test.d.ts.map +0 -1
  51. package/dist/__tests__/container.test.js +0 -147
  52. package/dist/__tests__/container.test.js.map +0 -1
  53. package/dist/__tests__/docxExport.test.d.ts +0 -8
  54. package/dist/__tests__/docxExport.test.d.ts.map +0 -1
  55. package/dist/__tests__/docxExport.test.js +0 -383
  56. package/dist/__tests__/docxExport.test.js.map +0 -1
  57. package/dist/__tests__/docxImport.test.d.ts +0 -8
  58. package/dist/__tests__/docxImport.test.d.ts.map +0 -1
  59. package/dist/__tests__/docxImport.test.js +0 -309
  60. package/dist/__tests__/docxImport.test.js.map +0 -1
  61. package/dist/__tests__/epub.test.d.ts +0 -8
  62. package/dist/__tests__/epub.test.d.ts.map +0 -1
  63. package/dist/__tests__/epub.test.js +0 -472
  64. package/dist/__tests__/epub.test.js.map +0 -1
  65. package/dist/__tests__/html.test.d.ts +0 -8
  66. package/dist/__tests__/html.test.d.ts.map +0 -1
  67. package/dist/__tests__/html.test.js +0 -243
  68. package/dist/__tests__/html.test.js.map +0 -1
  69. package/dist/__tests__/ooxml.test.d.ts +0 -5
  70. package/dist/__tests__/ooxml.test.d.ts.map +0 -1
  71. package/dist/__tests__/ooxml.test.js +0 -152
  72. package/dist/__tests__/ooxml.test.js.map +0 -1
  73. package/dist/__tests__/pdfExport.test.d.ts +0 -8
  74. package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
  75. package/dist/__tests__/pdfExport.test.js +0 -275
  76. package/dist/__tests__/pdfExport.test.js.map +0 -1
  77. package/dist/__tests__/pdfImport.test.d.ts +0 -11
  78. package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
  79. package/dist/__tests__/pdfImport.test.js +0 -254
  80. package/dist/__tests__/pdfImport.test.js.map +0 -1
  81. package/dist/__tests__/roundTrip.test.d.ts +0 -7
  82. package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
  83. package/dist/__tests__/roundTrip.test.js +0 -173
  84. package/dist/__tests__/roundTrip.test.js.map +0 -1
  85. package/dist/chunk-EUQQYBZZ.js.map +0 -1
  86. package/dist/chunk-MEZF76JA.js.map +0 -1
  87. package/dist/chunk-NGWHV77G.js.map +0 -1
  88. package/dist/chunk-UM5V2XZG.js +0 -242
  89. package/dist/chunk-UM5V2XZG.js.map +0 -1
  90. package/dist/container/index.d.ts.map +0 -1
  91. package/dist/docx/export.d.ts +0 -69
  92. package/dist/docx/export.d.ts.map +0 -1
  93. package/dist/docx/export.js +0 -854
  94. package/dist/docx/export.js.map +0 -1
  95. package/dist/docx/import.d.ts +0 -65
  96. package/dist/docx/import.d.ts.map +0 -1
  97. package/dist/docx/import.js +0 -779
  98. package/dist/docx/import.js.map +0 -1
  99. package/dist/docx/index.d.ts.map +0 -1
  100. package/dist/docx/styles.d.ts +0 -68
  101. package/dist/docx/styles.d.ts.map +0 -1
  102. package/dist/docx/styles.js +0 -125
  103. package/dist/docx/styles.js.map +0 -1
  104. package/dist/epub/export.d.ts +0 -72
  105. package/dist/epub/export.d.ts.map +0 -1
  106. package/dist/epub/export.js +0 -698
  107. package/dist/epub/export.js.map +0 -1
  108. package/dist/epub/index.d.ts.map +0 -1
  109. package/dist/html/htmlTemplate.d.ts +0 -70
  110. package/dist/html/htmlTemplate.d.ts.map +0 -1
  111. package/dist/html/htmlTemplate.js +0 -239
  112. package/dist/html/htmlTemplate.js.map +0 -1
  113. package/dist/html/imageUtils.d.ts +0 -29
  114. package/dist/html/imageUtils.d.ts.map +0 -1
  115. package/dist/html/imageUtils.js +0 -63
  116. package/dist/html/imageUtils.js.map +0 -1
  117. package/dist/html/index.d.ts.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/ooxml/index.d.ts.map +0 -1
  120. package/dist/ooxml/namespaces.d.ts +0 -77
  121. package/dist/ooxml/namespaces.d.ts.map +0 -1
  122. package/dist/ooxml/namespaces.js +0 -104
  123. package/dist/ooxml/namespaces.js.map +0 -1
  124. package/dist/ooxml/reader.d.ts +0 -52
  125. package/dist/ooxml/reader.d.ts.map +0 -1
  126. package/dist/ooxml/reader.js +0 -190
  127. package/dist/ooxml/reader.js.map +0 -1
  128. package/dist/ooxml/types.d.ts +0 -82
  129. package/dist/ooxml/types.d.ts.map +0 -1
  130. package/dist/ooxml/types.js +0 -9
  131. package/dist/ooxml/types.js.map +0 -1
  132. package/dist/ooxml/writer.d.ts +0 -71
  133. package/dist/ooxml/writer.d.ts.map +0 -1
  134. package/dist/ooxml/writer.js +0 -200
  135. package/dist/ooxml/writer.js.map +0 -1
  136. package/dist/ooxml/xmlUtils.d.ts +0 -76
  137. package/dist/ooxml/xmlUtils.d.ts.map +0 -1
  138. package/dist/ooxml/xmlUtils.js +0 -108
  139. package/dist/ooxml/xmlUtils.js.map +0 -1
  140. package/dist/pdf/export.d.ts +0 -56
  141. package/dist/pdf/export.d.ts.map +0 -1
  142. package/dist/pdf/export.js +0 -768
  143. package/dist/pdf/export.js.map +0 -1
  144. package/dist/pdf/import.d.ts +0 -94
  145. package/dist/pdf/import.d.ts.map +0 -1
  146. package/dist/pdf/import.js +0 -796
  147. package/dist/pdf/import.js.map +0 -1
  148. package/dist/pdf/index.d.ts.map +0 -1
  149. package/dist/pdf/styles.d.ts +0 -142
  150. package/dist/pdf/styles.d.ts.map +0 -1
  151. package/dist/pdf/styles.js +0 -145
  152. package/dist/pdf/styles.js.map +0 -1
  153. package/dist/pptx/export.d.ts +0 -75
  154. package/dist/pptx/export.d.ts.map +0 -1
  155. package/dist/pptx/export.js +0 -722
  156. package/dist/pptx/export.js.map +0 -1
  157. package/dist/pptx/index.d.ts.map +0 -1
  158. package/dist/pptx/styles.d.ts +0 -54
  159. package/dist/pptx/styles.d.ts.map +0 -1
  160. package/dist/pptx/styles.js +0 -73
  161. package/dist/pptx/styles.js.map +0 -1
  162. package/dist/pptx/templates.d.ts +0 -24
  163. package/dist/pptx/templates.d.ts.map +0 -1
  164. package/dist/pptx/templates.js +0 -158
  165. package/dist/pptx/templates.js.map +0 -1
  166. package/dist/xlsx/index.d.ts.map +0 -1
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
- import { resolveTheme } from '@bendyline/squisq/schemas';
21
+ import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
22
22
  import { docToMarkdown } from '@bendyline/squisq/doc';
23
23
  import type {
24
24
  MarkdownDocument,
@@ -40,6 +40,7 @@ import type {
40
40
  MarkdownImage,
41
41
  MarkdownFootnoteReference,
42
42
  } from '@bendyline/squisq/markdown';
43
+ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
43
44
 
44
45
  import { createPackage } from '../ooxml/writer.js';
45
46
  import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
@@ -117,9 +118,17 @@ export async function markdownDocToDocx(
117
118
  doc: MarkdownDocument,
118
119
  options: DocxExportOptions = {},
119
120
  ): Promise<ArrayBuffer> {
120
- const ctx = new ExportContext(options);
121
+ // Mirror the PPTX export: fall back to the doc's frontmatter themeId
122
+ // when the caller didn't pass one explicitly. Lets the editor's
123
+ // `squisq-theme: …` frontmatter flow straight through without each
124
+ // host wiring its own resolution.
125
+ const resolvedOptions: DocxExportOptions =
126
+ options.themeId !== undefined
127
+ ? options
128
+ : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
129
+ const ctx = new ExportContext(resolvedOptions);
121
130
  const bodyXml = convertBlocks(doc.children, ctx);
122
- return buildDocxPackage(bodyXml, ctx, options);
131
+ return buildDocxPackage(bodyXml, ctx, resolvedOptions);
123
132
  }
124
133
 
125
134
  /**
@@ -185,6 +194,12 @@ class ExportContext {
185
194
  readonly fontSize: number;
186
195
  /** Heading text color (hex without #), or undefined for default */
187
196
  readonly headingColor: string | undefined;
197
+ /** Body text color (hex without #), or undefined for default */
198
+ readonly bodyColor: string | undefined;
199
+ /** Muted text color (hex without #), used by Quote style. Undefined for default. */
200
+ readonly mutedColor: string | undefined;
201
+ /** Page background color (hex without #), or undefined for default white. */
202
+ readonly backgroundColor: string | undefined;
188
203
 
189
204
  /** Pre-resolved image data keyed by markdown image URL */
190
205
  readonly resolvedImages: Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>;
@@ -195,15 +210,26 @@ class ExportContext {
195
210
  let themeFont: string | undefined;
196
211
  let themeTitleFont: string | undefined;
197
212
  let themeHeadingColor: string | undefined;
213
+ let themeBodyColor: string | undefined;
214
+ let themeMutedColor: string | undefined;
215
+ let themeBackgroundColor: string | undefined;
198
216
 
199
217
  if (options.themeId) {
200
218
  const theme: Theme = resolveTheme(options.themeId);
201
- themeFont = theme.typography?.bodyFontFamily;
202
- themeTitleFont = theme.typography?.titleFontFamily;
203
- if (theme.colors?.primary) {
204
- const c = theme.colors.primary;
205
- themeHeadingColor = c.startsWith('#') ? c.slice(1) : c;
206
- }
219
+ // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
220
+ // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
221
+ // single font name — passing the whole stack is treated as a
222
+ // bogus literal name and Word falls through to Calibri. Take the
223
+ // first concrete name from the stack so Word can resolve it (or
224
+ // gracefully fall back to its own default if the font isn't
225
+ // installed on the reader's machine).
226
+ themeFont = firstFontFromStack(resolveFontFamily(theme.typography?.bodyFont, ''));
227
+ themeTitleFont = firstFontFromStack(resolveFontFamily(theme.typography?.titleFont, ''));
228
+ const stripHash = (c: string) => (c.startsWith('#') ? c.slice(1) : c);
229
+ if (theme.colors?.primary) themeHeadingColor = stripHash(theme.colors.primary);
230
+ if (theme.colors?.text) themeBodyColor = stripHash(theme.colors.text);
231
+ if (theme.colors?.textMuted) themeMutedColor = stripHash(theme.colors.textMuted);
232
+ if (theme.colors?.background) themeBackgroundColor = stripHash(theme.colors.background);
207
233
  }
208
234
 
209
235
  this.font = options.defaultFont ?? themeFont ?? DEFAULT_FONT;
@@ -212,6 +238,9 @@ class ExportContext {
212
238
  ? options.defaultFontSize * 2
213
239
  : DEFAULT_FONT_SIZE_HALF_POINTS;
214
240
  this.headingColor = themeHeadingColor;
241
+ this.bodyColor = themeBodyColor;
242
+ this.mutedColor = themeMutedColor;
243
+ this.backgroundColor = themeBackgroundColor;
215
244
  this.resolvedImages = options.images ?? new Map();
216
245
  }
217
246
 
@@ -277,6 +306,42 @@ interface NumberingDef {
277
306
  ordered: boolean;
278
307
  }
279
308
 
309
+ /**
310
+ * Pull the first concrete font name out of a CSS-style stack.
311
+ *
312
+ * `resolveFontFamily` returns CSS values like `"Oswald", Impact,
313
+ * "Arial Black", sans-serif`. Word's `w:ascii` / `w:hAnsi` attributes
314
+ * expect a single font name — feeding the whole stack causes Word to
315
+ * fall back to its default font instead of any of the named faces.
316
+ * We pick the first non-generic name (skipping `sans-serif`, `serif`,
317
+ * `monospace`, etc.) and strip the surrounding quotes that CSS lets
318
+ * you wrap multi-word family names in.
319
+ */
320
+ function firstFontFromStack(stack: string): string {
321
+ if (!stack) return '';
322
+ const generics = new Set([
323
+ 'sans-serif',
324
+ 'serif',
325
+ 'monospace',
326
+ 'system-ui',
327
+ 'cursive',
328
+ 'fantasy',
329
+ 'ui-serif',
330
+ 'ui-sans-serif',
331
+ 'ui-monospace',
332
+ 'ui-rounded',
333
+ 'inherit',
334
+ 'initial',
335
+ ]);
336
+ for (const raw of stack.split(',')) {
337
+ const name = raw.trim().replace(/^["']|["']$/g, '');
338
+ if (!name) continue;
339
+ if (generics.has(name.toLowerCase())) continue;
340
+ return name;
341
+ }
342
+ return '';
343
+ }
344
+
280
345
  // ============================================
281
346
  // Block Conversion
282
347
  // ============================================
@@ -324,16 +389,33 @@ function convertHeading(node: MarkdownHeading, ctx: ExportContext): string {
324
389
  }
325
390
 
326
391
  function convertParagraph(node: MarkdownParagraph, ctx: ExportContext): string {
327
- const runs = convertInlines(node.children, ctx);
392
+ // Force body color on every run rather than relying on `Normal` /
393
+ // `rPrDefault` style inheritance — see InlineFormat.color comment.
394
+ // Headings render via `convertHeading` and skip this path so the
395
+ // heading style's own color wins.
396
+ const runs = convertInlines(node.children, ctx, bodyFormat(ctx));
328
397
  return `<w:p>${runs}</w:p>`;
329
398
  }
330
399
 
400
+ /** Default inline format for body-text contexts (paragraphs, list items,
401
+ * blockquote bodies, table cells). Carries the theme body color so runs
402
+ * render in the right color regardless of style-resolution quirks. */
403
+ function bodyFormat(ctx: ExportContext): InlineFormat {
404
+ return ctx.bodyColor ? { color: ctx.bodyColor } : {};
405
+ }
406
+
331
407
  function convertBlockquote(node: MarkdownBlockquote, ctx: ExportContext): string {
332
408
  // Render each child block as a paragraph with Quote style
333
409
  const parts: string[] = [];
334
410
  for (const child of node.children) {
335
411
  if (child.type === 'paragraph') {
336
- const runs = convertInlines(child.children, ctx);
412
+ // Use the theme's muted color when set (Quote style already
413
+ // declares it but explicit run-level color survives Word's
414
+ // style overrides — see InlineFormat.color comment).
415
+ const quoteFormat: InlineFormat = ctx.mutedColor
416
+ ? { color: ctx.mutedColor }
417
+ : bodyFormat(ctx);
418
+ const runs = convertInlines(child.children, ctx, quoteFormat);
337
419
  parts.push(
338
420
  `<w:p>` +
339
421
  `<w:pPr><w:pStyle w:val="Quote"/>` +
@@ -369,7 +451,7 @@ function convertListItem(
369
451
  const parts: string[] = [];
370
452
  for (const child of item.children) {
371
453
  if (child.type === 'paragraph') {
372
- const runs = convertInlines(child.children, ctx);
454
+ const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
373
455
  parts.push(
374
456
  `<w:p>` +
375
457
  `<w:pPr>` +
@@ -466,7 +548,7 @@ function convertTableCell(
466
548
  isHeader: boolean,
467
549
  align: 'left' | 'right' | 'center' | null,
468
550
  ): string {
469
- const runs = convertInlines(cell.children, ctx);
551
+ const runs = convertInlines(cell.children, ctx, bodyFormat(ctx));
470
552
  const rPr = isHeader ? '<w:rPr><w:b/></w:rPr>' : '';
471
553
  const jcMap = { left: 'left', center: 'center', right: 'right' };
472
554
  const jc = align ? `<w:jc w:val="${jcMap[align]}"/>` : '';
@@ -511,7 +593,7 @@ function convertFootnoteDefinition(node: MarkdownFootnoteDefinition, ctx: Export
511
593
  const bodyParts: string[] = [];
512
594
  for (const child of node.children) {
513
595
  if (child.type === 'paragraph') {
514
- const runs = convertInlines(child.children, ctx);
596
+ const runs = convertInlines(child.children, ctx, bodyFormat(ctx));
515
597
  bodyParts.push(`<w:p>${runs}</w:p>`);
516
598
  }
517
599
  }
@@ -539,6 +621,14 @@ interface InlineFormat {
539
621
  italic?: boolean;
540
622
  strike?: boolean;
541
623
  code?: boolean;
624
+ /**
625
+ * Explicit run color (hex without `#`). When set, every emitted run
626
+ * gets `<w:color>` directly in its `<w:rPr>` — bypassing Word's style
627
+ * inheritance, which some Word configurations silently override.
628
+ * Body paragraphs pass `ctx.bodyColor` here; headings leave it unset
629
+ * so the heading style's own color wins.
630
+ */
631
+ color?: string;
542
632
  }
543
633
 
544
634
  function convertInlines(
@@ -590,6 +680,7 @@ function makeRun(text: string, format: InlineFormat): string {
590
680
  if (format.bold) rPrParts.push('<w:b/>');
591
681
  if (format.italic) rPrParts.push('<w:i/>');
592
682
  if (format.strike) rPrParts.push('<w:strike/>');
683
+ if (format.color) rPrParts.push(`<w:color w:val="${format.color}"/>`);
593
684
  if (format.code) {
594
685
  rPrParts.push(
595
686
  `<w:rFonts w:ascii="${DEFAULT_CODE_FONT}" w:hAnsi="${DEFAULT_CODE_FONT}"/>`,
@@ -789,7 +880,7 @@ async function buildDocxPackage(
789
880
  const footnotesRelId = `rId${relCounter++}`;
790
881
 
791
882
  // --- word/document.xml ---
792
- const documentXml = buildDocumentXml(bodyXml);
883
+ const documentXml = buildDocumentXml(bodyXml, ctx.backgroundColor);
793
884
  pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
794
885
 
795
886
  // --- word/styles.xml ---
@@ -797,7 +888,10 @@ async function buildDocxPackage(
797
888
  pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
798
889
 
799
890
  // --- word/settings.xml ---
800
- const settingsXml = buildSettingsXml();
891
+ // `displayBackgroundShape` is what makes Word actually paint the
892
+ // `<w:background>` element from document.xml on screen + print.
893
+ // Without it the bg color is in the file but invisible.
894
+ const settingsXml = buildSettingsXml(ctx.backgroundColor !== undefined);
801
895
  pkg.addPart('word/settings.xml', settingsXml, CONTENT_TYPE_DOCX_SETTINGS);
802
896
 
803
897
  // --- word/fontTable.xml ---
@@ -889,7 +983,11 @@ async function buildDocxPackage(
889
983
  // XML Part Generators
890
984
  // ============================================
891
985
 
892
- function buildDocumentXml(bodyXml: string): string {
986
+ function buildDocumentXml(bodyXml: string, backgroundColor?: string): string {
987
+ // `<w:background>` must be the FIRST child of `<w:document>` per the
988
+ // WordprocessingML schema. Word also requires `<w:displayBackgroundShape/>`
989
+ // in settings.xml before the bg actually paints (see buildSettingsXml).
990
+ const backgroundEl = backgroundColor ? `<w:background w:color="${backgroundColor}"/>` : '';
893
991
  return (
894
992
  xmlDeclaration() +
895
993
  `<w:document` +
@@ -903,6 +1001,7 @@ function buildDocumentXml(bodyXml: string): string {
903
1001
  ` xmlns:w10="urn:schemas-microsoft-com:office:word"` +
904
1002
  ` xmlns:w="${NS_WML}"` +
905
1003
  ` xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">` +
1004
+ backgroundEl +
906
1005
  `<w:body>` +
907
1006
  bodyXml +
908
1007
  `<w:sectPr>` +
@@ -918,6 +1017,16 @@ function buildDocumentXml(bodyXml: string): string {
918
1017
  function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string {
919
1018
  const font = ctx.font;
920
1019
  const headingFont = ctx.headingFont;
1020
+ // Body color from the theme's `text`. We set it on BOTH the
1021
+ // `<w:rPrDefault>` (for any rogue run that doesn't inherit Normal) and
1022
+ // on the `Normal` style itself. Setting it only on rPrDefault was
1023
+ // experimentally insufficient — Word's resolved formatting in Print
1024
+ // Layout view ignored the docDefault color for direct-typed body
1025
+ // paragraphs even though headings (with explicit rPr) honored their
1026
+ // own color. Setting it on Normal puts the color one level higher in
1027
+ // the precedence chain (paragraph-style > docDefaults) so it sticks.
1028
+ const bodyColorXml = ctx.bodyColor ? `<w:color w:val="${ctx.bodyColor}"/>` : '';
1029
+ const quoteColor = ctx.mutedColor ?? '404040';
921
1030
 
922
1031
  return (
923
1032
  xmlDeclaration() +
@@ -926,24 +1035,29 @@ function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string
926
1035
  `<w:docDefaults>` +
927
1036
  `<w:rPrDefault><w:rPr>` +
928
1037
  `<w:rFonts w:ascii="${escapeXml(font)}" w:hAnsi="${escapeXml(font)}" w:eastAsia="${escapeXml(font)}" w:cs="${escapeXml(font)}"/>` +
1038
+ bodyColorXml +
929
1039
  `<w:sz w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
930
1040
  `<w:szCs w:val="${DEFAULT_FONT_SIZE_HALF_POINTS}"/>` +
931
1041
  `</w:rPr></w:rPrDefault>` +
932
1042
  `<w:pPrDefault/>` +
933
1043
  `</w:docDefaults>` +
934
- // Normal style
1044
+ // Normal style — carries the body color explicitly so direct-typed
1045
+ // body paragraphs render in the theme's text color in every Word
1046
+ // view (not just Web Layout).
935
1047
  `<w:style w:type="paragraph" w:default="1" w:styleId="Normal">` +
936
1048
  `<w:name w:val="Normal"/>` +
937
1049
  `<w:qFormat/>` +
1050
+ (bodyColorXml ? `<w:rPr>${bodyColorXml}</w:rPr>` : '') +
938
1051
  `</w:style>` +
939
1052
  // Heading styles
940
1053
  buildHeadingStyles(headingFont, ctx.headingColor) +
941
- // Quote style
1054
+ // Quote style — muted text color from theme.textMuted (falls back
1055
+ // to a neutral grey when no theme is supplied).
942
1056
  `<w:style w:type="paragraph" w:styleId="Quote">` +
943
1057
  `<w:name w:val="Quote"/>` +
944
1058
  `<w:basedOn w:val="Normal"/>` +
945
1059
  `<w:pPr><w:ind w:left="720"/></w:pPr>` +
946
- `<w:rPr><w:i/><w:color w:val="404040"/></w:rPr>` +
1060
+ `<w:rPr><w:i/><w:color w:val="${quoteColor}"/></w:rPr>` +
947
1061
  `</w:style>` +
948
1062
  // Code style
949
1063
  `<w:style w:type="paragraph" w:styleId="Code">` +
@@ -1013,12 +1127,33 @@ function buildHeadingStyles(headingFont: string, headingColor?: string): string
1013
1127
  return result;
1014
1128
  }
1015
1129
 
1016
- function buildSettingsXml(): string {
1130
+ function buildSettingsXml(displayBackground: boolean): string {
1131
+ // `<w:compat>` with `compatibilityMode=15` is what tells Word "this
1132
+ // is a Word 2016+ .docx, render with modern features." Without it,
1133
+ // Word opens the file in Compatibility Mode and silently disables
1134
+ // newer behaviors — including page-background rendering in Print
1135
+ // Layout view and several default style precedence rules. The other
1136
+ // compatSetting flags match what Word 2016+ writes by default.
1137
+ const compatXml =
1138
+ `<w:compat>` +
1139
+ `<w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/>` +
1140
+ `<w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1141
+ `<w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1142
+ `<w:compatSetting w:name="doNotFlipMirrorIndents" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1143
+ `<w:compatSetting w:name="differentiateMultirowTableHeaders" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/>` +
1144
+ `</w:compat>`;
1145
+ // Element order inside `<w:settings>` matters — ECMA-376 defines a
1146
+ // strict sequence (`displayBackgroundShape` → `defaultTabStop` →
1147
+ // `characterSpacingControl` → … → `compat`). Word silently drops
1148
+ // out-of-order children, which is why `<w:background>` looks correct
1149
+ // in the file but doesn't paint when opened.
1017
1150
  return (
1018
1151
  xmlDeclaration() +
1019
1152
  `<w:settings xmlns:w="${NS_WML}">` +
1153
+ (displayBackground ? `<w:displayBackgroundShape/>` : '') +
1020
1154
  `<w:defaultTabStop w:val="720"/>` +
1021
1155
  `<w:characterSpacingControl w:val="doNotCompress"/>` +
1156
+ compatXml +
1022
1157
  `</w:settings>`
1023
1158
  );
1024
1159
  }
@@ -1078,7 +1213,13 @@ function buildNumberingXml(ctx: ExportContext): string {
1078
1213
  `<w:lvlText w:val="${bullet}"/>` +
1079
1214
  `<w:lvlJc w:val="left"/>` +
1080
1215
  `<w:pPr><w:ind w:left="${720 * (lvl + 1)}" w:hanging="360"/></w:pPr>` +
1081
- `<w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr>` +
1216
+ // Bullet glyph renders in the document's body font, not
1217
+ // Word's legacy `Symbol` font. `Symbol` only maps codes
1218
+ // 0x20-0xFF to Greek/math glyphs, so the Unicode bullets
1219
+ // we use (U+2022, U+25E6, U+25AA) come out as "tofu"
1220
+ // empty boxes when forced into Symbol. Every modern OS
1221
+ // font has these codepoints, so omitting the font
1222
+ // override gets a real bullet.
1082
1223
  `</w:lvl>`,
1083
1224
  );
1084
1225
  }
@@ -23,7 +23,7 @@
23
23
 
24
24
  import JSZip from 'jszip';
25
25
  import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
26
- import { resolveTheme } from '@bendyline/squisq/schemas';
26
+ import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
27
27
  import type {
28
28
  MarkdownDocument,
29
29
  MarkdownBlockNode,
@@ -803,8 +803,8 @@ function generateStylesheet(themeId?: string): string {
803
803
  --epub-bg: ${theme.colors.background};
804
804
  --epub-text: ${theme.colors.text};
805
805
  --epub-primary: ${theme.colors.primary};
806
- --epub-heading-font: ${theme.typography.titleFontFamily};
807
- --epub-body-font: ${theme.typography.bodyFontFamily};`;
806
+ --epub-heading-font: ${resolveFontFamily(theme.typography.titleFont, 'serif')};
807
+ --epub-body-font: ${resolveFontFamily(theme.typography.bodyFont, 'sans-serif')};`;
808
808
  }
809
809
 
810
810
  return `/* Squisq EPUB Stylesheet */