UNPKG

@aidin36/xmp

Version:

Read and write XMP metadata from/to various media formats

363 lines (362 loc) 19.3 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.heicWriteOrUpdateXmp = void 0; const heicReader_1 = require("./heicReader"); const utils_1 = require("./utils"); // "mime" and "infe" strings in the form of bytes const MIME = [109, 105, 109, 101]; const IINF = [105, 105, 110, 102]; const INFE = [105, 110, 102, 101]; const ILOC = [105, 108, 111, 99]; const APPLICATION_XML = [97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 47, 114, 100, 102, 43, 120, 109, 108]; /** * Goes through all the items in the iloc, and update their offsets. * Note that it modifies the image parameter. To save the memory. * * @param affectedOffset Will update ilocs that point to a location after this * offset. * @param addedSize adds this number to the offsets. It can be negative. */ const correctAllIlocItems = (image, metaBox, affectedOffset, addedSize) => { if (addedSize === 0) { return image; } const iloc = (0, heicReader_1.parseIlocBox)(metaBox.metaInnerBoxesData); if (!iloc) { throw Error(`correctAllIlocItems: Expected to find an iloc Box.`); } if (iloc.offsetSize !== 4 && iloc.offsetSize !== 8) { throw Error(`Invalid image format. Iloc's Offset Size must be 4 or 8, but was ${iloc.offsetSize}`); } iloc.ilocItems.forEach((item) => { item.extends.forEach((extend) => { // This is kinda dirty! When we're parsing iloc Box, we pass 'metaInnerBoxesData' to the method. // So all the indexes are relative to that. // The 'metaInnerBoxesData' start from 'metaBox.dataStartIndex + 4'. // Then, we have 'iloc.dataStartIndex' which is relative to the 'metaInnerBoxesData', and // offsetFieldRelativeIndex' is relative to the start of the iloc's data. // That's how find the index for the 'offset'! const offsetStartIndex = metaBox.dataStartIndex + 4 + iloc.dataStartIndex + extend.offsetFieldRelativeIndex; if (extend.offset === affectedOffset) { // The length of the data this one points to changed, but not its location. // i.e. it's the item that points to XMP data, and we updated the XMP. const lengthStartIndex = offsetStartIndex + iloc.offsetSize; const newLength = extend.length + addedSize; if (iloc.lengthSize === 4) { image.set((0, utils_1.uint32ToBytes)(newLength), lengthStartIndex); } else { image.set((0, utils_1.uint64ToBytes)(newLength), lengthStartIndex); } } if (extend.offset > affectedOffset) { const newOffset = extend.offset + addedSize; if (iloc.offsetSize === 4) { // Did it this way to save memory. Otherwise had to create a few // copies of the potentially large image array. image.set((0, utils_1.uint32ToBytes)(newOffset), offsetStartIndex); } else { image.set((0, utils_1.uint64ToBytes)(newOffset), offsetStartIndex); } } }); }); return image; }; /** * * @param version - Version that defined by the parent iloc Box * @param baseOffsetSize - Defined by the parent iloc Box * @param offsetSize - Defined by the parent iloc Box * @param lengthSize - Defined by the parent iloc Box * @param indexSize - Defined by the parent iloc Box * // TODO: Is this statement correct? We didn't use this index here. * @param index - In version 1 & 2 the items are sorted by index. Pass the index * this item should have. */ const createIlocItem = (version, baseOffsetSize, offsetSize, lengthSize, indexSize, index, metadataId, dataOffset, dataLength) => { const itemId = version === 2 ? (0, utils_1.uint32ToBytes)(metadataId) : (0, utils_1.uint16ToBytes)(metadataId); // We currently not using the construction method. const constructionMethod = version === 1 || version === 2 ? [0, 0] : []; // BaseOffsetSize can be zero let baseOffset = []; if (baseOffsetSize === 4) { baseOffset = [0, 0, 0, 0]; } if (baseOffsetSize === 8) { baseOffset = [0, 0, 0, 0, 0, 0, 0, 0]; } // 2 or 4 bytes Metadata ID (Item ID) // 2 bytes construction method. // 2 bytes Data Reference Index - Because we're storing XMP in the same file, it's zero. // 4 or 8 bytes Item Base Offset that we're not using. // 2 bytes Extent Count - we have one. const item = [...itemId, ...constructionMethod, 0, 0, ...baseOffset, 0, 1]; let extendIndex = []; if ((version === 1 || version === 2) && indexSize > 0) { if (indexSize === 4) { extendIndex = [0, 0, 0, 0]; } else { extendIndex = [0, 0, 0, 0, 0, 0, 0, 0]; } } let offset; if (offsetSize === 4) { offset = (0, utils_1.uint32ToBytes)(dataOffset); } if (offsetSize === 8) { offset = (0, utils_1.uint64ToBytes)(dataOffset); } if (offset === undefined) { throw Error(`iloc offset size must be 4 or 8, but it was ${offsetSize}`); } let length; if (lengthSize === 4) { length = (0, utils_1.uint32ToBytes)(dataLength); } if (lengthSize === 8) { length = (0, utils_1.uint64ToBytes)(dataLength); } if (length === undefined) { throw Error(`iloc length size must be 4 or 8 but was ${lengthSize}`); } const extend = [...extendIndex, ...offset, ...length]; return [...item, ...extend]; }; /** * @returns { ilocBox: the new Box, addedSize: Bytes that is added to the image } */ const createIlocBox = (metadataId, dataOffset, dataLength) => { // See the 'findXMPItemInIloc' method in the heicReader.ts for the structure. // one byte version // 3 bytes flags (I don't know if I need to put anything in the flags?) // 2 bytes values4 (Honestly, I copied it from another file! Don't know how to generate them.) // values4 are: offsetSize=4 lengthSize=4 baseOffsetSize=4 indexSize=0 // 2 bytes items count const header = [0, 0, 0, 0, 68, 64, 0, 1]; const item = createIlocItem(0, 4, 4, 4, 0, 0, metadataId, dataOffset, dataLength); const boxData = [...header, ...item]; // 4 bytes size (+8 : 4 bytes size + 4 bytes ILOC) // 4 bytes Box type const boxHeader = [...(0, utils_1.uint32ToBytes)(boxData.length + 8), ...ILOC]; const ilocBox = new Uint8Array([...boxHeader, ...boxData]); return { ilocBox, addedSize: ilocBox.length }; }; /** * @returns { ilocBox: the modified Box, addedSize: Bytes that is added to the image } */ const appendNewIlocItem = (iloc, metadataId, dataOffset, dataLength) => { // Appens the new item to the end of the ILOC Box, and updates the // Box's size. // In version 1 & 2 items are sorted by an index. const newItemIndex = (iloc.version === 1 || iloc.version === 2) && iloc.indexSize > 0 ? Math.max(...iloc.ilocItems.map((_) => _.itemId)) + 1 : 0; const newItem = createIlocItem(iloc.version, iloc.baseOffsetSize, iloc.offsetSize, iloc.lengthSize, iloc.indexSize, newItemIndex, metadataId, dataOffset, dataLength); const modifiedData = (0, utils_1.cloneUint8Array)(iloc.data); const newSize = (0, utils_1.uint32ToBytes)(iloc.size + newItem.length); // FIXME: We need to check if the item count exceeds the uint16 const newItemCount = iloc.version === 2 ? (0, utils_1.uint32ToBytes)(iloc.ilocItems.length + 1) : (0, utils_1.uint16ToBytes)(iloc.ilocItems.length + 1); // Setting the new item count modifiedData.set(newItemCount, 6); const boxHeader = (0, utils_1.concatArrays)(newSize, new Uint8Array(ILOC)); return { ilocBox: (0, utils_1.concatArrays)(boxHeader, modifiedData, new Uint8Array(newItem)), addedSize: newItem.length }; }; const createInfeBox = (metadataId) => { // 4 bytes size // 4 bytes Box type ("infe") // 1 byte version (version 2 and 3 only differ in Item ID sizes. We use 3 just to stay safe.) // 3 bytes flags that we don't use. // 4 bytes Item ID // 2 bytes protection index (zero means unprotected. I don't know what is a protect item honestly!) // 4 bytes type ("mime") then a null // Then the item name const boxData = [...INFE, 3, 0, 0, 0, ...(0, utils_1.uint32ToBytes)(metadataId), 0, 0, ...MIME, 0, ...APPLICATION_XML]; // +4 cause the 4 bytes 'size' is also included in the 'size' field. return [...(0, utils_1.uint32ToBytes)(boxData.length + 4), ...boxData]; }; /** * Creates IINF (with an INFE inside) and ILOC Boxes. */ const createNewIinfBox = (image, metaBox) => { // See the 'findXmpMetadataID' method for the structure of the IINF Box. // one byte version // 3 bytes flags (I don't know if I need to put anything in the flags?) // 4 bytes "entry counts" (count of INFE Boxes) - which is one here. const iinfHeader = [2, 0, 0, 0, 0, 0, 0, 1]; // Because it the first and only INFE, we start from 1 const metadataId = 1; const infeBox = createInfeBox(metadataId); // + 4 bytes size and 4 bytes "iinf" const iinfBoxSize = iinfHeader.length + infeBox.length + 8; const iinfBoxHeader = [...(0, utils_1.uint16ToBytes)(iinfBoxSize), ...IINF]; const iinfBox = new Uint8Array([...iinfBoxHeader, ...iinfHeader, ...infeBox]); // The size of the Meta Box will increase. We're setting the new size. const newMetaBoxSize = (0, utils_1.uint32ToBytes)(metaBox.size + iinfBox.length); // There was no iinf Box, so there shouldn't be any iloc Boxes either. So we // don't need to correct any iloc Boxes. // TODO: Validate the above by reading the standard. // The first four bytes of each Box is its size. return (0, utils_1.concatArrays)(image.subarray(0, metaBox.boxStartIndex), newMetaBoxSize, image.subarray(metaBox.boxStartIndex + 4, metaBox.boxEndIndex), iinfBox, image.subarray(metaBox.boxEndIndex)); }; const findNextInfeItemId = (iinfBox) => { // Same algorithm as findXmpMetadataID const iinfVersion = iinfBox.data[0]; const dataOffset = iinfVersion === 0 ? 2 : 4; let curIndex = 4 + dataOffset; let maxItemId = 1; let infeBox = (0, heicReader_1.findBox)(iinfBox.data, 'infe', curIndex); while (infeBox != null) { const infeVersion = infeBox.data[0]; const itemId = infeVersion <= 2 ? (0, utils_1.bytes2Uint16)(infeBox.data.subarray(4, 6)) : (0, utils_1.bytes2Uint32)(infeBox.data.subarray(4, 8)); if (itemId > maxItemId) { maxItemId = itemId; } // Continue checking other infe Boxes curIndex += infeBox.size; infeBox = (0, heicReader_1.findBox)(iinfBox.data, 'infe', curIndex); } return maxItemId + 1; }; const addInfeBox = (image, metaBox, iinfBox) => { // What it does: // Finds the next free INFE Item ID // Creates the INFE Box // Appends it to the IINF Box // Updates IINF Box's size const metadataId = findNextInfeItemId(iinfBox); const infeBox = createInfeBox(metadataId); const newIinfSize = iinfBox.size + infeBox.length; const newMetaSize = metaBox.size + infeBox.length; // When we're searching for IINF Box, we send 'metaBox.metaInnerBoxesData' to // the 'findBox' method. So, the indexes it returns are relative to the start // of the 'metaInnerBoxesData'. Which is 'metaBox.dataStartIndex + 4'. // TODO: It's very error prone to keep the indexes like this. I actually made // a mistake here that took me a while to debug! Need to clean this up. const absoluteIinfStartIndex = metaBox.dataStartIndex + 4 + iinfBox.boxStartIndex; const absoluteIinfEndIndex = metaBox.dataStartIndex + 4 + iinfBox.boxEndIndex; const absoluteIinfDataStartIndex = metaBox.dataStartIndex + 4 + iinfBox.dataStartIndex; // We need to update the entry count. The first bytes are the count. const iinfVersion = iinfBox.data.at(0); const oldEntryCount = iinfVersion === 0 ? (0, utils_1.bytes2Uint16)(iinfBox.data.subarray(1, 2)) : (0, utils_1.bytes2Uint32)(iinfBox.data.subarray(1, 4)); const newEntryCount = oldEntryCount + 1; const newEntryCountBytes = iinfVersion === 0 ? (0, utils_1.uint16ToBytes)(newEntryCount) : (0, utils_1.uint32ToBytes)(newEntryCount); const newEntryCountSize = iinfVersion === 0 ? 2 : 4; // We replace the 'size' of the IINF and Meta Boxes, and append our new // INFE Box to the end of the IINF Box. const modifiedImage = (0, utils_1.concatArrays)(image.subarray(0, metaBox.boxStartIndex), (0, utils_1.uint32ToBytes)(newMetaSize), image.subarray(metaBox.boxStartIndex + 4, absoluteIinfStartIndex), (0, utils_1.uint32ToBytes)(newIinfSize), // First byte is version. We replace the entry counts after that. image.subarray(absoluteIinfStartIndex + 4, absoluteIinfDataStartIndex + 1), newEntryCountBytes, image.subarray(absoluteIinfDataStartIndex + 1 + newEntryCountSize, absoluteIinfEndIndex), new Uint8Array(infeBox), image.subarray(absoluteIinfEndIndex)); const newMetaBox = (0, heicReader_1.findMetaBox)(modifiedImage); if (newMetaBox == null) { throw Error(`MetaBox corrupted after we modified it. Please report this bug!`); } const correctedImage = correctAllIlocItems(modifiedImage, newMetaBox, metaBox.boxStartIndex, infeBox.length); return { modifiedImage: correctedImage, newMetadataId: metadataId, newMetaBox }; }; /** * Append the XMP data to the image. */ const addNewXmpData = (image, xmp, metaBox, metadataId) => { const iloc = (0, heicReader_1.parseIlocBox)(metaBox.metaInnerBoxesData); // We're going to add the XMP to the end of the file, so the offset is the // end of the file. const { ilocBox, addedSize } = iloc == null ? createIlocBox(metadataId, image.length, xmp.length) : appendNewIlocItem(iloc, metadataId, image.length, xmp.length); // TODO: I should reduce the number of cloning to save the memory. I can clone // once and modify the parameter in other functions. const newMetaBoxSize = metaBox.size + addedSize; // metaBox.data is after the Header. Here we want to modify the header itself. let modifiedMetaBox = image.subarray(metaBox.boxStartIndex, metaBox.boxEndIndex); modifiedMetaBox.set((0, utils_1.uint32ToBytes)(newMetaBoxSize), 0); if (iloc == null) { modifiedMetaBox = (0, utils_1.concatArrays)(modifiedMetaBox, ilocBox); } else { // Replace the existing iloc Box // XXX: The indexes of iloc Box are relative to the beginning of // 'metaInnerBoxesData', which is after 4 bytes size + 4 bytes 'meta' + // 4 bytes flags. I made a mistake a few times to think that these indexes // are relative to the beginning of the meta Box. Need to find a better way! modifiedMetaBox = (0, utils_1.concatArrays)(modifiedMetaBox.subarray(0, iloc.boxStartIndex + 8 + 4), ilocBox, modifiedMetaBox.subarray(iloc.boxEndIndex + 8 + 4)); } // Replacing the meta Box, and at the same time appending the XMP // data to the end of the image. const concatedImage = (0, utils_1.concatArrays)(image.subarray(0, metaBox.boxStartIndex), modifiedMetaBox, image.subarray(metaBox.boxEndIndex), xmp); const newMetaBox = (0, heicReader_1.findMetaBox)(concatedImage); if (newMetaBox == null) { throw Error(`MetaBox corrupted after we modified it. Please report this bug!`); } const correctedImage = correctAllIlocItems(concatedImage, newMetaBox, metaBox.boxStartIndex, addedSize); return correctedImage; }; const replaceExistingXmpData = (image, metaBox, metadataId, newXmp) => { const iloc = (0, heicReader_1.parseIlocBox)(metaBox.metaInnerBoxesData); if (iloc == null) { throw Error(`This is a bug! The image should already have an iloc Box, but I didn't find it. meta Box = ${JSON.stringify(metaBox)}`); } const xmpItem = iloc.ilocItems.find((itm) => itm.itemId === metadataId); if (xmpItem == null) { throw Error(`This is a bug! The image should have an iloc with the ${metadataId} ID, but I didn't find it. meta Box=${JSON.stringify(metaBox)}`); } if (xmpItem.dataReferenceIndex !== 0) { throw Error('The XMP data is not stored in the current file. This is not supported yet.'); } // TODO: Maybe we can delete all the extends and then create a single one? if (xmpItem.extentCount !== 1) { throw Error(`Sorry, this image is not supported yet. The iloc item that points to the XMP data has multiple extends. item = ${JSON.stringify(xmpItem)}`); } const extend = xmpItem.extends.at(0); const oldXmp = image.subarray(extend.offset, extend.offset + extend.length); const modifiedImage = (0, utils_1.concatArrays)(image.subarray(0, extend.offset), newXmp, image.subarray(extend.offset + extend.length)); const sizeDiff = newXmp.length - oldXmp.length; // It also corrects the length of the iloc item that stored our XMP data. return correctAllIlocItems(modifiedImage, metaBox, extend.offset, sizeDiff); }; /** * @internal */ const heicWriteOrUpdateXmp = (image, xmp) => { const metaBox = (0, heicReader_1.findMetaBox)(image); if (metaBox == null) { throw Error('There is no Meta Box inside this file. Currently, creating a new Meta Box from scratch is not supported'); } // There are multiple Boxes inside the 'meta' Box. We're searching within the // Meta Box for our 'iinf' Box. const iinfBox = (0, heicReader_1.findBox)(metaBox.metaInnerBoxesData, 'iinf'); if (iinfBox == null) { const modifiedImage = createNewIinfBox(image, metaBox); const newMetaBox = (0, heicReader_1.findMetaBox)(modifiedImage); if (newMetaBox == null) { throw Error(`MetaBox corrupted after we modified it. Please report this bug!`); } // We created a new IINF box, so the Metadata ID starts from one. return addNewXmpData(modifiedImage, xmp, newMetaBox, 1); } // Now, we find the Metadata ID (from an INFE Box inside the IINF Box) const metadataId = (0, heicReader_1.findXmpMetadataID)(iinfBox.data); if (metadataId == null) { const { modifiedImage, newMetadataId, newMetaBox } = addInfeBox(image, metaBox, iinfBox); return addNewXmpData(modifiedImage, xmp, newMetaBox, newMetadataId); } return replaceExistingXmpData(image, metaBox, metadataId, xmp); }; exports.heicWriteOrUpdateXmp = heicWriteOrUpdateXmp;