diff --git a/package-lock.json b/package-lock.json index 250551e..95ba9ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "dayjs": "^1.11.13", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", + "heic-to": "^1.4.2", "heic2any": "^0.0.4", "i18next": "^25.3.2", "i18next-browser-languagedetector": "^8.2.0", @@ -7610,6 +7611,12 @@ "node": ">= 0.4" } }, + "node_modules/heic-to": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz", + "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==", + "license": "LGPL-3.0" + }, "node_modules/heic2any": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", diff --git a/package.json b/package.json index 8857a6e..427b58d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dayjs": "^1.11.13", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", + "heic-to": "^1.4.2", "heic2any": "^0.0.4", "i18next": "^25.3.2", "i18next-browser-languagedetector": "^8.2.0", diff --git a/public/locales/en/pdf.json b/public/locales/en/pdf.json index 82e5309..288e392 100644 --- a/public/locales/en/pdf.json +++ b/public/locales/en/pdf.json @@ -65,8 +65,20 @@ }, "convertToPdf": { "title": "Images to PDF", - "description": "Convert various image formats (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) to PDF, with options to scale the image and choose page orientation.", - "shortDescription": "Convert images to PDF with scale and orientation control" + "description": "Convert various image formats (PNG, GIF, JPG, WEBP, HEIC, HEIF) to PDF, with options to scale the image and choose page orientation.", + "shortDescription": "Convert images to PDF with scale and orientation control", + "inputTitle": "Input Images", + "resultTitle": "Output PDF", + "options": { + "type": "PDF Type", + "orientation": "Orientation", + "scale": "Scale", + "portrait": "Portrait (Vertical)", + "landscape": "Landscape (Horizontal)", + "fullsize": "Full Size (Image size)", + "a4": "A4 Page", + "image": "Image" + } }, "protectPdf": { "description": "Add password protection to your PDF files securely in your browser", diff --git a/src/components/input/ToolMultipleImageInput.tsx b/src/components/input/ToolMultipleImageInput.tsx index fe5e2f1..13094dc 100644 --- a/src/components/input/ToolMultipleImageInput.tsx +++ b/src/components/input/ToolMultipleImageInput.tsx @@ -5,6 +5,7 @@ import InputHeader from '../InputHeader'; import InputFooter from './InputFooter'; import ImageIcon from '@mui/icons-material/Image'; import { useTranslation } from 'react-i18next'; +import { heicTo, isHeic } from 'heic-to'; interface MultiImageInputComponentProps { accept: string[]; @@ -30,16 +31,44 @@ export default function ToolMultiImageInput({ const { t } = useTranslation(); const fileInputRef = useRef(null); - const handleFileChange = (event: React.ChangeEvent) => { + const handleFileChange = async ( + event: React.ChangeEvent + ) => { const files = event.target.files; - if (files) { - const newFiles: MultiImageInput[] = Array.from(files).map((file) => ({ - file, - order: value.length, - preview: URL.createObjectURL(file) - })); - onChange([...value, ...newFiles]); - } + if (!files) return; + + const newFiles: MultiImageInput[] = await Promise.all( + Array.from(files).map(async (file, index) => { + let processedFile = file; + + try { + if (await isHeic(file)) { + const convertedBlob = await heicTo({ + blob: file, + type: 'image/jpeg' + }); + + processedFile = new File( + [convertedBlob], + file.name.replace(/\.[^/.]+$/, '') + '.jpg', + { type: 'image/jpeg' } + ); + } + } catch (err) { + console.warn('HEIC conversion failed:', file.name, err); + } + + return { + file: processedFile, + order: value.length + index, + preview: URL.createObjectURL(processedFile) + }; + }) + ); + + onChange([...value, ...newFiles]); + + event.target.value = ''; }; const handleImportClick = () => { @@ -47,15 +76,27 @@ export default function ToolMultiImageInput({ }; function handleClear() { + value.forEach((f) => { + if (f.preview) URL.revokeObjectURL(f.preview); + }); onChange([]); } + function handleRemove(index: number) { + const fileToRemove = value[index]; + if (fileToRemove?.preview) { + URL.revokeObjectURL(fileToRemove.preview); + } + + const updatedFiles = value.filter((_, i) => i !== index); + onChange(updatedFiles); + } + function fileNameTruncate(fileName: string) { const maxLength = 10; - if (fileName.length > maxLength) { - return fileName.slice(0, maxLength) + '...'; - } - return fileName; + return fileName.length > maxLength + ? fileName.slice(0, maxLength) + '...' + : fileName; } return ( @@ -137,10 +178,7 @@ export default function ToolMultiImageInput({ { - const updatedFiles = value.filter((_, i) => i !== index); - onChange(updatedFiles); - }} + onClick={() => handleRemove(index)} > ✖ @@ -162,7 +200,7 @@ export default function ToolMultiImageInput({ type="file" accept={accept.join(',')} onChange={handleFileChange} - multiple={true} + multiple /> ); diff --git a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx deleted file mode 100644 index 107039a..0000000 --- a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import ConvertToPdf from './index'; -import { vi } from 'vitest'; -import '@testing-library/jest-dom'; - -describe('ConvertToPdf', () => { - it('renders with default state values (full, portrait hidden, no scale shown)', () => { - render(); - - expect(screen.getByLabelText(/Full Size \(Same as Image\)/i)).toBeChecked(); - - expect(screen.queryByLabelText(/A4 Page/i)).toBeInTheDocument(); - expect(screen.queryByLabelText(/Portrait/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Scale image:/i)).not.toBeInTheDocument(); - }); - - it('switches to A4 page type and shows orientation and scale', () => { - render(); - - const a4Option = screen.getByLabelText(/A4 Page/i); - fireEvent.click(a4Option); - expect(a4Option).toBeChecked(); - - expect(screen.getByLabelText(/Portrait/i)).toBeChecked(); - expect(screen.getByText(/Scale image:\s*100%/i)).toBeInTheDocument(); - }); - - it('updates scale when slider moves (after switching to A4)', () => { - render(); - - fireEvent.click(screen.getByLabelText(/A4 Page/i)); - - const slider = screen.getByRole('slider'); - fireEvent.change(slider, { target: { value: 80 } }); - - expect(screen.getByText(/Scale image:\s*80%/i)).toBeInTheDocument(); - }); -}); diff --git a/src/pages/tools/pdf/convert-to-pdf/index.tsx b/src/pages/tools/pdf/convert-to-pdf/index.tsx index 750e843..59fe785 100644 --- a/src/pages/tools/pdf/convert-to-pdf/index.tsx +++ b/src/pages/tools/pdf/convert-to-pdf/index.tsx @@ -9,63 +9,71 @@ import { } from '@mui/material'; import React, { useState } from 'react'; import ToolContent from '@components/ToolContent'; -import ToolImageInput from 'components/input/ToolImageInput'; +import ToolMultipleImageInput, { + MultiImageInput +} from '@components/input/ToolMultipleImageInput'; import ToolFileResult from 'components/result/ToolFileResult'; import { ToolComponentProps } from '@tools/defineTool'; -import { FormValues, Orientation, PageType, initialValues } from './types'; +import { Orientation, PageType, InitialValuesType, ImageSize } from './types'; import { buildPdf } from './service'; +import { useTranslation } from 'react-i18next'; -const initialFormValues: FormValues = initialValues; +const initialValues: InitialValuesType = { + pageType: 'full', + orientation: 'portrait', + scale: 100 +}; export default function ConvertToPdf({ title }: ToolComponentProps) { - const [input, setInput] = useState(null); + const { t } = useTranslation('pdf'); + const [input, setInput] = useState([]); const [result, setResult] = useState(null); - const [imageSize, setImageSize] = useState<{ - widthMm: number; - heightMm: number; - widthPx: number; - heightPx: number; - } | null>(null); + const [imageSizes, setImageSizes] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); - const compute = async (values: FormValues) => { - if (!input) return; - const { pdfFile, imageSize } = await buildPdf({ - file: input, - pageType: values.pageType, - orientation: values.orientation, - scale: values.scale - }); - setResult(pdfFile); - setImageSize(imageSize); + const compute = async ( + optionsValues: InitialValuesType, + input: MultiImageInput[] + ) => { + if (!input.length) return; + + setIsProcessing(true); + + try { + const files = input.sort((a, b) => a.order - b.order).map((i) => i.file); + + const { pdfFile, imageSizes } = await buildPdf(files, optionsValues); + setResult(pdfFile); + setImageSizes(imageSizes); + } catch (error) { + throw new Error('Error converting image(s) to PDF:' + error); + } finally { + setIsProcessing(false); + } }; return ( - + - } @@ -76,7 +84,9 @@ export default function ConvertToPdf({ title }: ToolComponentProps) { component: ( - PDF Type + + {t('convertToPdf.options.type')} + } - label="Full Size (Same as Image)" + label={t('convertToPdf.options.fullsize')} /> } - label="A4 Page" + label={t('convertToPdf.options.a4')} /> - {values.pageType === 'full' && imageSize && ( - - Image size: {imageSize.widthMm.toFixed(1)} ×{' '} - {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} - {imageSize.heightPx} px) - + {values.pageType === 'full' && imageSizes && ( + + {imageSizes.map((size, index) => ( + + {t('convertToPdf.options.image')} {index + 1}:{' '} + {size.widthMm.toFixed(1)} × {size.heightMm.toFixed(1)}{' '} + mm ({size.widthPx} × {size.heightPx} px) + + ))} + )} {values.pageType === 'a4' && ( - Orientation + + {t('convertToPdf.options.orientation')} + } - label="Portrait (Vertical)" + label={t('convertToPdf.options.portrait')} /> } - label="Landscape (Horizontal)" + label={t('convertToPdf.options.landscape')} /> @@ -134,8 +154,10 @@ export default function ConvertToPdf({ title }: ToolComponentProps) { {values.pageType === 'a4' && ( - Scale - Scale image: {values.scale}% + + {t('convertToPdf.options.scale')} + + {values.scale}% updateField('scale', v as number)} @@ -152,7 +174,12 @@ export default function ConvertToPdf({ title }: ToolComponentProps) { ] as const; }} resultComponent={ - + } /> ); diff --git a/src/pages/tools/pdf/convert-to-pdf/meta.ts b/src/pages/tools/pdf/convert-to-pdf/meta.ts index 0fa3879..69310c5 100644 --- a/src/pages/tools/pdf/convert-to-pdf/meta.ts +++ b/src/pages/tools/pdf/convert-to-pdf/meta.ts @@ -19,14 +19,10 @@ export const tool = defineTool('pdf', { 'jpeg', 'png', 'gif', - 'tiff', 'webp', 'heic', - 'raw', - 'psd', - 'svg', - 'quality', - 'compression' + 'heif', + 'quality' ], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/pdf/convert-to-pdf/service.ts b/src/pages/tools/pdf/convert-to-pdf/service.ts index 4a1eb22..8c25b96 100644 --- a/src/pages/tools/pdf/convert-to-pdf/service.ts +++ b/src/pages/tools/pdf/convert-to-pdf/service.ts @@ -1,40 +1,98 @@ import jsPDF from 'jspdf'; -import { Orientation, PageType, ImageSize } from './types'; +import { ImageSize, InitialValuesType, LoadedImage } from './types'; +import { heicTo, isHeic } from 'heic-to'; -export interface ComputeOptions { - file: File; - pageType: PageType; - orientation: Orientation; - scale: number; // 10..100 (only applied for A4) +const pxToMm = (px: number) => px * 0.264583; + +function returnImageFormat(file: File): 'PNG' | 'JPEG' { + const type = file.type.toLowerCase(); + const name = file.name.toLowerCase(); + + if (type.includes('png') || name.endsWith('.png')) return 'PNG'; + + if ( + type.includes('jpeg') || + type.includes('jpg') || + name.endsWith('.jpg') || + name.endsWith('.jpeg') + ) { + return 'JPEG'; + } + + if (type.includes('webp') || name.endsWith('.webp')) return 'JPEG'; + + if (type.includes('gif') || name.endsWith('.gif')) return 'JPEG'; + + console.warn(`Unsupported format: ${file.type}, defaulting to JPEG`); + return 'JPEG'; } -export interface ComputeResult { - pdfFile: File; - imageSize: ImageSize; +async function loadImage(file: File): Promise { + let processedFile = file; + + if (await isHeic(file)) { + const convertedBlob = await heicTo({ + blob: file, + type: 'image/jpeg' + }); + + processedFile = new File([convertedBlob], file.name + '.jpg', { + type: 'image/jpeg' + }); + } + + const image = new Image(); + const objectUrl = URL.createObjectURL(processedFile); + image.src = objectUrl; + await image.decode(); + return { + image, + objectUrl, + format: returnImageFormat(file), + filename: processedFile.name + }; } -export async function buildPdf({ - file, - pageType, - orientation, - scale -}: ComputeOptions): Promise { - const img = new Image(); - img.src = URL.createObjectURL(file); +export async function buildPdf( + files: File[], + options: InitialValuesType +): Promise<{ pdfFile: File; imageSizes: ImageSize[] }> { + if (!files.length) { + throw new Error('No files selected'); + } + + const { pageType, orientation, scale } = options; + + let loadedImages: LoadedImage[] = []; + const imageSizes: ImageSize[] = []; + + const results = await Promise.all( + files.map((file) => + loadImage(file).catch((err) => { + console.warn(`Skipping ${file.name}:`, err); + return null; + }) + ) + ); + + loadedImages = results.filter((img): img is LoadedImage => img !== null); + + if (!loadedImages.length) { + throw new Error('No images could be loaded'); + } + + const { image: firstImage, filename: firstImageFileName } = loadedImages[0]; + const firstImageWidthMm = pxToMm(firstImage.width); + const firstImageHeightMm = pxToMm(firstImage.height); try { - await img.decode(); - - const pxToMm = (px: number) => px * 0.264583; - const imgWidthMm = pxToMm(img.width); - const imgHeightMm = pxToMm(img.height); - const pdf = pageType === 'full' ? new jsPDF({ - orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait', + orientation: + firstImageWidthMm > firstImageHeightMm ? 'landscape' : 'portrait', unit: 'mm', - format: [imgWidthMm, imgHeightMm] + format: [firstImageWidthMm, firstImageHeightMm] }) : new jsPDF({ orientation, @@ -44,37 +102,61 @@ export async function buildPdf({ pdf.setDisplayMode('fullwidth'); - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); + for (let index = 0; index < loadedImages.length; index += 1) { + const { image, format } = loadedImages[index]; + const imageWidthMm = pxToMm(image.width); + const imageHeightMm = pxToMm(image.height); - const widthRatio = pageWidth / img.width; - const heightRatio = pageHeight / img.height; - const fitScale = Math.min(widthRatio, heightRatio); + if (index > 0) { + if (pageType === 'full') { + pdf.addPage( + [imageWidthMm, imageHeightMm], + imageWidthMm > imageHeightMm ? 'landscape' : 'portrait' + ); + } else { + pdf.addPage('a4', orientation); + } + } - const finalWidth = - pageType === 'full' ? pageWidth : img.width * fitScale * (scale / 100); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); - const finalHeight = - pageType === 'full' ? pageHeight : img.height * fitScale * (scale / 100); + const widthRatio = pageWidth / image.width; + const heightRatio = pageHeight / image.height; + const fitScale = Math.min(widthRatio, heightRatio); - const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2; - const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2; + const finalWidth = + pageType === 'full' + ? pageWidth + : image.width * fitScale * (scale / 100); - pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight); + const finalHeight = + pageType === 'full' + ? pageHeight + : image.height * fitScale * (scale / 100); + + const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2; + const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2; + + pdf.addImage(image, format, x, y, finalWidth, finalHeight); + imageSizes.push({ + widthMm: imageWidthMm, + heightMm: imageHeightMm, + widthPx: image.width, + heightPx: image.height + }); + } const blob = pdf.output('blob'); - const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf'; + const firstFileName = firstImageFileName.replace(/\.[^/.]+$/, ''); + const fileName = + files.length === 1 ? `${firstFileName}.pdf` : 'merged-images.pdf'; return { pdfFile: new File([blob], fileName, { type: 'application/pdf' }), - imageSize: { - widthMm: imgWidthMm, - heightMm: imgHeightMm, - widthPx: img.width, - heightPx: img.height - } + imageSizes: imageSizes }; } finally { - URL.revokeObjectURL(img.src); + loadedImages.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl)); } } diff --git a/src/pages/tools/pdf/convert-to-pdf/types.ts b/src/pages/tools/pdf/convert-to-pdf/types.ts index 93b9a42..42d0567 100644 --- a/src/pages/tools/pdf/convert-to-pdf/types.ts +++ b/src/pages/tools/pdf/convert-to-pdf/types.ts @@ -8,14 +8,15 @@ export interface ImageSize { heightPx: number; } -export interface FormValues { +export interface InitialValuesType { pageType: PageType; orientation: Orientation; scale: number; } -export const initialValues: FormValues = { - pageType: 'full', - orientation: 'portrait', - scale: 100 -}; +export interface LoadedImage { + image: HTMLImageElement; + objectUrl: string; + format: 'PNG' | 'JPEG'; + filename: string; +}