diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 5d9f08467..09cf2b04a 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2339,6 +2339,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" diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index 9b4945100..f565914b7 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -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,8 @@ interface ImageUploaderProps { label?: string; placeholder?: string; hint?: string; + allowBackgroundRemoval?: boolean; + onProcessedImageData?: (dataUrl: string | null) => void; } export const ImageUploader: React.FC = ({ @@ -16,9 +19,45 @@ export const ImageUploader: React.FC = ({ disabled = false, label, placeholder, - hint + hint, + allowBackgroundRemoval = false, + onProcessedImageData }) => { const { t } = useTranslation(); + const [removeBackground, setRemoveBackground] = useState(false); + const [currentFile, setCurrentFile] = useState(null); + const [originalImageData, setOriginalImageData] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise => { + if (shouldRemoveBackground && allowBackgroundRemoval) { + setIsProcessing(true); + try { + const transparentImageDataUrl = await removeWhiteBackground(imageSource, { + autoDetectCorner: true, + tolerance: 15 + }); + onProcessedImageData?.(transparentImageDataUrl); + } catch (error) { + console.error('Error removing background:', error); + onProcessedImageData?.(null); + } finally { + setIsProcessing(false); + } + } else { + // When background removal is disabled, return the original image data + if (typeof imageSource === 'string') { + onProcessedImageData?.(imageSource); + } else { + // Convert File to data URL if needed + const reader = new FileReader(); + reader.onload = (e) => { + onProcessedImageData?.(e.target?.result as string); + }; + reader.readAsDataURL(imageSource); + } + } + }; const handleImageChange = async (file: File | null) => { if (file && !disabled) { @@ -29,13 +68,35 @@ export const ImageUploader: React.FC = ({ return; } + setCurrentFile(file); onImageChange(file); + + const originalDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + + setOriginalImageData(originalDataUrl); + 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); + setOriginalImageData(null); onImageChange(null); + onProcessedImageData?.(null); + } + }; + + const handleBackgroundRemovalChange = async (checked: boolean) => { + if (isProcessing) return; // Prevent race conditions + setRemoveBackground(checked); + if (originalImageData) { + await processImage(originalImageData, checked); } }; @@ -47,14 +108,27 @@ export const ImageUploader: React.FC = ({ placeholder={placeholder || t('sign.image.placeholder', 'Select image file')} accept="image/*" onChange={handleImageChange} - disabled={disabled} + disabled={disabled || isProcessing} /> + {allowBackgroundRemoval && ( + handleBackgroundRemovalChange(event.currentTarget.checked)} + disabled={disabled || !currentFile || isProcessing} + /> + )} {hint && ( {hint} )} + {isProcessing && ( + + {t('sign.image.processing', 'Processing image...')} + + )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 12de2cadd..c4261e551 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -461,23 +461,13 @@ const SignSettings = ({ const handleImageChange = async (file: File | null) => { if (file && !disabled) { try { - const result = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result) { - resolve(e.target.result as string); - } else { - reject(new Error('Failed to read file')); - } - }; - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); - }); - // Reset pause state and directly activate placement setPlacementManuallyPaused(false); lastAppliedPlacementKey.current = null; - setImageSignatureData(result); + + // Image data will be set by onProcessedImageData callback in ImageUploader + // This avoids the race condition where both handleImageChange and onProcessedImageData + // try to set the image data, potentially with the wrong version // Directly activate placement on image upload if (typeof window !== 'undefined') { @@ -491,8 +481,6 @@ const SignSettings = ({ } else if (!file) { setImageSignatureData(undefined); onDeactivateSignature?.(); - setImageSignatureData(undefined); - onDeactivateSignature?.(); } }; @@ -835,6 +823,12 @@ const SignSettings = ({ { + if (dataUrl) { + setImageSignatureData(dataUrl); + } + }} /> {renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)} diff --git a/frontend/src/core/utils/imageTransparency.ts b/frontend/src/core/utils/imageTransparency.ts new file mode 100644 index 000000000..aa62849e7 --- /dev/null +++ b/frontend/src/core/utils/imageTransparency.ts @@ -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: 200, g: 200, b: 200 }; +const DEFAULT_UPPER_BOUND = { r: 255, g: 255, b: 255 }; // #FFFFFF + +export async function removeWhiteBackground( + imageFile: File | string, + options: TransparencyOptions = {} +): Promise { + 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: 0, y: 0 }, // top-left + { x: width - sampleSize, y: 0 }, // top-right + { x: 0, y: height - sampleSize }, // bottom-left + { x: width - sampleSize, y: height - sampleSize } // 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) + }; +}