booletwa
Version:
Generate TWA projects from a Web Manifest
142 lines (128 loc) • 5.25 kB
text/typescript
/*
* Copyright 2020 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as path from 'path';
import * as fs from 'fs';
import * as Jimp from 'jimp';
import {fetchUtils} from './FetchUtils';
import Color = require('color');
import {promisify} from 'util';
import {isSvgSupported, svg2img} from './wrappers/svg2img';
// fs.promises is marked as experimental. This should be replaced when stable.
const fsMkDir = promisify(fs.mkdir);
export interface IconDefinition {
dest: string;
size: number;
}
interface Icon {
url: string;
data: Jimp;
}
export class ImageHelper {
private async saveIcon(
icon: Icon, size: number, fileName: string, backgroundColor?: Color): Promise<void> {
const image = await Jimp.read(icon.data);
// Jimp creates artifacts when upscaling images that have an alpha channel. This happens
// because even when a pixel is fully transparent the RGB part of the color still gets blended
// with other pixels. To avoid this, we apply the RGB part of `backgroundColor` to transparent
// pixels so they are blended with that color instead and match a background.
// See https://github.com/GoogleChromeLabs/bubblewrap/issues/488#issuecomment-806560923.
if (backgroundColor && image.hasAlpha()) {
// The replacement colour is the same as the background colour, but fully transparent.
const replacementColor = ((backgroundColor.rgbNumber() << 8) & 0xFFFFFF00) >>> 0;
// Iterate over the pixels, check for fully transparent ones and replace with
// replacementColor.
for (let y = 0; y < image.getHeight(); y++) {
for (let x = 0; x < image.getWidth(); x++) {
const color = image.getPixelColour(x, y);
// Apply replacement color if the pixel is fully transparent.
if ((color & 0xFF) === 0) {
image.setPixelColour(replacementColor, x, y);
}
}
}
}
image.resize(size, size);
await image.writeAsync(fileName);
}
/**
* Generate a file for the given icon inside targetDir.
*
* @param {Object} icon Object containing the original URL and the icon image data.
* @param {string} targetDir Path to the directory the image will be saved in.
* @param {Object} iconDef Icon definitions specifying the size the icon should be exported as.
*/
async generateIcon(icon: Icon, targetDir: string, iconDef: IconDefinition,
backgroundColor?: Color): Promise<void> {
const destFile = path.join(targetDir, iconDef.dest);
await fsMkDir(path.dirname(destFile), {recursive: true});
return await this.saveIcon(icon, iconDef.size, destFile, backgroundColor);
}
/**
* Set the color of a monochrome icon to be the theme color.
*
* @param {Object} icon Original monochrome icon to use.
* @param {Color} themeColor Color to use for the icon.
* @returns New image data.
*/
async monochromeFilter(icon: Icon, themeColor: Color): Promise<Icon> {
const image = await Jimp.read(icon.data);
image.color([
// Set all pixels to black/0
{apply: 'red', params: [-255]},
{apply: 'green', params: [-255]},
{apply: 'blue', params: [-255]},
// Add to channels using theme color
{apply: 'red', params: [themeColor.red()]},
{apply: 'green', params: [themeColor.green()]},
{apply: 'blue', params: [themeColor.blue()]},
]);
return {url: icon.url, data: image};
}
/**
* Fetches an Icon.
*
* @param {string} iconUrl the URL to fetch the icon from.
* @returns an Object containing the original URL and the icon image data.
*/
async fetchIcon(iconUrl: string): Promise<Icon> {
const response = await fetchUtils.fetch(iconUrl);
if (response.status !== 200) {
throw new Error(
`Failed to download icon ${iconUrl}. Responded with status ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
throw new Error(`Received icon "${iconUrl}" with invalid Content-Type.` +
` Responded with Content-Type "${contentType}"`);
}
let body = await response.arrayBuffer();
if (contentType.startsWith('image/svg')) {
if (!isSvgSupported()) {
throw new Error(`This installation doesn't support image/svg. Please, ensure the optional
"svg2img" library is installed or use an alternative image format.`);
}
try {
body = await svg2img(iconUrl);
} catch (error) {
throw new Error(`Problem reading ${iconUrl}: ${error.message}`);
}
}
return {
url: iconUrl,
data: await Jimp.read(Buffer.from(body)),
};
}
}