Merge branch 'main' into compression

This commit is contained in:
Ibrahima G. Coulibaly
2025-04-03 02:03:55 +00:00
committed by GitHub
49 changed files with 1541 additions and 253 deletions

View File

@@ -1,20 +0,0 @@
[Coding Aider Plan]
## Overview
This plan outlines the refactoring of existing tools to utilize a `ToolContent` component. This will standardize the structure and styling of tool content across the application, improving maintainability and user experience.
## Problem Description
Currently, some tools directly render their content without using a common `ToolContent` component. This leads to inconsistencies in styling, layout, and overall structure. It also makes it harder to apply global changes or updates to the tool content areas.
## Goals
- Identify tools that do not currently use `ToolContent`.
- Implement `ToolContent` in these tools.
- Ensure consistent styling and layout across all tools.
## Additional Notes and Constraints
- The `ToolContent` component should be flexible enough to accommodate the different types of content used by each tool.
- Ensure that the refactoring does not introduce any regressions or break existing functionality.
- Consider creating a subplan if the number of tools requiring changes is large or if individual tools require complex modifications.
## References
- Existing tools that already use `ToolContent` can serve as examples.

View File

@@ -1,9 +0,0 @@
[Coding Aider Plan - Checklist]
- [ ] Create `ToolContent` component if it doesn't exist.
- [ ] Identify tools that do not use `ToolContent`.
- [x] For each identified tool:
- [x] Implement `ToolContent` wrapper.
- [ ] Adjust styling as needed to match existing design.
- [ ] Test the tool to ensure it functions correctly.
- [ ] Review all modified tools to ensure consistency.

View File

@@ -1,6 +0,0 @@
---
files:
- path: src\pages\tools\list\duplicate\index.tsx
readOnly: false
- path: src\pages\tools\list\index.ts
readOnly: false

View File

@@ -113,11 +113,17 @@ npm run dev
### Create a new tool
```bash
npm run script:create:tool my-tool-name folder1 # npm run script:create:tool split pdf
```
For tools located under multiple nested directories, use:
```bash
npm run script:create:tool my-tool-name folder1/folder2 # npm run script:create:tool compress image/png
```
Use `folder1\folder2` on Windows
Use `folder1\folder2` on Windows.
### Run tests

View File

