md-pdf-studio 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -11
- package/bin/cli.js +48 -15
- package/package.json +6 -4
- package/src/browser/renderer.js +27 -18
- package/src/cli/options.js +61 -0
- package/src/core/cover.js +23 -5
- package/src/core/docmodel.js +324 -0
- package/src/core/docx.js +493 -0
- package/src/core/epub.js +338 -0
- package/src/core/footnotes.js +1 -0
- package/src/core/frontmatter.js +1 -0
- package/src/core/images.js +70 -0
- package/src/core/include.js +105 -0
- package/src/core/index.js +80 -12
- 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 +6 -5
- package/src/core/zip.js +78 -0
- package/src/index.js +1 -0
|
@@ -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
|
@@ -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 { parseFrontMatter } = require('./frontmatter');
|
|
@@ -18,11 +19,11 @@ function preprocessMarkdown(mdContent = '') {
|
|
|
18
19
|
|
|
19
20
|
return mdContent
|
|
20
21
|
// GitHub Callouts / Alerts
|
|
21
|
-
.replace(
|
|
22
|
-
.replace(
|
|
23
|
-
.replace(
|
|
24
|
-
.replace(
|
|
25
|
-
.replace(
|
|
22
|
+
.replace(/^>[ \t]*\[!NOTE\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **ℹ️ NOTE:** ')
|
|
23
|
+
.replace(/^>[ \t]*\[!TIP\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **💡 TIP:** ')
|
|
24
|
+
.replace(/^>[ \t]*\[!WARNING\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **⚠️ WARNING:** ')
|
|
25
|
+
.replace(/^>[ \t]*\[!IMPORTANT\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **❗ IMPORTANT:** ')
|
|
26
|
+
.replace(/^>[ \t]*\[!CAUTION\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **🚫 CAUTION:** ')
|
|
26
27
|
// Pagebreaks
|
|
27
28
|
.replace(/<!--\s*page-?break\s*-->/gi, '<div class="page-break"></div>')
|
|
28
29
|
.replace(/\\pagebreak/gi, '<div class="page-break"></div>')
|
package/src/core/zip.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*! SPDX-License-Identifier: MIT | Copyright (c) 2026 Van Nguyen | github.com/vannt-dev/convert-md-to-pdf */
|
|
2
|
+
const zlib = require('zlib');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A ZIP archive writer, enough for the two container formats built on ZIP
|
|
6
|
+
* that this tool exports: DOCX and EPUB.
|
|
7
|
+
*
|
|
8
|
+
* Entries are written in the order given, which EPUB relies on: its
|
|
9
|
+
* `mimetype` entry has to come first and uncompressed. Timestamps are fixed,
|
|
10
|
+
* so the same input gives the same bytes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 1980-01-01 00:00:00, the earliest date the format can express.
|
|
14
|
+
const DOS_TIME = 0;
|
|
15
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ name: string, data: Buffer | string, store?: boolean }[]} entries
|
|
19
|
+
* @returns {Buffer}
|
|
20
|
+
*/
|
|
21
|
+
function createZip(entries) {
|
|
22
|
+
const localParts = [];
|
|
23
|
+
const centralParts = [];
|
|
24
|
+
let offset = 0;
|
|
25
|
+
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const name = Buffer.from(entry.name, 'utf8');
|
|
28
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
|
|
29
|
+
const compressed = entry.store ? data : zlib.deflateRawSync(data, { level: 9 });
|
|
30
|
+
const method = entry.store ? 0 : 8;
|
|
31
|
+
const crc = zlib.crc32(data);
|
|
32
|
+
|
|
33
|
+
const local = Buffer.alloc(30);
|
|
34
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
35
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
36
|
+
local.writeUInt16LE(0x0800, 6); // names are UTF-8
|
|
37
|
+
local.writeUInt16LE(method, 8);
|
|
38
|
+
local.writeUInt16LE(DOS_TIME, 10);
|
|
39
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
40
|
+
local.writeUInt32LE(crc, 14);
|
|
41
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
42
|
+
local.writeUInt32LE(data.length, 22);
|
|
43
|
+
local.writeUInt16LE(name.length, 26);
|
|
44
|
+
local.writeUInt16LE(0, 28);
|
|
45
|
+
|
|
46
|
+
const central = Buffer.alloc(46);
|
|
47
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
48
|
+
central.writeUInt16LE(20, 4); // version made by
|
|
49
|
+
central.writeUInt16LE(20, 6); // version needed
|
|
50
|
+
central.writeUInt16LE(0x0800, 8);
|
|
51
|
+
central.writeUInt16LE(method, 10);
|
|
52
|
+
central.writeUInt16LE(DOS_TIME, 12);
|
|
53
|
+
central.writeUInt16LE(DOS_DATE, 14);
|
|
54
|
+
central.writeUInt32LE(crc, 16);
|
|
55
|
+
central.writeUInt32LE(compressed.length, 20);
|
|
56
|
+
central.writeUInt32LE(data.length, 24);
|
|
57
|
+
central.writeUInt16LE(name.length, 28);
|
|
58
|
+
central.writeUInt32LE(offset, 42);
|
|
59
|
+
|
|
60
|
+
localParts.push(local, name, compressed);
|
|
61
|
+
centralParts.push(central, name);
|
|
62
|
+
offset += local.length + name.length + compressed.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const centralSize = centralParts.reduce((size, part) => size + part.length, 0);
|
|
66
|
+
const end = Buffer.alloc(22);
|
|
67
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
68
|
+
end.writeUInt16LE(entries.length, 8);
|
|
69
|
+
end.writeUInt16LE(entries.length, 10);
|
|
70
|
+
end.writeUInt32LE(centralSize, 12);
|
|
71
|
+
end.writeUInt32LE(offset, 16);
|
|
72
|
+
|
|
73
|
+
return Buffer.concat([...localParts, ...centralParts, end]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
createZip
|
|
78
|
+
};
|
package/src/index.js
CHANGED