mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
[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:
parent
429520d3f9
commit
98fa5dfcc1
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
137
frontend/src/core/utils/imageTransparency.ts
Normal file
137
frontend/src/core/utils/imageTransparency.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user