@@ -5,6 +5,7 @@ import { capitalizeFirstLetter } from '../utils/string';
import Grid from '@mui/material/Grid';
import { Icon, IconifyIcon } from '@iconify/react';
import { categoriesColors } from '../config/uiConfig';
import { getToolsByCategory } from '@tools/index';
const StyledButton = styled(Button)(({ theme }) => ({
backgroundColor: 'white',
@@ -70,7 +71,9 @@ export default function ToolHeader({
items={[
{ title: 'All tools', link: '/' },
{
title: capitalizeFirstLetter(type),
title: getToolsByCategory().find(
(category) => category.type === type
)!.rawTitle,
link: '/categories/' + type
},
{ title }

View File

@@ -53,7 +53,10 @@ export default function ToolLayout({
{children}
<Separator backgroundColor="#5581b5" margin="50px" />
<AllTools
title={`All ${capitalizeFirstLetter(type)} tools`}
title={`All ${capitalizeFirstLetter(
getToolsByCategory().find((category) => category.type === type)!
.rawTitle
)} tools`}
toolCards={otherCategoryTools}
/>
</Box>

View File

@@ -15,7 +15,7 @@ export default function ToolFileResult({
}: {
title?: string;
value: File | null;
extension: string;
extension?: string;
loading?: boolean;
loadingText?: string;
}) {
@@ -50,9 +50,20 @@ export default function ToolFileResult({
const handleDownload = () => {
if (value) {
const hasExtension = value.name.includes('.');
const filename = hasExtension ? value.name : `${value.name}.${extension}`;
let filename: string = value.name;
if (extension) {
// Split at the last period to separate filename and extension
const parts = filename.split('.');
// If there's more than one part (meaning there was a period)
if (parts.length > 1) {
// Remove the last part (the extension) and add the new extension
parts.pop();
filename = `${parts.join('.')}.${extension}`;
} else {
// No extension exists, just add it
filename = `${filename}.${extension}`;
}
}
const blob = new Blob([value], { type: value.type });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -3,6 +3,7 @@ export const globalDescriptionFontSize = 12;
export const categoriesColors: string[] = [
'#8FBC5D',
'#3CB6E2',
'#B17F59',
'#FFD400',
'#AB6993'
];

View File

@@ -43,10 +43,11 @@ export default function Home() {
<IconButton onClick={() => navigate('/')}>
<ArrowBackIcon color={'primary'} />
</IconButton>
<Typography
fontSize={22}
color={theme.palette.primary.main}
>{`All ${capitalizeFirstLetter(categoryName)} Tools`}</Typography>
<Typography fontSize={22} color={theme.palette.primary.main}>{`All ${
getToolsByCategory().find(
(category) => category.type === categoryName
)!.rawTitle
} Tools`}</Typography>
</Stack>
<Grid container spacing={2} mt={2}>
{getToolsByCategory()

View File

@@ -2,5 +2,12 @@ import { tool as csvToJson } from './csv-to-json/meta';
import { tool as csvToXml } from './csv-to-xml/meta';
import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
import { tool as csvToTsv } from './csv-to-tsv/meta';
import { tool as swapCsvColumns } from './swap-csv-columns/meta';
export const csvTools = [csvToJson, csvToXml, csvToRowsColumns, csvToTsv];
export const csvTools = [
csvToJson,
csvToXml,
csvToRowsColumns,
csvToTsv,
swapCsvColumns
];

View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import ToolContent from '@components/ToolContent';
import { GetGroupsType } from '@components/options/ToolOptions';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { csvColumnsSwap } from './service';
import { getCsvHeaders } from '@utils/csv';
import { InitialValuesType } from './types';
const initialValues: InitialValuesType = {
fromPositionStatus: true,
toPositionStatus: true,
fromPosition: '1',
toPosition: '2',
fromHeader: '',
toHeader: '',
emptyValuesFilling: true,
customFiller: '',
deleteComment: true,
commentCharacter: '#',
emptyLines: true
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Move the Key Column to the First Position',
description:
'In this example, we use our CSV column swapping tool to bring the most important information to the first column. As we are planning to go on vacation soon, in the input of the tool, we load data about national parks that include their names and locations. To decide, which is the closest park to us, we need to see the parks location first, therefore, we swap the first and second data columns so that the "location" column is at the beginning of the CSV data.',
sampleText: `park_name,location
Yellowstone,Wyoming
Yosemite,California
Grand Canyon,Arizona
Rocky Mountain,Colorado
Zion Park,Utah`,
sampleResult: `location,park_name
Wyoming,Yellowstone
California,Yosemite
Arizona,Grand Canyon
Colorado,Rocky Mountain
Utah,Zion Park`,
sampleOptions: {
fromPositionStatus: true,
toPositionStatus: true,
fromPosition: '1',
toPosition: '2',
fromHeader: 'park_name',
toHeader: 'location',
emptyValuesFilling: false,
customFiller: '*',
deleteComment: false,
commentCharacter: '',
emptyLines: false
}
},
{
title: 'Reorganize Columns in Vitamins CSV',
description:
'In this example, a lab intern made a mistake and created a corrupted CSV file with mixed-up columns and missing data. To fix the file, we swap the columns based on the headers "Vitamin" and "Function" so that the "Vitamin" column becomes the first in the output data. We also fill the incomplete CSV data by adding a custom asterisk "*" symbol in place of missing values.',
sampleText: `Function,Fat-Soluble,Vitamin,Sources
Supports vision,Fat-Soluble,A,Carrots
Immune function,Water-Soluble,C,Citrus fruits
Bone health,Fat-Soluble,D,Fatty fish
Antioxidant,Fat-Soluble,E,Nuts
Blood clotting,Fat-Soluble,K,Leafy greens
Energy production,Water-Soluble,B1
Energy production,Water-Soluble,B2
Energy production,Water-Soluble,B3,Meat
Protein metabolism,Water-Soluble,B6,Poultry
Nervous system,Water-Soluble,B12,Meat`,
sampleResult: `Vitamin,Fat-Soluble,Function,Sources
A,Fat-Soluble,Supports vision,Carrots
C,Water-Soluble,Immune function,Citrus fruits
D,Fat-Soluble,Bone health,Fatty fish
E,Fat-Soluble,Antioxidant,Nuts
K,Fat-Soluble,Blood clotting,Leafy greens
B1,Water-Soluble,Energy production,*
B2,Water-Soluble,Energy production,*
B3,Water-Soluble,Energy production,Meat
B6,Water-Soluble,Protein metabolism,Poultry
B12,Water-Soluble,Nervous system,Meat`,
sampleOptions: {
fromPositionStatus: false,
toPositionStatus: false,
fromPosition: '1',
toPosition: '2',
fromHeader: 'Vitamin',
toHeader: 'Function',
emptyValuesFilling: false,
customFiller: '*',
deleteComment: false,
commentCharacter: '',
emptyLines: false
}
},
{
title: 'Place Columns Side by Side for Analysis',
description:
'In this example, we change the order of columns in a CSV dataset to have the columns essential for analysis adjacent to each other. We match the "ScreenSize" column by its name and place it in the second-to-last position "-2". This groups the "ScreenSize" and "Price" columns together, allowing us to easily compare and choose the phone we want to buy. We also remove empty lines and specify that lines starting with the "#" symbol are comments and should be left as is.',
sampleText: `Brand,Model,ScreenSize,OS,Price
Apple,iPhone 15 Pro Max,6.7″,iOS,$1299
Samsung,Galaxy S23 Ultra,6.8″,Android,$1199
Google,Pixel 7 Pro,6.4″,Android,$899
#OnePlus,11 Pro,6.7″,Android,$949
Xiaomi,13 Ultra,6.6″,Android,$849`,
sampleResult: `Brand,Model,OS,ScreenSize,Price
Apple,iPhone 15 Pro Max,iOS,6.7″,$1299
Samsung,Galaxy S23 Ultra,Android,6.8″,$1199
Google,Pixel 7 Pro,Android,6.4″,$899
Xiaomi,13 Ultra,Android,6.6″,$849`,
sampleOptions: {
fromPositionStatus: false,
toPositionStatus: true,
fromPosition: '1',
toPosition: '4',
fromHeader: 'ScreenSize',
toHeader: 'OS',
emptyValuesFilling: true,
customFiller: 'x',
deleteComment: true,
commentCharacter: '#',
emptyLines: true
}
}
];
export default function CsvToTsv({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (optionsValues: InitialValuesType, input: string) => {
setResult(csvColumnsSwap(input, optionsValues));
};
const headers = getCsvHeaders(input);
const headerOptions = headers.map((item) => ({
label: `${item}`,
value: item
}));
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Swap-From Column',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('fromPositionStatus', true)}
title="Set Column-From position"
checked={values.fromPositionStatus}
/>
{values.fromPositionStatus && (
<TextFieldWithDesc
description={'Position of the first column you want to swap'}
value={values.fromPosition}
onOwnChange={(val) => updateField('fromPosition', val)}
type="number"
inputProps={{ min: 1, max: headers.length }}
/>
)}
<SimpleRadio
onClick={() => updateField('fromPositionStatus', false)}
title="Set Column-From Header"
checked={!values.fromPositionStatus}
/>
{!values.fromPositionStatus && (
<SelectWithDesc
selected={values.fromHeader}
options={headerOptions}
onChange={(value) => updateField('fromHeader', value)}
description={'Header of the first column you want to swap.'}
/>
)}
</Box>
)
},
{
title: 'Swap-to Column',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('toPositionStatus', true)}
title="Set Column-To position"
checked={values.toPositionStatus}
/>
{values.toPositionStatus && (
<TextFieldWithDesc
description={'Position of the second column you want to swap'}
value={values.toPosition}
onOwnChange={(val) => updateField('toPosition', val)}
type="number"
inputProps={{ min: 1, max: headers.length }}
/>
)}
<SimpleRadio
onClick={() => updateField('toPositionStatus', false)}
title="Set Column-To Header"
checked={!values.toPositionStatus}
/>
{!values.toPositionStatus && (
<SelectWithDesc
selected={values.toHeader}
options={headerOptions}
onChange={(value) => updateField('toHeader', value)}
description={'Header of the second column you want to swap..'}
/>
)}
</Box>
)
},
{
title: 'Incomplete Data',
component: (
<Box>
<SelectWithDesc
selected={values.emptyValuesFilling}
options={[
{ label: 'Fill With Empty Values', value: true },
{ label: 'Fill with Custom Values', value: false }
]}
onChange={(value) => updateField('emptyValuesFilling', value)}
description={
'Fill incomplete CSV data with empty symbols or a custom symbol.'
}
/>
{!values.emptyValuesFilling && (
<TextFieldWithDesc
description={
'Specify a custom symbol to fill incomplete CSV data with'
}
value={values.customFiller}
onOwnChange={(val) => updateField('customFiller', val)}
/>
)}
</Box>
)
},
{
title: 'Comments and Empty Lines',
component: (
<Box>
<CheckboxWithDesc
checked={values.deleteComment}
onChange={(value) => updateField('deleteComment', value)}
title="Delete Comments"
description="if checked, comments given by the following character will be deleted"
/>
{values.deleteComment && (
<TextFieldWithDesc
description={
'Specify the character used to start comments in the input CSV (and if needed remove them via checkbox above)'
}
value={values.commentCharacter}
onOwnChange={(val) => updateField('commentCharacter', val)}
/>
)}
<CheckboxWithDesc
checked={values.emptyLines}
onChange={(value) => updateField('emptyLines', value)}
title="Delete Empty Lines"
description="Do not include empty lines in the output data."
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
resultComponent={<ToolTextResult value={result} />}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
exampleCards={exampleCards}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('csv', {
name: 'Swap CSV Columns',
path: 'swap-csv-columns',
icon: 'eva:swap-outline',
description:
'Just upload your CSV file in the form below, specify the columns to swap, and the tool will automatically change the positions of the specified columns in the output file. In the tool options, you can specify the column positions or names that you want to swap, as well as fix incomplete data and optionally remove empty records and records that have been commented out.',
shortDescription: 'Reorder CSV columns',
longDescription:
'This tool reorganizes CSV data by swapping the positions of its columns. Swapping columns can enhance the readability of a CSV file by placing frequently used data together or in the front for easier data comparison and editing. For example, you can swap the first column with the last or swap the second column with the third. To swap columns based on their positions, select the "Set Column Position" mode and enter the numbers of the "from" and "to" columns to be swapped in the first and second blocks of options. For example, if you have a CSV file with four columns "1, 2, 3, 4" and swap columns with positions "2" and "4", the output CSV will have columns in the order: "1, 4, 3, 2".As an alternative to positions, you can swap columns by specifying their headers (column names on the first row of data). If you enable this mode in the options, then you can enter the column names like "location" and "city", and the program will swap these two columns. If any of the specified columns have incomplete data (some fields are missing), you can choose to skip such data or fill the missing fields with empty values or custom values (specified in the options). Additionally, you can specify the symbol used for comments in the CSV data, such as "#" or "//". If you do not need the commented lines in the output, you can remove them by using the "Delete Comments" checkbox. You can also activate the checkbox "Delete Empty Lines" to get rid of empty lines that contain no visible information. Csv-abulous!',
keywords: ['csv', 'swap', 'columns'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,61 @@
import { splitCsv } from '@utils/csv';
import { InitialValuesType } from './types';
function retrieveFromAndTo(
headerRow: string[],
options: InitialValuesType
): number[] {
const from = options.fromPositionStatus
? Number(options.fromPosition)
: headerRow.findIndex((header) => header === options.fromHeader) + 1;
const to = options.toPositionStatus
? Number(options.toPosition)
: headerRow.findIndex((header) => header === options.toHeader) + 1;
if (from <= 0 || to <= 0)
throw new Error('Invalid column positions. Check headers or positions.');
if (from > headerRow.length || to > headerRow.length)
throw new Error(`There are only ${headerRow.length} columns`);
return [from, to];
}
function swap(lines: string[][], from: number, to: number): string[][] {
if (from <= 0 || to <= 0)
throw new Error('Columns position must be greater than zero ');
return lines.map((row) => {
const newRow = [...row]; // Clone the row to avoid mutating the original
[newRow[from - 1], newRow[to - 1]] = [newRow[to - 1], newRow[from - 1]]; // Swap values
return newRow;
});
}
export function csvColumnsSwap(input: string, options: InitialValuesType) {
if (!input) {
return '';
}
// split csv input and remove comments
const rows = splitCsv(
input,
options.deleteComment,
options.commentCharacter,
options.emptyLines
);
const columnCount = Math.max(...rows.map((row) => row.length));
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < columnCount; j++) {
if (!rows[i][j]) {
rows[i][j] = options.emptyValuesFilling ? '' : options.customFiller;
}
}
}
const positions = retrieveFromAndTo(rows[0], options);
const result = swap(rows, positions[0], positions[1]);
return result.join('\n');
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { InitialValuesType } from './types';
import { csvColumnsSwap } from './service';
describe('csvColumnsSwap', () => {
it('should swap columns by position', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '1', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
it('should swap columns by header', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
const options: InitialValuesType = {
fromPositionStatus: false, // fromPositionStatus
fromPosition: '', // fromPosition
toPositionStatus: false, // toPositionStatus
toPosition: '', // toPosition
fromHeader: 'A', // fromHeader
toHeader: 'C', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
it('should fill missing values with custom filler', () => {
const input = 'A,B,C\n1,2\n4';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '1', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: false, // dataCompletion
customFiller: 'X', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('C,B,A\nX,2,1\nX,X,4');
});
it('should skip filling missing values', () => {
const input = 'A,B,C\n1,2\n4';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '1', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('C,B,A\n,2,1\n,,4');
});
it('should throw an error for invalid column positions', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '0', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
expect(() => csvColumnsSwap(input, options)).toThrow(
'Invalid column positions. Check headers or positions.'
);
});
it('should handle empty input gracefully', () => {
const input = '';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '1', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: false, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('');
});
it('should remove comments if deleteComment is true', () => {
const input = '# Comment\nA,B,C\n1,2,3\n4,5,6';
const options: InitialValuesType = {
fromPositionStatus: true, // fromPositionStatus
fromPosition: '1', // fromPosition
toPositionStatus: true, // toPositionStatus
toPosition: '3', // toPosition
fromHeader: '', // fromHeader
toHeader: '', // toHeader
emptyValuesFilling: true, // dataCompletion
customFiller: '', // customFiller
deleteComment: true, // deleteComment
commentCharacter: '#', // commentCharacter
emptyLines: true // emptyLines
};
const result = csvColumnsSwap(input, options);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
});

View File

@@ -0,0 +1,13 @@
export type InitialValuesType = {
fromPositionStatus: boolean;
fromPosition: string | '';
toPositionStatus: boolean;
toPosition: string | '';
fromHeader: string | '';
toHeader: string | '';
emptyValuesFilling: boolean;
customFiller: string | '';
deleteComment: boolean;
commentCharacter: string | '';
emptyLines: boolean;
};

View File

@@ -6,10 +6,10 @@ import { GetGroupsType } from '@components/options/ToolOptions';
import ColorSelector from '@components/options/ColorSelector';
import Color from 'color';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import { areColorsSimilar } from 'utils/color';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
import { processImage } from './service';
const initialValues = {
fromColor: 'white',
@@ -19,7 +19,7 @@ const initialValues = {
const validationSchema = Yup.object({
// splitSeparator: Yup.string().required('The separator is required')
});
export default function ChangeColorsInPng({ title }: ToolComponentProps) {
export default function ChangeColorsInImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
@@ -36,54 +36,10 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
} catch (err) {
return;
}
const processImage = async (
file: File,
fromColor: [number, number, number],
toColor: [number, number, number],
similarity: number
) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data: Uint8ClampedArray = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: 'image/png'
});
setResult(newFile);
}
}, 'image/png');
};
processImage(input, fromRgb, toRgb, Number(similarity));
processImage(input, fromRgb, toRgb, Number(similarity), setResult);
};
const getGroups: GetGroupsType<typeof initialValues> = ({
values,
updateField
@@ -127,22 +83,11 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Transparent PNG'}
value={result}
extension={'png'}
/>
}
toolInfo={{
title: 'Make Colors Transparent',
description:
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
}}
resultComponent={<ToolFileResult title={'Result image'} value={result} />}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Change colors in image',
path: 'change-colors',
icon: 'cil:color-fill',
description:
"World's simplest online Image color changer. Just import your image (JPG, PNG, SVG) in the editor on the left, select which colors to change, and you'll instantly get a new image with the new colors on the right. Free, quick, and very powerful. Import an image replace its colors.",
shortDescription: 'Quickly swap colors in a image',
keywords: ['change', 'colors', 'in', 'png', 'image', 'jpg'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,169 @@
import { areColorsSimilar } from '@utils/color';
export const processImage = async (
file: File,
fromColor: [number, number, number],
toColor: [number, number, number],
similarity: number,
setResult: (result: File | null) => void
): Promise<void> => {
if (file.type === 'image/svg+xml') {
const reader = new FileReader();
reader.onload = (e) => {
if (!e.target?.result) return;
let svgContent = e.target.result as string;
const toColorHex = rgbToHex(toColor[0], toColor[1], toColor[2]);
// Replace hex colors with various formats (#fff, #ffffff)
const hexRegexShort = new RegExp(`#[0-9a-f]{3}\\b`, 'gi');
const hexRegexLong = new RegExp(`#[0-9a-f]{6}\\b`, 'gi');
svgContent = svgContent.replace(hexRegexShort, (match) => {
// Expand short hex to full form for comparison
const expanded =
'#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3];
const matchRgb = hexToRgb(expanded);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
return toColorHex;
}
return match;
});
svgContent = svgContent.replace(hexRegexLong, (match) => {
const matchRgb = hexToRgb(match);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
return toColorHex;
}
return match;
});
// Replace RGB colors
const rgbRegex = new RegExp(
`rgb\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbRegex, (match, r, g, b) => {
const matchRgb: [number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b)
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgb(${toColor[0]}, ${toColor[1]}, ${toColor[2]})`;
}
return match;
});
// Replace RGBA colors (preserving alpha)
const rgbaRegex = new RegExp(
`rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*([\\d.]+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbaRegex, (match, r, g, b, a) => {
const matchRgb: [number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b)
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgba(${toColor[0]}, ${toColor[1]}, ${toColor[2]}, ${a})`;
}
return match;
});
// Replace named SVG colors if they match our target color
const namedColors = {
red: [255, 0, 0],
green: [0, 128, 0],
blue: [0, 0, 255],
black: [0, 0, 0],
white: [255, 255, 255]
// Add more named colors as needed
};
Object.entries(namedColors).forEach(([name, rgb]) => {
if (
areColorsSimilar(
rgb as [number, number, number],
fromColor,
similarity
)
) {
const colorRegex = new RegExp(`\\b${name}\\b`, 'gi');
svgContent = svgContent.replace(colorRegex, toColorHex);
}
});
// Create new file with modified content
const newFile = new File([svgContent], file.name, {
type: 'image/svg+xml'
});
setResult(newFile);
};
reader.readAsText(file);
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data: Uint8ClampedArray = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: file.type
});
setResult(newFile);
}
}, file.type);
};
const rgbToHex = (r: number, g: number, b: number): string => {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
};
// Helper function to parse hex to RGB
const hexToRgb = (hex: string): [number, number, number] | null => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
]
: null;
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { changeOpacity } from './service';
@@ -97,16 +97,12 @@ export default function ChangeOpacity({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Changed PNG'}
value={result}
extension={'png'}
/>
<ToolFileResult title={'Changed image'} value={result} />
}
initialValues={initialValues}
// exampleCards={exampleCards}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Change image Opacity',
path: 'change-opacity',
icon: 'material-symbols:opacity',
description:
'Easily adjust the transparency of your images. Simply upload your image, use the slider to set the desired opacity level between 0 (fully transparent) and 1 (fully opaque), and download the modified image.',
shortDescription: 'Adjust transparency of images',
keywords: ['opacity', 'transparency', 'png', 'alpha', 'jpg', 'jpeg', 'image'],
component: lazy(() => import('./index'))
});

View File

@@ -9,7 +9,10 @@ interface OpacityOptions {
areaHeight: number;
}
export async function changeOpacity(file: File, options: OpacityOptions): Promise<File> {
export async function changeOpacity(
file: File,
options: OpacityOptions
): Promise<File> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
@@ -32,12 +35,12 @@ export async function changeOpacity(file: File, options: OpacityOptions): Promis
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, { type: 'image/png' });
const newFile = new File([blob], file.name, { type: file.type });
resolve(newFile);
} else {
reject(new Error('Failed to generate image blob'));
}
}, 'image/png');
}, file.type);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = event.target?.result as string;
@@ -67,9 +70,10 @@ function applyGradientOpacity(
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.drawImage(img, 0, 0);
const gradient = options.gradientType === 'linear'
? createLinearGradient(ctx, options)
: createRadialGradient(ctx, options);
const gradient =
options.gradientType === 'linear'
? createLinearGradient(ctx, options)
: createRadialGradient(ctx, options);
ctx.fillStyle = gradient;
ctx.fillRect(areaLeft, areaTop, areaWidth, areaHeight);

View File

@@ -0,0 +1,121 @@
import React, { useContext, useState } from 'react';
import { InitialValuesType } from './types';
import { compressImage } from './service';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import { ToolComponentProps } from '@tools/defineTool';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
import { updateNumberField } from '@utils/string';
const initialValues: InitialValuesType = {
maxFileSizeInMB: 1.0,
quality: 80
};
export default function CompressImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [originalSize, setOriginalSize] = useState<number | null>(null); // Store original file size
const [compressedSize, setCompressedSize] = useState<number | null>(null); // Store compressed file size
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
setOriginalSize(input.size);
try {
setIsProcessing(true);
const compressed = await compressImage(input, values);
if (compressed) {
setResult(compressed);
setCompressedSize(compressed.size);
} else {
showSnackBar('Failed to compress image. Please try again.', 'error');
}
} catch (err) {
console.error('Error in compression:', err);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Compressed image'}
value={result}
loading={isProcessing}
/>
}
initialValues={initialValues}
getGroups={({ values, updateField }) => [
{
title: 'Compression options',
component: (
<Box>
<TextFieldWithDesc
name="maxFileSizeInMB"
type="number"
inputProps={{ min: 0.1, step: 0.1 }}
description="Maximum file size in megabytes"
onOwnChange={(value) =>
updateNumberField(value, 'maxFileSizeInMB', updateField)
}
value={values.maxFileSizeInMB}
/>
<TextFieldWithDesc
name="quality"
type="number"
inputProps={{ min: 10, max: 100, step: 1 }}
description="Image quality percentage (lower means smaller file size)"
onOwnChange={(value) =>
updateNumberField(value, 'quality', updateField)
}
value={values.quality}
/>
</Box>
)
},
{
title: 'File sizes',
component: (
<Box>
<Box>
{originalSize !== null && (
<Typography>
Original Size: {(originalSize / 1024).toFixed(2)} KB
</Typography>
)}
{compressedSize !== null && (
<Typography>
Compressed Size: {(compressedSize / 1024).toFixed(2)} KB
</Typography>
)}
</Box>
</Box>
)
}
]}
compute={compute}
setInput={setInput}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Compress Image',
path: 'compress',
component: lazy(() => import('./index')),
icon: 'material-symbols-light:compress-rounded',
description:
'Compress images to reduce file size while maintaining reasonable quality.',
shortDescription:
'Compress images to reduce file size while maintaining reasonable quality.',
keywords: ['image', 'compress', 'reduce', 'quality']
});

