mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-29 16:16:02 +00:00
Add full Images to PDF functionality with language updates and tests
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ConvertToPdf from './index';
|
||||
import { vi } from 'vitest';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
it('should render with default state values', () => {
|
||||
render(<ConvertToPdf title="Test PDF" />);
|
||||
expect(screen.getByLabelText(/A4 Page/i)).toBeChecked();
|
||||
expect(screen.getByLabelText(/Portrait/i)).toBeChecked();
|
||||
expect(screen.getByText(/Scale image: 100%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch to full page type when selected', () => {
|
||||
render(<ConvertToPdf title="Test PDF" />);
|
||||
const fullOption = screen.getByLabelText(/Full Size/i);
|
||||
fireEvent.click(fullOption);
|
||||
expect(fullOption).toBeChecked();
|
||||
});
|
||||
|
||||
it('should update scale when slider moves', () => {
|
||||
render(<ConvertToPdf title="Test PDF" />);
|
||||
const slider = screen.getByRole('slider');
|
||||
fireEvent.change(slider, { target: { value: 80 } });
|
||||
expect(screen.getByText(/Scale image: 80%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change orientation to landscape', () => {
|
||||
render(<ConvertToPdf title="Test PDF" />);
|
||||
const landscapeRadio = screen.getByLabelText(/Landscape/i);
|
||||
fireEvent.click(landscapeRadio);
|
||||
expect(landscapeRadio).toBeChecked();
|
||||
});
|
||||
|
||||
vi.mock('jspdf', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
setDisplayMode: vi.fn(),
|
||||
internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } },
|
||||
addImage: vi.fn(),
|
||||
output: vi.fn().mockReturnValue(new Blob())
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
it('should call jsPDF and addImage when compute is triggered', async () => {
|
||||
const createObjectURLStub = vi
|
||||
.spyOn(global.URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:url');
|
||||
|
||||
vi.mock('components/input/ToolImageInput', () => ({
|
||||
default: ({ onChange }: any) => (
|
||||
<input
|
||||
type="file"
|
||||
title="Input Image"
|
||||
onChange={(e) => onChange(e.target.files[0])}
|
||||
/>
|
||||
)
|
||||
}));
|
||||
|
||||
const mockFile = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' });
|
||||
render(<ConvertToPdf title="Test PDF" />);
|
||||
|
||||
const fileInput = screen.getByTitle(/Input Image/i);
|
||||
fireEvent.change(fileInput, { target: { files: [mockFile] } });
|
||||
|
||||
const jsPDF = (await import('jspdf')).default;
|
||||
expect(jsPDF).toHaveBeenCalled();
|
||||
|
||||
createObjectURLStub.mockRestore();
|
||||
});
|
||||
201
src/pages/tools/pdf/convert-to-pdf/index.tsx
Normal file
201
src/pages/tools/pdf/convert-to-pdf/index.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Box,
|
||||
Slider,
|
||||
Typography,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
import ToolImageInput from 'components/input/ToolImageInput';
|
||||
import ToolFileResult from 'components/result/ToolFileResult';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
export default function ConvertToPdf({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [scale, setScale] = useState<number>(100);
|
||||
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
|
||||
'portrait'
|
||||
);
|
||||
const [pageType, setPageType] = useState<'a4' | 'full'>('a4');
|
||||
const [imageSize, setImageSize] = useState<{
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
} | null>(null);
|
||||
|
||||
const compute = async (file: File | null, currentScale: number) => {
|
||||
if (!file) return;
|
||||
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
await img.decode();
|
||||
|
||||
const pxToMm = (px: number) => px * 0.264583;
|
||||
const imgWidthMm = pxToMm(img.width);
|
||||
const imgHeightMm = pxToMm(img.height);
|
||||
setImageSize({
|
||||
widthMm: imgWidthMm,
|
||||
heightMm: imgHeightMm,
|
||||
widthPx: img.width,
|
||||
heightPx: img.height
|
||||
});
|
||||
|
||||
const pdf =
|
||||
pageType === 'full'
|
||||
? new jsPDF({
|
||||
orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait',
|
||||
unit: 'mm',
|
||||
format: [imgWidthMm, imgHeightMm]
|
||||
})
|
||||
: new jsPDF({
|
||||
orientation,
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
pdf.setDisplayMode('fullwidth');
|
||||
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
const widthRatio = pageWidth / img.width;
|
||||
const heightRatio = pageHeight / img.height;
|
||||
const fitScale = Math.min(widthRatio, heightRatio);
|
||||
|
||||
const finalWidth =
|
||||
pageType === 'full'
|
||||
? pageWidth
|
||||
: img.width * fitScale * (currentScale / 100);
|
||||
const finalHeight =
|
||||
pageType === 'full'
|
||||
? pageHeight
|
||||
: img.height * fitScale * (currentScale / 100);
|
||||
|
||||
const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2;
|
||||
const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2;
|
||||
|
||||
pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight);
|
||||
|
||||
const blob = pdf.output('blob');
|
||||
const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf';
|
||||
setResult(new File([blob], fileName, { type: 'application/pdf' }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
compute(input, scale);
|
||||
}, [input, orientation, pageType]);
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<ToolImageInput
|
||||
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'
|
||||
]}
|
||||
title={'Input Image'}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>PDF Type</Typography>
|
||||
<RadioGroup
|
||||
row
|
||||
value={pageType}
|
||||
onChange={(e) => setPageType(e.target.value as 'a4' | 'full')}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="a4"
|
||||
control={<Radio />}
|
||||
label="A4 Page"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="full"
|
||||
control={<Radio />}
|
||||
label="Full Size (Same as Image)"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{pageType === 'a4' && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography gutterBottom>Orientation</Typography>
|
||||
<RadioGroup
|
||||
row
|
||||
value={orientation}
|
||||
onChange={(e) =>
|
||||
setOrientation(e.target.value as 'portrait' | 'landscape')
|
||||
}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="portrait"
|
||||
control={<Radio />}
|
||||
label="Portrait (Vertical)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="landscape"
|
||||
control={<Radio />}
|
||||
label="Landscape (Horizontal)"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Scale image: {scale}%</Typography>
|
||||
<Slider
|
||||
value={scale}
|
||||
onChange={(_, v) => setScale(v as number)}
|
||||
onChangeCommitted={(_, v) => compute(input, v as number)}
|
||||
min={10}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult title={'Output PDF'} value={result} extension={'pdf'} />
|
||||
}
|
||||
compute={() => compute(input, scale)}
|
||||
setInput={setInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/pages/tools/pdf/convert-to-pdf/meta.ts
Normal file
32
src/pages/tools/pdf/convert-to-pdf/meta.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('pdf', {
|
||||
i18n: {
|
||||
name: 'pdf:convertToPdf.title',
|
||||
description: 'pdf:convertToPdf.description',
|
||||
shortDescription: 'pdf:convertToPdf.shortDescription'
|
||||
},
|
||||
|
||||
path: 'convert-to-pdf',
|
||||
icon: 'ph:file-pdf-thin',
|
||||
|
||||
keywords: [
|
||||
'convert',
|
||||
'pdf',
|
||||
'image',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'tiff',
|
||||
'webp',
|
||||
'heic',
|
||||
'raw',
|
||||
'psd',
|
||||
'svg',
|
||||
'quality',
|
||||
'compression'
|
||||
],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { tool as compressPdfTool } from './compress-pdf/meta';
|
||||
import { tool as protectPdfTool } from './protect-pdf/meta';
|
||||
import { meta as pdfToEpub } from './pdf-to-epub/meta';
|
||||
import { tool as pdfEditor } from './editor/meta';
|
||||
import { tool as convertToPdf } from './convert-to-pdf/meta';
|
||||
|
||||
export const pdfTools: DefinedTool[] = [
|
||||
pdfEditor,
|
||||
@@ -16,5 +17,6 @@ export const pdfTools: DefinedTool[] = [
|
||||
protectPdfTool,
|
||||
mergePdf,
|
||||
pdfToEpub,
|
||||
pdfPdfToPng
|
||||
pdfPdfToPng,
|
||||
convertToPdf
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user