feat(sign): add automatic white background removal for signature images

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2025-12-10 10:33:45 +01:00
parent c980ee10c0
commit baefc88c3d
5 changed files with 211 additions and 7 deletions

View File

@ -58,6 +58,7 @@ public class InitialSetup {
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();

View File

@ -2306,6 +2306,8 @@ saved = "Saved"
label = "Upload signature image"
placeholder = "Select image file"
hint = "Upload a PNG or JPG image of your signature"
removeBackground = "Remove white background (make transparent)"
processing = "Processing image..."
[sign.instructions]
title = "How to add signature"

View File

@ -1,7 +1,8 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import React, { useState } from 'react';
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { removeWhiteBackground } from '@app/utils/imageTransparency';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@ -9,6 +10,9 @@ interface ImageUploaderProps {
label?: string;
placeholder?: string;
hint?: string;
allowBackgroundRemoval?: boolean;
onProcessedImageData?: (dataUrl: string | null) => void;
currentImageData?: string;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
@ -16,9 +20,37 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled = false,
label,
placeholder,
hint
hint,
allowBackgroundRemoval = false,
onProcessedImageData,
currentImageData
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(true);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean) => {
if (shouldRemoveBackground && allowBackgroundRemoval) {
setIsProcessing(true);
try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
autoDetectCorner: true,
tolerance: 15
});
onProcessedImageData?.(transparentImageDataUrl);
return transparentImageDataUrl;
} catch (error) {
console.error('Error removing background:', error);
onProcessedImageData?.(null);
} finally {
setIsProcessing(false);
}
} else {
setIsProcessing(false);
}
return null;
};
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
@ -29,13 +61,26 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
return;
}
setCurrentFile(file);
onImageChange(file);
await processImage(file, removeBackground);
} catch (error) {
console.error('Error processing image file:', error);
}
} else if (!file) {
// Clear image data when no file is selected
setCurrentFile(null);
onImageChange(null);
onProcessedImageData?.(null);
}
};
const handleBackgroundRemovalChange = async (checked: boolean) => {
setRemoveBackground(checked);
if (currentImageData) {
await processImage(currentImageData, checked);
} else if (currentFile) {
await processImage(currentFile, checked);
}
};
@ -47,14 +92,27 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
disabled={disabled || isProcessing}
/>
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
disabled={disabled || !currentFile || isProcessing}
/>
)}
{hint && (
<Text size="sm" c="dimmed">
{hint}
</Text>
)}
{isProcessing && (
<Text size="sm" c="dimmed">
{t('sign.image.processing', 'Processing image...')}
</Text>
)}
</Stack>
);
};
};

View File

@ -477,6 +477,7 @@ const SignSettings = ({
// Reset pause state and directly activate placement
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
setImageSignatureData(result);
// Directly activate placement on image upload
@ -491,8 +492,6 @@ const SignSettings = ({
} else if (!file) {
setImageSignatureData(undefined);
onDeactivateSignature?.();
setImageSignatureData(undefined);
onDeactivateSignature?.();
}
};
@ -835,6 +834,13 @@ const SignSettings = ({
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
allowBackgroundRemoval={true}
currentImageData={imageSignatureData}
onProcessedImageData={(dataUrl) => {
if (dataUrl) {
setImageSignatureData(dataUrl);
}
}}
/>
{renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)}
</Stack>

View File

@ -0,0 +1,137 @@
export interface TransparencyOptions {
lowerBound?: { r: number; g: number; b: number };
upperBound?: { r: number; g: number; b: number };
autoDetectCorner?: boolean;
tolerance?: number;
}
const DEFAULT_LOWER_BOUND = { r: 238, g: 238, b: 238 }; // #EEEEEE
const DEFAULT_UPPER_BOUND = { r: 255, g: 255, b: 255 }; // #FFFFFF
export async function removeWhiteBackground(
imageFile: File | string,
options: TransparencyOptions = {}
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
try {
const result = processImageTransparency(img, options);
resolve(result);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
if (typeof imageFile === 'string') {
img.src = imageFile;
} else {
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target?.result as string;
};
reader.onerror = () => {
reject(new Error('Failed to read image file'));
};
reader.readAsDataURL(imageFile);
}
});
}
function processImageTransparency(
img: HTMLImageElement,
options: TransparencyOptions
): string {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let lowerBound = options.lowerBound || DEFAULT_LOWER_BOUND;
let upperBound = options.upperBound || DEFAULT_UPPER_BOUND;
if (options.autoDetectCorner) {
const cornerColor = detectCornerColor(imageData);
const tolerance = options.tolerance || 10;
lowerBound = {
r: Math.max(0, cornerColor.r - tolerance),
g: Math.max(0, cornerColor.g - tolerance),
b: Math.max(0, cornerColor.b - tolerance)
};
upperBound = {
r: Math.min(255, cornerColor.r + tolerance),
g: Math.min(255, cornerColor.g + tolerance),
b: Math.min(255, cornerColor.b + tolerance)
};
}
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (
r >= lowerBound.r && r <= upperBound.r &&
g >= lowerBound.g && g <= upperBound.g &&
b >= lowerBound.b && b <= upperBound.b
) {
data[i + 3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function detectCornerColor(imageData: ImageData): { r: number; g: number; b: number } {
const { width, height, data } = imageData;
const sampleSize = 5;
const corners = [
{ x: sampleSize, y: sampleSize }, // top-left
{ x: width - sampleSize - 1, y: sampleSize }, // top-right
{ x: sampleSize, y: height - sampleSize - 1 }, // bottom-left
{ x: width - sampleSize - 1, y: height - sampleSize - 1 } // bottom-right
];
let totalR = 0, totalG = 0, totalB = 0;
let samples = 0;
corners.forEach(corner => {
for (let dy = 0; dy < sampleSize; dy++) {
for (let dx = 0; dx < sampleSize; dx++) {
const x = Math.min(width - 1, Math.max(0, corner.x + dx));
const y = Math.min(height - 1, Math.max(0, corner.y + dy));
const i = (y * width + x) * 4;
totalR += data[i];
totalG += data[i + 1];
totalB += data[i + 2];
samples++;
}
}
});
return {
r: Math.round(totalR / samples),
g: Math.round(totalG / samples),
b: Math.round(totalB / samples)
};
}