Merge pull request #343 from TiwariLokesh/feature/multiple-images-to-pdf

feat: support multiple images to PDF conversion
This commit is contained in:
Chesterkxng
2026-03-24 23:21:24 +01:00
committed by GitHub
9 changed files with 287 additions and 161 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+14 -2
View File
@@ -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",
+56 -18
View File
@@ -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<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
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({
</Typography>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
onClick={() => handleRemove(index)}
>
✖
</Box>
@@ -162,7 +200,7 @@ export default function ToolMultiImageInput({
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
multiple
/>
</Box>
);
@@ -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(<ConvertToPdf title="Test PDF" />);
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(<ConvertToPdf title="Test PDF" />);
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(<ConvertToPdf title="Test PDF" />);
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();
});
});
+73 -46
View File
@@ -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<File | null>(null);
const { t } = useTranslation('pdf');
const [input, setInput] = useState<MultiImageInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [imageSize, setImageSize] = useState<{
widthMm: number;
heightMm: number;
widthPx: number;
heightPx: number;
} | null>(null);
const [imageSizes, setImageSizes] = useState<ImageSize[] | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(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 (
<ToolContent<FormValues, File | null>
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialFormValues}
initialValues={initialValues}
compute={compute}
inputComponent={
<Box>
<ToolImageInput
<ToolMultipleImageInput
type="image"
value={input}
onChange={setInput}
accept={[
'image/png',
'image/jpeg',
'image/webp',
'image/tiff',
'image/gif',
'image/heic',
'image/heif',
'image/x-adobe-dng',
'image/x-canon-cr2',
'image/x-nikon-nef',
'image/x-sony-arw',
'image/vnd.adobe.photoshop'
'image/heif'
]}
title="Input Image"
title={t('convertToPdf.inputTitle')}
/>
</Box>
}
@@ -76,7 +84,9 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
component: (
<Stack spacing={4}>
<Box>
<Typography variant="h6">PDF Type</Typography>
<Typography variant="h6">
{t('convertToPdf.options.type')}
</Typography>
<RadioGroup
row
value={values.pageType}
@@ -87,27 +97,37 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
<FormControlLabel
value="full"
control={<Radio />}
label="Full Size (Same as Image)"
label={t('convertToPdf.options.fullsize')}
/>
<FormControlLabel
value="a4"
control={<Radio />}
label="A4 Page"
label={t('convertToPdf.options.a4')}
/>
</RadioGroup>
{values.pageType === 'full' && imageSize && (
<Typography variant="body2" color="text.secondary">
Image size: {imageSize.widthMm.toFixed(1)} ×{' '}
{imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '}
{imageSize.heightPx} px)
</Typography>
{values.pageType === 'full' && imageSizes && (
<Stack spacing={1} mt={1}>
{imageSizes.map((size, index) => (
<Typography
key={index}
variant="body2"
color="text.secondary"
>
{t('convertToPdf.options.image')} {index + 1}:{' '}
{size.widthMm.toFixed(1)} × {size.heightMm.toFixed(1)}{' '}
mm ({size.widthPx} × {size.heightPx} px)
</Typography>
))}
</Stack>
)}
</Box>
{values.pageType === 'a4' && (
<Box>
<Typography variant="h6">Orientation</Typography>
<Typography variant="h6">
{t('convertToPdf.options.orientation')}
</Typography>
<RadioGroup
row
value={values.orientation}
@@ -121,12 +141,12 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
<FormControlLabel
value="portrait"
control={<Radio />}
label="Portrait (Vertical)"
label={t('convertToPdf.options.portrait')}
/>
<FormControlLabel
value="landscape"
control={<Radio />}
label="Landscape (Horizontal)"
label={t('convertToPdf.options.landscape')}
/>
</RadioGroup>
</Box>
@@ -134,8 +154,10 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
{values.pageType === 'a4' && (
<Box>
<Typography variant="h6">Scale</Typography>
<Typography>Scale image: {values.scale}%</Typography>
<Typography variant="h6">
{t('convertToPdf.options.scale')}
</Typography>
<Typography>{values.scale}%</Typography>
<Slider
value={values.scale}
onChange={(_, v) => updateField('scale', v as number)}
@@ -152,7 +174,12 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
] as const;
}}
resultComponent={
<ToolFileResult title="Output PDF" value={result} extension="pdf" />
<ToolFileResult
title={t('convertToPdf.resultTitle')}
value={result}
extension="pdf"
loading={isProcessing}
/>
}
/>
);
+2 -6
View File
@@ -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'))
});
+127 -45
View File
@@ -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<LoadedImage> {
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<ComputeResult> {
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));
}
}
+7 -6
View File
@@ -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;
}