Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<Buffer>): 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<string>) {
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 30 additions & 21 deletions packages/react-native/React/Base/RCTUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
}

Expand Down
Binary file added packages/rn-tester/js/assets/animated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions packages/rn-tester/js/examples/Image/ImageExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,10 @@ const styles = StyleSheet.create({
flex: 1,
height: 200,
},
apng: {
width: 64,
height: 64,
},
base64: {
flex: 1,
height: 50,
Expand Down Expand Up @@ -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 (
<Image
testID="image-animated-png"
style={styles.apng}
source={require('../../assets/animated.png')}
/>
);
},
platform: 'ios',
},
{
title: 'Base64 image',
name: 'base64-image',
Expand Down
Loading