View File

@@ -0,0 +1,30 @@
import { InitialValuesType } from './types';
import imageCompression from 'browser-image-compression';
export const compressImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
try {
const { maxFileSizeInMB, quality } = options;
// Configuration for the compression library
const compressionOptions = {
maxSizeMB: maxFileSizeInMB,
maxWidthOrHeight: 1920, // Reasonable default for most use cases
useWebWorker: true,
initialQuality: quality / 100 // Convert percentage to decimal
};
// Compress the image
const compressedFile = await imageCompression(file, compressionOptions);
// Create a new file with the original name
return new File([compressedFile], file.name, {
type: compressedFile.type
});
} catch (error) {
console.error('Error compressing image:', error);
return null;
}
};

View File

@@ -0,0 +1,4 @@
export interface InitialValuesType {
maxFileSizeInMB: number;
quality: number;
}

View File

@@ -5,7 +5,7 @@ import Jimp from 'jimp';
test.describe('Create transparent PNG', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/png/create-transparent');
await page.goto('/image-generic/create-transparent');
});
//TODO check why failing

View File

@@ -112,8 +112,8 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
@@ -131,7 +131,7 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
toolInfo={{
title: 'Create Transparent PNG',
description:
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
'This tool allows you to make specific colors in an image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
}}
/>
);

