texcmp.js (9135B)
1 /* eslint-env node, es6 */ 2 /* eslint-disable no-console */ 3 "use strict"; 4 5 const childProcess = require("child_process"); 6 const fs = require("fs"); 7 const path = require("path"); 8 const Q = require("q"); // To debug, pass Q_DEBUG=1 in the environment 9 const pngparse = require("pngparse"); 10 const fft = require("ndarray-fft"); 11 const ndarray = require("ndarray-fft/node_modules/ndarray"); 12 13 const data = require("../../test/screenshotter/ss_data"); 14 15 // Adapt node functions to Q promises 16 const readFile = Q.denodeify(fs.readFile); 17 const writeFile = Q.denodeify(fs.writeFile); 18 const mkdir = Q.denodeify(fs.mkdir); 19 20 let todo; 21 if (process.argv.length > 2) { 22 todo = process.argv.slice(2); 23 } else { 24 todo = Object.keys(data).filter(function(key) { 25 return !data[key].nolatex; 26 }); 27 } 28 29 // Dimensions used when we do the FFT-based alignment computation 30 const alignWidth = 2048; // should be at least twice the width resp. height 31 const alignHeight = 2048; // of the screenshots, and a power of two. 32 33 // Compute required resolution to match test.html. 16px default font, 34 // scaled to 4em in test.html, and to 1.21em in katex.css. Corresponding 35 // LaTeX font size is 10pt. There are 72.27pt per inch. 36 const pxPerEm = 16 * 4 * 1.21; 37 const pxPerPt = pxPerEm / 10; 38 const dpi = pxPerPt * 72.27; 39 40 const tmpDir = "/tmp/texcmp"; 41 const ssDir = path.normalize( 42 path.join(__dirname, "..", "..", "test", "screenshotter")); 43 const imagesDir = path.join(ssDir, "images"); 44 const teximgDir = path.join(ssDir, "tex"); 45 const diffDir = path.join(ssDir, "diff"); 46 let template; 47 48 Q.all([ 49 readFile(path.join(ssDir, "test.tex"), "utf-8"), 50 ensureDir(tmpDir), 51 ensureDir(teximgDir), 52 ensureDir(diffDir), 53 ]).spread(function(data) { 54 template = data; 55 // dirs have been created, template has been read, now rasterize. 56 return Q.all(todo.map(processTestCase)); 57 }).done(); 58 59 // Process a single test case: rasterize, then create diff 60 function processTestCase(key) { 61 const itm = data[key]; 62 let tex = "$" + itm.tex + "$"; 63 if (itm.display) { 64 tex = "\\[" + itm.tex + "\\]"; 65 } 66 if (itm.pre) { 67 tex = itm.pre.replace("<br>", "\\\\") + tex; 68 } 69 if (itm.post) { 70 tex = tex + itm.post.replace("<br>", "\\\\"); 71 } 72 tex = template.replace(/\$.*\$/, tex.replace(/\$/g, "$$$$")); 73 const texFile = path.join(tmpDir, key + ".tex"); 74 const pdfFile = path.join(tmpDir, key + ".pdf"); 75 const pngFile = path.join(teximgDir, key + "-pdflatex.png"); 76 const browserFile = path.join(imagesDir, key + "-firefox.png"); 77 const diffFile = path.join(diffDir, key + ".png"); 78 79 // Step 1: write key.tex file 80 const fftLatex = writeFile(texFile, tex).then(function() { 81 // Step 2: call "pdflatex key" to create key.pdf 82 return execFile("pdflatex", [ 83 "-interaction", "nonstopmode", key, 84 ], {cwd: tmpDir}); 85 }).then(function() { 86 console.log("Typeset " + key); 87 // Step 3: call "convert ... key.pdf key.png" to create key.png 88 return execFile("convert", [ 89 "-density", dpi, "-units", "PixelsPerInch", "-flatten", 90 "-depth", "8", pdfFile, pngFile, 91 ]); 92 }).then(function() { 93 console.log("Rasterized " + key); 94 // Step 4: apply FFT to that 95 return readPNG(pngFile).then(fftImage); 96 }); 97 // Step 5: apply FFT to reference image as well 98 const fftBrowser = readPNG(browserFile).then(fftImage); 99 100 return Q.all([fftBrowser, fftLatex]).spread(function(browser, latex) { 101 // Now we have the FFT result from both 102 // Step 6: find alignment which maximizes overlap. 103 // This uses a FFT-based correlation computation. 104 let x; 105 let y; 106 const real = createMatrix(); 107 const imag = createMatrix(); 108 109 // Step 6a: (real + i*imag) = latex * conjugate(browser) 110 for (y = 0; y < alignHeight; ++y) { 111 for (x = 0; x < alignWidth; ++x) { 112 const br = browser.real.get(y, x); 113 const bi = browser.imag.get(y, x); 114 const lr = latex.real.get(y, x); 115 const li = latex.imag.get(y, x); 116 real.set(y, x, br * lr + bi * li); 117 imag.set(y, x, br * li - bi * lr); 118 } 119 } 120 121 // Step 6b: (real + i*imag) = inverseFFT(real + i*imag) 122 fft(-1, real, imag); 123 124 // Step 6c: find position where the (squared) absolute value is maximal 125 let offsetX = 0; 126 let offsetY = 0; 127 let maxSquaredNorm = -1; // any result is greater than initial value 128 for (y = 0; y < alignHeight; ++y) { 129 for (x = 0; x < alignWidth; ++x) { 130 const or = real.get(y, x); 131 const oi = imag.get(y, x); 132 const squaredNorm = or * or + oi * oi; 133 if (maxSquaredNorm < squaredNorm) { 134 maxSquaredNorm = squaredNorm; 135 offsetX = x; 136 offsetY = y; 137 } 138 } 139 } 140 141 // Step 6d: Treat negative offsets in a non-cyclic way 142 if (offsetY > (alignHeight / 2)) { 143 offsetY -= alignHeight; 144 } 145 if (offsetX > (alignWidth / 2)) { 146 offsetX -= alignWidth; 147 } 148 console.log("Positioned " + key + ": " + offsetX + ", " + offsetY); 149 150 // Step 7: use these offsets to compute difference illustration 151 const bx = Math.max(offsetX, 0); // browser left padding 152 const by = Math.max(offsetY, 0); // browser top padding 153 const lx = Math.max(-offsetX, 0); // latex left padding 154 const ly = Math.max(-offsetY, 0); // latex top padding 155 const uw = Math.max(browser.width + bx, latex.width + lx); // union w. 156 const uh = Math.max(browser.height + by, latex.height + ly); // u. h. 157 return execFile("convert", [ 158 // First image: latex rendering, converted to grayscale and padded 159 "(", pngFile, "-grayscale", "Rec709Luminance", 160 "-extent", uw + "x" + uh + "-" + lx + "-" + ly, 161 ")", 162 // Second image: browser screenshot, to grayscale and padded 163 "(", browserFile, "-grayscale", "Rec709Luminance", 164 "-extent", uw + "x" + uh + "-" + bx + "-" + by, 165 ")", 166 // Third image: the per-pixel minimum of the first two images 167 "(", "-clone", "0-1", "-compose", "darken", "-composite", ")", 168 // First image is red, second green, third blue channel of result 169 "-channel", "RGB", "-combine", 170 "-trim", // remove everything with the same color as the corners 171 diffFile, // output file name 172 ]); 173 }).then(function() { 174 console.log("Compared " + key); 175 }); 176 } 177 178 // Create a directory, but ignore error if the directory already exists. 179 function ensureDir(dir) { 180 return mkdir(dir).fail(function(err) { 181 if (err.code !== "EEXIST") { 182 throw err; 183 } 184 }); 185 } 186 187 // Execute a given command, and return a promise to its output. 188 // Don't denodeify here, since fail branch needs access to stderr. 189 function execFile(cmd, args, opts) { 190 const deferred = Q.defer(); 191 childProcess.execFile(cmd, args, opts, function(err, stdout, stderr) { 192 if (err) { 193 console.error("Error executing " + cmd + " " + args.join(" ")); 194 console.error(stdout + stderr); 195 err.stdout = stdout; 196 err.stderr = stderr; 197 deferred.reject(err); 198 } else { 199 deferred.resolve(stdout); 200 } 201 }); 202 return deferred.promise; 203 } 204 205 // Read given file and parse it as a PNG file. 206 function readPNG(file) { 207 const deferred = Q.defer(); 208 const onerror = deferred.reject.bind(deferred); 209 const stream = fs.createReadStream(file); 210 stream.on("error", onerror); 211 pngparse.parseStream(stream, function(err, image) { 212 if (err) { 213 console.log("Failed to load " + file); 214 onerror(err); 215 return; 216 } 217 deferred.resolve(image); 218 }); 219 return deferred.promise; 220 } 221 222 // Take a parsed image data structure and apply FFT transformation to it 223 function fftImage(image) { 224 const real = createMatrix(); 225 const imag = createMatrix(); 226 let idx = 0; 227 const nchan = image.channels; 228 const alphachan = 1 - (nchan % 2); 229 const colorchan = nchan - alphachan; 230 for (let y = 0; y < image.height; ++y) { 231 for (let x = 0; x < image.width; ++x) { 232 let v = 0; 233 for (let c = 0; c < colorchan; ++c) { 234 v += 255 - image.data[idx++]; 235 } 236 for (let c = 0; c < alphachan; ++c) { 237 v += image.data[idx++]; 238 } 239 real.set(y, x, v); 240 } 241 } 242 fft(1, real, imag); 243 return { 244 real: real, 245 imag: imag, 246 width: image.width, 247 height: image.height, 248 }; 249 } 250 251 // Create a new matrix of preconfigured dimensions, initialized to zero 252 function createMatrix() { 253 const array = new Float64Array(alignWidth * alignHeight); 254 return new ndarray(array, [alignWidth, alignHeight]); 255 }