Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

George / FEQ-1990 / Transfer file utils #15

Merged
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 };
Loading