How to compress image files in Angular 8 on the front-end before upload

Svetoslav Mihaylov
6 min readNov 30, 2019

In the coming weeks, I am going to post a full tutorial, covering the topic of creating a fully operational and reusable image upload component which can be part of a reactive or template driven forms, with own validation and services to compress and upload the user selected images to Firestorage and reference them in Firestore. If you are interested in such a tutorial, follow me on Medium, so you get notified once it is published.

In the current article. I am going to explain how to compress an image on the front-end with Angular 8 and Typescript.

Let’s set a new project.

ng new ImageCompressor

Than create a service named ImageCompressorService :

ng g service image-compressor

We are going to use the AppComponent as the consumer of the service, for simplicity, so no extra components, pipes or services will be needed. You can serve your app already, and get to coding…

There is many to be written about the pros and cons of compressing images on the front-end, but I won’’t go through that, I will just show you a way of doing it and let you decide when it works for your needs.

Always have in mind browser compatibility and make sure that you have a fallback in case of incompatibility.

We will create an interface and a class ( inside the image-compressor-service.ts file ) which will help us determine browser support for the HTMLCanvasElement.toBlob() , URL.CreateObjectURL() and File API’s File constructor.

... src -> app -> image-compressor.service.ts@Injectable({providedIn: 'root'})export class ImageCompressorService { 
...
}
export class BrowserCompatibility {constructor() {}static addToBlobPolyfill() {Object.defineProperty(HTMLCanvasElement.prototype , 'toBlob' , {value(callback , extension , quality) {const canvas: HTMLCanvasElement = this ;setTimeout(function() {const binStr = atob(canvas.toDataURL(extension , quality).split(',')[1] );const len = binStr.length ;const byteNumbers = new Array(len);let arr: Uint8Array ;for (let i = 0 ; i < len ; i++) {byteNumbers[i] = binStr.charCodeAt(i);}arr = new Uint8Array(byteNumbers);callback(new Blob([arr] , {type : extension}));}, 10 );}});}init(): BrowserCompatibilityOptions {const blob = HTMLCanvasElement.prototype.toBlob ? true : false;const url = URL.createObjectURL ? true : false;let fileCtor : boolean;let file: File;try {file = new File([], 'test'); fileCtor = true; } catch ( err ) {fileCtor = false; }return {toBlob : blob ,fileConstructor : fileCtor ,urlCreateObjectUrl : url}; }}

The BrowseCompatibilityOptions is an interface which will hold boolean values, that can tell if the browser supports the metods described above.

The BrowserCompability class has an init() method, which once called will return a BrowserCompabilityOptions object, letting the service know what to do further. Depending on the browser support detected, there is a possibility that the static method addToBlobPolyfill() will create a polyfill for the canvas.toBlob method, but be noticed that this is a low-performance solution. If the File constructor method is not supported, you will get a Blob as a result . Depending on your backend that might be an issue ( Firebase Firestorage deals with Blobs , base64 strings and base64 Url strings very gracefully ).

We will create a class, which will be responsible for processing each user-selected image and return the result to the service. Here is the code , followed by some explanation :

... src -> app -> image-compressor.service.tsclass ImageCompressor {
constructor(
private doneCallback: (_file: FileResult , action: 'add' | 'delete') => void ,
private file: File,
private newWidth: number,
private newHeight: number,
private extension: string,
private quality: number,
private namePrefix: string,
private compat: BrowserCompatibilityOptions ) {
const reader = new FileReader();
reader.readAsDataURL(this.file);
reader.onload = this.readerOnloadCallback();
reader.onerror = (err) => { console.error(err); };
}
private readerOnloadCallback() {return (event: ProgressEvent) => {
const originalImage = new Image();
originalImage.src = (event.target as any).result;
originalImage.onerror = (err) => console.error(err);
originalImage.onload = this.imageOnloadCallback(originalImage);
}; }
private imageOnloadCallback(img: HTMLImageElement) {
return () => {
const canvas = document.createElement('canvas');
const namePrefix = this.namePrefix ;
canvas.width = this.newWidth;
canvas.height = this.newHeight;
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
context.drawImage(img , 0 , 0 , canvas.width , canvas.height);
context.canvas.toBlob(blob => {
let newFile: any;
let size: number ;
let imgUrl: any ;
if (this.compat.fileConstructor) {
newFile = new File([blob], namePrefix , {type : blob.type , lastModified : Date.now() }) ;
const fr: FileResult = {
file : newFile ,
name : this.namePrefix + Date.now() ,
imgUrl : this.compat.urlCreateObjectUrl ? URL.createObjectURL(newFile) : imgUrl,
fileSize : newFile.size ? newFile.size : size
};
if (this.compat.urlCreateObjectUrl) {
URL.revokeObjectURL(newFile); }
this.doneCallback(fr , 'add');
} else {
newFile = blob ;
size = blob.size ;
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => { imgUrl = reader.result ;
const fr: FileResult = {
file : newFile ,
name : this.namePrefix + Date.now() ,
imgUrl : reader.result ,
fileSize : newFile.size ? newFile.size : size ,
};
this.doneCallback(fr , 'add');
}; }
} , this.extension , this.quality);
}; }
}

