Skip to content

Commit 546eaba

Browse files
authored
Merge pull request #33 from catdad-experiments/canvas-browser-implementation
add a canvas-based browser implementation for encoding jpeg/png images
2 parents 9463113 + b79ce58 commit 546eaba

File tree

10 files changed

+294
-170
lines changed

10 files changed

+294
-170
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
npm install heic-convert
1919
```
2020

21-
## Usage
21+
## Usage in NodeJS
2222

2323
Convert the main image in a HEIC to JPEG
2424

@@ -83,6 +83,18 @@ The work to convert an image is done when calling `image.convert()`, so if you o
8383

8484
_Note that while the converter returns a Promise and is overall asynchronous, a lot of work is still done synchronously, so you should consider using a worker thread in order to not block the main thread in highly concurrent production environments._
8585

86+
## Usage in the browser
87+
88+
While the NodeJS version of `heic-convert` may be compiled for use in the browser with something like `webpack`, [not all build tools necessarily like to compile all modules well](https://github.com/catdad-experiments/heic-convert/issues/29). However, what further complicates things is that this module uses pure-javascript implementations of a jpeg and png encoder. But the browser has its own native encoders! Let's just use those instead of including a ton of extra code in your bundle.
89+
90+
When compiling a client-side project, use:
91+
92+
```javascript
93+
const convert = require('heic-convert/browser');
94+
```
95+
96+
This is currently only supported in the main thread. Support for workers may be added in the future, but if you need it sooner, please create an issue or even a PR!
97+
8698
## Related
8799

88100
* [heic-cli](https://github.com/catdad-experiments/heic-cli) - convert heic/heif images to jpeg or png from the command line

browser.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const decode = require('heic-decode');
2+
const formats = require('./formats-browser.js');
3+
const { one, all } = require('./lib.js')(decode, formats);
4+
5+
module.exports = one;
6+
module.exports.all = all;

formats-browser.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const initializeCanvas = ({ width, height }) => {
2+
const canvas = document.createElement('canvas');
3+
canvas.width = width;
4+
canvas.height = height;
5+
6+
return canvas;
7+
};
8+
9+
const convert = async ({ data, width, height }, ...blobArgs) => {
10+
const canvas = initializeCanvas({ width, height });
11+
12+
const ctx = canvas.getContext('2d');
13+
ctx.putImageData(new ImageData(data, width, height), 0, 0);
14+
15+
const blob = await new Promise((resolve, reject) => {
16+
canvas.toBlob(blob => {
17+
if (blob) {
18+
return resolve(blob);
19+
}
20+
21+
return reject(new Error('failed to convert the image'));
22+
}, ...blobArgs);
23+
});
24+
25+
const arrayBuffer = await blob.arrayBuffer();
26+
27+
return new Uint8Array(arrayBuffer);
28+
};
29+
30+
module.exports = {
31+
JPEG: async ({ data, width, height, quality }) => await convert({ data, width, height }, 'image/jpeg', quality),
32+
PNG: async ({ data, width, height }) => await convert({ data, width, height }, 'image/png')
33+
};

formats-node.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const jpegJs = require('jpeg-js');
2+
const { PNG } = require('pngjs');
3+
4+
module.exports = {};
5+
6+
module.exports.JPEG = ({ data, width, height, quality }) => jpegJs.encode({ data, width, height }, Math.floor(quality * 100)).data;
7+
8+
module.exports.PNG = ({ data, width, height }) => {
9+
const png = new PNG({ width, height });
10+
png.data = data;
11+
12+
return PNG.sync.write(png, {
13+
width: width,
14+
height: height,
15+
deflateLevel: 9,
16+
deflateStrategy: 3,
17+
filterType: -1,
18+
colorType: 6,
19+
inputHasAlpha: true
20+
});
21+
};

index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const decode = require('heic-decode');
2-
const { one, all } = require('./lib.js')(decode);
2+
const formats = require('./formats-node.js');
3+
const { one, all } = require('./lib.js')(decode, formats);
34

45
module.exports = one;
56
module.exports.all = all;

lib.js

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,16 @@
1-
const jpegJs = require('jpeg-js');
2-
const { PNG } = require('pngjs');
3-
4-
module.exports = decode => {
5-
const to = {
6-
JPEG: ({ data, width, height, quality }) => jpegJs.encode({ data, width, height }, quality).data,
7-
PNG: ({ data, width, height }) => {
8-
const png = new PNG({ width, height });
9-
png.data = data;
10-
11-
return PNG.sync.write(png, {
12-
width: width,
13-
height: height,
14-
deflateLevel: 9,
15-
deflateStrategy: 3,
16-
filterType: -1,
17-
colorType: 6,
18-
inputHasAlpha: true
19-
});
20-
}
21-
};
22-
1+
module.exports = (decode, encode) => {
232
const convertImage = async ({ image, format, quality }) => {
24-
return await to[format]({
3+
return await encode[format]({
254
width: image.width,
265
height: image.height,
276
data: image.data,
28-
quality: Math.floor(quality * 100)
7+
quality
298
});
309
};
3110

3211
const convert = async ({ buffer, format, quality, all }) => {
33-
if (!to[format]) {
34-
throw new Error(`output format needs to be one of [${Object.keys(to)}]`);
12+
if (!encode[format]) {
13+
throw new Error(`output format needs to be one of [${Object.keys(encode)}]`);
3514
}
3615

3716
if (!all) {

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"main": "index.js",
66
"scripts": {
77
"pretest": "node scripts/images.js",
8-
"test": "mocha --timeout 40000 --slow 0 \"test/**/*.js\""
8+
"test": "mocha --timeout 40000 --slow 0 \"test/**/*.test.js\""
99
},
1010
"repository": {
1111
"type": "git",
@@ -28,10 +28,12 @@
2828
"homepage": "https://github.com/catdad-experiments/heic-convert#readme",
2929
"devDependencies": {
3030
"buffer-to-uint8array": "^1.1.0",
31+
"canvas": "^2.11.2",
3132
"chai": "^4.2.0",
3233
"eslint": "^5.16.0",
3334
"file-type": "^13.1.0",
3435
"fs-extra": "^8.1.0",
36+
"jsdom": "^22.1.0",
3537
"mocha": "^7.0.0",
3638
"node-fetch": "^2.6.0",
3739
"pixelmatch": "^5.2.1",

test/browser.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// jsdom is not supported in node < 16, and...
2+
// that's fine, because we don't need to run the browser
3+
// tests everywhere, just at least in one env
4+
if (Number(process.versions.node.split('.')[0]) < 16) {
5+
return;
6+
}
7+
8+
const JSDOM = require('jsdom').JSDOM;
9+
const canvas = require('canvas');
10+
const runTests = require('./run-tests.js');
11+
12+
describe('heic-convert (browser image encoding)', () => {
13+
before(() => {
14+
const { window } = new JSDOM(``, {
15+
pretendToBeVisual: true
16+
});
17+
18+
global.window = window;
19+
global.document = window.document;
20+
global.ImageData = canvas.ImageData;
21+
22+
// okay, now we are getting hacky... jsdom folks got into a
23+
// fight when talking about implementing this spec, which
24+
// is now broadly supported across all evergreen browsers
25+
// https://github.com/jsdom/jsdom/issues/2555
26+
global.window.Blob.prototype.arrayBuffer = async function() {
27+
const blob = this;
28+
const fileReader = new window.FileReader();
29+
30+
var arrayBuffer = new Promise(r => {
31+
fileReader.onload = function(event) {
32+
r(event.target.result);
33+
};
34+
35+
fileReader.readAsArrayBuffer(blob);
36+
});
37+
38+
return arrayBuffer;
39+
};
40+
});
41+
42+
after(() => {
43+
global.window.close();
44+
delete global.window;
45+
});
46+
47+
runTests(require('../browser'));
48+
});

test/index.test.js

Lines changed: 15 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,22 @@
1-
/* eslint-env mocha */
2-
const { promisify } = require('util');
3-
const fs = require('fs');
4-
const path = require('path');
1+
const runTests = require('./run-tests.js');
52

6-
const root = require('rootrequire');
7-
const { fromBuffer: filetype } = require('file-type');
8-
const { expect } = require('chai');
9-
const toUint8 = require('buffer-to-uint8array');
10-
const pixelmatch = require('pixelmatch');
11-
const { PNG } = require('pngjs');
12-
const jpegJs = require('jpeg-js');
13-
14-
const convert = require('../');
15-
16-
const readFile = promisify(fs.readFile);
17-
18-
describe('heic-convert', () => {
19-
it('exports a function', () => {
20-
expect(convert).to.be.a('function');
21-
});
22-
23-
const decode = {
24-
'image/png': buffer => PNG.sync.read(buffer),
25-
'image/jpeg': buffer => jpegJs.decode(buffer)
26-
};
27-
28-
const readControl = async name => {
29-
const buffer = await readFile(path.resolve(root, `temp/${name}`));
30-
return decode['image/png'](buffer);
31-
};
32-
33-
const compare = (expected, actual, width, height, errString = 'actual image did not match control image') => {
34-
const result = pixelmatch(toUint8(Buffer.from(expected)), toUint8(Buffer.from(actual)), null, width, height, {
35-
threshold: 0.1
36-
});
37-
38-
// allow 5% of pixels to be different
39-
expect(result).to.be.below(width * height * 0.05, errString);
40-
};
41-
42-
const assertImage = async (buffer, mime, control) => {
43-
const type = await filetype(buffer);
44-
expect(type.mime).to.equal(mime);
45-
46-
const actual = decode[mime](buffer);
47-
48-
expect(actual.width).to.equal(control.width);
49-
expect(actual.height).to.equal(control.height);
50-
51-
compare(control.data, actual.data, control.width, control.height);
52-
};
53-
54-
it('converts known image to jpeg', async () => {
55-
const control = await readControl('0002-control.png');
56-
const buffer = await readFile(path.resolve(root, 'temp', '0002.heic'));
57-
58-
// quality of 92%
59-
const output92 = await convert({ buffer, format: 'JPEG' });
60-
await assertImage(output92, 'image/jpeg', control);
61-
62-
// quality of 100%
63-
const output100 = await convert({ buffer, format: 'JPEG', quality: 1 });
64-
await assertImage(output100, 'image/jpeg', control);
65-
66-
expect(output92).to.not.deep.equal(output100);
67-
expect(output92.length).to.be.below(output100.length);
68-
});
69-
70-
it('converts known image to png', async () => {
71-
const control = await readControl('0002-control.png');
72-
const buffer = await readFile(path.resolve(root, 'temp', '0002.heic'));
73-
74-
const output = await convert({ buffer, format: 'PNG' });
75-
await assertImage(output, 'image/png', control);
76-
});
77-
78-
it('converts multiple images inside a single file to jpeg', async () => {
79-
const controls = await Promise.all([
80-
readControl('0003-0-control.png'),
81-
readControl('0003-1-control.png'),
82-
]);
83-
const buffer = await readFile(path.resolve(root, 'temp', '0003.heic'));
84-
const images = await convert.all({ buffer, format: 'JPEG' });
85-
86-
expect(images).to.have.lengthOf(3);
87-
88-
for (let { i, control } of [
89-
{ i: 0, control: controls[0] },
90-
{ i: 1, control: controls[1] },
91-
{ i: 2, control: controls[1] },
92-
]) {
93-
expect(images[i]).to.have.a.property('convert').and.to.be.a('function');
94-
await assertImage(await images[i].convert(), 'image/jpeg', control);
95-
}
96-
});
97-
98-
it('converts multiple images inside a single file to png', async () => {
99-
const controls = await Promise.all([
100-
readControl('0003-0-control.png'),
101-
readControl('0003-1-control.png'),
102-
]);
103-
const buffer = await readFile(path.resolve(root, 'temp', '0003.heic'));
104-
const images = await convert.all({ buffer, format: 'PNG' });
105-
106-
expect(images).to.have.lengthOf(3);
107-
108-
for (let { i, control } of [
109-
{ i: 0, control: controls[0] },
110-
{ i: 1, control: controls[1] },
111-
{ i: 2, control: controls[1] },
112-
]) {
113-
expect(images[i]).to.have.a.property('convert').and.to.be.a('function');
114-
await assertImage(await images[i].convert(), 'image/png', control);
115-
}
116-
});
117-
118-
[
119-
{ when: 'converting only the main image', method: convert },
120-
{ when: 'converting all images', method: convert.all }
121-
].forEach(({ when, method }) => {
122-
it(`errors for an unknown output format when ${when}`, async () => {
123-
const buffer = Buffer.from(Math.random().toString() + Math.random().toString());
124-
125-
let error;
126-
127-
await method({ buffer, format: 'PINEAPPLES' }).catch(err => {
128-
error = err;
129-
});
3+
describe('heic-convert (default wasm libheif)', () => {
4+
runTests(require('..'));
5+
});
1306

131-
expect(error).to.be.instanceof(Error)
132-
.and.to.have.property('message', 'output format needs to be one of [JPEG,PNG]');
133-
});
7+
// I wouldn't say these are strictly required, but 🤷‍♀️
8+
describe('heic-convert (legacy js libheif)', () => {
9+
const libheif = require('libheif-js/index.js');
10+
const decodeLib = require('heic-decode/lib.js');
11+
const formats = require('../formats-node.js');
13412

135-
it(`errors if data other than a HEIC image is passed in when ${when}`, async () => {
136-
const buffer = Buffer.from(Math.random().toString() + Math.random().toString());
13+
const { one: decodeOne, all: decodeAll } = decodeLib(libheif);
14+
decodeOne.all = decodeAll;
13715

138-
let error;
16+
const { one, all } = require('../lib.js')(decodeOne, formats);
13917

140-
await method({ buffer, format: 'JPEG' }).catch(err => {
141-
error = err;
142-
});
18+
const convert = one;
19+
convert.all = all;
14320

144-
expect(error).to.be.instanceof(TypeError)
145-
.and.to.have.property('message', 'input buffer is not a HEIC image');
146-
});
147-
});
21+
runTests(convert);
14822
});

0 commit comments

Comments
 (0)