md-pdf-studio 1.1.0 → 2.1.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/LICENSE +21 -0
- package/README.md +214 -208
- package/bin/cli.js +188 -186
- package/package.json +62 -58
- package/src/browser/renderer.js +142 -142
- package/src/core/cover.js +40 -40
- package/src/core/frontmatter.js +54 -54
- package/src/core/index.js +80 -80
- package/src/core/parser.js +164 -163
- package/src/core/sanitizer.js +57 -35
- package/src/core/templates.js +192 -192
- package/src/core/themes/academic.js +20 -20
- package/src/core/themes/cover.js +14 -14
- package/src/core/themes/cyberpunk.js +21 -21
- package/src/core/themes/dark.js +21 -21
- package/src/core/themes/ebook.js +20 -20
- package/src/core/themes/github.js +21 -21
- package/src/core/themes/highlight.js +38 -0
- package/src/core/themes/index.js +58 -52
- package/src/core/themes/minimal.js +20 -20
- package/src/core/themes/modern.js +34 -34
- package/src/core/toc.js +72 -72
- package/src/core/utils.js +85 -85
- package/src/index.js +2 -2
package/src/core/parser.js
CHANGED
|
@@ -1,163 +1,164 @@
|
|
|
1
|
-
const { marked } = require('marked');
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
* @
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
renderer.code
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const slug = headingsMap.get(
|
|
48
|
-
return `<h${
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return renderer;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Wraps section headers with section-group divs for clean page-break handling
|
|
56
|
-
* @param {string} html
|
|
57
|
-
* @returns {string}
|
|
58
|
-
*/
|
|
59
|
-
function wrapSectionGroups(html) {
|
|
60
|
-
let wrapped = html.replace(
|
|
61
|
-
/<h3>(.*?)<\/h3>\s*(<div class="ui-mockup-container">|<table|<div class="mermaid-container">|<ul|<ol)/g,
|
|
62
|
-
'<div class="section-group"><h3>$1</h3>$2'
|
|
63
|
-
);
|
|
64
|
-
return wrapped.replace(
|
|
65
|
-
/(<\/table>|<\/div>|<\/ul>|<\/ol>)\s*(?=<h[1-4]>|<hr|\$)/g,
|
|
66
|
-
'$1</div>'
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Converts Markdown content into a fully styled HTML string
|
|
72
|
-
* @param {string} rawMarkdownContent
|
|
73
|
-
* @param {Object} options
|
|
74
|
-
* @returns {string} HTML string
|
|
75
|
-
*/
|
|
76
|
-
function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
77
|
-
// 1. Extract Front Matter & Metadata
|
|
78
|
-
const { data: frontMatter, content: markdownContent } = parseFrontMatter(rawMarkdownContent);
|
|
79
|
-
const titleMatch = markdownContent.match(/^#\s+(.+)$/m);
|
|
80
|
-
const title = frontMatter.title || options.title || (titleMatch ? titleMatch[1].trim() : 'Converted Document');
|
|
81
|
-
const subtitle = frontMatter.subtitle || options.subtitle || '';
|
|
82
|
-
const author = frontMatter.author || options.author || '';
|
|
83
|
-
const date = frontMatter.date || options.date || '';
|
|
84
|
-
|
|
85
|
-
// 2. Merge options
|
|
86
|
-
const mergedOptions = {
|
|
87
|
-
theme: options.theme || frontMatter.theme || 'modern',
|
|
88
|
-
font: options.font || frontMatter.font || 'Inter',
|
|
89
|
-
pageSize: options.pageSize || frontMatter.pageSize || 'A4',
|
|
90
|
-
orientation: options.orientation || frontMatter.orientation || (frontMatter.landscape ? 'landscape' : 'portrait'),
|
|
91
|
-
margin: options.margin || frontMatter.margin || '14mm 12mm 16mm 12mm',
|
|
92
|
-
toc: options.toc !== undefined ? options.toc : (frontMatter.toc || false),
|
|
93
|
-
cover: options.cover !== undefined ? options.cover : (frontMatter.cover || false),
|
|
94
|
-
header: options.header !== undefined ? options.header : (frontMatter.header || true),
|
|
95
|
-
footer: options.footer !== undefined ? options.footer : (frontMatter.footer || true),
|
|
96
|
-
mermaid: options.mermaid !== undefined ? options.mermaid : (frontMatter.mermaid !== false),
|
|
97
|
-
katex: options.katex !== undefined ? options.katex : (frontMatter.katex !== false),
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
bodyHtml =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
1
|
+
const { marked } = require('marked');
|
|
2
|
+
const hljs = require('highlight.js/lib/common');
|
|
3
|
+
const {
|
|
4
|
+
parseFrontMatter,
|
|
5
|
+
generateCoverPage,
|
|
6
|
+
preprocessMarkdown,
|
|
7
|
+
generateToc,
|
|
8
|
+
readCustomCss,
|
|
9
|
+
sanitizeHtml,
|
|
10
|
+
escapeHtml
|
|
11
|
+
} = require('./utils');
|
|
12
|
+
const { getHtmlTemplate } = require('./templates');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds marked custom renderer for diagrams, UI mockups, and headings
|
|
16
|
+
* @param {Map<string, string>} headingsMap
|
|
17
|
+
* @param {boolean} highlight Colour fenced code blocks that name a known language
|
|
18
|
+
* @returns {marked.Renderer}
|
|
19
|
+
*/
|
|
20
|
+
function buildMarkedRenderer(headingsMap, highlight = true) {
|
|
21
|
+
const renderer = new marked.Renderer();
|
|
22
|
+
const originalCodeRenderer = renderer.code.bind(renderer);
|
|
23
|
+
|
|
24
|
+
renderer.code = function(token) {
|
|
25
|
+
const text = token.text || '';
|
|
26
|
+
const lang = token.lang || '';
|
|
27
|
+
|
|
28
|
+
if (lang === 'mermaid') {
|
|
29
|
+
return `<div class="mermaid-container"><pre class="mermaid">${text}</pre></div>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (text.includes('┌') && text.includes('└')) {
|
|
33
|
+
return `<div class="ui-mockup-container"><pre class="ui-mockup"><code>${escapeHtml(text)}</code></pre></div>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Only fences that name a language are coloured: guessing one gets short snippets wrong.
|
|
37
|
+
const language = lang.trim().split(/\s+/)[0];
|
|
38
|
+
if (highlight && language && hljs.getLanguage(language)) {
|
|
39
|
+
const highlighted = hljs.highlight(text, { language, ignoreIllegals: true }).value;
|
|
40
|
+
return `<pre><code class="hljs language-${escapeHtml(language)}">${highlighted}</code></pre>\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return originalCodeRenderer.call(this, token);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
renderer.heading = function({ tokens, depth, text }) {
|
|
47
|
+
const slug = headingsMap.get(text.trim()) || `heading-${depth}`;
|
|
48
|
+
return `<h${depth} id="${slug}">${this.parser.parseInline(tokens)}</h${depth}>`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return renderer;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wraps section headers with section-group divs for clean page-break handling
|
|
56
|
+
* @param {string} html
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function wrapSectionGroups(html) {
|
|
60
|
+
let wrapped = html.replace(
|
|
61
|
+
/<h3>(.*?)<\/h3>\s*(<div class="ui-mockup-container">|<table|<div class="mermaid-container">|<ul|<ol)/g,
|
|
62
|
+
'<div class="section-group"><h3>$1</h3>$2'
|
|
63
|
+
);
|
|
64
|
+
return wrapped.replace(
|
|
65
|
+
/(<\/table>|<\/div>|<\/ul>|<\/ol>)\s*(?=<h[1-4]>|<hr|\$)/g,
|
|
66
|
+
'$1</div>'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Converts Markdown content into a fully styled HTML string
|
|
72
|
+
* @param {string} rawMarkdownContent
|
|
73
|
+
* @param {Object} options
|
|
74
|
+
* @returns {string} HTML string
|
|
75
|
+
*/
|
|
76
|
+
function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
|
|
77
|
+
// 1. Extract Front Matter & Metadata
|
|
78
|
+
const { data: frontMatter, content: markdownContent } = parseFrontMatter(rawMarkdownContent);
|
|
79
|
+
const titleMatch = markdownContent.match(/^#\s+(.+)$/m);
|
|
80
|
+
const title = frontMatter.title || options.title || (titleMatch ? titleMatch[1].trim() : 'Converted Document');
|
|
81
|
+
const subtitle = frontMatter.subtitle || options.subtitle || '';
|
|
82
|
+
const author = frontMatter.author || options.author || '';
|
|
83
|
+
const date = frontMatter.date || options.date || '';
|
|
84
|
+
|
|
85
|
+
// 2. Merge options
|
|
86
|
+
const mergedOptions = {
|
|
87
|
+
theme: options.theme || frontMatter.theme || 'modern',
|
|
88
|
+
font: options.font || frontMatter.font || 'Inter',
|
|
89
|
+
pageSize: options.pageSize || frontMatter.pageSize || 'A4',
|
|
90
|
+
orientation: options.orientation || frontMatter.orientation || (frontMatter.landscape ? 'landscape' : 'portrait'),
|
|
91
|
+
margin: options.margin || frontMatter.margin || '14mm 12mm 16mm 12mm',
|
|
92
|
+
toc: options.toc !== undefined ? options.toc : (frontMatter.toc || false),
|
|
93
|
+
cover: options.cover !== undefined ? options.cover : (frontMatter.cover || false),
|
|
94
|
+
header: options.header !== undefined ? options.header : (frontMatter.header || true),
|
|
95
|
+
footer: options.footer !== undefined ? options.footer : (frontMatter.footer || true),
|
|
96
|
+
mermaid: options.mermaid !== undefined ? options.mermaid : (frontMatter.mermaid !== false),
|
|
97
|
+
katex: options.katex !== undefined ? options.katex : (frontMatter.katex !== false),
|
|
98
|
+
highlight: options.highlight !== undefined ? options.highlight : (frontMatter.highlight !== false),
|
|
99
|
+
customCss: options.customCss || '',
|
|
100
|
+
cssFile: options.cssFile || frontMatter.cssFile || frontMatter.css
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 3. Preprocess Markdown (LaTeX, pagebreaks, alerts)
|
|
104
|
+
const processedMd = preprocessMarkdown(markdownContent);
|
|
105
|
+
|
|
106
|
+
// 4. Generate TOC & Cover Page
|
|
107
|
+
let tocHtml = '';
|
|
108
|
+
let headingsMap = new Map();
|
|
109
|
+
if (mergedOptions.toc) {
|
|
110
|
+
const tocResult = generateToc(processedMd);
|
|
111
|
+
tocHtml = tocResult.tocHtml;
|
|
112
|
+
headingsMap = tocResult.headingsMap;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const coverHtml = generateCoverPage({
|
|
116
|
+
title,
|
|
117
|
+
subtitle,
|
|
118
|
+
author,
|
|
119
|
+
date,
|
|
120
|
+
cover: mergedOptions.cover
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 5. Read custom CSS file if specified
|
|
124
|
+
let customCss = mergedOptions.customCss || '';
|
|
125
|
+
if (mergedOptions.cssFile) {
|
|
126
|
+
customCss += '\n' + readCustomCss(mergedOptions.cssFile);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 6. Parse Markdown using custom renderer
|
|
130
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
131
|
+
const renderer = buildMarkedRenderer(headingsMap, mergedOptions.highlight);
|
|
132
|
+
marked.use({ renderer });
|
|
133
|
+
|
|
134
|
+
let bodyHtml = marked.parse(processedMd);
|
|
135
|
+
bodyHtml = sanitizeHtml(bodyHtml);
|
|
136
|
+
|
|
137
|
+
// 7. Inject TOC, Cover Page & Section Groups
|
|
138
|
+
if (tocHtml) {
|
|
139
|
+
bodyHtml = bodyHtml.includes('</h1>')
|
|
140
|
+
? bodyHtml.replace('</h1>', '</h1>\n' + tocHtml)
|
|
141
|
+
: tocHtml + bodyHtml;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (coverHtml) {
|
|
145
|
+
bodyHtml = coverHtml + bodyHtml;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
bodyHtml = wrapSectionGroups(bodyHtml);
|
|
149
|
+
|
|
150
|
+
// 8. Generate and return full HTML template
|
|
151
|
+
return getHtmlTemplate({
|
|
152
|
+
title,
|
|
153
|
+
author,
|
|
154
|
+
bodyHtml,
|
|
155
|
+
options: {
|
|
156
|
+
...mergedOptions,
|
|
157
|
+
customCss
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
parseMarkdownToHtml
|
|
164
|
+
};
|
package/src/core/sanitizer.js
CHANGED
|
@@ -1,35 +1,57 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTML Sanitizer & Escaping Utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Sanitizes HTML content by stripping dangerous inline script tags and inline handlers
|
|
7
|
-
* @param {string} html
|
|
8
|
-
* @returns {string} Clean HTML string
|
|
9
|
-
*/
|
|
10
|
-
function sanitizeHtml(html) {
|
|
11
|
-
if (!html) return '';
|
|
12
|
-
return html
|
|
13
|
-
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
14
|
-
.replace(
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1
|
+
/**
|
|
2
|
+
* HTML Sanitizer & Escaping Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sanitizes HTML content by stripping dangerous inline script tags and inline handlers
|
|
7
|
+
* @param {string} html
|
|
8
|
+
* @returns {string} Clean HTML string
|
|
9
|
+
*/
|
|
10
|
+
function sanitizeHtml(html) {
|
|
11
|
+
if (!html) return '';
|
|
12
|
+
return html
|
|
13
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
14
|
+
.replace(OPENING_TAG, stripEventHandlers);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// An opening tag, with quoted attribute values allowed to contain ">". A quote that is never
|
|
18
|
+
// closed still ends at the next ">", as the tag would in a lenient parser.
|
|
19
|
+
const OPENING_TAG = /<([a-zA-Z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>])*)>/g;
|
|
20
|
+
// One attribute: its name, then an optional value. Consuming the value keeps it from being
|
|
21
|
+
// read as another attribute.
|
|
22
|
+
const ATTRIBUTE = /[\s/]*([^\s"'<>/=]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Drops on* attributes from one opening tag. Text between tags is never touched, so code
|
|
26
|
+
* such as `el.onclick = fn` survives.
|
|
27
|
+
* @param {string} tag
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @param {string} attributes
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function stripEventHandlers(tag, name, attributes) {
|
|
33
|
+
const kept = attributes.replace(ATTRIBUTE, (attribute, attributeName) =>
|
|
34
|
+
/^on/i.test(attributeName) ? '' : attribute
|
|
35
|
+
);
|
|
36
|
+
return `<${name}${kept}>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Escapes special HTML characters
|
|
41
|
+
* @param {string} str
|
|
42
|
+
* @returns {string} Escaped string
|
|
43
|
+
*/
|
|
44
|
+
function escapeHtml(str) {
|
|
45
|
+
if (!str) return '';
|
|
46
|
+
return str
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
sanitizeHtml,
|
|
56
|
+
escapeHtml
|
|
57
|
+
};
|