@bendyline/squisq-formats 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/html.test.js +78 -4
- package/dist/__tests__/html.test.js.map +1 -1
- package/dist/__tests__/pdfExport.test.js +1 -1
- package/dist/__tests__/pdfExport.test.js.map +1 -1
- package/dist/__tests__/plainHtml.test.d.ts +5 -0
- package/dist/__tests__/plainHtml.test.d.ts.map +1 -0
- package/dist/__tests__/plainHtml.test.js +327 -0
- package/dist/__tests__/plainHtml.test.js.map +1 -0
- package/dist/__tests__/plainHtmlBundle.test.d.ts +11 -0
- package/dist/__tests__/plainHtmlBundle.test.d.ts.map +1 -0
- package/dist/__tests__/plainHtmlBundle.test.js +210 -0
- package/dist/__tests__/plainHtmlBundle.test.js.map +1 -0
- package/dist/chunk-33YRFXZZ.js +1187 -0
- package/dist/chunk-33YRFXZZ.js.map +1 -0
- package/dist/{chunk-EUQQYBZZ.js → chunk-ERZ627GR.js} +4 -4
- package/dist/chunk-ERZ627GR.js.map +1 -0
- package/dist/{chunk-MEZF76JA.js → chunk-UDS45KUJ.js} +68 -25
- package/dist/chunk-UDS45KUJ.js.map +1 -0
- package/dist/{chunk-NGWHV77G.js → chunk-VN2KEOYB.js} +7 -6
- package/dist/chunk-VN2KEOYB.js.map +1 -0
- package/dist/docx/export.d.ts.map +1 -1
- package/dist/docx/export.js +153 -22
- package/dist/docx/export.js.map +1 -1
- package/dist/epub/export.js +3 -3
- package/dist/epub/export.js.map +1 -1
- package/dist/html/docsHtmlBundle.d.ts +53 -0
- package/dist/html/docsHtmlBundle.d.ts.map +1 -0
- package/dist/html/docsHtmlBundle.js +299 -0
- package/dist/html/docsHtmlBundle.js.map +1 -0
- package/dist/html/htmlTemplate.d.ts.map +1 -1
- package/dist/html/htmlTemplate.js +43 -0
- package/dist/html/htmlTemplate.js.map +1 -1
- package/dist/html/index.d.ts +6 -0
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +31 -3
- package/dist/html/index.js.map +1 -1
- package/dist/html/plainHtml.d.ts +79 -0
- package/dist/html/plainHtml.d.ts.map +1 -0
- package/dist/html/plainHtml.js +614 -0
- package/dist/html/plainHtml.js.map +1 -0
- package/dist/html/plainHtmlBundle.d.ts +93 -0
- package/dist/html/plainHtmlBundle.d.ts.map +1 -0
- package/dist/html/plainHtmlBundle.js +357 -0
- package/dist/html/plainHtmlBundle.js.map +1 -0
- package/dist/pptx/export.d.ts.map +1 -1
- package/dist/pptx/export.js +10 -8
- package/dist/pptx/export.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/html.test.ts +82 -4
- package/src/__tests__/pdfExport.test.ts +1 -1
- package/src/__tests__/plainHtml.test.ts +372 -0
- package/src/__tests__/plainHtmlBundle.test.ts +235 -0
- package/src/docx/export.ts +163 -22
- package/src/epub/export.ts +3 -3
- package/src/html/docsHtmlBundle.ts +369 -0
- package/src/html/htmlTemplate.ts +40 -0
- package/src/html/index.ts +32 -3
- package/src/html/plainHtml.ts +736 -0
- package/src/html/plainHtmlBundle.ts +419 -0
- package/src/pptx/export.ts +10 -9
- package/dist/chunk-EUQQYBZZ.js.map +0 -1
- package/dist/chunk-MEZF76JA.js.map +0 -1
- package/dist/chunk-NGWHV77G.js.map +0 -1
- package/dist/chunk-UM5V2XZG.js +0 -242
- package/dist/chunk-UM5V2XZG.js.map +0 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the recursive plain-HTML bundle export
|
|
3
|
+
* (`markdownDocsToPlainHtmlBundle`).
|
|
4
|
+
*
|
|
5
|
+
* Each test sets up an in-memory "container" — a `Map<path, string>`
|
|
6
|
+
* for markdown sources and a `Map<path, ArrayBuffer>` for binary
|
|
7
|
+
* assets — wires it up as the `readDocument` / `readBinary` callbacks,
|
|
8
|
+
* exports the bundle, and inspects the resulting JSZip.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import JSZip from 'jszip';
|
|
13
|
+
import { markdownDocsToPlainHtmlBundle } from '../html/plainHtmlBundle';
|
|
14
|
+
|
|
15
|
+
/** Helper to read a Blob as Uint8Array (works in jsdom). */
|
|
16
|
+
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const reader = new FileReader();
|
|
19
|
+
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
|
20
|
+
reader.onerror = () => reject(reader.error);
|
|
21
|
+
reader.readAsArrayBuffer(blob);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeContainer(docs: Record<string, string>, binaries: Record<string, ArrayBuffer> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
readDocument: async (p: string) => (p in docs ? docs[p] : null),
|
|
28
|
+
readBinary: async (p: string) => (p in binaries ? binaries[p] : null),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readZipPath(blob: Blob, path: string): Promise<string | null> {
|
|
33
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
34
|
+
const file = zip.file(path);
|
|
35
|
+
return file ? file.async('text') : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listZipPaths(blob: Blob): Promise<string[]> {
|
|
39
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
40
|
+
// JSZip emits implicit directory entries (paths ending in `/`) when
|
|
41
|
+
// any file lives in a subfolder. We're testing file content; the
|
|
42
|
+
// directory placeholders are noise.
|
|
43
|
+
return Object.keys(zip.files)
|
|
44
|
+
.filter((p) => !p.endsWith('/'))
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('markdownDocsToPlainHtmlBundle', () => {
|
|
49
|
+
it('exports a single doc with no links to a 1-file html', async () => {
|
|
50
|
+
const c = makeContainer({ 'home.md': '# Home\n\nJust a single page.' });
|
|
51
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
52
|
+
entryPath: 'home.md',
|
|
53
|
+
...c,
|
|
54
|
+
});
|
|
55
|
+
const paths = await listZipPaths(blob);
|
|
56
|
+
expect(paths).toEqual(['home.html']);
|
|
57
|
+
const html = await readZipPath(blob, 'home.html');
|
|
58
|
+
expect(html).toContain('<h1>Home</h1>');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('follows a linear chain home.md → resume.md and bundles both', async () => {
|
|
62
|
+
const c = makeContainer({
|
|
63
|
+
'home.md': '# Home\n\nMy [resume](resume.md) is here.',
|
|
64
|
+
'resume.md': '# Resume\n\nWork history.',
|
|
65
|
+
});
|
|
66
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
67
|
+
entryPath: 'home.md',
|
|
68
|
+
...c,
|
|
69
|
+
});
|
|
70
|
+
const paths = await listZipPaths(blob);
|
|
71
|
+
expect(paths).toContain('home.html');
|
|
72
|
+
expect(paths).toContain('resume.html');
|
|
73
|
+
const home = await readZipPath(blob, 'home.html');
|
|
74
|
+
// The .md link is rewritten to .html
|
|
75
|
+
expect(home).toContain('href="resume.html"');
|
|
76
|
+
expect(home).not.toContain('href="resume.md"');
|
|
77
|
+
const resume = await readZipPath(blob, 'resume.html');
|
|
78
|
+
expect(resume).toContain('<h1>Resume</h1>');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('cycle-detects so a.md ↔ b.md does not loop', async () => {
|
|
82
|
+
const c = makeContainer({
|
|
83
|
+
'a.md': '# A\n\n[b](b.md)',
|
|
84
|
+
'b.md': '# B\n\n[a](a.md)',
|
|
85
|
+
});
|
|
86
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
87
|
+
entryPath: 'a.md',
|
|
88
|
+
...c,
|
|
89
|
+
});
|
|
90
|
+
const paths = await listZipPaths(blob);
|
|
91
|
+
expect(paths.sort()).toEqual(['a.html', 'b.html']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does NOT follow ../parent.md (scope rule)', async () => {
|
|
95
|
+
const c = makeContainer({
|
|
96
|
+
'subdir/intro.md': '# Intro\n\nLink to [parent](../outside.md).',
|
|
97
|
+
'outside.md': '# Outside',
|
|
98
|
+
});
|
|
99
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
100
|
+
entryPath: 'subdir/intro.md',
|
|
101
|
+
...c,
|
|
102
|
+
});
|
|
103
|
+
const paths = await listZipPaths(blob);
|
|
104
|
+
// `outside.md` lives ABOVE the entry doc's directory → out of scope.
|
|
105
|
+
expect(paths).toEqual(['subdir/intro.html']);
|
|
106
|
+
const intro = await readZipPath(blob, 'subdir/intro.html');
|
|
107
|
+
// The link is not rewritten to .html — it stays as authored.
|
|
108
|
+
expect(intro).toContain('href="../outside.md"');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('follows links into subfolders (still in scope)', async () => {
|
|
112
|
+
const c = makeContainer({
|
|
113
|
+
'home.md': '# Home\n\n[chapter 1](chapters/one.md)',
|
|
114
|
+
'chapters/one.md': '# Chapter 1\n\nContent.',
|
|
115
|
+
});
|
|
116
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
117
|
+
entryPath: 'home.md',
|
|
118
|
+
...c,
|
|
119
|
+
});
|
|
120
|
+
const paths = await listZipPaths(blob);
|
|
121
|
+
expect(paths).toContain('home.html');
|
|
122
|
+
expect(paths).toContain('chapters/one.html');
|
|
123
|
+
const home = await readZipPath(blob, 'home.html');
|
|
124
|
+
expect(home).toContain('href="chapters/one.html"');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('preserves #anchor fragments when rewriting .md → .html', async () => {
|
|
128
|
+
const c = makeContainer({
|
|
129
|
+
'home.md': '# Home\n\nJump to [exp](resume.md#experience).',
|
|
130
|
+
'resume.md': '# Resume\n\n## Experience',
|
|
131
|
+
});
|
|
132
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
133
|
+
entryPath: 'home.md',
|
|
134
|
+
...c,
|
|
135
|
+
});
|
|
136
|
+
const home = await readZipPath(blob, 'home.html');
|
|
137
|
+
expect(home).toContain('href="resume.html#experience"');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('aborts with an error when a linked doc cannot be read', async () => {
|
|
141
|
+
const c = makeContainer({
|
|
142
|
+
'home.md': '# Home\n\n[missing](does-not-exist.md)',
|
|
143
|
+
});
|
|
144
|
+
await expect(markdownDocsToPlainHtmlBundle({ entryPath: 'home.md', ...c })).rejects.toThrow(
|
|
145
|
+
/failed to read.*does-not-exist\.md/i,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('emits image assets at their authored relative paths inside the zip', async () => {
|
|
150
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
|
|
151
|
+
const c = makeContainer(
|
|
152
|
+
{ 'home.md': '# Home\n\n' },
|
|
153
|
+
{ 'home_files/hero.png': png },
|
|
154
|
+
);
|
|
155
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
156
|
+
entryPath: 'home.md',
|
|
157
|
+
...c,
|
|
158
|
+
});
|
|
159
|
+
const paths = await listZipPaths(blob);
|
|
160
|
+
expect(paths).toContain('home_files/hero.png');
|
|
161
|
+
const home = await readZipPath(blob, 'home.html');
|
|
162
|
+
expect(home).toContain('src="home_files/hero.png"');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('linked sub-folder docs reference parent assets via ../', async () => {
|
|
166
|
+
// The doc lives in subdir/, the image in home_files/, so the
|
|
167
|
+
// rewritten src should walk up one level.
|
|
168
|
+
const png = new Uint8Array([0x89]).buffer;
|
|
169
|
+
const c = makeContainer(
|
|
170
|
+
{
|
|
171
|
+
'home.md': '# Home\n\n[deep](chapters/one.md)',
|
|
172
|
+
'chapters/one.md': '# One\n\n',
|
|
173
|
+
},
|
|
174
|
+
{ 'home_files/hero.png': png },
|
|
175
|
+
);
|
|
176
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
177
|
+
entryPath: 'home.md',
|
|
178
|
+
...c,
|
|
179
|
+
});
|
|
180
|
+
const paths = await listZipPaths(blob);
|
|
181
|
+
expect(paths).toContain('home_files/hero.png');
|
|
182
|
+
const one = await readZipPath(blob, 'chapters/one.html');
|
|
183
|
+
expect(one).toContain('src="../home_files/hero.png"');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('passes the entry title through; sibling docs derive title from the shallowest heading', async () => {
|
|
187
|
+
const c = makeContainer({
|
|
188
|
+
'home.md': '# Home\n\n[resume](resume.md)\n\n[notes](notes.md)',
|
|
189
|
+
'resume.md': '# Resume',
|
|
190
|
+
'notes.md': 'Just a paragraph.',
|
|
191
|
+
});
|
|
192
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
193
|
+
entryPath: 'home.md',
|
|
194
|
+
title: 'My Home Page',
|
|
195
|
+
...c,
|
|
196
|
+
});
|
|
197
|
+
const home = await readZipPath(blob, 'home.html');
|
|
198
|
+
expect(home).toContain('<title>My Home Page</title>');
|
|
199
|
+
// Heading-bearing sibling uses its heading text, not the filename.
|
|
200
|
+
const resume = await readZipPath(blob, 'resume.html');
|
|
201
|
+
expect(resume).toContain('<title>Resume</title>');
|
|
202
|
+
// Heading-less sibling falls back to the filename.
|
|
203
|
+
const notes = await readZipPath(blob, 'notes.html');
|
|
204
|
+
expect(notes).toContain('<title>notes</title>');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('respects maxDepth — depth 0 means entry only, no link following', async () => {
|
|
208
|
+
const c = makeContainer({
|
|
209
|
+
'home.md': '# Home\n\n[resume](resume.md)',
|
|
210
|
+
'resume.md': '# Resume',
|
|
211
|
+
});
|
|
212
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
213
|
+
entryPath: 'home.md',
|
|
214
|
+
maxDepth: 0,
|
|
215
|
+
...c,
|
|
216
|
+
});
|
|
217
|
+
const paths = await listZipPaths(blob);
|
|
218
|
+
expect(paths).toEqual(['home.html']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not rewrite external links (http://) or fragment-only links', async () => {
|
|
222
|
+
const c = makeContainer({
|
|
223
|
+
'home.md':
|
|
224
|
+
'[google](https://google.com)\n\n[#section](#somewhere)\n\n[email](mailto:me@example.com)',
|
|
225
|
+
});
|
|
226
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
227
|
+
entryPath: 'home.md',
|
|
228
|
+
...c,
|
|
229
|
+
});
|
|
230
|
+
const home = await readZipPath(blob, 'home.html');
|
|
231
|
+
expect(home).toContain('href="https://google.com"');
|
|
232
|
+
expect(home).toContain('href="#somewhere"');
|
|
233
|
+
expect(home).toContain('href="mailto:me@example.com"');
|
|
234
|
+
});
|
|
235
|
+
});
|
package/src/docx/export.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
}
|
package/src/epub/export.ts
CHANGED
|
@@ -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.
|
|
807
|
-
--epub-body-font: ${theme.typography.
|
|
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 */
|