From baefc88c3ddcc70e861891b452a60288f181cb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Wed, 10 Dec 2025 10:33:45 +0100 Subject: [PATCH 1/4] feat(sign): add automatic white background removal for signature images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Balázs Szücs --- .../software/SPDF/config/InitialSetup.java | 1 + .../public/locales/en-GB/translation.toml | 2 + .../annotation/shared/ImageUploader.tsx | 68 ++++++++- .../components/tools/sign/SignSettings.tsx | 10 +- frontend/src/core/utils/imageTransparency.ts | 137 ++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/src/core/utils/imageTransparency.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index ef592cb55..88755f950 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -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(); diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index fcdcf592f..ba7bc4d9f 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index 9b4945100..7dc5b8351 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,9 @@ interface ImageUploaderProps { label?: string; placeholder?: string; hint?: string; + allowBackgroundRemoval?: boolean; + onProcessedImageData?: (dataUrl: string | null) => void; + currentImageData?: string; } export const ImageUploader: React.FC = ({ @@ -16,9 +20,37 @@ export const ImageUploader: React.FC = ({ disabled = false, label, placeholder, - hint + hint, + allowBackgroundRemoval = false, + onProcessedImageData, + currentImageData }) => { const { t } = useTranslation(); + const [removeBackground, setRemoveBackground] = useState(true); + const [currentFile, setCurrentFile] = useState(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 = ({ 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 = ({ 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 233b87373..8ad80ed3b 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -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 = ({ { + 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..d6e2eb160 --- /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: 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 { + 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) + }; +} From 7c6bcdfe7a465e8d72019c010b67c7360e1c5512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Wed, 10 Dec 2025 10:39:06 +0100 Subject: [PATCH 2/4] feat(image-uploader): update background removal logic and store original image data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Balázs Szücs --- .../annotation/shared/ImageUploader.tsx | 20 ++++++++++++++++--- .../components/tools/sign/SignSettings.tsx | 1 - 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index 7dc5b8351..b9576f181 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -26,8 +26,9 @@ export const ImageUploader: React.FC = ({ currentImageData }) => { const { t } = useTranslation(); - const [removeBackground, setRemoveBackground] = useState(true); + 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) => { @@ -47,6 +48,9 @@ export const ImageUploader: React.FC = ({ setIsProcessing(false); } } else { + if (originalImageData) { + onProcessedImageData?.(originalImageData); + } setIsProcessing(false); } return null; @@ -63,6 +67,15 @@ export const ImageUploader: React.FC = ({ 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); @@ -70,6 +83,7 @@ export const ImageUploader: React.FC = ({ } else if (!file) { // Clear image data when no file is selected setCurrentFile(null); + setOriginalImageData(null); onImageChange(null); onProcessedImageData?.(null); } @@ -77,8 +91,8 @@ export const ImageUploader: React.FC = ({ const handleBackgroundRemovalChange = async (checked: boolean) => { setRemoveBackground(checked); - if (currentImageData) { - await processImage(currentImageData, checked); + if (originalImageData) { + await processImage(originalImageData, checked); } else if (currentFile) { await processImage(currentFile, checked); } diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 8ad80ed3b..0726a3963 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -835,7 +835,6 @@ const SignSettings = ({ onImageChange={handleImageChange} disabled={disabled} allowBackgroundRemoval={true} - currentImageData={imageSignatureData} onProcessedImageData={(dataUrl) => { if (dataUrl) { setImageSignatureData(dataUrl); From a6fd3f0939920482f633478be5aefa70d0b23cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Wed, 10 Dec 2025 11:17:45 +0100 Subject: [PATCH 3/4] feat(image-uploader): improve background removal handling and streamline image data processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Balázs Szücs --- .../annotation/shared/ImageUploader.tsx | 24 ++++++++++--------- .../components/tools/sign/SignSettings.tsx | 17 +++---------- frontend/src/core/utils/imageTransparency.ts | 8 +++---- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index b9576f181..f565914b7 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -12,7 +12,6 @@ interface ImageUploaderProps { hint?: string; allowBackgroundRemoval?: boolean; onProcessedImageData?: (dataUrl: string | null) => void; - currentImageData?: string; } export const ImageUploader: React.FC = ({ @@ -22,8 +21,7 @@ export const ImageUploader: React.FC = ({ placeholder, hint, allowBackgroundRemoval = false, - onProcessedImageData, - currentImageData + onProcessedImageData }) => { const { t } = useTranslation(); const [removeBackground, setRemoveBackground] = useState(false); @@ -31,7 +29,7 @@ export const ImageUploader: React.FC = ({ const [originalImageData, setOriginalImageData] = useState(null); const [isProcessing, setIsProcessing] = useState(false); - const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean) => { + const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise => { if (shouldRemoveBackground && allowBackgroundRemoval) { setIsProcessing(true); try { @@ -40,7 +38,6 @@ export const ImageUploader: React.FC = ({ tolerance: 15 }); onProcessedImageData?.(transparentImageDataUrl); - return transparentImageDataUrl; } catch (error) { console.error('Error removing background:', error); onProcessedImageData?.(null); @@ -48,12 +45,18 @@ export const ImageUploader: React.FC = ({ setIsProcessing(false); } } else { - if (originalImageData) { - onProcessedImageData?.(originalImageData); + // 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); } - setIsProcessing(false); } - return null; }; const handleImageChange = async (file: File | null) => { @@ -90,11 +93,10 @@ export const ImageUploader: React.FC = ({ }; const handleBackgroundRemovalChange = async (checked: boolean) => { + if (isProcessing) return; // Prevent race conditions setRemoveBackground(checked); if (originalImageData) { await processImage(originalImageData, checked); - } else if (currentFile) { - await processImage(currentFile, checked); } }; diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 0726a3963..c27adf31a 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -461,24 +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') { diff --git a/frontend/src/core/utils/imageTransparency.ts b/frontend/src/core/utils/imageTransparency.ts index d6e2eb160..17fe2f42a 100644 --- a/frontend/src/core/utils/imageTransparency.ts +++ b/frontend/src/core/utils/imageTransparency.ts @@ -105,10 +105,10 @@ function detectCornerColor(imageData: ImageData): { r: number; g: number; b: num 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 + { 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; From 8b007049b8dad01b7b14922dca239cbd366e1d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Wed, 10 Dec 2025 17:06:18 +0100 Subject: [PATCH 4/4] fix(imageTransparency): adjust default lower bound for background removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Balázs Szücs --- frontend/src/core/utils/imageTransparency.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/utils/imageTransparency.ts b/frontend/src/core/utils/imageTransparency.ts index 17fe2f42a..aa62849e7 100644 --- a/frontend/src/core/utils/imageTransparency.ts +++ b/frontend/src/core/utils/imageTransparency.ts @@ -5,7 +5,7 @@ export interface TransparencyOptions { tolerance?: number; } -const DEFAULT_LOWER_BOUND = { r: 238, g: 238, b: 238 }; // #EEEEEE +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(