Introduction
The following approach allows for increased file compression, inherently lowering file size as well as an added ability to store images in Base64-encoded strings if desired.
Behind-the-Scenes (Base64)
To dive slightly deeper into what's involved in this process we can look at what a Base64 string actually is, and how we can use it within a canvas
and img
tag to create a new, usable version of an image.
When given an image file (PNG, JPG, JPEG, etc.), the binary information can be extracted into our new format, Base64, which is simply a string used to transport a sequence of 6-bit Base64 characters. These characters are utilized for many use-cases, mostly in web-pages and emails since HTML can easily rely on converting these text-assets to their visual equivalent.
Now, how exactly does our computer manage to convert these values to and from text? To not go into unnecessary detail, we can look at this conversion table:
Basically, the binary stream taken from an image file gets converted to its appropriate code points, and that information gets appended to the Base64 string.
Once we receive our new string from the computer our HTML code can interpret this data and display an image for us, in somewhat of a "URL". Here is an example url's first 50 characters:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAApEAA
NOTE: When storing images as a Base64 string, they will take up 1/3 more space within storage than the original file. "[This] causes an overhead of 33–37% (33% by the encoding itself; up to 4% more by the inserted line breaks)." -Wikipedia (MORE INFO)
Then, as you will see later on in this blog, that image can be used within a canvas
tag, where HTML allows us to resize this by passing in a height and width variable, along with a "quality" parameter, where the image will be compressed.
The Process
There are many ways to initially approach this problem within a project, this will show my implementation in a recent TypeScript/React environment.
First, we must get the desired image via a form as so:
import { ChangeEvent, useState } from "react";
export default function ImagePage() {
const [imageUrl, setImageUrl] = useState<string>("")
async function handleFileUpload(e: ChangeEvent) {
// not-so-pretty typescript handling
const target = e.target as HTMLInputElement;
const file: File = (target.files as FileList)[0];
}
return (
<>
<input
type="file"
accept=".jpeg, .jpg, .png"
onChange={(e: ChangeEvent) => handleFileUpload(e)}
/>
<img src={imageUrl} />
</>
)
}
In the code above we are having an EventListener wait for an image file to be uploaded, then we are storing it inside our constant file
.
Next, we have to convert this file to a Base64 string. Here, we can use an instance of FileReader
to extract the data in the desired format.
This handler function is simple yet powerful:
export default function convertToBase64(file: File): Promise<string>{
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
// where the conversion occurs, handled by .readAsDataUrl(...) which will return us a Base64 string
fileReader.readAsDataURL(file);
fileReader.onload = (e: ProgressEvent<FileReader>) => {
resolve(fileReader.result as string);
}
fileReader.onerror = (e) => reject(e);
}
}
As we can see, most of the work is done for us. Now the remaining steps involve taking that value, figuring out how to pass the associated image into a canvas
, and ensuring said canvas is displaying the information at our desired resolution/size.
We can go back to the ImagePage
function, or more specifically our handleFileUpload function, where we can now add our Promise
to get the desired value. Then, we will do a workaround to pass the rendered image into our canvas
tag, which will draw the information at the quality we desire. Last, that newly drawn image will be the compressed file which we can display onto a new img
tag to ensure it has been been successfully compressed. If this sounds confusing, the code will likely clear up any questions:
const WIDTH = 100;
async function handleFileUpload(e: ChangeEvent){
try {
const target = e.target as HTMLInputElement;
const file: File = (target.files as FileList)[0];
const imageString = await convertToBase64(file);
let img = document.createElement("img");
img.src = imageString;
// we are waiting until the image has rendered by targeting its onload event, which is required when we'd like to get a height and width to use for the final output's length:width ratio
img.onload = (e: any) => {
// set a width value for the height of the produced image to depend on (i.e. WIDTH = 100 will be 100px)
let canvas = document.createElement("canvas");
let ratio = WIDTH / e.target.width;
canvas.width = WIDTH;
canvas.height = e.target.height * ratio;
// context is where the canvas references to know what data to render
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
// "compression" occurs below, where the new image is drawn onto the canvas based on the parameters we pass in
// the second and third parameters tell the canvas where to place the image within its render, starting from the top left corner (i.e. a value greater than 0 will add whitespace from top-down, left-to-right
// referencing https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
context.drawImage(img, 0, 0, canvas.width, canvas.height);
// here we specify the quality, which is the second argument in .toDataUrl(...) | here the output should be 50% the quality of the original, lowering the detail and file size
let newImageUrl = context.canvas.toDataURL("image/jpg", 50); // quality ranges 1-100
// below is not necessary (used for testing)
setImage(newImageUrl);
}
} catch (e: Error) console.log(e);
}
Converting Compressed Image to a Usable File
Now we have our new, compressed image within a canvas. We can then revert this new image to a Base64 string that JavaScript will know how to handle. To pseudocode this process it would look something like so:
(1) fetch the image content from the canvas tag
(2) convert the image's content to a Base64 string
(3) convert the Base64 string into a Blob object
(4) pass the Blob into a new File object, along with the desired file name and type
For the first two steps, they can be done like so using canvas
's built-in .toUrlString()
method:
// change the file type to whichever suits your use-case or dynamically handle different file types
// the second parameter is for "quality" again, in this case set to 90%
const compressedImageUrl = canvas.toDataUrl('image/jpg', 90);
For the final conversion steps, I am referencing a StackOverflow answer I found while searching for the simplest way to approach this, which you can find HERE. Bringing this code to life would look like so:
// takes the dataUrl from the previous step as an argument
async function dataUrlToFile(dataUrl: string, fileName: string): Promise<File> {
const res: Response = await fetch(dataUrl); // grabbing the image data
const blob: Blob = await res.blob();
return new File([blob], fileName, { type: 'image/jpg' });
}
Those steps should cover most use-cases, and allows us to compress files natively in JavaScript/TypeScript. Now you can pass in the new file object wherever you may please and it can be uploaded as a string or as an actual image.
Please leave any feedback or questions as I am always trying to improve these technical-oriented posts to leave less room for misinterpretation/misunderstanding.
Thank you for reading <3
Top comments (0)