84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
|
|
import sharp from "sharp";
|
||
|
|
|
||
|
|
export type WorkbookSourceImage = {
|
||
|
|
bytes: Uint8Array;
|
||
|
|
contentType: string;
|
||
|
|
width?: number | null;
|
||
|
|
height?: number | null;
|
||
|
|
description: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type WorkbookImageNormalizationOptions = {
|
||
|
|
maxDimension?: number;
|
||
|
|
outputFormat?: "png" | "jpeg";
|
||
|
|
jpegQuality?: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
const NORMALIZABLE_IMAGE = /^image\/(?:jpe?g|png|webp|gif|tiff?|avif|heic|heif)$/i;
|
||
|
|
|
||
|
|
function hasImageSignature(bytes: Uint8Array) {
|
||
|
|
if (bytes.length < 4) return false;
|
||
|
|
if (bytes[0] === 0xff && bytes[1] === 0xd8) return true;
|
||
|
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return true;
|
||
|
|
if (
|
||
|
|
bytes.length >= 12 &&
|
||
|
|
String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" &&
|
||
|
|
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
|
||
|
|
) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
|
||
|
|
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Excel viewers disagree on whether JPEG EXIF orientation should be applied.
|
||
|
|
* Bake that orientation into the pixels before the image enters the workbook.
|
||
|
|
* Callers may also resize and encode large source images as JPEG to keep the
|
||
|
|
* generated workbook within the upload limit.
|
||
|
|
*/
|
||
|
|
export async function normalizeWorkbookImage(
|
||
|
|
image: WorkbookSourceImage,
|
||
|
|
options: WorkbookImageNormalizationOptions = {},
|
||
|
|
): Promise<WorkbookSourceImage> {
|
||
|
|
if (!NORMALIZABLE_IMAGE.test(image.contentType) && !hasImageSignature(image.bytes)) {
|
||
|
|
return image;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
let normalized = sharp(image.bytes, { animated: false }).rotate();
|
||
|
|
if (options.maxDimension && options.maxDimension > 0) {
|
||
|
|
normalized = normalized.resize({
|
||
|
|
width: Math.floor(options.maxDimension),
|
||
|
|
height: Math.floor(options.maxDimension),
|
||
|
|
fit: "inside",
|
||
|
|
withoutEnlargement: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
normalized =
|
||
|
|
options.outputFormat === "jpeg"
|
||
|
|
? normalized
|
||
|
|
.flatten({ background: "#ffffff" })
|
||
|
|
.jpeg({
|
||
|
|
quality: Math.min(95, Math.max(50, options.jpegQuality ?? 82)),
|
||
|
|
mozjpeg: true,
|
||
|
|
})
|
||
|
|
: normalized.png({ compressionLevel: 6 });
|
||
|
|
const { data, info } = await normalized.toBuffer({ resolveWithObject: true });
|
||
|
|
return {
|
||
|
|
...image,
|
||
|
|
bytes: new Uint8Array(data),
|
||
|
|
contentType:
|
||
|
|
options.outputFormat === "jpeg" ? "image/jpeg" : "image/png",
|
||
|
|
width: info.width,
|
||
|
|
height: info.height,
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
return image;
|
||
|
|
}
|
||
|
|
}
|