Skip to content

Commit dbaa78d

Browse files
chore!: migrate sharp -> jsquash/png
1 parent ea3dc47 commit dbaa78d

18 files changed

Lines changed: 1779 additions & 2638 deletions

.eslintignore

Lines changed: 0 additions & 3 deletions
This file was deleted.

.eslintrc.js

Lines changed: 0 additions & 4 deletions
This file was deleted.

README.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Build Status](https://travis-ci.org/gemini-testing/looks-same.svg?branch=master)](https://travis-ci.org/gemini-testing/looks-same)
44

5-
Node.js library for comparing images, taking into account human color
5+
Pure node.js library for comparing png images, taking into account human color
66
perception. It is created specially for the needs of visual regression testing
77
for [`testplane`](http://github.com/gemini-testing/testplane) utility, but can be used
88
for other purposes.
@@ -14,12 +14,6 @@ Benchmark is presented in the corresponding directory.
1414
- [Benchmark description](./benchmark/README.md)
1515
- [Benchmark results](./benchmark/results.md)
1616

17-
## Supported image formats
18-
19-
JPEG, PNG, WebP, GIF, AVIF, TIFF and SVG images are supported.
20-
21-
*Note: If you want to compare jpeg files, you may encounter random differences due to the jpeg structure if they are not lossless jpeg files.*
22-
2317
## Comparing images
2418

2519
```javascript

eslint.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import globals from "globals";
2+
import pluginJs from "@eslint/js";
3+
4+
export default [
5+
{ignores: ["lib/vendor/**", "**/.*", "node_modules/**"]},
6+
{files: ["index.js", "lib/**/*.js", "benchmark/**/*.js"], languageOptions: {sourceType: "commonjs", globals: globals.node}},
7+
{files: ["test/**/*.js"], languageOptions: { globals: {...globals.node, ...globals.mocha, ...globals.chai} }},
8+
pluginJs.configs.recommended
9+
];

index.js

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const AntialiasingComparator = require('./lib/antialiasing-comparator');
88
const IgnoreCaretComparator = require('./lib/ignore-caret-comparator');
99
const DiffArea = require('./lib/diff-area');
1010
const utils = require('./lib/utils');
11-
const {JND} = require('./lib/constants');
11+
const {JND, PNG: {RGBA_CHANNELS}} = require('./lib/constants');
1212

1313
const makeAntialiasingComparator = (comparator, img1, img2, opts) => {
1414
const antialiasingComparator = new AntialiasingComparator(comparator, img1, img2, opts);
@@ -24,13 +24,42 @@ function makeCIEDE2000Comparator(tolerance) {
2424
const upperBound = tolerance * 6.2; // cie76 <= 6.2 * ciede2000
2525
const lowerBound = tolerance * 0.695; // cie76 >= 0.695 * ciede2000
2626

27+
let rgbColor1 = {};
28+
let rgbColor2 = {};
29+
let labColor1 = {};
30+
let labColor2 = {};
31+
2732
return function doColorsLookSame(data) {
2833
if (areColorsSame(data)) {
2934
return true;
3035
}
36+
37+
let lab1, lab2;
38+
3139
/*jshint camelcase:false*/
32-
const lab1 = colorDiff.rgb_to_lab(data.color1);
33-
const lab2 = colorDiff.rgb_to_lab(data.color2);
40+
if (areColorsSame({color1: data.color1, color2: rgbColor1})) {
41+
lab1 = labColor1;
42+
} else if (areColorsSame({color1: data.color1, color2: rgbColor2})) {
43+
lab1 = labColor2;
44+
}
45+
46+
if (areColorsSame({color1: data.color2, color2: rgbColor1})) {
47+
lab2 = labColor1;
48+
} else if (areColorsSame({color1: data.color2, color2: rgbColor2})) {
49+
lab2 = labColor2;
50+
}
51+
52+
if (!lab1) {
53+
lab1 = colorDiff.rgb_to_lab(data.color1);
54+
rgbColor1 = data.color1;
55+
labColor1 = lab1;
56+
}
57+
58+
if (!lab2) {
59+
lab2 = colorDiff.rgb_to_lab(data.color2);
60+
rgbColor2 = data.color2;
61+
labColor2 = lab2;
62+
}
3463

3564
const cie76 = Math.sqrt(
3665
(lab1.L - lab2.L) * (lab1.L - lab2.L) +
@@ -93,13 +122,14 @@ const buildDiffImage = async (img1, img2, options) => {
93122
const minHeight = Math.min(img1.height, img2.height);
94123

95124
const highlightColor = options.highlightColor;
96-
const resultBuffer = Buffer.alloc(width * height * 3);
125+
const resultBuffer = Buffer.allocUnsafe(width * height * RGBA_CHANNELS);
97126

98127
const setPixel = (buf, x, y, {R, G, B}) => {
99-
const pixelInd = (y * width + x) * 3;
128+
const pixelInd = (y * width + x) * RGBA_CHANNELS;
100129
buf[pixelInd] = R;
101130
buf[pixelInd + 1] = G;
102131
buf[pixelInd + 2] = B;
132+
buf[pixelInd + 3] = 0xff;
103133
};
104134

105135
await iterateRect(width, height, (x, y) => {
@@ -118,7 +148,7 @@ const buildDiffImage = async (img1, img2, options) => {
118148
}
119149
});
120150

121-
return img.fromBuffer(resultBuffer, {raw: {width, height, channels: 3}});
151+
return img.fromBuffer(resultBuffer, {rgb: {width, height}});
122152
};
123153

124154
const getToleranceFromOpts = (opts) => {

lib/constants.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
'use strict';
22

3+
// https://en.wikipedia.org/wiki/PNG
4+
const PNG = {
5+
RGBA_CHANNELS: 4,
6+
WIDTH_OFFSET: 16,
7+
HEIGHT_OFFSET: 20,
8+
SIGNATURE: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
9+
BIT_DEPTH_EIGHT_BIT: 8,
10+
COLOR_TYPE_RGBA: 6,
11+
COMPRESSION_DEFLATE: 0,
12+
FILTER_NO_FILTER: 0,
13+
INTERLACE_NO_INTERLACE: 0,
14+
IHDR_LENGTH: 13,
15+
MIN_ASSIST_BYTES: 57
16+
};
17+
318
module.exports = {
419
JND: 2.3, // Just noticeable difference if ciede2000 >= JND then colors difference is noticeable by human eye
520
REQUIRED_IMAGE_FIELDS: ['source', 'boundingBox'],
621
REQUIRED_BOUNDING_BOX_FIELDS: ['left', 'top', 'right', 'bottom'],
722
CLUSTERS_SIZE: 10,
8-
DIFF_IMAGE_CHANNELS: 3
23+
BITS_IN_BYTE: 8,
24+
PNG,
925
};

lib/image/bounded-image.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
const Image = require('./image');
44

55
module.exports = class BoundedImage extends Image {
6-
constructor(img, boundingBox) {
7-
super(img);
6+
constructor(buffer, rgb, boundingBox) {
7+
super(buffer, rgb);
88

99
this._boundingBox = boundingBox;
1010
}

lib/image/eight-bit-rgba-to-png.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const {unsigned: crc32} = require('buffer-crc32');
2+
const zlib = require('node:zlib');
3+
const { PNG } = require('../constants');
4+
5+
const COMPRESSION_LEVEL = 3;
6+
7+
const convertRgbaToScanlines = (rgba, width, height) => {
8+
const stride = width * PNG.RGBA_CHANNELS;
9+
const scanlines = Buffer.allocUnsafe(height * (1 + stride)); // extra byte for filter
10+
11+
let scanlineOffset = 0;
12+
let pixelDataOffset = 0;
13+
14+
for (let y = 0; y < height; y++) {
15+
scanlineOffset = scanlines.writeUInt8(PNG.FILTER_NO_FILTER, scanlineOffset);
16+
scanlineOffset += rgba.copy(scanlines, scanlineOffset, pixelDataOffset, pixelDataOffset + stride);
17+
18+
pixelDataOffset += stride;
19+
}
20+
21+
if (scanlineOffset !== scanlines.byteLength) {
22+
throw new Error('Got malformed input while trying to convert rgba to png');
23+
}
24+
25+
return scanlines;
26+
}
27+
28+
exports.convertRgbaToPng = (rgba, width, height, compressionLevel = COMPRESSION_LEVEL) => {
29+
const scanlines = convertRgbaToScanlines(rgba, width, height);
30+
const compressedData = zlib.deflateSync(scanlines, { level: compressionLevel });
31+
const resultBuffer = Buffer.allocUnsafe(PNG.MIN_ASSIST_BYTES + compressedData.length);
32+
33+
let pointer = 0;
34+
35+
// signature
36+
pointer += PNG.SIGNATURE.copy(resultBuffer);
37+
38+
// IHDR
39+
const ihdrPointer = (pointer = resultBuffer.writeUInt32BE(PNG.IHDR_LENGTH, pointer));
40+
pointer += resultBuffer.write("IHDR", pointer, "ascii");
41+
pointer = resultBuffer.writeUInt32BE(width, pointer);
42+
pointer = resultBuffer.writeUInt32BE(height, pointer);
43+
pointer = resultBuffer.writeUInt8(PNG.BIT_DEPTH_EIGHT_BIT, pointer);
44+
pointer = resultBuffer.writeUInt8(PNG.COLOR_TYPE_RGBA, pointer);
45+
pointer = resultBuffer.writeUInt8(PNG.COMPRESSION_DEFLATE, pointer);
46+
pointer = resultBuffer.writeUInt8(PNG.FILTER_NO_FILTER, pointer);
47+
pointer = resultBuffer.writeUInt8(PNG.INTERLACE_NO_INTERLACE, pointer);
48+
const ihdrCrc = crc32(Buffer.from(resultBuffer.buffer, ihdrPointer, pointer - ihdrPointer));
49+
pointer = resultBuffer.writeUInt32BE(ihdrCrc, pointer);
50+
51+
// IDAT
52+
const idatPointer = (pointer = resultBuffer.writeUInt32BE(compressedData.length, pointer));
53+
pointer += resultBuffer.write("IDAT", idatPointer, "ascii");
54+
pointer += compressedData.copy(resultBuffer, pointer);
55+
const idatCrc = crc32(Buffer.from(resultBuffer.buffer, idatPointer, pointer - idatPointer));
56+
pointer = resultBuffer.writeUInt32BE(idatCrc, pointer);
57+
58+
// IEND (empty)
59+
const iendPointer = (pointer = resultBuffer.writeUInt32BE(0, pointer));
60+
pointer += resultBuffer.write("IEND", pointer, "ascii");
61+
const iendCrc = crc32(Buffer.from(resultBuffer.buffer, iendPointer, pointer - iendPointer));
62+
pointer = resultBuffer.writeUInt32BE(iendCrc, pointer);
63+
64+
if (pointer !== resultBuffer.byteLength) {
65+
throw new Error("Got malformed input while trying to convert rgba to png");
66+
}
67+
return resultBuffer;
68+
};

lib/image/image.js

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,77 @@
11
'use strict';
22

3+
const fs = require('fs');
4+
const {convertRgbaToPng} = require('./eight-bit-rgba-to-png');
35
const ImageBase = require('../image-base');
6+
const {BITS_IN_BYTE, PNG: {WIDTH_OFFSET, HEIGHT_OFFSET, RGBA_CHANNELS}} = require('../constants');
7+
8+
const initJsquashPromise = new Promise(resolve => {
9+
const wasmLocation = require.resolve('@jsquash/png/codec/pkg/squoosh_png_bg.wasm');
10+
11+
Promise.all([
12+
import('@jsquash/png/decode.js'),
13+
fs.promises.readFile(wasmLocation)
14+
]).then(([mod, wasmBytes]) => mod.init(wasmBytes)).then(resolve);
15+
});
16+
17+
/** @type import('@jsquash/png')['decode'] */
18+
const jsquashDecode = (buffer, opts) => {
19+
return Promise.all([
20+
import('@jsquash/png/decode.js'),
21+
initJsquashPromise
22+
]).then(([{decode}]) => decode(buffer, opts));
23+
};
424

525
module.exports = class Image extends ImageBase {
6-
constructor(img) {
26+
constructor(buffer, rgb) {
727
super();
828

9-
this._img = img;
29+
if (rgb) {
30+
this._width = rgb.width;
31+
this._height = rgb.height;
32+
this._imgData = buffer;
33+
} else {
34+
this._width = buffer.readUInt32BE(WIDTH_OFFSET);
35+
this._height = buffer.readUInt32BE(HEIGHT_OFFSET);
36+
this._imgDataPromise = jsquashDecode(buffer, {bitDepth: BITS_IN_BYTE}).then(({data}) => Buffer.from(data));
37+
}
1038
}
1139

12-
async init() {
13-
const {data, info} = await this._img.raw().toBuffer({resolveWithObject: true});
40+
async _getImgData() {
41+
if (this._imgData) {
42+
return this._imgData;
43+
}
1444

15-
this._buffer = data;
16-
this._width = info.width;
17-
this._height = info.height;
18-
this._channels = info.channels;
45+
return this._imgData = await this._imgDataPromise;
1946
}
2047

21-
async initMeta() {
22-
const {width, height, channels} = await this._img.metadata();
23-
24-
this._width = width;
25-
this._height = height;
26-
this._channels = channels;
48+
async init() {
49+
await this._getImgData();
2750
}
2851

2952
getPixel(x, y) {
30-
const idx = this._getIdx(x, y);
53+
const idx = (this._width * y + x) * RGBA_CHANNELS;
54+
3155
return {
32-
R: this._buffer[idx],
33-
G: this._buffer[idx + 1],
34-
B: this._buffer[idx + 2]
56+
R: this._imgData[idx],
57+
G: this._imgData[idx + 1],
58+
B: this._imgData[idx + 2]
3559
};
3660
}
3761

38-
_getIdx(x, y) {
39-
return (this._width * y + x) * this._channels;
62+
async _getPngBuffer() {
63+
const imageData = await this._getImgData();
64+
65+
return convertRgbaToPng(imageData, this._width, this._height);
4066
}
4167

4268
async save(path) {
43-
return this._img.toFile(path);
69+
const data = await this._getPngBuffer();
70+
71+
return fs.promises.writeFile(path, data);
4472
}
4573

4674
async createBuffer(extension) {
47-
return this._img.toFormat(extension).toBuffer();
75+
return extension === 'raw' ? Buffer.from(this._imgData.data) : this._getPngBuffer();
4876
}
4977
};

lib/image/index.js

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
11
'use strict';
22

3-
const fs = require('fs-extra');
3+
const fs = require('fs');
44
const NestedError = require('nested-error-stacks');
5-
const sharp = require('sharp');
65
const OriginalIMG = require('./original-image');
76
const BoundedIMG = require('./bounded-image');
87

9-
const createimage = async (img, {boundingBox} = {}) => {
8+
const createimage = async (buffer, {boundingBox, rgb} = {}) => {
109
return boundingBox
11-
? BoundedIMG.create(img, boundingBox)
12-
: OriginalIMG.create(img);
10+
? BoundedIMG.create(buffer, rgb, boundingBox)
11+
: OriginalIMG.create(buffer, rgb);
1312
};
1413

15-
exports.fromBuffer = async (buffer, opts) => {
16-
const img = sharp(buffer, opts);
17-
return createimage(img, opts);
18-
};
14+
exports.fromBuffer = (buffer, opts) => createimage(buffer, opts);
1915

2016
exports.fromFile = async (filePath, opts = {}) => {
2117
try {
22-
const buffer = await fs.readFile(filePath);
18+
const buffer = await fs.promises.readFile(filePath);
2319
return exports.fromBuffer(buffer, opts);
2420
} catch (err) {
2521
throw new NestedError(`Can't load img file ${filePath}`, err);

0 commit comments

Comments
 (0)