md-pdf-studio 2.1.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.
- package/README.md +35 -2
- package/bin/cli.js +35 -14
- package/package.json +5 -3
- package/src/core/cover.js +1 -2
- package/src/core/docmodel.js +323 -0
- package/src/core/docx.js +492 -0
- package/src/core/epub.js +337 -0
- package/src/core/footnotes.js +122 -0
- package/src/core/images.js +69 -0
- package/src/core/include.js +104 -0
- package/src/core/index.js +63 -9
- package/src/core/parser.js +3 -2
- package/src/core/templates.js +6 -0
- package/src/core/utils.js +5 -5
- package/src/core/zip.js +77 -0
package/src/core/epub.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const hljs = require('highlight.js/lib/common');
|
|
3
|
+
const { buildDocument, collectHeadings, isPortableLink, plainText } = require('./docmodel');
|
|
4
|
+
const { readLocalImage } = require('./images');
|
|
5
|
+
const { createZip } = require('./zip');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Markdown to EPUB 3, written from the document model. Every page is XHTML
|
|
9
|
+
* serialized here, so it is well formed whatever raw HTML the Markdown holds.
|
|
10
|
+
*
|
|
11
|
+
* The book is split into one file per top-level heading, with a navigation
|
|
12
|
+
* document (and an NCX for older readers) built from the headings. Local
|
|
13
|
+
* images are embedded; an image on the web is replaced by a link to it, since
|
|
14
|
+
* a reader may be offline. Themes, custom CSS, Mermaid and KaTeX are not
|
|
15
|
+
* carried over: a reading system applies its own typography.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function xml(text) {
|
|
19
|
+
return String(text)
|
|
20
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STYLESHEET = `body { font-family: serif; line-height: 1.5; margin: 0 5%; }
|
|
28
|
+
h1, h2, h3, h4, h5, h6 { font-family: sans-serif; line-height: 1.25; page-break-after: avoid; }
|
|
29
|
+
h1 { font-size: 1.8em; margin-top: 1.5em; }
|
|
30
|
+
h2 { font-size: 1.4em; }
|
|
31
|
+
h3 { font-size: 1.2em; }
|
|
32
|
+
p { margin: 0.6em 0; }
|
|
33
|
+
a { color: #0b57d0; }
|
|
34
|
+
img { max-width: 100%; height: auto; }
|
|
35
|
+
code { font-family: monospace; font-size: 0.9em; background: #f2f2f2; padding: 0.1em 0.25em; }
|
|
36
|
+
pre { background: #f5f5f5; padding: 0.8em; white-space: pre-wrap; word-wrap: break-word; font-size: 0.85em; }
|
|
37
|
+
pre code { background: none; padding: 0; font-size: 1em; }
|
|
38
|
+
blockquote { margin: 0.8em 0; padding-left: 1em; border-left: 0.25em solid #bfbfbf; color: #595959; }
|
|
39
|
+
table { border-collapse: collapse; margin: 0.8em 0; width: 100%; }
|
|
40
|
+
th, td { border: 1px solid #bfbfbf; padding: 0.3em 0.5em; text-align: left; vertical-align: top; }
|
|
41
|
+
th { background: #f2f2f2; }
|
|
42
|
+
hr { border: 0; border-top: 1px solid #bfbfbf; margin: 1.5em 0; }
|
|
43
|
+
ul.tasks { list-style: none; padding-left: 1.2em; }
|
|
44
|
+
.page-break { page-break-after: always; }
|
|
45
|
+
.cover { text-align: center; margin-top: 30%; }
|
|
46
|
+
.cover h1 { font-size: 2.2em; }
|
|
47
|
+
.cover .subtitle { font-size: 1.3em; color: #595959; }
|
|
48
|
+
.cover .line { color: #595959; }
|
|
49
|
+
nav ol { list-style: none; padding-left: 1.2em; }
|
|
50
|
+
.hljs-comment, .hljs-quote { color: #6a737d; }
|
|
51
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-type { color: #d73a49; }
|
|
52
|
+
.hljs-string, .hljs-regexp, .hljs-attr, .hljs-addition { color: #22863a; }
|
|
53
|
+
.hljs-number, .hljs-built_in, .hljs-symbol, .hljs-meta { color: #005cc5; }
|
|
54
|
+
.hljs-title, .hljs-section, .hljs-name, .hljs-function { color: #6f42c1; }
|
|
55
|
+
.hljs-deletion { color: #b31d28; }
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
class EpubWriter {
|
|
59
|
+
constructor(document, baseDir) {
|
|
60
|
+
this.document = document;
|
|
61
|
+
this.baseDir = baseDir;
|
|
62
|
+
this.images = [];
|
|
63
|
+
this.imagePaths = new Map();
|
|
64
|
+
// Where each heading ends up, so a link to `#slug` can name the right file.
|
|
65
|
+
this.targets = new Map();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
image(run) {
|
|
69
|
+
const { src, alt } = run.image;
|
|
70
|
+
if (!this.imagePaths.has(src)) {
|
|
71
|
+
const image = readLocalImage(src, this.baseDir);
|
|
72
|
+
if (image) {
|
|
73
|
+
const name = `images/image-${this.images.length + 1}.${image.extension}`;
|
|
74
|
+
this.images.push({ name, data: image.data, mediaType: image.mediaType });
|
|
75
|
+
this.imagePaths.set(src, name);
|
|
76
|
+
} else {
|
|
77
|
+
this.imagePaths.set(src, null);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const path = this.imagePaths.get(src);
|
|
81
|
+
if (path) return `<img src="${xml(path)}" alt="${xml(alt)}"/>`;
|
|
82
|
+
// Not a local file: a link stands in for it when it has a web address.
|
|
83
|
+
const label = xml(alt || src);
|
|
84
|
+
return /^https?:\/\//i.test(src) ? `<a href="${xml(src)}">${label}</a>` : `<em>[${label}]</em>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The address a link is written with, or null when it would lead nowhere from inside the book. */
|
|
88
|
+
href(target) {
|
|
89
|
+
if (target[0] !== '#') return isPortableLink(target) ? target : null;
|
|
90
|
+
let id = target.slice(1);
|
|
91
|
+
try {
|
|
92
|
+
id = decodeURIComponent(id);
|
|
93
|
+
} catch (_) {
|
|
94
|
+
// Used as written.
|
|
95
|
+
}
|
|
96
|
+
const file = this.targets.get(id);
|
|
97
|
+
return file ? `${file}#${encodeURIComponent(id)}` : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
runs(runs) {
|
|
101
|
+
return runs
|
|
102
|
+
.map(run => {
|
|
103
|
+
let html;
|
|
104
|
+
if (run.lineBreak) return '<br/>';
|
|
105
|
+
if (run.image) html = this.image(run);
|
|
106
|
+
else html = xml(run.text);
|
|
107
|
+
if (run.code) html = `<code>${html}</code>`;
|
|
108
|
+
if (run.sup) html = `<sup>${html}</sup>`;
|
|
109
|
+
else if (run.sub) html = `<sub>${html}</sub>`;
|
|
110
|
+
if (run.strike) html = `<del>${html}</del>`;
|
|
111
|
+
if (run.italic) html = `<em>${html}</em>`;
|
|
112
|
+
if (run.bold) html = `<strong>${html}</strong>`;
|
|
113
|
+
const href = run.href ? this.href(run.href) : null;
|
|
114
|
+
// An image that already became a link is not wrapped in a second one.
|
|
115
|
+
if (href && !html.startsWith('<a ')) html = `<a href="${xml(href)}">${html}</a>`;
|
|
116
|
+
return html;
|
|
117
|
+
})
|
|
118
|
+
.join('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
code(block) {
|
|
122
|
+
const highlight = this.document.settings.highlight && block.lang && hljs.getLanguage(block.lang);
|
|
123
|
+
// highlight.js escapes the code and adds only balanced spans, so its output is XHTML too.
|
|
124
|
+
const body = highlight ? hljs.highlight(block.text, { language: block.lang, ignoreIllegals: true }).value : xml(block.text);
|
|
125
|
+
return `<pre><code>${body}</code></pre>\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
blocks(blocks) {
|
|
129
|
+
let output = '';
|
|
130
|
+
for (const block of blocks) {
|
|
131
|
+
switch (block.type) {
|
|
132
|
+
case 'heading': {
|
|
133
|
+
const tag = `h${Math.min(block.depth, 6)}`;
|
|
134
|
+
output += `<${tag} id="${xml(block.id)}">${this.runs(block.runs)}</${tag}>\n`;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'paragraph':
|
|
138
|
+
output += `<p>${this.runs(block.runs)}</p>\n`;
|
|
139
|
+
break;
|
|
140
|
+
case 'code':
|
|
141
|
+
output += this.code(block);
|
|
142
|
+
break;
|
|
143
|
+
case 'quote':
|
|
144
|
+
output += `<blockquote>\n${this.blocks(block.blocks) || '<p></p>\n'}</blockquote>\n`;
|
|
145
|
+
break;
|
|
146
|
+
case 'list': {
|
|
147
|
+
const tasks = block.items.some(item => item.checked !== null);
|
|
148
|
+
const tag = block.ordered ? 'ol' : 'ul';
|
|
149
|
+
const attributes = block.ordered && block.start !== 1 ? ` start="${block.start}"` : !block.ordered && tasks ? ' class="tasks"' : '';
|
|
150
|
+
const items = block.items.map(item => {
|
|
151
|
+
const box = item.checked === null ? '' : item.checked ? '☑ ' : '☐ ';
|
|
152
|
+
// A tight item is one line of text: no paragraph around it.
|
|
153
|
+
const single = item.blocks.length === 1 && item.blocks[0].type === 'paragraph';
|
|
154
|
+
const body = single ? box + this.runs(item.blocks[0].runs) : box + this.blocks(item.blocks);
|
|
155
|
+
return `<li>${body}</li>\n`;
|
|
156
|
+
});
|
|
157
|
+
output += `<${tag}${attributes}>\n${items.join('') || '<li></li>\n'}</${tag}>\n`;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'table': {
|
|
161
|
+
const cell = (tag, source) => {
|
|
162
|
+
const align = source.align && source.align !== 'left' ? ` style="text-align: ${source.align}"` : '';
|
|
163
|
+
return `<${tag}${align}>${this.runs(source.runs)}</${tag}>`;
|
|
164
|
+
};
|
|
165
|
+
output +=
|
|
166
|
+
`<table>\n<thead>\n<tr>${block.header.map(source => cell('th', source)).join('')}</tr>\n</thead>\n` +
|
|
167
|
+
(block.rows.length
|
|
168
|
+
? `<tbody>\n${block.rows.map(row => `<tr>${row.map(source => cell('td', source)).join('')}</tr>\n`).join('')}</tbody>\n`
|
|
169
|
+
: '') +
|
|
170
|
+
'</table>\n';
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'rule':
|
|
174
|
+
output += '<hr/>\n';
|
|
175
|
+
break;
|
|
176
|
+
case 'pageBreak':
|
|
177
|
+
output += '<div class="page-break"></div>\n';
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
page(title, body, bodyAttributes = '') {
|
|
187
|
+
const language = xml(this.document.meta.language);
|
|
188
|
+
return (
|
|
189
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE html>\n' +
|
|
190
|
+
`<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${language}" xml:lang="${language}">\n` +
|
|
191
|
+
`<head>\n<meta charset="UTF-8"/>\n<title>${xml(title)}</title>\n<link rel="stylesheet" type="text/css" href="style.css"/>\n</head>\n` +
|
|
192
|
+
`<body${bodyAttributes}>\n${body}</body>\n</html>\n`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The blocks cut into chapters: a new one at every top-level heading. */
|
|
197
|
+
chapters() {
|
|
198
|
+
const { blocks, meta } = this.document;
|
|
199
|
+
const chapters = [];
|
|
200
|
+
for (const block of blocks) {
|
|
201
|
+
const opens = block.type === 'heading' && block.depth === 1;
|
|
202
|
+
if (opens || chapters.length === 0) {
|
|
203
|
+
chapters.push({ title: opens ? plainText(block.runs) : meta.title, blocks: [] });
|
|
204
|
+
}
|
|
205
|
+
chapters[chapters.length - 1].blocks.push(block);
|
|
206
|
+
}
|
|
207
|
+
if (chapters.length === 0) chapters.push({ title: meta.title, blocks: [] });
|
|
208
|
+
chapters.forEach((chapter, index) => {
|
|
209
|
+
chapter.file = `chapter-${index + 1}.xhtml`;
|
|
210
|
+
for (const heading of collectHeadings(chapter.blocks)) this.targets.set(heading.id, chapter.file);
|
|
211
|
+
});
|
|
212
|
+
return chapters;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Headings up to the third level as a nested list of { title, href, children }. */
|
|
216
|
+
outline(chapters) {
|
|
217
|
+
const root = { depth: 0, children: [] };
|
|
218
|
+
const stack = [root];
|
|
219
|
+
for (const chapter of chapters) {
|
|
220
|
+
const headings = collectHeadings(chapter.blocks).filter(heading => heading.depth <= 3);
|
|
221
|
+
// A chapter that opens with no heading still has to be reachable.
|
|
222
|
+
if (!headings.length || chapter.blocks[0] !== headings[0]) {
|
|
223
|
+
stack.length = 1;
|
|
224
|
+
const entry = { depth: 1, title: chapter.title, href: chapter.file, children: [] };
|
|
225
|
+
root.children.push(entry);
|
|
226
|
+
stack.push(entry);
|
|
227
|
+
}
|
|
228
|
+
for (const heading of headings) {
|
|
229
|
+
while (stack[stack.length - 1].depth >= heading.depth) stack.pop();
|
|
230
|
+
const entry = { depth: heading.depth, title: plainText(heading.runs) || 'Untitled', href: `${chapter.file}#${encodeURIComponent(heading.id)}`, children: [] };
|
|
231
|
+
stack[stack.length - 1].children.push(entry);
|
|
232
|
+
stack.push(entry);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return root.children;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
build(now = new Date()) {
|
|
239
|
+
const { meta, settings } = this.document;
|
|
240
|
+
const chapters = this.chapters();
|
|
241
|
+
const outline = this.outline(chapters);
|
|
242
|
+
const pages = chapters.map(chapter => ({ name: chapter.file, data: this.page(chapter.title, this.blocks(chapter.blocks)) }));
|
|
243
|
+
|
|
244
|
+
const navList = entries =>
|
|
245
|
+
`<ol>\n${entries
|
|
246
|
+
.map(entry => `<li><a href="${xml(entry.href)}">${xml(entry.title)}</a>${entry.children.length ? `\n${navList(entry.children)}` : ''}</li>\n`)
|
|
247
|
+
.join('')}</ol>\n`;
|
|
248
|
+
const nav = this.page(
|
|
249
|
+
'Table of Contents',
|
|
250
|
+
`<nav epub:type="toc" id="toc">\n<h1>Table of Contents</h1>\n${navList(outline)}</nav>\n`
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
let playOrder = 0;
|
|
254
|
+
const navPoints = entries =>
|
|
255
|
+
entries
|
|
256
|
+
.map(entry => {
|
|
257
|
+
const order = ++playOrder;
|
|
258
|
+
return (
|
|
259
|
+
`<navPoint id="nav-${order}" playOrder="${order}"><navLabel><text>${xml(entry.title)}</text></navLabel>` +
|
|
260
|
+
`<content src="${xml(entry.href)}"/>${navPoints(entry.children)}</navPoint>\n`
|
|
261
|
+
);
|
|
262
|
+
})
|
|
263
|
+
.join('');
|
|
264
|
+
const identifier = `urn:uuid:${crypto.randomUUID()}`;
|
|
265
|
+
const ncx =
|
|
266
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n' +
|
|
267
|
+
`<head><meta name="dtb:uid" content="${identifier}"/><meta name="dtb:depth" content="3"/>` +
|
|
268
|
+
'<meta name="dtb:totalPageCount" content="0"/><meta name="dtb:maxPageNumber" content="0"/></head>\n' +
|
|
269
|
+
`<docTitle><text>${xml(meta.title)}</text></docTitle>\n<navMap>\n${navPoints(outline)}</navMap>\n</ncx>\n`;
|
|
270
|
+
|
|
271
|
+
let cover = null;
|
|
272
|
+
if (settings.cover) {
|
|
273
|
+
cover = this.page(
|
|
274
|
+
meta.title,
|
|
275
|
+
`<div class="cover">\n<h1>${xml(meta.title)}</h1>\n` +
|
|
276
|
+
(meta.subtitle ? `<p class="subtitle">${xml(meta.subtitle)}</p>\n` : '') +
|
|
277
|
+
[meta.author, meta.date].filter(Boolean).map(line => `<p class="line">${xml(line)}</p>\n`).join('') +
|
|
278
|
+
'</div>\n'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const manifest = [
|
|
283
|
+
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
|
284
|
+
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
|
|
285
|
+
'<item id="css" href="style.css" media-type="text/css"/>',
|
|
286
|
+
...(cover ? ['<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>'] : []),
|
|
287
|
+
...pages.map((page, index) => `<item id="chapter-${index + 1}" href="${page.name}" media-type="application/xhtml+xml"/>`),
|
|
288
|
+
...this.images.map((image, index) => `<item id="image-${index + 1}" href="${xml(image.name)}" media-type="${image.mediaType}"/>`)
|
|
289
|
+
];
|
|
290
|
+
const spine = [
|
|
291
|
+
...(cover ? ['<itemref idref="cover"/>'] : []),
|
|
292
|
+
// The contents page is always in the book for the reader's own menu; it is a page to read only when asked for.
|
|
293
|
+
...(settings.toc ? ['<itemref idref="nav"/>'] : []),
|
|
294
|
+
...pages.map((_, index) => `<itemref idref="chapter-${index + 1}"/>`)
|
|
295
|
+
];
|
|
296
|
+
const opf =
|
|
297
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
298
|
+
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">\n' +
|
|
299
|
+
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n' +
|
|
300
|
+
`<dc:identifier id="book-id">${identifier}</dc:identifier>\n<dc:title>${xml(meta.title)}</dc:title>\n` +
|
|
301
|
+
`<dc:language>${xml(meta.language)}</dc:language>\n` +
|
|
302
|
+
(meta.author ? `<dc:creator>${xml(meta.author)}</dc:creator>\n` : '') +
|
|
303
|
+
`<meta property="dcterms:modified">${now.toISOString().replace(/\.\d+Z$/, 'Z')}</meta>\n</metadata>\n` +
|
|
304
|
+
`<manifest>\n${manifest.join('\n')}\n</manifest>\n<spine toc="ncx">\n${spine.join('\n')}\n</spine>\n</package>\n`;
|
|
305
|
+
const container =
|
|
306
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
307
|
+
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n' +
|
|
308
|
+
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>\n</container>\n';
|
|
309
|
+
|
|
310
|
+
return createZip([
|
|
311
|
+
// First and uncompressed: it is how a reader recognizes the file.
|
|
312
|
+
{ name: 'mimetype', data: 'application/epub+zip', store: true },
|
|
313
|
+
{ name: 'META-INF/container.xml', data: container },
|
|
314
|
+
{ name: 'OEBPS/content.opf', data: opf },
|
|
315
|
+
{ name: 'OEBPS/nav.xhtml', data: nav },
|
|
316
|
+
{ name: 'OEBPS/toc.ncx', data: ncx },
|
|
317
|
+
{ name: 'OEBPS/style.css', data: STYLESHEET },
|
|
318
|
+
...(cover ? [{ name: 'OEBPS/cover.xhtml', data: cover }] : []),
|
|
319
|
+
...pages.map(page => ({ name: `OEBPS/${page.name}`, data: page.data })),
|
|
320
|
+
...this.images.map(image => ({ name: `OEBPS/${image.name}`, data: image.data, store: true }))
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Converts Markdown to the bytes of an .epub file.
|
|
327
|
+
* @param {string} rawMarkdown
|
|
328
|
+
* @param {Object} [options] The converter's options, plus `baseDir`: the folder images are relative to
|
|
329
|
+
* @returns {Buffer}
|
|
330
|
+
*/
|
|
331
|
+
function markdownToEpub(rawMarkdown, options = {}) {
|
|
332
|
+
return new EpubWriter(buildDocument(rawMarkdown, options), options.baseDir).build();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = {
|
|
336
|
+
markdownToEpub
|
|
337
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown footnotes: `text[^id]` in the body, `[^id]: note` anywhere on a line of its own.
|
|
3
|
+
*
|
|
4
|
+
* Works on the Markdown text, before it is parsed, and needs nothing from Node: the same file
|
|
5
|
+
* is shipped in the extension as extension/lib/footnotes.js (a test keeps the two identical).
|
|
6
|
+
*/
|
|
7
|
+
(function (scope) {
|
|
8
|
+
const DEFINITION = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
|
9
|
+
const REFERENCE = /\[\^([^\]\s]+)\]/g;
|
|
10
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
11
|
+
const CONTINUATION = /^(?: {2,}|\t)\S/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Splits the text into lines tagged as code or prose. Footnote syntax inside a fenced
|
|
15
|
+
* block is an example of the syntax, not a footnote.
|
|
16
|
+
* @param {string} markdown
|
|
17
|
+
* @returns {{ text: string, code: boolean }[]}
|
|
18
|
+
*/
|
|
19
|
+
function tagLines(markdown) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
let fence = null;
|
|
22
|
+
for (const text of markdown.split(/\r?\n/)) {
|
|
23
|
+
const match = FENCE.exec(text);
|
|
24
|
+
if (fence) {
|
|
25
|
+
lines.push({ text, code: true });
|
|
26
|
+
if (match && match[1][0] === fence[0] && match[1].length >= fence.length) fence = null;
|
|
27
|
+
} else if (match) {
|
|
28
|
+
fence = match[1];
|
|
29
|
+
lines.push({ text, code: true });
|
|
30
|
+
} else {
|
|
31
|
+
lines.push({ text, code: false });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Applies `replace` to the parts of a line that are not inline code.
|
|
39
|
+
* @param {string} line
|
|
40
|
+
* @param {(prose: string) => string} replace
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function outsideInlineCode(line, replace) {
|
|
44
|
+
return line
|
|
45
|
+
.split(/(`+[^`]*`+)/)
|
|
46
|
+
.map((part, index) => (index % 2 === 1 ? part : replace(part)))
|
|
47
|
+
.join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Numbers the footnotes of a Markdown document in the order they are first referenced,
|
|
52
|
+
* replaces each reference with a superscript link, and appends the notes as a list.
|
|
53
|
+
*
|
|
54
|
+
* A reference to a note that is never defined is left as the author typed it; a note that
|
|
55
|
+
* is never referenced is left out, as GitHub does.
|
|
56
|
+
* @param {string} markdown
|
|
57
|
+
* @returns {string} Markdown, with HTML for the references and the notes section
|
|
58
|
+
*/
|
|
59
|
+
function applyFootnotes(markdown = '') {
|
|
60
|
+
if (!markdown || markdown.indexOf('[^') === -1) return markdown;
|
|
61
|
+
|
|
62
|
+
const lines = tagLines(markdown);
|
|
63
|
+
const definitions = new Map();
|
|
64
|
+
const body = [];
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < lines.length; index++) {
|
|
67
|
+
const line = lines[index];
|
|
68
|
+
const match = line.code ? null : DEFINITION.exec(line.text);
|
|
69
|
+
if (!match) {
|
|
70
|
+
body.push(line);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const parts = [match[2].trim()];
|
|
74
|
+
// Indented lines that follow belong to the note.
|
|
75
|
+
while (
|
|
76
|
+
index + 1 < lines.length &&
|
|
77
|
+
!lines[index + 1].code &&
|
|
78
|
+
CONTINUATION.test(lines[index + 1].text)
|
|
79
|
+
) {
|
|
80
|
+
index += 1;
|
|
81
|
+
parts.push(lines[index].text.trim());
|
|
82
|
+
}
|
|
83
|
+
// The first definition of an id wins, as with link reference definitions.
|
|
84
|
+
if (!definitions.has(match[1])) definitions.set(match[1], parts.join(' ').trim());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (definitions.size === 0) return markdown;
|
|
88
|
+
|
|
89
|
+
const numbers = new Map();
|
|
90
|
+
const uses = new Map();
|
|
91
|
+
const text = body
|
|
92
|
+
.map((line) => {
|
|
93
|
+
if (line.code) return line.text;
|
|
94
|
+
return outsideInlineCode(line.text, (prose) =>
|
|
95
|
+
prose.replace(REFERENCE, (whole, id) => {
|
|
96
|
+
if (!definitions.has(id)) return whole;
|
|
97
|
+
if (!numbers.has(id)) numbers.set(id, numbers.size + 1);
|
|
98
|
+
const number = numbers.get(id);
|
|
99
|
+
const use = (uses.get(id) || 0) + 1;
|
|
100
|
+
uses.set(id, use);
|
|
101
|
+
const anchor = use === 1 ? `fnref-${number}` : `fnref-${number}-${use}`;
|
|
102
|
+
return `<sup class="footnote-ref"><a href="#fn-${number}" id="${anchor}">${number}</a></sup>`;
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
})
|
|
106
|
+
.join('\n');
|
|
107
|
+
|
|
108
|
+
if (numbers.size === 0) return text;
|
|
109
|
+
|
|
110
|
+
const notes = Array.from(numbers.entries()).map(([id, number]) => {
|
|
111
|
+
const note = definitions.get(id);
|
|
112
|
+
return `${number}. <span id="fn-${number}"></span>${note} <a href="#fnref-${number}" class="footnote-backref" aria-label="Back to the text">↩</a>`;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The blank lines let Markdown inside the section be parsed as Markdown.
|
|
116
|
+
return `${text.replace(/\s+$/, '')}\n\n<section class="footnotes">\n\n${notes.join('\n')}\n\n</section>\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const api = { applyFootnotes };
|
|
120
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
121
|
+
else scope.MdPdfFootnotes = api;
|
|
122
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local images for the exporters that embed them (DOCX, EPUB).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MEDIA_TYPES = {
|
|
9
|
+
png: 'image/png',
|
|
10
|
+
jpg: 'image/jpeg',
|
|
11
|
+
jpeg: 'image/jpeg',
|
|
12
|
+
gif: 'image/gif',
|
|
13
|
+
svg: 'image/svg+xml',
|
|
14
|
+
webp: 'image/webp'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Width and height in pixels of a PNG, JPEG or GIF, read from its header.
|
|
19
|
+
* @param {Buffer} data
|
|
20
|
+
* @returns {{ width: number, height: number } | null} null for anything else
|
|
21
|
+
*/
|
|
22
|
+
function imageSize(data) {
|
|
23
|
+
if (data.length >= 24 && data.readUInt32BE(0) === 0x89504e47 && data.toString('ascii', 12, 16) === 'IHDR') {
|
|
24
|
+
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
|
|
25
|
+
}
|
|
26
|
+
if (data.length >= 10 && data.toString('ascii', 0, 3) === 'GIF') {
|
|
27
|
+
return { width: data.readUInt16LE(6), height: data.readUInt16LE(8) };
|
|
28
|
+
}
|
|
29
|
+
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
|
30
|
+
let offset = 2;
|
|
31
|
+
while (offset + 9 < data.length) {
|
|
32
|
+
if (data[offset] !== 0xff) return null;
|
|
33
|
+
const marker = data[offset + 1];
|
|
34
|
+
// Start-of-frame markers carry the size; C4, C8 and CC are other tables.
|
|
35
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
36
|
+
return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) };
|
|
37
|
+
}
|
|
38
|
+
offset += 2 + data.readUInt16BE(offset + 2);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads the image a Markdown document refers to.
|
|
46
|
+
* @param {string} src As written in the document
|
|
47
|
+
* @param {string} baseDir Folder of the document
|
|
48
|
+
* @returns {{ data: Buffer, extension: string, mediaType: string, size: { width: number, height: number } | null } | null}
|
|
49
|
+
* null when it is not a readable local file of a known type (a web address, for one)
|
|
50
|
+
*/
|
|
51
|
+
function readLocalImage(src, baseDir) {
|
|
52
|
+
if (!src || /^[a-z][a-z0-9+.-]*:/i.test(src) && !/^file:/i.test(src) && !/^[a-zA-Z]:[\\/]/.test(src)) return null;
|
|
53
|
+
let relative = src.replace(/^file:\/\/\/?/i, '').split(/[?#]/)[0];
|
|
54
|
+
try {
|
|
55
|
+
relative = decodeURIComponent(relative);
|
|
56
|
+
} catch (_) {
|
|
57
|
+
// A stray percent sign: the name is used as written.
|
|
58
|
+
}
|
|
59
|
+
const filePath = path.resolve(baseDir || process.cwd(), relative);
|
|
60
|
+
const extension = path.extname(filePath).slice(1).toLowerCase();
|
|
61
|
+
if (!MEDIA_TYPES[extension] || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null;
|
|
62
|
+
const data = fs.readFileSync(filePath);
|
|
63
|
+
return { data, extension: extension === 'jpeg' ? 'jpg' : extension, mediaType: MEDIA_TYPES[extension], size: imageSize(data) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
imageSize,
|
|
68
|
+
readLocalImage
|
|
69
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { parseFrontMatter } = require('./frontmatter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File includes: a line that holds only `@import "path"` is replaced by the
|
|
7
|
+
* file it names, relative to the file the line is in.
|
|
8
|
+
*
|
|
9
|
+
* A Markdown file (.md, .markdown) is put in as Markdown, without its front
|
|
10
|
+
* matter, and may import further files itself. Any other file is put in as a
|
|
11
|
+
* fenced code block labelled with its extension, so `@import "src/app.js"`
|
|
12
|
+
* shows the source. Lines inside a fenced code block are left as they are.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const IMPORT_LINE = /^[ \t]*@import[ \t]+(["'])(.+?)\1[ \t]*$/;
|
|
16
|
+
const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})/;
|
|
17
|
+
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
|
|
18
|
+
const MAX_DEPTH = 10;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A fence longer than any run of backticks in the code, so the code cannot
|
|
22
|
+
* close its own block.
|
|
23
|
+
* @param {string} code
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function fenceFor(code) {
|
|
27
|
+
const longest = (code.match(/`+/g) || []).reduce((max, run) => Math.max(max, run.length), 0);
|
|
28
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function expand(markdown, filePath, chain, files) {
|
|
32
|
+
const baseDir = path.dirname(filePath);
|
|
33
|
+
const output = [];
|
|
34
|
+
let fence = null;
|
|
35
|
+
|
|
36
|
+
markdown.split(/\r?\n/).forEach((line, index) => {
|
|
37
|
+
const fenceMatch = line.match(FENCE_LINE);
|
|
38
|
+
if (fenceMatch) {
|
|
39
|
+
const marker = fenceMatch[1];
|
|
40
|
+
if (!fence) {
|
|
41
|
+
fence = marker;
|
|
42
|
+
} else if (marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) {
|
|
43
|
+
fence = null;
|
|
44
|
+
}
|
|
45
|
+
output.push(line);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const importMatch = fence ? null : line.match(IMPORT_LINE);
|
|
50
|
+
if (!importMatch) {
|
|
51
|
+
output.push(line);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const where = `${filePath}:${index + 1}`;
|
|
56
|
+
const target = path.resolve(baseDir, importMatch[2]);
|
|
57
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
58
|
+
throw new Error(`Cannot import "${importMatch[2]}" (${where}): file not found at ${target}`);
|
|
59
|
+
}
|
|
60
|
+
if (chain.includes(target)) {
|
|
61
|
+
const loop = [...chain.slice(chain.indexOf(target)), target]
|
|
62
|
+
.map(file => path.basename(file))
|
|
63
|
+
.join(' → ');
|
|
64
|
+
throw new Error(`Cannot import "${importMatch[2]}" (${where}): the files import each other (${loop})`);
|
|
65
|
+
}
|
|
66
|
+
if (chain.length > MAX_DEPTH) {
|
|
67
|
+
throw new Error(`Cannot import "${importMatch[2]}" (${where}): imports are nested more than ${MAX_DEPTH} deep`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
files.add(target);
|
|
71
|
+
const text = fs.readFileSync(target, 'utf8').replace(/^/, '');
|
|
72
|
+
const extension = path.extname(target).toLowerCase();
|
|
73
|
+
|
|
74
|
+
if (MARKDOWN_EXTENSIONS.has(extension)) {
|
|
75
|
+
const body = parseFrontMatter(text).content;
|
|
76
|
+
output.push(expand(body, target, [...chain, target], files).replace(/(\r?\n)+$/, ''));
|
|
77
|
+
} else {
|
|
78
|
+
const code = text.replace(/(\r?\n)+$/, '');
|
|
79
|
+
const marker = fenceFor(code);
|
|
80
|
+
output.push(`${marker}${extension.slice(1)}`, code, marker);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return output.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Replaces every `@import "path"` line of a Markdown document with the file
|
|
89
|
+
* it names.
|
|
90
|
+
* @param {string} markdown Content of the document
|
|
91
|
+
* @param {string} filePath Path of the document; imports are relative to it
|
|
92
|
+
* @returns {{ content: string, files: string[] }} The expanded Markdown and
|
|
93
|
+
* the absolute path of every file that was imported, directly or not
|
|
94
|
+
*/
|
|
95
|
+
function expandIncludes(markdown = '', filePath = '') {
|
|
96
|
+
const absolutePath = path.resolve(filePath || 'document.md');
|
|
97
|
+
const files = new Set();
|
|
98
|
+
const content = expand(markdown, absolutePath, [absolutePath], files);
|
|
99
|
+
return { content, files: Array.from(files) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
expandIncludes
|
|
104
|
+
};
|