diff --git a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx index 82ace2d..107039a 100644 --- a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx +++ b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx @@ -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(); - 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(); - const fullOption = screen.getByLabelText(/Full Size/i); - fireEvent.click(fullOption); - expect(fullOption).toBeChecked(); -}); - -it('should update scale when slider moves', () => { - render(); - 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(); - 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) => ( - onChange(e.target.files[0])} - /> - ) - })); - - const mockFile = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' }); - render(); - - 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(); + + 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(); + + 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(); + + 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(); + }); }); diff --git a/src/pages/tools/pdf/convert-to-pdf/index.tsx b/src/pages/tools/pdf/convert-to-pdf/index.tsx index 1e35a6c..750e843 100644 --- a/src/pages/tools/pdf/convert-to-pdf/index.tsx +++ b/src/pages/tools/pdf/convert-to-pdf/index.tsx @@ -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(null); const [result, setResult] = useState(null); - const [scale, setScale] = useState(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 ( - title={title} input={input} + setInput={setInput} + initialValues={initialFormValues} + compute={compute} inputComponent={ - + - - - PDF Type - setPageType(e.target.value as 'a4' | 'full')} - > - } - label="A4 Page" - /> - } - label="Full Size (Same as Image)" - /> - - - {pageType === 'full' && imageSize && ( - - Image size: {imageSize.widthMm.toFixed(1)} ×{' '} - {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} - {imageSize.heightPx} px) - - )} - - - {pageType === 'a4' && ( - <> - - Orientation - - setOrientation(e.target.value as 'portrait' | 'landscape') - } - > - } - label="Portrait (Vertical)" - /> - } - label="Landscape (Horizontal)" - /> - - - - - Scale image: {scale}% - setScale(v as number)} - onChangeCommitted={(_, v) => compute(input, v as number)} - min={10} - max={100} - step={1} - valueLabelDisplay="auto" - /> - - - )} } + getGroups={({ values, updateField }) => { + return [ + { + title: '', + component: ( + + + PDF Type + + updateField('pageType', e.target.value as PageType) + } + > + } + label="Full Size (Same as Image)" + /> + } + label="A4 Page" + /> + + + {values.pageType === 'full' && imageSize && ( + + Image size: {imageSize.widthMm.toFixed(1)} ×{' '} + {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} + {imageSize.heightPx} px) + + )} + + + {values.pageType === 'a4' && ( + + Orientation + + updateField( + 'orientation', + e.target.value as Orientation + ) + } + > + } + label="Portrait (Vertical)" + /> + } + label="Landscape (Horizontal)" + /> + + + )} + + {values.pageType === 'a4' && ( + + Scale + Scale image: {values.scale}% + updateField('scale', v as number)} + min={10} + max={100} + step={1} + valueLabelDisplay="auto" + /> + + )} + + ) + } + ] as const; + }} resultComponent={ - + } - compute={() => compute(input, scale)} - setInput={setInput} /> ); } diff --git a/src/pages/tools/pdf/convert-to-pdf/service.ts b/src/pages/tools/pdf/convert-to-pdf/service.ts new file mode 100644 index 0000000..4a1eb22 --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/service.ts @@ -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 { + 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); + } +} diff --git a/src/pages/tools/pdf/convert-to-pdf/types.ts b/src/pages/tools/pdf/convert-to-pdf/types.ts new file mode 100644 index 0000000..93b9a42 --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/types.ts @@ -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 +};