diff --git a/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js index 36023209e6bd..1f7260a7b91f 100644 --- a/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js +++ b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js @@ -8,8 +8,15 @@ * @format */ -import {getCatalogImages, getImageSet} from '../assetCatalogIOS'; +import { + getCatalogImages, + getImageSet, + isAnimatedPNG, + isCatalogAsset, +} from '../assetCatalogIOS'; +const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); jest.dontMock('../assetCatalogIOS'); @@ -102,3 +109,138 @@ describe('getImageSet', () => { ]); }); }); + +describe('isAnimatedPNG', () => { + const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + // CRCs are not validated by the detector, so chunks carry a zero CRC. + function chunk(type: string, data: Buffer = Buffer.alloc(0)): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + return Buffer.concat([ + length, + Buffer.from(type, 'latin1'), + data, + Buffer.alloc(4), + ]); + } + + const IHDR = chunk('IHDR', Buffer.alloc(13)); + const IDAT = chunk('IDAT', Buffer.from([0x78, 0x9c, 0x03, 0x00])); + const IEND = chunk('IEND'); + const acTL = chunk('acTL', Buffer.alloc(8)); + + let dir: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'assetCatalogIOS-')); + }); + + afterAll(() => { + fs.rmSync(dir, {recursive: true, force: true}); + }); + + function writeFile(name: string, ...parts: Array): string { + const file = path.join(dir, name); + fs.writeFileSync(file, Buffer.concat(parts)); + return file; + } + + test('detects an acTL chunk before the first IDAT', () => { + const file = writeFile( + 'animated.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isAnimatedPNG(file)).toBe(true); + }); + + test('skips over chunk data when walking the headers', () => { + const large = chunk('iCCP', Buffer.alloc(70000)); + const file = writeFile( + 'icc.png', + PNG_SIGNATURE, + IHDR, + large, + acTL, + IDAT, + IEND, + ); + expect(isAnimatedPNG(file)).toBe(true); + }); + + test('is false for a still PNG', () => { + const file = writeFile('still.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a file without the PNG signature', () => { + const file = writeFile('not.png', Buffer.from('not a png')); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a file truncated inside the chunk headers', () => { + const file = writeFile('truncated.png', PNG_SIGNATURE, IHDR.subarray(0, 6)); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a missing file', () => { + expect(isAnimatedPNG(path.join(dir, 'missing.png'))).toBe(false); + }); + + describe('isCatalogAsset', () => { + function makeFileAsset(type: string, files: Array) { + return { + ...makeAsset(files.map((_, idx) => idx + 1)), + files, + type, + }; + } + + test('keeps a still PNG in the catalog', () => { + const file = writeFile('logo.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + expect(isCatalogAsset(makeFileAsset('png', [file]))).toBe(true); + }); + + test('keeps an animated PNG out of the catalog', () => { + const file = writeFile( + 'spinner.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isCatalogAsset(makeFileAsset('png', [file]))).toBe(false); + }); + + test('keeps the whole asset out when only one scale is animated', () => { + const still = writeFile('mixed.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + const animated = writeFile( + 'mixed@2x.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isCatalogAsset(makeFileAsset('png', [still, animated]))).toBe( + false, + ); + }); + + test('does not read non-PNG assets', () => { + expect( + isCatalogAsset(makeFileAsset('jpg', [path.join(dir, 'missing.jpg')])), + ).toBe(true); + expect( + isCatalogAsset(makeFileAsset('gif', [path.join(dir, 'missing.gif')])), + ).toBe(false); + }); + }); +}); diff --git a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js index ff03a6ef130e..98c57f64847b 100644 --- a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js +++ b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js @@ -86,7 +86,59 @@ export function getImageSet(catalogDir: string, asset: AssetData): ImageSet { } export function isCatalogAsset(asset: AssetData): boolean { - return asset.type === 'png' || asset.type === 'jpg' || asset.type === 'jpeg'; + if (asset.type === 'png') { + // The runtime resolves an asset by name, so all of its scales must be + // either in the catalog or loose. + return !asset.files.some(isAnimatedPNG); + } + return asset.type === 'jpg' || asset.type === 'jpeg'; +} + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** + * Whether the file is an animated PNG: a PNG with an `acTL` chunk before its + * first `IDAT` (https://www.w3.org/TR/png-3/#acTL-chunk). actool compiles + * such a file into a single still frame, so it must stay a loose file. + * + * Only the chunk headers are read. An unreadable or malformed file is reported + * as not animated so that it stays on the catalog path, where actool reports it. + */ +export function isAnimatedPNG(filePath: string): boolean { + let fd = null; + try { + fd = fs.openSync(filePath, 'r'); + const signature = Buffer.alloc(PNG_SIGNATURE.length); + if ( + fs.readSync(fd, signature, 0, signature.length, 0) !== signature.length || + !signature.equals(PNG_SIGNATURE) + ) { + return false; + } + const chunkHeader = Buffer.alloc(8); + let position = signature.length; + while (fs.readSync(fd, chunkHeader, 0, 8, position) === 8) { + const dataLength = chunkHeader.readUInt32BE(0); + const type = chunkHeader.toString('latin1', 4, 8); + if (type === 'acTL') { + return true; + } + if (type === 'IDAT' || type === 'IEND') { + return false; + } + // Skip the chunk data and its 4-byte CRC. + position += 8 + dataLength + 4; + } + return false; + } catch { + return false; + } finally { + if (fd != null) { + fs.closeSync(fd); + } + } } export function writeImageSet(imageSet: ImageSet): void { diff --git a/packages/react-native/React/Base/RCTUtils.mm b/packages/react-native/React/Base/RCTUtils.mm index 21ddfb6ff1f2..67d2beb71ad6 100644 --- a/packages/react-native/React/Base/RCTUtils.mm +++ b/packages/react-native/React/Base/RCTUtils.mm @@ -1117,32 +1117,26 @@ static BOOL RCTUseAssetCatalog(void) UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL) { + NSString *catalogName = nil; if (RCTUseAssetCatalog()) { - NSString *catalogName = RCTAssetCatalogNameForURL(imageURL); + catalogName = RCTAssetCatalogNameForURL(imageURL); if (catalogName != nil) { // The app opted into the asset catalog and this is a packager asset, so it - // was compiled into RNAssets.bundle at build time. Trust the catalog and - // return directly, keeping the common path a single lookup with no - // filesystem fallback. Non-catalog assets (nil name) fall through below. + // was compiled into RNAssets.bundle at build time. Return directly on a + // hit, keeping the common path a single lookup. Non-catalog assets (nil + // name) and catalog misses fall through to the loose-file lookup below: + // the CLI copies animated PNGs as loose files, since actool would compile + // them into a single still frame. RNAssets.bundle itself is absent when + // every image asset was copied loose, so a missing bundle is a miss too. NSBundle *assetCatalogBundle = RCTAssetCatalogBundle(); - if (assetCatalogBundle == nil) { - // Passing a nil bundle to imageNamed:inBundle: would silently search the - // main bundle instead, potentially resolving an unrelated app image. - RCTLogError( - @"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be " - "bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)", - imageURL); - return nil; - } - UIImage *image = [UIImage imageNamed:catalogName inBundle:assetCatalogBundle compatibleWithTraitCollection:nil]; - if (image == nil) { - RCTLogError( - @"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, " - "so image assets must be compiled into the app's RNAssets.bundle at build time.", - catalogName, - imageURL); + // Passing a nil bundle to imageNamed:inBundle: would silently search the + // main bundle instead, potentially resolving an unrelated app image. + if (assetCatalogBundle != nil) { + UIImage *image = [UIImage imageNamed:catalogName inBundle:assetCatalogBundle compatibleWithTraitCollection:nil]; + if (image != nil) { + return image; + } } - return image; } } @@ -1194,6 +1188,21 @@ static BOOL RCTUseAssetCatalog(void) } } } + + if (image == nil && catalogName != nil) { + if (RCTAssetCatalogBundle() == nil) { + RCTLogError( + @"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be " + "bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)", + imageURL); + } else { + RCTLogError( + @"Image \"%@\" (%@) was not found in the asset catalog or as a file. RCTUseAssetCatalog is enabled, " + "so image assets must be compiled into the app's RNAssets.bundle at build time.", + catalogName, + imageURL); + } + } return image; } diff --git a/packages/rn-tester/js/assets/animated.png b/packages/rn-tester/js/assets/animated.png new file mode 100644 index 000000000000..e51b9fc03c79 Binary files /dev/null and b/packages/rn-tester/js/assets/animated.png differ diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index eb0b2d3875b4..f94fb99050b8 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -1115,6 +1115,10 @@ const styles = StyleSheet.create({ flex: 1, height: 200, }, + apng: { + width: 64, + height: 64, + }, base64: { flex: 1, height: 50, @@ -1965,6 +1969,22 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'Animated PNG', + name: 'animated-png', + description: + 'A bundled APNG. Image shows its first frame, but with RCTUseAssetCatalog on the file ships loose, with all its frames, instead of being flattened into the asset catalog.', + render: function (): React.Node { + return ( + + ); + }, + platform: 'ios', + }, { title: 'Base64 image', name: 'base64-image',