@kobekeye/mdf 1.0.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 +82 -0
- package/index.js +78 -0
- package/package.json +33 -0
- package/renderer.js +212 -0
- package/themes/default.css +370 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# mdf
|
|
2
|
+
|
|
3
|
+
Convert Markdown to beautiful PDFs — free, open-source, and zero-config.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Syntax highlighting** — fenced code blocks with language detection (via highlight.js)
|
|
8
|
+
- **Math formulas** — inline and block LaTeX via KaTeX (`$...$` and `$$...$$`)
|
|
9
|
+
- **Table of contents** — insert `[TOC]` anywhere in your document
|
|
10
|
+
- **Callout blocks** — `:::info`, `:::warning`, `:::danger`, `:::success`
|
|
11
|
+
- **Task lists** — `- [ ]` and `- [x]`
|
|
12
|
+
- **Tables, images, blockquotes**
|
|
13
|
+
- **Manual page breaks** — insert `==page==` on its own line
|
|
14
|
+
- **CJK support** — uses Inter + Noto Sans TC via Google Fonts (requires internet on first run)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g mdf
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or run without installing:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx mdf input.md
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
mdf input.md # outputs input.pdf
|
|
32
|
+
mdf input.md output.pdf # custom output name
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Syntax Guide
|
|
36
|
+
|
|
37
|
+
### Table of Contents
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
[TOC]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Math
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
Inline: $E = mc^2$
|
|
47
|
+
|
|
48
|
+
Block:
|
|
49
|
+
$$
|
|
50
|
+
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
|
|
51
|
+
$$
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Callout Blocks
|
|
55
|
+
|
|
56
|
+
```markdown
|
|
57
|
+
:::info Title
|
|
58
|
+
This is an info callout.
|
|
59
|
+
:::
|
|
60
|
+
|
|
61
|
+
:::warning
|
|
62
|
+
Watch out!
|
|
63
|
+
:::
|
|
64
|
+
|
|
65
|
+
:::danger Critical
|
|
66
|
+
This is dangerous.
|
|
67
|
+
:::
|
|
68
|
+
|
|
69
|
+
:::success
|
|
70
|
+
Operation completed.
|
|
71
|
+
:::
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Manual Page Break
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
==page==
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const puppeteer = require('puppeteer');
|
|
5
|
+
const { renderToHtml } = require('./renderer');
|
|
6
|
+
|
|
7
|
+
// parse command line arguments
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (args.length === 0) {
|
|
11
|
+
console.log(`
|
|
12
|
+
usage: node index.js <input.md> [output.pdf]
|
|
13
|
+
|
|
14
|
+
input.md required, the Markdown file to convert
|
|
15
|
+
output.pdf optional, the output PDF file name
|
|
16
|
+
(if omitted, it will automatically use the same name, e.g. input.pdf)
|
|
17
|
+
|
|
18
|
+
examples:
|
|
19
|
+
node index.js README.md
|
|
20
|
+
node index.js doc.md custom-name.pdf
|
|
21
|
+
`);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const inputFile = args[0];
|
|
26
|
+
const outputFile = args[1] || inputFile.replace(/\.md$/i, '.pdf');
|
|
27
|
+
|
|
28
|
+
// check if input file exists
|
|
29
|
+
if (!fs.existsSync(inputFile)) {
|
|
30
|
+
console.error(`\x1b[31mError: file not found: ${inputFile}\x1b[0m`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function convertToPdf() {
|
|
35
|
+
console.log(`converting: ${inputFile} → ${outputFile}`);
|
|
36
|
+
|
|
37
|
+
const fullHtml = renderToHtml(inputFile);
|
|
38
|
+
|
|
39
|
+
// write HTML to temp file (in the same directory as Markdown), let Puppeteer parse relative path of images correctly
|
|
40
|
+
const inputDir = path.dirname(path.resolve(inputFile));
|
|
41
|
+
const tempHtmlPath = path.join(inputDir, `.mdf-temp-${Date.now()}.html`);
|
|
42
|
+
fs.writeFileSync(tempHtmlPath, fullHtml, 'utf-8');
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const browser = await puppeteer.launch();
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
|
|
48
|
+
// use file:// URL to load, let relative path of images parse correctly
|
|
49
|
+
await page.goto(`file://${tempHtmlPath}`, { waitUntil: 'networkidle0' });
|
|
50
|
+
// wait for all fonts (including Google Fonts) to load completely, avoid CJK characters use synthetic bold
|
|
51
|
+
await page.evaluate(() => document.fonts.ready);
|
|
52
|
+
|
|
53
|
+
await page.pdf({
|
|
54
|
+
path: outputFile,
|
|
55
|
+
format: 'A4',
|
|
56
|
+
printBackground: true,
|
|
57
|
+
margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
|
|
58
|
+
|
|
59
|
+
displayHeaderFooter: true,
|
|
60
|
+
headerTemplate: '<span></span>', // do not display header
|
|
61
|
+
footerTemplate: `
|
|
62
|
+
<div style="width: 100%; text-align: center; font-size: 14px; color: #888; font-family: 'Times New Roman', Times, serif; margin-bottom: 5mm;">
|
|
63
|
+
<span class="pageNumber"></span>
|
|
64
|
+
</div>`,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await browser.close();
|
|
68
|
+
console.log(`\x1b[32mDone! Output to: ${path.resolve(outputFile)}\x1b[0m`);
|
|
69
|
+
} finally {
|
|
70
|
+
// clean up temp HTML file
|
|
71
|
+
try { fs.unlinkSync(tempHtmlPath); } catch (_) { }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
convertToPdf().catch((err) => {
|
|
76
|
+
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kobekeye/mdf",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Convert Markdown to beautiful PDFs — free, open-source, and zero-config.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mdf": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"markdown",
|
|
14
|
+
"pdf",
|
|
15
|
+
"converter",
|
|
16
|
+
"cli",
|
|
17
|
+
"katex",
|
|
18
|
+
"syntax-highlighting"
|
|
19
|
+
],
|
|
20
|
+
"author": "kobekeye",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"highlight.js": "^11.11.1",
|
|
25
|
+
"katex": "^0.16.33",
|
|
26
|
+
"markdown-it": "^14.1.1",
|
|
27
|
+
"markdown-it-anchor": "^9.2.0",
|
|
28
|
+
"markdown-it-container": "^4.0.0",
|
|
29
|
+
"markdown-it-task-lists": "^2.1.1",
|
|
30
|
+
"markdown-it-texmath": "^1.0.0",
|
|
31
|
+
"puppeteer": "^24.37.5"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/renderer.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const MarkdownIt = require('markdown-it');
|
|
4
|
+
const texmath = require('markdown-it-texmath');
|
|
5
|
+
const katex = require('katex');
|
|
6
|
+
const hljs = require('highlight.js');
|
|
7
|
+
const anchor = require('markdown-it-anchor');
|
|
8
|
+
const taskLists = require('markdown-it-task-lists');
|
|
9
|
+
const container = require('markdown-it-container');
|
|
10
|
+
const md = new MarkdownIt({
|
|
11
|
+
html: true,
|
|
12
|
+
highlight: function (str, lang) {
|
|
13
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
14
|
+
try {
|
|
15
|
+
return '<pre class="hljs"><code>' +
|
|
16
|
+
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
|
|
17
|
+
'</code></pre>';
|
|
18
|
+
} catch (_) { }
|
|
19
|
+
}
|
|
20
|
+
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
md.use(texmath, {
|
|
24
|
+
engine: katex,
|
|
25
|
+
delimiters: 'dollars',
|
|
26
|
+
});
|
|
27
|
+
md.use(anchor, {
|
|
28
|
+
permalink: false,
|
|
29
|
+
slugify: (s) => encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-')),
|
|
30
|
+
});
|
|
31
|
+
md.use(taskLists, { enabled: true });
|
|
32
|
+
// --- Callout container blocks: :::info, :::warning, :::danger, :::success ---
|
|
33
|
+
// Color aliases: :::blue = :::info, :::orange = :::warning, etc.
|
|
34
|
+
const CALLOUT_TYPES = [
|
|
35
|
+
{ names: ['info', 'blue'], cssClass: 'info' },
|
|
36
|
+
{ names: ['warning', 'orange'], cssClass: 'warning' },
|
|
37
|
+
{ names: ['danger', 'red'], cssClass: 'danger' },
|
|
38
|
+
{ names: ['success', 'green'], cssClass: 'success' },
|
|
39
|
+
];
|
|
40
|
+
for (const { names, cssClass } of CALLOUT_TYPES) {
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
md.use(container, name, {
|
|
43
|
+
render(tokens, idx) {
|
|
44
|
+
const token = tokens[idx];
|
|
45
|
+
if (token.nesting === 1) {
|
|
46
|
+
// opening tag – extract optional title after :::type
|
|
47
|
+
const titleMatch = token.info.trim().match(new RegExp(`^${name}\\s+(.*)$`));
|
|
48
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
49
|
+
const titleHtml = title
|
|
50
|
+
? `<p class="callout-title">${md.utils.escapeHtml(title)}</p>\n`
|
|
51
|
+
: '';
|
|
52
|
+
return `<div class="callout callout-${cssClass}">\n${titleHtml}`;
|
|
53
|
+
}
|
|
54
|
+
return '</div>\n';
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// --- Spoiler (collapsible) container: :::spoiler ---
|
|
60
|
+
md.use(container, 'spoiler', {
|
|
61
|
+
render(tokens, idx) {
|
|
62
|
+
const token = tokens[idx];
|
|
63
|
+
if (token.nesting === 1) {
|
|
64
|
+
const titleMatch = token.info.trim().match(/^spoiler\s+(.*)$/);
|
|
65
|
+
const summary = titleMatch ? titleMatch[1].trim() : 'Spoiler';
|
|
66
|
+
return `<details class="callout-spoiler">\n<summary>${md.utils.escapeHtml(summary)}</summary>\n`;
|
|
67
|
+
}
|
|
68
|
+
return '</details>\n';
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
// Unique HTML comment used as TOC marker (survives markdown-it rendering)
|
|
72
|
+
const TOC_MARKER = '<!--TOC_PLACEHOLDER-->';
|
|
73
|
+
// Regex to match [TOC] on its own line in raw Markdown
|
|
74
|
+
const TOC_MD_REGEX = /^\[TOC\]$/gmi;
|
|
75
|
+
/**
|
|
76
|
+
* Generate a table of contents HTML from rendered HTML headings
|
|
77
|
+
* @param {string} html - rendered HTML string
|
|
78
|
+
* @returns {string} TOC HTML string
|
|
79
|
+
*/
|
|
80
|
+
function generateTOC(html) {
|
|
81
|
+
const headingRegex = /<h([1-6])\s+id="([^"]*)"[^>]*>(.*?)<\/h[1-6]>/gi;
|
|
82
|
+
const headings = [];
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = headingRegex.exec(html)) !== null) {
|
|
85
|
+
headings.push({
|
|
86
|
+
level: parseInt(match[1]),
|
|
87
|
+
id: match[2],
|
|
88
|
+
text: match[3].replace(/<[^>]+>/g, ''), // strip inner HTML tags
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (headings.length === 0) return '';
|
|
92
|
+
// find the minimum heading level to use as base
|
|
93
|
+
const minLevel = Math.min(...headings.map(h => h.level));
|
|
94
|
+
let tocHtml = '<nav class="toc">\n<p class="toc-title">Contents</p>\n<ul>\n';
|
|
95
|
+
let currentLevel = minLevel;
|
|
96
|
+
let isFirst = true;
|
|
97
|
+
for (const heading of headings) {
|
|
98
|
+
const level = heading.level;
|
|
99
|
+
if (level > currentLevel) {
|
|
100
|
+
// go deeper: open nested <ul>
|
|
101
|
+
for (let i = currentLevel; i < level; i++) {
|
|
102
|
+
tocHtml += '<ul>\n';
|
|
103
|
+
}
|
|
104
|
+
} else if (level < currentLevel) {
|
|
105
|
+
// go up: close nested <ul>
|
|
106
|
+
for (let i = currentLevel; i > level; i--) {
|
|
107
|
+
tocHtml += '</ul>\n</li>\n';
|
|
108
|
+
}
|
|
109
|
+
} else if (!isFirst) {
|
|
110
|
+
// same level, close previous <li>
|
|
111
|
+
tocHtml += '</li>\n';
|
|
112
|
+
}
|
|
113
|
+
tocHtml += `<li><a href="#${heading.id}">${heading.text}</a>\n`;
|
|
114
|
+
currentLevel = level;
|
|
115
|
+
isFirst = false;
|
|
116
|
+
}
|
|
117
|
+
// close remaining open tags
|
|
118
|
+
for (let i = currentLevel; i >= minLevel; i--) {
|
|
119
|
+
tocHtml += '</li>\n</ul>\n';
|
|
120
|
+
}
|
|
121
|
+
tocHtml += '</nav>';
|
|
122
|
+
return tocHtml;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Replace [TOC] in raw Markdown with a unique marker before rendering
|
|
126
|
+
* @param {string} markdown - raw Markdown string
|
|
127
|
+
* @returns {string} Markdown with [TOC] replaced by marker
|
|
128
|
+
*/
|
|
129
|
+
function preprocessTOC(markdown) {
|
|
130
|
+
return markdown.replace(TOC_MD_REGEX, TOC_MARKER);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Replace TOC marker in rendered HTML with the actual generated TOC
|
|
134
|
+
* @param {string} html - rendered HTML string
|
|
135
|
+
* @returns {string} HTML with TOC inserted
|
|
136
|
+
*/
|
|
137
|
+
function processTOC(html) {
|
|
138
|
+
if (!html.includes(TOC_MARKER)) return html;
|
|
139
|
+
const toc = generateTOC(html);
|
|
140
|
+
return html.replace(TOC_MARKER, toc);
|
|
141
|
+
}
|
|
142
|
+
// CSS file paths
|
|
143
|
+
const themePath = path.join(__dirname, 'themes', 'default.css');
|
|
144
|
+
const hljsCssPath = path.join(__dirname, 'node_modules', 'highlight.js', 'styles', 'github-dark.css');
|
|
145
|
+
const texmathCssPath = path.join(__dirname, 'node_modules', 'markdown-it-texmath', 'css', 'texmath.css');
|
|
146
|
+
// KaTeX CSS is referenced as a local file:// URL so its bundled fonts resolve correctly
|
|
147
|
+
const katexCssUrl = `file://${path.join(__dirname, 'node_modules', 'katex', 'dist', 'katex.min.css')}`;
|
|
148
|
+
// CSS cache: read once, avoid I/O on every render
|
|
149
|
+
let cachedThemeCSS = null;
|
|
150
|
+
let cachedHljsCSS = null;
|
|
151
|
+
let cachedTexmathCSS = null;
|
|
152
|
+
function loadCSS() {
|
|
153
|
+
if (!cachedThemeCSS) {
|
|
154
|
+
cachedThemeCSS = fs.readFileSync(themePath, 'utf-8');
|
|
155
|
+
cachedHljsCSS = fs.readFileSync(hljsCssPath, 'utf-8');
|
|
156
|
+
cachedTexmathCSS = fs.readFileSync(texmathCssPath, 'utf-8');
|
|
157
|
+
}
|
|
158
|
+
return { themeCSS: cachedThemeCSS, hljsCSS: cachedHljsCSS, texmathCSS: cachedTexmathCSS };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* render only Markdown body HTML (without <head>, CSS, etc.) for SSE real-time update, avoid full page reload
|
|
162
|
+
* @param {string} markdownFilePath - Markdown file path
|
|
163
|
+
* @returns {string} rendered HTML body content
|
|
164
|
+
*/
|
|
165
|
+
function renderBodyHtml(markdownFilePath) {
|
|
166
|
+
let content = fs.readFileSync(markdownFilePath, 'utf-8');
|
|
167
|
+
content = content.replace(/^==page==$/gm, '\n<div class="page-break"></div>\n');
|
|
168
|
+
content = preprocessTOC(content);
|
|
169
|
+
let html = md.render(content);
|
|
170
|
+
html = processTOC(html);
|
|
171
|
+
return html;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* render Markdown file to complete HTML string (including CSS)
|
|
175
|
+
* @param {string} markdownFilePath - Markdown file path
|
|
176
|
+
* @param {object} [options] - options
|
|
177
|
+
* @param {string} [options.extraHeadHtml] - extra HTML to inject into <head>
|
|
178
|
+
* @param {string} [options.extraBodyHtml] - extra HTML to inject at the end of <body>
|
|
179
|
+
* @param {string} [options.contentWrapperId] - if provided, wrap content in a div with this ID (for SSE real-time update)
|
|
180
|
+
* @returns {string} complete HTML string
|
|
181
|
+
*/
|
|
182
|
+
function renderToHtml(markdownFilePath, options = {}) {
|
|
183
|
+
let markdownContent = fs.readFileSync(markdownFilePath, 'utf-8');
|
|
184
|
+
markdownContent = markdownContent.replace(/^==page==$/gm, '\n<div class="page-break"></div>\n');
|
|
185
|
+
markdownContent = preprocessTOC(markdownContent);
|
|
186
|
+
const { themeCSS, hljsCSS, texmathCSS } = loadCSS();
|
|
187
|
+
let bodyHtml = md.render(markdownContent);
|
|
188
|
+
bodyHtml = processTOC(bodyHtml);
|
|
189
|
+
// if contentWrapperId is provided, wrap content in a div for SSE real-time update
|
|
190
|
+
if (options.contentWrapperId) {
|
|
191
|
+
bodyHtml = `<div id="${options.contentWrapperId}">${bodyHtml}</div>`;
|
|
192
|
+
}
|
|
193
|
+
const extraHead = options.extraHeadHtml || '';
|
|
194
|
+
const extraBody = options.extraBodyHtml || '';
|
|
195
|
+
return `<!DOCTYPE html>
|
|
196
|
+
<html>
|
|
197
|
+
<head>
|
|
198
|
+
<meta charset="UTF-8">
|
|
199
|
+
<link rel="stylesheet" href="${katexCssUrl}">
|
|
200
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
201
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
202
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
|
|
203
|
+
<style>${texmathCSS} ${hljsCSS} ${themeCSS}</style>
|
|
204
|
+
${extraHead}
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
${bodyHtml}
|
|
208
|
+
${extraBody}
|
|
209
|
+
</body>
|
|
210
|
+
</html>`;
|
|
211
|
+
}
|
|
212
|
+
module.exports = { renderToHtml, renderBodyHtml };
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/* global */
|
|
2
|
+
*,
|
|
3
|
+
*::before,
|
|
4
|
+
*::after {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
font-family: 'inter', 'noto sans tc', sans-serif;
|
|
10
|
+
font-size: 15px;
|
|
11
|
+
line-height: 1.75;
|
|
12
|
+
color: #1a1a2e;
|
|
13
|
+
background: #ffffff;
|
|
14
|
+
max-width: 780px;
|
|
15
|
+
margin: 0 auto;
|
|
16
|
+
padding: 0;
|
|
17
|
+
-webkit-font-smoothing: antialiased;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* headings */
|
|
21
|
+
h1,
|
|
22
|
+
h2,
|
|
23
|
+
h3,
|
|
24
|
+
h4,
|
|
25
|
+
h5,
|
|
26
|
+
h6 {
|
|
27
|
+
font-weight: 700;
|
|
28
|
+
line-height: 1.3;
|
|
29
|
+
margin-top: 2em;
|
|
30
|
+
margin-bottom: 0.6em;
|
|
31
|
+
color: #0d0d1a;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
h1 {
|
|
35
|
+
font-size: 2em;
|
|
36
|
+
padding-bottom: 0.3em;
|
|
37
|
+
border-bottom: 2px solid #e8eaf0;
|
|
38
|
+
margin-top: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ensure the first element on every page has no top margin */
|
|
42
|
+
body> :first-child {
|
|
43
|
+
margin-top: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.page-break+* {
|
|
47
|
+
margin-top: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
h2 {
|
|
51
|
+
font-size: 1.4em;
|
|
52
|
+
padding-bottom: 0.2em;
|
|
53
|
+
border-bottom: 1px solid #e8eaf0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
h3 {
|
|
57
|
+
font-size: 1.15em;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
h4 {
|
|
61
|
+
font-size: 1em;
|
|
62
|
+
color: #444;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* paragraphs and texts */
|
|
66
|
+
p {
|
|
67
|
+
margin: 0 0 1em;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
a {
|
|
71
|
+
color: #3b82f6;
|
|
72
|
+
text-decoration: none;
|
|
73
|
+
border-bottom: 1px solid rgba(59, 130, 246, 0.3);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
strong {
|
|
77
|
+
font-weight: 700;
|
|
78
|
+
color: #0d0d1a;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
em {
|
|
82
|
+
font-style: italic;
|
|
83
|
+
color: #333;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* code */
|
|
87
|
+
code {
|
|
88
|
+
font-family: 'jetbrains mono', 'menlo', monospace;
|
|
89
|
+
font-size: 0.85em;
|
|
90
|
+
background: #ebebeb;
|
|
91
|
+
color: #1a1a1a;
|
|
92
|
+
padding: 0.15em 0.4em;
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* code blocks */
|
|
97
|
+
pre {
|
|
98
|
+
font-family: 'jetbrains mono', 'menlo', monospace;
|
|
99
|
+
font-size: 0.82em;
|
|
100
|
+
line-height: 1.65;
|
|
101
|
+
background: #1e1e2e;
|
|
102
|
+
color: #cdd6f4;
|
|
103
|
+
padding: 20px 24px;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
overflow-x: auto;
|
|
106
|
+
margin: 1.2em 0;
|
|
107
|
+
border: 1px solid #2a2a3e;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pre code {
|
|
111
|
+
background: none;
|
|
112
|
+
color: inherit;
|
|
113
|
+
padding: 0;
|
|
114
|
+
font-size: inherit;
|
|
115
|
+
border-radius: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* blockquotes */
|
|
119
|
+
blockquote {
|
|
120
|
+
margin: 1.2em 0;
|
|
121
|
+
padding: 12px 20px;
|
|
122
|
+
border-left: 4px solid #3b82f6;
|
|
123
|
+
background: #f0f6ff;
|
|
124
|
+
border-radius: 0 4px 4px 0;
|
|
125
|
+
color: #334155;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
blockquote p {
|
|
129
|
+
margin: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* horizontal rules */
|
|
133
|
+
hr {
|
|
134
|
+
border: none;
|
|
135
|
+
border-top: 2px solid #e8eaf0;
|
|
136
|
+
margin: 2em 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* lists */
|
|
140
|
+
ul,
|
|
141
|
+
ol {
|
|
142
|
+
padding-left: 1.6em;
|
|
143
|
+
margin: 0.5em 0 1em;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
li {
|
|
147
|
+
margin: 0.3em 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* task lists */
|
|
151
|
+
ul.contains-task-list {
|
|
152
|
+
list-style-type: none;
|
|
153
|
+
padding-left: 0.2em;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
li.task-list-item {
|
|
157
|
+
list-style: none;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
li.task-list-item input[type="checkbox"] {
|
|
161
|
+
margin-right: 6px;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* tables */
|
|
166
|
+
table {
|
|
167
|
+
width: 100%;
|
|
168
|
+
table-layout: fixed;
|
|
169
|
+
border-collapse: collapse;
|
|
170
|
+
margin: 1.2em 0;
|
|
171
|
+
font-size: 0.92em;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
thead tr {
|
|
175
|
+
background: #f5f6ff;
|
|
176
|
+
border-bottom: 2px solid #d4d8f0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
th {
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
text-align: left;
|
|
182
|
+
padding: 10px 14px;
|
|
183
|
+
color: #0d0d1a;
|
|
184
|
+
border: 1px solid #d4d8f0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
td {
|
|
188
|
+
padding: 9px 14px;
|
|
189
|
+
border: 1px solid #e8eaf0;
|
|
190
|
+
color: #334155;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
tr:nth-child(even) td {
|
|
194
|
+
background: #fafafe;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* images */
|
|
198
|
+
img {
|
|
199
|
+
max-width: 100%;
|
|
200
|
+
border-radius: 6px;
|
|
201
|
+
display: block;
|
|
202
|
+
margin: 1em auto;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* fundamental automatic page breaks */
|
|
206
|
+
h1,
|
|
207
|
+
h2,
|
|
208
|
+
h3,
|
|
209
|
+
h4 {
|
|
210
|
+
break-after: avoid;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
pre,
|
|
214
|
+
blockquote,
|
|
215
|
+
img,
|
|
216
|
+
table,
|
|
217
|
+
figure {
|
|
218
|
+
break-inside: avoid;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* manual page breaks */
|
|
222
|
+
.page-break {
|
|
223
|
+
break-after: page;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* table of contents */
|
|
227
|
+
.toc {
|
|
228
|
+
background: #f8f9fc;
|
|
229
|
+
border: 1px solid #e2e5f0;
|
|
230
|
+
border-radius: 6px;
|
|
231
|
+
padding: 20px 28px;
|
|
232
|
+
margin: 1.5em 0 2em;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.toc-title {
|
|
236
|
+
font-weight: 700;
|
|
237
|
+
font-size: 1.15em;
|
|
238
|
+
color: #0d0d1a;
|
|
239
|
+
margin: 0 0 0.6em;
|
|
240
|
+
padding-bottom: 0.4em;
|
|
241
|
+
border-bottom: 1px solid #e2e5f0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.toc ul {
|
|
245
|
+
list-style: none;
|
|
246
|
+
padding-left: 0;
|
|
247
|
+
margin: 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.toc ul ul {
|
|
251
|
+
padding-left: 1.4em;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.toc li {
|
|
255
|
+
margin: 0.25em 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.toc a {
|
|
259
|
+
color: #3b5998;
|
|
260
|
+
border-bottom: none;
|
|
261
|
+
text-decoration: none;
|
|
262
|
+
font-size: 0.95em;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.toc a:hover {
|
|
266
|
+
color: #3b82f6;
|
|
267
|
+
text-decoration: underline;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ===== Callout container blocks ===== */
|
|
271
|
+
.callout {
|
|
272
|
+
margin: 1.2em 0;
|
|
273
|
+
padding: 14px 20px;
|
|
274
|
+
border-left: 4px solid;
|
|
275
|
+
border-radius: 0 6px 6px 0;
|
|
276
|
+
font-size: 0.95em;
|
|
277
|
+
line-height: 1.7;
|
|
278
|
+
break-inside: avoid;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.callout p {
|
|
282
|
+
margin: 0 0 0.5em;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.callout p:last-child {
|
|
286
|
+
margin-bottom: 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.callout strong,
|
|
290
|
+
.callout-spoiler strong {
|
|
291
|
+
color: inherit;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.callout-title {
|
|
295
|
+
font-weight: 700;
|
|
296
|
+
margin: 0 0 0.4em !important;
|
|
297
|
+
font-size: 1em;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* info – blue */
|
|
301
|
+
.callout-info {
|
|
302
|
+
border-left-color: #3b82f6;
|
|
303
|
+
background: #eff6ff;
|
|
304
|
+
color: #1e3a5f;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.callout-info .callout-title {
|
|
308
|
+
color: #2563eb;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* warning – amber */
|
|
312
|
+
.callout-warning {
|
|
313
|
+
border-left-color: #f59e0b;
|
|
314
|
+
background: #fffbeb;
|
|
315
|
+
color: #78350f;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.callout-warning .callout-title {
|
|
319
|
+
color: #d97706;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* danger – red */
|
|
323
|
+
.callout-danger {
|
|
324
|
+
border-left-color: #ef4444;
|
|
325
|
+
background: #fef2f2;
|
|
326
|
+
color: #7f1d1d;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.callout-danger .callout-title {
|
|
330
|
+
color: #dc2626;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* success – green */
|
|
334
|
+
.callout-success {
|
|
335
|
+
border-left-color: #22c55e;
|
|
336
|
+
background: #f0fdf4;
|
|
337
|
+
color: #14532d;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.callout-success .callout-title {
|
|
341
|
+
color: #16a34a;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* spoiler – collapsible */
|
|
345
|
+
.callout-spoiler {
|
|
346
|
+
margin: 1.2em 0;
|
|
347
|
+
padding: 14px 20px;
|
|
348
|
+
border-left: 4px solid #94a3b8;
|
|
349
|
+
background: #f8fafc;
|
|
350
|
+
border-radius: 0 6px 6px 0;
|
|
351
|
+
break-inside: avoid;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.callout-spoiler summary {
|
|
355
|
+
font-weight: 700;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
color: #475569;
|
|
358
|
+
font-size: 0.95em;
|
|
359
|
+
user-select: none;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.callout-spoiler summary:hover {
|
|
363
|
+
color: #334155;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.callout-spoiler[open] summary {
|
|
367
|
+
margin-bottom: 0.6em;
|
|
368
|
+
padding-bottom: 0.4em;
|
|
369
|
+
border-bottom: 1px solid #e2e8f0;
|
|
370
|
+
}
|