View File

@@ -1,13 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
export const tool = defineTool('image-generic', {
name: 'Create transparent PNG',
path: 'create-transparent',
icon: 'mdi:circle-transparent',
shortDescription: 'Quickly make a PNG image transparent',
shortDescription: 'Quickly make an image transparent',
description:
"World's simplest online Portable Network Graphics transparency maker. Just import your PNG image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import a PNG get a transparent PNG.",
"World's simplest online Portable Network Graphics transparency maker. Just import your image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import an image get a transparent PNG.",
keywords: ['create', 'transparent'],
component: lazy(() => import('./index'))
});

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -32,7 +32,7 @@ const validationSchema = Yup.object({
.required('Height is required')
});
export default function CropPng({ title }: ToolComponentProps) {
export default function CropImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
@@ -101,11 +101,11 @@ export default function CropPng({ title }: ToolComponentProps) {
destCanvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: 'image/png'
type: file.type
});
setResult(newFile);
}
}, 'image/png');
}, file.type);
};
processImage(input, x, y, width, height, isCircular);
@@ -180,13 +180,13 @@ export default function CropPng({ title }: ToolComponentProps) {
<SimpleRadio
onClick={() => updateField('cropShape', 'rectangular')}
checked={values.cropShape == 'rectangular'}
description={'Crop a rectangular fragment from a PNG.'}
description={'Crop a rectangular fragment from an image.'}
title={'Rectangular Crop Shape'}
/>
<SimpleRadio
onClick={() => updateField('cropShape', 'circular')}
checked={values.cropShape == 'circular'}
description={'Crop a circular fragment from a PNG.'}
description={'Crop a circular fragment from an image.'}
title={'Circular Crop Shape'}
/>
</Box>
@@ -200,8 +200,8 @@ export default function CropPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
showCropOverlay={!!input}
cropShape={values.cropShape as 'rectangular' | 'circular'}
cropPosition={{
@@ -225,16 +225,12 @@ export default function CropPng({ title }: ToolComponentProps) {
validationSchema={validationSchema}
renderCustomInput={renderCustomInput}
resultComponent={
<ToolFileResult
title={'Cropped PNG'}
value={result}
extension={'png'}
/>
<ToolFileResult title={'Cropped image'} value={result} />
}
toolInfo={{
title: 'Crop PNG Image',
title: 'Crop Image',
description:
'This tool allows you to crop a PNG image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
'This tool allows you to crop an image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
}}
/>
);

View File

@@ -1,7 +1,7 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
export const tool = defineTool('image-generic', {
name: 'Crop',
path: 'crop',
icon: 'mdi:crop', // Iconify icon as a string

View File

@@ -0,0 +1,17 @@
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
import { tool as removeBackground } from './remove-background/meta';
import { tool as cropImage } from './crop/meta';
import { tool as changeOpacity } from './change-opacity/meta';
import { tool as createTransparent } from './create-transparent/meta';
export const imageGenericTools = [
resizeImage,
compressImage,
removeBackground,
cropImage,
changeOpacity,
changeColors,
createTransparent
];

View File

@@ -1,4 +1,3 @@
import { Box, CircularProgress, Typography } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
@@ -11,7 +10,9 @@ const initialValues = {};
const validationSchema = Yup.object({});
export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
export default function RemoveBackgroundFromImage({
title
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
@@ -64,7 +65,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png', 'image/jpeg', 'image/jpg']}
accept={['image/*']}
title={'Input Image'}
/>
}
@@ -78,7 +79,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
/>
}
toolInfo={{
title: 'Remove Background from PNG',
title: 'Remove Background from Image',
description:
'This tool uses AI to automatically remove the background from your images, creating a transparent PNG. Perfect for product photos, profile pictures, and design assets.'
}}

View File

@@ -0,0 +1,21 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Remove Background from Image',
path: 'remove-background',
icon: 'mdi:image-remove',
description:
"World's simplest online tool to remove backgrounds from images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
shortDescription: 'Automatically remove backgrounds from images',
keywords: [
'remove',
'background',
'png',
'transparent',
'image',
'ai',
'jpg'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,203 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import SimpleRadio from '@components/options/SimpleRadio';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import { processImage } from './service';
import { InitialValuesType } from './types';
const initialValues: InitialValuesType = {
resizeMethod: 'pixels' as 'pixels' | 'percentage',
dimensionType: 'width' as 'width' | 'height',
width: '800',
height: '600',
percentage: '50',
maintainAspectRatio: true
};
const validationSchema = Yup.object({
width: Yup.number().when('resizeMethod', {
is: 'pixels',
then: (schema) =>
schema.min(1, 'Width must be at least 1px').required('Width is required')
}),
height: Yup.number().when('resizeMethod', {
is: 'pixels',
then: (schema) =>
schema
.min(1, 'Height must be at least 1px')
.required('Height is required')
}),
percentage: Yup.number().when('resizeMethod', {
is: 'percentage',
then: (schema) =>
schema
.min(1, 'Percentage must be at least 1%')
.max(1000, 'Percentage must be at most 1000%')
.required('Percentage is required')
})
});
export default function ResizeImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setResult(await processImage(input, optionsValues));
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Resize Method',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('resizeMethod', 'pixels')}
checked={values.resizeMethod === 'pixels'}
description={'Resize by specifying dimensions in pixels.'}
title={'Resize by Pixels'}
/>
<SimpleRadio
onClick={() => updateField('resizeMethod', 'percentage')}
checked={values.resizeMethod === 'percentage'}
description={
'Resize by specifying a percentage of the original size.'
}
title={'Resize by Percentage'}
/>
</Box>
)
},
...(values.resizeMethod === 'pixels'
? [
{
title: 'Dimension Type',
component: (
<Box>
<CheckboxWithDesc
checked={values.maintainAspectRatio}
onChange={(value) =>
updateField('maintainAspectRatio', value)
}
description={
'Maintain the original aspect ratio of the image.'
}
title={'Maintain Aspect Ratio'}
/>
{values.maintainAspectRatio && (
<Box>
<SimpleRadio
onClick={() => updateField('dimensionType', 'width')}
checked={values.dimensionType === 'width'}
description={
'Specify the width in pixels and calculate height based on aspect ratio.'
}
title={'Set Width'}
/>
<SimpleRadio
onClick={() => updateField('dimensionType', 'height')}
checked={values.dimensionType === 'height'}
description={
'Specify the height in pixels and calculate width based on aspect ratio.'
}
title={'Set Height'}
/>
</Box>
)}
<TextFieldWithDesc
value={values.width}
onOwnChange={(val) => updateField('width', val)}
description={'Width (in pixels)'}
disabled={
values.maintainAspectRatio &&
values.dimensionType === 'height'
}
inputProps={{
'data-testid': 'width-input',
type: 'number',
min: 1
}}
/>
<TextFieldWithDesc
value={values.height}
onOwnChange={(val) => updateField('height', val)}
description={'Height (in pixels)'}
disabled={
values.maintainAspectRatio &&
values.dimensionType === 'width'
}
inputProps={{
'data-testid': 'height-input',
type: 'number',
min: 1
}}
/>
</Box>
)
}
]
: [
{
title: 'Percentage',
component: (
<Box>
<TextFieldWithDesc
value={values.percentage}
onOwnChange={(val) => updateField('percentage', val)}
description={
'Percentage of original size (e.g., 50 for half size, 200 for double size)'
}
inputProps={{
'data-testid': 'percentage-input',
type: 'number',
min: 1,
max: 1000
}}
/>
</Box>
)
}
])
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/jpeg', 'image/png', 'image/svg+xml', 'image/gif']}
title={'Input Image'}
/>
}
resultComponent={
<ToolFileResult
title={'Resized Image'}
value={result}
extension={input?.name.split('.').pop() || 'png'}
/>
}
toolInfo={{
title: 'Resize Image',
description:
'This tool allows you to resize JPG, PNG, SVG, or GIF images. You can resize by specifying dimensions in pixels or by percentage, with options to maintain the original aspect ratio.'
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Resize Image',
path: 'resize',
icon: 'mdi:resize', // Iconify icon as a string
description:
'Resize JPG, PNG, SVG or GIF images by pixels or percentage while maintaining aspect ratio or not.',
shortDescription: 'Resize images easily.',
keywords: [
'resize',
'image',
'scale',
'jpg',
'png',
'svg',
'gif',
'dimensions'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,218 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
export const processImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
const {
width,
height,
resizeMethod,
percentage,
dimensionType,
maintainAspectRatio
} = options;
if (file.type === 'image/svg+xml') {
try {
// Read the SVG file
const fileText = await file.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(fileText, 'image/svg+xml');
const svgElement = svgDoc.documentElement;
// Get original dimensions
const viewBox = svgElement.getAttribute('viewBox');
let originalWidth: string | number | null =
svgElement.getAttribute('width');
let originalHeight: string | number | null =
svgElement.getAttribute('height');
// Parse viewBox if available and width/height are not explicitly set
let viewBoxValues = null;
if (viewBox) {
viewBoxValues = viewBox.split(' ').map(Number);
}
// Determine original dimensions from viewBox if not explicitly set
if (!originalWidth && viewBoxValues && viewBoxValues.length === 4) {
originalWidth = String(viewBoxValues[2]);
}
if (!originalHeight && viewBoxValues && viewBoxValues.length === 4) {
originalHeight = String(viewBoxValues[3]);
}
// Default dimensions if still not available
originalWidth = originalWidth ? parseFloat(originalWidth) : 300;
originalHeight = originalHeight ? parseFloat(originalHeight) : 150;
// Calculate new dimensions
let newWidth = originalWidth;
let newHeight = originalHeight;
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
newHeight = Math.round((newWidth / originalWidth) * originalHeight);
} else {
newHeight = parseInt(height);
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
newWidth = Math.round((newHeight / originalHeight) * originalWidth);
} else {
newWidth = parseInt(width);
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
newWidth = Math.round(originalWidth * scale);
newHeight = Math.round(originalHeight * scale);
}
// Update SVG attributes
svgElement.setAttribute('width', String(newWidth));
svgElement.setAttribute('height', String(newHeight));
// If viewBox isn't already set, add it to preserve scaling
if (!viewBox) {
svgElement.setAttribute(
'viewBox',
`0 0 ${originalWidth} ${originalHeight}`
);
}
// Serialize the modified SVG document
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgDoc);
// Create a new file
return new File([svgString], file.name, {
type: 'image/svg+xml'
});
} catch (error) {
console.error('Error processing SVG:', error);
// Fall back to canvas method if SVG processing fails
}
} else if (file.type === 'image/gif') {
try {
const ffmpeg = new FFmpeg();
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
// Write the input file to memory
await ffmpeg.writeFile('input.gif', await fetchFile(file));
// Calculate new dimensions
let newWidth = 0;
let newHeight = 0;
let scaleFilter = '';
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
scaleFilter = `scale=${newWidth}:-1`;
} else {
newHeight = parseInt(height);
scaleFilter = `scale=${newWidth}:${newHeight}`;
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
scaleFilter = `scale=-1:${newHeight}`;
} else {
newWidth = parseInt(width);
scaleFilter = `scale=${newWidth}:${newHeight}`;
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
scaleFilter = `scale=iw*${scale}:ih*${scale}`;
}
// Run FFmpeg command
await ffmpeg.exec(['-i', 'input.gif', '-vf', scaleFilter, 'output.gif']);
// Read the output file
const data = await ffmpeg.readFile('output.gif');
// Create a new File object
return new File([data], file.name, { type: 'image/gif' });
} catch (error) {
console.error('Error processing GIF with FFmpeg:', error);
// Fall back to canvas method if FFmpeg processing fails
}
}
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return null;
// Load image
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
// Calculate new dimensions
let newWidth = img.width;
let newHeight = img.height;
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
newHeight = Math.round((newWidth / img.width) * img.height);
} else {
newHeight = parseInt(height);
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
newWidth = Math.round((newHeight / img.height) * img.width);
} else {
newWidth = parseInt(width);
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
newWidth = Math.round(img.width * scale);
newHeight = Math.round(img.height * scale);
}
// Set canvas dimensions
canvas.width = newWidth;
canvas.height = newHeight;
// Draw resized image
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// Determine output type based on input file
let outputType = 'image/png';
if (file.type) {
outputType = file.type;
}
// Convert canvas to blob and create file
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], file.name, { type: outputType }));
} else {
resolve(null);
}
}, outputType);
});
};

