mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-09 05:14:03 +01:00
Merge branch 'main' into compression
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
files:
|
||||
- path: src\pages\tools\list\duplicate\index.tsx
|
||||
readOnly: false
|
||||
- path: src\pages\tools\list\index.ts
|
||||
readOnly: false
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -3,6 +3,7 @@ export const globalDescriptionFontSize = 12;
|
||||
export const categoriesColors: string[] = [
|
||||
'#8FBC5D',
|
||||
'#3CB6E2',
|
||||
'#B17F59',
|
||||
'#FFD400',
|
||||
'#AB6993'
|
||||
];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
297
src/pages/tools/csv/swap-csv-columns/index.tsx
Normal file
297
src/pages/tools/csv/swap-csv-columns/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/pages/tools/csv/swap-csv-columns/meta.ts
Normal file
15
src/pages/tools/csv/swap-csv-columns/meta.ts
Normal 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'))
|
||||
});
|
||||
61
src/pages/tools/csv/swap-csv-columns/service.ts
Normal file
61
src/pages/tools/csv/swap-csv-columns/service.ts
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
13
src/pages/tools/csv/swap-csv-columns/types.ts
Normal file
13
src/pages/tools/csv/swap-csv-columns/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal file
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal 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'))
|
||||
});
|
||||
169
src/pages/tools/image/generic/change-colors/service.ts
Normal file
169
src/pages/tools/image/generic/change-colors/service.ts
Normal 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;
|
||||
};
|
||||
@@ -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}
|
||||
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal file
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal 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'))
|
||||
});
|
||||
@@ -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);
|
||||
121
src/pages/tools/image/generic/compress/index.tsx
Normal file
121
src/pages/tools/image/generic/compress/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/pages/tools/image/generic/compress/meta.ts
Normal file
14
src/pages/tools/image/generic/compress/meta.ts
Normal 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']
|
||||
});
|
||||
30
src/pages/tools/image/generic/compress/service.ts
Normal file
30
src/pages/tools/image/generic/compress/service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
4
src/pages/tools/image/generic/compress/types.ts
Normal file
4
src/pages/tools/image/generic/compress/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface InitialValuesType {
|
||||
maxFileSizeInMB: number;
|
||||
quality: number;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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'))
|
||||
});
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -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.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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
|
||||
17
src/pages/tools/image/generic/index.ts
Normal file
17
src/pages/tools/image/generic/index.ts
Normal 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
|
||||
];
|
||||
@@ -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.'
|
||||
}}
|
||||
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal file
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal 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'))
|
||||
});
|
||||
203
src/pages/tools/image/generic/resize/index.tsx
Normal file
203
src/pages/tools/image/generic/resize/index.tsx
Normal 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.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/pages/tools/image/generic/resize/meta.ts
Normal file
22
src/pages/tools/image/generic/resize/meta.ts
Normal 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'))
|
||||
});
|
||||
218
src/pages/tools/image/generic/resize/service.ts
Normal file
218
src/pages/tools/image/generic/resize/service.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
8
src/pages/tools/image/generic/resize/types.ts
Normal file
8
src/pages/tools/image/generic/resize/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type InitialValuesType = {
|
||||
resizeMethod: 'pixels' | 'percentage';
|
||||
dimensionType: 'width' | 'height';
|
||||
width: string;
|
||||
height: string;
|
||||
percentage: string;
|
||||
maintainAspectRatio: boolean;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pngTools } from './png';
|
||||
import { imageGenericTools } from './generic';
|
||||
|
||||
export const imageTools = [...pngTools];
|
||||
export const imageTools = [...imageGenericTools, ...pngTools];
|
||||
|
||||
@@ -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));
|
||||
// });
|
||||
});
|
||||
@@ -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'))
|
||||
});
|
||||
@@ -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'))
|
||||
});
|
||||
@@ -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 |
@@ -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];
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
@@ -23,8 +23,8 @@ export type ToolCategory =
|
||||
| 'json'
|
||||
| 'time'
|
||||
| 'csv'
|
||||
| 'time'
|
||||
| 'pdf';
|
||||
| 'pdf'
|
||||
| 'image-generic';
|
||||
|
||||
export interface DefinedTool {
|
||||
type: ToolCategory;
|
||||
|
||||
@@ -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
36
src/utils/csv.ts
Normal 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()) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user