@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.
- package/dist/container/index.d.ts +7 -5
- package/dist/container/index.js +39 -70
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +128 -15
- package/dist/docx/index.js +17 -22
- package/dist/docx/index.js.map +1 -1
- package/dist/epub/index.d.ts +66 -11
- package/dist/epub/index.js +10 -18
- package/dist/epub/index.js.map +1 -1
- package/dist/html/index.d.ts +278 -15
- package/dist/html/index.js +26 -165
- package/dist/html/index.js.map +1 -1
- package/dist/index.d.ts +9 -38
- package/dist/index.js +62 -37
- package/dist/index.js.map +1 -1
- package/dist/ooxml/index.d.ts +353 -14
- package/dist/ooxml/index.js +116 -22
- package/dist/ooxml/index.js.map +1 -1
- package/dist/pdf/index.d.ts +143 -17
- package/dist/pdf/index.js +16 -25
- package/dist/pdf/index.js.map +1 -1
- package/dist/pptx/index.d.ts +83 -8
- package/dist/pptx/index.js +14 -34
- package/dist/pptx/index.js.map +1 -1
- package/dist/xlsx/index.d.ts +12 -9
- package/dist/xlsx/index.js +12 -44
- package/dist/xlsx/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/__tests__/container.test.d.ts +0 -8
- package/dist/__tests__/container.test.d.ts.map +0 -1
- package/dist/__tests__/container.test.js +0 -147
- package/dist/__tests__/container.test.js.map +0 -1
- package/dist/__tests__/docxExport.test.d.ts +0 -8
- package/dist/__tests__/docxExport.test.d.ts.map +0 -1
- package/dist/__tests__/docxExport.test.js +0 -383
- package/dist/__tests__/docxExport.test.js.map +0 -1
- package/dist/__tests__/docxImport.test.d.ts +0 -8
- package/dist/__tests__/docxImport.test.d.ts.map +0 -1
- package/dist/__tests__/docxImport.test.js +0 -309
- package/dist/__tests__/docxImport.test.js.map +0 -1
- package/dist/__tests__/epub.test.d.ts +0 -8
- package/dist/__tests__/epub.test.d.ts.map +0 -1
- package/dist/__tests__/epub.test.js +0 -472
- package/dist/__tests__/epub.test.js.map +0 -1
- package/dist/__tests__/html.test.d.ts +0 -8
- package/dist/__tests__/html.test.d.ts.map +0 -1
- package/dist/__tests__/html.test.js +0 -317
- package/dist/__tests__/html.test.js.map +0 -1
- package/dist/__tests__/ooxml.test.d.ts +0 -5
- package/dist/__tests__/ooxml.test.d.ts.map +0 -1
- package/dist/__tests__/ooxml.test.js +0 -152
- package/dist/__tests__/ooxml.test.js.map +0 -1
- package/dist/__tests__/pdfExport.test.d.ts +0 -8
- package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
- package/dist/__tests__/pdfExport.test.js +0 -275
- package/dist/__tests__/pdfExport.test.js.map +0 -1
- package/dist/__tests__/pdfImport.test.d.ts +0 -11
- package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
- package/dist/__tests__/pdfImport.test.js +0 -254
- package/dist/__tests__/pdfImport.test.js.map +0 -1
- package/dist/__tests__/plainHtml.test.d.ts +0 -5
- package/dist/__tests__/plainHtml.test.d.ts.map +0 -1
- package/dist/__tests__/plainHtml.test.js +0 -327
- package/dist/__tests__/plainHtml.test.js.map +0 -1
- package/dist/__tests__/plainHtmlBundle.test.d.ts +0 -11
- package/dist/__tests__/plainHtmlBundle.test.d.ts.map +0 -1
- package/dist/__tests__/plainHtmlBundle.test.js +0 -210
- package/dist/__tests__/plainHtmlBundle.test.js.map +0 -1
- package/dist/__tests__/roundTrip.test.d.ts +0 -7
- package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
- package/dist/__tests__/roundTrip.test.js +0 -173
- package/dist/__tests__/roundTrip.test.js.map +0 -1
- package/dist/container/index.d.ts.map +0 -1
- package/dist/docx/export.d.ts +0 -69
- package/dist/docx/export.d.ts.map +0 -1
- package/dist/docx/export.js +0 -985
- package/dist/docx/export.js.map +0 -1
- package/dist/docx/import.d.ts +0 -65
- package/dist/docx/import.d.ts.map +0 -1
- package/dist/docx/import.js +0 -779
- package/dist/docx/import.js.map +0 -1
- package/dist/docx/index.d.ts.map +0 -1
- package/dist/docx/styles.d.ts +0 -68
- package/dist/docx/styles.d.ts.map +0 -1
- package/dist/docx/styles.js +0 -125
- package/dist/docx/styles.js.map +0 -1
- package/dist/epub/export.d.ts +0 -72
- package/dist/epub/export.d.ts.map +0 -1
- package/dist/epub/export.js +0 -698
- package/dist/epub/export.js.map +0 -1
- package/dist/epub/index.d.ts.map +0 -1
- package/dist/html/docsHtmlBundle.d.ts +0 -53
- package/dist/html/docsHtmlBundle.d.ts.map +0 -1
- package/dist/html/docsHtmlBundle.js +0 -299
- package/dist/html/docsHtmlBundle.js.map +0 -1
- package/dist/html/htmlTemplate.d.ts +0 -70
- package/dist/html/htmlTemplate.d.ts.map +0 -1
- package/dist/html/htmlTemplate.js +0 -282
- package/dist/html/htmlTemplate.js.map +0 -1
- package/dist/html/imageUtils.d.ts +0 -29
- package/dist/html/imageUtils.d.ts.map +0 -1
- package/dist/html/imageUtils.js +0 -63
- package/dist/html/imageUtils.js.map +0 -1
- package/dist/html/index.d.ts.map +0 -1
- package/dist/html/plainHtml.d.ts +0 -79
- package/dist/html/plainHtml.d.ts.map +0 -1
- package/dist/html/plainHtml.js +0 -614
- package/dist/html/plainHtml.js.map +0 -1
- package/dist/html/plainHtmlBundle.d.ts +0 -93
- package/dist/html/plainHtmlBundle.d.ts.map +0 -1
- package/dist/html/plainHtmlBundle.js +0 -357
- package/dist/html/plainHtmlBundle.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/ooxml/index.d.ts.map +0 -1
- package/dist/ooxml/namespaces.d.ts +0 -77
- package/dist/ooxml/namespaces.d.ts.map +0 -1
- package/dist/ooxml/namespaces.js +0 -104
- package/dist/ooxml/namespaces.js.map +0 -1
- package/dist/ooxml/reader.d.ts +0 -52
- package/dist/ooxml/reader.d.ts.map +0 -1
- package/dist/ooxml/reader.js +0 -190
- package/dist/ooxml/reader.js.map +0 -1
- package/dist/ooxml/types.d.ts +0 -82
- package/dist/ooxml/types.d.ts.map +0 -1
- package/dist/ooxml/types.js +0 -9
- package/dist/ooxml/types.js.map +0 -1
- package/dist/ooxml/writer.d.ts +0 -71
- package/dist/ooxml/writer.d.ts.map +0 -1
- package/dist/ooxml/writer.js +0 -200
- package/dist/ooxml/writer.js.map +0 -1
- package/dist/ooxml/xmlUtils.d.ts +0 -76
- package/dist/ooxml/xmlUtils.d.ts.map +0 -1
- package/dist/ooxml/xmlUtils.js +0 -108
- package/dist/ooxml/xmlUtils.js.map +0 -1
- package/dist/pdf/export.d.ts +0 -56
- package/dist/pdf/export.d.ts.map +0 -1
- package/dist/pdf/export.js +0 -768
- package/dist/pdf/export.js.map +0 -1
- package/dist/pdf/import.d.ts +0 -94
- package/dist/pdf/import.d.ts.map +0 -1
- package/dist/pdf/import.js +0 -796
- package/dist/pdf/import.js.map +0 -1
- package/dist/pdf/index.d.ts.map +0 -1
- package/dist/pdf/styles.d.ts +0 -142
- package/dist/pdf/styles.d.ts.map +0 -1
- package/dist/pdf/styles.js +0 -145
- package/dist/pdf/styles.js.map +0 -1
- package/dist/pptx/export.d.ts +0 -75
- package/dist/pptx/export.d.ts.map +0 -1
- package/dist/pptx/export.js +0 -724
- package/dist/pptx/export.js.map +0 -1
- package/dist/pptx/index.d.ts.map +0 -1
- package/dist/pptx/styles.d.ts +0 -54
- package/dist/pptx/styles.d.ts.map +0 -1
- package/dist/pptx/styles.js +0 -73
- package/dist/pptx/styles.js.map +0 -1
- package/dist/pptx/templates.d.ts +0 -24
- package/dist/pptx/templates.d.ts.map +0 -1
- package/dist/pptx/templates.js +0 -158
- package/dist/pptx/templates.js.map +0 -1
- package/dist/xlsx/index.d.ts.map +0 -1
package/dist/docx/export.js
DELETED
|
@@ -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
|