Skip to content

Commit

Permalink
feat(ios): automatically remove alpha channel for iOS icons
Browse files Browse the repository at this point in the history
resolves #94
  • Loading branch information
imhoffd committed Apr 15, 2020
1 parent 4ca2da7 commit f4f7d20
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 56 deletions.
10 changes: 3 additions & 7 deletions src/__tests__/image.ts
Expand Up @@ -9,7 +9,7 @@ describe('cordova-res', () => {

let image: typeof import('../image');
let fsMock: { [key: string]: jest.Mock };
let resourcesMock: { RASTER_RESOURCE_VALIDATORS: { [key: string]: jest.Mock } };
let resourcesMock: { validateResource: jest.Mock };

beforeEach(async () => {
jest.resetModules();
Expand All @@ -19,11 +19,7 @@ describe('cordova-res', () => {
writeFile: jest.fn(),
};

resourcesMock = {
RASTER_RESOURCE_VALIDATORS: {
[ResourceType.ICON]: jest.fn(),
},
};
resourcesMock = { validateResource: jest.fn() };

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

});
Expand Down
2 changes: 1 addition & 1 deletion src/cordova/config.ts
Expand Up @@ -79,7 +79,7 @@ export function runConfig(configPath: string, doc: et.ElementTree, resources: re
const orientation = orientationPreference || 'default';

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

const platforms = groupImages(resources);
Expand Down
39 changes: 24 additions & 15 deletions src/image.ts
Expand Up @@ -5,10 +5,12 @@ import util from 'util';

import { ResolveSourceImageError, ValidationError } from './error';
import { Platform } from './platform';
import { Format, RASTER_RESOURCE_VALIDATORS, ResolvedImageSource, ResourceType, SourceType } from './resources';
import { Format, ResolvedImageSource, ResourceType, SourceType, validateResource } from './resources';

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

export type SharpTransformation = (pipeline: Sharp) => Sharp;

/**
* Check an array of source files, returning the first viable image.
*/
Expand All @@ -35,7 +37,7 @@ export async function resolveSourceImage(platform: Platform, type: ResourceType,

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

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

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

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

const pipeline = applyFormatConversion(image.format, transformImage(image, src));
const transformations = [createImageResizer(image), createImageConverter(image.format)];
const pipeline = applyTransformations(src, transformations);

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

export function transformImage(image: ImageSchema, src: Sharp): Sharp {
return src.resize(image.width, image.height);
export function applyTransformations(src: Sharp, transformations: readonly SharpTransformation[]): Sharp {
return transformations.reduce((pipeline, transformation) => transformation(pipeline), src);
}

export function applyFormatConversion(format: Format, src: Sharp): Sharp {
switch (format) {
case Format.PNG:
return src.png();
case Format.JPEG:
return src.jpeg();
}
export function createImageResizer(image: ImageSchema): SharpTransformation {
return src => src.resize(image.width, image.height);
}

return src;
export function createImageConverter(format: Format): SharpTransformation {
return src => {
switch (format) {
case Format.PNG:
return src.png();
case Format.JPEG:
return src.jpeg();
}

return src;
};
}
2 changes: 1 addition & 1 deletion src/index.ts
Expand Up @@ -63,7 +63,7 @@ async function CordovaRes(options: CordovaRes.Options = {}): Promise<Result> {
debug('File missing/not writable: %O', configPath);

if (errstream) {
errstream.write(`WARN: No config.xml file in directory. Skipping config.\n`);
errstream.write(`WARN:\tNo config.xml file in directory. Skipping config.\n`);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/native.ts
Expand Up @@ -193,7 +193,7 @@ export async function copyToNativeProject(platform: Platform, nativeProject: Nat
logstream.write(util.format(`Copied %s resource items to %s`, ANDROID_ICONS.length + ANDROID_SPLASHES.length, prettyPlatform(platform)) + '\n');
} else {
if (errstream) {
errstream.write(util.format('WARN: Copying to native projects is not supported for %s', prettyPlatform(platform)) + '\n');
errstream.write(util.format('WARN:\tCopying to native projects is not supported for %s', prettyPlatform(platform)) + '\n');
}
}
}
65 changes: 47 additions & 18 deletions src/platform.ts
@@ -1,6 +1,7 @@
import { ensureDir } from '@ionic/utils-fs';
import Debug from 'debug';
import pathlib from 'path';
import { Sharp } from 'sharp';

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

export interface SimpleResourceOptions {
export type TransformFunction = (image: ImageSchema, pipeline: Sharp) => Sharp;

export interface ResourceOptions<S> {
/**
* Represents the sources to use for this resource.
*
* Usually, this is a file path or {@link ImageSource}. In the case of
* Android Adaptive Icons, this may be a {@link ColorSource}.
*/
readonly sources: readonly S[];

/**
* Paths to source images to use for this resource.
* Additional image transformations to apply.
*/
sources: (string | ImageSource)[];
readonly transform?: TransformFunction;
}

export type SimpleResourceOptions = ResourceOptions<string | ImageSource>;

export interface SimpleResourceResult {
resources: GeneratedResource[];
source: ResolvedSource;
Expand All @@ -56,13 +69,7 @@ export interface AdaptiveIconResourceOptions {
/**
* Options for the background portion of adaptive icons.
*/
background: {

/**
* Paths to source images or colors to use for this resource.
*/
sources: (string | ImageSource | ColorSource)[];
};
background: ResourceOptions<string | ImageSource | ColorSource>;
}

export interface RunPlatformOptions {
Expand Down Expand Up @@ -157,7 +164,7 @@ export async function generateSimpleResources(type: ResourceType.ICON | Resource
const resources = await Promise.all(config.resources.map(
async (resource): Promise<GeneratedResource> => ({
...resource,
...await generateImageResource(type, platform, resourcesDirectory, config, source.image, resource, errstream),
...await generateImageResource(type, platform, resourcesDirectory, config, source.image, resource, getResourceTransformFunction(platform, type, options), errstream),
})
));

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

export function getResourceTransformFunction(platform: Platform, type: ResourceType, { transform = (image, pipeline) => pipeline }: Readonly<SimpleResourceOptions>): TransformFunction {
const transforms = [transform];

if (platform === Platform.IOS && type === ResourceType.ICON) {
// Automatically remove the alpha channel for iOS icons. If alpha channels
// exist in iOS icons when uploaded to the App Store, the app may be
// rejected referencing ITMS-90717.
//
// @see https://github.com/ionic-team/cordova-res/issues/94
transforms.push((image, pipeline) => pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }));
}

return combineTransformFunctions(transforms);
}

export function combineTransformFunctions(transformations: readonly TransformFunction[]): TransformFunction {
return transformations.reduce((acc, transformation) => (image, pipeline) => transformation(image, acc(image, pipeline)));
}

/**
* Attempt to generate Adaptive Icons for any platform.
*
Expand Down Expand Up @@ -200,10 +226,10 @@ export async function generateAdaptiveIconResources(resourcesDirectory: string,
debug('Building %s resources', ResourceType.ADAPTIVE_ICON);

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

const resources = await consolidateAdaptiveIconResources(foregroundResources, backgroundResources);
Expand Down Expand Up @@ -245,16 +271,16 @@ export async function consolidateAdaptiveIconResources(foregrounds: readonly Gen
/**
* Generate the foreground of Adaptive Icons.
*/
export async function generateAdaptiveIconResourcesPortion(resourcesDirectory: string, type: ResourceKey.FOREGROUND | ResourceKey.BACKGROUND, sources: (string | ImageSource)[], errstream?: NodeJS.WritableStream): Promise<SimpleResourceResult> {
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> {
const source = await resolveSourceImage(Platform.ANDROID, ResourceType.ADAPTIVE_ICON, sources.map(s => imageSourceToPath(s)), errstream);

return {
resources: await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, type, source, errstream),
resources: await generateAdaptiveIconResourcesPortionFromImageSource(resourcesDirectory, type, source, transform, errstream),
source,
};
}

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

const config = getResourcesConfig(Platform.ANDROID, ResourceType.ADAPTIVE_ICON);
Expand All @@ -268,14 +294,15 @@ export async function generateAdaptiveIconResourcesPortionFromImageSource(resour
config,
source.image,
{ ...resource, src: resource[type] },
transform,
errstream
),
})));

return resources;
}

export async function generateImageResource(type: ResourceType, platform: Platform, resourcesDirectory: string, config: ResourcesTypeConfig<ResourceKeyValues, ResourceKey>, image: ImageSourceData, schema: ResourceKeyValues & ImageSchema, errstream?: NodeJS.WritableStream): Promise<GeneratedResource> {
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> {
const { pipeline, metadata } = image;
const { src, format, width, height } = schema;
const { nodeName, nodeAttributes, indexAttribute, includedResources } = config.configXml;
Expand All @@ -285,7 +312,9 @@ export async function generateImageResource(type: ResourceType, platform: Platfo
const dest = pathlib.join(resourcesDirectory, src);

await ensureDir(pathlib.dirname(dest));
await generateImage({ src: dest, format, width, height }, pipeline.clone(), metadata, errstream);

const generatedImage: ImageSchema = { src: dest, format, width, height };
await generateImage(generatedImage, transform(generatedImage, pipeline.clone()), metadata, errstream);

return {
type,
Expand Down
57 changes: 44 additions & 13 deletions src/resources.ts
@@ -1,7 +1,8 @@
import { Metadata, Sharp } from 'sharp';
import util from 'util';

import { BadInputError, ValidationError, ValidationErrorCode } from './error';
import { Platform } from './platform';
import { Platform, prettyPlatform } from './platform';

export const DEFAULT_RESOURCES_DIRECTORY = 'resources';

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

export type ResourceValidator = (source: string, pipeline: Sharp) => Promise<Metadata>;

export function isResourceFormat(format: any): format is Format {
return RESOURCE_FORMATS.includes(format);
}
Expand All @@ -92,11 +91,14 @@ export function isRasterResourceFormat(format: any): format is Format {
return RESOURCE_RASTER_FORMATS.includes(format);
}

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

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

if (!format || !isRasterResourceFormat(format)) {
throw new ValidationError(`The format for source image of type "${type}" must be one of: (${RESOURCE_RASTER_FORMATS.join(', ')}) (image format is "${format}").`, {
Expand All @@ -119,17 +121,46 @@ async function rasterResourceValidator(type: ResourceType, source: string, pipel
requiredHeight,
});
}

return metadata;
}

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

export const RASTER_RESOURCE_VALIDATORS: { readonly [T in ResourceType]: ResourceValidator; } = {
[ResourceType.ADAPTIVE_ICON]: async (source, pipeline) => rasterResourceValidator(ResourceType.ADAPTIVE_ICON, source, pipeline, [432, 432]),
[ResourceType.ICON]: async (source, pipeline) => rasterResourceValidator(ResourceType.ICON, source, pipeline, [1024, 1024]),
[ResourceType.SPLASH]: async (source, pipeline) => rasterResourceValidator(ResourceType.SPLASH, source, pipeline, [2732, 2732]),
};
export function getRasterResourceSchema(platform: Platform, type: ResourceType): RasterResourceSchema {
switch (type) {
case ResourceType.ADAPTIVE_ICON:
return { width: 432, height: 432 };
case ResourceType.ICON:
return { width: 1024, height: 1024 };
case ResourceType.SPLASH:
return { width: 2732, height: 2732 };
}
}

export async function validateResource(platform: Platform, type: ResourceType, source: string, pipeline: Sharp, errstream?: NodeJS.WritableStream): Promise<Metadata> {
const metadata = await pipeline.metadata();

const schema = getRasterResourceSchema(platform, type);
await validateRasterResource(platform, type, source, metadata, schema);

if (errstream) {
if (platform === Platform.IOS && type === ResourceType.ICON) {
if (metadata.hasAlpha) {
// @see https://github.com/ionic-team/cordova-res/issues/94
errstream.write(util.format(
(
'WARN:\tSource icon %s contains alpha channel, generated icons for %s will not.\n\n' +
'\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' +
'\t[1]: https://developer.apple.com/design/human-interface-guidelines/ios/icons-and-images/app-icon/\n'
),
source,
prettyPlatform(platform)
) + '\n');
}
}
}

return metadata;
}

export const enum Format {
NONE = 'none',
Expand Down

0 comments on commit f4f7d20

Please sign in to comment.