From 98fa5dfcc11a35a5f0fdbc07fd8ef8a65da69dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Sat, 27 Dec 2025 00:50:21 +0100 Subject: [PATCH] [V2] feat(sign): add automatic white background removal for signature images (#5210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This pull request adds a new feature that allows users to automatically remove the white background from uploaded signature images, making them transparent. The changes include UI improvements for image uploading, new image processing utilities, and updates to translation files for better user feedback. ### Image Upload & Processing Enhancements * Added a `removeWhiteBackground` utility function in `imageTransparency.ts` to process images and remove white backgrounds, with options for automatic corner color detection and tolerance settings. * Updated the `ImageUploader` component to support background removal, including a checkbox for toggling the feature, processing state feedback, and integration with the new utility ### Signature Tool Integration * Modified `SignSettings.tsx` to enable the background removal feature for signature images, handle processed image data, and ensure signature placement logic works seamlessly with the new feature. ### UI & Localization Updates * Added new translation strings for background removal and image processing feedback in the English locale file. https://github.com/user-attachments/assets/28263940-1756-4f0e-9bfb-5603a6fb8a2c --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../public/locales/en-GB/translation.toml | 4 + .../annotation/shared/ImageUploader.tsx | 90 +++++++++++- .../components/tools/sign/SignSettings.tsx | 26 ++-- frontend/src/core/utils/imageTransparency.ts | 137 ++++++++++++++++++ 4 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 frontend/src/core/utils/imageTransparency.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index f47d45d5d..e9d008535 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2356,6 +2356,10 @@ 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..." +backgroundRemovalFailedTitle = "Background removal failed" +backgroundRemovalFailedMessage = "Could not remove the background from the image. Using original image instead." [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 b031b3ce2..c95116440 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -1,7 +1,9 @@ -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'; +import { alert } from '@app/components/toast'; interface ImageUploaderProps { onImageChange: (file: File | null) => void; @@ -9,6 +11,8 @@ interface ImageUploaderProps { label?: string; placeholder?: string; hint?: string; + allowBackgroundRemoval?: boolean; + onProcessedImageData?: (dataUrl: string | null) => void; } export const ImageUploader: React.FC = ({ @@ -16,9 +20,50 @@ 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); + alert({ + title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'), + body: t('sign.image.backgroundRemovalFailedMessage', 'Could not remove the background from the image. Using original image instead.'), + alertType: '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 +74,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 +114,27 @@ export const ImageUploader: React.FC = ({ placeholder={placeholder || t('sign.image.placeholder', 'Select image file')} accept="image/*,.svg" 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) + }; +}