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.
@@ -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, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;');
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
+ };
@@ -1,3 +1,4 @@
1
+ /*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
1
2
  /**
2
3
  * Markdown footnotes: `text[^id]` in the body, `[^id]: note` anywhere on a line of its own.
3
4
  *
@@ -1,3 +1,4 @@
1
+ /*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
1
2
  /**
2
3
  * Front Matter Parser Utility
3
4
  */
@@ -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
- * Main function to convert Markdown file to PDF
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 .pdf file
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
- const markdownContent = fs.readFileSync(absInputPath, 'utf8');
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 = path.join(path.parse(absOutputPath).dir, `_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.html`);
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
- renderHtmlToPdf
145
+ pdfOutline,
146
+ renderHtmlToPdf,
147
+ temporaryHtmlPath
80
148
  };