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,324 @@
1
+ /*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
2
+ const { marked } = require('marked');
3
+ const { parseFrontMatter } = require('./frontmatter');
4
+ const { applyFootnotes } = require('./footnotes');
5
+ const { preprocessMarkdown } = require('./utils');
6
+
7
+ /**
8
+ * A format-neutral model of a Markdown document, for the exporters that do
9
+ * not go through HTML (DOCX, EPUB).
10
+ *
11
+ * Blocks:
12
+ * { type: 'heading', depth, id, runs }
13
+ * { type: 'paragraph', runs }
14
+ * { type: 'code', lang, text }
15
+ * { type: 'quote', blocks }
16
+ * { type: 'list', ordered, start, items: [{ checked, blocks }] }
17
+ * { type: 'table', header: [{ align, runs }], rows: [[{ align, runs }]] }
18
+ * { type: 'rule' } | { type: 'pageBreak' }
19
+ *
20
+ * Runs are flat: nested emphasis becomes flags on each piece of text, so a
21
+ * writer never has to balance tags.
22
+ * { text, bold, italic, strike, code, sup, sub, href }
23
+ * { image: { src, alt }, href } | { lineBreak: true }
24
+ */
25
+
26
+ const NAMED_ENTITIES = {
27
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®',
28
+ trade: '™', hellip: '…', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“',
29
+ rdquo: '”', laquo: '«', raquo: '»', times: '×', divide: '÷', deg: '°', plusmn: '±',
30
+ micro: 'µ', para: '¶', sect: '§', euro: '€', pound: '£', yen: '¥', cent: '¢', minus: '−',
31
+ larr: '←', rarr: '→', uarr: '↑', darr: '↓', bull: '•', middot: '·'
32
+ };
33
+
34
+ /**
35
+ * Turns HTML character references into the characters they stand for.
36
+ * A reference that is not known is left as it was written.
37
+ * @param {string} text
38
+ * @returns {string}
39
+ */
40
+ function decodeEntities(text = '') {
41
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, name) => {
42
+ if (name[0] === '#') {
43
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
44
+ return code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
45
+ }
46
+ return Object.hasOwn(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : whole;
47
+ });
48
+ }
49
+
50
+ /** The same slugs as the table of contents of the HTML output, kept unique. */
51
+ function createSlugger() {
52
+ const counts = new Map();
53
+ return (title, fallback) => {
54
+ let slug = title
55
+ .toLowerCase()
56
+ .replace(/[^\wÀ-ɏẠ-ỹ]+/g, '-')
57
+ .replace(/^-+|-+$/g, '') || fallback;
58
+ const seen = counts.get(slug) || 0;
59
+ counts.set(slug, seen + 1);
60
+ if (seen > 0) slug = `${slug}-${seen + 1}`;
61
+ return slug;
62
+ };
63
+ }
64
+
65
+ const HTML_TAG = /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^<>]*?)?)\s*(\/?)>/g;
66
+ const FLAG_TAGS = {
67
+ b: 'bold', strong: 'bold', i: 'italic', em: 'italic', s: 'strike', del: 'strike',
68
+ strike: 'strike', code: 'code', kbd: 'code', sup: 'sup', sub: 'sub'
69
+ };
70
+
71
+ function attribute(source, name) {
72
+ const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(source);
73
+ return match ? decodeEntities(match[2] ?? match[3] ?? match[4] ?? '') : null;
74
+ }
75
+
76
+ /**
77
+ * Collects the runs of one block. Raw HTML is reduced to what it means for
78
+ * the text: emphasis, line breaks, links and images are kept, any other tag
79
+ * is dropped and its content stays.
80
+ */
81
+ function createRunCollector() {
82
+ const runs = [];
83
+ const flags = { bold: 0, italic: 0, strike: 0, code: 0, sup: 0, sub: 0 };
84
+ // Raw <a> elements still open: a link target, or null for one whose text is skipped.
85
+ const anchors = [];
86
+ let skipping = 0;
87
+
88
+ const styleNow = (style) => {
89
+ const run = {};
90
+ for (const flag of Object.keys(flags)) {
91
+ if (style[flag] || flags[flag] > 0) run[flag] = true;
92
+ }
93
+ const href = style.href || anchors.filter(anchor => anchor && anchor.href).map(anchor => anchor.href).pop();
94
+ if (href) run.href = href;
95
+ return run;
96
+ };
97
+
98
+ const pushText = (text, style) => {
99
+ if (!text || skipping > 0) return;
100
+ const run = { text, ...styleNow(style) };
101
+ const previous = runs[runs.length - 1];
102
+ // Neighbouring pieces that look the same are one run.
103
+ if (previous && previous.text !== undefined && sameStyle(previous, run)) previous.text += text;
104
+ else runs.push(run);
105
+ };
106
+
107
+ const pushHtml = (html, style) => {
108
+ let last = 0;
109
+ HTML_TAG.lastIndex = 0;
110
+ for (let match = HTML_TAG.exec(html); match; match = HTML_TAG.exec(html)) {
111
+ pushText(decodeEntities(html.slice(last, match.index)), style);
112
+ last = HTML_TAG.lastIndex;
113
+ const closing = match[1] === '/';
114
+ const tag = match[2].toLowerCase();
115
+ const attributes = match[3] || '';
116
+
117
+ if (FLAG_TAGS[tag]) {
118
+ const flag = FLAG_TAGS[tag];
119
+ flags[flag] = Math.max(0, flags[flag] + (closing ? -1 : 1));
120
+ } else if (tag === 'br' && !closing) {
121
+ if (skipping === 0) runs.push({ lineBreak: true });
122
+ } else if (tag === 'img' && !closing) {
123
+ const src = attribute(attributes, 'src');
124
+ if (src && skipping === 0) {
125
+ runs.push({ image: { src, alt: attribute(attributes, 'alt') || '' }, ...styleNow(style) });
126
+ }
127
+ } else if (tag === 'a') {
128
+ if (closing) {
129
+ const anchor = anchors.pop();
130
+ if (anchor === null) skipping = Math.max(0, skipping - 1);
131
+ } else if (/\bfootnote-backref\b/.test(attributes)) {
132
+ // The arrow back to the text means nothing without the link.
133
+ anchors.push(null);
134
+ skipping += 1;
135
+ } else {
136
+ const href = attribute(attributes, 'href');
137
+ // A footnote number stays a number; its target is a list item further down.
138
+ anchors.push({ href: href && !/^#fn-?/.test(href) ? href : null });
139
+ }
140
+ }
141
+ }
142
+ pushText(decodeEntities(html.slice(last)), style);
143
+ };
144
+
145
+ const walk = (tokens, style) => {
146
+ for (const token of tokens || []) {
147
+ switch (token.type) {
148
+ case 'strong': walk(token.tokens, { ...style, bold: true }); break;
149
+ case 'em': walk(token.tokens, { ...style, italic: true }); break;
150
+ case 'del': walk(token.tokens, { ...style, strike: true }); break;
151
+ case 'codespan': pushText(decodeEntities(token.text), { ...style, code: true }); break;
152
+ case 'br': if (skipping === 0) runs.push({ lineBreak: true }); break;
153
+ case 'link': walk(token.tokens, { ...style, href: token.href }); break;
154
+ case 'image':
155
+ if (skipping === 0) runs.push({ image: { src: token.href, alt: token.text || '' }, ...styleNow(style) });
156
+ break;
157
+ case 'html': pushHtml(token.text, style); break;
158
+ case 'checkbox': break;
159
+ case 'escape': pushText(token.text, style); break;
160
+ default:
161
+ if (token.tokens) walk(token.tokens, style);
162
+ else if (typeof token.text === 'string') pushText(decodeEntities(token.text), style);
163
+ }
164
+ }
165
+ };
166
+
167
+ return { runs, walk, pushHtml };
168
+ }
169
+
170
+ function sameStyle(a, b) {
171
+ for (const key of ['bold', 'italic', 'strike', 'code', 'sup', 'sub', 'href']) {
172
+ if ((a[key] || false) !== (b[key] || false)) return false;
173
+ }
174
+ return true;
175
+ }
176
+
177
+ function inlineRuns(tokens) {
178
+ const collector = createRunCollector();
179
+ collector.walk(tokens, {});
180
+ return trimRuns(collector.runs);
181
+ }
182
+
183
+ function htmlRuns(html) {
184
+ const collector = createRunCollector();
185
+ collector.pushHtml(html, {});
186
+ return trimRuns(collector.runs);
187
+ }
188
+
189
+ /**
190
+ * Drops the whitespace at both ends of a block: all that a block of bare tags
191
+ * leaves, or what stood before a tag whose content was skipped.
192
+ */
193
+ function trimRuns(runs) {
194
+ const blank = run => run.text !== undefined && run.text.trim() === '';
195
+ while (runs.length && blank(runs[0])) runs.shift();
196
+ while (runs.length && blank(runs[runs.length - 1])) runs.pop();
197
+ const first = runs[0];
198
+ const last = runs[runs.length - 1];
199
+ if (first && first.text !== undefined) first.text = first.text.replace(/^\s+/, '');
200
+ if (last && last.text !== undefined) last.text = last.text.replace(/\s+$/, '');
201
+ return runs;
202
+ }
203
+
204
+ /**
205
+ * Whether a link still means something once the document is a single file:
206
+ * a web or mail address does, a path to a neighbouring file does not.
207
+ * @param {string} href
208
+ * @returns {boolean}
209
+ */
210
+ function isPortableLink(href) {
211
+ return /^(https?:|mailto:|tel:|ftp:)/i.test(href || '');
212
+ }
213
+
214
+ /** The text of a list of runs, for slugs, titles and alt text. */
215
+ function plainText(runs) {
216
+ return runs.map(run => (run.text !== undefined ? run.text : run.image ? run.image.alt : ' ')).join('');
217
+ }
218
+
219
+ function toBlocks(tokens, slug) {
220
+ const blocks = [];
221
+ for (const token of tokens || []) {
222
+ switch (token.type) {
223
+ case 'space':
224
+ case 'def':
225
+ break;
226
+ case 'heading': {
227
+ const runs = inlineRuns(token.tokens);
228
+ blocks.push({ type: 'heading', depth: token.depth, id: slug(plainText(runs), `heading-${blocks.length + 1}`), runs });
229
+ break;
230
+ }
231
+ case 'code':
232
+ blocks.push({ type: 'code', lang: (token.lang || '').trim().split(/\s+/)[0], text: token.text || '' });
233
+ break;
234
+ case 'blockquote':
235
+ blocks.push({ type: 'quote', blocks: toBlocks(token.tokens, slug) });
236
+ break;
237
+ case 'list':
238
+ blocks.push({
239
+ type: 'list',
240
+ ordered: Boolean(token.ordered),
241
+ start: Number.isInteger(token.start) ? token.start : 1,
242
+ items: token.items.map(item => ({
243
+ checked: item.task ? Boolean(item.checked) : null,
244
+ blocks: toBlocks(item.tokens, slug)
245
+ }))
246
+ });
247
+ break;
248
+ case 'table': {
249
+ const cell = (source, index) => ({ align: token.align[index] || null, runs: inlineRuns(source.tokens) });
250
+ blocks.push({ type: 'table', header: token.header.map(cell), rows: token.rows.map(row => row.map(cell)) });
251
+ break;
252
+ }
253
+ case 'hr':
254
+ blocks.push({ type: 'rule' });
255
+ break;
256
+ case 'html': {
257
+ if (/class=["'][^"']*\bpage-break\b/.test(token.text)) {
258
+ blocks.push({ type: 'pageBreak' });
259
+ } else if (/<section class="footnotes">/.test(token.text)) {
260
+ // The notes follow as an ordinary numbered list; a rule sets them apart.
261
+ blocks.push({ type: 'rule' });
262
+ } else {
263
+ const runs = htmlRuns(token.text);
264
+ if (runs.length) blocks.push({ type: 'paragraph', runs });
265
+ }
266
+ break;
267
+ }
268
+ default: {
269
+ const runs = inlineRuns(token.tokens || [token]);
270
+ if (runs.length) blocks.push({ type: 'paragraph', runs });
271
+ }
272
+ }
273
+ }
274
+ return blocks;
275
+ }
276
+
277
+ /**
278
+ * Reads a Markdown document into its metadata and blocks.
279
+ * @param {string} rawMarkdown
280
+ * @param {Object} [options] The converter's options; front matter fills in what they leave out
281
+ * @returns {{ meta: Object, blocks: Object[], settings: Object }}
282
+ */
283
+ function buildDocument(rawMarkdown = '', options = {}) {
284
+ const { data: frontMatter, content } = parseFrontMatter(rawMarkdown);
285
+ const titleMatch = content.match(/^#\s+(.+)$/m);
286
+ const meta = {
287
+ title: frontMatter.title || options.title || (titleMatch ? titleMatch[1].trim() : 'Converted Document'),
288
+ subtitle: frontMatter.subtitle || options.subtitle || '',
289
+ author: frontMatter.author || options.author || '',
290
+ date: frontMatter.date || options.date || '',
291
+ language: frontMatter.lang || frontMatter.language || options.language || 'en'
292
+ };
293
+ const settings = {
294
+ font: options.font || frontMatter.font || 'Inter',
295
+ pageSize: options.pageSize || frontMatter.pageSize || 'A4',
296
+ orientation: options.orientation || frontMatter.orientation || (frontMatter.landscape ? 'landscape' : 'portrait'),
297
+ margin: options.margin || frontMatter.margin || '14mm 12mm 16mm 12mm',
298
+ toc: options.toc !== undefined ? options.toc : Boolean(frontMatter.toc),
299
+ cover: options.cover !== undefined ? options.cover : Boolean(frontMatter.cover),
300
+ highlight: options.highlight !== undefined ? options.highlight : frontMatter.highlight !== false
301
+ };
302
+
303
+ const processed = preprocessMarkdown(applyFootnotes(content));
304
+ const tokens = marked.lexer(processed, { gfm: true, breaks: true });
305
+ return { meta, blocks: toBlocks(tokens, createSlugger()), settings };
306
+ }
307
+
308
+ /** Every heading of the document, at any depth of nesting, in order. */
309
+ function collectHeadings(blocks, found = []) {
310
+ for (const block of blocks) {
311
+ if (block.type === 'heading') found.push(block);
312
+ else if (block.type === 'quote') collectHeadings(block.blocks, found);
313
+ else if (block.type === 'list') block.items.forEach(item => collectHeadings(item.blocks, found));
314
+ }
315
+ return found;
316
+ }
317
+
318
+ module.exports = {
319
+ buildDocument,
320
+ collectHeadings,
321
+ decodeEntities,
322
+ isPortableLink,
323
+ plainText
324
+ };