render-math 0.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/README.md +27 -0
- package/eslint.config.js +7 -0
- package/package.json +42 -0
- package/src/index.js +16 -0
- package/src/math-renderer.js +207 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# render-math
|
|
2
|
+
|
|
3
|
+
Render MathJax formula inside html fiies from `--src-dir` to SVG and
|
|
4
|
+
write the result html files to `--dest-dir`.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
render-math --help
|
|
8
|
+
Usage: render-math [options]
|
|
9
|
+
|
|
10
|
+
MathJax renderer. Read HTML and render math formula to SVG.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--src-dir <path> source directory (default: "...")
|
|
14
|
+
--dest-dir <path> destination directory (default: "...")
|
|
15
|
+
-f, --force force render
|
|
16
|
+
-q, --quite print render message only
|
|
17
|
+
--quieter do not print per file message
|
|
18
|
+
-h, --help display help for command
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The program uses default source/destination directories
|
|
22
|
+
if you don't specify `--src-dir` and `--dest-dir` options.
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
$BLOG_BASE_DIR/public # src
|
|
26
|
+
$BLOG_BASE_DIR/rendered_public # dest
|
|
27
|
+
```
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import { defineConfig } from "eslint/config";
|
|
4
|
+
|
|
5
|
+
export default defineConfig([
|
|
6
|
+
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
|
|
7
|
+
]);
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "render-math",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool that render maths with MathJax",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"math",
|
|
7
|
+
"mathjax",
|
|
8
|
+
"latex",
|
|
9
|
+
"tex",
|
|
10
|
+
"formula"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/ntalbs/render-math/#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ntalbs/render-math/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ntalbs/render-math.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"author": "ntalbs",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "src/index.js",
|
|
24
|
+
"bin": {
|
|
25
|
+
"render-math": "./src/index.js"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.6.2",
|
|
33
|
+
"commander": "^14.0.2",
|
|
34
|
+
"cripto": "^1.1.4",
|
|
35
|
+
"glob": "^13.0.0",
|
|
36
|
+
"jsdom": "^27.4.0",
|
|
37
|
+
"mathjax-full": "^3.2.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"eslint": "^9.39.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { render } from './math-renderer.js';
|
|
5
|
+
|
|
6
|
+
program
|
|
7
|
+
.description('MathJax renderer. Read HTML and render math formula to SVG.')
|
|
8
|
+
.option('--src-dir <path>', 'source directory', `${process.env.BLOG_BASE_DIR}/public`)
|
|
9
|
+
.option('--dest-dir <path>', 'destination directory', `${process.env.BLOG_BASE_DIR}/rendered-public`)
|
|
10
|
+
.option('-f, --force', 'force render')
|
|
11
|
+
.option('-q, --quite', 'print render message only')
|
|
12
|
+
.option('--quieter', 'do not print per file message')
|
|
13
|
+
.action((options) => {
|
|
14
|
+
render(options.srcDir, options.destDir, options);
|
|
15
|
+
})
|
|
16
|
+
.parse(process.argv);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { JSDOM } from 'jsdom';
|
|
7
|
+
|
|
8
|
+
// MathJax imports
|
|
9
|
+
import { mathjax } from 'mathjax-full/js/mathjax.js';
|
|
10
|
+
import { TeX } from 'mathjax-full/js/input/tex.js';
|
|
11
|
+
import { SVG } from 'mathjax-full/js/output/svg.js';
|
|
12
|
+
import { liteAdaptor } from 'mathjax-full/js/adaptors/liteAdaptor.js';
|
|
13
|
+
import { RegisterHTMLHandler } from 'mathjax-full/js/handlers/html.js';
|
|
14
|
+
import { AllPackages } from 'mathjax-full/js/input/tex/AllPackages.js';
|
|
15
|
+
|
|
16
|
+
export function render(src, target, options) {
|
|
17
|
+
// --- MathJax Initialization ---
|
|
18
|
+
const adaptor = liteAdaptor();
|
|
19
|
+
RegisterHTMLHandler(adaptor);
|
|
20
|
+
const tex = new TeX({ packages: AllPackages });
|
|
21
|
+
const svg = new SVG({ fontCache: 'local' });
|
|
22
|
+
const mjPage = mathjax.document('', { InputJax: tex, OutputJax: svg });
|
|
23
|
+
|
|
24
|
+
const cacheFile = path.join(target, 'math-cache.json');
|
|
25
|
+
let cache = readCacheFile(cacheFile);
|
|
26
|
+
|
|
27
|
+
const stat = {
|
|
28
|
+
directories: 0,
|
|
29
|
+
rendered: 0,
|
|
30
|
+
copied: 0,
|
|
31
|
+
skipped: 0,
|
|
32
|
+
total: 0
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
const files = glob.sync(`${src}/**/*`);
|
|
37
|
+
|
|
38
|
+
console.log(chalk.yellow.bold('> Start processing:'), `Found ${files.length} files ...`);
|
|
39
|
+
files.forEach(f => process(f));
|
|
40
|
+
console.log(chalk.green.bold('> Completed.'), stat);
|
|
41
|
+
|
|
42
|
+
writeCacheFile(cacheFile);
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
// -- internal functions --
|
|
46
|
+
|
|
47
|
+
function readCacheFile(cacheFile) {
|
|
48
|
+
if (fs.existsSync(cacheFile)) {
|
|
49
|
+
return JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
50
|
+
} else {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeCacheFile(cacheFile) {
|
|
56
|
+
fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function process(sourcePath) {
|
|
60
|
+
let targetPath = getTargetPathFrom(sourcePath);
|
|
61
|
+
|
|
62
|
+
let sourcePathStat = fs.statSync(sourcePath);
|
|
63
|
+
stat.total++;
|
|
64
|
+
if (sourcePathStat.isDirectory()) {
|
|
65
|
+
ensureDir(targetPath);
|
|
66
|
+
stat.directories++;
|
|
67
|
+
} else {
|
|
68
|
+
if (isSrcNotChanged(sourcePath) && !options.force) {
|
|
69
|
+
stat.skipped ++;
|
|
70
|
+
if (!options.quite) {
|
|
71
|
+
log('skip');
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ensureDir(path.dirname(targetPath));
|
|
77
|
+
if (sourcePath.endsWith('.html')) {
|
|
78
|
+
if (processHtml(sourcePath, targetPath)) {
|
|
79
|
+
log('render');
|
|
80
|
+
} else {
|
|
81
|
+
log('copy');
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
stat.copied++;
|
|
85
|
+
if (!options.quite) {
|
|
86
|
+
log('copy');
|
|
87
|
+
}
|
|
88
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function log(action) {
|
|
93
|
+
if (options.quieter) return;
|
|
94
|
+
|
|
95
|
+
switch (action) {
|
|
96
|
+
case 'render':
|
|
97
|
+
console.log(chalk.red.bold('RENDER:'), sourcePath);
|
|
98
|
+
break;
|
|
99
|
+
case 'copy':
|
|
100
|
+
if (options.quiet) return;
|
|
101
|
+
console.log(chalk.yellow.bold('COPY:'), sourcePath);
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
if (options.quiet) return;
|
|
105
|
+
console.log(chalk.green.bold('SKIP:'), sourcePath);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isSrcNotChanged(src) {
|
|
112
|
+
let newMd5 = md5(src);
|
|
113
|
+
let same = cache[src] === newMd5;
|
|
114
|
+
if (!same) {
|
|
115
|
+
cache[src] = newMd5;
|
|
116
|
+
}
|
|
117
|
+
return same;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function md5(src) {
|
|
121
|
+
let content = fs.readFileSync(src)
|
|
122
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function processHtml(sourcePath, targetPath) {
|
|
126
|
+
const html = fs.readFileSync(sourcePath, 'utf8');
|
|
127
|
+
const dom = new JSDOM(html);
|
|
128
|
+
const document = dom.window.document;
|
|
129
|
+
const body = document.body;
|
|
130
|
+
let needsUpdate = false;
|
|
131
|
+
|
|
132
|
+
processNode(body);
|
|
133
|
+
|
|
134
|
+
if (needsUpdate) {
|
|
135
|
+
// Add the required MathJax CSS to the <head>
|
|
136
|
+
const styleTag = document.createElement('style');
|
|
137
|
+
styleTag.setAttribute('id', 'MJX-SVG-styles');
|
|
138
|
+
styleTag.innerHTML = adaptor.innerHTML(svg.styleSheet(mjPage));
|
|
139
|
+
document.head.appendChild(styleTag);
|
|
140
|
+
fs.writeFileSync(targetPath, dom.serialize());
|
|
141
|
+
stat.rendered++;
|
|
142
|
+
return true; // rendered
|
|
143
|
+
} else {
|
|
144
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
145
|
+
stat.copied++;
|
|
146
|
+
return false; // no math, copied
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function processNode(node) {
|
|
150
|
+
// Regex for $$...$$ and $...$
|
|
151
|
+
const displayRegex = /\$\$(.*?)\$\$/gs;
|
|
152
|
+
const inlineRegex = /(?<!\\)\$([^$]+?)\$/g;
|
|
153
|
+
|
|
154
|
+
if (node.nodeType === 3) { // Text node
|
|
155
|
+
let text = node.textContent;
|
|
156
|
+
if (displayRegex.test(text) || inlineRegex.test(text)) {
|
|
157
|
+
needsUpdate = true;
|
|
158
|
+
|
|
159
|
+
// Render Display Math
|
|
160
|
+
text = text.replace(displayRegex, (_, texStr) => {
|
|
161
|
+
const output = mjPage.convert(texStr, { display: true });
|
|
162
|
+
const svg = adaptor.innerHTML(output);
|
|
163
|
+
return `<mjx-container display="true" style="display: block; text-align: center; margin: 1em 0;">${svg}</mjx-container>`;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Render Inline Math
|
|
167
|
+
text = text.replace(inlineRegex, (_, texStr) => {
|
|
168
|
+
const output = mjPage.convert(texStr, { display: false });
|
|
169
|
+
return adaptor.innerHTML(output);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Create a temporary container to hold the new HTML
|
|
173
|
+
const wrapper = document.createElement('div');
|
|
174
|
+
wrapper.innerHTML = text;
|
|
175
|
+
node.replaceWith(...wrapper.childNodes);
|
|
176
|
+
}
|
|
177
|
+
} else if (node.className === 'latex-block') { // div.latex-block by org-mode
|
|
178
|
+
needsUpdate = true;
|
|
179
|
+
|
|
180
|
+
let texStr = node.textContent;
|
|
181
|
+
|
|
182
|
+
// Render Display Math
|
|
183
|
+
const output = mjPage.convert(texStr, { display: true });
|
|
184
|
+
const svg = adaptor.innerHTML(output);
|
|
185
|
+
let text = `<mjx-container display="true" style="display: block; text-align: center; margin: 1em 0;">${svg}</mjx-container>`;
|
|
186
|
+
|
|
187
|
+
// Create a temporary container to hold the new HTML
|
|
188
|
+
const wrapper = document.createElement('div');
|
|
189
|
+
wrapper.innerHTML = text;
|
|
190
|
+
node.replaceWith(...wrapper.childNodes);
|
|
191
|
+
} else if (node.nodeName !== 'SCRIPT' && node.nodeName !== 'CODE' && node.nodeName !== 'PRE') {
|
|
192
|
+
// Recursively check children, skipping code blocks
|
|
193
|
+
Array.from(node.childNodes).forEach(processNode);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getTargetPathFrom(sourcePath) {
|
|
199
|
+
return sourcePath.replace(src, target);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureDir(dir) {
|
|
203
|
+
if (!fs.existsSync(dir)) {
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|