[V2] feat(sign): add automatic white background removal for signature images (#5210)

# 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


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2025-12-27 00:50:21 +01:00 committed by GitHub
parent 429520d3f9
commit 98fa5dfcc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 236 additions and 21 deletions

View File

@ -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"

View File

@ -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<ImageUploaderProps> = ({
@ -16,9 +20,50 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled = false,
label,
placeholder,
hint
hint,
allowBackgroundRemoval = false,
onProcessedImageData
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [originalImageData, setOriginalImageData] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise<void> => {
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<ImageUploaderProps> = ({
return;
}
setCurrentFile(file);
onImageChange(file);
const originalDataUrl = await new Promise<string>((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<ImageUploaderProps> = ({
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*,.svg"
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

@ -461,23 +461,13 @@ const SignSettings = ({
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((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 = ({
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
allowBackgroundRemoval={true}
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: 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<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: 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)
};
}