www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }