md-pdf-studio 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,8 @@
30
30
  - 🌈 **Syntax Highlighting**: Fenced code blocks that name a language (` ```js `, ` ```python `, ` ```diff `, …) are coloured at build time with highlight.js — no CDN, so it also works offline and in static HTML export. Colours follow the theme's code block background. Works in the CLI and in the extension preview.
31
31
  - 📐 **LaTeX Math Support**: Mathematical equations rendered with KaTeX.
32
32
  - 📝 **Footnotes**: `text[^1]` with `[^1]: the note` anywhere in the file. Notes are numbered in the order they are first referenced, gathered at the end of the document, and linked both ways. Works in the CLI and in the extension preview.
33
+ - 📘 **Word & EPUB Export (CLI)**: `-f docx` writes a Word document and `-f epub` an EPUB 3 e-book, straight from the Markdown and without a browser. Headings, lists (nested, numbered from any start, task lists), tables with their alignment, code blocks, quotes, footnotes, links and local images become the real thing in each format — Word styles you can restyle, a chapter per top-level heading and a navigation menu in the EPUB. `--toc`, `--cover`, page size, orientation and margins apply to Word; `--toc` and `--cover` to EPUB. Themes, custom CSS, Mermaid diagrams and KaTeX math are for PDF and HTML only: a diagram or formula is kept as its source. An image on the web is not downloaded — it is named in Word and becomes a link in the EPUB — and a link to a neighbouring file keeps its text only.
34
+ - 📎 **File Includes (CLI)**: A line that holds only `@import "chapters/intro.md"` is replaced by that file, relative to the file the line is in. A Markdown file is inserted as Markdown (without its front matter) and may import further files; any other file — `@import "src/app.js"` — is inserted as a code block labelled with its extension. Lines inside a code block are left alone, a missing file or files that import each other stop the conversion with the file and line, and watch mode follows the imported files. `--no-include` turns it off.
33
35
  - 💡 **GitHub Callout Box Alerts**: Support for `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, and `> [!CAUTION]`.
34
36
  - 💻 **ASCII UI Mockups**: Styled dark-theme boxes for terminal output & ASCII wireframe mockups.
35
37
 
@@ -83,6 +85,29 @@ npx md-pdf-studio file1.md file2.md -o output_dir/
83
85
 
84
86
  # Convert entire directory of .md files
85
87
  npx md-pdf-studio ./docs/ -o ./dist/
88
+
89
+ # Word document with a cover page and a table of contents
90
+ npx md-pdf-studio report.md -f docx --cover --toc
91
+
92
+ # EPUB e-book, one chapter per top-level heading
93
+ npx md-pdf-studio book.md -f epub -o book.epub
94
+ ```
95
+
96
+ A book kept as one file per chapter is one document with includes:
97
+
98
+ ```markdown
99
+ ---
100
+ title: Handbook
101
+ toc: true
102
+ ---
103
+ # Handbook
104
+
105
+ @import "chapters/01-intro.md"
106
+ @import "chapters/02-setup.md"
107
+
108
+ ## Example config
109
+
110
+ @import "examples/config.yml"
86
111
  ```
87
112
 
88
113
  ---
@@ -91,8 +116,8 @@ npx md-pdf-studio ./docs/ -o ./dist/
91
116
 
92
117
  | Flag | Alias | Description | Default |
93
118
  | :--- | :--- | :--- | :--- |
94
- | `-o, --output <path>`| | Output PDF/HTML file path or target directory | Auto-derived |
95
- | `-f, --format <format>`| | Output format: `pdf`, `html` | `pdf` |
119
+ | `-o, --output <path>`| | Output file path or target directory | Auto-derived |
120
+ | `-f, --format <format>`| | Output format: `pdf`, `html`, `docx`, `epub` | `pdf` |
96
121
  | `-w, --watch` | | Live watch input file and auto-recompile on change | `false` |
97
122
  | `-t, --theme <theme>` | | Theme: `modern`, `dark`, `academic`, `github`, `ebook`, `cyberpunk`, `minimal` | `modern` |
98
123
  | `--font <font>` | | Font family: `Inter`, `Roboto`, `Lora`, `Merriweather`, `JetBrains Mono`, `Fira Code` | `Inter` |
@@ -106,6 +131,7 @@ npx md-pdf-studio ./docs/ -o ./dist/
106
131
  | `--no-mermaid` | | Disable Mermaid diagram rendering | `false` |
107
132
  | `--no-katex` | | Disable KaTeX math formula rendering | `false` |
108
133
  | `--no-highlight` | | Disable syntax highlighting of code blocks | `false` |
134
+ | `--no-include` | | Leave `@import "file"` lines as they are instead of inserting the file | `false` |
109
135
 
110
136
  ---
111
137
 
@@ -181,6 +207,12 @@ convert-md-to-pdf/
181
207
  │ │ │ ├── cyberpunk.js # Cyberpunk neon dark theme
182
208
  │ │ │ └── minimal.js # Minimal monochrome theme
183
209
  │ │ ├── frontmatter.js # YAML front matter parser
210
+ │ │ ├── include.js # @import "file" expansion
211
+ │ │ ├── docmodel.js # Format-neutral document model (blocks of flat runs)
212
+ │ │ ├── docx.js # Word (.docx) writer
213
+ │ │ ├── epub.js # EPUB 3 writer
214
+ │ │ ├── images.js # Local image reading & sizes
215
+ │ │ ├── zip.js # ZIP container writer for docx/epub
184
216
  │ │ ├── cover.js # Cover page generator
185
217
  │ │ ├── toc.js # Dotted leader line TOC generator
186
218
  │ │ ├── sanitizer.js # HTML sanitizer & escaping
package/bin/cli.js CHANGED
@@ -6,6 +6,10 @@ const fs = require('fs');
6
6
  const { convertMarkdownToPdf } = require('../src/core/index');
7
7
  const packageJson = require('../package.json');
8
8
 
9
+ const OUTPUT_FORMATS = ['pdf', 'html', 'docx', 'epub'];
10
+ // An --output that names a file rather than a folder.
11
+ const OUTPUT_FILE = /\.(pdf|html|docx|epub)$/i;
12
+
9
13
  const program = new Command();
10
14
 
11
15
  program
@@ -14,8 +18,8 @@ program
14
18
  .description('High-quality Markdown to PDF converter CLI with Mermaid, Math, TOC, Custom CSS, and Batch conversion.')
15
19
  .version(packageJson.version, '-v, --version', 'Output tool version')
16
20
  .argument('<inputs...>', 'Input Markdown file(s) or wildcard pattern (.md)')
17
- .option('-o, --output <path>', 'Output PDF/HTML file path or destination directory')
18
- .option('-f, --format <format>', 'Output format: pdf (default), html', 'pdf')
21
+ .option('-o, --output <path>', 'Output file path or destination directory')
22
+ .option('-f, --format <format>', 'Output format: pdf (default), html, docx, epub', 'pdf')
19
23
  .option('-w, --watch', 'Watch input file(s) and recompile automatically on changes', false)
20
24
  .option('-t, --theme <theme>', 'Theme name: modern (default), dark, academic, minimal, github, ebook, cyberpunk', 'modern')
21
25
  .option('--font <font>', 'Font family: Inter (default), Roboto, Lora, Merriweather, JetBrains Mono, Fira Code', 'Inter')
@@ -29,9 +33,13 @@ program
29
33
  .option('--no-mermaid', 'Disable Mermaid diagram rendering')
30
34
  .option('--no-katex', 'Disable KaTeX math formula rendering')
31
35
  .option('--no-highlight', 'Disable syntax highlighting of code blocks')
36
+ .option('--no-include', 'Leave @import "file" lines as they are instead of inserting the file')
32
37
  .option('-b, --browser <path>', 'Custom Chrome/Edge executable path')
33
38
  .action(async (inputArgs, options) => {
34
39
  try {
40
+ // Files pulled in with @import, so watch mode can follow them too.
41
+ const importedFiles = new Set();
42
+
35
43
  const runConversion = async () => {
36
44
  const startTime = Date.now();
37
45
  const inputFiles = expandInputFiles(inputArgs);
@@ -43,6 +51,9 @@ program
43
51
  }
44
52
 
45
53
  const targetFormat = (options.format || 'pdf').toLowerCase();
54
+ if (!OUTPUT_FORMATS.includes(targetFormat)) {
55
+ throw new Error(`Unknown format "${options.format}". Use one of: ${OUTPUT_FORMATS.join(', ')}`);
56
+ }
46
57
  console.log(`\n\x1b[36m🚀 Converting ${inputFiles.length} file(s) to ${targetFormat.toUpperCase()}...\x1b[0m`);
47
58
  console.log(` Theme: ${options.theme}`);
48
59
  console.log(` Font: ${options.font}`);
@@ -54,7 +65,7 @@ program
54
65
  let targetOutputDir = null;
55
66
  if (options.output) {
56
67
  const resolvedOut = path.resolve(options.output);
57
- if (inputFiles.length === 1 && (resolvedOut.endsWith('.pdf') || resolvedOut.endsWith('.html'))) {
68
+ if (inputFiles.length === 1 && OUTPUT_FILE.test(resolvedOut)) {
58
69
  targetOutputDir = null;
59
70
  } else {
60
71
  targetOutputDir = resolvedOut;
@@ -71,7 +82,7 @@ program
71
82
 
72
83
  if (targetOutputDir) {
73
84
  fileOutPath = path.join(targetOutputDir, `${fileBaseName}.${targetFormat}`);
74
- } else if (options.output && (options.output.endsWith('.pdf') || options.output.endsWith('.html'))) {
85
+ } else if (options.output && OUTPUT_FILE.test(options.output)) {
75
86
  fileOutPath = options.output;
76
87
  }
77
88
 
@@ -91,10 +102,12 @@ program
91
102
  mermaid: options.mermaid,
92
103
  katex: options.katex,
93
104
  highlight: options.highlight,
105
+ include: options.include,
94
106
  executablePath: options.browser
95
107
  });
96
108
 
97
- const outPathDisplay = result.pdfPath || result.htmlPath;
109
+ result.includedFiles.forEach(includedFile => importedFiles.add(includedFile));
110
+ const outPathDisplay = result.outputPath;
98
111
  console.log(` \x1b[32m✔ File saved:\x1b[0m ${outPathDisplay}`);
99
112
  count++;
100
113
  }
@@ -112,27 +125,35 @@ program
112
125
  console.log('\n\x1b[35m👀 Watch mode enabled. Monitoring files for changes...\x1b[0m');
113
126
 
114
127
  let debounceTimer = null;
128
+ let watchImports = () => {};
115
129
  const triggerRecompile = (filename) => {
116
130
  if (debounceTimer) clearTimeout(debounceTimer);
117
131
  debounceTimer = setTimeout(async () => {
118
132
  console.log(`\n\x1b[33m🔄 Change detected in ${filename}. Recompiling...\x1b[0m`);
119
133
  try {
120
134
  await runConversion();
135
+ watchImports();
121
136
  } catch (err) {
122
137
  console.error(`\x1b[31m✖ Error during watch recompile:\x1b[0m ${err.message}`);
123
138
  }
124
139
  }, 300);
125
140
  };
126
141
 
127
- inputFiles.forEach(filePath => {
128
- if (fs.existsSync(filePath)) {
129
- fs.watch(filePath, (eventType) => {
130
- if (eventType === 'change') {
131
- triggerRecompile(path.basename(filePath));
132
- }
133
- });
134
- }
135
- });
142
+ const watched = new Set();
143
+ const watchFile = (filePath) => {
144
+ if (watched.has(filePath) || !fs.existsSync(filePath)) return;
145
+ watched.add(filePath);
146
+ fs.watch(filePath, (eventType) => {
147
+ if (eventType === 'change') {
148
+ triggerRecompile(path.basename(filePath));
149
+ }
150
+ });
151
+ };
152
+ // An edit can add an @import, so the list is read again after every run.
153
+ watchImports = () => importedFiles.forEach(watchFile);
154
+
155
+ inputFiles.forEach(watchFile);
156
+ watchImports();
136
157
 
137
158
  if (options.css && fs.existsSync(options.css)) {
138
159
  fs.watch(options.css, (eventType) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "md-pdf-studio",
3
- "version": "2.2.0",
4
- "description": "A powerful CLI tool and Manifest V3 Browser Extension to convert Markdown files, text, or web pages into beautifully styled PDFs with Mermaid diagrams, LaTeX math, TOC, and themes.",
3
+ "version": "2.3.0",
4
+ "description": "A powerful CLI tool and Manifest V3 Browser Extension to convert Markdown files, text, or web pages into beautifully styled PDFs with Mermaid diagrams, LaTeX math, TOC, and themes. The CLI also writes Word (.docx) and EPUB.",
5
5
  "main": "src/core/index.js",
6
6
  "bin": {
7
7
  "md-pdf-studio": "bin/cli.js",
@@ -33,7 +33,9 @@
33
33
  "md2pdf",
34
34
  "marked",
35
35
  "md-pdf-studio",
36
- "markdown-to-pdf"
36
+ "markdown-to-pdf",
37
+ "docx",
38
+ "epub"
37
39
  ],
38
40
  "author": "vannt-dev",
39
41
  "license": "MIT",
package/src/core/cover.js CHANGED
@@ -31,8 +31,7 @@ function generateCoverPage(metadata = {}) {
31
31
  ${docDate ? `<div class="cover-meta-item"><span class="cover-meta-label">Ngày lập:</span> <span class="cover-meta-val">${escapeHtml(docDate)}</span></div>` : ''}
32
32
  </div>
33
33
  </div>
34
- </div>
35
- <div class="page-break"></div>\n`;
34
+ </div>\n`;
36
35
  }
37
36
 
38
37
  module.exports = {
@@ -0,0 +1,323 @@
1
+ const { marked } = require('marked');
2
+ const { parseFrontMatter } = require('./frontmatter');
3
+ const { applyFootnotes } = require('./footnotes');
4
+ const { preprocessMarkdown } = require('./utils');
5
+
6
+ /**
7
+ * A format-neutral model of a Markdown document, for the exporters that do
8
+ * not go through HTML (DOCX, EPUB).
9
+ *
10
+ * Blocks:
11
+ * { type: 'heading', depth, id, runs }
12
+ * { type: 'paragraph', runs }
13
+ * { type: 'code', lang, text }
14
+ * { type: 'quote', blocks }
15
+ * { type: 'list', ordered, start, items: [{ checked, blocks }] }
16
+ * { type: 'table', header: [{ align, runs }], rows: [[{ align, runs }]] }
17
+ * { type: 'rule' } | { type: 'pageBreak' }
18
+ *
19
+ * Runs are flat: nested emphasis becomes flags on each piece of text, so a
20
+ * writer never has to balance tags.
21
+ * { text, bold, italic, strike, code, sup, sub, href }
22
+ * { image: { src, alt }, href } | { lineBreak: true }
23
+ */
24
+
25
+ const NAMED_ENTITIES = {
26
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®',
27
+ trade: '™', hellip: '…', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“',
28
+ rdquo: '”', laquo: '«', raquo: '»', times: '×', divide: '÷', deg: '°', plusmn: '±',
29
+ micro: 'µ', para: '¶', sect: '§', euro: '€', pound: '£', yen: '¥', cent: '¢', minus: '−',
30
+ larr: '←', rarr: '→', uarr: '↑', darr: '↓', bull: '•', middot: '·'
31
+ };
32
+
33
+ /**
34
+ * Turns HTML character references into the characters they stand for.
35
+ * A reference that is not known is left as it was written.
36
+ * @param {string} text
37
+ * @returns {string}
38
+ */
39
+ function decodeEntities(text = '') {
40
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, name) => {
41
+ if (name[0] === '#') {
42
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
43
+ return code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
44
+ }
45
+ return Object.hasOwn(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : whole;
46
+ });
47
+ }
48
+
49
+ /** The same slugs as the table of contents of the HTML output, kept unique. */
50
+ function createSlugger() {
51
+ const counts = new Map();
52
+ return (title, fallback) => {
53
+ let slug = title
54
+ .toLowerCase()
55
+ .replace(/[^\wÀ-ɏẠ-ỹ]+/g, '-')
56
+ .replace(/^-+|-+$/g, '') || fallback;
57
+ const seen = counts.get(slug) || 0;
58
+ counts.set(slug, seen + 1);
59
+ if (seen > 0) slug = `${slug}-${seen + 1}`;
60
+ return slug;
61
+ };
62
+ }
63
+
64
+ const HTML_TAG = /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^<>]*?)?)\s*(\/?)>/g;
65
+ const FLAG_TAGS = {
66
+ b: 'bold', strong: 'bold', i: 'italic', em: 'italic', s: 'strike', del: 'strike',
67
+ strike: 'strike', code: 'code', kbd: 'code', sup: 'sup', sub: 'sub'
68
+ };
69
+
70
+ function attribute(source, name) {
71
+ const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(source);
72
+ return match ? decodeEntities(match[2] ?? match[3] ?? match[4] ?? '') : null;
73
+ }
74
+
75
+ /**
76
+ * Collects the runs of one block. Raw HTML is reduced to what it means for
77
+ * the text: emphasis, line breaks, links and images are kept, any other tag
78
+ * is dropped and its content stays.
79
+ */
80
+ function createRunCollector() {
81
+ const runs = [];
82
+ const flags = { bold: 0, italic: 0, strike: 0, code: 0, sup: 0, sub: 0 };
83
+ // Raw <a> elements still open: a link target, or null for one whose text is skipped.
84
+ const anchors = [];
85
+ let skipping = 0;
86
+
87
+ const styleNow = (style) => {
88
+ const run = {};
89
+ for (const flag of Object.keys(flags)) {
90
+ if (style[flag] || flags[flag] > 0) run[flag] = true;
91
+ }
92
+ const href = style.href || anchors.filter(anchor => anchor && anchor.href).map(anchor => anchor.href).pop();
93
+ if (href) run.href = href;
94
+ return run;
95
+ };
96
+
97
+ const pushText = (text, style) => {
98
+ if (!text || skipping > 0) return;
99
+ const run = { text, ...styleNow(style) };
100
+ const previous = runs[runs.length - 1];
101
+ // Neighbouring pieces that look the same are one run.
102
+ if (previous && previous.text !== undefined && sameStyle(previous, run)) previous.text += text;
103
+ else runs.push(run);
104
+ };
105
+
106
+ const pushHtml = (html, style) => {
107
+ let last = 0;
108
+ HTML_TAG.lastIndex = 0;
109
+ for (let match = HTML_TAG.exec(html); match; match = HTML_TAG.exec(html)) {
110
+ pushText(decodeEntities(html.slice(last, match.index)), style);
111
+ last = HTML_TAG.lastIndex;
112
+ const closing = match[1] === '/';
113
+ const tag = match[2].toLowerCase();
114
+ const attributes = match[3] || '';
115
+
116
+ if (FLAG_TAGS[tag]) {
117
+ const flag = FLAG_TAGS[tag];
118
+ flags[flag] = Math.max(0, flags[flag] + (closing ? -1 : 1));
119
+ } else if (tag === 'br' && !closing) {
120
+ if (skipping === 0) runs.push({ lineBreak: true });
121
+ } else if (tag === 'img' && !closing) {
122
+ const src = attribute(attributes, 'src');
123
+ if (src && skipping === 0) {
124
+ runs.push({ image: { src, alt: attribute(attributes, 'alt') || '' }, ...styleNow(style) });
125
+ }
126
+ } else if (tag === 'a') {
127
+ if (closing) {
128
+ const anchor = anchors.pop();
129
+ if (anchor === null) skipping = Math.max(0, skipping - 1);
130
+ } else if (/\bfootnote-backref\b/.test(attributes)) {
131
+ // The arrow back to the text means nothing without the link.
132
+ anchors.push(null);
133
+ skipping += 1;
134
+ } else {
135
+ const href = attribute(attributes, 'href');
136
+ // A footnote number stays a number; its target is a list item further down.
137
+ anchors.push({ href: href && !/^#fn-?/.test(href) ? href : null });
138
+ }
139
+ }
140
+ }
141
+ pushText(decodeEntities(html.slice(last)), style);
142
+ };
143
+
144
+ const walk = (tokens, style) => {
145
+ for (const token of tokens || []) {
146
+ switch (token.type) {
147
+ case 'strong': walk(token.tokens, { ...style, bold: true }); break;
148
+ case 'em': walk(token.tokens, { ...style, italic: true }); break;
149
+ case 'del': walk(token.tokens, { ...style, strike: true }); break;
150
+ case 'codespan': pushText(decodeEntities(token.text), { ...style, code: true }); break;
151
+ case 'br': if (skipping === 0) runs.push({ lineBreak: true }); break;
152
+ case 'link': walk(token.tokens, { ...style, href: token.href }); break;
153
+ case 'image':
154
+ if (skipping === 0) runs.push({ image: { src: token.href, alt: token.text || '' }, ...styleNow(style) });
155
+ break;
156
+ case 'html': pushHtml(token.text, style); break;
157
+ case 'checkbox': break;
158
+ case 'escape': pushText(token.text, style); break;
159
+ default:
160
+ if (token.tokens) walk(token.tokens, style);
161
+ else if (typeof token.text === 'string') pushText(decodeEntities(token.text), style);
162
+ }
163
+ }
164
+ };
165
+
166
+ return { runs, walk, pushHtml };
167
+ }
168
+
169
+ function sameStyle(a, b) {
170
+ for (const key of ['bold', 'italic', 'strike', 'code', 'sup', 'sub', 'href']) {
171
+ if ((a[key] || false) !== (b[key] || false)) return false;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ function inlineRuns(tokens) {
177
+ const collector = createRunCollector();
178
+ collector.walk(tokens, {});
179
+ return trimRuns(collector.runs);
180
+ }
181
+
182
+ function htmlRuns(html) {
183
+ const collector = createRunCollector();
184
+ collector.pushHtml(html, {});
185
+ return trimRuns(collector.runs);
186
+ }
187
+
188
+ /**
189
+ * Drops the whitespace at both ends of a block: all that a block of bare tags
190
+ * leaves, or what stood before a tag whose content was skipped.
191
+ */
192
+ function trimRuns(runs) {
193
+ const blank = run => run.text !== undefined && run.text.trim() === '';
194
+ while (runs.length && blank(runs[0])) runs.shift();
195
+ while (runs.length && blank(runs[runs.length - 1])) runs.pop();
196
+ const first = runs[0];
197
+ const last = runs[runs.length - 1];
198
+ if (first && first.text !== undefined) first.text = first.text.replace(/^\s+/, '');
199
+ if (last && last.text !== undefined) last.text = last.text.replace(/\s+$/, '');
200
+ return runs;
201
+ }
202
+
203
+ /**
204
+ * Whether a link still means something once the document is a single file:
205
+ * a web or mail address does, a path to a neighbouring file does not.
206
+ * @param {string} href
207
+ * @returns {boolean}
208
+ */
209
+ function isPortableLink(href) {
210
+ return /^(https?:|mailto:|tel:|ftp:)/i.test(href || '');
211
+ }
212
+
213
+ /** The text of a list of runs, for slugs, titles and alt text. */
214
+ function plainText(runs) {
215
+ return runs.map(run => (run.text !== undefined ? run.text : run.image ? run.image.alt : ' ')).join('');
216
+ }
217
+
218
+ function toBlocks(tokens, slug) {
219
+ const blocks = [];
220
+ for (const token of tokens || []) {
221
+ switch (token.type) {
222
+ case 'space':
223
+ case 'def':
224
+ break;
225
+ case 'heading': {
226
+ const runs = inlineRuns(token.tokens);
227
+ blocks.push({ type: 'heading', depth: token.depth, id: slug(plainText(runs), `heading-${blocks.length + 1}`), runs });
228
+ break;
229
+ }
230
+ case 'code':
231
+ blocks.push({ type: 'code', lang: (token.lang || '').trim().split(/\s+/)[0], text: token.text || '' });
232
+ break;
233
+ case 'blockquote':
234
+ blocks.push({ type: 'quote', blocks: toBlocks(token.tokens, slug) });
235
+ break;
236
+ case 'list':
237
+ blocks.push({
238
+ type: 'list',
239
+ ordered: Boolean(token.ordered),
240
+ start: Number.isInteger(token.start) ? token.start : 1,
241
+ items: token.items.map(item => ({
242
+ checked: item.task ? Boolean(item.checked) : null,
243
+ blocks: toBlocks(item.tokens, slug)
244
+ }))
245
+ });
246
+ break;
247
+ case 'table': {
248
+ const cell = (source, index) => ({ align: token.align[index] || null, runs: inlineRuns(source.tokens) });
249
+ blocks.push({ type: 'table', header: token.header.map(cell), rows: token.rows.map(row => row.map(cell)) });
250
+ break;
251
+ }
252
+ case 'hr':
253
+ blocks.push({ type: 'rule' });
254
+ break;
255
+ case 'html': {
256
+ if (/class=["'][^"']*\bpage-break\b/.test(token.text)) {
257
+ blocks.push({ type: 'pageBreak' });
258
+ } else if (/<section class="footnotes">/.test(token.text)) {
259
+ // The notes follow as an ordinary numbered list; a rule sets them apart.
260
+ blocks.push({ type: 'rule' });
261
+ } else {
262
+ const runs = htmlRuns(token.text);
263
+ if (runs.length) blocks.push({ type: 'paragraph', runs });
264
+ }
265
+ break;
266
+ }
267
+ default: {
268
+ const runs = inlineRuns(token.tokens || [token]);
269
+ if (runs.length) blocks.push({ type: 'paragraph', runs });
270
+ }
271
+ }
272
+ }
273
+ return blocks;
274
+ }
275
+
276
+ /**
277
+ * Reads a Markdown document into its metadata and blocks.
278
+ * @param {string} rawMarkdown
279
+ * @param {Object} [options] The converter's options; front matter fills in what they leave out
280
+ * @returns {{ meta: Object, blocks: Object[], settings: Object }}
281
+ */
282
+ function buildDocument(rawMarkdown = '', options = {}) {
283
+ const { data: frontMatter, content } = parseFrontMatter(rawMarkdown);
284
+ const titleMatch = content.match(/^#\s+(.+)$/m);
285
+ const meta = {
286
+ title: frontMatter.title || options.title || (titleMatch ? titleMatch[1].trim() : 'Converted Document'),
287
+ subtitle: frontMatter.subtitle || options.subtitle || '',
288
+ author: frontMatter.author || options.author || '',
289
+ date: frontMatter.date || options.date || '',
290
+ language: frontMatter.lang || frontMatter.language || options.language || 'en'
291
+ };
292
+ const settings = {
293
+ font: options.font || frontMatter.font || 'Inter',
294
+ pageSize: options.pageSize || frontMatter.pageSize || 'A4',
295
+ orientation: options.orientation || frontMatter.orientation || (frontMatter.landscape ? 'landscape' : 'portrait'),
296
+ margin: options.margin || frontMatter.margin || '14mm 12mm 16mm 12mm',
297
+ toc: options.toc !== undefined ? options.toc : Boolean(frontMatter.toc),
298
+ cover: options.cover !== undefined ? options.cover : Boolean(frontMatter.cover),
299
+ highlight: options.highlight !== undefined ? options.highlight : frontMatter.highlight !== false
300
+ };
301
+
302
+ const processed = preprocessMarkdown(applyFootnotes(content));
303
+ const tokens = marked.lexer(processed, { gfm: true, breaks: true });
304
+ return { meta, blocks: toBlocks(tokens, createSlugger()), settings };
305
+ }
306
+
307
+ /** Every heading of the document, at any depth of nesting, in order. */
308
+ function collectHeadings(blocks, found = []) {
309
+ for (const block of blocks) {
310
+ if (block.type === 'heading') found.push(block);
311
+ else if (block.type === 'quote') collectHeadings(block.blocks, found);
312
+ else if (block.type === 'list') block.items.forEach(item => collectHeadings(item.blocks, found));
313
+ }
314
+ return found;
315
+ }
316
+
317
+ module.exports = {
318
+ buildDocument,
319
+ collectHeadings,
320
+ decodeEntities,
321
+ isPortableLink,
322
+ plainText
323
+ };