refactor code structure - created types.ts and service.ts

This commit is contained in:
Matus 2025-10-28 16:13:45 +01:00
commit 691bef8304
4 changed files with 240 additions and 213 deletions

View file

@ -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();
});
});

View file

@ -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}
/>
);
}

View 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);
}
}

View 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
};