Skip to content

Commit

Permalink
Merge pull request #33 from catdad-experiments/canvas-browser-impleme…
Browse files Browse the repository at this point in the history
…ntation

add a canvas-based browser implementation for encoding jpeg/png images
  • Loading branch information
catdad authored Nov 30, 2023
2 parents 9463113 + b79ce58 commit 546eaba
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 170 deletions.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
npm install heic-convert
```

## Usage
## Usage in NodeJS

Convert the main image in a HEIC to JPEG

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

_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._

## Usage in the browser

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.

When compiling a client-side project, use:

```javascript
const convert = require('heic-convert/browser');
```

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!

## Related

* [heic-cli](https://github.com/catdad-experiments/heic-cli) - convert heic/heif images to jpeg or png from the command line
Expand Down
6 changes: 6 additions & 0 deletions browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const decode = require('heic-decode');
const formats = require('./formats-browser.js');
const { one, all } = require('./lib.js')(decode, formats);

module.exports = one;
module.exports.all = all;
33 changes: 33 additions & 0 deletions formats-browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const initializeCanvas = ({ width, height }) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

return canvas;
};

const convert = async ({ data, width, height }, ...blobArgs) => {
const canvas = initializeCanvas({ width, height });

const ctx = canvas.getContext('2d');
ctx.putImageData(new ImageData(data, width, height), 0, 0);

const blob = await new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
return resolve(blob);
}

return reject(new Error('failed to convert the image'));
}, ...blobArgs);
});

const arrayBuffer = await blob.arrayBuffer();

return new Uint8Array(arrayBuffer);
};

module.exports = {
JPEG: async ({ data, width, height, quality }) => await convert({ data, width, height }, 'image/jpeg', quality),
PNG: async ({ data, width, height }) => await convert({ data, width, height }, 'image/png')
};
21 changes: 21 additions & 0 deletions formats-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const jpegJs = require('jpeg-js');
const { PNG } = require('pngjs');

module.exports = {};

module.exports.JPEG = ({ data, width, height, quality }) => jpegJs.encode({ data, width, height }, Math.floor(quality * 100)).data;

module.exports.PNG = ({ data, width, height }) => {
const png = new PNG({ width, height });
png.data = data;

return PNG.sync.write(png, {
width: width,
height: height,
deflateLevel: 9,
deflateStrategy: 3,
filterType: -1,
colorType: 6,
inputHasAlpha: true
});
};
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const decode = require('heic-decode');
const { one, all } = require('./lib.js')(decode);
const formats = require('./formats-node.js');
const { one, all } = require('./lib.js')(decode, formats);

module.exports = one;
module.exports.all = all;
31 changes: 5 additions & 26 deletions lib.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,16 @@
const jpegJs = require('jpeg-js');
const { PNG } = require('pngjs');

module.exports = decode => {
const to = {
JPEG: ({ data, width, height, quality }) => jpegJs.encode({ data, width, height }, quality).data,
PNG: ({ data, width, height }) => {
const png = new PNG({ width, height });
png.data = data;

return PNG.sync.write(png, {
width: width,
height: height,
deflateLevel: 9,
deflateStrategy: 3,
filterType: -1,
colorType: 6,
inputHasAlpha: true
});
}
};

module.exports = (decode, encode) => {
const convertImage = async ({ image, format, quality }) => {
return await to[format]({
return await encode[format]({
width: image.width,
height: image.height,
data: image.data,
quality: Math.floor(quality * 100)
quality
});
};

const convert = async ({ buffer, format, quality, all }) => {
if (!to[format]) {
throw new Error(`output format needs to be one of [${Object.keys(to)}]`);
if (!encode[format]) {
throw new Error(`output format needs to be one of [${Object.keys(encode)}]`);
}

if (!all) {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"pretest": "node scripts/images.js",
"test": "mocha --timeout 40000 --slow 0 \"test/**/*.js\""
"test": "mocha --timeout 40000 --slow 0 \"test/**/*.test.js\""
},
"repository": {
"type": "git",
Expand All @@ -28,10 +28,12 @@
"homepage": "https://github.com/catdad-experiments/heic-convert#readme",
"devDependencies": {
"buffer-to-uint8array": "^1.1.0",
"canvas": "^2.11.2",
"chai": "^4.2.0",
"eslint": "^5.16.0",
"file-type": "^13.1.0",
"fs-extra": "^8.1.0",
"jsdom": "^22.1.0",
"mocha": "^7.0.0",
"node-fetch": "^2.6.0",
"pixelmatch": "^5.2.1",
Expand Down
48 changes: 48 additions & 0 deletions test/browser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// jsdom is not supported in node < 16, and...
// that's fine, because we don't need to run the browser
// tests everywhere, just at least in one env
if (Number(process.versions.node.split('.')[0]) < 16) {
return;
}

const JSDOM = require('jsdom').JSDOM;
const canvas = require('canvas');
const runTests = require('./run-tests.js');

describe('heic-convert (browser image encoding)', () => {
before(() => {
const { window } = new JSDOM(``, {
pretendToBeVisual: true
});

global.window = window;
global.document = window.document;
global.ImageData = canvas.ImageData;

// okay, now we are getting hacky... jsdom folks got into a
// fight when talking about implementing this spec, which
// is now broadly supported across all evergreen browsers
// https://github.com/jsdom/jsdom/issues/2555
global.window.Blob.prototype.arrayBuffer = async function() {
const blob = this;
const fileReader = new window.FileReader();

var arrayBuffer = new Promise(r => {
fileReader.onload = function(event) {
r(event.target.result);
};

fileReader.readAsArrayBuffer(blob);
});

return arrayBuffer;
};
});

after(() => {
global.window.close();
delete global.window;
});

runTests(require('../browser'));
});
156 changes: 15 additions & 141 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,148 +1,22 @@
/* eslint-env mocha */
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const runTests = require('./run-tests.js');

const root = require('rootrequire');
const { fromBuffer: filetype } = require('file-type');
const { expect } = require('chai');
const toUint8 = require('buffer-to-uint8array');
const pixelmatch = require('pixelmatch');
const { PNG } = require('pngjs');
const jpegJs = require('jpeg-js');

const convert = require('../');

const readFile = promisify(fs.readFile);

describe('heic-convert', () => {
it('exports a function', () => {
expect(convert).to.be.a('function');
});

const decode = {
'image/png': buffer => PNG.sync.read(buffer),
'image/jpeg': buffer => jpegJs.decode(buffer)
};

const readControl = async name => {
const buffer = await readFile(path.resolve(root, `temp/${name}`));
return decode['image/png'](buffer);
};

const compare = (expected, actual, width, height, errString = 'actual image did not match control image') => {
const result = pixelmatch(toUint8(Buffer.from(expected)), toUint8(Buffer.from(actual)), null, width, height, {
threshold: 0.1
});

// allow 5% of pixels to be different
expect(result).to.be.below(width * height * 0.05, errString);
};

const assertImage = async (buffer, mime, control) => {
const type = await filetype(buffer);
expect(type.mime).to.equal(mime);

const actual = decode[mime](buffer);

expect(actual.width).to.equal(control.width);
expect(actual.height).to.equal(control.height);

compare(control.data, actual.data, control.width, control.height);
};

it('converts known image to jpeg', async () => {
const control = await readControl('0002-control.png');
const buffer = await readFile(path.resolve(root, 'temp', '0002.heic'));

// quality of 92%
const output92 = await convert({ buffer, format: 'JPEG' });
await assertImage(output92, 'image/jpeg', control);

// quality of 100%
const output100 = await convert({ buffer, format: 'JPEG', quality: 1 });
await assertImage(output100, 'image/jpeg', control);

expect(output92).to.not.deep.equal(output100);
expect(output92.length).to.be.below(output100.length);
});

it('converts known image to png', async () => {
const control = await readControl('0002-control.png');
const buffer = await readFile(path.resolve(root, 'temp', '0002.heic'));

const output = await convert({ buffer, format: 'PNG' });
await assertImage(output, 'image/png', control);
});

it('converts multiple images inside a single file to jpeg', async () => {
const controls = await Promise.all([
readControl('0003-0-control.png'),
readControl('0003-1-control.png'),
]);
const buffer = await readFile(path.resolve(root, 'temp', '0003.heic'));
const images = await convert.all({ buffer, format: 'JPEG' });

expect(images).to.have.lengthOf(3);

for (let { i, control } of [
{ i: 0, control: controls[0] },
{ i: 1, control: controls[1] },
{ i: 2, control: controls[1] },
]) {
expect(images[i]).to.have.a.property('convert').and.to.be.a('function');
await assertImage(await images[i].convert(), 'image/jpeg', control);
}
});

it('converts multiple images inside a single file to png', async () => {
const controls = await Promise.all([
readControl('0003-0-control.png'),
readControl('0003-1-control.png'),
]);
const buffer = await readFile(path.resolve(root, 'temp', '0003.heic'));
const images = await convert.all({ buffer, format: 'PNG' });

expect(images).to.have.lengthOf(3);

for (let { i, control } of [
{ i: 0, control: controls[0] },
{ i: 1, control: controls[1] },
{ i: 2, control: controls[1] },
]) {
expect(images[i]).to.have.a.property('convert').and.to.be.a('function');
await assertImage(await images[i].convert(), 'image/png', control);
}
});

[
{ when: 'converting only the main image', method: convert },
{ when: 'converting all images', method: convert.all }
].forEach(({ when, method }) => {
it(`errors for an unknown output format when ${when}`, async () => {
const buffer = Buffer.from(Math.random().toString() + Math.random().toString());

let error;

await method({ buffer, format: 'PINEAPPLES' }).catch(err => {
error = err;
});
describe('heic-convert (default wasm libheif)', () => {
runTests(require('..'));
});

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

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

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

await method({ buffer, format: 'JPEG' }).catch(err => {
error = err;
});
const convert = one;
convert.all = all;

expect(error).to.be.instanceof(TypeError)
.and.to.have.property('message', 'input buffer is not a HEIC image');
});
});
runTests(convert);
});
Loading

0 comments on commit 546eaba

Please sign in to comment.