Files
cbsnexora/src/app/clients/shared/kyc-upload-helper.service.ts
T
keshav 7b895a0e2b
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
2026-08-11 11:11:18 +05:30

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;
});
}
}