UNPKG

@wordpress/upload-media

Version:
961 lines (856 loc) 29.9 kB
/* eslint-disable no-bitwise, jsdoc/require-param */ /** * External dependencies */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; /** * Internal dependencies */ import { parseHeic, reverseBits32, parseExifOrientation, getUnappliedExifOrientation, } from '../heic-parser'; // --------------------------------------------------------------------------- // Helpers for constructing synthetic ISOBMFF structures // --------------------------------------------------------------------------- /** Write a big-endian uint32 into a DataView. */ function writeU32( view: DataView, offset: number, value: number ) { view.setUint32( offset, value ); } /** Write a big-endian uint16 into a DataView. */ function writeU16( view: DataView, offset: number, value: number ) { view.setUint16( offset, value ); } /** Build an ISOBMFF box (size + fourcc + data). */ function buildBox( type: string, data: Uint8Array ): Uint8Array { const size = 8 + data.length; const buf = new Uint8Array( size ); const view = new DataView( buf.buffer ); writeU32( view, 0, size ); for ( let i = 0; i < 4; i++ ) { buf[ 4 + i ] = type.charCodeAt( i ); } buf.set( data, 8 ); return buf; } /** Build a FullBox (size + fourcc + version + flags + data). */ function buildFullBox( type: string, version: number, flags: number, data: Uint8Array ): Uint8Array { const inner = new Uint8Array( 4 + data.length ); inner[ 0 ] = version; inner[ 1 ] = ( flags >> 16 ) & 0xff; inner[ 2 ] = ( flags >> 8 ) & 0xff; inner[ 3 ] = flags & 0xff; inner.set( data, 4 ); return buildBox( type, inner ); } /** Concatenate multiple Uint8Arrays. */ function concat( ...arrays: Uint8Array[] ): Uint8Array { const total = arrays.reduce( ( sum, a ) => sum + a.length, 0 ); const result = new Uint8Array( total ); let offset = 0; for ( const a of arrays ) { result.set( a, offset ); offset += a.length; } return result; } /** Build a pitm box (Primary Item). */ function buildPitm( primaryItemId: number ): Uint8Array { const data = new Uint8Array( 2 ); const view = new DataView( data.buffer ); writeU16( view, 0, primaryItemId ); return buildFullBox( 'pitm', 0, 0, data ); } /** * Build a minimal hvcC box (HEVCDecoderConfigurationRecord). * * Fields: configVersion=1, profileSpace=0, tier=0, profileIdc=1, * compatFlags=0x60000000, constraintBytes=[0xB0,0,0,0,0,0], * levelIdc=93, then zeros for remaining fields + 0 NAL arrays. */ function buildHvcC(): Uint8Array { // HEVCDecoderConfigurationRecord (23 bytes minimum with 0 arrays) const record = new Uint8Array( 23 ); record[ 0 ] = 1; // configurationVersion // byte1: profileSpace=0 (bits 6-7), tier=0 (bit 5), profileIdc=1 (bits 0-4) record[ 1 ] = 0x01; // general_profile_compatibility_flags = 0x60000000 record[ 2 ] = 0x60; record[ 3 ] = 0x00; record[ 4 ] = 0x00; record[ 5 ] = 0x00; // general_constraint_indicator_flags (6 bytes) = [0xB0, 0, 0, 0, 0, 0] record[ 6 ] = 0xb0; // bytes 7-11 are zero (remaining constraint bytes) // general_level_idc = 93 record[ 12 ] = 93; // remaining fields: min_spatial_segmentation_idc, parallelismType, // chromaFormat, bitDepthLuma, bitDepthChroma, avgFrameRate, misc, numOfArrays // All zero is valid for our test purposes. return buildBox( 'hvcC', record ); } /** Build an ispe (Image Spatial Extents) box. */ function buildIspe( width: number, height: number ): Uint8Array { const data = new Uint8Array( 8 ); const view = new DataView( data.buffer ); writeU32( view, 0, width ); writeU32( view, 4, height ); return buildFullBox( 'ispe', 0, 0, data ); } /** Build an irot (Image Rotation) box. angle is 0-3 (multiplied by 90°). */ function buildIrot( angle: number ): Uint8Array { const data = new Uint8Array( [ angle & 0x3 ] ); return buildBox( 'irot', data ); } /** Build an ipco (Item Property Container) with the given property boxes. */ function buildIpco( ...properties: Uint8Array[] ): Uint8Array { return buildBox( 'ipco', concat( ...properties ) ); } /** * Build an ipma (Item Property Association) box. * * @param associations Array of [itemId, propertyIndices[]] */ function buildIpma( associations: Array< [ number, number[] ] > ): Uint8Array { // Calculate data size: 4 (entry_count) + per entry: 2 (itemId) + 1 (assocCount) + N (indices) let dataSize = 4; for ( const [ , indices ] of associations ) { dataSize += 2 + 1 + indices.length; } const data = new Uint8Array( dataSize ); const view = new DataView( data.buffer ); writeU32( view, 0, associations.length ); let pos = 4; for ( const [ itemId, indices ] of associations ) { writeU16( view, pos, itemId ); pos += 2; data[ pos ] = indices.length; pos += 1; for ( const idx of indices ) { data[ pos ] = idx & 0x7f; // 7-bit index, essential=0 pos += 1; } } return buildFullBox( 'ipma', 0, 0, data ); } /** Build an iprp box containing ipco + ipma. */ function buildIprp( ipco: Uint8Array, ipma: Uint8Array ): Uint8Array { return buildBox( 'iprp', concat( ipco, ipma ) ); } /** * Build an iloc box for version 0, with 4-byte offsets and 4-byte lengths. * * @param items Array of [itemId, [[offset, length], ...]] */ function buildIloc( items: Array< [ number, Array< [ number, number ] > ] > ): Uint8Array { // version 0: offsetSize=4, lengthSize=4, baseOffsetSize=0 // per item: 2 (itemId) + 2 (data_reference_index) + 0 (base_offset) + 2 (extent_count) + N*(4+4) let dataSize = 2 + 2; // sizes byte + item_count for ( const [ , extents ] of items ) { dataSize += 2 + 2 + 2 + extents.length * 8; } const data = new Uint8Array( dataSize ); const view = new DataView( data.buffer ); // offset_size=4 (upper nibble), length_size=4 (lower nibble) data[ 0 ] = 0x44; // base_offset_size=0 (upper nibble), reserved=0 data[ 1 ] = 0x00; // item_count writeU16( view, 2, items.length ); let pos = 4; for ( const [ itemId, extents ] of items ) { writeU16( view, pos, itemId ); pos += 2; // data_reference_index = 0 writeU16( view, pos, 0 ); pos += 2; // no base_offset (size=0) // extent_count writeU16( view, pos, extents.length ); pos += 2; for ( const [ offset, length ] of extents ) { writeU32( view, pos, offset ); pos += 4; writeU32( view, pos, length ); pos += 4; } } return buildFullBox( 'iloc', 0, 0, data ); } /** Build an hdlr (Handler) box with handler_type='pict'. */ function buildHdlr(): Uint8Array { // Minimal hdlr: 4 bytes pre_defined + 4 bytes handler_type + 12 bytes reserved + 1 byte name (null) const data = new Uint8Array( 21 ); // handler_type = 'pict' at offset 4 data[ 4 ] = 0x70; // p data[ 5 ] = 0x69; // i data[ 6 ] = 0x63; // c data[ 7 ] = 0x74; // t return buildFullBox( 'hdlr', 0, 0, data ); } /** * Build a minimal single-image HEIC file as an ArrayBuffer. * * The image data is fake (not decodable) but the container structure * is valid for testing the parser. */ function buildSingleImageHeic( { width = 100, height = 80, imageData = new Uint8Array( [ 0xde, 0xad, 0xbe, 0xef ] ), rotation, }: { width?: number; height?: number; imageData?: Uint8Array; rotation?: number; } = {} ): ArrayBuffer { const primaryItemId = 1; // Build property boxes (1-indexed: 1=ispe, 2=hvcC, optionally 3=irot) const ispe = buildIspe( width, height ); const hvcC = buildHvcC(); const propBoxes: Uint8Array[] = [ ispe, hvcC ]; const propIndices = [ 1, 2 ]; if ( rotation !== undefined ) { propBoxes.push( buildIrot( rotation / 90 ) ); propIndices.push( 3 ); } const ipco = buildIpco( ...propBoxes ); const ipma = buildIpma( [ [ primaryItemId, propIndices ] ] ); const iprp = buildIprp( ipco, ipma ); // We need to know where mdat data will be placed. // Build everything except mdat first to calculate the offset. const ftyp = buildBox( 'ftyp', new Uint8Array( [ 0x68, 0x65, 0x69, 0x63 ] ) ); // brand='heic' const pitm = buildPitm( primaryItemId ); // iloc will reference the image data at an absolute file offset. // We'll calculate the actual offset after constructing the meta box. // Use a placeholder first, then fix it up. // Build meta children (without iloc - we'll add it after calculating offset) const hdlr = buildHdlr(); const metaChildrenWithoutIloc = concat( hdlr, pitm, iprp ); // Calculate sizes to determine mdat data offset: // ftyp + meta box header (8 + 4 fullbox) + metaChildren + iloc + mdat header (8) // iloc size depends on items, so build a placeholder iloc to get its size. const placeholderIloc = buildIloc( [ [ primaryItemId, [ [ 0, imageData.length ] ] ], ] ); const metaSize = 8 + 4 + metaChildrenWithoutIloc.length + placeholderIloc.length; const mdatDataOffset = ftyp.length + metaSize + 8; // +8 for mdat box header // Now build the real iloc with the correct offset const iloc = buildIloc( [ [ primaryItemId, [ [ mdatDataOffset, imageData.length ] ] ], ] ); // Build meta box (FullBox) const metaChildren = concat( hdlr, pitm, iloc, iprp ); const meta = buildFullBox( 'meta', 0, 0, metaChildren ); // Build mdat const mdat = buildBox( 'mdat', imageData ); // Assemble full file const file = concat( ftyp, meta, mdat ); return file.buffer; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe( 'heic-parser', () => { describe( 'reverseBits32', () => { it( 'should reverse bits of 0x60000000 to 0x00000006', () => { expect( reverseBits32( 0x60000000 ) ).toBe( 0x00000006 ); } ); it( 'should reverse bits of 0 to 0', () => { expect( reverseBits32( 0 ) ).toBe( 0 ); } ); it( 'should reverse bits of 0xFFFFFFFF to 0xFFFFFFFF', () => { expect( reverseBits32( 0xffffffff ) ).toBe( 0xffffffff ); } ); it( 'should reverse bits of 1 to 0x80000000', () => { expect( reverseBits32( 1 ) ).toBe( 0x80000000 ); } ); it( 'should reverse bits of 0x80000000 to 1', () => { expect( reverseBits32( 0x80000000 ) ).toBe( 1 ); } ); } ); describe( 'parseHeic – single image', () => { it( 'should parse a minimal single-image HEIC', () => { const imageData = new Uint8Array( [ 1, 2, 3, 4, 5, 6 ] ); const buffer = buildSingleImageHeic( { width: 200, height: 150, imageData, } ); const result = parseHeic( buffer ); expect( result.outputWidth ).toBe( 200 ); expect( result.outputHeight ).toBe( 150 ); expect( result.tileWidth ).toBe( 200 ); expect( result.tileHeight ).toBe( 150 ); expect( result.tiles ).toHaveLength( 1 ); expect( result.tiles[ 0 ].x ).toBe( 0 ); expect( result.tiles[ 0 ].y ).toBe( 0 ); expect( result.tiles[ 0 ].data ).toEqual( imageData ); } ); it( 'should build correct codec string for Main Profile L3.1', () => { const buffer = buildSingleImageHeic(); const result = parseHeic( buffer ); // profileIdc=1, compatFlags=0x60000000→reversed=6, // tier=L, level=93, constraints=B0 expect( result.codecString ).toBe( 'hvc1.1.6.L93.B0' ); } ); it( 'should extract the HEVCDecoderConfigurationRecord', () => { const buffer = buildSingleImageHeic(); const result = parseHeic( buffer ); expect( result.description ).toBeInstanceOf( Uint8Array ); expect( result.description.length ).toBe( 23 ); // minimal hvcC record expect( result.description[ 0 ] ).toBe( 1 ); // configurationVersion } ); it( 'should return rotation 0 when no irot box present', () => { const buffer = buildSingleImageHeic(); const result = parseHeic( buffer ); expect( result.rotation ).toBe( 0 ); } ); it( 'should parse 90° CCW rotation from irot box', () => { const buffer = buildSingleImageHeic( { rotation: 90 } ); const result = parseHeic( buffer ); expect( result.rotation ).toBe( 90 ); } ); it( 'should parse 270° CCW rotation from irot box', () => { const buffer = buildSingleImageHeic( { rotation: 270 } ); const result = parseHeic( buffer ); expect( result.rotation ).toBe( 270 ); } ); } ); describe( 'parseHeic – grid/tiled image', () => { /** * Build a grid HEIC file with multiple tiles (like iPhone photos). * * The structure is: ftyp, meta (hdlr, pitm, iinf, iref, iloc, iprp), mdat. * The primary item is type 'grid' referencing tile items of type 'hvc1'. */ function buildGridHeic( { rows = 2, columns = 2, tileWidth = 512, tileHeight = 512, outputWidth = 1024, outputHeight = 1024, tileData = new Uint8Array( [ 0xca, 0xfe ] ), }: { rows?: number; columns?: number; tileWidth?: number; tileHeight?: number; outputWidth?: number; outputHeight?: number; tileData?: Uint8Array; } = {} ): ArrayBuffer { const gridItemId = 1; const tileCount = rows * columns; // Tile item IDs start at 2. const tileItemIds = Array.from( { length: tileCount }, ( _, i ) => i + 2 ); // Grid descriptor: version(1), flags(1), rows_minus_one(1), columns_minus_one(1), // output_width(2), output_height(2) = 8 bytes (small fields). const gridDescriptor = new Uint8Array( 8 ); gridDescriptor[ 0 ] = 0; // version gridDescriptor[ 1 ] = 0; // flags (no large fields) gridDescriptor[ 2 ] = rows - 1; gridDescriptor[ 3 ] = columns - 1; const gdv = new DataView( gridDescriptor.buffer ); gdv.setUint16( 4, outputWidth ); gdv.setUint16( 6, outputHeight ); // Build property boxes (1=hvcC, 2=ispe) const hvcC = buildHvcC(); const ispe = buildIspe( tileWidth, tileHeight ); const ipco = buildIpco( hvcC, ispe ); // ipma: each tile item → [1, 2] (hvcC at index 1, ispe at index 2) const associations: Array< [ number, number[] ] > = tileItemIds.map( ( id ) => [ id, [ 1, 2 ] ] as [ number, number[] ] ); const ipma = buildIpma( associations ); const iprp = buildIprp( ipco, ipma ); // iinf: grid item type 'grid', tile items type 'hvc1'. const iinf = buildIinf( [ [ gridItemId, 'grid' ], ...tileItemIds.map( ( id ) => [ id, 'hvc1' ] as [ number, string ] ), ] ); // iref: dimg reference from grid to tiles. const iref = buildIref( gridItemId, tileItemIds ); // Build everything except iloc and mdat first to calculate offsets. const ftyp = buildBox( 'ftyp', new Uint8Array( [ 0x68, 0x65, 0x69, 0x63 ] ) ); const hdlr = buildHdlr(); const pitm = buildPitm( gridItemId ); // We need iloc for grid item + all tile items. // First, build a placeholder iloc to calculate meta size. const placeholderItems: Array< [ number, Array< [ number, number ] > ] > = [ [ gridItemId, [ [ 0, gridDescriptor.length ] ] ], ...tileItemIds.map( ( id ) => [ id, [ [ 0, tileData.length ] ] ] as [ number, Array< [ number, number ] >, ] ), ]; const placeholderIloc = buildIloc( placeholderItems ); const metaChildren = concat( hdlr, pitm, iinf, iref, placeholderIloc, iprp ); const metaSize = 8 + 4 + metaChildren.length; // box header + fullbox const mdatHeaderSize = 8; const mdatDataStart = ftyp.length + metaSize + mdatHeaderSize; // Grid descriptor is at the start of mdat, tiles follow. const gridOffset = mdatDataStart; let currentOffset = gridOffset + gridDescriptor.length; const tileOffsets: number[] = []; for ( let i = 0; i < tileCount; i++ ) { tileOffsets.push( currentOffset ); currentOffset += tileData.length; } // Build real iloc. const realItems: Array< [ number, Array< [ number, number ] > ] > = [ [ gridItemId, [ [ gridOffset, gridDescriptor.length ] ] ], ...tileItemIds.map( ( id, i ) => [ id, [ [ tileOffsets[ i ], tileData.length ] ], ] as [ number, Array< [ number, number ] > ] ), ]; const iloc = buildIloc( realItems ); const realMetaChildren = concat( hdlr, pitm, iinf, iref, iloc, iprp ); const meta = buildFullBox( 'meta', 0, 0, realMetaChildren ); // Build mdat: grid descriptor + tile data. const mdatPayload = concat( gridDescriptor, ...Array( tileCount ).fill( tileData ) ); const mdat = buildBox( 'mdat', mdatPayload ); return concat( ftyp, meta, mdat ).buffer; } /** Build an iinf box with infe entries. */ function buildIinf( items: Array< [ number, string ] > ): Uint8Array { // Each infe: version=2, flags=0, itemId(u16), protection_index(u16), item_type(4 bytes) const infeBoxes = items.map( ( [ itemId, itemType ] ) => { const infeData = new Uint8Array( 8 ); const idv = new DataView( infeData.buffer ); idv.setUint16( 0, itemId ); idv.setUint16( 2, 0 ); // protection index for ( let k = 0; k < 4; k++ ) { infeData[ 4 + k ] = itemType.charCodeAt( k ); } return buildFullBox( 'infe', 2, 0, infeData ); } ); // iinf: version=0, entry_count(u16), then infe boxes. const countData = new Uint8Array( 2 ); new DataView( countData.buffer ).setUint16( 0, items.length ); return buildFullBox( 'iinf', 0, 0, concat( countData, ...infeBoxes ) ); } /** Build an iref box with a single dimg reference. */ function buildIref( fromId: number, toIds: number[] ): Uint8Array { // dimg reference box: fromId(u16), refCount(u16), toIds(u16 each) const dimgData = new Uint8Array( 4 + toIds.length * 2 ); const dv = new DataView( dimgData.buffer ); dv.setUint16( 0, fromId ); dv.setUint16( 2, toIds.length ); for ( let i = 0; i < toIds.length; i++ ) { dv.setUint16( 4 + i * 2, toIds[ i ] ); } const dimgBox = buildBox( 'dimg', dimgData ); // iref is a FullBox (version=0). return buildFullBox( 'iref', 0, 0, dimgBox ); } it( 'should parse a 2x2 grid HEIC', () => { const tileData = new Uint8Array( [ 0x11, 0x22, 0x33 ] ); const buffer = buildGridHeic( { rows: 2, columns: 2, tileWidth: 512, tileHeight: 512, outputWidth: 1024, outputHeight: 1024, tileData, } ); const result = parseHeic( buffer ); expect( result.outputWidth ).toBe( 1024 ); expect( result.outputHeight ).toBe( 1024 ); expect( result.tileWidth ).toBe( 512 ); expect( result.tileHeight ).toBe( 512 ); expect( result.tiles ).toHaveLength( 4 ); } ); it( 'should place tiles in correct grid positions', () => { const buffer = buildGridHeic( { rows: 2, columns: 2, tileWidth: 256, tileHeight: 256, } ); const result = parseHeic( buffer ); expect( result.tiles[ 0 ].x ).toBe( 0 ); expect( result.tiles[ 0 ].y ).toBe( 0 ); expect( result.tiles[ 1 ].x ).toBe( 256 ); expect( result.tiles[ 1 ].y ).toBe( 0 ); expect( result.tiles[ 2 ].x ).toBe( 0 ); expect( result.tiles[ 2 ].y ).toBe( 256 ); expect( result.tiles[ 3 ].x ).toBe( 256 ); expect( result.tiles[ 3 ].y ).toBe( 256 ); } ); it( 'should extract tile data for each tile', () => { const tileData = new Uint8Array( [ 0xaa, 0xbb ] ); const buffer = buildGridHeic( { tileData } ); const result = parseHeic( buffer ); for ( const tile of result.tiles ) { expect( tile.data ).toEqual( tileData ); } } ); it( 'should extract codec string from grid tiles', () => { const buffer = buildGridHeic(); const result = parseHeic( buffer ); // Same hvcC as single image tests. expect( result.codecString ).toBe( 'hvc1.1.6.L93.B0' ); } ); it( 'should parse a 1x3 grid', () => { const buffer = buildGridHeic( { rows: 1, columns: 3, tileWidth: 100, tileHeight: 300, outputWidth: 300, outputHeight: 300, } ); const result = parseHeic( buffer ); expect( result.tiles ).toHaveLength( 3 ); expect( result.tiles[ 0 ].x ).toBe( 0 ); expect( result.tiles[ 1 ].x ).toBe( 100 ); expect( result.tiles[ 2 ].x ).toBe( 200 ); expect( result.outputWidth ).toBe( 300 ); expect( result.outputHeight ).toBe( 300 ); } ); it( 'should extract the HEVCDecoderConfigurationRecord from grid', () => { const buffer = buildGridHeic(); const result = parseHeic( buffer ); expect( result.description ).toBeInstanceOf( Uint8Array ); expect( result.description.length ).toBe( 23 ); expect( result.description[ 0 ] ).toBe( 1 ); // configurationVersion } ); } ); describe( 'parseHeic – error cases', () => { it( 'should throw for empty buffer', () => { expect( () => parseHeic( new ArrayBuffer( 0 ) ) ).toThrow( 'No meta box found' ); } ); it( 'should throw for buffer without meta box', () => { const ftyp = buildBox( 'ftyp', new Uint8Array( [ 0x68, 0x65, 0x69, 0x63 ] ) ); expect( () => parseHeic( ftyp.buffer ) ).toThrow( 'No meta box found' ); } ); it( 'should throw when required boxes are missing', () => { // meta box with only hdlr (no pitm, iloc, iprp) const hdlr = buildHdlr(); const meta = buildFullBox( 'meta', 0, 0, hdlr ); expect( () => parseHeic( meta.buffer ) ).toThrow( 'Missing required boxes' ); } ); it( 'should throw for missing ipco or ipma inside iprp', () => { // meta with pitm, iloc, iprp but iprp is empty (no ipco/ipma) const hdlr = buildHdlr(); const pitm = buildPitm( 1 ); const iloc = buildIloc( [ [ 1, [ [ 0, 4 ] ] ] ] ); const emptyIprp = buildBox( 'iprp', new Uint8Array( 0 ) ); const meta = buildFullBox( 'meta', 0, 0, concat( hdlr, pitm, iloc, emptyIprp ) ); expect( () => parseHeic( meta.buffer ) ).toThrow( 'Missing ipco or ipma' ); } ); it( 'should throw when primary item has no location data', () => { // iloc with item ID 99, but pitm says primary is 1 const hdlr = buildHdlr(); const pitm = buildPitm( 1 ); const iloc = buildIloc( [ [ 99, [ [ 0, 4 ] ] ] ] ); const hvcC = buildHvcC(); const ispe = buildIspe( 100, 100 ); const ipco = buildIpco( hvcC, ispe ); const ipma = buildIpma( [ [ 1, [ 1, 2 ] ] ] ); const iprp = buildIprp( ipco, ipma ); const meta = buildFullBox( 'meta', 0, 0, concat( hdlr, pitm, iloc, iprp ) ); expect( () => parseHeic( meta.buffer ) ).toThrow( 'No location data for primary item' ); } ); it( 'should throw when primary item has no property associations', () => { const hdlr = buildHdlr(); const pitm = buildPitm( 1 ); const iloc = buildIloc( [ [ 1, [ [ 0, 4 ] ] ] ] ); const hvcC = buildHvcC(); const ispe = buildIspe( 100, 100 ); const ipco = buildIpco( hvcC, ispe ); // ipma associates item 99, not item 1 const ipma = buildIpma( [ [ 99, [ 1, 2 ] ] ] ); const iprp = buildIprp( ipco, ipma ); const meta = buildFullBox( 'meta', 0, 0, concat( hdlr, pitm, iloc, iprp ) ); expect( () => parseHeic( meta.buffer ) ).toThrow( 'No property associations' ); } ); } ); } ); describe( 'parseExifOrientation / getUnappliedExifOrientation', () => { /** * Build a TIFF/EXIF block carrying a single Orientation tag (0x0112). * * Big-endian ('MM') layout: header (8) + IFD0 (count + one 12-byte entry + * next-IFD offset). * * @param orientation EXIF orientation value (1-8). * @param withPrefix Prepend the 4-byte exif_tiff_header_offset (=0). */ function buildExifTiff( orientation: number, withPrefix = true ): Uint8Array { const tiff = new Uint8Array( 8 + 2 + 12 + 4 ); const view = new DataView( tiff.buffer ); // 'MM' big-endian + magic 0x002A + IFD0 offset = 8. tiff[ 0 ] = 0x4d; tiff[ 1 ] = 0x4d; writeU16( view, 2, 0x002a ); writeU32( view, 4, 8 ); // IFD0: one entry. writeU16( view, 8, 1 ); // Entry: tag=0x0112, type=3 (SHORT), count=1, value in first 2 bytes. writeU16( view, 10, 0x0112 ); writeU16( view, 12, 3 ); writeU32( view, 14, 1 ); writeU16( view, 18, orientation ); // next-IFD offset = 0 at byte 24. if ( ! withPrefix ) { return tiff; } const payload = new Uint8Array( 4 + tiff.length ); payload.set( tiff, 4 ); // 4-byte exif_tiff_header_offset = 0. return payload; } /** Build an iinf box declaring item types via infe entries. */ function buildIinf( entries: Array< [ number, string ] > ): Uint8Array { const infeBoxes = entries.map( ( [ itemId, type ] ) => { const data = new Uint8Array( 2 + 2 + 4 + 1 ); const view = new DataView( data.buffer ); writeU16( view, 0, itemId ); writeU16( view, 2, 0 ); // protection index for ( let i = 0; i < 4; i++ ) { data[ 4 + i ] = type.charCodeAt( i ); } // trailing null item name at byte 8. return buildFullBox( 'infe', 2, 0, data ); } ); const count = new Uint8Array( 2 ); writeU16( new DataView( count.buffer ), 0, entries.length ); return buildFullBox( 'iinf', 0, 0, concat( count, ...infeBoxes ) ); } /** * Build a minimal ISOBMFF (AVIF-shaped) file with an `Exif` item. * * Options: `orientation` (1-8) to embed, `withIrot` to add a native irot * transform property, and `withPrefix` to include the EXIF offset prefix. */ function buildAvifWithExif( { orientation = 1, withIrot = false, withPrefix = true, }: { orientation?: number; withIrot?: boolean; withPrefix?: boolean; } = {} ): ArrayBuffer { const primaryItemId = 1; const exifItemId = 2; const exifPayload = buildExifTiff( orientation, withPrefix ); const ftyp = buildBox( 'ftyp', new Uint8Array( [ 0x61, 0x76, 0x69, 0x66 ] ) // brand='avif' ); const hdlr = buildHdlr(); const pitm = buildPitm( primaryItemId ); const iinf = buildIinf( [ [ primaryItemId, 'av01' ], [ exifItemId, 'Exif' ], ] ); // Optional native transform property associated with the primary item. let iprp = new Uint8Array( 0 ); if ( withIrot ) { const ipco = buildIpco( buildIspe( 10, 10 ), buildIrot( 1 ) ); const ipma = buildIpma( [ [ primaryItemId, [ 1, 2 ] ] ] ); iprp = buildIprp( ipco, ipma ); } // Compute the absolute file offset of the EXIF payload inside mdat. // Build a placeholder iloc to size the meta box, then fix the offset. const buildMeta = ( exifOffset: number ) => { const iloc = buildIloc( [ [ exifItemId, [ [ exifOffset, exifPayload.length ] ] ], ] ); return buildFullBox( 'meta', 0, 0, concat( hdlr, pitm, iinf, iloc, iprp ) ); }; const metaSize = buildMeta( 0 ).length; const exifOffset = ftyp.length + metaSize + 8; // +8 for mdat header. const meta = buildMeta( exifOffset ); const mdat = buildBox( 'mdat', exifPayload ); return concat( ftyp, meta, mdat ).buffer; } describe( 'parseExifOrientation', () => { it.each( [ 1, 2, 3, 4, 5, 6, 7, 8 ] )( 'reads EXIF orientation %i from the Exif item', ( orientation ) => { const buffer = buildAvifWithExif( { orientation } ); expect( parseExifOrientation( buffer ) ).toBe( orientation ); } ); it( 'reads orientation when the EXIF payload omits the offset prefix', () => { const buffer = buildAvifWithExif( { orientation: 6, withPrefix: false, } ); expect( parseExifOrientation( buffer ) ).toBe( 6 ); } ); it( 'returns 1 when there is no Exif item', () => { // A plain HEIC has no Exif item. const buffer = buildSingleImageHeic(); expect( parseExifOrientation( buffer ) ).toBe( 1 ); } ); it( 'returns 1 for a non-ISOBMFF buffer', () => { const buffer = new Uint8Array( [ 0xff, 0xd8, 0xff, 0xe0 ] ).buffer; expect( parseExifOrientation( buffer ) ).toBe( 1 ); } ); } ); describe( 'getUnappliedExifOrientation', () => { it( 'returns the EXIF orientation when no native transform is present', () => { const buffer = buildAvifWithExif( { orientation: 6 } ); expect( getUnappliedExifOrientation( buffer ) ).toBe( 6 ); } ); it( 'returns 1 when a native irot transform is present (libheif handles it)', () => { const buffer = buildAvifWithExif( { orientation: 6, withIrot: true, } ); expect( getUnappliedExifOrientation( buffer ) ).toBe( 1 ); } ); } ); /* * Real encoder output (ImageMagick/libheif + exiftool), complementing the * hand-built boxes above — mirroring the rotated-image fixture set used * by WordPress core's PHPUnit media tests. Generated with: * * magick -size 32x32 xc:'#cc0000' -size 32x32 xc:'#0000cc' +append base.png * magick base.png -quality 60 exif-base.avif # also base.heic * exiftool -n -Orientation=6 -o exif-rotated-90cw.avif exif-base.avif * exiftool -n -Orientation=8 -o exif-rotated-90ccw.avif exif-base.avif * exiftool -n -Orientation=3 -o exif-upside-down.avif exif-base.avif * exiftool -n -Orientation=6 -o exif-rotated-90cw.heic base.heic * heif-enc base.png --rotate-cw 90 -A -o irot-rotated-90.avif * exiftool -n -Orientation=6 -o irot-and-exif.avif irot-rotated-90.avif */ describe( 'real encoder fixtures', () => { const loadFixture = ( file: string ): ArrayBuffer => { const contents = readFileSync( join( __dirname, 'fixtures', file ) ); return contents.buffer.slice( contents.byteOffset, contents.byteOffset + contents.byteLength ) as ArrayBuffer; }; it.each( [ [ 'exif-rotated-90cw.avif', 6 ], [ 'exif-rotated-90ccw.avif', 8 ], [ 'exif-upside-down.avif', 3 ], [ 'exif-rotated-90cw.heic', 6 ], ] )( 'reads the unapplied EXIF orientation from %s', ( file, orientation ) => { expect( getUnappliedExifOrientation( loadFixture( file ) ) ).toBe( orientation ); } ); it( 'returns 1 when an EXIF tag coexists with a native irot transform', () => { // Some encoders write both; libheif already applies the `irot` // on decode, so rotating again from EXIF would double-rotate. expect( getUnappliedExifOrientation( loadFixture( 'irot-and-exif.avif' ) ) ).toBe( 1 ); } ); it( 'returns 1 for a file without an orientation tag', () => { expect( getUnappliedExifOrientation( loadFixture( 'exif-base.avif' ) ) ).toBe( 1 ); } ); } ); } ); /* eslint-enable no-bitwise, jsdoc/require-param */