mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
OCR fix and Mobile QR changes (#5433)
# Description of Changes ## OCR / Tesseract path handling Makes tessDataPath resolution deterministic with priority: config > TESSDATA_PREFIX env > default. Updates language discovery to use runtimePathConfig.getTessDataPath() instead of raw config value. Ensure default OCR dir is debian based not alpine ## Mobile scanner: feature gating + new conversion settings Adds system.mobileScannerSettings (convert-to-PDF + resolution + page format + stretch) exposed via backend config and configurable in the proprietary admin UI. Enforces enableMobileScanner on the MobileScannerController endpoints (403 when disabled). Frontend mobile upload flow can now optionally convert received images to PDF (pdf-lib + canvas). ## Desktop/Tauri connectivity work Expands tauri-plugin-http permissions and enables dangerous-settings. Adds a very comprehensive multi-stage server connection diagnostic routine (with lots of logging). <img width="688" height="475" alt="image" src="https://github.com/user-attachments/assets/6f9c1aec-58c7-449b-96b0-52f25430d741" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] 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) - [ ] I have performed a self-review of my own code - [ ] 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) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### 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) - [ ] 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.
This commit is contained in:
@@ -8,6 +8,8 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
@@ -24,6 +26,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile;
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
@@ -127,15 +132,17 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
onClick={handleMobileUploadClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled={!isMobileUploadEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
backgroundColor: isMobileUploadEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isMobileUploadEnabled ? t('fileManager.mobileUploadNotAvailable', 'Mobile upload not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { withBasePath } from '@app/constants/app';
|
||||
import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils';
|
||||
|
||||
interface MobileUploadModalProps {
|
||||
opened: boolean;
|
||||
@@ -132,10 +133,25 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const blob = await downloadResponse.blob();
|
||||
const file = new File([blob], fileMetadata.filename, {
|
||||
let file = new File([blob], fileMetadata.filename, {
|
||||
type: fileMetadata.contentType || 'image/jpeg'
|
||||
});
|
||||
|
||||
// Convert images to PDF if enabled
|
||||
if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) {
|
||||
try {
|
||||
file = await convertImageToPdf(file, {
|
||||
imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined,
|
||||
pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined,
|
||||
stretchToFit: config?.mobileScannerStretchToFit,
|
||||
});
|
||||
console.log('Converted image to PDF:', file.name);
|
||||
} catch (convertError) {
|
||||
console.warn('Failed to convert image to PDF, using original file:', convertError);
|
||||
// Continue with original image file if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
processedFiles.current.add(fileMetadata.filename);
|
||||
setFilesReceived((prev) => prev + 1);
|
||||
onFilesReceived([file]);
|
||||
@@ -256,10 +272,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'mobileUpload.description',
|
||||
'Scan this QR code with your mobile device to upload photos directly to this page.'
|
||||
)}
|
||||
{config?.mobileScannerConvertToPdf !== false
|
||||
? t(
|
||||
'mobileUpload.description',
|
||||
'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.'
|
||||
)
|
||||
: t(
|
||||
'mobileUpload.descriptionNoConvert',
|
||||
'Scan this QR code with your mobile device to upload photos.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
@@ -308,10 +329,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
||||
{t(
|
||||
'mobileUpload.instructions',
|
||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
||||
)}
|
||||
{config?.mobileScannerConvertToPdf !== false
|
||||
? t(
|
||||
'mobileUpload.instructions',
|
||||
'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.'
|
||||
)
|
||||
: t(
|
||||
'mobileUpload.instructionsNoConvert',
|
||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface AppConfig {
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
mobileScannerConvertToPdf?: boolean;
|
||||
mobileScannerImageResolution?: string;
|
||||
mobileScannerPageFormat?: string;
|
||||
mobileScannerStretchToFit?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
|
||||
297
frontend/src/core/utils/imageToPdfUtils.ts
Normal file
297
frontend/src/core/utils/imageToPdfUtils.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { PDFDocument, PageSizes } from 'pdf-lib';
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
imageResolution?: 'full' | 'reduced';
|
||||
pageFormat?: 'keep' | 'A4' | 'letter';
|
||||
stretchToFit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to a PDF file
|
||||
* @param imageFile - The image file to convert (JPEG, PNG, etc.)
|
||||
* @param options - Conversion options
|
||||
* @returns A Promise that resolves to a PDF File object
|
||||
*/
|
||||
export async function convertImageToPdf(
|
||||
imageFile: File,
|
||||
options: ImageToPdfOptions = {}
|
||||
): Promise<File> {
|
||||
const {
|
||||
imageResolution = 'full',
|
||||
pageFormat = 'A4',
|
||||
stretchToFit = false,
|
||||
} = options;
|
||||
try {
|
||||
// Create a new PDF document
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Read the image file as an array buffer
|
||||
let imageBytes = await imageFile.arrayBuffer();
|
||||
|
||||
// Apply image resolution reduction if requested
|
||||
if (imageResolution === 'reduced') {
|
||||
const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side
|
||||
imageBytes = await reducedImage.arrayBuffer();
|
||||
}
|
||||
|
||||
// Embed the image based on its type
|
||||
let image;
|
||||
const imageType = imageFile.type.toLowerCase();
|
||||
|
||||
if (imageType === 'image/jpeg' || imageType === 'image/jpg') {
|
||||
image = await pdfDoc.embedJpg(imageBytes);
|
||||
} else if (imageType === 'image/png') {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
} else {
|
||||
// For other image types, try to convert to PNG first using canvas
|
||||
const convertedImage = await convertImageToPng(imageFile);
|
||||
const convertedBytes = await convertedImage.arrayBuffer();
|
||||
image = await pdfDoc.embedPng(convertedBytes);
|
||||
}
|
||||
|
||||
// Get image dimensions
|
||||
const { width: imageWidth, height: imageHeight } = image;
|
||||
|
||||
// Determine page dimensions based on pageFormat option
|
||||
let pageWidth: number;
|
||||
let pageHeight: number;
|
||||
|
||||
if (pageFormat === 'keep') {
|
||||
// Use original image dimensions
|
||||
pageWidth = imageWidth;
|
||||
pageHeight = imageHeight;
|
||||
} else if (pageFormat === 'letter') {
|
||||
// US Letter: 8.5" x 11" = 612 x 792 points
|
||||
pageWidth = PageSizes.Letter[0];
|
||||
pageHeight = PageSizes.Letter[1];
|
||||
} else {
|
||||
// A4: 210mm x 297mm = 595 x 842 points (default)
|
||||
pageWidth = PageSizes.A4[0];
|
||||
pageHeight = PageSizes.A4[1];
|
||||
}
|
||||
|
||||
// Adjust page orientation based on image orientation if using standard page size
|
||||
if (pageFormat !== 'keep') {
|
||||
const imageIsLandscape = imageWidth > imageHeight;
|
||||
const pageIsLandscape = pageWidth > pageHeight;
|
||||
|
||||
// Rotate page to match image orientation
|
||||
if (imageIsLandscape !== pageIsLandscape) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a page
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
|
||||
// Calculate image placement based on stretchToFit option
|
||||
let drawX: number;
|
||||
let drawY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
|
||||
if (stretchToFit || pageFormat === 'keep') {
|
||||
// Stretch/fill to page
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageHeight;
|
||||
} else {
|
||||
// Fit within page bounds while preserving aspect ratio
|
||||
const imageAspectRatio = imageWidth / imageHeight;
|
||||
const pageAspectRatio = pageWidth / pageHeight;
|
||||
|
||||
if (imageAspectRatio > pageAspectRatio) {
|
||||
// Image is wider than page - fit to width
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageWidth / imageAspectRatio;
|
||||
drawX = 0;
|
||||
drawY = (pageHeight - drawHeight) / 2; // Center vertically
|
||||
} else {
|
||||
// Image is taller than page - fit to height
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = pageHeight * imageAspectRatio;
|
||||
drawY = 0;
|
||||
drawX = (pageWidth - drawWidth) / 2; // Center horizontally
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the image on the page
|
||||
page.drawImage(image, {
|
||||
x: drawX,
|
||||
y: drawY,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
|
||||
// Save the PDF to bytes
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
||||
// Create a filename by replacing the image extension with .pdf
|
||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
|
||||
|
||||
// Create a File object from the PDF bytes
|
||||
const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
return pdfFile;
|
||||
} catch (error) {
|
||||
console.error('Error converting image to PDF:', error);
|
||||
throw new Error(
|
||||
`Failed to convert image to PDF: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to PNG using canvas
|
||||
* This is used for image types that pdf-lib doesn't directly support
|
||||
*/
|
||||
async function convertImageToPng(imageFile: File): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Create a canvas with the image dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// Draw the image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Convert canvas to blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const pngFilename = imageFile.name.replace(/\.[^.]+$/, '.png');
|
||||
const pngFile = new File([blob], pngFilename, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(pngFile);
|
||||
},
|
||||
'image/png',
|
||||
1.0
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce image resolution to a maximum dimension
|
||||
* @param imageFile - The image file to reduce
|
||||
* @param maxDimension - Maximum width or height in pixels
|
||||
* @returns A Promise that resolves to a reduced resolution image file
|
||||
*/
|
||||
async function reduceImageResolution(
|
||||
imageFile: File,
|
||||
maxDimension: number
|
||||
): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const { width, height } = img;
|
||||
|
||||
// Check if reduction is needed
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(imageFile); // No reduction needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new dimensions while preserving aspect ratio
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
if (width > height) {
|
||||
newWidth = maxDimension;
|
||||
newHeight = (height / width) * maxDimension;
|
||||
} else {
|
||||
newHeight = maxDimension;
|
||||
newWidth = (width / height) * maxDimension;
|
||||
}
|
||||
|
||||
// Create a canvas with the new dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// Draw the resized image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// Convert canvas to blob (preserve original format if possible)
|
||||
const outputType = imageFile.type.startsWith('image/')
|
||||
? imageFile.type
|
||||
: 'image/jpeg';
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const reducedFile = new File([blob], imageFile.name, {
|
||||
type: outputType,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(reducedFile);
|
||||
},
|
||||
outputType,
|
||||
0.9 // Quality (only applies to JPEG)
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image
|
||||
*/
|
||||
export function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
Reference in New Issue
Block a user