218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import { Injectable, inject } from '@angular/core';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { ImageCropperDialogComponent } from '../clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class KycUploadHelperService {
|
|
private dialog = inject(MatDialog);
|
|
|
|
/**
|
|
* If the picked file is an image, open the cropper dialog and resolve with
|
|
* the cropped File (or null if cancelled). Non-image files (PDFs) are
|
|
* passed through unchanged.
|
|
*/
|
|
pickWithCrop(file: File, title: string): Promise<File | null> {
|
|
if (!file.type.startsWith('image/')) {
|
|
return Promise.resolve(file);
|
|
}
|
|
return new Promise((resolve) => {
|
|
const ref = this.dialog.open(ImageCropperDialogComponent, {
|
|
width: '720px',
|
|
maxWidth: '95vw',
|
|
data: { file, title }
|
|
});
|
|
ref.afterClosed().subscribe((cropped: File | null) => resolve(cropped || null));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stack two images (front above back) into one PNG. Resolves with both
|
|
* the new File (suitable for upload) and a data-URL preview for the UI.
|
|
* Returns null if either side is not an image (e.g. one is a PDF).
|
|
*/
|
|
mergeFrontBack(front: File, back: File, name: string): Promise<{ file: File; preview: string } | null> {
|
|
if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) {
|
|
return Promise.resolve(null);
|
|
}
|
|
return Promise.all([
|
|
this.fileToImage(front),
|
|
this.fileToImage(back)
|
|
]).then(
|
|
([
|
|
fImg,
|
|
bImg
|
|
]) => {
|
|
const targetWidth = Math.max(fImg.width, bImg.width);
|
|
const fScale = targetWidth / fImg.width;
|
|
const bScale = targetWidth / bImg.width;
|
|
const fH = fImg.height * fScale;
|
|
const bH = bImg.height * bScale;
|
|
const gap = 20;
|
|
const totalH = Math.round(fH + bH + gap);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = targetWidth;
|
|
canvas.height = totalH;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return null;
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
|
|
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
|
|
|
|
return new Promise<{ file: File; preview: string } | null>((resolve) => {
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const file = new File([blob], `${name}-combined.png`, { type: 'image/png' });
|
|
const preview = canvas.toDataURL('image/png');
|
|
resolve({ file, preview });
|
|
}, 'image/png');
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Split a stacked front-above-back scan back into its two sides.
|
|
*
|
|
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
|
|
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
|
|
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
|
|
* completely white rows across the full width — that band is the seam, and it is the only thing
|
|
* in the file that says where one side ends and the other begins.
|
|
*
|
|
* Resolves null when no such seam is found, which is the honest answer for an image that was not
|
|
* produced by that merge: better to show one scan than to cut a document in half at a guess.
|
|
*/
|
|
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
|
|
if (!blob.type.startsWith('image/')) {
|
|
return Promise.resolve(null);
|
|
}
|
|
type Sides = { front: Blob; back: Blob } | null;
|
|
return this.blobToImage(blob)
|
|
.then((img): Sides | Promise<Sides> => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.naturalWidth || img.width;
|
|
canvas.height = img.naturalHeight || img.height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx || canvas.width === 0 || canvas.height === 0) {
|
|
return null;
|
|
}
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
|
|
if (!seam) {
|
|
return null;
|
|
}
|
|
|
|
return Promise.all([
|
|
this.cropToBlob(img, canvas.width, 0, seam.start),
|
|
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
|
|
]).then(
|
|
([
|
|
front,
|
|
back
|
|
]): Sides => (front && back ? { front, back } : null)
|
|
);
|
|
})
|
|
.catch((): Sides => null);
|
|
}
|
|
|
|
/**
|
|
* The tallest run of all-white rows in the middle of the image.
|
|
*
|
|
* Confined to the middle half because a scan often has white margins at the top and bottom, and
|
|
* those are part of the document rather than the join between two of them.
|
|
*/
|
|
private findWhiteSeam(
|
|
ctx: CanvasRenderingContext2D,
|
|
width: number,
|
|
height: number
|
|
): { start: number; end: number } | null {
|
|
const from = Math.floor(height * 0.25);
|
|
const to = Math.ceil(height * 0.75);
|
|
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
|
|
|
|
let bestStart = -1;
|
|
let bestLength = 0;
|
|
let runStart = -1;
|
|
|
|
for (let y = from; y <= to; y++) {
|
|
let white = y < to;
|
|
if (white) {
|
|
const row = ctx.getImageData(0, y, width, 1).data;
|
|
for (let x = 0; x < width; x += step) {
|
|
const i = x * 4;
|
|
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
|
|
white = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (white) {
|
|
if (runStart < 0) {
|
|
runStart = y;
|
|
}
|
|
} else if (runStart >= 0) {
|
|
if (y - runStart > bestLength) {
|
|
bestStart = runStart;
|
|
bestLength = y - runStart;
|
|
}
|
|
runStart = -1;
|
|
}
|
|
}
|
|
|
|
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
|
|
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
|
|
}
|
|
|
|
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
|
|
if (height <= 0) {
|
|
return Promise.resolve(null);
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
return Promise.resolve(null);
|
|
}
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
|
|
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
|
|
}
|
|
|
|
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
|
|
return this.fileToImage(blob as File);
|
|
}
|
|
|
|
private fileToImage(file: File): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(img);
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error('image load failed'));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
}
|