mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge 6b9e88734f into 3529849bca
This commit is contained in:
commit
3181e437ec
@ -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"
|
||||
|
||||
@ -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<ImageUploaderProps> = ({
|
||||
@ -16,9 +19,45 @@ 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);
|
||||
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<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 +108,27 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
accept="image/*"
|
||||
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