UNPKG

@aidin36/xmp

Version:

Read and write XMP metadata from/to various media formats

371 lines (370 loc) 17.4 kB
"use strict"; /* * This file is part of @aidin36/xmp Javascript package. * * @aidin36/xmp is free software: you can redistribute it and/or modify it under * the terms of the GNU Lesser General Public License as published by the Free * Software Foundation, either version 3 of the License, or (at your option) any * later version. * * @aidin36/xmp is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details * * You should have received a copy of the GNU Lesser General Public License * along with @aidin26/xmp. If not, see <https://www.gnu.org/licenses/>. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.heicExtractXmp = exports.parseIlocBox = exports.findXmpMetadataID = exports.findMetaBox = exports.findBox = void 0; /** * The structure of HEIC file * =========================== * * Well, actually, we only care about how XMP data is stored in the file. Here I * explain how we find the XMP data we need. * * The HEIC file is consist of "Boxes". These Boxes contains image data, * metadata, etc. The structure of some of the Boxes are defined in the * standard. But the standard lets you define any type of boxes! And there are * other standards that defines what are these boxes. * * The Box we care about is "meta". It contains other Boxes within itself. * * Basically, to parse the file, we read it box-by-box. Until we find our * "meta" box. * * The structure of each box is: * 4 bytes size of the box (including the size bytes themseves) + * 4 bytes Type of the box (e.g. 'xml ') * (Size can be zero, which means the box extends until the end of the file.) * (There can be user-defined box types, but we don't care about them here.) * * So, we read the first 4 bytes and store it in 'size'. Then we read the second * 4 bytes as a string. If it wasn't 'meta', we move forward by the 'size'. Now * we're at the beginning of the second Box. We read 4 bytes and store it in * 'size'. And so on. * * Inside the 'meta' box, there can be multiple type of Boxes that store * different types of metadata (with different standards.) There is a 'iinf' Box * that also contains multiple 'infe' Boxes inside it! One of them contains * the XMP data we're looking for. * * We're looking for an 'infe' box that has a 'mime' and its mime is * 'application/rdf+xml'. * * The 'infe' box doesn't contain the actual XMP data. It contains an ID. The * actual data is stored in a 'iloc' Box, that contains the ID we read from the * 'infe' box. * (There's an 'iref' Box with the same ID too. Currently, I don't know what * that is.) * * Within the 'iloc' Box, there are multiple 'items'. We need to find an 'item' * with the same ID. That 'item' is our XMP data. * * Reference: * I've learned about the basic structure of the file from this document and its * Python source code: * https://github.com/spacestation93/heif_howto * Reading 'libheif' code also helped: * https://github.com/strukturag/libheif/blob/1a131f9be0e899004233b605cd3102251986d933/libheif/context.cc#L1358 * * The publicly available standard is here: * https://web.archive.org/web/20220208140702/https://b.goeswhere.com/ISO_IEC_14496-12_2015.pdf */ const utils_1 = require("./utils"); const MIME = [109, 105, 109, 101]; /** * @internal * Finds the first box with the specified type. * * @param from - Where to start the search. * * @return Box info or undefined if the box not found. */ const findBox = (image, boxType, from = 0) => { if (from >= image.length) { return undefined; } // TODO: size = 1 is also a special case. See: https://github.com/spacestation93/heif_howto/blob/main/box_parts.py#L182 const subImage = image.subarray(from); const foundSize = (0, utils_1.bytes2Uint32)(subImage); // When size is zero, it means the box extends until the end of the image. const actualSize = foundSize === 0 ? image.length - from : foundSize; const foundType = (0, utils_1.binArray2String)(subImage.subarray(4, 8)); if (foundType === boxType) { const dataStartIndex = from + 8; const boxEndIndex = from + actualSize; return { size: foundSize, type: foundType, boxStartIndex: from, dataStartIndex, boxEndIndex, data: image.subarray(dataStartIndex, boxEndIndex), }; } return (0, exports.findBox)(image, boxType, from + actualSize); }; exports.findBox = findBox; /** * @internal * * Finds 'meta' box. Returns undefined it the box not found. * 'meta' has multiple Boxes inside it. The 'metaInnerBoxesData' property will * be the unparsed data of those Boxes. */ const findMetaBox = (image) => { const metaBox = (0, exports.findBox)(image, 'meta'); if (metaBox == null) { return undefined; } // Inside the 'meta' Box, first byte is the version, and the three bytes after // that are flags. (We're ignoring them for now.) // So Boxes start from the fifth byte. const metaInnerBoxesData = image.subarray(metaBox.dataStartIndex + 4, metaBox.boxEndIndex); return Object.assign(Object.assign({}, metaBox), { metaInnerBoxesData }); }; exports.findMetaBox = findMetaBox; /** * @internal * * Finds the INFE Box within the IINF Box that contains the ID of the IREF Box * we need. * * @returns Item ID (Metadata ID) inside the INFE Box. Returns undefined if * no such box found. */ const findXmpMetadataID = (iinfBox) => { // There are multiple infe Boxes within the iinf Box. We're looking for one // that has 'application/rdf+xml' meme. // Structure of each infe box. Copied from: // https://github.com/strukturag/libheif/blob/1a131f9be0e899004233b605cd3102251986d933/libheif/box.cc#L2136 /* * version <= 1 version 2 version > 2 mime uri * ----------------------------------------------------------------------------------------------- * item id 16 16 32 16/32 16/32 * protection index 16 16 16 16 16 * item type - yes yes yes yes * item name yes yes yes yes yes * content type yes - - yes - * content encoding yes - - yes - * hidden item - yes yes yes yes * item uri type - - - - yes * * Note: HEIF does not allow version 0 and version 1 boxes ! (see 23008-12, 10.2.1) */ // 'version' is one byte. After that are 3 bytes 'flags' just like the iinfe box. // We're looking for a Box with 'item type === mime' and 'item name === 'application/rfd+xml' // This is how libheif write these data. I read the logic of this code to // figure out how these Boxes are structured: // https://github.com/strukturag/libheif/blob/1a131f9be0e899004233b605cd3102251986d933/libheif/context.cc#L1365 // The first byte of the iinf Box is its version. const iinfVersion = iinfBox[0]; const dataOffset = iinfVersion === 0 ? 2 : 4; // One byte 'version' and three bytes 'flags'. Depending on the 'version', // data starts 2 or 4 bytes after that. // (I don't know the logic! Saw it in other codes.) // TODO: I found out these 2 or 4 bytes are "entry count". I should use it. const startOfBoxes = 4 + dataOffset; let curIndex = startOfBoxes; let infeBox = (0, exports.findBox)(iinfBox, 'infe', curIndex); while (infeBox != null) { const infeVersion = infeBox.data[0]; // Version <=1 is not a 'mime' infe we're looking for. // Versions above 3 shouldn't exists in a HEIF file. if (infeVersion === 2 || infeVersion === 3) { // 3 bytes flags, then Item ID. const itemId = infeVersion === 2 ? (0, utils_1.bytes2Uint16)(infeBox.data.subarray(4, 6)) : (0, utils_1.bytes2Uint32)(infeBox.data.subarray(4, 8)); const itemTypeIndex = infeVersion === 2 ? // 3 bytes flags, 2 bytes 'item id' and 2 bytes 'protection index', then 'type' 8 : // 3 bytes flags, 4 bytes 'item id' and 2 bytes 'protection index', then 'type' 10; // TODO: Is there a flag or something that tells me the data is not inside the INFE box, but I should find the IREF & CDSC? Should I check the length of the box? // First four bytes should be 'mime' if (infeBox.data.at(itemTypeIndex) === MIME.at(0) && infeBox.data.at(itemTypeIndex + 1) === MIME.at(1) && infeBox.data.at(itemTypeIndex + 2) === MIME.at(2) && infeBox.data.at(itemTypeIndex + 3) === MIME.at(3)) { // After 'mime' there's a null character that we skip. const itemNameIndex = itemTypeIndex + 5; if ((0, utils_1.binArray2String)(infeBox.data.subarray(itemNameIndex, itemNameIndex + 19)) === 'application/rdf+xml') { return itemId; } } } // Continue checking other infe Boxes curIndex += infeBox.size; infeBox = (0, exports.findBox)(iinfBox, 'infe', curIndex); } // No Box found that refers to XMP data. return undefined; }; exports.findXmpMetadataID = findXmpMetadataID; const parseIlocBox = (metaBoxesData) => { // There's only one 'iloc' Box inside the image. const ilocBox = (0, exports.findBox)(metaBoxesData, 'iloc'); if (ilocBox == null) { return undefined; } // The algorithm is copied from here: // https://github.com/strukturag/libheif/blob/1a131f9be0e899004233b605cd3102251986d933/libheif/box.cc#L1347 // Also from the standard's PDF, section 8.11.3.2 const version = ilocBox.data[0]; if (version > 2) { throw Error(`Unsupported version of 'iloc' Box: ${version}`); } // 1 byte version, 3 bytes flags, then 2 bytes 'values4' const values4 = (0, utils_1.bytes2Uint16)(ilocBox.data.subarray(4, 6)); /* eslint-disable no-bitwise */ const offsetSize = (values4 >> 12) & 0xf; const lengthSize = (values4 >> 8) & 0xf; const baseOffsetSize = (values4 >> 4) & 0xf; const indexSize = version === 0 ? 0 : values4 & 0xf; /* eslint-enable no-bitwise */ // There's no way to detect the start and end of each Item and Extend. // So we need to go through the buffer and parse it byte by byte to find out. const result = []; const itemCount = version === 2 ? (0, utils_1.bytes2Uint32)(ilocBox.data.subarray(6, 10)) : (0, utils_1.bytes2Uint16)(ilocBox.data.subarray(6, 8)); const itemsStartIndex = version === 2 ? 10 : 8; const itemsBuffer = ilocBox.data.subarray(itemsStartIndex); let curIndex = 0; for (let itemNum = 0; itemNum < itemCount; itemNum++) { if (curIndex >= itemsBuffer.length) { throw Error(`Invalid iloc Box. Expected to find ${itemCount} items, but found less.`); } const item = { startIndex: curIndex, endIndex: -1, itemId: -1, dataReferenceIndex: -1, extentCount: -1, extends: [], }; // Items have a predefined structure. So, we're reading them byte by byte based on the version. // TODO: Draw the structure table here, from the standard PDF. item.itemId = version === 2 ? (0, utils_1.bytes2Uint32)(itemsBuffer.subarray(curIndex, curIndex + 4)) : (0, utils_1.bytes2Uint16)(itemsBuffer.subarray(curIndex, curIndex + 2)); curIndex = version === 2 ? curIndex + 4 : curIndex + 2; if (version === 1 || version === 2) { // Construction method that we're ignoring at the moment. // 12 bits reserved, 4 bits construction method. curIndex += 2; } item.dataReferenceIndex = (0, utils_1.bytes2Uint16)(itemsBuffer.subarray(curIndex, curIndex + 2)); curIndex += 2; // let itemBaseOffset = 0 // if (baseOffsetSize === 4) { // itemBaseOffset = bytes2Uint32(itemsBuffer.subarray(curIndex, curIndex + 4)) // } // if (baseOffsetSize === 8) { // itemBaseOffset = bytes2Uint64(itemsBuffer.subarray(curIndex, curIndex + 8)) // } curIndex += baseOffsetSize; item.extentCount = (0, utils_1.bytes2Uint16)(itemsBuffer.subarray(curIndex, curIndex + 2)); curIndex += 2; for (let extentNum = 0; extentNum < item.extentCount; extentNum++) { const extend = { index: 0, offset: 0, length: 0, offsetFieldRelativeIndex: 0 }; if ((version === 1 || version === 2) && indexSize > 0) { if (indexSize === 4) { extend.index = (0, utils_1.bytes2Uint32)(itemsBuffer.subarray(curIndex, curIndex + 4)); curIndex += 4; } if (indexSize === 8) { extend.index = (0, utils_1.bytes2Uint64)(itemsBuffer.subarray(curIndex, curIndex + 8)); curIndex += 8; } } extend.offsetFieldRelativeIndex = itemsStartIndex + curIndex; if (offsetSize === 4) { extend.offset = (0, utils_1.bytes2Uint32)(itemsBuffer.subarray(curIndex, curIndex + 4)); curIndex += 4; } if (offsetSize === 8) { extend.offset = (0, utils_1.bytes2Uint64)(itemsBuffer.subarray(curIndex, curIndex + 8)); curIndex += 8; } if (lengthSize === 4) { extend.length = (0, utils_1.bytes2Uint32)(itemsBuffer.subarray(curIndex, curIndex + 4)); curIndex += 4; } if (lengthSize === 8) { extend.length = (0, utils_1.bytes2Uint64)(itemsBuffer.subarray(curIndex, curIndex + 8)); curIndex += 8; } item.extends.push(extend); } item.endIndex = curIndex; result.push(item); } return Object.assign(Object.assign({}, ilocBox), { version, baseOffsetSize, offsetSize, lengthSize, indexSize, ilocItems: result }); }; exports.parseIlocBox = parseIlocBox; /** * Finds an item in the ILOC Box with 'Item ID === metadataId'. * Returns its "extends". * Will return 'undefined' if no such item found. */ const findXMPItemInIloc = (metadataId, metaBoxesData) => { const iloc = (0, exports.parseIlocBox)(metaBoxesData); if (iloc == null) { return undefined; } const { ilocItems } = iloc; const filteredItems = ilocItems.filter((item) => item.itemId === metadataId); if (filteredItems.length === 0) { return undefined; } if (filteredItems.length > 1) { throw Error(`Found more than one ILOC item with the same Item ID. Items: ${JSON.stringify(filteredItems)}`); } const xmpItem = filteredItems[0]; // data-reference-index is either zero (‘this file’) or a 1‐based index into the data references in the // data information box if (xmpItem.dataReferenceIndex !== 0) { throw Error('The XMP data is not stored in the current file. This is not supported yet.'); } return xmpItem.extends; }; const heicExtractXmp = (image) => { const metaBox = (0, exports.findMetaBox)(image); if (metaBox == null) { return undefined; } // There are multiple Boxes inside the 'meta' Box. We're searching within the // Meta Box for our 'iinf' Box. const iinfBox = (0, exports.findBox)(metaBox.metaInnerBoxesData, 'iinf'); if (iinfBox == null) { return undefined; } // Now, we find the Metadata ID (from an INFE Box inside the IINF Box) const metadataId = (0, exports.findXmpMetadataID)(iinfBox.data); if (metadataId == null) { return undefined; } const xmpIlocExtends = findXMPItemInIloc(metadataId, metaBox.metaInnerBoxesData); if (xmpIlocExtends == null || xmpIlocExtends.length === 0) { throw Error('Metadata ID found in the file, but relevant iLoc Box could not be found.'); } // XMP data can be spreaded between multiple "extends". We're merging them. if (xmpIlocExtends.length === 1) { const extend = xmpIlocExtends[0]; return image.subarray(extend.offset, extend.offset + extend.length); } // TODO: Write a test for when XMP is inside multiple extends. // Note: This method is ten times faster than using spread syntax. const binaryArrays = xmpIlocExtends .sort((a, b) => a.index - b.index) .map((extend) => image.subarray(extend.offset, extend.offset + extend.length)); const totalLengh = binaryArrays.reduce((acc, binArray) => acc + binArray.length, 0); const concatedArrays = new Uint8Array(totalLengh); let offset = 0; binaryArrays.forEach((binArray) => { concatedArrays.set(binArray, offset); offset += binArray.length; }); return concatedArrays; }; exports.heicExtractXmp = heicExtractXmp;