diff --git a/src/constants/document.constants.ts b/src/constants/document.constants.ts new file mode 100644 index 0000000..401a89a --- /dev/null +++ b/src/constants/document.constants.ts @@ -0,0 +1,3 @@ +export const supportedDocumentFormats = ["PNG", "JPG", "JPEG", "GIF", "PDF"] as const; + +export type DocumentFormats = (typeof supportedDocumentFormats)[number]; diff --git a/src/constants/index.ts b/src/constants/index.ts index c5c07f5..b1525e1 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,7 +1,15 @@ import * as AppIDConstants from "./app-id.constants"; import * as CurrencyConstants from "./currency.constants"; +import * as DocumentConstants from "./document.constants"; import * as LocalStorageConstants from "./localstorage.constants"; import * as URLConstants from "./url.constants"; import * as ValidationConstants from "./validation.constants"; -export { AppIDConstants, CurrencyConstants, LocalStorageConstants, URLConstants, ValidationConstants }; +export { + AppIDConstants, + CurrencyConstants, + DocumentConstants, + LocalStorageConstants, + URLConstants, + ValidationConstants, +}; diff --git a/src/utils/__test__/image.utils.spec.ts b/src/utils/__test__/image.utils.spec.ts new file mode 100644 index 0000000..86e5fb3 --- /dev/null +++ b/src/utils/__test__/image.utils.spec.ts @@ -0,0 +1,97 @@ +import { convertToBase64, isSupportedImageFormat } from "../image.utils"; +import { describe, test, expect } from "vitest"; + +describe("convertToBase64()", () => { + const mockImageFile = new File(["image-data-contents"], "image1.jpg", { type: "image/jpeg" }); + + test("should convert a File to base64", async () => { + const base64Image = await convertToBase64(mockImageFile); + + expect(base64Image).toEqual( + expect.objectContaining({ + src: expect.any(String), + filename: "image1.jpg", + }), + ); + + expect(base64Image.src).toMatch(/^data:image\/jpeg;base64,/); + }); + + test("should handle empty files", async () => { + const mockFile = new File([], "empty.txt", { type: "text/plain" }); + const base64Image = await convertToBase64(mockFile); + + expect(base64Image).toEqual( + expect.objectContaining({ + src: "data:text/plain;base64,", + filename: "empty.txt", + }), + ); + }); + + test("should handle files with special characters in their names", async () => { + const mockFile = new File(["file contents"], "special&chars.jpg", { type: "image/jpeg" }); + const base64Image = await convertToBase64(mockFile); + + expect(base64Image).toEqual( + expect.objectContaining({ + src: expect.any(String), + filename: "special&chars.jpg", + }), + ); + + expect(base64Image.src).toMatch(/^data:image\/jpeg;base64,/); + }); + + test("should handle non-image files", async () => { + const mockFile = new File(["file contents"], "document.pdf", { type: "application/pdf" }); + const base64Image = await convertToBase64(mockFile); + + expect(base64Image).toEqual( + expect.objectContaining({ + src: expect.any(String), + filename: "document.pdf", + }), + ); + + expect(base64Image.src).toMatch(/^data:application\/pdf;base64,/); + }); + + test("should handle files with no type information", async () => { + const mockFile = new File(["file contents"], "unknown", { type: "" }); + const base64Image = await convertToBase64(mockFile); + + expect(base64Image).toEqual( + expect.objectContaining({ + src: expect.any(String), + filename: "unknown", + }), + ); + }); +}); + +describe("isSupportedImageFormat()", () => { + test("should return true for supported image formats", () => { + expect(isSupportedImageFormat("image1.png")).toBe(true); + expect(isSupportedImageFormat("image1.jpg")).toBe(true); + expect(isSupportedImageFormat("image1.jpeg")).toBe(true); + expect(isSupportedImageFormat("image1.gif")).toBe(true); + expect(isSupportedImageFormat("document.pdf")).toBe(true); + expect(isSupportedImageFormat("mixed.CaSe.JPeG")).toBe(true); + }); + + test("should return false for unsupported image formats", () => { + expect(isSupportedImageFormat("file.txt")).toBe(false); + expect(isSupportedImageFormat("document.docx")).toBe(false); + expect(isSupportedImageFormat("data.xml")).toBe(false); + expect(isSupportedImageFormat("fake-image.jpg.txt")).toBe(false); + expect(isSupportedImageFormat("compressed.jpg.rar")).toBe(false); + }); + + test("should handle edge cases", () => { + // @ts-expect-error - test case to simulate passing null + expect(isSupportedImageFormat(null)).toBe(false); + // @ts-expect-error - test case to simulate passing undefined + expect(isSupportedImageFormat(undefined)).toBe(false); + }); +}); diff --git a/src/utils/image.utils.ts b/src/utils/image.utils.ts new file mode 100644 index 0000000..9cca228 --- /dev/null +++ b/src/utils/image.utils.ts @@ -0,0 +1,200 @@ +import { DocumentConstants } from "../constants"; + +const DEFAULT_IMAGE_WIDTH = 2560; +const DEFAULT_IMAGE_QUALITY = 0.9; +const WORD_SIZE = 4; + +interface IExtendedBlob extends Blob { + lastModifiedDate?: number; + name?: string; +} + +type TCompressImageOption = { + maxWidth?: number; + quality?: number; +}; + +type TBase64Image = { + filename: string; + src: string; +}; + +type TCompressImage = TBase64Image & { + options?: TCompressImageOption; +}; + +export type TFileObject = { + filename?: File["name"]; + buffer: FileReader["result"]; + fileSize: File["size"]; +}; + +/** + * Compress an image and return it as a Blob. + * @param {TCompressImage} params - The parameters for image compression. + * @param {string} params.src - The source image URL or data URI. + * @param {string} params.filename - The desired filename for the compressed image. + * @param {Object} [params.options] - Options for image compression. + * @param {number} [params.options.maxWidth=DEFAULT_IMAGE_WIDTH] - The maximum width for the compressed image. + * @param {number} [params.options.quality=DEFAULT_IMAGE_QUALITY] - The image quality (0 to 1) for compression. + * @returns {Promise} A Promise that resolves with the compressed image as a Blob. + */ +export const compressImage = ({ src, filename, options }: TCompressImage): Promise => { + const { maxWidth = DEFAULT_IMAGE_WIDTH, quality = DEFAULT_IMAGE_QUALITY } = options || {}; + + return new Promise((resolve, reject) => { + const image = new Image(); + image.src = src; + image.onload = () => { + const canvas = document.createElement("canvas"); + const canvas_context = canvas.getContext("2d"); + if (!canvas_context || !(canvas_context instanceof CanvasRenderingContext2D)) { + return reject(new Error("Failed to get 2D context")); + } + + if (image.naturalWidth > maxWidth) { + const width = DEFAULT_IMAGE_WIDTH; + const scaleFactor = width / image.naturalWidth; + canvas.width = width; + canvas.height = image.naturalHeight * scaleFactor; + } else { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + } + + canvas_context.fillStyle = "transparent"; + canvas_context.fillRect(0, 0, canvas.width, canvas.height); + canvas_context.save(); + canvas_context.drawImage(image, 0, 0, canvas.width, canvas.height); + + canvas.toBlob( + (blob) => { + if (!blob) return; + const modified_filename = filename.replace(/\.[^/.]+$/, ".jpg"); + const file: IExtendedBlob = new Blob([blob], { type: "image/jpeg" }); + file.lastModifiedDate = Date.now(); + file.name = modified_filename; + resolve(file); + }, + "image/jpeg", + quality, + ); + }; + }); +}; + +/** + * Convert a File to a Base64 encoded image representation. + * @param {File} file - The File object to convert to Base64. + * @returns {Promise} A Promise that resolves with an object containing the Base64 image data and the filename. + */ +export const convertToBase64 = (file: File): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onloadend = () => { + resolve({ + src: reader.result?.toString() || "", + filename: file.name, + }); + }; + }); +}; + +/** + * Check if a given filename has a supported image format extension. + * + * @param {string} filename - The filename to check for a supported image format. + * @returns {boolean} True if the filename has a supported image format extension, false otherwise. + */ +export const isSupportedImageFormat = (filename: string) => { + if (!filename) return false; + + return DocumentConstants.supportedDocumentFormats.some((documentFormat) => + filename.toUpperCase().endsWith(documentFormat), + ); +}; + +/** + * Convert image to base64 and compress an image file if it is a supported image format. + * + * @param {File} file - The File object to compress. + * @returns {Promise} A Promise that resolves with the compressed image as a Blob. + */ +export const compressImageFile = (file: File) => { + return new Promise((resolve) => { + if (isSupportedImageFormat(file.name)) { + convertToBase64(file).then((img) => { + compressImage(img).then(resolve); + }); + } else { + resolve(file); + } + }); +}; + +/** + * Get Uint8Array from number + * + * @param {num} number - The number to convert to Uint8Array. + * @returns {Uint8Array} Uint8Array + */ +export function numToUint8Array(num: number, arraySize = WORD_SIZE) { + const typedArray = new Uint8Array(arraySize); + const dv = new DataView(typedArray.buffer); + dv.setUint32(0, num); + return typedArray; +} + +/** + * Turn binary into array of chunks + * + * @param {binary} Uint8Array - Uint8Array to be chunked. + * @returns {Uint8Array[]} Array of Uint8Array chunks + */ +export const generateChunks = (binary: Uint8Array, { chunkSize = 16384 /* 16KB */ }) => { + const chunks = []; + for (let i = 0; i < binary.length; i++) { + const item = binary[i]; + if (i % chunkSize === 0) { + chunks.push([item]); + } else { + chunks[chunks.length - 1].push(item); + } + } + return chunks.map((b) => new Uint8Array(b)).concat(new Uint8Array([])); +}; + +/** + * Read a file and return it as modified object with a buffer of the file contents. + * @param {IExtendedBlob} file - The file to read. + * @returns {Promise} A Promise that resolves with the file as a TFileObject. + * + */ +export const readFile = (file: IExtendedBlob) => { + const fr = new FileReader(); + return new Promise< + | TFileObject + | { + message: string; + } + >((resolve) => { + fr.onload = () => { + const fileMetadata = { + filename: file.name, + buffer: fr.result, + fileSize: file.size, + }; + resolve(fileMetadata); + }; + + fr.onerror = () => { + resolve({ + message: `Unable to read file ${file.name}`, + }); + }; + + // Reading file + fr.readAsArrayBuffer(file); + }); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index ea35a2a..25ee606 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,8 +1,9 @@ import * as FormatUtils from "./format.utils"; +import * as ImageUtils from "./image.utils"; import * as LocalStorageUtils from "./localstorage.utils"; import * as ObjectUtils from "./object.utils"; import * as PromiseUtils from "./promise.utils"; import * as URLUtils from "./url.utils"; import * as WebSocketUtils from "./websocket.utils"; -export { FormatUtils, LocalStorageUtils, ObjectUtils, PromiseUtils, URLUtils, WebSocketUtils }; +export { ImageUtils, FormatUtils, LocalStorageUtils, ObjectUtils, PromiseUtils, URLUtils, WebSocketUtils };