Skip to content

Commit f4f7d20

Browse files
committed
feat(ios): automatically remove alpha channel for iOS icons
resolves #94
1 parent 4ca2da7 commit f4f7d20

File tree

7 files changed

+121
-56
lines changed

7 files changed

+121
-56
lines changed

src/__tests__/image.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('cordova-res', () => {
99

1010
let image: typeof import('../image');
1111
let fsMock: { [key: string]: jest.Mock };
12-
let resourcesMock: { RASTER_RESOURCE_VALIDATORS: { [key: string]: jest.Mock } };
12+
let resourcesMock: { validateResource: jest.Mock };
1313

1414
beforeEach(async () => {
1515
jest.resetModules();
@@ -19,11 +19,7 @@ describe('cordova-res', () => {
1919
writeFile: jest.fn(),
2020
};
2121

22-
resourcesMock = {
23-
RASTER_RESOURCE_VALIDATORS: {
24-
[ResourceType.ICON]: jest.fn(),
25-
},
26-
};
22+
resourcesMock = { validateResource: jest.fn() };
2723

2824
jest.mock('@ionic/utils-fs', () => fsMock);
2925
jest.mock('../resources', () => resourcesMock);
@@ -48,7 +44,7 @@ describe('cordova-res', () => {
4844
const { src } = await image.resolveSourceImage(Platform.ANDROID, ResourceType.ICON, ['foo.png', 'bar.png']);
4945
expect(src).toEqual('bar.png');
5046
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
51-
expect(resourcesMock.RASTER_RESOURCE_VALIDATORS[ResourceType.ICON]).toHaveBeenCalledTimes(1);
47+
expect(resourcesMock.validateResource).toHaveBeenCalledTimes(1);
5248
});
5349

5450
});

src/cordova/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function runConfig(configPath: string, doc: et.ElementTree, resources: re
7979
const orientation = orientationPreference || 'default';
8080

8181
if (orientation !== 'default' && errstream) {
82-
errstream.write(util.format(`WARN: Orientation preference set to '%s'. Only configuring %s resources.`, orientation, orientation) + '\n');
82+
errstream.write(util.format(`WARN:\tOrientation preference set to '%s'. Only configuring %s resources.`, orientation, orientation) + '\n');
8383
}
8484

8585
const platforms = groupImages(resources);

src/image.ts

+24-15
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import util from 'util';
55

66
import { ResolveSourceImageError, ValidationError } from './error';
77
import { Platform } from './platform';
8-
import { Format, RASTER_RESOURCE_VALIDATORS, ResolvedImageSource, ResourceType, SourceType } from './resources';
8+
import { Format, ResolvedImageSource, ResourceType, SourceType, validateResource } from './resources';
99

1010
const debug = Debug('cordova-res:image');
1111

12+
export type SharpTransformation = (pipeline: Sharp) => Sharp;
13+
1214
/**
1315
* Check an array of source files, returning the first viable image.
1416
*/
@@ -35,7 +37,7 @@ export async function resolveSourceImage(platform: Platform, type: ResourceType,
3537

3638
export async function readSourceImage(platform: Platform, type: ResourceType, src: string, errstream?: NodeJS.WritableStream): Promise<ResolvedImageSource> {
3739
const image = sharp(await readFile(src));
38-
const metadata = await RASTER_RESOURCE_VALIDATORS[type](src, image);
40+
const metadata = await validateResource(platform, type, src, image, errstream);
3941

4042
debug('Source image for %s: %O', type, metadata);
4143

@@ -53,7 +55,7 @@ export function debugSourceImage(src: string, error: NodeJS.ErrnoException, errs
5355
debug('Source file missing: %s', src);
5456
} else {
5557
if (errstream) {
56-
errstream.write(util.format('WARN: Error with source file %s: %s', src, error) + '\n');
58+
errstream.write(util.format('WARN:\tError with source file %s: %s', src, error) + '\n');
5759
} else {
5860
debug('Error with source file %s: %O', src, error);
5961
}
@@ -77,26 +79,33 @@ export async function generateImage(image: ImageSchema, src: Sharp, metadata: Me
7779

7880
if (errstream) {
7981
if (metadata.format !== image.format) {
80-
errstream.write(util.format(`WARN: Must perform conversion from %s to png.`, metadata.format) + '\n');
82+
errstream.write(util.format(`WARN:\tMust perform conversion from %s to png.`, metadata.format) + '\n');
8183
}
8284
}
8385

84-
const pipeline = applyFormatConversion(image.format, transformImage(image, src));
86+
const transformations = [createImageResizer(image), createImageConverter(image.format)];
87+
const pipeline = applyTransformations(src, transformations);
8588

8689
await writeFile(image.src, await pipeline.toBuffer());
8790
}
8891

89-
export function transformImage(image: ImageSchema, src: Sharp): Sharp {
90-
return src.resize(image.width, image.height);
92+
export function applyTransformations(src: Sharp, transformations: readonly SharpTransformation[]): Sharp {
93+
return transformations.reduce((pipeline, transformation) => transformation(pipeline), src);
9194
}
9295

93-
export function applyFormatConversion(format: Format, src: Sharp): Sharp {
94-
switch (format) {
95-
case Format.PNG:
96-
return src.png();
97-
case Format.JPEG:
98-
return src.jpeg();
99-
}
96+
export function createImageResizer(image: ImageSchema): SharpTransformation {
97+
return src => src.resize(image.width, image.height);
98+
}
10099

101-
return src;
100+
export function createImageConverter(format: Format): SharpTransformation {
101+
return src => {
102+
switch (format) {
103+
case Format.PNG:
104+
return src.png();
105+
case Format.JPEG:
106+
return src.jpeg();
107+
}
108+
109+
return src;
110+
};
102111
}

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async function CordovaRes(options: CordovaRes.Options = {}): Promise<Result> {
6363
debug('File missing/not writable: %O', configPath);
6464

6565
if (errstream) {
66-
errstream.write(`WARN: No config.xml file in directory. Skipping config.\n`);
66+
errstream.write(`WARN:\tNo config.xml file in directory. Skipping config.\n`);
6767
}
6868
}
6969
}

src/native.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export async function copyToNativeProject(platform: Platform, nativeProject: Nat
193193
logstream.write(util.format(`Copied %s resource items to %s`, ANDROID_ICONS.length + ANDROID_SPLASHES.length, prettyPlatform(platform)) + '\n');
194194
} else {
195195
if (errstream) {
196-
errstream.write(util.format('WARN: Copying to native projects is not supported for %s', prettyPlatform(platform)) + '\n');
196+
errstream.write(util.format('WARN:\tCopying to native projects is not supported for %s', prettyPlatform(platform)) + '\n');
197197
}
198198
}
199199
}

src/platform.ts

+47-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ensureDir } from '@ionic/utils-fs';
22
import Debug from 'debug';
33
import pathlib from 'path';
4+
import { Sharp } from 'sharp';
45

56
import { BadInputError, ResolveSourceImageError } from './error';
67
import { ImageSchema, debugSourceImage, generateImage, readSourceImage, resolveSourceImage } from './image';
@@ -27,13 +28,25 @@ export interface GeneratedResource extends ResourceKeyValues {
2728
};
2829
}
2930

30-
export interface SimpleResourceOptions {
31+
export type TransformFunction = (image: ImageSchema, pipeline: Sharp) => Sharp;
32+
33+
export interface ResourceOptions<S> {
34+
/**
35+
* Represents the sources to use for this resource.
36+
*
37+
* Usually, this is a file path or {@link ImageSource}. In the case of
38+
* Android Adaptive Icons, this may be a {@link ColorSource}.
39+
*/
40+
readonly sources: readonly S[];
41+
3142
/**
32-
* Paths to source images to use for this resource.
43+
* Additional image transformations to apply.
3344
*/
34-
sources: (string | ImageSource)[];
45+
readonly transform?: TransformFunction;
3546
}
3647

48+
export type SimpleResourceOptions = ResourceOptions<string | ImageSource>;
49+
3750
export interface SimpleResourceResult {
3851
resources: GeneratedResource[];
3952
source: ResolvedSource;
@@ -56,13 +69,7 @@ export interface AdaptiveIconResourceOptions {
5669
/**
5770
* Options for the background portion of adaptive icons.
5871
*/
59-
background: {
60-
61-
/**
62-
* Paths to source images or colors to use for this resource.
63-
*/
64-
sources: (string | ImageSource | ColorSource)[];
65-
};
72+
background: ResourceOptions<string | ImageSource | ColorSource>;
6673
}
6774

6875
export interface RunPlatformOptions {
@@ -157,7 +164,7 @@ export async function generateSimpleResources(type: ResourceType.ICON | Resource
157164
const resources = await Promise.all(config.resources.map(
158165
async (resource): Promise<GeneratedResource> => ({
159166
...resource,
160-
...await generateImageResource(type, platform, resourcesDirectory, config, source.image, resource, errstream),
167+
...await generateImageResource(type, platform, resourcesDirectory, config, source.image, resource, getResourceTransformFunction(platform, type, options), errstream),
161168
})
162169
));
163170

@@ -167,6 +174,25 @@ export async function generateSimpleResources(type: ResourceType.ICON | Resource
167174
};
168175
}
169176

177+
export function getResourceTransformFunction(platform: Platform, type: ResourceType, { transform = (image, pipeline) => pipeline }: Readonly<SimpleResourceOptions>): TransformFunction {
178+
const transforms = [transform];
179+
180+
if (platform === Platform.IOS && type === ResourceType.ICON) {
181+
// Automatically remove the alpha channel for iOS icons. If alpha channels
182+
// exist in iOS icons when uploaded to the App Store, the app may be
183+
// rejected referencing ITMS-90717.
184+
//
185+
// @see https://github.com/ionic-team/cordova-res/issues/94
186+
transforms.push((image, pipeline) => pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }));
187+
}
188+
189+
return combineTransformFunctions(transforms);
190+
}
191+
192+
export function combineTransformFunctions(transformations: readonly TransformFunction[]): TransformFunction {
193+
return transformations.reduce((acc, transformation) => (image, pipeline) => transformation(image, acc(image, pipeline)));
194+
}
195+
170196
/**
171197
* Attempt to generate Adaptive Icons for any platform.
172198
*
@@ -200,10 +226,10 @@ export async function generateAdaptiveIconResources(resourcesDirectory: string,
200226
debug('Building %s resources', ResourceType.ADAPTIVE_ICON);
201227

202228
const { resources: iconResources = [], source: iconSource } = (await safelyGenerateSimpleResources(ResourceType.ICON, Platform.ANDROID, resourcesDirectory, options.icon, errstream)) || { source: undefined };
203-
const { resources: foregroundResources, source: foregroundSource } = await generateAdaptiveIconResourcesPortion(resourcesDirectory, ResourceKey.FOREGROUND, options.foreground.sources, errstream);
229+
const { resources: foregroundResources, source: foregroundSource } = await generateAdaptiveIconResourcesPortion(resourcesDirectory, ResourceKey.FOREGROUND, options.foreground.sources, options.foreground.transform, errstream);
204230
const resolvedBackgroundSource = await resolveSource(Platform.ANDROID, ResourceType.ADAPTIVE_ICON, ResourceKey.BACKGROUND, options.background.sources, errstream);
205231
const backgroundResources = resolvedBackgroundSource.type === SourceType.RASTER
206-
? await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, ResourceKey.BACKGROUND, resolvedBackgroundSource, errstream)
232+
? await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, ResourceKey.BACKGROUND, resolvedBackgroundSource, options.background.transform, errstream)
207233
: foregroundResources.map(resource => ({ ...resource, src: '@color/background' }));
208234

209235
const resources = await consolidateAdaptiveIconResources(foregroundResources, backgroundResources);
@@ -245,16 +271,16 @@ export async function consolidateAdaptiveIconResources(foregrounds: readonly Gen
245271
/**
246272
* Generate the foreground of Adaptive Icons.
247273
*/
248-
export async function generateAdaptiveIconResourcesPortion(resourcesDirectory: string, type: ResourceKey.FOREGROUND | ResourceKey.BACKGROUND, sources: (string | ImageSource)[], errstream?: NodeJS.WritableStream): Promise<SimpleResourceResult> {
274+
export async function generateAdaptiveIconResourcesPortion(resourcesDirectory: string, type: ResourceKey.FOREGROUND | ResourceKey.BACKGROUND, sources: readonly (string | ImageSource)[], transform: TransformFunction = (image, pipeline) => pipeline, errstream?: NodeJS.WritableStream): Promise<SimpleResourceResult> {
249275
const source = await resolveSourceImage(Platform.ANDROID, ResourceType.ADAPTIVE_ICON, sources.map(s => imageSourceToPath(s)), errstream);
250276

251277
return {
252-
resources: await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, type, source, errstream),
278+
resources: await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, type, source, transform, errstream),
253279
source,
254280
};
255281
}
256282

257-
export async function generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory: string, type: ResourceKey.FOREGROUND | ResourceKey.BACKGROUND, source: ResolvedImageSource, errstream?: NodeJS.WritableStream): Promise<GeneratedResource[]> {
283+
export async function generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory: string, type: ResourceKey.FOREGROUND | ResourceKey.BACKGROUND, source: ResolvedImageSource, transform: TransformFunction = (image, pipeline) => pipeline, errstream?: NodeJS.WritableStream): Promise<GeneratedResource[]> {
258284
debug('Using %O for %s source image for %s', source.image.src, ResourceType.ADAPTIVE_ICON, Platform.ANDROID);
259285

260286
const config = getResourcesConfig(Platform.ANDROID, ResourceType.ADAPTIVE_ICON);
@@ -268,14 +294,15 @@ export async function generateAdaptiveIconResourcesPortionFromImageSource(resour
268294
config,
269295
source.image,
270296
{ ...resource, src: resource[type] },
297+
transform,
271298
errstream
272299
),
273300
})));
274301

275302
return resources;
276303
}
277304