View File

@@ -0,0 +1,8 @@
export type InitialValuesType = {
resizeMethod: 'pixels' | 'percentage';
dimensionType: 'width' | 'height';
width: string;
height: string;
percentage: string;
maintainAspectRatio: boolean;
};

View File

@@ -1,3 +1,4 @@
import { pngTools } from './png';
import { imageGenericTools } from './generic';
export const imageTools = [...pngTools];
export const imageTools = [...imageGenericTools, ...pngTools];

View File

@@ -1,43 +0,0 @@
import { expect, test } from '@playwright/test';
import { Buffer } from 'buffer';
import path from 'path';
import Jimp from 'jimp';
import { convertHexToRGBA } from '@utils/color';
test.describe('Change colors in png', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/png/change-colors-in-png');
});
// test('should change pixel color', async ({ page }) => {
// // Upload image
// const fileInput = page.locator('input[type="file"]');
// const imagePath = path.join(__dirname, 'test.png');
// await fileInput?.setInputFiles(imagePath);
//
// await page.getByTestId('from-color-input').fill('#FF0000');
// const toColor = '#0000FF';
// await page.getByTestId('to-color-input').fill(toColor);
//
// // Click on download
// const downloadPromise = page.waitForEvent('download');
// await page.getByText('Save as').click();
//
// // Intercept and read downloaded PNG
// const download = await downloadPromise;
// const downloadStream = await download.createReadStream();
//
// const chunks = [];
// for await (const chunk of downloadStream) {
// chunks.push(chunk);
// }
// const fileContent = Buffer.concat(chunks);
//
// expect(fileContent.length).toBeGreaterThan(0);
//
// // Check that the first pixel is transparent
// const image = await Jimp.read(fileContent);
// const color = image.getPixelColor(0, 0);
// expect(color).toBe(convertHexToRGBA(toColor));
// });
});

