Skip to content

Commit

Permalink
Merge pull request deriv-com#15 from heorhi-deriv/FEQ-1990-Transfer-f…
Browse files Browse the repository at this point in the history
…ile-utils

George / FEQ-1990 / Transfer file utils
  • Loading branch information
niloofar-deriv authored Apr 29, 2024
2 parents 66d41ec + 8204f13 commit 5b6629d
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 2 deletions.
3 changes: 3 additions & 0 deletions src/constants/document.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const supportedDocumentFormats = ["PNG", "JPG", "JPEG", "GIF", "PDF"] as const;

export type DocumentFormats = (typeof supportedDocumentFormats)[number];
10 changes: 9 additions & 1 deletion src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
97 changes: 97 additions & 0 deletions src/utils/__test__/image.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
200 changes: 200 additions & 0 deletions src/utils/image.utils.ts
Original file line number Diff line number Diff line change
@@ -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<IExtendedBlob>} A Promise that resolves with the compressed image as a Blob.
*/
export const compressImage = ({ src, filename, options }: TCompressImage): Promise<IExtendedBlob> => {
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<TBase64Image>} A Promise that resolves with an object containing the Base64 image data and the filename.
*/
export const convertToBase64 = (file: File): Promise<TBase64Image> => {
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<Blob>} A Promise that resolves with the compressed image as a Blob.
*/
export const compressImageFile = (file: File) => {
return new Promise<Blob>((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<TFileObject>} 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);
});
};
3 changes: 2 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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 };

0 comments on commit 5b6629d

Please sign in to comment.