forked from joeraut/latex2image-web
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathapp.js
196 lines (161 loc) · 6.34 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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{xcolor}
\\usepackage{siunitx}
\\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;
}