md-pdf-studio 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -9
- package/bin/cli.js +13 -1
- package/package.json +2 -2
- package/src/browser/renderer.js +27 -18
- package/src/cli/options.js +61 -0
- package/src/core/cover.js +22 -3
- package/src/core/docmodel.js +1 -0
- package/src/core/docx.js +1 -0
- package/src/core/epub.js +1 -0
- package/src/core/footnotes.js +1 -0
- package/src/core/frontmatter.js +1 -0
- package/src/core/images.js +1 -0
- package/src/core/include.js +1 -0
- package/src/core/index.js +17 -3
- package/src/core/pagemarks.js +142 -0
- package/src/core/parser.js +20 -3
- package/src/core/sanitizer.js +1 -0
- package/src/core/templates.js +4 -2
- package/src/core/themes/academic.js +1 -0
- package/src/core/themes/cover.js +1 -0
- package/src/core/themes/cyberpunk.js +1 -0
- package/src/core/themes/dark.js +1 -0
- package/src/core/themes/ebook.js +1 -0
- package/src/core/themes/github.js +1 -0
- package/src/core/themes/highlight.js +1 -0
- package/src/core/themes/index.js +1 -0
- package/src/core/themes/minimal.js +1 -0
- package/src/core/themes/modern.js +1 -0
- package/src/core/toc.js +1 -0
- package/src/core/utils.js +1 -0
- package/src/core/zip.js +1 -0
- package/src/index.js +1 -0
package/README.md
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/md-pdf-studio)
|
|
9
9
|

|
|
10
10
|

