mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-05 08:24:56 +05:30
refactor code structure - created types.ts and service.ts
This commit is contained in:
parent
0ee424fe12
commit
691bef8304
4 changed files with 240 additions and 213 deletions
|
|
@ -3,68 +3,36 @@ 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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,23 +4,22 @@ import {
|
|||
Typography,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio
|
||||
Radio,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
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';
|
||||
import { FormValues, Orientation, PageType, initialValues } from './types';
|
||||
import { buildPdf } from './service';
|
||||
|
||||
const initialFormValues: FormValues = initialValues;
|
||||
|
||||
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;
|
||||
|
|
@ -28,81 +27,27 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
|
|||
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);
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
compute(input, scale);
|
||||
}, [input, orientation, pageType]);
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
<ToolContent<FormValues, File | null>
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={initialFormValues}
|
||||
compute={compute}
|
||||
inputComponent={
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<Box>
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
|
|
@ -120,82 +65,95 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
|
|||
'image/x-sony-arw',
|
||||
'image/vnd.adobe.photoshop'
|
||||
]}
|
||||
title={'Input Image'}
|
||||
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>
|
||||
}
|
||||
getGroups={({ values, updateField }) => {
|
||||
return [
|
||||
{
|
||||
title: '',
|
||||
component: (
|
||||
<Stack spacing={4}>
|
||||
<Box>
|
||||
<Typography variant="h6">PDF Type</Typography>
|
||||
<RadioGroup
|
||||
row
|
||||
value={values.pageType}
|
||||
onChange={(e) =>
|
||||
updateField('pageType', e.target.value as PageType)
|
||||
}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="full"
|
||||
control={<Radio />}
|
||||
label="Full Size (Same as Image)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="a4"
|
||||
control={<Radio />}
|
||||
label="A4 Page"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{values.pageType === 'a4' && (
|
||||
<Box>
|
||||
<Typography variant="h6">Orientation</Typography>
|
||||
<RadioGroup
|
||||
row
|
||||
value={values.orientation}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'orientation',
|
||||
e.target.value as Orientation
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="portrait"
|
||||
control={<Radio />}
|
||||
label="Portrait (Vertical)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="landscape"
|
||||
control={<Radio />}
|
||||
label="Landscape (Horizontal)"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{values.pageType === 'a4' && (
|
||||
<Box>
|
||||
<Typography variant="h6">Scale</Typography>
|
||||
<Typography>Scale image: {values.scale}%</Typography>
|
||||
<Slider
|
||||
value={values.scale}
|
||||
onChange={(_, v) => updateField('scale', v as number)}
|
||||
min={10}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
] as const;
|
||||
}}
|
||||
resultComponent={
|
||||
<ToolFileResult title={'Output PDF'} value={result} extension={'pdf'} />
|
||||
<ToolFileResult title="Output PDF" value={result} extension="pdf" />
|
||||
}
|
||||
compute={() => compute(input, scale)}
|
||||
setInput={setInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
80
src/pages/tools/pdf/convert-to-pdf/service.ts
Normal file
80
src/pages/tools/pdf/convert-to-pdf/service.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import jsPDF from 'jspdf';
|
||||
import { Orientation, PageType, ImageSize } from './types';
|
||||
|
||||
export interface ComputeOptions {
|
||||
file: File;
|
||||
pageType: PageType;
|
||||
orientation: Orientation;
|
||||
scale: number; // 10..100 (only applied for A4)
|
||||
}
|
||||
|
||||
export interface ComputeResult {
|
||||
pdfFile: File;
|
||||
imageSize: ImageSize;
|
||||
}
|
||||
|
||||
export async function buildPdf({
|
||||
file,
|
||||
pageType,
|
||||
orientation,
|
||||
scale
|
||||
}: ComputeOptions): Promise<ComputeResult> {
|
||||
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);
|
||||
|
||||
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 * (scale / 100);
|
||||
|
||||
const finalHeight =
|
||||
pageType === 'full' ? pageHeight : img.height * fitScale * (scale / 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';
|
||||
|
||||
return {
|
||||
pdfFile: new File([blob], fileName, { type: 'application/pdf' }),
|
||||
imageSize: {
|
||||
widthMm: imgWidthMm,
|
||||
heightMm: imgHeightMm,
|
||||
widthPx: img.width,
|
||||
heightPx: img.height
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
}
|
||||
21
src/pages/tools/pdf/convert-to-pdf/types.ts
Normal file
21
src/pages/tools/pdf/convert-to-pdf/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type Orientation = 'portrait' | 'landscape';
|
||||
export type PageType = 'a4' | 'full';
|
||||
|
||||
export interface ImageSize {
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
pageType: PageType;
|
||||
orientation: Orientation;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export const initialValues: FormValues = {
|
||||
pageType: 'full',
|
||||
orientation: 'portrait',
|
||||
scale: 100
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue