Skip to content

Commit e94b1b2

Browse files
authored
feat: base 64 formulas support (#113)
* feat: base 64 formulas support * different test for gh * gh approach * fix asciimath parameters * remove console * mathml with base64
1 parent c5a5c35 commit e94b1b2

11 files changed

+1709
-567
lines changed

package-lock.json

+817-462
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
"twig": "~1.15.4"
2121
},
2222
"devDependencies": {
23-
"chai": "^4.3.7",
24-
"mocha": "^10.2.0",
23+
"chai": "^4.5.0",
24+
"mocha": "^10.8.2",
25+
"proxyquire": "^2.1.3",
26+
"sinon": "^19.0.2",
2527
"supertest": "^6.3.3"
2628
}
2729
}

routes/asciimath.js

+23-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,34 @@ const imageGenerator = require('../src/imageGenerator');
66

77
router.get('/', (req, res, next) => {
88

9+
let formula = req.query.asciimath;
10+
11+
// Check if the formula is base64 encoded
12+
if (req.query.isBase64 === 'true' || req.query.isBase64 === '1' && formula) {
13+
try {
14+
formula = Buffer.from(formula, 'base64').toString('utf-8');
15+
} catch (error) {
16+
return res.status(400).json({
17+
error: 'Invalid base64 string',
18+
message: 'The provided formula is not a valid base64 encoded string'
19+
});
20+
}
21+
}
22+
923
const configs = {
1024
typeset: {
11-
math: req.query.asciimath,
12-
format: 'AsciiMath',
25+
math: formula,
26+
format: req.query.format || 'AsciiMath',
1327
svg: true,
1428
speakText: true, // a11y
1529
},
30+
query: {
31+
...req.query,
32+
svg: req.query.svg === '1' || req.query.svg === 'true',
33+
fg: req.query.fg || '000000', // Default to black
34+
dpi: parseInt(req.query.dpi) || 75, // Default to 75 DPI
35+
isBase64: req.query.isBase64 === 'true' || req.query.isBase64 === '1'
36+
}
1637
};
1738

1839
return imageGenerator.generate(configs, req, res, next);

routes/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ var router = express.Router();
33

44
/* GET home page. */
55
router.get('/', function(req, res, next) {
6-
res.render('index', { title: 'PB MathJax' });
6+
res.render('index', { title: 'PB MathJax 🚀' });
77
});
88

99
module.exports = router;

routes/latex.js

+44-5
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,57 @@ const router = express.Router();
55
const imageGenerator = require('../src/imageGenerator');
66

77
router.get('/', (req, res, next) => {
8+
let formula = req.query.latex;
9+
10+
// Check if the formula is base64 encoded
11+
if (req.query.isBase64 === 'true' || req.query.isBase64 === '1' && formula) {
12+
try {
13+
formula = Buffer.from(formula, 'base64').toString('utf-8');
14+
} catch (error) {
15+
return res.status(400).json({
16+
error: 'Invalid base64 string',
17+
message: 'The provided formula is not a valid base64 encoded string'
18+
});
19+
}
20+
}
821

922
const configs = {
1023
typeset: {
11-
math: req.query.latex,
12-
format: 'TeX',
13-
svg: true,
14-
speakText: true, // a11y
24+
math: formula,
25+
format: req.query.format || 'TeX',
26+
speakText: req.query.speakText !== 'false', // a11y
27+
em: parseFloat(req.query.em) || 16,
28+
ex: parseFloat(req.query.ex) || 8,
29+
containerWidth: parseInt(req.query.width) || 1000,
30+
lineWidth: parseInt(req.query.lineWidth) || 1000,
31+
scale: parseFloat(req.query.scale) || 1
1532
},
33+
query: {
34+
...req.query,
35+
svg: req.query.svg === '1' || req.query.svg === 'true',
36+
fg: req.query.fg || '000000', // Default to black
37+
dpi: parseInt(req.query.dpi) || 75, // Default to 75 DPI
38+
isBase64: req.query.isBase64 === 'true' || req.query.isBase64 === '1'
39+
}
1640
};
1741

18-
return imageGenerator.generate(configs, req, res, next);
42+
// Validate formula
43+
if (!formula) {
44+
return res.status(400).json({
45+
error: 'Missing formula',
46+
message: 'The latex parameter is required'
47+
});
48+
}
49+
50+
// Validate color format if provided
51+
if (req.query.fg && !/^[0-9A-Fa-f]{6}$/.test(req.query.fg)) {
52+
return res.status(400).json({
53+
error: 'Invalid color format',
54+
message: 'The fg parameter must be a 6-digit hex color without the # prefix'
55+
});
56+
}
1957

58+
return imageGenerator.generate(configs, req, res, next);
2059
});
2160

2261
module.exports = router;

routes/mathml.js

+22-1
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,34 @@ const imageGenerator = require('../src/imageGenerator');
66

77
router.get('/', (req, res, next) => {
88

9+
let formula = req.query.mathml;
10+
11+
// Check if the formula is base64 encoded
12+
if (req.query.isBase64 === 'true' || req.query.isBase64 === '1' && formula) {
13+
try {
14+
formula = Buffer.from(formula, 'base64').toString('utf-8');
15+
} catch (error) {
16+
return res.status(400).json({
17+
error: 'Invalid base64 string',
18+
message: 'The provided formula is not a valid base64 encoded string'
19+
});
20+
}
21+
}
22+
923
const configs = {
1024
typeset: {
11-
math: req.query.mathml,
25+
math: formula,
1226
format: 'MathML',
1327
svg: true,
1428
speakText: true, // a11y
1529
},
30+
query: {
31+
...req.query,
32+
svg: req.query.svg === '1' || req.query.svg === 'true',
33+
fg: req.query.fg || '000000', // Default to black
34+
dpi: parseInt(req.query.dpi) || 75, // Default to 75 DPI
35+
isBase64: req.query.isBase64 === 'true' || req.query.isBase64 === '1'
36+
}
1637
};
1738

1839
return imageGenerator.generate(configs, req, res, next);

src/imageGenerator.js

+65-27
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const { liteAdaptor } = require('mathjax-full/js/adaptors/liteAdaptor.js');
99
const { RegisterHTMLHandler } = require('mathjax-full/js/handlers/html.js');
1010
const { AllPackages } = require('mathjax-full/js/input/tex/AllPackages.js');
1111
const { decode } = require('html-entities');
12-
12+
const { log } = require('console');
1313

1414
const adaptor = liteAdaptor();
1515
RegisterHTMLHandler(adaptor);
@@ -24,7 +24,7 @@ const tex = new TeX({
2424
});
2525

2626
const mathml = new MathML();
27-
const asciimath = new AsciiMath(); // Enable AsciiMath input
27+
const asciimath = new AsciiMath();
2828

2929
const svg = new SVG({
3030
fontCache: 'none',
@@ -39,9 +39,11 @@ function handleError(res) {
3939
}
4040

4141
module.exports.generate = async (configs, req, res, next) => {
42-
let myForeground = req.query.fg;
43-
let dpi = req.query.dpi;
44-
let isSvg = req.query.svg;
42+
const query = configs.query || {};
43+
44+
let myForeground = query.fg;
45+
let dpi = query.dpi;
46+
let isSvg = query.svg === true || query.svg === '1' || query.svg === 'true';
4547

4648
let inputFormat = tex;
4749

@@ -68,7 +70,6 @@ module.exports.generate = async (configs, req, res, next) => {
6870
}
6971

7072
function stripRequireCommands(math) {
71-
// Match \require{package} with optional spaces
7273
return math.replace(/\\require\s*\{[^}]*\}\s*/g, '');
7374
}
7475

@@ -79,35 +80,34 @@ module.exports.generate = async (configs, req, res, next) => {
7980
if (dpi < 75) dpi = 75;
8081
if (dpi > 2400) dpi = 2400;
8182

82-
isSvg = !(!isSvg || isSvg === '0');
83-
8483
try {
8584
if (!configs?.typeset?.math) {
86-
console.log('No math provided');
8785
return handleError(res);
8886
}
89-
// Decode HTML entities in the math input
90-
const math = stripRequireCommands(decode(configs.typeset.math));
87+
88+
let decodedMath = configs.typeset.math;
89+
90+
try {
91+
decodedMath = decodeURIComponent(decodedMath);
92+
decodedMath = decodedMath.replace(/&#038;/g, '&').replace(/&#38;/g, '&');
93+
decodedMath = decode(decodedMath);
94+
} catch (decodeError) {
95+
return handleError(res);
96+
}
9197

98+
const math = stripRequireCommands(decodedMath);
9299
const isInline = (math.startsWith('\\(') && math.endsWith('\\)')) ||
93100
(math.startsWith('$') && math.endsWith('$') && !math.startsWith('$$'));
94-
95101
const isBlock = (math.startsWith('\\[') && math.endsWith('\\]')) ||
96102
(math.startsWith('$$') && math.endsWith('$$'));
97103

98104
let cleanMath = math.trim();
99-
100105
if (isInline) {
101-
if (math.startsWith('\\(') && math.endsWith('\\)')) {
102-
cleanMath = math.slice(2, -2);
103-
} else if (math.startsWith('$') && math.endsWith('$')) {
104-
cleanMath = math.slice(1, -1);
105-
}
106+
cleanMath = math.slice(math.startsWith('\\(') ? 2 : 1, -2);
106107
} else if (isBlock) {
107108
cleanMath = math.slice(2, -2);
108109
}
109110

110-
let svgContent;
111111
try {
112112
const node = mathJaxDocument.convert(cleanMath, {
113113
display: !isInline,
@@ -118,7 +118,9 @@ module.exports.generate = async (configs, req, res, next) => {
118118
scale: 1
119119
});
120120

121-
svgContent = adaptor.innerHTML(node);
121+
122+
let svgContent = adaptor.innerHTML(node);
123+
122124

123125
if (!svgContent || !svgContent.includes('<svg') || !svgContent.includes('</svg>')) {
124126
return handleError(res);
@@ -130,25 +132,61 @@ module.exports.generate = async (configs, req, res, next) => {
130132
);
131133

132134
if (svgContent.includes('merror')) {
133-
console.error('MathJax detected an error:', svgContent);
134135
return handleError(res);
135136
}
136137

137138
if (isSvg) {
138139
res.set('Content-Type', 'image/svg+xml');
139140
return res.send(svgContent);
140141
} else {
141-
const sharp = require('sharp');
142-
const png = await sharp(Buffer.from(svgContent), { density: dpi }).png().toBuffer();
143-
res.set('Content-Type', 'image/png');
144-
return res.send(png);
142+
try {
143+
if (!svgContent.trim().startsWith('<svg')) {
144+
return handleError(res);
145+
}
146+
147+
// Set Content-Type header early
148+
res.set('Content-Type', 'image/png');
149+
150+
let fullSvgContent = svgContent;
151+
152+
if (!fullSvgContent.includes('xmlns="http://www.w3.org/2000/svg"')) {
153+
fullSvgContent = fullSvgContent.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
154+
}
155+
156+
if (!fullSvgContent.startsWith('<?xml')) {
157+
fullSvgContent = '<?xml version="1.0" standalone="no"?>\n' + fullSvgContent;
158+
}
159+
160+
161+
const sharp = require('sharp');
162+
const buffer = Buffer.from(fullSvgContent);
163+
const image = sharp(buffer, {
164+
density: dpi > 300 ? 300 : dpi,
165+
limitInputPixels: 5000 * 5000
166+
});
167+
168+
const png = await image
169+
.resize(500, 500, {
170+
fit: 'inside',
171+
withoutEnlargement: true,
172+
background: { r: 255, g: 255, b: 255, alpha: 0 }
173+
})
174+
.png({
175+
compressionLevel: 6,
176+
adaptiveFiltering: false,
177+
force: true
178+
})
179+
.toBuffer();
180+
181+
return res.send(png);
182+
} catch (pngError) {
183+
return handleError(res);
184+
}
145185
}
146186
} catch (err) {
147-
console.error('MathJax processing error:', err);
148187
return handleError(res);
149188
}
150189
} catch (err) {
151-
console.error('General error:', err);
152190
return handleError(res);
153191
}
154192
};

0 commit comments

Comments
 (0)