|
|
11
|
+
[](https://vannt-dev.github.io/official.html)
|
|
11
12
|
|
|
12
13
|
---
|
|
13
14
|
|
|
15
|
+
Official builds are listed at [vannt-dev.github.io/official.html](https://vannt-dev.github.io/official.html);
|
|
16
|
+
anything else is not published by vannt-dev.
|
|
17
|
+
|
|
14
18
|
## ✨ Features
|
|
15
19
|
|
|
16
20
|
- 🧩 **Browser Extension (Manifest V3)**:
|
|
@@ -19,7 +23,9 @@
|
|
|
19
23
|
- Convert active web page or GitHub raw Markdown files with 1 click.
|
|
20
24
|
- Context menu integration: Right-click selected Markdown text or any page -> "Convert to PDF".
|
|
21
25
|
- Full-page Print Preview with live theme switching, Font Selector, Auto TOC, Custom CSS, and native PDF/HTML export.
|
|
22
|
-
-
|
|
26
|
+
- Page numbers and a running header/footer, set in the popup (off by default).
|
|
27
|
+
- 📑 **Auto Cover Page Generator**: Generate elegant document report cover pages via YAML Front Matter (`cover: true`) or `--cover` flag. The author and date labels are in English, or in Vietnamese with `lang: vi` in the front matter.
|
|
28
|
+
- 🔢 **Page Numbers & Running Header/Footer**: `--page-numbers` prints "Page 3 of 12" at the foot of each page (`--page-numbers simple` prints "3"). `--header "text"` and `--footer "text"` print a line of text at the top and foot of each page, with `{title}`, `{author}`, `{date}`, `{page}` and `{pages}` filled in (`--header "{title}"` prints the document title, `""` prints nothing). Both need their text right after the flag: `md2pdf --header README.md` is refused, because `README.md` is an input file. `--page-numbers` takes `full` or `simple` as its value and nothing else, so `md2pdf --page-numbers README.md` converts `README.md`. With both a footer and page numbers, the footer text sits left and the numbers right. The cover page is printed without them, but counts as page 1. All off by default; front matter keys `header`, `footer` and `pageNumbers` do the same. PDF only (the CLI and the extension); Word and EPUB leave them out.
|
|
23
29
|
- 📌 **Dotted Leader Line Table of Contents (TOC)**: Auto-generates publication-grade TOC with dotted leader lines (`...`) and clickable anchor links (`--toc`).
|
|
24
30
|
- 🎨 **7 Preset Themes**: `modern` (default), `dark`, `academic`, `github`, `ebook`, `cyberpunk`, and `minimal`.
|
|
25
31
|
- 🔤 **Open-Source Font Selector**: 100% free under SIL OFL / Apache 2.0 (`Inter`, `Roboto`, `Lora`, `Merriweather`, `JetBrains Mono`, `Fira Code`).
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
- 📐 **LaTeX Math Support**: Mathematical equations rendered with KaTeX.
|
|
32
38
|
- 📝 **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
39
|
- 📘 **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.
|
|
40
|
+
- 🔖 **PDF Bookmarks (CLI)**: The PDF gets an outline (bookmarks) built from its headings, nested by level, so a PDF viewer's sidebar can jump to any section. On by default; `--no-outline` or `outline: false` in the front matter leaves it out. The extension prints through the browser's print dialog, which does not add bookmarks.
|
|
34
41
|
- 📎 **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.
|
|
35
42
|
- 💡 **GitHub Callout Box Alerts**: Support for `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, and `> [!CAUTION]`.
|
|
36
43
|
- 💻 **ASCII UI Mockups**: Styled dark-theme boxes for terminal output & ASCII wireframe mockups.
|
|
@@ -123,6 +130,9 @@ toc: true
|
|
|
123
130
|
| `--font <font>` | | Font family: `Inter`, `Roboto`, `Lora`, `Merriweather`, `JetBrains Mono`, `Fira Code` | `Inter` |
|
|
124
131
|
| `--cover` | | Auto-generate document report Cover Page | `false` |
|
|
125
132
|
| `--toc` | | Auto-generate Table of Contents with Dotted Leader Lines | `false` |
|
|
133
|
+
| `--page-numbers [style]` | | Page numbers at the foot of each page: `full` ("Page 3 of 12") or `simple` ("3"); any other next argument stays an input | off (`full` when given alone) |
|
|
134
|
+
| `--header <text>` | | Running header; `{title}`, `{author}`, `{date}`, `{page}`, `{pages}` are filled in; `""` for none | off |
|
|
135
|
+
| `--footer <text>` | | Running footer, same placeholders; `""` for none | off |
|
|
126
136
|
| `-p, --page-size <size>`| | Paper size: `A4`, `Letter`, `A3`, `Legal` | `A4` |
|
|
127
137
|
| `-l, --landscape` | | Use landscape page orientation | `false` |
|
|
128
138
|
| `-m, --margin <margin>`| | Custom page margin (e.g. `14mm 12mm 16mm 12mm`) | `14mm 12mm 16mm 12mm` |
|
|
@@ -132,6 +142,7 @@ toc: true
|
|
|
132
142
|
| `--no-katex` | | Disable KaTeX math formula rendering | `false` |
|
|
133
143
|
| `--no-highlight` | | Disable syntax highlighting of code blocks | `false` |
|
|
134
144
|
| `--no-include` | | Leave `@import "file"` lines as they are instead of inserting the file | `false` |
|
|
145
|
+
| `--no-outline` | | Leave out the PDF bookmarks (outline) made from the headings | `false` |
|
|
135
146
|
|
|
136
147
|
---
|
|
137
148
|
|
|
@@ -152,6 +163,13 @@ font: Inter
|
|
|
152
163
|
pageSize: A4
|
|
153
164
|
landscape: false
|
|
154
165
|
highlight: true
|
|
166
|
+
# document language (default en): cover labels and the page's lang
|
|
167
|
+
lang: en
|
|
168
|
+
# running header and footer; header: true also means the title
|
|
169
|
+
header: "{title}"
|
|
170
|
+
footer: "Engineering Team"
|
|
171
|
+
# full ("Page 3 of 12"), simple ("3") or false
|
|
172
|
+
pageNumbers: full
|
|
155
173
|
---
|
|
156
174
|
# Document Executive Summary
|
|
157
175
|
...
|
|
@@ -159,13 +177,10 @@ highlight: true
|
|
|
159
177
|
|
|
160
178
|
---
|
|
161
179
|
|
|
162
|
-
## 🌿
|
|
163
|
-
|
|
164
|
-
This project follows standard **Git Flow** branching and automated release publishing:
|
|
180
|
+
## 🌿 Branching & Automated Release Workflow
|
|
165
181
|
|
|
166
182
|
### 🔀 Branching Strategy
|
|
167
|
-
- **`main`**:
|
|
168
|
-
- **`develop`**: Active development branch. Daily commits, feature additions, and refactoring take place here.
|
|
183
|
+
- **`main`**: The default branch. Changes arrive through pull requests from short-lived feature branches, and pushing a version tag `v*` from `main` triggers a release.
|
|
169
184
|
|
|
170
185
|
### 🚀 Automated Release Pipeline
|
|
171
186
|
Whenever a version tag `v*` is pushed, GitHub Actions ([`.github/workflows/release.yml`](.github/workflows/release.yml)) builds from that tag and automatically:
|
|
@@ -219,18 +234,20 @@ convert-md-to-pdf/
|
|
|
219
234
|
│ │ ├── utils.js # Preprocessors & helper re-exports
|
|
220
235
|
│ │ ├── parser.js # Pipeline parser (Markdown -> HTML)
|
|
221
236
|
│ │ ├── templates.js # HTML document template builder
|
|
237
|
+
│ │ ├── pagemarks.js # Page numbers & running header/footer CSS (shared with the extension)
|
|
222
238
|
│ │ └── index.js # Main library API entrypoint
|
|
239
|
+
│ ├── cli/ # Command line option checks (options.js)
|
|
223
240
|
│ └── browser/ # Headless browser rendering engine
|
|
224
|
-
│ └── renderer.js #
|
|
241
|
+
│ └── renderer.js # Runs installed Chrome/Edge headless (--print-to-pdf) & PDF worker pool
|
|
225
242
|
├── extension/ # Browser Extension (Manifest V3)
|
|
226
243
|
│ ├── manifest.json
|
|
227
244
|
│ ├── background/ # Service worker & context menus
|
|
228
245
|
│ ├── content/ # Content script for raw MD pages
|
|
229
246
|
│ ├── popup/ # Extension Popup UI (Batch file, Paste, Page, History)
|
|
230
247
|
│ ├── preview/ # Print Preview & PDF/HTML export page
|
|
231
|
-
│ ├── lib/ # Vendor libraries (marked, mermaid, katex)
|
|
248
|
+
│ ├── lib/ # Vendor libraries (marked, mermaid, katex) & code shared with the CLI
|
|
232
249
|
│ └── icons/ # Extension icons (16, 48, 128)
|
|
233
|
-
├── tests/ # Standalone test suite (
|
|
250
|
+
├── tests/ # Standalone test suite (`npm test`)
|
|
234
251
|
│ └── index.test.js
|
|
235
252
|
├── dist/ # Built Chrome Extension zip artifacts
|
|
236
253
|
├── examples/ # Usage examples & custom CSS templates
|
package/bin/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
2
3
|
|
|
3
4
|
const { Command } = require('commander');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const { convertMarkdownToPdf } = require('../src/core/index');
|
|
8
|
+
const { normalizePageNumbersArgs, parseMarginText } = require('../src/cli/options');
|
|
7
9
|
const packageJson = require('../package.json');
|
|
8
10
|
|
|
9
11
|
const OUTPUT_FORMATS = ['pdf', 'html', 'docx', 'epub'];
|
|
@@ -28,12 +30,16 @@ program
|
|
|
28
30
|
.option('-l, --landscape', 'Use landscape orientation', false)
|
|
29
31
|
.option('-m, --margin <margin>', 'Page margin (e.g. 14mm 12mm 16mm 12mm)', '14mm 12mm 16mm 12mm')
|
|
30
32
|
.option('--toc', 'Auto-generate Table of Contents (TOC)', false)
|
|
33
|
+
.option('--page-numbers [style]', 'Print page numbers in the footer: full ("Page 3 of 12", default) or simple ("3")')
|
|
34
|
+
.option('--header <text>', 'Running header text; {title}, {author}, {date}, {page}, {pages} are filled in ("" for none)', parseMarginText)
|
|
35
|
+
.option('--footer <text>', 'Running footer text, same placeholders ("" for none)', parseMarginText)
|
|
31
36
|
.option('-c, --css <path>', 'Custom CSS stylesheet file path to inject')
|
|
32
37
|
.option('-k, --keep-html', 'Keep temporary HTML file after conversion', false)
|
|
33
38
|
.option('--no-mermaid', 'Disable Mermaid diagram rendering')
|
|
34
39
|
.option('--no-katex', 'Disable KaTeX math formula rendering')
|
|
35
40
|
.option('--no-highlight', 'Disable syntax highlighting of code blocks')
|
|
36
41
|
.option('--no-include', 'Leave @import "file" lines as they are instead of inserting the file')
|
|
42
|
+
.option('--no-outline', 'Leave out the PDF bookmarks (outline) made from the headings')
|
|
37
43
|
.option('-b, --browser <path>', 'Custom Chrome/Edge executable path')
|
|
38
44
|
.action(async (inputArgs, options) => {
|
|
39
45
|
try {
|
|
@@ -60,6 +66,7 @@ program
|
|
|
60
66
|
console.log(` Paper: ${options.pageSize} (${options.landscape ? 'Landscape' : 'Portrait'})`);
|
|
61
67
|
if (options.cover) console.log(` Cover: Auto-generated Cover Page Enabled`);
|
|
62
68
|
if (options.toc) console.log(` TOC: Auto-generated Table of Contents Enabled`);
|
|
69
|
+
if (options.pageNumbers) console.log(` Pages: Page numbers (${options.pageNumbers === true ? 'full' : options.pageNumbers})`);
|
|
63
70
|
if (options.css) console.log(` CSS: ${path.resolve(options.css)}`);
|
|
64
71
|
|
|
65
72
|
let targetOutputDir = null;
|
|
@@ -97,12 +104,17 @@ program
|
|
|
97
104
|
margin: options.margin,
|
|
98
105
|
toc: options.toc,
|
|
99
106
|
cover: options.cover,
|
|
107
|
+
header: options.header,
|
|
108
|
+
footer: options.footer,
|
|
109
|
+
pageNumbers: options.pageNumbers,
|
|
100
110
|
cssFile: options.css,
|
|
101
111
|
keepHtml: options.keepHtml,
|
|
102
112
|
mermaid: options.mermaid,
|
|
103
113
|
katex: options.katex,
|
|
104
114
|
highlight: options.highlight,
|
|
105
115
|
include: options.include,
|
|
116
|
+
// Only an explicit --no-outline overrides `outline:` in the front matter.
|
|
117
|
+
outline: options.outline === false ? false : undefined,
|
|
106
118
|
executablePath: options.browser
|
|
107
119
|
});
|
|
108
120
|
|
|
@@ -206,4 +218,4 @@ function expandInputFiles(args) {
|
|
|
206
218
|
return Array.from(filesSet);
|
|
207
219
|
}
|
|
208
220
|
|
|
209
|
-
program.parse(process.argv);
|
|
221
|
+
program.parse(normalizePageNumbersArgs(process.argv));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-pdf-studio",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
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": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"@commitlint/cli": "^21.2.3",
|
|
60
60
|
"@commitlint/config-conventional": "^21.2.3",
|
|
61
61
|
"adm-zip": "^0.6.0",
|
|
62
|
-
"lefthook": "^2.1
|
|
62
|
+
"lefthook": "^2.2.1"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/browser/renderer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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 os = require('os');
|
|
@@ -48,6 +49,29 @@ function findBrowserExecutable() {
|
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The command line that prints a page to PDF with headless Chrome/Edge.
|
|
54
|
+
* @param {string} fileUrl Page to print
|
|
55
|
+
* @param {string} outputPath PDF to write
|
|
56
|
+
* @param {Object} [options]
|
|
57
|
+
* @param {boolean} [options.legacyHeadless] Use `--headless` for browsers without `--headless=new`
|
|
58
|
+
* @param {boolean} [options.outline] Add PDF bookmarks from the headings (default true)
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
function buildBrowserArgs(fileUrl, outputPath, options = {}) {
|
|
62
|
+
return [
|
|
63
|
+
options.legacyHeadless ? '--headless' : '--headless=new',
|
|
64
|
+
'--disable-gpu',
|
|
65
|
+
'--no-sandbox',
|
|
66
|
+
'--no-pdf-header-footer',
|
|
67
|
+
// Bookmarks from h1-h6. A browser that does not know the switch ignores it.
|
|
68
|
+
...(options.outline === false ? [] : ['--generate-pdf-document-outline']),
|
|
69
|
+
'--virtual-time-budget=8000',
|
|
70
|
+
`--print-to-pdf=${outputPath}`,
|
|
71
|
+
fileUrl
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* Renders HTML file to PDF using headless browser
|
|
53
77
|
* @param {string} htmlPath
|
|
@@ -64,28 +88,12 @@ function renderHtmlToPdf(htmlPath, outputPath, options = {}) {
|
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
const fileUrl = 'file:///' + path.resolve(htmlPath).replace(/\\/g, '/');
|
|
67
|
-
const args =
|
|
68
|
-
'--headless=new',
|
|
69
|
-
'--disable-gpu',
|
|
70
|
-
'--no-sandbox',
|
|
71
|
-
'--no-pdf-header-footer',
|
|
72
|
-
'--virtual-time-budget=8000',
|
|
73
|
-
`--print-to-pdf=${outputPath}`,
|
|
74
|
-
fileUrl
|
|
75
|
-
];
|
|
91
|
+
const args = buildBrowserArgs(fileUrl, outputPath, { outline: options.outline });
|
|
76
92
|
|
|
77
93
|
execFile(browserPath, args, { timeout: 30000 }, (error) => {
|
|
78
94
|
if (error) {
|
|
79
95
|
// Fallback for older chrome versions that don't support --headless=new
|
|
80
|
-
const fallbackArgs =
|
|
81
|
-
'--headless',
|
|
82
|
-
'--disable-gpu',
|
|
83
|
-
'--no-sandbox',
|
|
84
|
-
'--no-pdf-header-footer',
|
|
85
|
-
'--virtual-time-budget=8000',
|
|
86
|
-
`--print-to-pdf=${outputPath}`,
|
|
87
|
-
fileUrl
|
|
88
|
-
];
|
|
96
|
+
const fallbackArgs = buildBrowserArgs(fileUrl, outputPath, { outline: options.outline, legacyHeadless: true });
|
|
89
97
|
|
|
90
98
|
execFile(browserPath, fallbackArgs, { timeout: 30000 }, (err2) => {
|
|
91
99
|
if (err2) {
|
|
@@ -135,6 +143,7 @@ async function renderBatchHtmlToPdf(itemPairs, options = {}) {
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
module.exports = {
|
|
146
|
+
buildBrowserArgs,
|
|
138
147
|
findBrowserExecutable,
|
|
139
148
|
renderHtmlToPdf,
|
|
140
149
|
renderBatchHtmlToPdf
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
2
|
+
/**
|
|
3
|
+
* Command line helpers for options whose value could be mistaken for an input file.
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { InvalidArgumentError } = require('commander');
|
|
7
|
+
|
|
8
|
+
const PAGE_NUMBER_STYLES = ['full', 'simple'];
|
|
9
|
+
const INPUT_FILE = /\.(md|markdown|txt)$/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `--page-numbers` takes an optional style. Only a known style is read as its value, so in
|
|
13
|
+
* `--page-numbers README.md` the file stays an input: the flag is given its default instead.
|
|
14
|
+
* @param {string[]} argv process.argv
|
|
15
|
+
* @returns {string[]}
|
|
16
|
+
*/
|
|
17
|
+
function normalizePageNumbersArgs(argv) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === '--') {
|
|
22
|
+
out.push(...argv.slice(i));
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
if (arg === '--page-numbers') {
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next !== undefined && PAGE_NUMBER_STYLES.includes(next.toLowerCase())) {
|
|
28
|
+
out.push(`--page-numbers=${next.toLowerCase()}`);
|
|
29
|
+
i++;
|
|
30
|
+
} else {
|
|
31
|
+
out.push('--page-numbers=full');
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
out.push(arg);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Value check for `--header <text>` and `--footer <text>`. An empty string turns them off.
|
|
42
|
+
* A value that names an existing Markdown file is refused: it is almost always an input file
|
|
43
|
+
* that followed the flag, as in `md2pdf --header README.md`.
|
|
44
|
+
* @param {string} value
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function parseMarginText(value) {
|
|
48
|
+
if (INPUT_FILE.test(value) && fs.existsSync(value) && fs.statSync(value).isFile()) {
|
|
49
|
+
throw new InvalidArgumentError(
|
|
50
|
+
`"${value}" is an input file, not header/footer text. Give the text right after the flag, ` +
|
|
51
|
+
`e.g. --header "{title}" ${value}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
PAGE_NUMBER_STYLES,
|
|
59
|
+
normalizePageNumbersArgs,
|
|
60
|
+
parseMarginText
|
|
61
|
+
};
|
package/src/core/cover.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
1
2
|
/**
|
|
2
3
|
* Cover Page Generator Utility
|
|
3
4
|
*/
|
|
4
5
|
const { escapeHtml } = require('./sanitizer');
|
|
5
6
|
|
|
7
|
+
// Cover labels by document language (front matter `lang`); any other language gets English.
|
|
8
|
+
const COVER_LABELS = {
|
|
9
|
+
en: { author: 'Author', date: 'Date' },
|
|
10
|
+
vi: { author: 'Tác giả', date: 'Ngày lập' }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The cover labels for a language tag such as `en`, `vi` or `vi-VN`.
|
|
15
|
+
* @param {string} [language]
|
|
16
|
+
* @returns {{ author: string, date: string }}
|
|
17
|
+
*/
|
|
18
|
+
function coverLabels(language) {
|
|
19
|
+
const primary = String(language || 'en').toLowerCase().split(/[-_]/)[0];
|
|
20
|
+
return COVER_LABELS[primary] || COVER_LABELS.en;
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
/**
|
|
7
24
|
* Generates a Cover Page HTML block if requested in front matter or options
|
|
8
25
|
* @param {Object} metadata
|
|
9
26
|
* @returns {string} Cover page HTML string
|
|
10
27
|
*/
|
|
11
28
|
function generateCoverPage(metadata = {}) {
|
|
12
|
-
const { title, subtitle, author, date, cover } = metadata;
|
|
29
|
+
const { title, subtitle, author, date, cover, language } = metadata;
|
|
13
30
|
|
|
14
31
|
if (!cover && !subtitle && !author && !date) {
|
|
15
32
|
return '';
|
|
@@ -19,6 +36,7 @@ function generateCoverPage(metadata = {}) {
|
|
|
19
36
|
const docSubtitle = subtitle || '';
|
|
20
37
|
const docAuthor = author || '';
|
|
21
38
|
const docDate = date || '';
|
|
39
|
+
const labels = coverLabels(language);
|
|
22
40
|
|
|
23
41
|
return `<div class="cover-page">
|
|
24
42
|
<div class="cover-page-inner">
|
|
@@ -27,13 +45,14 @@ function generateCoverPage(metadata = {}) {
|
|
|
27
45
|
${docSubtitle ? `<h2 class="cover-subtitle">${escapeHtml(docSubtitle)}</h2>` : ''}
|
|
28
46
|
<div class="cover-divider"></div>
|
|
29
47
|
<div class="cover-meta-container">
|
|
30
|
-
${docAuthor ? `<div class="cover-meta-item"><span class="cover-meta-label"
|
|
31
|
-
${docDate ? `<div class="cover-meta-item"><span class="cover-meta-label"
|
|
48
|
+
${docAuthor ? `<div class="cover-meta-item"><span class="cover-meta-label">${labels.author}:</span> <span class="cover-meta-val">${escapeHtml(docAuthor)}</span></div>` : ''}
|
|
49
|
+
${docDate ? `<div class="cover-meta-item"><span class="cover-meta-label">${labels.date}:</span> <span class="cover-meta-val">${escapeHtml(docDate)}</span></div>` : ''}
|
|
32
50
|
</div>
|
|
33
51
|
</div>
|
|
34
52
|
</div>\n`;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
module.exports = {
|
|
56
|
+
coverLabels,
|
|
38
57
|
generateCoverPage
|
|
39
58
|
};
|
package/src/core/docmodel.js
CHANGED
package/src/core/docx.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
1
2
|
const { buildDocument, collectHeadings, isPortableLink, plainText } = require('./docmodel');
|
|
2
3
|
const { readLocalImage } = require('./images');
|
|
3
4
|
const { createZip } = require('./zip');
|
package/src/core/epub.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
1
2
|
const crypto = require('crypto');
|
|
2
3
|
const hljs = require('highlight.js/lib/common');
|
|
3
4
|
const { buildDocument, collectHeadings, isPortableLink, plainText } = require('./docmodel');
|
package/src/core/footnotes.js
CHANGED
package/src/core/frontmatter.js
CHANGED
package/src/core/images.js
CHANGED
package/src/core/include.js
CHANGED
package/src/core/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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');
|
|
6
7
|
const { expandIncludes } = require('./include');
|
|
7
8
|
const { markdownToDocx } = require('./docx');
|
|
8
9
|
const { markdownToEpub } = require('./epub');
|
|
@@ -29,6 +30,18 @@ function temporaryHtmlPath(absInputPath, absOutputPath) {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
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
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Main function to convert a Markdown file: to PDF by default, or to the
|
|
34
47
|
* format named in `options.format` (`html`, `docx`, `epub`)
|
|
@@ -98,8 +111,8 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
|
|
|
98
111
|
// Save temporary HTML file
|
|
99
112
|
fs.writeFileSync(tempHtmlPath, htmlContent, 'utf8');
|
|
100
113
|
|
|
101
|
-
// Render HTML to PDF via headless browser
|
|
102
|
-
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) });
|
|
103
116
|
|
|
104
117
|
// Clean up temporary HTML file unless explicitly requested to keep
|
|
105
118
|
if (!options.keepHtml) {
|
|
@@ -129,6 +142,7 @@ module.exports = {
|
|
|
129
142
|
markdownToDocx,
|
|
130
143
|
markdownToEpub,
|
|
131
144
|
parseMarkdownToHtml,
|
|
145
|
+
pdfOutline,
|
|
132
146
|
renderHtmlToPdf,
|
|
133
147
|
temporaryHtmlPath
|
|
134
148
|
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
2
|
+
/**
|
|
3
|
+
* Page numbers and a running header/footer, printed in the page margins with CSS `@page`
|
|
4
|
+
* margin boxes. Chrome and Edge draw these both for `--print-to-pdf` and in their print
|
|
5
|
+
* dialog, so the CLI and the extension share this file: it needs nothing from Node and is
|
|
6
|
+
* shipped in the extension as extension/lib/pagemarks.js (a test keeps the two identical).
|
|
7
|
+
*
|
|
8
|
+
* Header and footer are text with placeholders: {page}, {pages}, {title}, {author}, {date}.
|
|
9
|
+
*/
|
|
10
|
+
(function (scope) {
|
|
11
|
+
const NUMBER_STYLES = {
|
|
12
|
+
full: 'Page {page} of {pages}',
|
|
13
|
+
simple: '{page}'
|
|
14
|
+
};
|
|
15
|
+
const DEFAULT_HEADER = '{title}';
|
|
16
|
+
const PLACEHOLDER = /\{(page|pages|title|author|date)\}/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The text for a header or footer option: a string as it is, `true` for the default.
|
|
20
|
+
* @param {*} value
|
|
21
|
+
* @param {string} fallback Text used for `true`
|
|
22
|
+
* @returns {string} '' when off
|
|
23
|
+
*/
|
|
24
|
+
function textOf(value, fallback) {
|
|
25
|
+
if (value === true) return fallback;
|
|
26
|
+
if (typeof value === 'number') return String(value);
|
|
27
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The page number text for the `pageNumbers` option.
|
|
32
|
+
* @param {*} value true, 'full', 'simple', or false/empty for none
|
|
33
|
+
* @returns {string} '' when off
|
|
34
|
+
*/
|
|
35
|
+
function numbersOf(value) {
|
|
36
|
+
if (value === undefined || value === null || value === false || value === '') return '';
|
|
37
|
+
if (value === true) return NUMBER_STYLES.full;
|
|
38
|
+
const style = String(value).trim().toLowerCase();
|
|
39
|
+
if (style === 'true') return NUMBER_STYLES.full;
|
|
40
|
+
if (style === 'false' || style === 'none' || style === 'off') return '';
|
|
41
|
+
if (!NUMBER_STYLES[style]) {
|
|
42
|
+
throw new Error(`Unknown page number style "${value}". Use one of: ${Object.keys(NUMBER_STYLES).join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
return NUMBER_STYLES[style];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A CSS string literal that cannot end early or close the <style> element.
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function cssString(text) {
|
|
53
|
+
const safe = text
|
|
54
|
+
.replace(/\\/g, '\\\\')
|
|
55
|
+
.replace(/"/g, '\\"')
|
|
56
|
+
.replace(/[\r\n]+/g, ' ')
|
|
57
|
+
.replace(/</g, '\\3C ');
|
|
58
|
+
return `"${safe}"`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Turns header/footer text into the value of a CSS `content` property.
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @param {{ title?: string, author?: string, date?: string }} meta
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function contentValue(text, meta = {}) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
let literal = '';
|
|
70
|
+
let last = 0;
|
|
71
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
72
|
+
literal += text.slice(last, match.index);
|
|
73
|
+
last = match.index + match[0].length;
|
|
74
|
+
const name = match[1];
|
|
75
|
+
if (name === 'page' || name === 'pages') {
|
|
76
|
+
if (literal) parts.push(cssString(literal));
|
|
77
|
+
literal = '';
|
|
78
|
+
parts.push(`counter(${name})`);
|
|
79
|
+
} else {
|
|
80
|
+
literal += meta[name] ? String(meta[name]) : '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
literal += text.slice(last);
|
|
84
|
+
if (literal) parts.push(cssString(literal));
|
|
85
|
+
return parts.length ? parts.join(' ') : '""';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where each text goes. The header sits top centre. The footer sits bottom centre, unless
|
|
90
|
+
* page numbers are also on: then the footer text goes left and the numbers right.
|
|
91
|
+
* @param {{ header?: *, footer?: *, pageNumbers?: * }} options
|
|
92
|
+
* @returns {Object<string, string>} margin box name -> text
|
|
93
|
+
*/
|
|
94
|
+
function pageMarkBoxes(options = {}) {
|
|
95
|
+
const header = textOf(options.header, DEFAULT_HEADER);
|
|
96
|
+
const numbers = numbersOf(options.pageNumbers);
|
|
97
|
+
const footer = textOf(options.footer, NUMBER_STYLES.full);
|
|
98
|
+
const boxes = {};
|
|
99
|
+
if (header) boxes['top-center'] = header;
|
|
100
|
+
if (footer && numbers && footer !== numbers) {
|
|
101
|
+
boxes['bottom-left'] = footer;
|
|
102
|
+
boxes['bottom-right'] = numbers;
|
|
103
|
+
} else if (footer || numbers) {
|
|
104
|
+
boxes['bottom-center'] = footer || numbers;
|
|
105
|
+
}
|
|
106
|
+
return boxes;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* CSS for the page numbers and running header/footer, or '' when none is on.
|
|
111
|
+
* A cover page (.cover-page) is printed without them.
|
|
112
|
+
* @param {{ header?: *, footer?: *, pageNumbers?: *, font?: string }} options
|
|
113
|
+
* @param {{ title?: string, author?: string, date?: string }} [meta]
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function pageMarksCss(options = {}, meta = {}) {
|
|
117
|
+
const boxes = pageMarkBoxes(options);
|
|
118
|
+
const names = Object.keys(boxes);
|
|
119
|
+
if (!names.length) return '';
|
|
120
|
+
|
|
121
|
+
const font = options.font ? `${cssString(String(options.font))}, sans-serif` : 'sans-serif';
|
|
122
|
+
const style = `font-family: ${font}; font-size: 9px; color: #64748b;`;
|
|
123
|
+
const rules = names.map(
|
|
124
|
+
(name) => ` @${name} { content: ${contentValue(boxes[name], meta)}; ${style} }`
|
|
125
|
+
);
|
|
126
|
+
const blank = names.map((name) => ` @${name} { content: none; }`);
|
|
127
|
+
return [
|
|
128
|
+
'/* Page numbers and running header/footer */',
|
|
129
|
+
'@page {',
|
|
130
|
+
...rules,
|
|
131
|
+
'}',
|
|
132
|
+
'.cover-page { page: cover; }',
|
|
133
|
+
'@page cover {',
|
|
134
|
+
...blank,
|
|
135
|
+
'}'
|
|
136
|
+
].join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const api = { pageMarksCss, pageMarkBoxes, contentValue };
|
|
140
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
141
|
+
else scope.MdPdfPageMarks = api;
|
|
142
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
package/src/core/parser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
1
2
|
const { marked } = require('marked');
|
|
2
3
|
const hljs = require('highlight.js/lib/common');
|
|
3
4
|
const {
|
|
@@ -11,6 +12,7 @@ const {
|
|
|
11
12
|
} = require('./utils');
|
|
12
13
|
const { getHtmlTemplate } = require('./templates');
|
|
13
14
|
const { applyFootnotes } = require('./footnotes');
|
|
15
|
+
const { pageMarksCss } = require('./pagemarks');
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Builds marked custom renderer for diagrams, UI mockups, and headings
|
|
@@ -82,6 +84,8 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
|
82
84
|
const subtitle = frontMatter.subtitle || options.subtitle || '';
|
|
83
85
|
const author = frontMatter.author || options.author || '';
|
|
84
86
|
const date = frontMatter.date || options.date || '';
|
|
87
|
+
// Document language, as for EPUB: sets the cover labels and the page's lang attribute
|
|
88
|
+
const language = String(frontMatter.lang || frontMatter.language || options.language || 'en');
|
|
85
89
|
|
|
86
90
|
// 2. Merge options
|
|
87
91
|
const mergedOptions = {
|
|
@@ -92,8 +96,10 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
|
92
96
|
margin: options.margin || frontMatter.margin || '14mm 12mm 16mm 12mm',
|
|
93
97
|
toc: options.toc !== undefined ? options.toc : (frontMatter.toc || false),
|
|
94
98
|
cover: options.cover !== undefined ? options.cover : (frontMatter.cover || false),
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
// Running header/footer and page numbers: off unless asked for (see pagemarks.js)
|
|
100
|
+
header: options.header !== undefined ? options.header : frontMatter.header,
|
|
101
|
+
footer: options.footer !== undefined ? options.footer : frontMatter.footer,
|
|
102
|
+
pageNumbers: options.pageNumbers !== undefined ? options.pageNumbers : frontMatter.pageNumbers,
|
|
97
103
|
mermaid: options.mermaid !== undefined ? options.mermaid : (frontMatter.mermaid !== false),
|
|
98
104
|
katex: options.katex !== undefined ? options.katex : (frontMatter.katex !== false),
|
|
99
105
|
highlight: options.highlight !== undefined ? options.highlight : (frontMatter.highlight !== false),
|
|
@@ -118,7 +124,8 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
|
118
124
|
subtitle,
|
|
119
125
|
author,
|
|
120
126
|
date,
|
|
121
|
-
cover: mergedOptions.cover
|
|
127
|
+
cover: mergedOptions.cover,
|
|
128
|
+
language
|
|
122
129
|
});
|
|
123
130
|
|
|
124
131
|
// 5. Read custom CSS file if specified
|
|
@@ -127,6 +134,15 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
|
127
134
|
customCss += '\n' + readCustomCss(mergedOptions.cssFile);
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
// Page numbers and running header/footer, printed in the page margins
|
|
138
|
+
const pageCss = pageMarksCss(
|
|
139
|
+
{ header: mergedOptions.header, footer: mergedOptions.footer, pageNumbers: mergedOptions.pageNumbers, font: mergedOptions.font },
|
|
140
|
+
{ title, author, date }
|
|
141
|
+
);
|
|
142
|
+
if (pageCss) {
|
|
143
|
+
customCss = pageCss + '\n' + customCss;
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
// 6. Parse Markdown using custom renderer
|
|
131
147
|
marked.setOptions({ gfm: true, breaks: true });
|
|
132
148
|
const renderer = buildMarkedRenderer(headingsMap, mergedOptions.highlight);
|
|
@@ -152,6 +168,7 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
|
152
168
|
return getHtmlTemplate({
|
|
153
169
|
title,
|
|
154
170
|
author,
|
|
171
|
+
language,
|
|
155
172
|
bodyHtml,
|
|
156
173
|
options: {
|
|
157
174
|
...mergedOptions,
|
package/src/core/sanitizer.js
CHANGED
package/src/core/templates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
1
2
|
const { getThemeStyles } = require('./themes');
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -15,11 +16,12 @@ function escapeXml(str) {
|
|
|
15
16
|
* @param {Object} params
|
|
16
17
|
* @param {string} params.title Document title
|
|
17
18
|
* @param {string} [params.author] Document author
|
|
19
|
+
* @param {string} [params.language] Document language tag (default en)
|
|
18
20
|
* @param {string} params.bodyHtml Document body HTML
|
|
19
21
|
* @param {Object} [params.options] Template options
|
|
20
22
|
* @returns {string} Full HTML string
|
|
21
23
|
*/
|
|
22
|
-
function getHtmlTemplate({ title, author = '', bodyHtml, options = {} }) {
|
|
24
|
+
function getHtmlTemplate({ title, author = '', language = 'en', bodyHtml, options = {} }) {
|
|
23
25
|
const theme = options.theme || 'modern';
|
|
24
26
|
const enableMermaid = options.mermaid !== false;
|
|
25
27
|
const enableKatex = options.katex !== false;
|
|
@@ -31,7 +33,7 @@ function getHtmlTemplate({ title, author = '', bodyHtml, options = {} }) {
|
|
|
31
33
|
const themeStyles = getThemeStyles(theme, options.font);
|
|
32
34
|
|
|
33
35
|
return `<!DOCTYPE html>
|
|
34
|
-
<html lang="
|
|
36
|
+
<html lang="${escapeXml(language)}">
|
|
35
37
|
<head>
|
|
36
38
|
<meta charset="UTF-8">
|
|
37
39
|
<title>${escapeXml(title)}</title>
|
package/src/core/themes/cover.js
CHANGED
package/src/core/themes/dark.js
CHANGED
package/src/core/themes/ebook.js
CHANGED
package/src/core/themes/index.js
CHANGED
package/src/core/toc.js
CHANGED
package/src/core/utils.js
CHANGED
package/src/core/zip.js
CHANGED
package/src/index.js
CHANGED