mirror of
https://github.com/Smaug123/KaTeX
synced 2025-10-05 19:28:39 +00:00
* Update texcmp to ubuntu 17.04 and avoid mounted host directory Switch to linux 17.04 in order to have a version of nodejs which understands ES6 syntax, for the sake of a consistent codebase. Avoid mounting the host directory, but use “docker cp” instead to transfer files between host and container. This should avoid ownership and permission issues. Support macros with positional arguments. Fix one overline example which caused LaTeX failure due to missing braces. * Extract texcmp results as current user This allows running the texcmp.sh script using sudo on afs. * Change texcmp conversion to gray As per #708, this should increase compatibility with older versions of imagemagick, and might also do a better job of preserving the original sRGB color space. * Abandon tar and use plain docker cp instead Thanks to Erik Demaine for suggesting this. * Move npm install into creation of texcmp docker image
273 lines
9.5 KiB
JavaScript
273 lines
9.5 KiB
JavaScript
/* eslint-env node, es6 */
|
|
/* eslint-disable no-console */
|
|
"use strict";
|
|
|
|
const childProcess = require("child_process");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const Q = require("q"); // To debug, pass Q_DEBUG=1 in the environment
|
|
const pngparse = require("pngparse");
|
|
const fft = require("ndarray-fft");
|
|
const ndarray = require("ndarray");
|
|
|
|
const data = require("../../test/screenshotter/ss_data");
|
|
|
|
// Adapt node functions to Q promises
|
|
const readFile = Q.denodeify(fs.readFile);
|
|
const writeFile = Q.denodeify(fs.writeFile);
|
|
const mkdir = Q.denodeify(fs.mkdir);
|
|
|
|
let todo;
|
|
if (process.argv.length > 2) {
|
|
todo = process.argv.slice(2);
|
|
} else {
|
|
todo = Object.keys(data).filter(function(key) {
|
|
return !data[key].nolatex;
|
|
});
|
|
}
|
|
|
|
// Dimensions used when we do the FFT-based alignment computation
|
|
const alignWidth = 2048; // should be at least twice the width resp. height
|
|
const alignHeight = 2048; // of the screenshots, and a power of two.
|
|
|
|
// Compute required resolution to match test.html. 16px default font,
|
|
// scaled to 4em in test.html, and to 1.21em in katex.css. Corresponding
|
|
// LaTeX font size is 10pt. There are 72.27pt per inch.
|
|
const pxPerEm = 16 * 4 * 1.21;
|
|
const pxPerPt = pxPerEm / 10;
|
|
const dpi = pxPerPt * 72.27;
|
|
|
|
const tmpDir = "/tmp/texcmp";
|
|
const ssDir = path.normalize(
|
|
path.join(__dirname, "..", "..", "test", "screenshotter"));
|
|
const imagesDir = path.join(ssDir, "images");
|
|
const teximgDir = path.join(ssDir, "tex");
|
|
const diffDir = path.join(ssDir, "diff");
|
|
let template;
|
|
|
|
Q.all([
|
|
readFile(path.join(ssDir, "test.tex"), "utf-8"),
|
|
ensureDir(tmpDir),
|
|
ensureDir(teximgDir),
|
|
ensureDir(diffDir),
|
|
]).spread(function(data) {
|
|
template = data;
|
|
// dirs have been created, template has been read, now rasterize.
|
|
return Q.all(todo.map(processTestCase));
|
|
}).done();
|
|
|
|
// Process a single test case: rasterize, then create diff
|
|
function processTestCase(key) {
|
|
const itm = data[key];
|
|
let tex = "$" + itm.tex + "$";
|
|
if (itm.display) {
|
|
tex = "\\[" + itm.tex + "\\]";
|
|
}
|
|
if (itm.pre) {
|
|
tex = itm.pre.replace("<br>", "\\\\") + tex;
|
|
}
|
|
if (itm.post) {
|
|
tex = tex + itm.post.replace("<br>", "\\\\");
|
|
}
|
|
if (itm.macros) {
|
|
tex = Object.keys(itm.macros).map(name => {
|
|
const expansion = itm.macros[name];
|
|
let numArgs = 0;
|
|
if (expansion.indexOf("#") !== -1) {
|
|
const stripped = expansion.replace(/##/g, "");
|
|
while (stripped.indexOf("#" + (numArgs + 1)) !== -1) {
|
|
++numArgs;
|
|
}
|
|
}
|
|
let args = "";
|
|
for (let i = 1; i <= numArgs; ++i) {
|
|
args += "#" + i;
|
|
}
|
|
return "\\def" + name + args + "{" + expansion + "}\n";
|
|
}).join("") + tex;
|
|
}
|
|
tex = template.replace(/\$.*\$/, tex.replace(/\$/g, "$$$$"));
|
|
const texFile = path.join(tmpDir, key + ".tex");
|
|
const pdfFile = path.join(tmpDir, key + ".pdf");
|
|
const pngFile = path.join(teximgDir, key + "-pdflatex.png");
|
|
const browserFile = path.join(imagesDir, key + "-firefox.png");
|
|
const diffFile = path.join(diffDir, key + ".png");
|
|
|
|
// Step 1: write key.tex file
|
|
const fftLatex = writeFile(texFile, tex).then(function() {
|
|
// Step 2: call "pdflatex key" to create key.pdf
|
|
return execFile("pdflatex", [
|
|
"-interaction", "nonstopmode", key,
|
|
], {cwd: tmpDir});
|
|
}).then(function() {
|
|
console.log("Typeset " + key);
|
|
// Step 3: call "convert ... key.pdf key.png" to create key.png
|
|
return execFile("convert", [
|
|
"-density", dpi, "-units", "PixelsPerInch", "-flatten",
|
|
"-depth", "8", pdfFile, pngFile,
|
|
]);
|
|
}).then(function() {
|
|
console.log("Rasterized " + key);
|
|
// Step 4: apply FFT to that
|
|
return readPNG(pngFile).then(fftImage);
|
|
});
|
|
// Step 5: apply FFT to reference image as well
|
|
const fftBrowser = readPNG(browserFile).then(fftImage);
|
|
|
|
return Q.all([fftBrowser, fftLatex]).spread(function(browser, latex) {
|
|
// Now we have the FFT result from both
|
|
// Step 6: find alignment which maximizes overlap.
|
|
// This uses a FFT-based correlation computation.
|
|
let x;
|
|
let y;
|
|
const real = createMatrix();
|
|
const imag = createMatrix();
|
|
|
|
// Step 6a: (real + i*imag) = latex * conjugate(browser)
|
|
for (y = 0; y < alignHeight; ++y) {
|
|
for (x = 0; x < alignWidth; ++x) {
|
|
const br = browser.real.get(y, x);
|
|
const bi = browser.imag.get(y, x);
|
|
const lr = latex.real.get(y, x);
|
|
const li = latex.imag.get(y, x);
|
|
real.set(y, x, br * lr + bi * li);
|
|
imag.set(y, x, br * li - bi * lr);
|
|
}
|
|
}
|
|
|
|
// Step 6b: (real + i*imag) = inverseFFT(real + i*imag)
|
|
fft(-1, real, imag);
|
|
|
|
// Step 6c: find position where the (squared) absolute value is maximal
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
let maxSquaredNorm = -1; // any result is greater than initial value
|
|
for (y = 0; y < alignHeight; ++y) {
|
|
for (x = 0; x < alignWidth; ++x) {
|
|
const or = real.get(y, x);
|
|
const oi = imag.get(y, x);
|
|
const squaredNorm = or * or + oi * oi;
|
|
if (maxSquaredNorm < squaredNorm) {
|
|
maxSquaredNorm = squaredNorm;
|
|
offsetX = x;
|
|
offsetY = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 6d: Treat negative offsets in a non-cyclic way
|
|
if (offsetY > (alignHeight / 2)) {
|
|
offsetY -= alignHeight;
|
|
}
|
|
if (offsetX > (alignWidth / 2)) {
|
|
offsetX -= alignWidth;
|
|
}
|
|
console.log("Positioned " + key + ": " + offsetX + ", " + offsetY);
|
|
|
|
// Step 7: use these offsets to compute difference illustration
|
|
const bx = Math.max(offsetX, 0); // browser left padding
|
|
const by = Math.max(offsetY, 0); // browser top padding
|
|
const lx = Math.max(-offsetX, 0); // latex left padding
|
|
const ly = Math.max(-offsetY, 0); // latex top padding
|
|
const uw = Math.max(browser.width + bx, latex.width + lx); // union w.
|
|
const uh = Math.max(browser.height + by, latex.height + ly); // u. h.
|
|
return execFile("convert", [
|
|
// First image: latex rendering, converted to grayscale and padded
|
|
"(", pngFile, "-colorspace", "Gray",
|
|
"-extent", uw + "x" + uh + "-" + lx + "-" + ly,
|
|
")",
|
|
// Second image: browser screenshot, to grayscale and padded
|
|
"(", browserFile, "-colorspace", "Gray",
|
|
"-extent", uw + "x" + uh + "-" + bx + "-" + by,
|
|
")",
|
|
// Third image: the per-pixel minimum of the first two images
|
|
"(", "-clone", "0-1", "-compose", "darken", "-composite", ")",
|
|
// First image is red, second green, third blue channel of result
|
|
"-channel", "RGB", "-combine",
|
|
"-trim", // remove everything with the same color as the corners
|
|
diffFile, // output file name
|
|
]);
|
|
}).then(function() {
|
|
console.log("Compared " + key);
|
|
});
|
|
}
|
|
|
|
// Create a directory, but ignore error if the directory already exists.
|
|
function ensureDir(dir) {
|
|
return mkdir(dir).fail(function(err) {
|
|
if (err.code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Execute a given command, and return a promise to its output.
|
|
// Don't denodeify here, since fail branch needs access to stderr.
|
|
function execFile(cmd, args, opts) {
|
|
const deferred = Q.defer();
|
|
childProcess.execFile(cmd, args, opts, function(err, stdout, stderr) {
|
|
if (err) {
|
|
console.error("Error executing " + cmd + " " + args.join(" "));
|
|
console.error(stdout + stderr);
|
|
err.stdout = stdout;
|
|
err.stderr = stderr;
|
|
deferred.reject(err);
|
|
} else {
|
|
deferred.resolve(stdout);
|
|
}
|
|
});
|
|
return deferred.promise;
|
|
}
|
|
|
|
// Read given file and parse it as a PNG file.
|
|
function readPNG(file) {
|
|
const deferred = Q.defer();
|
|
const onerror = deferred.reject.bind(deferred);
|
|
const stream = fs.createReadStream(file);
|
|
stream.on("error", onerror);
|
|
pngparse.parseStream(stream, function(err, image) {
|
|
if (err) {
|
|
console.log("Failed to load " + file);
|
|
onerror(err);
|
|
return;
|
|
}
|
|
deferred.resolve(image);
|
|
});
|
|
return deferred.promise;
|
|
}
|
|
|
|
// Take a parsed image data structure and apply FFT transformation to it
|
|
function fftImage(image) {
|
|
const real = createMatrix();
|
|
const imag = createMatrix();
|
|
let idx = 0;
|
|
const nchan = image.channels;
|
|
const alphachan = 1 - (nchan % 2);
|
|
const colorchan = nchan - alphachan;
|
|
for (let y = 0; y < image.height; ++y) {
|
|
for (let x = 0; x < image.width; ++x) {
|
|
let v = 0;
|
|
for (let c = 0; c < colorchan; ++c) {
|
|
v += 255 - image.data[idx++];
|
|
}
|
|
for (let c = 0; c < alphachan; ++c) {
|
|
v += image.data[idx++];
|
|
}
|
|
real.set(y, x, v);
|
|
}
|
|
}
|
|
fft(1, real, imag);
|
|
return {
|
|
real: real,
|
|
imag: imag,
|
|
width: image.width,
|
|
height: image.height,
|
|
};
|
|
}
|
|
|
|
// Create a new matrix of preconfigured dimensions, initialized to zero
|
|
function createMatrix() {
|
|
const array = new Float64Array(alignWidth * alignHeight);
|
|
return new ndarray(array, [alignWidth, alignHeight]);
|
|
}
|