278-
export async function generateImageResource(type: ResourceType, platform: Platform, resourcesDirectory: string, config: ResourcesTypeConfig<ResourceKeyValues, ResourceKey>, image: ImageSourceData, schema: ResourceKeyValues & ImageSchema, errstream?: NodeJS.WritableStream): Promise<GeneratedResource> {
305+
export async function generateImageResource(type: ResourceType, platform: Platform, resourcesDirectory: string, config: ResourcesTypeConfig<ResourceKeyValues, ResourceKey>, image: ImageSourceData, schema: ResourceKeyValues & ImageSchema, transform: TransformFunction = (image, pipeline) => pipeline, errstream?: NodeJS.WritableStream): Promise<GeneratedResource> {
279306
const { pipeline, metadata } = image;
280307
const { src, format, width, height } = schema;
281308
const { nodeName, nodeAttributes, indexAttribute, includedResources } = config.configXml;
@@ -285,7 +312,9 @@ export async function generateImageResource(type: ResourceType, platform: Platfo
285312
const dest = pathlib.join(resourcesDirectory, src);
286313

287314
await ensureDir(pathlib.dirname(dest));
288-
await generateImage({ src: dest, format, width, height }, pipeline.clone(), metadata, errstream);
315+
316+
const generatedImage: ImageSchema = { src: dest, format, width, height };
317+
await generateImage(generatedImage, transform(generatedImage, pipeline.clone()), metadata, errstream);
289318

290319
return {
291320
type,

src/resources.ts

+44-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Metadata, Sharp } from 'sharp';
2+
import util from 'util';
23

34
import { BadInputError, ValidationError, ValidationErrorCode } from './error';
4-
import { Platform } from './platform';
5+
import { Platform, prettyPlatform } from './platform';
56

67
export const DEFAULT_RESOURCES_DIRECTORY = 'resources';
78

@@ -82,8 +83,6 @@ export type ResolvedSource = ResolvedImageSource | ResolvedColorSource;
8283
export const RESOURCE_FORMATS: readonly Format[] = [Format.JPEG, Format.PNG];
8384
export const RESOURCE_RASTER_FORMATS: readonly Format[] = [Format.JPEG, Format.PNG];
8485

85-
export type ResourceValidator = (source: string, pipeline: Sharp) => Promise<Metadata>;
86-
8786
export function isResourceFormat(format: any): format is Format {
8887
return RESOURCE_FORMATS.includes(format);
8988
}
@@ -92,11 +91,14 @@ export function isRasterResourceFormat(format: any): format is Format {
9291
return RESOURCE_RASTER_FORMATS.includes(format);
9392
}
9493

95-
async function rasterResourceValidator(type: ResourceType, source: string, pipeline: Sharp, dimensions: [number, number]): Promise<Metadata> {
96-
const metadata = await pipeline.metadata();
94+
export interface RasterResourceSchema {
95+
width: number;
96+
height: number;
97+
}
9798

99+
export async function validateRasterResource(platform: Platform, type: ResourceType, source: string, metadata: Metadata, schema: RasterResourceSchema): Promise<void> {
98100
const { format, width, height } = metadata;
99-
const [ requiredWidth, requiredHeight ] = dimensions;
101+
const { width: requiredWidth, height: requiredHeight } = schema;
100102

101103
if (!format || !isRasterResourceFormat(format)) {
102104
throw new ValidationError(`The format for source image of type "${type}" must be one of: (${RESOURCE_RASTER_FORMATS.join(', ')}) (image format is "${format}").`, {
@@ -119,17 +121,46 @@ async function rasterResourceValidator(type: ResourceType, source: string, pipel
119121
requiredHeight,
120122
});
121123
}
122-
123-
return metadata;
124124
}
125125

126126
export const COLOR_REGEX = /^\#[A-F0-9]{6}$/;
127127

128-
export const RASTER_RESOURCE_VALIDATORS: { readonly [T in ResourceType]: ResourceValidator; } = {
129-
[ResourceType.ADAPTIVE_ICON]: async (source, pipeline) => rasterResourceValidator(ResourceType.ADAPTIVE_ICON, source, pipeline, [432, 432]),
130-
[ResourceType.ICON]: async (source, pipeline) => rasterResourceValidator(ResourceType.ICON, source, pipeline, [1024, 1024]),
131-
[ResourceType.SPLASH]: async (source, pipeline) => rasterResourceValidator(ResourceType.SPLASH, source, pipeline, [2732, 2732]),
132-
};
128+
export function getRasterResourceSchema(platform: Platform, type: ResourceType): RasterResourceSchema {
129+
switch (type) {
130+
case ResourceType.ADAPTIVE_ICON:
131+
return { width: 432, height: 432 };
132+
case ResourceType.ICON:
133+
return { width: 1024, height: 1024 };
134+
case ResourceType.SPLASH:
135+
return { width: 2732, height: 2732 };
136+
}
137+
}
138+
139+
export async function validateResource(platform: Platform, type: ResourceType, source: string, pipeline: Sharp, errstream?: NodeJS.WritableStream): Promise<Metadata> {
140+
const metadata = await pipeline.metadata();
141+
142+
const schema = getRasterResourceSchema(platform, type);
143+
await validateRasterResource(platform, type, source, metadata, schema);
144+
145+
if (errstream) {
146+
if (platform === Platform.IOS && type === ResourceType.ICON) {
147+
if (metadata.hasAlpha) {
148+
// @see https://github.com/ionic-team/cordova-res/issues/94
149+
errstream.write(util.format(
150+
(
151+
'WARN:\tSource icon %s contains alpha channel, generated icons for %s will not.\n\n' +
152+
'\tApple recommends avoiding transparency. See the App Icon Human Interface Guidelines[1] for details. Any transparency in your icon will be filled in with white.\n\n' +
153+
'\t[1]: https://developer.apple.com/design/human-interface-guidelines/ios/icons-and-images/app-icon/\n'
154+
),
155+
source,
156+
prettyPlatform(platform)
157+
) + '\n');
158+
}
159+
}
160+
}
161+
162+
return metadata;
163+
}
133164

134165
export const enum Format {
135166
NONE = 'none',

0 commit comments

Comments
 (0)