|
1 | 1 | import {promises as fs} from "fs";
|
| 2 | +import {createCanvas, loadImage} from "canvas"; |
| 3 | +import {max, mean, quantile} from "d3"; |
2 | 4 | import * as path from "path";
|
3 | 5 | import beautify from "js-beautify";
|
4 | 6 | import assert from "./assert.js";
|
@@ -38,10 +40,8 @@ for (const [name, plot] of Object.entries(plots)) {
|
38 | 40 | }
|
39 | 41 |
|
40 | 42 | // node-canvas won’t produce the same output on different architectures, so
|
41 |
| - // until we have a way to normalize the output, we need to ignore the |
42 |
| - // generated image data during comparison. But you can still review the |
43 |
| - // generated output visually and hopefully it’ll be correct. |
44 |
| - const equal = process.env.CI === "true" ? stripImageData(actual) === stripImageData(expected) : actual === expected; |
| 43 | + // we parse and compare pixel values instead of the encoded output. |
| 44 | + const equal = stripImages(actual) === stripImages(expected) && (await compareImages(actual, expected)); |
45 | 45 |
|
46 | 46 | if (equal) {
|
47 | 47 | if (process.env.CI !== "true") {
|
@@ -108,9 +108,33 @@ function reindexClip(root) {
|
108 | 108 | }
|
109 | 109 | }
|
110 | 110 |
|
111 |
| -function stripImageData(string) { |
112 |
| - return string.replace( |
113 |
| - /data:image\/png;base64,[^"]+/g, |
114 |
| - "data:image/svg+xml,%3Csvg width='15' height='15' viewBox='0 0 20 20' style='background-color: white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h10v10H0zm10 10h10v10H10z' fill='%23f4f4f4' fill-rule='evenodd'/%3E%3C/svg%3E" |
115 |
| - ); |
| 111 | +const imageRe = /data:image\/png;base64,[^"]+/g; |
| 112 | + |
| 113 | +function stripImages(string) { |
| 114 | + return string.replace(imageRe, "<replaced>"); |
| 115 | +} |
| 116 | + |
| 117 | +async function compareImages(a, b) { |
| 118 | + const reA = new RegExp(imageRe, "g"); |
| 119 | + const reB = new RegExp(imageRe, "g"); |
| 120 | + let matchA; |
| 121 | + let matchB; |
| 122 | + while (((matchA = reA.exec(a)), (matchB = reB.exec(b)))) { |
| 123 | + const [imageA, imageB] = await Promise.all([getImageData(matchA[0]), getImageData(matchB[0])]); |
| 124 | + const {width, height} = imageA; |
| 125 | + if (width !== imageB.width || height !== imageB.height) return false; |
| 126 | + const E = imageA.data.map((a, i) => Math.abs(a - imageB.data[i])); |
| 127 | + if (!(quantile(E, 0.95) <= 1)) return false; // at least 95% with almost no error |
| 128 | + if (!(mean(E) < 0.1)) return false; // no more than 0.1 average error |
| 129 | + if (!(max(E) < 10)) return false; // no more than 10 maximum error |
| 130 | + } |
| 131 | + return true; |
| 132 | +} |
| 133 | + |
| 134 | +async function getImageData(url) { |
| 135 | + const image = await loadImage(url); |
| 136 | + const canvas = createCanvas(image.width, image.height); |
| 137 | + const context = canvas.getContext("2d"); |
| 138 | + context.drawImage(image, 0, 0); |
| 139 | + return context.getImageData(0, 0, image.width, image.height); |
116 | 140 | }
|
0 commit comments