diff --git a/README.md b/README.md index c5f74f1..b488f88 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm install heic-convert ``` -## Usage +## Usage in NodeJS Convert the main image in a HEIC to JPEG @@ -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 diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..fc9deb3 --- /dev/null +++ b/browser.js @@ -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; diff --git a/formats-browser.js b/formats-browser.js new file mode 100644 index 0000000..170be9f --- /dev/null +++ b/formats-browser.js @@ -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') +}; diff --git a/formats-node.js b/formats-node.js new file mode 100644 index 0000000..093a2f3 --- /dev/null +++ b/formats-node.js @@ -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 + }); +}; diff --git a/index.js b/index.js index 4ac14c9..9158ae9 100644 --- a/index.js +++ b/index.js @@ -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; diff --git a/lib.js b/lib.js index 678df09..3380715 100644 --- a/lib.js +++ b/lib.js @@ -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) { diff --git a/package.json b/package.json index 81da190..f80fce8 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/test/browser.test.js b/test/browser.test.js new file mode 100644 index 0000000..bb2851b --- /dev/null +++ b/test/browser.test.js @@ -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')); +}); diff --git a/test/index.test.js b/test/index.test.js index 9a42c2d..ac0b207 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -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); }); diff --git a/test/run-tests.js b/test/run-tests.js new file mode 100644 index 0000000..c79695b --- /dev/null +++ b/test/run-tests.js @@ -0,0 +1,148 @@ +/* eslint-env mocha */ +const { promisify } = require('util'); +const fs = require('fs'); +const path = require('path'); + +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 readFile = promisify(fs.readFile); + +module.exports = function runTests(convert) { + it('exports a function', () => { + expect(convert).to.be.a('function'); + }); + + const decode = { + 'image/png': buffer => PNG.sync.read(Buffer.from(buffer)), + 'image/jpeg': buffer => jpegJs.decode(Buffer.from(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) => { + expect(buffer).to.be.instanceOf(Uint8Array); + + 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; + }); + + expect(error).to.be.instanceof(Error) + .and.to.have.property('message', 'output format needs to be one of [JPEG,PNG]'); + }); + + 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()); + + let error; + + await method({ buffer, format: 'JPEG' }).catch(err => { + error = err; + }); + + expect(error).to.be.instanceof(TypeError) + .and.to.have.property('message', 'input buffer is not a HEIC image'); + }); + }); +}