md-pdf-studio 2.2.0 → 2.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.
@@ -0,0 +1,492 @@
1
+ const { buildDocument, collectHeadings, isPortableLink, plainText } = require('./docmodel');
2
+ const { readLocalImage } = require('./images');
3
+ const { createZip } = require('./zip');
4
+
5
+ /**
6
+ * Markdown to Word (.docx), written from the document model rather than from
7
+ * HTML: headings, lists, tables, code and quotes become real Word styles and
8
+ * structures, which a reader can restyle, instead of formatted lookalikes.
9
+ *
10
+ * Not carried over: themes and custom CSS, Mermaid diagrams (kept as their
11
+ * source), and LaTeX math (kept as typed). Images are embedded when they are
12
+ * local PNG, JPEG or GIF files; any other image is named with its address.
13
+ */
14
+
15
+ const TWIPS_PER_MM = 1440 / 25.4;
16
+ const EMU_PER_PIXEL = 9525;
17
+ const EMU_PER_TWIP = 635;
18
+ const PAGE_SIZES = { A4: [11906, 16838], LETTER: [12240, 15840], A3: [16838, 23811], LEGAL: [12240, 20160] };
19
+ const HEADING_SIZES = [36, 30, 26, 24, 22, 22];
20
+ const CODE_FONT = 'Consolas';
21
+
22
+ function xml(text) {
23
+ return String(text)
24
+ // Characters XML 1.0 cannot carry at all.
25
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, '')
26
+ .replace(/&/g, '&')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;');
30
+ }
31
+
32
+ /** `14mm 12mm 16mm 12mm` (top right bottom left, as in CSS) in twips. */
33
+ function parseMargins(margin) {
34
+ const parts = String(margin || '').trim().split(/\s+/).map(part => {
35
+ const match = /^([\d.]+)(mm|cm|in|pt|px)?$/.exec(part);
36
+ if (!match) return null;
37
+ const value = parseFloat(match[1]);
38
+ const factor = { mm: TWIPS_PER_MM, cm: TWIPS_PER_MM * 10, in: 1440, pt: 20, px: 15 }[match[2] || 'mm'];
39
+ return Math.round(value * factor);
40
+ });
41
+ if (!parts.length || parts.some(part => part === null)) return parseMargins('14mm 12mm 16mm 12mm');
42
+ const [top, right = top, bottom = top, left = right] = parts;
43
+ return { top, right, bottom, left };
44
+ }
45
+
46
+ function bookmarkName(id) {
47
+ return `h_${id.replace(/[^\w]/g, '_')}`.slice(0, 40);
48
+ }
49
+
50
+ class DocxWriter {
51
+ constructor(document, baseDir) {
52
+ this.document = document;
53
+ this.baseDir = baseDir;
54
+ this.relationships = [];
55
+ this.media = [];
56
+ this.orderedLists = [];
57
+ this.bookmarks = new Map();
58
+ this.drawingId = 0;
59
+
60
+ const { settings } = document;
61
+ const [width, height] = PAGE_SIZES[String(settings.pageSize).toUpperCase()] || PAGE_SIZES.A4;
62
+ const landscape = settings.orientation === 'landscape';
63
+ this.page = { width: landscape ? height : width, height: landscape ? width : height, landscape };
64
+ this.margins = parseMargins(settings.margin);
65
+ this.contentWidth = this.page.width - this.margins.left - this.margins.right;
66
+
67
+ // Bookmark names are cut to Word's limit, so two long headings could collide.
68
+ const taken = new Set();
69
+ for (const heading of collectHeadings(document.blocks)) {
70
+ let name = bookmarkName(heading.id);
71
+ for (let suffix = 2; taken.has(name); suffix++) name = `${bookmarkName(heading.id).slice(0, 36)}_${suffix}`;
72
+ taken.add(name);
73
+ this.bookmarks.set(heading.id, { id: taken.size, name });
74
+ }
75
+ }
76
+
77
+ relationship(type, target, external = false) {
78
+ const id = `rId${this.relationships.length + 1}`;
79
+ this.relationships.push({ id, type, target, external });
80
+ return id;
81
+ }
82
+
83
+ runProperties(run, extra = '') {
84
+ let properties = extra;
85
+ if (run.code) {
86
+ properties += `<w:rFonts w:ascii="${CODE_FONT}" w:hAnsi="${CODE_FONT}" w:cs="${CODE_FONT}"/><w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>`;
87
+ }
88
+ if (run.bold) properties += '<w:b/>';
89
+ if (run.italic) properties += '<w:i/>';
90
+ if (run.strike) properties += '<w:strike/>';
91
+ if (run.sup) properties += '<w:vertAlign w:val="superscript"/>';
92
+ else if (run.sub) properties += '<w:vertAlign w:val="subscript"/>';
93
+ return properties ? `<w:rPr>${properties}</w:rPr>` : '';
94
+ }
95
+
96
+ textRun(run, extra = '') {
97
+ // A tab or newline inside text is its own element in Word.
98
+ const pieces = run.text.split(/(\t|\n)/).map(piece => {
99
+ if (piece === '\t') return '<w:tab/>';
100
+ if (piece === '\n') return '<w:br/>';
101
+ return piece ? `<w:t xml:space="preserve">${xml(piece)}</w:t>` : '';
102
+ });
103
+ return `<w:r>${this.runProperties(run, extra)}${pieces.join('')}</w:r>`;
104
+ }
105
+
106
+ imageRun(run) {
107
+ const image = readLocalImage(run.image.src, this.baseDir);
108
+ if (!image || !image.size || !['png', 'jpg', 'gif'].includes(image.extension)) {
109
+ // Not a file Word can hold: say what was there instead of dropping it silently.
110
+ const label = run.image.alt ? `${run.image.alt} (${run.image.src})` : run.image.src;
111
+ return this.textRun({ text: `[${label}]`, italic: true });
112
+ }
113
+ const name = `image${this.media.length + 1}.${image.extension}`;
114
+ this.media.push({ name, data: image.data });
115
+ const relationshipId = this.relationship(
116
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
117
+ `media/${name}`
118
+ );
119
+ const maxWidth = this.contentWidth * EMU_PER_TWIP;
120
+ let cx = image.size.width * EMU_PER_PIXEL;
121
+ let cy = image.size.height * EMU_PER_PIXEL;
122
+ if (cx > maxWidth) {
123
+ cy = Math.round(cy * (maxWidth / cx));
124
+ cx = maxWidth;
125
+ }
126
+ const id = ++this.drawingId;
127
+ const alt = xml(run.image.alt || name);
128
+ return (
129
+ '<w:r><w:drawing>' +
130
+ `<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/>` +
131
+ `<wp:docPr id="${id}" name="Picture ${id}" descr="${alt}"/>` +
132
+ '<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">' +
133
+ '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
134
+ '<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
135
+ `<pic:nvPicPr><pic:cNvPr id="${id}" name="${xml(name)}" descr="${alt}"/><pic:cNvPicPr/></pic:nvPicPr>` +
136
+ `<pic:blipFill><a:blip r:embed="${relationshipId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
137
+ `<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
138
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>' +
139
+ '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>'
140
+ );
141
+ }
142
+
143
+ /** Runs, with neighbouring runs of one link wrapped in a single hyperlink. */
144
+ runs(runs) {
145
+ let output = '';
146
+ for (let index = 0; index < runs.length; ) {
147
+ const href = runs[index].href;
148
+ let end = index;
149
+ while (end < runs.length && runs[end].href === href) end += 1;
150
+ const group = runs.slice(index, end);
151
+ const body = group
152
+ .map(run => {
153
+ if (run.lineBreak) return '<w:r><w:br/></w:r>';
154
+ if (run.image) return this.imageRun(run);
155
+ return this.textRun(run, href ? '<w:rStyle w:val="Hyperlink"/>' : '');
156
+ })
157
+ .join('');
158
+ if (!href) {
159
+ output += body;
160
+ } else if (href[0] === '#') {
161
+ const bookmark = this.bookmarks.get(decodeURIComponentSafe(href.slice(1)));
162
+ output += bookmark ? `<w:hyperlink w:anchor="${xml(bookmark.name)}" w:history="1">${body}</w:hyperlink>` : body;
163
+ } else if (!isPortableLink(href)) {
164
+ // A path to a file next to the Markdown leads nowhere from a Word document.
165
+ output += body;
166
+ } else {
167
+ const id = this.relationship('http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', href, true);
168
+ output += `<w:hyperlink r:id="${id}" w:history="1">${body}</w:hyperlink>`;
169
+ }
170
+ index = end;
171
+ }
172
+ return output;
173
+ }
174
+
175
+ paragraph(content, { style, indent, numbering, bookmark } = {}) {
176
+ let properties = '';
177
+ if (style) properties += `<w:pStyle w:val="${style}"/>`;
178
+ if (numbering) properties += `<w:numPr><w:ilvl w:val="${numbering.level}"/><w:numId w:val="${numbering.id}"/></w:numPr>`;
179
+ if (indent) properties += `<w:ind w:left="${indent.left}"${indent.hanging ? ` w:hanging="${indent.hanging}"` : ''}/>`;
180
+ const mark = bookmark
181
+ ? `<w:bookmarkStart w:id="${bookmark.id}" w:name="${xml(bookmark.name)}"/><w:bookmarkEnd w:id="${bookmark.id}"/>`
182
+ : '';
183
+ return `<w:p>${properties ? `<w:pPr>${properties}</w:pPr>` : ''}${mark}${content}</w:p>`;
184
+ }
185
+
186
+ /**
187
+ * @param {Object[]} blocks
188
+ * @param {{ quote: number, list: number, numbering: Object | null }} context
189
+ * How deep in quotes and lists the blocks are; `numbering` is set for the
190
+ * first paragraph of a list item only, which is the one that carries the marker.
191
+ */
192
+ blocks(blocks, context = { quote: 0, list: -1, numbering: null }) {
193
+ let output = '';
194
+ let numbering = context.numbering;
195
+ const base = context.quote * 360 + (context.list + 1) * 720;
196
+ const plainIndent = base > 0 ? { left: base } : undefined;
197
+ const takeNumbering = () => {
198
+ const current = numbering;
199
+ numbering = null;
200
+ return current;
201
+ };
202
+
203
+ for (const block of blocks) {
204
+ switch (block.type) {
205
+ case 'heading': {
206
+ output += this.paragraph(this.runs(block.runs), {
207
+ style: `Heading${Math.min(block.depth, 6)}`,
208
+ indent: plainIndent,
209
+ bookmark: this.bookmarks.get(block.id)
210
+ });
211
+ break;
212
+ }
213
+ case 'paragraph': {
214
+ const marker = takeNumbering();
215
+ const prefix = marker && marker.checkbox ? this.textRun({ text: `${marker.checkbox} ` }) : '';
216
+ output += this.paragraph(prefix + this.runs(block.runs), {
217
+ style: context.quote > 0 ? 'Quote' : undefined,
218
+ numbering: marker ? { id: marker.id, level: context.list } : undefined,
219
+ indent: marker ? { left: base, hanging: 360 } : plainIndent
220
+ });
221
+ break;
222
+ }
223
+ case 'code': {
224
+ const lines = block.text.replace(/\t/g, ' ').split(/\r?\n/);
225
+ for (const line of lines) {
226
+ output += this.paragraph(line ? `<w:r><w:t xml:space="preserve">${xml(line)}</w:t></w:r>` : '', {
227
+ style: 'CodeBlock',
228
+ indent: plainIndent
229
+ });
230
+ }
231
+ // Two code blocks in a row would otherwise merge into one shaded box.
232
+ output += this.paragraph('', { style: 'CodeSpacer' });
233
+ break;
234
+ }
235
+ case 'quote':
236
+ output += this.blocks(block.blocks, { quote: context.quote + 1, list: context.list, numbering: null });
237
+ break;
238
+ case 'list': {
239
+ const level = Math.min(context.list + 1, 8);
240
+ let id = 1; // the shared bullet list
241
+ if (block.ordered) {
242
+ this.orderedLists.push({ level, start: block.start });
243
+ id = this.orderedLists.length + 1;
244
+ }
245
+ for (const item of block.items) {
246
+ const checkbox = item.checked === null ? null : item.checked ? '☑' : '☐';
247
+ const content = item.blocks.length ? item.blocks : [{ type: 'paragraph', runs: [] }];
248
+ // An item that opens with something other than text still needs a line for its marker.
249
+ const lead = content[0].type === 'paragraph' ? [] : [{ type: 'paragraph', runs: [] }];
250
+ output += this.blocks([...lead, ...content], { quote: context.quote, list: level, numbering: { id, checkbox } });
251
+ }
252
+ break;
253
+ }
254
+ case 'table':
255
+ output += this.table(block);
256
+ break;
257
+ case 'rule':
258
+ output += this.paragraph('', { style: 'Rule' });
259
+ break;
260
+ case 'pageBreak':
261
+ output += '<w:p><w:r><w:br w:type="page"/></w:r></w:p>';
262
+ break;
263
+ default:
264
+ break;
265
+ }
266
+ }
267
+ return output;
268
+ }
269
+
270
+ table(block) {
271
+ const columns = Math.max(block.header.length, 1);
272
+ const width = Math.floor(this.contentWidth / columns);
273
+ const border = name => `<w:${name} w:val="single" w:sz="4" w:space="0" w:color="BFBFBF"/>`;
274
+ const cell = (source, header) => {
275
+ const align = { center: 'center', right: 'right' }[source.align];
276
+ const runs = header ? source.runs.map(run => (run.text !== undefined ? { ...run, bold: true } : run)) : source.runs;
277
+ return (
278
+ `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>` +
279
+ (header ? '<w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>' : '') +
280
+ `</w:tcPr><w:p><w:pPr><w:pStyle w:val="TableText"/>${align ? `<w:jc w:val="${align}"/>` : ''}</w:pPr>` +
281
+ `${this.runs(runs)}</w:p></w:tc>`
282
+ );
283
+ };
284
+ const row = (cells, header) =>
285
+ `<w:tr>${header ? '<w:trPr><w:tblHeader/></w:trPr>' : ''}${cells.map(source => cell(source, header)).join('')}</w:tr>`;
286
+ return (
287
+ '<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>' +
288
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map(border).join('') +
289
+ '</w:tblBorders><w:tblCellMar><w:left w:w="100" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tblCellMar></w:tblPr>' +
290
+ `<w:tblGrid>${`<w:gridCol w:w="${width}"/>`.repeat(columns)}</w:tblGrid>` +
291
+ row(block.header, true) +
292
+ block.rows.map(cells => row(cells, false)).join('') +
293
+ '</w:tbl>' +
294
+ // Word needs a paragraph between a table and whatever follows it.
295
+ this.paragraph('', { style: 'CodeSpacer' })
296
+ );
297
+ }
298
+
299
+ cover() {
300
+ const { meta } = this.document;
301
+ let output = this.paragraph(this.textRun({ text: meta.title }), { style: 'Title' });
302
+ if (meta.subtitle) output += this.paragraph(this.textRun({ text: meta.subtitle }), { style: 'Subtitle' });
303
+ for (const line of [meta.author, meta.date]) {
304
+ if (line) output += this.paragraph(this.textRun({ text: String(line) }), { style: 'CoverLine' });
305
+ }
306
+ return `${output}<w:p><w:r><w:br w:type="page"/></w:r></w:p>`;
307
+ }
308
+
309
+ contents() {
310
+ const headings = collectHeadings(this.document.blocks).filter(heading => heading.depth <= 3);
311
+ if (!headings.length) return '';
312
+ let output = this.paragraph(this.textRun({ text: 'Table of Contents' }), { style: 'ContentsTitle' });
313
+ for (const heading of headings) {
314
+ const run = { text: plainText(heading.runs), href: `#${heading.id}` };
315
+ output += this.paragraph(this.runs([run]), { style: 'ContentsEntry', indent: { left: (heading.depth - 1) * 360 } });
316
+ }
317
+ return output;
318
+ }
319
+
320
+ documentXml() {
321
+ const { settings, blocks } = this.document;
322
+ let body = this.blocks(blocks);
323
+ if (settings.toc) {
324
+ // After the title when the document opens with one, as in the PDF.
325
+ const contents = this.contents();
326
+ const titleEnd = blocks[0] && blocks[0].type === 'heading' && blocks[0].depth === 1 ? body.indexOf('</w:p>') + 6 : 0;
327
+ body = body.slice(0, titleEnd) + contents + body.slice(titleEnd);
328
+ }
329
+ if (settings.cover) body = this.cover() + body;
330
+ const { page, margins } = this;
331
+ return (
332
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
333
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
334
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
335
+ 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">' +
336
+ `<w:body>${body}<w:sectPr>` +
337
+ `<w:pgSz w:w="${page.width}" w:h="${page.height}"${page.landscape ? ' w:orient="landscape"' : ''}/>` +
338
+ `<w:pgMar w:top="${margins.top}" w:right="${margins.right}" w:bottom="${margins.bottom}" w:left="${margins.left}" w:header="708" w:footer="708" w:gutter="0"/>` +
339
+ '</w:sectPr></w:body></w:document>'
340
+ );
341
+ }
342
+
343
+ stylesXml() {
344
+ // Inter, the default of the PDF output, is rarely installed where Word runs, and Word
345
+ // would put a serif face in its place. A font that was asked for is used as named.
346
+ const chosen = this.document.settings.font;
347
+ const font = xml(!chosen || chosen === 'Inter' ? 'Calibri' : chosen);
348
+ const paragraphStyle = (id, name, paragraph, run, basedOn = 'Normal') =>
349
+ `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/>` +
350
+ (basedOn ? `<w:basedOn w:val="${basedOn}"/>` : '') +
351
+ `<w:qFormat/>${paragraph ? `<w:pPr>${paragraph}</w:pPr>` : ''}${run ? `<w:rPr>${run}</w:rPr>` : ''}</w:style>`;
352
+ const headings = HEADING_SIZES.map((size, index) =>
353
+ paragraphStyle(
354
+ `Heading${index + 1}`,
355
+ `heading ${index + 1}`,
356
+ `<w:keepNext/><w:spacing w:before="${index === 0 ? 360 : 240}" w:after="120"/><w:outlineLvl w:val="${index}"/>`,
357
+ `<w:b/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/>`
358
+ )
359
+ ).join('');
360
+ const codeShade = '<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>';
361
+ return (
362
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
363
+ '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
364
+ `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:cs="${font}"/>` +
365
+ '<w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:rPrDefault>' +
366
+ '<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>' +
367
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>' +
368
+ headings +
369
+ paragraphStyle('Title', 'Title', '<w:spacing w:before="2400" w:after="240"/><w:jc w:val="center"/>', '<w:b/><w:sz w:val="56"/><w:szCs w:val="56"/>') +
370
+ paragraphStyle('Subtitle', 'Subtitle', '<w:spacing w:after="480"/><w:jc w:val="center"/>', '<w:color w:val="595959"/><w:sz w:val="32"/><w:szCs w:val="32"/>') +
371
+ paragraphStyle('CoverLine', 'Cover Line', '<w:jc w:val="center"/>', '<w:color w:val="595959"/>') +
372
+ paragraphStyle('Quote', 'Quote', '<w:pBdr><w:left w:val="single" w:sz="18" w:space="8" w:color="BFBFBF"/></w:pBdr>', '<w:color w:val="595959"/>') +
373
+ paragraphStyle(
374
+ 'CodeBlock',
375
+ 'Code Block',
376
+ `<w:spacing w:after="0" w:line="240" w:lineRule="auto"/>${codeShade}`,
377
+ `<w:rFonts w:ascii="${CODE_FONT}" w:hAnsi="${CODE_FONT}" w:cs="${CODE_FONT}"/><w:sz w:val="19"/><w:szCs w:val="19"/>`
378
+ ) +
379
+ paragraphStyle('CodeSpacer', 'Code Spacer', '<w:spacing w:after="0" w:line="120" w:lineRule="exact"/>', '<w:sz w:val="8"/>') +
380
+ paragraphStyle('TableText', 'Table Text', '<w:spacing w:before="40" w:after="40"/>', '') +
381
+ paragraphStyle('Rule', 'Rule', '<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="BFBFBF"/></w:pBdr><w:spacing w:after="240"/>', '') +
382
+ paragraphStyle('ContentsTitle', 'Contents Title', '<w:spacing w:before="240" w:after="120"/>', '<w:b/><w:sz w:val="28"/><w:szCs w:val="28"/>') +
383
+ paragraphStyle('ContentsEntry', 'Contents Entry', '<w:spacing w:after="60"/>', '') +
384
+ '<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>' +
385
+ '</w:styles>'
386
+ );
387
+ }
388
+
389
+ numberingXml() {
390
+ const bullets = ['•', '◦', '▪'];
391
+ const formats = ['decimal', 'lowerLetter', 'lowerRoman'];
392
+ const level = (index, format, text) =>
393
+ `<w:lvl w:ilvl="${index}"><w:start w:val="1"/><w:numFmt w:val="${format}"/><w:lvlText w:val="${text}"/>` +
394
+ `<w:lvlJc w:val="left"/><w:pPr><w:ind w:left="${(index + 1) * 720}" w:hanging="360"/></w:pPr></w:lvl>`;
395
+ const levels = make => Array.from({ length: 9 }, (_, index) => make(index)).join('');
396
+ return (
397
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
398
+ '<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
399
+ `<w:abstractNum w:abstractNumId="1"><w:multiLevelType w:val="hybridMultilevel"/>${levels(index => level(index, 'bullet', bullets[index % 3]))}</w:abstractNum>` +
400
+ `<w:abstractNum w:abstractNumId="2"><w:multiLevelType w:val="hybridMultilevel"/>${levels(index => level(index, formats[index % 3], `%${index + 1}.`))}</w:abstractNum>` +
401
+ '<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>' +
402
+ // One numbering instance per ordered list, so each starts where its Markdown says.
403
+ this.orderedLists
404
+ .map(
405
+ (list, index) =>
406
+ `<w:num w:numId="${index + 2}"><w:abstractNumId w:val="2"/>` +
407
+ `<w:lvlOverride w:ilvl="${list.level}"><w:startOverride w:val="${list.start}"/></w:lvlOverride></w:num>`
408
+ )
409
+ .join('') +
410
+ '</w:numbering>'
411
+ );
412
+ }
413
+
414
+ build() {
415
+ const { meta } = this.document;
416
+ // The body first: it is what registers images, links and lists.
417
+ const documentXml = this.documentXml();
418
+ const numberingXml = this.numberingXml();
419
+ this.relationship('http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles', 'styles.xml');
420
+ this.relationship('http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering', 'numbering.xml');
421
+
422
+ const extensions = [...new Set(this.media.map(file => file.name.split('.').pop()))];
423
+ const imageTypes = { png: 'image/png', jpg: 'image/jpeg', gif: 'image/gif' };
424
+ const contentTypes =
425
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
426
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
427
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
428
+ '<Default Extension="xml" ContentType="application/xml"/>' +
429
+ extensions.map(extension => `<Default Extension="${extension}" ContentType="${imageTypes[extension]}"/>`).join('') +
430
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
431
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' +
432
+ '<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>' +
433
+ '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
434
+ '</Types>';
435
+ const packageRels =
436
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
437
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
438
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
439
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
440
+ '</Relationships>';
441
+ const documentRels =
442
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
443
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
444
+ this.relationships
445
+ .map(
446
+ relation =>
447
+ `<Relationship Id="${relation.id}" Type="${relation.type}" Target="${xml(relation.target)}"${relation.external ? ' TargetMode="External"' : ''}/>`
448
+ )
449
+ .join('') +
450
+ '</Relationships>';
451
+ const core =
452
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
453
+ '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' +
454
+ 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' +
455
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
456
+ `<dc:title>${xml(meta.title)}</dc:title>${meta.author ? `<dc:creator>${xml(meta.author)}</dc:creator>` : ''}` +
457
+ '</cp:coreProperties>';
458
+
459
+ return createZip([
460
+ { name: '[Content_Types].xml', data: contentTypes },
461
+ { name: '_rels/.rels', data: packageRels },
462
+ { name: 'docProps/core.xml', data: core },
463
+ { name: 'word/document.xml', data: documentXml },
464
+ { name: 'word/styles.xml', data: this.stylesXml() },
465
+ { name: 'word/numbering.xml', data: numberingXml },
466
+ { name: 'word/_rels/document.xml.rels', data: documentRels },
467
+ ...this.media.map(file => ({ name: `word/media/${file.name}`, data: file.data, store: true }))
468
+ ]);
469
+ }
470
+ }
471
+
472
+ function decodeURIComponentSafe(value) {
473
+ try {
474
+ return decodeURIComponent(value);
475
+ } catch (_) {
476
+ return value;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Converts Markdown to the bytes of a .docx file.
482
+ * @param {string} rawMarkdown
483
+ * @param {Object} [options] The converter's options, plus `baseDir`: the folder images are relative to
484
+ * @returns {Buffer}
485
+ */
486
+ function markdownToDocx(rawMarkdown, options = {}) {
487
+ return new DocxWriter(buildDocument(rawMarkdown, options), options.baseDir).build();
488
+ }
489
+
490
+ module.exports = {
491
+ markdownToDocx
492
+ };