195 lines
6.3 KiB
JavaScript
195 lines
6.3 KiB
JavaScript
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;
|
|
|
|
const staticDir = 'static';
|
|
const tempDir = 'temp';
|
|
const outputDir = 'output';
|
|
const httpOutputDir = 'output';
|
|
|
|
// Checklist of valid formats from the frontend, to verify form values are correct
|
|
const validFormats = ['SVG', 'PNG', 'JPG'];
|
|
|
|
// Maps scales received from the frontend into values appropriate for LaTeX
|
|
const scaleMap = {
|
|
'10%': '0.1',
|
|
'25%': '0.25',
|
|
'50%': '0.5',
|
|
'75%': '0.75',
|
|
'100%': '1.0',
|
|
'125%': '1.25',
|
|
'150%': '1.5',
|
|
'200%': '2.0',
|
|
'500%': '5.0',
|
|
'1000%': '10.0'
|
|
};
|
|
|
|
// Unsupported commands we will error on
|
|
const unsupportedCommands = ['\\usepackage', '\\input', '\\include', '\\write18', '\\immediate', '\\verbatiminput'];
|
|
|
|
const app = express();
|
|
|
|
const bodyParser = require('body-parser');
|
|
app.use(bodyParser.json());
|
|
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));
|
|
|
|
const conversionRouter = promiseRouter();
|
|
app.use(conversionRouter);
|
|
|
|
// 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 }));
|
|
|
|
// Conversion request endpoint
|
|
conversionRouter.post('/convert', async (req, res) => {
|
|
const id = generateID(); // Generate a unique ID for this request
|
|
|
|
try {
|
|
if (!req.body.latexInput) {
|
|
res.end(JSON.stringify({ error: 'No LaTeX input provided.' }));
|
|
return;
|
|
}
|
|
|
|
if (!scaleMap[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 unsupportedCommandsPresent = unsupportedCommands.filter(cmd => req.body.latexInput.includes(cmd));
|
|
if (unsupportedCommandsPresent.length > 0) {
|
|
res.end(JSON.stringify({ error: `Unsupported command(s) found: ${unsupportedCommandsPresent.join(', ')}. Please remove them and try again.` }));
|
|
return;
|
|
}
|
|
|
|
const equation = req.body.latexInput.trim();
|
|
const fileFormat = req.body.outputFormat.toLowerCase();
|
|
const outputScale = scaleMap[req.body.outputScale];
|
|
|
|
// Generate and write the .tex file
|
|
await fsPromises.mkdir(`${tempDir}/${id}`);
|
|
await fsPromises.writeFile(`${tempDir}/${id}/equation.tex`, getLatexTemplate(equation));
|
|
|
|
// Run the LaTeX compiler and generate a .svg file
|
|
await execAsync(getDockerCommand(id, outputScale));
|
|
|
|
const inputSvgFileName = `${tempDir}/${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: `${httpOutputDir}/img-${id}.${fileFormat}` }));
|
|
|
|
// An exception occurred somewhere, return an error
|
|
} catch (e) {
|
|
console.error(e);
|
|
await cleanupTempFilesAsync(id);
|
|
res.end(JSON.stringify({ error: 'Error converting LaTeX to image. Please ensure the input is valid.' }));
|
|
}
|
|
});
|
|
|
|
// Create temp and output directories if they don't exist yet
|
|
if (!fs.existsSync(tempDir)) {
|
|
fs.mkdirSync(tempDir);
|
|
}
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir);
|
|
}
|
|
|
|
// Start the server
|
|
app.listen(port, () => console.log(`Latex2Image listening at http://localhost:${port}`));
|
|
|
|
//// Helper functions
|
|
|
|
// Get the LaTeX document template for the requested equation
|
|
function getLatexTemplate(equation) {
|
|
return `
|
|
\\documentclass[12pt]{article}
|
|
\\usepackage{amsmath}
|
|
\\usepackage{amssymb}
|
|
\\usepackage{amsfonts}
|
|
\\usepackage[utf8]{inputenc}
|
|
\\thispagestyle{empty}
|
|
\\begin{document}
|
|
${equation}
|
|
\\end{document}`;
|
|
}
|
|
|
|
// Get the final command responsible for launching the Docker container and generating a svg file
|
|
function getDockerCommand(id, output_scale) {
|
|
// Commands to run within the container
|
|
const containerCmds = `
|
|
# Prevent LaTeX from reading/writing files in parent directories
|
|
echo 'openout_any = p\nopenin_any = p' > /tmp/texmf.cnf
|
|
export TEXMFCNF='/tmp:'
|
|
|
|
# Compile .tex file to .dvi file. Timeout kills it after 5 seconds if held up
|
|
timeout 5 latex -no-shell-escape -interaction=nonstopmode -halt-on-error equation.tex
|
|
|
|
# Convert .dvi to .svg file. Timeout kills it after 5 seconds if held up
|
|
timeout 5 dvisvgm --no-fonts --scale=${output_scale} --exact equation.dvi`;
|
|
|
|
// Start the container in the appropriate directory and run commands within it.
|
|
// Files in this directory will be accessible under /data within the container.
|
|
return `
|
|
cd ${tempDir}/${id}
|
|
docker run --rm -i --user="$(id -u):$(id -g)" \
|
|
--net=none -v "$PWD":/data "blang/latex:ubuntu" \
|
|
/bin/bash -c "${containerCmds}"`;
|
|
}
|
|
|
|
// Deletes temporary files created during a conversion request
|
|
function cleanupTempFilesAsync(id) {
|
|
return fsPromises.rmdir(`${tempDir}/${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;
|
|
}
|