md-pdf-studio 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/bin/cli.js +35 -14
- package/package.json +5 -3
- package/src/core/cover.js +1 -2
- package/src/core/docmodel.js +323 -0
- package/src/core/docx.js +492 -0
- package/src/core/epub.js +337 -0
- package/src/core/footnotes.js +122 -0
- package/src/core/images.js +69 -0
- package/src/core/include.js +104 -0
- package/src/core/index.js +63 -9
- package/src/core/parser.js +3 -2
- package/src/core/templates.js +6 -0
- package/src/core/utils.js +5 -5
- package/src/core/zip.js +77 -0
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
- 📊 **Mermaid Diagrams**: Sequence diagrams (with auto note wrapping), flowcharts, class diagrams, and gantt charts.
|
|
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
|
+
- 📝 **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.
|
|
32
35
|
- 💡 **GitHub Callout Box Alerts**: Support for `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, and `> [!CAUTION]`.
|
|
33
36
|
- 💻 **ASCII UI Mockups**: Styled dark-theme boxes for terminal output & ASCII wireframe mockups.
|
|
34
37
|
|
|
@@ -82,6 +85,29 @@ npx md-pdf-studio file1.md file2.md -o output_dir/
|
|
|
82
85
|
|
|
83
86
|
# Convert entire directory of .md files
|
|
84
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"
|
|
85
111
|
```
|
|
86
112
|
|
|
87
113
|
---
|
|
@@ -90,8 +116,8 @@ npx md-pdf-studio ./docs/ -o ./dist/
|
|
|
90
116
|
|
|
91
117
|
| Flag | Alias | Description | Default |
|
|
92
118
|
| :--- | :--- | :--- | :--- |
|
|
93
|
-
| `-o, --output <path>`| | Output
|
|
94
|
-
| `-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` |
|
|
95
121
|
| `-w, --watch` | | Live watch input file and auto-recompile on change | `false` |
|
|
96
122
|
| `-t, --theme <theme>` | | Theme: `modern`, `dark`, `academic`, `github`, `ebook`, `cyberpunk`, `minimal` | `modern` |
|
|
97
123
|
| `--font <font>` | | Font family: `Inter`, `Roboto`, `Lora`, `Merriweather`, `JetBrains Mono`, `Fira Code` | `Inter` |
|
|
@@ -105,6 +131,7 @@ npx md-pdf-studio ./docs/ -o ./dist/
|
|
|
105
131
|
| `--no-mermaid` | | Disable Mermaid diagram rendering | `false` |
|
|
106
132
|
| `--no-katex` | | Disable KaTeX math formula rendering | `false` |
|
|
107
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` |
|
|
108
135
|
|
|
109
136
|
---
|
|
110
137
|
|
|
@@ -180,6 +207,12 @@ convert-md-to-pdf/
|
|
|
180
207
|
│ │ │ ├── cyberpunk.js # Cyberpunk neon dark theme
|
|
181
208
|
│ │ │ └── minimal.js # Minimal monochrome theme
|
|
182
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
|
|
183
216
|
│ │ ├── cover.js # Cover page generator
|
|
184
217
|
│ │ ├── toc.js # Dotted leader line TOC generator
|
|
185
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
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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
|
+
};
|