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 new file mode 100644 index 000000000000..36023209e6bd --- /dev/null +++ b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {getCatalogImages, getImageSet} from '../assetCatalogIOS'; + +const path = require('node:path'); + +jest.dontMock('../assetCatalogIOS'); + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function makeAsset(scales: Array) { + return { + __packager_asset: true, + fileSystemLocation: '/project/img', + httpServerLocation: '/assets/img', + width: 100, + height: 100, + scales, + files: scales.map( + scale => `/project/img/logo${scale === 1 ? '' : `@${scale}x`}.png`, + ), + hash: 'hash', + name: 'logo', + type: 'png', + }; +} + +describe('getCatalogImages', () => { + test('pairs each standard scale with its file', () => { + const asset = makeAsset([1, 2, 3]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 1, src: '/project/img/logo.png'}, + {scale: 2, src: '/project/img/logo@2x.png'}, + {scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('skips non-standard scales without shifting file pairing', () => { + // Regression test: filtering scales without filtering files used to + // associate the 2x rendition with the 1.5x file. + const asset = makeAsset([1, 1.5, 2, 3]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 1, src: '/project/img/logo.png'}, + {scale: 2, src: '/project/img/logo@2x.png'}, + {scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('maps a fractional-only asset into the nearest valid slot', () => { + const asset = makeAsset([1.5]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 2, src: '/project/img/logo@1.5x.png'}, + ]); + }); + + test('clamps scales larger than 3x to the 3x slot', () => { + const asset = makeAsset([4]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 3, src: '/project/img/logo@4x.png'}, + ]); + }); + + test('uses the largest fractional variant when several exist', () => { + const asset = makeAsset([1.5, 2.5]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 3, src: '/project/img/logo@2.5x.png'}, + ]); + }); +}); + +describe('getImageSet', () => { + test('builds imageset path and per-scale file entries', () => { + const asset = makeAsset([1, 2, 3]); + const imageSet = getImageSet('/catalog', asset); + expect(imageSet.basePath).toBe(path.join('/catalog', 'img_logo.imageset')); + expect(imageSet.files).toEqual([ + {name: 'img_logo.png', scale: 1, src: '/project/img/logo.png'}, + {name: 'img_logo@2x.png', scale: 2, src: '/project/img/logo@2x.png'}, + {name: 'img_logo@3x.png', scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('names the fallback rendition after its catalog slot', () => { + const asset = makeAsset([1.5]); + const imageSet = getImageSet('/catalog', asset); + expect(imageSet.files).toEqual([ + {name: 'img_logo@2x.png', scale: 2, src: '/project/img/logo@1.5x.png'}, + ]); + }); +}); diff --git a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js index 7ecf219a5372..657110f5b8fa 100644 --- a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js +++ b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js @@ -28,20 +28,57 @@ type ImageSet = { files: {name: string, src: string, scale: number}[], }; -export function getImageSet( - catalogDir: string, - asset: AssetData, - scales: ReadonlyArray, -): ImageSet { +// Scales an iOS asset catalog imageset can hold. actool silently drops +// renditions at any other scale (e.g. a fractional @1.5x). +const CATALOG_SCALES = [1, 2, 3]; + +type CatalogImage = {scale: number, src: string}; + +/** + * Pairs each catalog-valid scale of the asset with its source file. + * + * If the asset has no valid scale at all (e.g. only a fractional @1.5x + * variant), its closest variant is mapped into the nearest valid slot, + * mirroring the "closest larger" fallback filterPlatformAssetScales applies + * to loose files, so the imageset always contains at least one rendition + * actool will compile. + */ +export function getCatalogImages(asset: AssetData): Array { + const images: Array = []; + asset.scales.forEach((scale, idx) => { + if (CATALOG_SCALES.includes(scale)) { + images.push({scale, src: asset.files[idx]}); + } + }); + if (images.length === 0 && asset.scales.length > 0) { + const maxCatalogScale = CATALOG_SCALES[CATALOG_SCALES.length - 1]; + let idx = asset.scales.findIndex(scale => scale > maxCatalogScale); + if (idx === -1) { + idx = asset.scales.length - 1; + } + const scale = Math.min( + maxCatalogScale, + Math.max(1, Math.ceil(asset.scales[idx])), + ); + console.warn( + `warning: Asset "${asset.name}" has no 1x/2x/3x variant; ` + + `using its @${asset.scales[idx]}x file as the ${scale}x catalog rendition.`, + ); + images.push({scale, src: asset.files[idx]}); + } + return images; +} + +export function getImageSet(catalogDir: string, asset: AssetData): ImageSet { const fileName = getAndroidResourceIdentifier(asset); return { basePath: path.join(catalogDir, `${fileName}.imageset`), - files: scales.map((scale, idx) => { + files: getCatalogImages(asset).map(({scale, src}) => { const suffix = scale === 1 ? '' : `@${scale}x`; return { name: `${fileName + suffix}.${asset.type}`, scale, - src: asset.files[idx], + src, }; }), }; diff --git a/packages/community-cli-plugin/src/commands/bundle/saveAssets.js b/packages/community-cli-plugin/src/commands/bundle/saveAssets.js index 666b7671e974..c8487aef66c8 100644 --- a/packages/community-cli-plugin/src/commands/bundle/saveAssets.js +++ b/packages/community-cli-plugin/src/commands/bundle/saveAssets.js @@ -74,12 +74,7 @@ async function saveAssets( cleanAssetCatalog(catalogDir); for (const asset of assets) { if (isCatalogAsset(asset)) { - const imageSet = getImageSet( - catalogDir, - asset, - filterPlatformAssetScales(platform, asset.scales), - ); - writeImageSet(imageSet); + writeImageSet(getImageSet(catalogDir, asset)); } else { addAssetToCopy(asset); }