From 4eed896d87fadde221cbc800644ce447a849b7ad Mon Sep 17 00:00:00 2001 From: heorhi-deriv Date: Fri, 29 Mar 2024 17:36:13 +0300 Subject: [PATCH 1/3] feat: :sparkles: add file utils --- src/utils/__test__/file.utils.spec.ts | 97 +++++++++++++ src/utils/file.utils.ts | 194 ++++++++++++++++++++++++++ src/utils/index.ts | 3 +- 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/utils/__test__/file.utils.spec.ts create mode 100644 src/utils/file.utils.ts diff --git a/src/utils/__test__/file.utils.spec.ts b/src/utils/__test__/file.utils.spec.ts new file mode 100644 index 0000000..8a6f1b0 --- /dev/null +++ b/src/utils/__test__/file.utils.spec.ts @@ -0,0 +1,97 @@ +import { convertToBase64, isSupportedImageFormat } from "../file.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/file.utils.ts b/src/utils/file.utils.ts new file mode 100644 index 0000000..d214a00 --- /dev/null +++ b/src/utils/file.utils.ts @@ -0,0 +1,194 @@ +const DEFAULT_IMAGE_WIDTH = 2560; +const DEFAULT_IMAGE_QUALITY = 0.9; +const WORD_SIZE = 4; + +declare global { + interface 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 = 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) => /\.(png|jpg|jpeg|gif|pdf)$/gi.test(filename ?? ""); + +/** + * 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.type)) { + 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) { + const typedArray = new Uint8Array(WORD_SIZE); + 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 {Blob} file - The file to read. + * @returns {Promise} A Promise that resolves with the file as a TFileObject. + * + */ +export const readFile = (file: Blob) => { + 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..a8205a7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,8 +1,9 @@ import * as FormatUtils from "./format.utils"; +import * as FileUtils from "./file.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 { FileUtils, FormatUtils, LocalStorageUtils, ObjectUtils, PromiseUtils, URLUtils, WebSocketUtils }; From dbaa9312f46ae4228ad70c8fb9e7c544c355625d Mon Sep 17 00:00:00 2001 From: heorhi-deriv Date: Fri, 29 Mar 2024 17:36:13 +0300 Subject: [PATCH 2/3] perf: :art: apply changes --- ...file.utils.spec.ts => image.utils.spec.ts} | 2 +- src/utils/{file.utils.ts => image.utils.ts} | 22 +++++++++---------- src/utils/index.ts | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) rename src/utils/__test__/{file.utils.spec.ts => image.utils.spec.ts} (98%) rename src/utils/{file.utils.ts => image.utils.ts} (91%) diff --git a/src/utils/__test__/file.utils.spec.ts b/src/utils/__test__/image.utils.spec.ts similarity index 98% rename from src/utils/__test__/file.utils.spec.ts rename to src/utils/__test__/image.utils.spec.ts index 8a6f1b0..86e5fb3 100644 --- a/src/utils/__test__/file.utils.spec.ts +++ b/src/utils/__test__/image.utils.spec.ts @@ -1,4 +1,4 @@ -import { convertToBase64, isSupportedImageFormat } from "../file.utils"; +import { convertToBase64, isSupportedImageFormat } from "../image.utils"; import { describe, test, expect } from "vitest"; describe("convertToBase64()", () => { diff --git a/src/utils/file.utils.ts b/src/utils/image.utils.ts similarity index 91% rename from src/utils/file.utils.ts rename to src/utils/image.utils.ts index d214a00..6cbaa55 100644 --- a/src/utils/file.utils.ts +++ b/src/utils/image.utils.ts @@ -2,11 +2,9 @@ const DEFAULT_IMAGE_WIDTH = 2560; const DEFAULT_IMAGE_QUALITY = 0.9; const WORD_SIZE = 4; -declare global { - interface Blob { - lastModifiedDate?: number; - name?: string; - } +interface IExtendedBlob extends Blob { + lastModifiedDate?: number; + name?: string; } type TCompressImageOption = { @@ -37,9 +35,9 @@ export type TFileObject = { * @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. + * @returns {Promise} A Promise that resolves with the compressed image as a Blob. */ -export const compressImage = ({ src, filename, options }: TCompressImage): Promise => { +export const compressImage = ({ src, filename, options }: TCompressImage): Promise => { const { maxWidth = DEFAULT_IMAGE_WIDTH, quality = DEFAULT_IMAGE_QUALITY } = options || {}; return new Promise((resolve, reject) => { @@ -71,7 +69,7 @@ export const compressImage = ({ src, filename, options }: TCompressImage): Promi (blob) => { if (!blob) return; const modified_filename = filename.replace(/\.[^/.]+$/, ".jpg"); - const file = new Blob([blob], { type: "image/jpeg" }); + const file: IExtendedBlob = new Blob([blob], { type: "image/jpeg" }); file.lastModifiedDate = Date.now(); file.name = modified_filename; resolve(file); @@ -133,8 +131,8 @@ export const compressImageFile = (file: File) => { * @param {num} number - The number to convert to Uint8Array. * @returns {Uint8Array} Uint8Array */ -export function numToUint8Array(num: number) { - const typedArray = new Uint8Array(WORD_SIZE); +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; @@ -161,11 +159,11 @@ export const generateChunks = (binary: Uint8Array, { chunkSize = 16384 /* 16KB * /** * Read a file and return it as modified object with a buffer of the file contents. - * @param {Blob} file - The file to read. + * @param {IExtendedBlob} file - The file to read. * @returns {Promise} A Promise that resolves with the file as a TFileObject. * */ -export const readFile = (file: Blob) => { +export const readFile = (file: IExtendedBlob) => { const fr = new FileReader(); return new Promise< | TFileObject diff --git a/src/utils/index.ts b/src/utils/index.ts index a8205a7..25ee606 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,9 +1,9 @@ import * as FormatUtils from "./format.utils"; -import * as FileUtils from "./file.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 { FileUtils, FormatUtils, LocalStorageUtils, ObjectUtils, PromiseUtils, URLUtils, WebSocketUtils }; +export { ImageUtils, FormatUtils, LocalStorageUtils, ObjectUtils, PromiseUtils, URLUtils, WebSocketUtils }; From 459f8cbe41c6799dbc408b90d5f0e38de7cdeddc Mon Sep 17 00:00:00 2001 From: heorhi-deriv Date: Fri, 19 Apr 2024 15:13:17 +0300 Subject: [PATCH 3/3] perf: :art: add document format constants --- src/constants/document.constants.ts | 3 +++ src/constants/index.ts | 10 +++++++++- src/utils/image.utils.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/constants/document.constants.ts 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/image.utils.ts b/src/utils/image.utils.ts index 6cbaa55..9cca228 100644 --- a/src/utils/image.utils.ts +++ b/src/utils/image.utils.ts @@ -1,3 +1,5 @@ +import { DocumentConstants } from "../constants"; + const DEFAULT_IMAGE_WIDTH = 2560; const DEFAULT_IMAGE_QUALITY = 0.9; const WORD_SIZE = 4; @@ -105,7 +107,13 @@ export const convertToBase64 = (file: File): Promise => { * @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) => /\.(png|jpg|jpeg|gif|pdf)$/gi.test(filename ?? ""); +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. @@ -115,7 +123,7 @@ export const isSupportedImageFormat = (filename: string) => /\.(png|jpg|jpeg|gif */ export const compressImageFile = (file: File) => { return new Promise((resolve) => { - if (isSupportedImageFormat(file.type)) { + if (isSupportedImageFormat(file.name)) { convertToBase64(file).then((img) => { compressImage(img).then(resolve); });