Using Sharp for image conversion; promises; and more
Rewrote much of the application to use Promises instead of callbacks. Replaced the two previous image conversion packages with Sharp, which now handles svg-to-png and svg-to-jpg conversion. Added queueing support to prevent resource exhaustion.
This commit is contained in:
parent
18f4dc46fa
commit
dd7b5d9d0b
15
README.md
15
README.md
@ -43,15 +43,6 @@ cd latex2image-web/
|
||||
npm install
|
||||
```
|
||||
|
||||
### (Optional) Global Node.js packages for non-SVG image export
|
||||
|
||||
SVG files can be generated as-is, but for PNG and JPG export support, the two global Node.js packages [svgexport](https://www.npmjs.com/package/svgexport) and [imagemin-cli](https://www.npmjs.com/package/imagemin-cli) are required:
|
||||
|
||||
```
|
||||
npm install svgexport -g
|
||||
npm install imagemin-cli -g
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To run:
|
||||
@ -74,12 +65,12 @@ Additionally, the compilation process will be killed after 5 seconds if not comp
|
||||
|
||||
## Internals
|
||||
|
||||
Commands used:
|
||||
### Commands used:
|
||||
|
||||
* `latex` - Converts `.tex` source file to `.dvi` intermediate
|
||||
* `dvisvgm` - Converts `.dvi` file to `.svg` vector image
|
||||
* `svgexport` - Converts `.svg` to `.png` or `.jpg` raster images
|
||||
* `imagemin` - Compresses `.png` and `.jpg` images
|
||||
|
||||
We use [sharp](https://www.npmjs.com/package/sharp) to convert the SVG file to PNG and JPG images where required.
|
||||
|
||||
## Notes
|
||||
|
||||
|
194
app.js
194
app.js
@ -1,6 +1,11 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const shell = require('shelljs');
|
||||
const express = require('express');
|
||||
const promiseRouter = require('express-promise-router');
|
||||
const queue = require('express-queue');
|
||||
const sharp = require('sharp');
|
||||
const Promise = require('bluebird');
|
||||
|
||||
const port = 3001;
|
||||
|
||||
@ -15,23 +20,17 @@ const validScales = ['10%', '25%', '50%', '75%', '100%', '125%', '150%', '200%',
|
||||
// Percentage scales mapped to floating point values used in arguments
|
||||
const validScalesInternal = ['0.1', '0.25', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0', '5.0', '10.0'];
|
||||
|
||||
// Command to compile .tex file to .dvi file. Timeout kills LaTeX after 5 seconds if held up
|
||||
// Command to compile .tex file to .dvi file. Timeout kills it after 5 seconds if held up
|
||||
const latexCMD = 'timeout 5 latex -interaction nonstopmode -halt-on-error --no-shell-escape equation.tex';
|
||||
|
||||
// Command to convert .dvi to .svg file
|
||||
const dvisvgmCMD = 'dvisvgm --no-fonts --scale=OUTPUT_SCALE --exact equation.dvi';
|
||||
// Command to convert .dvi to .svg file. Timeout kills it after 5 seconds if held up
|
||||
const dvisvgmCMD = 'timeout 5 dvisvgm --no-fonts --scale=OUTPUT_SCALE --exact equation.dvi';
|
||||
|
||||
const dockerImageName = 'blang/latex:ubuntu'; // https://github.com/blang/latex-docker
|
||||
|
||||
// Command to run the above commands in a new Docker container (with LaTeX preinstalled)
|
||||
const dockerCMD = `cd TEMP_DIR_NAME && exec docker run --rm -i --user="$(id -u):$(id -g)" --net=none -v "$PWD":/data "${dockerImageName}" /bin/sh -c "${latexCMD} && ${dvisvgmCMD}"`;
|
||||
|
||||
// Commands to convert .svg to .png/.jpg and compress
|
||||
const svgToImageCMD = 'svgexport SVG_FILE_NAME OUT_FILE_NAME';
|
||||
const imageMinCMD = 'imagemin IN_FILE_NAME > OUT_FILE_NAME';
|
||||
|
||||
const fontSize = 12;
|
||||
|
||||
// LaTeX document template
|
||||
const preamble = `
|
||||
\\usepackage{amsmath}
|
||||
@ -41,7 +40,7 @@ const preamble = `
|
||||
`;
|
||||
|
||||
const documentTemplate = `
|
||||
\\documentclass[${fontSize}pt]{article}
|
||||
\\documentclass[12pt]{article}
|
||||
${preamble}
|
||||
\\thispagestyle{empty}
|
||||
\\begin{document}
|
||||
@ -52,108 +51,119 @@ EQUATION
|
||||
|
||||
// Create temp and output directories on first run
|
||||
if (!fs.existsSync(tempDirRoot)) {
|
||||
fs.mkdirSync(tempDirRoot);
|
||||
fs.mkdirSync(tempDirRoot);
|
||||
}
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir);
|
||||
fs.mkdirSync(outputDir);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({extended: true}));
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
|
||||
// Allow static html files and output files to be accessible
|
||||
app.use('/', express.static(staticDir));
|
||||
app.use('/output', express.static(outputDir));
|
||||
|
||||
// POST call for LaTeX to image conversion. Convert and return image URL or error message
|
||||
app.post('/convert', function (req, res) {
|
||||
// Ensure valid inputs
|
||||
if (req.body.latexInput) {
|
||||
if (validScales.includes(req.body.outputScale)) {
|
||||
if (validFormats.includes(req.body.outputFormat)) {
|
||||
const id = generateID(); // Generate unique ID for filename
|
||||
const conversionRouter = promiseRouter();
|
||||
app.use(conversionRouter);
|
||||
|
||||
let eqnInput = req.body.latexInput.trim();
|
||||
if (/\\\\(?!$)/.test(eqnInput) && !eqnInput.includes("&")) { // if any "\\" not at EOF, unless intentionally aligned with &
|
||||
eqnInput = '&' + eqnInput.replace(/\\\\(?!$)/g, "\\\\&"); // replace any "\\" not at EOF with "\\&", to enforce left alignment
|
||||
}
|
||||
// Queue requests to ensure that only one is processed at a time, preventing
|
||||
// multiple concurrent Docker containers from exhausting system resources
|
||||
conversionRouter.use(queue({ activeLimit: 1, queuedLimit: -1 }));
|
||||
|
||||
shell.mkdir(`${tempDirRoot}${id}`);
|
||||
// Conversion request endpoint
|
||||
conversionRouter.post('/convert', async (req, res) => {
|
||||
const id = generateID(); // Generate a unique ID for this request
|
||||
|
||||
const document = documentTemplate.replace('EQUATION', eqnInput);
|
||||
fs.writeFileSync(`${tempDirRoot}${id}/equation.tex`, document); // Write generated .tex file
|
||||
|
||||
let result = {};
|
||||
|
||||
let finalDockerCMD = dockerCMD.replace('TEMP_DIR_NAME', `${tempDirRoot}${id}`);
|
||||
finalDockerCMD = finalDockerCMD.replace('OUTPUT_SCALE', validScalesInternal[validScales.indexOf(req.body.outputScale)]);
|
||||
|
||||
const fileFormat = req.body.outputFormat.toLowerCase();
|
||||
|
||||
// Asynchronously compile and render the LaTeX to an image
|
||||
shell.exec(finalDockerCMD, {async: true}, function() {
|
||||
if (fs.existsSync(`${tempDirRoot}${id}/equation.svg`)) {
|
||||
if (fileFormat === 'svg') { // Converting to SVG, no further processing required
|
||||
shell.cp(`${tempDirRoot}${id}/equation.svg`, `${outputDir}img-${id}.svg`);
|
||||
result.imageURL = `${httpOutputURL}img-${id}.svg`;
|
||||
} else {
|
||||
|
||||
// Convert svg to png/jpg
|
||||
let finalSvgToImageCMD = svgToImageCMD.replace('SVG_FILE_NAME', `${tempDirRoot}${id}/equation.svg`);
|
||||
finalSvgToImageCMD = finalSvgToImageCMD.replace('OUT_FILE_NAME', `${tempDirRoot}${id}/equation.${fileFormat}`);
|
||||
if (fileFormat === 'jpg') { // Add a white background for jpg images
|
||||
finalSvgToImageCMD += ' "svg {background: white}"';
|
||||
}
|
||||
shell.exec(finalSvgToImageCMD);
|
||||
|
||||
// Ensure conversion was successful; eg. fails if `svgexport` or `imagemin` is not installed
|
||||
if (fs.existsSync(`${tempDirRoot}${id}/equation.${fileFormat}`)) {
|
||||
// Compress the resultant image
|
||||
let finalImageMinCMD = imageMinCMD.replace('IN_FILE_NAME', `${tempDirRoot}${id}/equation.${fileFormat}`);
|
||||
finalImageMinCMD = finalImageMinCMD.replace('OUT_FILE_NAME', `${tempDirRoot}${id}/equation_compressed.${fileFormat}`);
|
||||
shell.exec(finalImageMinCMD);
|
||||
|
||||
// Final image
|
||||
shell.cp(`${tempDirRoot}${id}/equation_compressed.${fileFormat}`, `${outputDir}img-${id}.${fileFormat}`);
|
||||
|
||||
result.imageURL = `${httpOutputURL}img-${id}.${fileFormat}`;
|
||||
} else {
|
||||
result.error = `Error converting SVG file to ${fileFormat.toUpperCase()} image.`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.error = 'Error converting LaTeX to image. Please ensure the input is valid.';
|
||||
}
|
||||
|
||||
shell.rm('-r', `${tempDirRoot}${id}`); // Delete temporary files for this conversion
|
||||
|
||||
res.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
} else {
|
||||
res.end(JSON.stringify({error: 'Invalid image format'}));
|
||||
}
|
||||
} else {
|
||||
res.end(JSON.stringify({error: 'Invalid scale'}));
|
||||
}
|
||||
} else {
|
||||
res.end(JSON.stringify({error: 'No LaTeX input provided'}));
|
||||
try {
|
||||
if (!req.body.latexInput) {
|
||||
res.end(JSON.stringify({ error: 'No LaTeX input provided.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validScales.includes(req.body.outputScale)) {
|
||||
res.end(JSON.stringify({ error: 'Invalid scale.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validFormats.includes(req.body.outputFormat)) {
|
||||
res.end(JSON.stringify({ error: 'Invalid image format.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const eqnInput = req.body.latexInput.trim();
|
||||
const fileFormat = req.body.outputFormat.toLowerCase();
|
||||
const outputScale = req.body.outputScale;
|
||||
|
||||
// Generate and write the .tex file
|
||||
const document = documentTemplate.replace('EQUATION', eqnInput);
|
||||
await fsPromises.mkdir(`${tempDirRoot}${id}`);
|
||||
await fsPromises.writeFile(`${tempDirRoot}${id}/equation.tex`, document);
|
||||
|
||||
// Run the LaTeX compiler and generate a .svg file
|
||||
const finalDockerCMD = dockerCMD
|
||||
.replace('TEMP_DIR_NAME', `${tempDirRoot}${id}`)
|
||||
.replace('OUTPUT_SCALE', validScalesInternal[validScales.indexOf(outputScale)]);
|
||||
await execAsync(finalDockerCMD);
|
||||
|
||||
const inputSvgFileName = `${tempDirRoot}${id}/equation.svg`;
|
||||
const outputFileName = `${outputDir}img-${id}.${fileFormat}`;
|
||||
|
||||
// Return the SVG image, no further processing required
|
||||
if (fileFormat === 'svg') {
|
||||
await fsPromises.copyFile(inputSvgFileName, outputFileName);
|
||||
|
||||
// Convert to PNG
|
||||
} else if (fileFormat === 'png') {
|
||||
await sharp(inputSvgFileName, { density: 96 })
|
||||
.toFile(outputFileName); // Sharp's PNG type is implicitly determined via the output file extension
|
||||
|
||||
// Convert to JPG
|
||||
} else {
|
||||
await sharp(inputSvgFileName, { density: 96 })
|
||||
.flatten({ background: { r: 255, g: 255, b: 255 } }) // as JPG is not transparent, use a white background
|
||||
.jpeg({ quality: 95 })
|
||||
.toFile(outputFileName);
|
||||
}
|
||||
|
||||
await cleanupTempFilesAsync(id);
|
||||
res.end(JSON.stringify({ imageURL: `${httpOutputURL}img-${id}.${fileFormat}` }));
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await cleanupTempFilesAsync(id);
|
||||
res.end(JSON.stringify({ error: 'Error converting LaTeX to image. Please ensure the input is valid.' }));
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
app.listen(port, function() {
|
||||
console.log(`Latex2image listening on port ${port}`);
|
||||
});
|
||||
app.listen(port, () => console.log(`Latex2Image listening at http://localhost:${port}/`));
|
||||
|
||||
// Helper functions
|
||||
|
||||
function generateID() { // Generate a random 16-char hexadecimal ID
|
||||
let output = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
output += '0123456789abcdef'.charAt(Math.floor(Math.random() * 16));
|
||||
}
|
||||
return output;
|
||||
// Deletes temporary files created during a conversion request
|
||||
function cleanupTempFilesAsync(id) {
|
||||
return fsPromises.rmdir(`${tempDirRoot}${id}`, { recursive: true });
|
||||
}
|
||||
|
||||
// Execute a shell command
|
||||
function execAsync(cmd, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
shell.exec(cmd, opts, (code, stdout, stderr) => {
|
||||
if (code != 0) reject(new Error(stderr));
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function generateID() {
|
||||
// Generate a random 16-char hexadecimal ID
|
||||
let output = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
output += '0123456789abcdef'.charAt(Math.floor(Math.random() * 16));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
2119
package-lock.json
generated
2119
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,8 +9,12 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"express": "^4.17.1",
|
||||
"shelljs": "^0.8.3"
|
||||
"express-promise-router": "^4.0.1",
|
||||
"express-queue": "^0.0.12",
|
||||
"sharp": "^0.27.0",
|
||||
"shelljs": "^0.8.4"
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user