mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-29 16:16:02 +00:00
refactor code structure - created types.ts and service.ts
This commit is contained in:
@@ -3,68 +3,36 @@ import ConvertToPdf from './index';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
it('should render with default state values', () => {
|
describe('ConvertToPdf', () => {
|
||||||
render(<ConvertToPdf title="Test PDF" />);
|
it('renders with default state values (full, portrait hidden, no scale shown)', () => {
|
||||||
expect(screen.getByLabelText(/A4 Page/i)).toBeChecked();
|
render(<ConvertToPdf title="Test PDF" />);
|
||||||
expect(screen.getByLabelText(/Portrait/i)).toBeChecked();
|
|
||||||
expect(screen.getByText(/Scale image: 100%/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/Full Size \(Same as Image\)/i)).toBeChecked();
|
||||||
});
|
|
||||||
|
expect(screen.queryByLabelText(/A4 Page/i)).toBeInTheDocument();
|
||||||
it('should switch to full page type when selected', () => {
|
expect(screen.queryByLabelText(/Portrait/i)).not.toBeInTheDocument();
|
||||||
render(<ConvertToPdf title="Test PDF" />);
|
expect(screen.queryByText(/Scale image:/i)).not.toBeInTheDocument();
|
||||||
const fullOption = screen.getByLabelText(/Full Size/i);
|
});
|
||||||
fireEvent.click(fullOption);
|
|
||||||
expect(fullOption).toBeChecked();
|
it('switches to A4 page type and shows orientation and scale', () => {
|
||||||
});
|
render(<ConvertToPdf title="Test PDF" />);
|
||||||
|
|
||||||
it('should update scale when slider moves', () => {
|
const a4Option = screen.getByLabelText(/A4 Page/i);
|
||||||
render(<ConvertToPdf title="Test PDF" />);
|
fireEvent.click(a4Option);
|
||||||
const slider = screen.getByRole('slider');
|
expect(a4Option).toBeChecked();
|
||||||
fireEvent.change(slider, { target: { value: 80 } });
|
|
||||||
expect(screen.getByText(/Scale image: 80%/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/Portrait/i)).toBeChecked();
|
||||||
});
|
expect(screen.getByText(/Scale image:\s*100%/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
it('should change orientation to landscape', () => {
|
|
||||||
render(<ConvertToPdf title="Test PDF" />);
|
it('updates scale when slider moves (after switching to A4)', () => {
|
||||||
const landscapeRadio = screen.getByLabelText(/Landscape/i);
|
render(<ConvertToPdf title="Test PDF" />);
|
||||||
fireEvent.click(landscapeRadio);
|
|
||||||
expect(landscapeRadio).toBeChecked();
|
fireEvent.click(screen.getByLabelText(/A4 Page/i));
|
||||||
});
|
|
||||||
|
const slider = screen.getByRole('slider');
|
||||||
vi.mock('jspdf', () => {
|
fireEvent.change(slider, { target: { value: 80 } });
|
||||||
return {
|
|
||||||
default: vi.fn().mockImplementation(() => ({
|
expect(screen.getByText(/Scale image:\s*80%/i)).toBeInTheDocument();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,23 +4,22 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Radio
|
Radio,
|
||||||
|
Stack
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import ToolContent from '@components/ToolContent';
|
||||||
import ToolImageInput from 'components/input/ToolImageInput';
|
import ToolImageInput from 'components/input/ToolImageInput';
|
||||||
import ToolFileResult from 'components/result/ToolFileResult';
|
import ToolFileResult from 'components/result/ToolFileResult';
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import ToolContent from '@components/ToolContent';
|
|
||||||
import { ToolComponentProps } from '@tools/defineTool';
|
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) {
|
export default function ConvertToPdf({ title }: ToolComponentProps) {
|
||||||
const [input, setInput] = useState<File | null>(null);
|
const [input, setInput] = useState<File | null>(null);
|
||||||
const [result, setResult] = 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<{
|
const [imageSize, setImageSize] = useState<{
|
||||||
widthMm: number;
|
widthMm: number;
|
||||||
heightMm: number;
|
heightMm: number;
|
||||||
@@ -28,81 +27,27 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
|
|||||||
heightPx: number;
|
heightPx: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const compute = async (file: File | null, currentScale: number) => {
|
const compute = async (values: FormValues) => {
|
||||||
if (!file) return;
|
if (!input) return;
|
||||||
|
const { pdfFile, imageSize } = await buildPdf({
|
||||||
const img = new Image();
|
file: input,
|
||||||
img.src = URL.createObjectURL(file);
|
pageType: values.pageType,
|
||||||
|
orientation: values.orientation,
|
||||||
try {
|
scale: values.scale
|
||||||
await img.decode();
|
});
|
||||||
|
setResult(pdfFile);
|
||||||
const pxToMm = (px: number) => px * 0.264583;
|
setImageSize(imageSize);
|
||||||
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 (
|
return (
|
||||||
<ToolContent
|
<ToolContent<FormValues, File | null>
|
||||||
title={title}
|
title={title}
|
||||||
input={input}
|
input={input}
|
||||||
|
setInput={setInput}
|
||||||
|
initialValues={initialFormValues}
|
||||||
|
compute={compute}
|
||||||
inputComponent={
|
inputComponent={
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
<Box>
|
||||||
<ToolImageInput
|
<ToolImageInput
|
||||||
value={input}
|
value={input}
|
||||||
onChange={setInput}
|
onChange={setInput}
|
||||||
@@ -120,82 +65,95 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
|
|||||||
'image/x-sony-arw',
|
'image/x-sony-arw',
|
||||||
'image/vnd.adobe.photoshop'
|
'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>
|
</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={
|
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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user