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