View File

@@ -1,13 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Change colors in png',
path: 'change-colors-in-png',
icon: 'cil:color-fill',
description:
"World's simplest online Portable Network Graphics (PNG) color changer. Just import your PNG image in the editor on the left, select which colors to change, and you'll instantly get a new PNG with the new colors on the right. Free, quick, and very powerful. Import a PNG replace its colors.",
shortDescription: 'Quickly swap colors in a PNG image',
keywords: ['change', 'colors', 'in', 'png'],
component: lazy(() => import('./index'))
});

View File

@@ -1,12 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Change PNG Opacity',
path: 'change-opacity',
icon: 'material-symbols:opacity',
description: 'Easily adjust the transparency of your PNG images. Simply upload your PNG file, use the slider to set the desired opacity level between 0 (fully transparent) and 1 (fully opaque), and download the modified image.',
shortDescription: 'Adjust transparency of PNG images',
keywords: ['opacity', 'transparency', 'png', 'alpha'],
component: lazy(() => import('./index'))
});

View File

@@ -8,7 +8,7 @@ export const tool = defineTool('png', {
icon: 'material-symbols-light:compress',
description:
'This is a program that compresses PNG pictures. As soon as you paste your PNG picture in the input area, the program will compress it and show the result in the output area. In the options, you can adjust the compression level, as well as find the old and new picture file sizes.',
shortDescription: 'Quicly compress a PNG',
shortDescription: 'Quickly compress a PNG',
keywords: ['compress', 'png'],
component: lazy(() => import('./index'))
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,17 +1,4 @@
import { tool as pngCrop } from './crop/meta';
import { tool as pngCompressPng } from './compress-png/meta';
import { tool as convertJgpToPng } from './convert-jgp-to-png/meta';
import { tool as pngCreateTransparent } from './create-transparent/meta';
import { tool as changeColorsInPng } from './change-colors-in-png/meta';
import { tool as changeOpacity } from './change-opacity/meta';
import { tool as removeBackground } from './remove-background/meta';
export const pngTools = [
pngCompressPng,
pngCreateTransparent,
changeColorsInPng,
convertJgpToPng,
changeOpacity,
pngCrop,
removeBackground
];
export const pngTools = [pngCompressPng, convertJgpToPng];

View File

@@ -1,13 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Remove Background from PNG',
path: 'remove-background',
icon: 'mdi:image-remove',
description:
"World's simplest online tool to remove backgrounds from PNG images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
shortDescription: 'Automatically remove backgrounds from images',
keywords: ['remove', 'background', 'png', 'transparent', 'image', 'ai'],
component: lazy(() => import('./index'))
});

