diff --git a/.eslintrc.yml b/.eslintrc.yml index 2c9e6cb..82e205c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -40,7 +40,7 @@ rules: max-depth: [ 1, 6 ] max-nested-callbacks: [ 1, 4 ] # string can exceed 80 chars, but should not overflow github website :) - max-len: [ 2, 120, 1000 ] + max-len: [ 2, 120, 1000, { ignoreUrls: true } ] new-cap: 2 new-parens: 2 # Postponed diff --git a/lib/parse_stream/avif.js b/lib/parse_stream/avif.js new file mode 100644 index 0000000..d3661d1 --- /dev/null +++ b/lib/parse_stream/avif.js @@ -0,0 +1,147 @@ +'use strict'; + +/* eslint-disable consistent-return */ + + +var ParserStream = require('../common').ParserStream; +var str2arr = require('../common').str2arr; +var sliceEq = require('../common').sliceEq; +var readUInt32BE = require('../common').readUInt32BE; + +var SIG_FTYP = str2arr('ftyp'); + + +function readBox(data, offset) { + if (data.length < 4 + offset) return null; + + var size = readUInt32BE(data, offset); + + // size includes first 4 bytes (length) + if (data.length < size + offset || size < 8) return null; + + // if size === 1, real size is following uint64 (only for big boxes, not needed) + // if size === 0, real size is until the end of the file (only for big boxes, not needed) + + return { + type: String.fromCharCode.apply(null, data.slice(offset + 4, offset + 8)), + data: data.slice(offset + 8, offset + size), + end: offset + size + }; +} + + +function readAvifSizeFromMeta(data) { + var newData, box, offset; + + for (offset = 4 /* version+flags */, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'iprp') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ipco') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ispe') { + newData = box.data; + // no break here (!), second ispe may override the first, see + // https://github.com/tigranbs/test-heic-images + } + } + + if (!newData) return; + data = newData; + + return { + width: readUInt32BE(data, 4), + height: readUInt32BE(data, 8) + }; +} + + +function readAvifSize(parser, imgType) { + parser._bytes(8, function (data) { + var size = readUInt32BE(data, 0) - 8; + var type = String.fromCharCode.apply(null, data.slice(4, 8)); + + if (size <= 0) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } else if (type === 'mdat') { + parser._skipBytes(Infinity); + parser.push(null); + return; + } else if (type === 'meta') { + parser._bytes(size, function (data) { + parser._skipBytes(Infinity); + + var imgSize = readAvifSizeFromMeta(data); + + if (!imgSize) return; + + parser.push({ + width: imgSize.width, + height: imgSize.height, + type: imgType === 'heic' ? 'heic' : 'avif', + mime: imgType === 'heic' ? 'image/heic' : 'image/avif', + wUnits: 'px', + hUnits: 'px' + }); + + parser.push(null); + }); + } else { + parser._skipBytes(size, function () { + readAvifSize(parser, type); + }); + } + }); +} + + +module.exports = function () { + var parser = new ParserStream(); + + parser._bytes(12, function (data) { + if (!sliceEq(data, 4, SIG_FTYP)) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + var type = String.fromCharCode.apply(null, data.slice(8, 12)); + + if (type !== 'heic' && type !== 'avif') { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + var size = readUInt32BE(data, 0) - 12; + + if (size <= 0) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + parser._skipBytes(size, function () { + readAvifSize(parser, type); + }); + }); + + return parser; +}; diff --git a/lib/parse_sync/avif.js b/lib/parse_sync/avif.js new file mode 100644 index 0000000..6589d73 --- /dev/null +++ b/lib/parse_sync/avif.js @@ -0,0 +1,126 @@ +// ISO media file spec: +// https://web.archive.org/web/20180219054429/http://l.web.umkc.edu/lizhu/teaching/2016sp.video-communication/ref/mp4.pdf +// +// ISO image file format spec: +// https://standards.iso.org/ittf/PubliclyAvailableStandards/c066067_ISO_IEC_23008-12_2017.zip +// + +/* eslint-disable consistent-return */ + +'use strict'; + + +var str2arr = require('../common').str2arr; +var sliceEq = require('../common').sliceEq; +var readUInt32BE = require('../common').readUInt32BE; + +var SIG_FTYP = str2arr('ftyp'); + + +/* + * interface Box { + * size: uint32; // if size == 0, box lasts until EOF + * boxtype: char[4]; + * largesize?: uint64; // only if size == 1 + * usertype?: char[16]; // only if boxtype == 'uuid' + * } + */ +function readBox(data, offset) { + if (data.length < 4 + offset) return null; + + var size = readUInt32BE(data, offset); + + // size includes first 4 bytes (length) + if (data.length < size + offset || size < 8) return null; + + // if size === 1, real size is following uint64 (only for big boxes, not needed) + // if size === 0, real size is until the end of the file (only for big boxes, not needed) + + return { + type: String.fromCharCode.apply(null, data.slice(offset + 4, offset + 8)), + data: data.slice(offset + 8, offset + size), + end: offset + size + }; +} + + +function readAvifSizeFromMeta(data) { + var newData, box, offset; + + for (offset = 4 /* version+flags */, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'iprp') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ipco') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ispe') { + newData = box.data; + // no break here (!), second ispe may override the first, see + // https://github.com/tigranbs/test-heic-images + } + } + + if (!newData) return; + data = newData; + + return { + width: readUInt32BE(data, 4), + height: readUInt32BE(data, 8) + }; +} + + +module.exports = function (data) { + // ISO media file (avif format) starts with ftyp box: + // 0000 0020 6674 7970 6176 6966 + // (length) f t y p a v i f + // + if (!sliceEq(data, 4, SIG_FTYP)) return; + + var type = String.fromCharCode.apply(null, data.slice(8, 12)); + + if (type !== 'avif' && type !== 'heic') return; + + var newData, box, offset; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + // mdat block SHOULD be last (but not strictly required), + // so it's unlikely that metadata is after it + if (box.type === 'mdat') return; + if (box.type === 'meta') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + var imgSize = readAvifSizeFromMeta(data); + + if (!imgSize) return; + + return { + width: imgSize.width, + height: imgSize.height, + type: type === 'heic' ? 'heic' : 'avif', + mime: type === 'heic' ? 'image/heic' : 'image/avif', + wUnits: 'px', + hUnits: 'px' + }; +}; diff --git a/lib/parsers_stream.js b/lib/parsers_stream.js index 66ac5b8..a54b352 100644 --- a/lib/parsers_stream.js +++ b/lib/parsers_stream.js @@ -1,6 +1,7 @@ 'use strict'; module.exports = { + avif: require('./parse_stream/avif'), bmp: require('./parse_stream/bmp'), gif: require('./parse_stream/gif'), ico: require('./parse_stream/ico'), diff --git a/lib/parsers_sync.js b/lib/parsers_sync.js index 2641e49..c02edd6 100644 --- a/lib/parsers_sync.js +++ b/lib/parsers_sync.js @@ -2,10 +2,11 @@ module.exports = { + avif: require('./parse_sync/avif'), bmp: require('./parse_sync/bmp'), gif: require('./parse_sync/gif'), - jpeg: require('./parse_sync/jpeg'), ico: require('./parse_sync/ico'), + jpeg: require('./parse_sync/jpeg'), png: require('./parse_sync/png'), psd: require('./parse_sync/psd'), svg: require('./parse_sync/svg'), diff --git a/test/fixtures/iojs_logo.avif b/test/fixtures/iojs_logo.avif new file mode 100644 index 0000000..b3f3757 Binary files /dev/null and b/test/fixtures/iojs_logo.avif differ diff --git a/test/test_formats.js b/test/test_formats.js index a148797..a5cf432 100644 --- a/test/test_formats.js +++ b/test/test_formats.js @@ -304,6 +304,26 @@ describe('File formats', function () { }); + describe('AVIF', function () { + it('should detect AVIF', async function () { + let file = path.join(__dirname, 'fixtures', 'iojs_logo.avif'); + let size = await probe(fs.createReadStream(file)); + + assert.deepStrictEqual(size, { width: 367, height: 187, type: 'avif', mime: 'image/avif', wUnits: 'px', hUnits: 'px' }); + }); + }); + + + describe('AVIF (sync)', function () { + it('should detect AVIF', function () { + let file = path.join(__dirname, 'fixtures', 'iojs_logo.avif'); + let size = probe.sync(fs.readFileSync(file)); + + assert.deepStrictEqual(size, { width: 367, height: 187, type: 'avif', mime: 'image/avif', wUnits: 'px', hUnits: 'px' }); + }); + }); + + describe('PSD', function () { it('should detect PSD', async function () { let file = path.join(__dirname, 'fixtures', 'empty.psd');