Merge branch 'main' into pr/Srivarshan-T/216

This commit is contained in:
Chesterkxng
2025-12-10 10:55:56 +01:00
72 changed files with 5339 additions and 3027 deletions

View File

@@ -127,7 +127,10 @@ const Navbar: React.FC<NavbarProps> = ({
></iframe>,
<Button
onClick={() => {
window.open('https://buymeacoffee.com/iib0011', '_blank');
window.open(
'https://drive.google.com/file/d/1-r9-rDYnDJic9dnDywKTAsueehIAVp5F/view?usp=sharing',
'_blank'
);
}}
sx={{ borderRadius: '100px' }}
variant={'contained'}
@@ -135,11 +138,11 @@ const Navbar: React.FC<NavbarProps> = ({
<Icon
style={{ cursor: 'pointer' }}
fontSize={25}
icon={'mdi:heart-outline'}
icon={'hugeicons:job-search'}
/>
}
>
{t('navbar.buyMeACoffee')}
{t('navbar.hireMe')}
</Button>
];
const drawerList = (

View File

@@ -41,7 +41,7 @@ export async function changeAudioSpeed(
const outputName = `output.${outputFormat}`;
await ffmpeg.writeFile(fileName, await fetchFile(input));
const audioFilter = computeAudioFilter(newSpeed);
let args = ['-i', fileName, '-filter:a', audioFilter];
const args = ['-i', fileName, '-filter:a', audioFilter];
if (outputFormat === 'mp3') {
args.push('-b:a', '192k', '-f', 'mp3', outputName);
} else if (outputFormat === 'aac') {
@@ -64,7 +64,7 @@ export async function changeAudioSpeed(
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
const blob = new Blob([data], { type: mimeType });
const blob = new Blob([data as any], { type: mimeType });
const newFile = new File(
[blob],
fileName.replace(/\.[^/.]+$/, `-${newSpeed}x.${outputFormat}`),

View File

@@ -57,7 +57,7 @@ export async function extractAudioFromVideo(
return new File(
[
new Blob([extractedAudio], {
new Blob([extractedAudio as any], {
type: `audio/${configuredOutputAudioFormat}`
})
],

View File

@@ -105,7 +105,7 @@ export async function mergeAudioFiles(
return new File(
[
new Blob([mergedAudio], {
new Blob([mergedAudio as any], {
type: mimeType
})
],

View File

@@ -98,7 +98,7 @@ export async function trimAudio(
return new File(
[
new Blob([trimmedAudio], {
new Blob([trimmedAudio as any], {
type: mimeType
})
],

View File

@@ -40,7 +40,7 @@ const initialValues: InitialValuesType = {
// WiFi
wifiSsid: '',
wifiPassword: '',
wifiEncryption: 'WPA/WPA2',
wifiEncryption: 'WPA',
// vCard
vCardName: '',
@@ -353,7 +353,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
label="Encryption Type"
margin="normal"
>
<MenuItem value="WPA/WPA2">WPA/WPA2</MenuItem>
<MenuItem value="WPA">WPA</MenuItem>
<MenuItem value="WEP">WEP</MenuItem>
<MenuItem value="None">None</MenuItem>
</TextField>

View File

@@ -7,7 +7,7 @@ export type QRCodeType =
| 'WiFi'
| 'vCard';
export type WifiEncryptionType = 'WPA/WPA2' | 'WEP' | 'None';
export type WifiEncryptionType = 'WPA' | 'WEP' | 'None';
export interface InitialValuesType {
qrCodeType: QRCodeType;

View File

@@ -148,7 +148,7 @@ export const processImage = async (
const data = await ffmpeg.readFile('output.gif');
// Create a new File object
return new File([data], file.name, { type: 'image/gif' });
return new File([data as any], file.name, { type: 'image/gif' });
} catch (error) {
console.error('Error processing GIF with FFmpeg:', error);
// Fall back to canvas method if FFmpeg processing fails

View File

@@ -66,7 +66,7 @@ export const processImage = async (
// Read the output file
const data = await ffmpeg.readFile('output.' + file.name.split('.').pop());
return new File([data], file.name, { type: file.type });
return new File([data as any], file.name, { type: file.type });
} catch (error) {
console.error('Error processing image:', error);
return null;

View File

@@ -3,6 +3,7 @@ import voltageDropInWire from './voltageDropInWire';
import sphereArea from './sphereArea';
import sphereVolume from './sphereVolume';
import slackline from './slackline';
export default [
ohmslaw,
voltageDropInWire,

View File

@@ -0,0 +1,38 @@
import { render, screen, fireEvent } from '@testing-library/react';
import ConvertToPdf from './index';
import { vi } from 'vitest';
import '@testing-library/jest-dom';
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

@@ -0,0 +1,159 @@
import {
Box,
Slider,
Typography,
RadioGroup,
FormControlLabel,
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 { ToolComponentProps } from '@tools/defineTool';
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 [imageSize, setImageSize] = useState<{
widthMm: number;
heightMm: number;
widthPx: number;
heightPx: number;
} | null>(null);
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);
};
return (
<ToolContent<FormValues, File | null>
title={title}
input={input}
setInput={setInput}
initialValues={initialFormValues}
compute={compute}
inputComponent={
<Box>
<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>
}
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" />
}
/>
);
}

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

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

View File

@@ -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
];

View File

@@ -70,7 +70,9 @@ export async function splitPdf(
const newPdfBytes = await newPdf.save();
const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf');
return new File([newPdfBytes], newFileName, { type: 'application/pdf' });
return new File([newPdfBytes as any], newFileName, {
type: 'application/pdf'
});
}
/**
@@ -89,7 +91,7 @@ export async function mergePdf(pdfFiles: File[]): Promise<File> {
const mergedPdfBytes = await mergedPdf.save();
const mergedFileName = 'merged.pdf';
return new File([mergedPdfBytes], mergedFileName, {
return new File([mergedPdfBytes as any], mergedFileName, {
type: 'application/pdf'
});
}

View File

@@ -28,7 +28,7 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: context, viewport }).promise;
await page.render({ canvas, canvasContext: context, viewport }).promise;
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => b && resolve(b), 'image/png')

View File

@@ -78,5 +78,7 @@ export async function rotatePdf(
const modifiedPdfBytes = await pdfDoc.save();
const newFileName = pdfFile.name.replace('.pdf', '-rotated.pdf');
return new File([modifiedPdfBytes], newFileName, { type: 'application/pdf' });
return new File([modifiedPdfBytes as any], newFileName, {
type: 'application/pdf'
});
}

View File

@@ -70,5 +70,7 @@ export async function splitPdf(
const newPdfBytes = await newPdf.save();
const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf');
return new File([newPdfBytes], newFileName, { type: 'application/pdf' });
return new File([newPdfBytes as any], newFileName, {
type: 'application/pdf'
});
}

View File

@@ -21,6 +21,7 @@ import { tool as stringCensor } from './censor/meta';
import { tool as stringPasswordGenerator } from './password-generator/meta';
import { tool as stringEncodeUrl } from './url-encode/meta';
import { tool as StringDecodeUrl } from './url-decode/meta';
import { tool as stringUnicode } from './unicode/meta';
export const stringTools = [
stringSplit,
@@ -45,5 +46,6 @@ export const stringTools = [
stringPasswordGenerator,
stringEncodeUrl,
StringDecodeUrl,
stringUnicode,
stringHiddenCharacterDetector
];

View File

@@ -0,0 +1,123 @@
import { Box } from '@mui/material';
import { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import { unicode } from './service';
import { InitialValuesType } from './types';
import SimpleRadio from '@components/options/SimpleRadio';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
mode: 'encode',
uppercase: false
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Encode to Unicode Escape',
description: 'Encode plain text to Unicode escape sequences.',
sampleText: 'Hello, World!',
sampleResult:
'\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021',
sampleOptions: {
mode: 'encode',
uppercase: false
}
},
{
title: 'Encode to Unicode Escape (Uppercase)',
description: 'Encode plain text to uppercase Unicode escape sequences.',
sampleText: 'Hello, World!',
sampleResult:
'\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021'.toUpperCase(),
sampleOptions: {
mode: 'encode',
uppercase: true
}
},
{
title: 'Decode Unicode Escape',
description: 'Decode Unicode escape sequences back to plain text.',
sampleText:
'\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021',
sampleResult: 'Hello, World!',
sampleOptions: {
mode: 'decode',
uppercase: false
}
}
];
export default function Unicode({ title }: ToolComponentProps) {
const { t } = useTranslation('string');
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (values: InitialValuesType, input: string) => {
setResult(unicode(input, values));
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: t('unicode.optionsTitle'),
component: (
<Box>
<SimpleRadio
onClick={() => updateField('mode', 'encode')}
checked={values.mode === 'encode'}
title={t('unicode.encode')}
/>
<SimpleRadio
onClick={() => updateField('mode', 'decode')}
checked={values.mode === 'decode'}
title={t('unicode.decode')}
/>
</Box>
)
},
{
title: t('unicode.caseOptionsTitle'),
component: (
<Box>
<CheckboxWithDesc
checked={values.uppercase}
onChange={(value) => updateField('uppercase', value)}
title={t('unicode.uppercase')}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolTextInput
value={input}
onChange={setInput}
title={t('unicode.inputTitle')}
/>
}
resultComponent={
<ToolTextResult value={result} title={t('unicode.resultTitle')} />
}
initialValues={initialValues}
exampleCards={exampleCards}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{
title: t('unicode.toolInfo.title'),
description: t('unicode.toolInfo.description')
}}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('string', {
i18n: {
name: 'string:unicode.title',
description: 'string:unicode.description',
shortDescription: 'string:unicode.shortDescription'
},
path: 'unicode',
icon: 'mdi:unicode',
keywords: ['unicode', 'encode', 'decode', 'escape', 'text'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,21 @@
import { InitialValuesType } from './types';
export function unicode(input: string, options: InitialValuesType): string {
if (!input) return '';
if (options.mode === 'encode') {
let result = '';
for (let i = 0; i < input.length; i++) {
let hex = input.charCodeAt(i).toString(16);
hex = ('0000' + hex).slice(-4);
if (options.uppercase) {
hex = hex.toUpperCase();
}
result += '\\u' + hex;
}
return result;
} else {
return input.replace(/\\u([\dA-Fa-f]{4})/g, (match, grp) => {
return String.fromCharCode(parseInt(grp, 16));
});
}
}

View File

@@ -0,0 +1,4 @@
export type InitialValuesType = {
mode: 'encode' | 'decode';
uppercase: boolean;
};

View File

@@ -0,0 +1,178 @@
import { expect, describe, it } from 'vitest';
import { unicode } from './service';
describe('unicode', () => {
it('should encode an English string to lowercase hex correctly', () => {
const input = 'Hello, World!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe(
'\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021'
);
});
it('should encode an English string to uppercase hex correctly', () => {
const input = 'Hello, World!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe(
'\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021'
);
});
it('should decode an English lowercase hex string correctly', () => {
const input =
'\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('Hello, World!');
});
it('should decode an English uppercase hex string correctly', () => {
const input =
'\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('Hello, World!');
});
it('should encode a Korean string to lowercase hex correctly', () => {
const input = '안녕하세요, 세계!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe(
'\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021'
);
});
it('should encode a Korean string to uppercase hex correctly', () => {
const input = '안녕하세요, 세계!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe(
'\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021'
);
});
it('should decode a Korean lowercase hex string correctly', () => {
const input =
'\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('안녕하세요, 세계!');
});
it('should decode a Korean uppercase hex string correctly', () => {
const input =
'\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('안녕하세요, 세계!');
});
it('should encode a Japanese string to lowercase hex correctly', () => {
const input = 'こんにちは、世界!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe(
'\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01'
);
});
it('should encode a Japanese string to uppercase hex correctly', () => {
const input = 'こんにちは、世界!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe(
'\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01'
);
});
it('should decode a Japanese lowercase hex string correctly', () => {
const input =
'\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('こんにちは、世界!');
});
it('should decode a Japanese uppercase hex string correctly', () => {
const input =
'\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('こんにちは、世界!');
});
it('should encode a Chinese string to lowercase hex correctly', () => {
const input = '你好,世界!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe('\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01');
});
it('should encode a Chinese string to uppercase hex correctly', () => {
const input = '你好,世界!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe('\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01');
});
it('should decode a Chinese lowercase hex string correctly', () => {
const input = '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('你好,世界!');
});
it('should decode a Chinese uppercase hex string correctly', () => {
const input = '\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('你好,世界!');
});
it('should encode a Russian string to lowercase hex correctly', () => {
const input = 'Привет, мир!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe(
'\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021'
);
});
it('should encode a Russian string to uppercase hex correctly', () => {
const input = 'Привет, мир!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe(
'\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021'
);
});
it('should decode a Russian lowercase hex string correctly', () => {
const input =
'\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('Привет, мир!');
});
it('should decode a Russian uppercase hex string correctly', () => {
const input =
'\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('Привет, мир!');
});
it('should encode a Spanish string to lowercase hex correctly', () => {
const input = '¡Hola, Mundo!';
const result = unicode(input, { mode: 'encode', uppercase: false });
expect(result).toBe(
'\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021'
);
});
it('should encode a Spanish string to uppercase hex correctly', () => {
const input = '¡Hola, Mundo!';
const result = unicode(input, { mode: 'encode', uppercase: true });
expect(result).toBe(
'\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021'
);
});
it('should decode a Spanish lowercase hex string correctly', () => {
const input =
'\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('¡Hola, Mundo!');
});
it('should decode a Spanish uppercase hex string correctly', () => {
const input =
'\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021';
const result = unicode(input, { mode: 'decode', uppercase: false });
expect(result).toBe('¡Hola, Mundo!');
});
});

View File

@@ -0,0 +1,40 @@
import { expect, describe, it } from 'vitest';
import { convertTimeToDecimal } from './service';
describe('convert-time-to-decimal', () => {
it('should convert time to decimal with default decimal places', () => {
const input = '31:23:59';
const result = convertTimeToDecimal(input, { decimalPlaces: '6' });
expect(result).toBe('31.399722');
});
it('should convert time to decimal with specified decimal places', () => {
const input = '31:23:59';
const result = convertTimeToDecimal(input, { decimalPlaces: '10' });
expect(result).toBe('31.3997222222');
});
it('should convert time to decimal with supplied format of HH:MM:SS', () => {
const input = '13:25:30';
const result = convertTimeToDecimal(input, { decimalPlaces: '6' });
expect(result).toBe('13.425000');
});
it('should convert time to decimal with supplied format of HH:MM', () => {
const input = '13:25';
const result = convertTimeToDecimal(input, { decimalPlaces: '6' });
expect(result).toBe('13.416667');
});
it('should convert time to decimal with supplied format of HH:MM:', () => {
const input = '13:25';
const result = convertTimeToDecimal(input, { decimalPlaces: '6' });
expect(result).toBe('13.416667');
});
it('should convert time to decimal with supplied format of HH.MM.SS', () => {
const input = '13.25.30';
const result = convertTimeToDecimal(input, { decimalPlaces: '6' });
expect(result).toBe('13.425000');
});
});

View File

@@ -0,0 +1,72 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import { convertTimeToDecimal } from './service';
import { InitialValuesType } from './types';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
const initialValues: InitialValuesType = {
decimalPlaces: '6'
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Convert time to decimal',
description:
'This example shows how to convert a formatted time (HH:MM:SS) to a decimal version.',
sampleText: '31:23:59',
sampleResult: `31.399722`,
sampleOptions: {
decimalPlaces: '6'
}
}
];
export default function ConvertTimeToDecimal({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (values: InitialValuesType, input: string) => {
setResult(convertTimeToDecimal(input, values));
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Decimal places',
component: (
<Box>
<TextFieldWithDesc
description={'How many decimal places should the result contain?'}
value={values.decimalPlaces}
onOwnChange={(val) => updateField('decimalPlaces', val)}
type={'text'}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
resultComponent={<ToolTextResult value={result} />}
initialValues={initialValues}
exampleCards={exampleCards}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('time', {
i18n: {
name: 'time:convertTimeToDecimal.title',
description: 'time:convertTimeToDecimal.description',
shortDescription: 'time:convertTimeToDecimal.shortDescription',
longDescription: 'time:convertTimeToDecimal.longDescription'
},
path: 'convert-time-to-decimal',
icon: 'material-symbols-light:decimal-increase-rounded',
keywords: ['convert', 'time', 'to', 'decimal'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,37 @@
import { InitialValuesType } from './types';
import { humanTimeValidation } from 'utils/time';
export function convertTimeToDecimal(
input: string,
options: InitialValuesType
): string {
if (!input) return '';
const dp = parseInt(options.decimalPlaces, 10);
if (isNaN(dp) || dp < 0) {
return 'Invalid decimal places value.';
}
// Multiple lines processing
const lines = input.split('\n');
if (!lines) return '';
const result: string[] = [];
lines.forEach((line) => {
line = line.trim();
if (!line) return;
const { isValid, hours, minutes, seconds } = humanTimeValidation(line);
if (!isValid) {
result.push('Incorrect input format use `HH:MM:(SS)` or `HH.MM.(SS )`.');
return;
}
const decimalTime = hours + minutes / 60 + seconds / 3600;
result.push(decimalTime.toFixed(dp).toString());
});
return result.join('\n');
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
decimalPlaces: string;
};

View File

@@ -1,3 +1,4 @@
import { tool as timeConvertTimeToDecimal } from './convert-time-to-decimal/meta';
import { tool as timeConvertUnixToDate } from './convert-unix-to-date/meta';
import { tool as timeCrontabGuru } from './crontab-guru/meta';
import { tool as timeBetweenDates } from './time-between-dates/meta';
@@ -17,5 +18,6 @@ export const timeTools = [
timeBetweenDates,
timeCrontabGuru,
checkLeapYear,
timeConvertUnixToDate
timeConvertUnixToDate,
timeConvertTimeToDecimal
];

View File

@@ -101,7 +101,7 @@ export default function ChangeSpeed({
const data = await ffmpeg.readFile(outputName);
// Create new file from processed data
const blob = new Blob([data], { type: 'video/mp4' });
const blob = new Blob([data as any], { type: 'video/mp4' });
const newFile = new File(
[blob],
file.name.replace('.mp4', `-${newSpeed}x.mp4`),

View File

@@ -53,7 +53,7 @@ export async function compressVideo(
}
const compressedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([compressedData], { type: 'video/mp4' })],
[new Blob([compressedData as any], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_compressed_${options.width}p.mp4`,
{ type: 'video/mp4' }
);

View File

@@ -60,7 +60,7 @@ export async function cropVideo(
const croppedData = await ffmpeg.readFile(outputName);
return await new File(
[new Blob([croppedData], { type: 'video/mp4' })],
[new Blob([croppedData as any], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_cropped.mp4`,
{ type: 'video/mp4' }
);

View File

@@ -36,7 +36,7 @@ export async function flipVideo(
const flippedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([flippedData], { type: 'video/mp4' })],
[new Blob([flippedData as any], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_flipped.mp4`,
{ type: 'video/mp4' }
);

View File

@@ -71,7 +71,7 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
const data = await ffmpeg.readFile('output.gif');
// Create a new file from the processed data
const blob = new Blob([data], { type: 'image/gif' });
const blob = new Blob([data as any], { type: 'image/gif' });
const newFile = new File(
[blob],
file.name.replace('.gif', `-${newSpeed}x.gif`),

View File

@@ -3,7 +3,7 @@ import { lazy } from 'react';
export const tool = defineTool('video', {
path: 'loop',
icon: 'material-symbols:loop',
icon: 'ic:outline-loop',
keywords: ['video', 'loop', 'repeat', 'continuous'],
component: lazy(() => import('./index')),

View File

@@ -34,7 +34,7 @@ export async function loopVideo(
const loopedData = await ffmpeg.readFile(outputName);
return await new File(
[new Blob([loopedData], { type: 'video/mp4' })],
[new Blob([loopedData as any], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_looped.mp4`,
{ type: 'video/mp4' }
);

View File

@@ -112,7 +112,7 @@ export async function mergeVideos(
throw new Error('Output file is empty or corrupted');
}
return new Blob([mergedData], { type: 'video/mp4' });
return new Blob([mergedData as any], { type: 'video/mp4' });
} catch (error) {
console.error('Error merging videos:', error);
throw error instanceof Error

View File

@@ -37,7 +37,7 @@ export async function rotateVideo(
const rotatedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([rotatedData], { type: 'video/mp4' })],
[new Blob([rotatedData as any], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`,
{ type: 'video/mp4' }
);

View File

@@ -67,7 +67,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
]);
// Retrieve the processed file
const trimmedData = await ffmpeg.readFile(outputName);
const trimmedBlob = new Blob([trimmedData], { type: 'video/mp4' });
const trimmedBlob = new Blob([trimmedData as any], { type: 'video/mp4' });
const trimmedFile = new File(
[trimmedBlob],
`${input.name.replace(/\.[^/.]+$/, '')}_trimmed.mp4`,

View File

@@ -1,8 +1,11 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import ToolFileResult from '@components/result/ToolFileResult';
@@ -13,9 +16,19 @@ import { fetchFile } from '@ffmpeg/util';
const initialValues: InitialValuesType = {
quality: 'mid',
fps: '10',
scale: '320:-1:flags=bicubic'
scale: '320:-1:flags=bicubic',
start: 0,
end: 100
};
const validationSchema = Yup.object({
start: Yup.number().min(0, 'Start time must be positive'),
end: Yup.number().min(
Yup.ref('start'),
'End time must be greater than start time'
)
});
export default function VideoToGif({
title,
longDescription
@@ -26,14 +39,16 @@ export default function VideoToGif({
const compute = (values: InitialValuesType, input: File | null) => {
if (!input) return;
const { fps, scale } = values;
const { fps, scale, start, end } = values;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
const convertVideoToGif = async (
file: File,
fps: string,
scale: string
scale: string,
start: number,
end: number
): Promise<void> => {
setLoading(true);
@@ -58,6 +73,10 @@ export default function VideoToGif({
await ffmpeg.exec([
'-i',
fileName,
'-ss',
start.toString(),
'-to',
end.toString(),
'-vf',
`fps=${fps},scale=${scale},palettegen`,
'palette.png'
@@ -68,6 +87,10 @@ export default function VideoToGif({
fileName,
'-i',
'palette.png',
'-ss',
start.toString(),
'-to',
end.toString(),
'-filter_complex',
`fps=${fps},scale=${scale}[x];[x][1:v]paletteuse`,
outputName
@@ -75,7 +98,7 @@ export default function VideoToGif({
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: 'image/gif' });
const blob = new Blob([data as any], { type: 'image/gif' });
const convertedFile = new File([blob], outputName, {
type: 'image/gif'
});
@@ -92,7 +115,7 @@ export default function VideoToGif({
}
};
convertVideoToGif(input, fps, scale);
convertVideoToGif(input, fps, scale, start, end);
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
@@ -141,6 +164,28 @@ export default function VideoToGif({
/>
</Box>
)
},
{
title: 'Timestamps',
component: (
<Box>
<TextFieldWithDesc
onOwnChange={(value) =>
updateNumberField(value, 'start', updateField)
}
value={values.start}
label="Start Time"
sx={{ mb: 2, backgroundColor: 'background.paper' }}
/>
<TextFieldWithDesc
onOwnChange={(value) =>
updateNumberField(value, 'end', updateField)
}
value={values.end}
label="End Time"
/>
</Box>
)
}
];
@@ -148,9 +193,22 @@ export default function VideoToGif({
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput value={input} onChange={setInput} title="Input Video" />
}
renderCustomInput={({ start, end }, setFieldValue) => {
return (
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
showTrimControls={true}
onTrimChange={(start, end) => {
setFieldValue('start', start);
setFieldValue('end', end);
}}
trimStart={start}
trimEnd={end}
/>
);
}}
resultComponent={
loading ? (
<ToolFileResult

View File

@@ -2,4 +2,6 @@ export type InitialValuesType = {
quality: 'mid' | 'high' | 'low' | 'ultra';
fps: string;
scale: string;
start: number;
end: number;
};

58
src/utils/time.ts Normal file
View File

@@ -0,0 +1,58 @@
type TimeValidationResult = {
isValid: boolean;
hours: number;
minutes: number;
seconds: number;
};
/**
* Validates human-readable time format (HH:MM or HH:MM:SS)
* Supports either ':' or '.' as a separator, but not both
* @param {string} input - string time format
* * @returns {{
* isValid: boolean, // true if the input is a valid time
* hours: number, // parsed hours (0 or greater)
* minutes: number, // parsed minutes (0-59)
* seconds: number // parsed seconds (0-59, 0 if not provided)
* }}
*/
export function humanTimeValidation(input: string): TimeValidationResult {
const result = { isValid: false, hours: 0, minutes: 0, seconds: 0 };
if (!input) return result;
input = input.trim();
// Operator use validation
// use of one between these two operators '.' or ':'
const hasColon = input.includes(':');
const hasDot = input.includes('.');
if (hasColon && hasDot) return result;
if (!hasColon && !hasDot) return result;
const separator = hasColon ? ':' : '.';
// Time parts validation
const parts = input.split(separator);
if (parts.length < 2 || parts.length > 3) return result;
const [h, m, s = '0'] = parts;
// every character should be a digit
if (![h, m, s].every((x) => /^\d+$/.test(x))) return result;
const hours = parseInt(h);
const minutes = parseInt(m);
const seconds = parseInt(s);
if (minutes < 0 || minutes > 59) return result;
if (seconds < 0 || seconds > 59) return result;
if (hours < 0) return result;
return { isValid: true, hours, minutes, seconds };
}