feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user