Essentially, what this class will do is to take a single image File, read it through the FileReader, than create an Image and draw it on a canvas element . Than depending, on the browser support, it will return a FileResult object, containing a Blob or a File in the file property, to the doneCallback method supplied in its constructor. Here is the code for the FileResult interface :

... src -> app -> image-compressor.service.tsexport interface FileResult {file: File | Blob;name: string;imgUrl: SafeUrl;fileSize: number;}

Now let’s see how the service is going to work. For the sake of simplicity I am posting the full service code, and the explanation follows :

... src -> app -> image-compressor.service.tsimport { Injectable, NgZone } from '@angular/core';
import { Subject } from 'rxjs';
import { SafeUrl } from '@angular/platform-browser';
@Injectable({
providedIn: 'root'
})
export class ImageCompressorService {private compressedFiles: FileResult[];
private fileListEmitter: Subject<FileResult[]>;
private compat: BrowserCompatibilityOptions;
private updateCompressedFiles = (file : FileResult , action) => {if (!file ) {
this.compressedFiles = [] ;
this.fileListEmitter.next(this.compressedFiles);
return ;
}
if (action ) {
switch (action) {
case 'add' : {
this.compressedFiles.push(file);
this._zone.run(() => {
this.fileListEmitter.next(this.compressedFiles);});
break;
}
case 'delete' : {
this.compressedFiles.forEach((item , index) => {
if (item === file) {
this.compressedFiles.splice(index , 1);
this.fileListEmitter.next(this.compressedFiles); }
});
break; }
}}}
public getCompressedFiles = () => this.fileListEmitter.asObservable() ;constructor(private _zone : NgZone) {this.compressedFiles = new Array<FileResult>();this.fileListEmitter = new Subject<FileResult[]>();this.compat = new BrowserCompatibility().init();if (!this.compat.toBlob) {BrowserCompatibility.addToBlobPolyfill();}}compress(fileList: FileList, namePrefix: string, width: number, height: number, extension: string , quality: number) {for (let i = 0 ; i < fileList.length; i++) {const ic = new ImageCompressor(this.updateCompressedFiles,fileList.item(i),width,height,extension,quality,namePrefix,this.compat);}}}

The FileResult is the data structure that we will use in our component to read the results of image compression. The service defines an array of FileResult and a Subject<FileResult[]> which will be used to emit data to the component. The getCompressedFiles property will return an Observable<FileResult[]> which we can use later on in our component to deal with the compressed images.

The updateCompressedFiles property is the callback, that the ImageCompressor instance will call once done. This callback will actually update the compressedFiles array and emit it to all the subscribers. I have put and extra parameter to the callback called : actions . It might be usable if you wanted to clear the compressedFile array and emit an empty one. For that you would need to write an extra public function ( e.g. reset() ).

The use of NgZone.run() in updateCompressedFiles is needed due to the fact that some of the code in ImageCompressor runs outside Angular’s Zone and therefore no ChangeDetection is triggered once the image is compressed and the compressedFiles array is emitted back to the subscribers.

Now, I will post the code of my app.component.html and app.component.ts :

... -> src -> app -> app.component.html<input type="file" accept="image/*" multiple (change)='change($event.target.files)'><div *ngIf="compressedImages "><div *ngFor="let image of compressedImages | async" ><img [src]='sanitize(image.imgUrl)'></div></div>... -> src -> app -> app.component.tsimport { ImageCompressorService , FileResult } from './image-compressor.service';import { Component } from '@angular/core';import { Observable } from 'rxjs';import { DomSanitizer } from '@angular/platform-browser';@Component({selector: 'app-root',templateUrl: './app.component.html',styleUrls: ['./app.component.css']})export class AppComponent {compressedImages: Observable<FileResult[]>;constructor(
private ics: ImageCompressorService ,
private sanitizer : DomSanitizer) {
this.compressedImages = this.ics.getCompressedFiles();}change(files) {console.log(files);this.ics.compress(files,'someName', 200 , 200 , 'image/jpeg', 1);}sanitize(url) {return this.sanitizer.bypassSecurityTrustUrl(url);}}

I know there are a lot of issues that can arise, so I must warn you that this is not production ready, although it works, and supports a big percentage of the browsers out there. I am working on a very detailed ( and much more complicated ) tutorial covering in details the topic of front-end image processing, which I will be releasing in the next couple of weeks.

The complete code you can find here :

--

--