View File

@@ -23,8 +23,8 @@ export type ToolCategory =
| 'json'
| 'time'
| 'csv'
| 'time'
| 'pdf';
| 'pdf'
| 'image-generic';
export interface DefinedTool {
type: ToolCategory;

View File

@@ -12,6 +12,19 @@ import { timeTools } from '../pages/tools/time';
import { IconifyIcon } from '@iconify/react';
import { pdfTools } from '../pages/tools/pdf';
const toolCategoriesOrder: ToolCategory[] = [
'image-generic',
'string',
'json',
'pdf',
'list',
'csv',
'video',
'number',
'png',
'time',
'gif'
];
export const tools: DefinedTool[] = [
...imageTools,
...stringTools,
@@ -95,6 +108,13 @@ const categoriesConfig: {
icon: 'fluent-mdl2:date-time',
value:
'Tools for working with time and date draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.'
},
{
type: 'image-generic',
title: 'Image',
icon: 'material-symbols-light:image-outline-rounded',
value:
'Tools for working with pictures compress, resize, crop, convert to JPG, rotate, remove background and much more.'
}
];
// use for changelogs
@@ -123,20 +143,22 @@ export const filterTools = (
export const getToolsByCategory = (): {
title: string;
rawTitle: string;
description: string;
icon: IconifyIcon | string;
type: string;
type: ToolCategory;
example: { title: string; path: string };
tools: DefinedTool[];
}[] => {
const groupedByType: Partial<Record<ToolCategory, DefinedTool[]>> =
Object.groupBy(tools, ({ type }) => type);
return (Object.entries(groupedByType) as Entries<typeof groupedByType>).map(
([type, tools]) => {
return (Object.entries(groupedByType) as Entries<typeof groupedByType>)
.map(([type, tools]) => {
const categoryConfig = categoriesConfig.find(
(config) => config.type === type
);
return {
rawTitle: categoryConfig?.title ?? capitalizeFirstLetter(type),
title: `${categoryConfig?.title ?? capitalizeFirstLetter(type)} Tools`,
description: categoryConfig?.value ?? '',
type,
@@ -146,6 +168,10 @@ export const getToolsByCategory = (): {
? { title: tools[0].name, path: tools[0].path }
: { title: '', path: '' }
};
}
);
})
.sort(
(a, b) =>
toolCategoriesOrder.indexOf(a.type) -
toolCategoriesOrder.indexOf(b.type)
);
};

36
src/utils/csv.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Splits a CSV string into rows, skipping any blank lines.
* @param {string} input - The CSV input string.
* @param {string} commentCharacter - The character used to denote comments.
* @returns {string[][]} - The CSV rows as a 2D array.
*/
export function splitCsv(
input: string,
deleteComment: boolean,
commentCharacter: string,
deleteEmptyLines: boolean
): string[][] {
let rows = input.split('\n').map((row) => row.split(','));
// Remove comments if deleteComment is true
if (deleteComment && commentCharacter) {
rows = rows.filter((row) => !row[0].trim().startsWith(commentCharacter));
}
// Remove empty lines if deleteEmptyLines is true
if (deleteEmptyLines) {
rows = rows.filter((row) => row.some((cell) => cell.trim() !== ''));
}
return rows;
}
/**
* get the headers from a CSV string .
* @param {string} input - The CSV input string.
* @returns {string[]} - The CSV header as a 1D array.
*/
export function getCsvHeaders(csvString: string): string[] {
const rows = csvString.split('\n').map((row) => row.split(','));
return rows.length > 0 ? rows[0].map((header) => header.trim()) : [];
}