Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add a canvas-based browser implementation for encoding jpeg/png images #33

Merged
merged 10 commits into from
Nov 30, 2023
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