blob: 1445eb84715afea39b3ec53c161be924492582bb [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {checkTransparency} from './transparency.js';
export const SUPPORTED_FILE_TYPES = [
'image/bmp',
'image/heic',
'image/heif',
'image/jpeg',
'image/png',
'image/tiff',
'image/webp',
'image/x-icon',
];
type MimeType = typeof SUPPORTED_FILE_TYPES[number];
const MIME_TYPE_TO_EXTENSION_MAP: ReadonlyMap<MimeType, string> =
new Map<MimeType, string>([
['image/png', '.png'],
['image/webp', '.webp'],
['image/bmp', '.bmp'],
['image/heif', '.heif'],
['image/jpeg', '.jpg'],
['image/tiff', '.tif'],
['image/heic', '.heic'],
['image/x-icon', '.ico'],
]);
const MAX_LONGEST_EDGE_PIXELS = 1000;
const TRANSPARENCY_FILL_BG_COLOR = '#ffffff';
const JPEG_QUALITY = 0.4;
const DEFAULT_MIME_TYPE = 'image/jpeg' as MimeType;
export interface ProcessedFile {
processedFile: File;
imageWidth?: number;
imageHeight?: number;
}
// Takes an image file and does the following:
// 1. Downscales the image so that the longest edge is maxLongestEdgePixels
// (default 1000 px). Aspect ratio is preserved.
// 2. Transcodes the image to jpeg, filling in the background with white if
// the original image had transparency.
// The processed image is returned along with the new image dimensions.
export async function processFile(
file: File, maxLongestEdgePixels: number = MAX_LONGEST_EDGE_PIXELS):
Promise<ProcessedFile> {
const image = await readImageFile(file);
if (!image) {
return {processedFile: file};
}
const originalImageWidth = image.width;
const originalImageHeight = image.height;
const hasTransparency = checkTransparency(await file.arrayBuffer());
const blobInfo = await processImage(
image, DEFAULT_MIME_TYPE, hasTransparency, maxLongestEdgePixels);
if (!blobInfo || !blobInfo.blob) {
return {
processedFile: file,
imageWidth: originalImageWidth,
imageHeight: originalImageHeight,
};
}
const processedImage = blobInfo.blob;
let imageWidth = blobInfo.imageWidth;
let imageHeight = blobInfo.imageHeight;
const lastDot = file.name.lastIndexOf('.');
const fileName = `${lastDot > 0 ? file.name.slice(0, lastDot) : file.name}${
MIME_TYPE_TO_EXTENSION_MAP.get(processedImage.type)}`;
let processedFile = new File(
[processedImage], fileName,
{lastModified: Date.now(), type: processedImage.type});
if (processedFile.size > file.size) {
processedFile = file;
imageWidth = originalImageWidth;
imageHeight = originalImageHeight;
}
return {processedFile, imageWidth, imageHeight};
}
async function readImageFile(file: File): Promise<HTMLImageElement|null> {
const dataUrl = await readAsDataURL(file);
if (!dataUrl || dataUrl instanceof ArrayBuffer) {
return null;
}
return createImageFromDataUrl(dataUrl);
}
function processImage(
image: HTMLImageElement, mimeType: MimeType, hasTransparency: boolean,
maxLongestEdgePixels?: number) {
const [width, height] = getDimensions(image, maxLongestEdgePixels);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', {alpha: false, desynchronized: true});
if (!context) {
return null;
}
if (hasTransparency) {
fillBackground(
context, canvas.width, canvas.height, TRANSPARENCY_FILL_BG_COLOR);
}
context.drawImage(image, /*dx=*/ 0, /*dy=*/ 0, width, height);
return toBlob(canvas, mimeType, JPEG_QUALITY, width, height);
}
function getDimensions(image: HTMLImageElement, maxLongestEdgePixels?: number):
[width: number, height: number] {
let width = image.width;
let height = image.height;
if (maxLongestEdgePixels &&
(width > maxLongestEdgePixels || height > maxLongestEdgePixels)) {
const downscaleRatio =
Math.min(maxLongestEdgePixels / width, maxLongestEdgePixels / height);
width *= downscaleRatio;
height *= downscaleRatio;
}
return [Math.floor(width), Math.floor(height)];
}
function fillBackground(
context: CanvasRenderingContext2D, canvasWidth: number,
canvasHeight: number, backgroundColor: string) {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
function toBlob(
canvas: HTMLCanvasElement, type: MimeType, encodingCompressionRatio: number,
imageWidth: number, imageHeight: number) {
return new Promise<
{blob: Blob | null, imageWidth: number, imageHeight: number}>(
(resolve) => {
canvas.toBlob((result) => {
if (result) {
resolve({blob: result, imageWidth, imageHeight});
} else {
resolve({blob: null, imageWidth, imageHeight});
}
}, type, encodingCompressionRatio);
});
}
function readAsDataURL(file: File) {
const fileReader = new FileReader();
const promise = new Promise<string|ArrayBuffer|null>((resolve) => {
fileReader.onloadend = () => {
resolve(fileReader.result);
};
fileReader.onerror = () => {
// Failed to read file.
resolve(null);
};
});
fileReader.readAsDataURL(file);
return promise;
}
function createImageFromDataUrl(dataUrl: string) {
const image = new Image();
const promise = new Promise<HTMLImageElement|null>((resolve) => {
image.onload = () => {
resolve(image);
};
image.onerror = () => {
// Failed to load image from data url.
resolve(null);
};
});
image.src = dataUrl;
return promise;
}