/** * 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 { 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 => { 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 { 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 { return this.fileToImage(blob as File); } private fileToImage(file: File): Promise { 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; }); } }