mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-09 09:55:07 +05:30
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
c2a8b00357
15 changed files with 1268 additions and 46 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Box, Stack, TextField } from '@mui/material';
|
||||
import { Box, Stack, TextField, TextFieldProps } from '@mui/material';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
|
@ -7,14 +7,15 @@ import { globalDescriptionFontSize } from '../../config/uiConfig';
|
|||
|
||||
interface ColorSelectorProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onColorChange: (val: string) => void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ColorSelector: React.FC<ColorSelectorProps> = ({
|
||||
const ColorSelector: React.FC<ColorSelectorProps & TextFieldProps> = ({
|
||||
value = '#ffffff',
|
||||
onChange,
|
||||
description
|
||||
onColorChange,
|
||||
description,
|
||||
...props
|
||||
}) => {
|
||||
const [color, setColor] = useState<string>(value);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -22,7 +23,7 @@ const ColorSelector: React.FC<ColorSelectorProps> = ({
|
|||
const handleColorChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = event.target.value;
|
||||
setColor(val);
|
||||
onChange(val);
|
||||
onColorChange(val);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -32,6 +33,7 @@ const ColorSelector: React.FC<ColorSelectorProps> = ({
|
|||
sx={{ backgroundColor: 'white' }}
|
||||
value={color}
|
||||
onChange={handleColorChange}
|
||||
{...props}
|
||||
/>
|
||||
<IconButton onClick={() => inputRef.current?.click()}>
|
||||
<PaletteIcon />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
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));
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import ColorSelector from '../../../../components/options/ColorSelector';
|
|||
import Color from 'color';
|
||||
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
|
||||
import ToolInputAndResult from '../../../../components/ToolInputAndResult';
|
||||
import { areColorsSimilar } from 'utils/color';
|
||||
|
||||
const initialValues = {
|
||||
fromColor: 'white',
|
||||
|
|
@ -55,28 +56,13 @@ export default function ChangeColorsInPng() {
|
|||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data: Uint8ClampedArray = imageData.data;
|
||||
|
||||
const colorDistance = (
|
||||
c1: [number, number, number],
|
||||
c2: [number, number, number]
|
||||
) => {
|
||||
return Math.sqrt(
|
||||
Math.pow(c1[0] - c2[0], 2) +
|
||||
Math.pow(c1[1] - c2[1], 2) +
|
||||
Math.pow(c1[2] - c2[2], 2)
|
||||
);
|
||||
};
|
||||
const maxColorDistance = Math.sqrt(
|
||||
Math.pow(255, 2) + Math.pow(255, 2) + Math.pow(255, 2)
|
||||
);
|
||||
const similarityThreshold = (similarity / 100) * maxColorDistance;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const currentColor: [number, number, number] = [
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2]
|
||||
];
|
||||
if (colorDistance(currentColor, fromColor) <= similarityThreshold) {
|
||||
if (areColorsSimilar(currentColor, fromColor, similarity)) {
|
||||
data[i] = toColor[0]; // Red
|
||||
data[i + 1] = toColor[1]; // Green
|
||||
data[i + 2] = toColor[2]; // Blue
|
||||
|
|
@ -125,13 +111,15 @@ export default function ChangeColorsInPng() {
|
|||
<Box>
|
||||
<ColorSelector
|
||||
value={values.fromColor}
|
||||
onChange={(val) => updateField('fromColor', val)}
|
||||
onColorChange={(val) => updateField('fromColor', val)}
|
||||
description={'Replace this color (from color)'}
|
||||
inputProps={{ 'data-testid': 'from-color-input' }}
|
||||
/>
|
||||
<ColorSelector
|
||||
value={values.toColor}
|
||||
onChange={(val) => updateField('toColor', val)}
|
||||
onColorChange={(val) => updateField('toColor', val)}
|
||||
description={'With this color (to color)'}
|
||||
inputProps={{ 'data-testid': 'to-color-input' }}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.similarity}
|
||||
|
|
|
|||
BIN
src/pages/image/png/change-colors-in-png/test.png
Normal file
BIN
src/pages/image/png/change-colors-in-png/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -0,0 +1,72 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { Buffer } from 'buffer';
|
||||
import path from 'path';
|
||||
import Jimp from 'jimp';
|
||||
|
||||
test.describe('Convert JPG to PNG tool', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/png/convert-jgp-to-png');
|
||||
});
|
||||
|
||||
test('should convert jpg to png', async ({ page }) => {
|
||||
// Upload image
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const imagePath = path.join(__dirname, 'test.jpg');
|
||||
await fileInput?.setInputFiles(imagePath);
|
||||
|
||||
// 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 0x808080ff
|
||||
const image = await Jimp.read(fileContent);
|
||||
const color = image.getPixelColor(0, 0);
|
||||
expect(color).toBe(0x808080ff);
|
||||
});
|
||||
|
||||
test('should apply transparency before converting jpg to png', async ({
|
||||
page
|
||||
}) => {
|
||||
// Upload image
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const imagePath = path.join(__dirname, 'test.jpg');
|
||||
await fileInput?.setInputFiles(imagePath);
|
||||
|
||||
// Enable transparency on color 0x808080
|
||||
await page.getByLabel('Enable PNG Transparency').check();
|
||||
await page.getByTestId('color-input').fill('#808080');
|
||||
|
||||
// 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(0);
|
||||
});
|
||||
});
|
||||
155
src/pages/image/png/convert-jgp-to-png/index.tsx
Normal file
155
src/pages/image/png/convert-jgp-to-png/index.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Box } from '@mui/material';
|
||||
import ToolInputAndResult from 'components/ToolInputAndResult';
|
||||
import ToolFileInput from 'components/input/ToolFileInput';
|
||||
import CheckboxWithDesc from 'components/options/CheckboxWithDesc';
|
||||
import ColorSelector from 'components/options/ColorSelector';
|
||||
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
|
||||
import ToolOptions from 'components/options/ToolOptions';
|
||||
import ToolFileResult from 'components/result/ToolFileResult';
|
||||
import Color from 'color';
|
||||
import React, { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import { areColorsSimilar } from 'utils/color';
|
||||
|
||||
const initialValues = {
|
||||
enableTransparency: false,
|
||||
color: 'white',
|
||||
similarity: '10'
|
||||
};
|
||||
const validationSchema = Yup.object({
|
||||
// splitSeparator: Yup.string().required('The separator is required')
|
||||
});
|
||||
export default function ConvertJgpToPng() {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: typeof initialValues,
|
||||
input: any
|
||||
): Promise<void> => {
|
||||
if (!input) return;
|
||||
|
||||
const processImage = async (
|
||||
file: File,
|
||||
transparencyTransform?: {
|
||||
color: [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);
|
||||
|
||||
if (transparencyTransform) {
|
||||
const { color, similarity } = transparencyTransform;
|
||||
|
||||
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, color, similarity)) {
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, {
|
||||
type: 'image/png'
|
||||
});
|
||||
setResult(newFile);
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
if (optionsValues.enableTransparency) {
|
||||
let rgb: [number, number, number];
|
||||
try {
|
||||
//@ts-ignore
|
||||
rgb = Color(optionsValues.color).rgb().array();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
processImage(input, {
|
||||
color: rgb,
|
||||
similarity: Number(optionsValues.similarity)
|
||||
});
|
||||
} else {
|
||||
processImage(input);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ToolInputAndResult
|
||||
input={
|
||||
<ToolFileInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/jpeg']}
|
||||
title={'Input JPG'}
|
||||
/>
|
||||
}
|
||||
result={
|
||||
<ToolFileResult
|
||||
title={'Output PNG'}
|
||||
value={result}
|
||||
extension={'png'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ToolOptions
|
||||
compute={compute}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: 'PNG Transparency Color',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
key="enableTransparency"
|
||||
title="Enable PNG Transparency"
|
||||
checked={!!values.enableTransparency}
|
||||
onChange={(value) => updateField('enableTransparency', value)}
|
||||
description="Make the color below transparent."
|
||||
/>
|
||||
<ColorSelector
|
||||
value={values.color}
|
||||
onColorChange={(val) => updateField('color', val)}
|
||||
description={'With this color (to color)'}
|
||||
inputProps={{ 'data-testid': 'color-input' }}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.similarity}
|
||||
onOwnChange={(val) => updateField('similarity', val)}
|
||||
description={
|
||||
'Match this % of similar. For example, 10% white will match white and a little bit of gray.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
initialValues={initialValues}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
14
src/pages/image/png/convert-jgp-to-png/meta.ts
Normal file
14
src/pages/image/png/convert-jgp-to-png/meta.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
import image from '@assets/image.png';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
name: 'Convert JPG to PNG',
|
||||
path: 'convert-jgp-to-png',
|
||||
image,
|
||||
description:
|
||||
'Quickly convert your JPG images to PNG. Just import your PNG image in the editor on the left',
|
||||
shortDescription: 'Quickly convert your JPG images to PNG',
|
||||
keywords: ['convert', 'jgp', 'png'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
BIN
src/pages/image/png/convert-jgp-to-png/test.jpg
Normal file
BIN
src/pages/image/png/convert-jgp-to-png/test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,40 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { Buffer } from 'buffer';
|
||||
import path from 'path';
|
||||
import Jimp from 'jimp';
|
||||
|
||||
test.describe('Create transparent PNG', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/png/create-transparent');
|
||||
});
|
||||
|
||||
test('should make png color transparent', 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('color-input').fill('#FF0000');
|
||||
|
||||
// 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(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import ColorSelector from '../../../../components/options/ColorSelector';
|
|||
import Color from 'color';
|
||||
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
|
||||
import ToolInputAndResult from '../../../../components/ToolInputAndResult';
|
||||
import { areColorsSimilar } from 'utils/color';
|
||||
|
||||
const initialValues = {
|
||||
fromColor: 'white',
|
||||
|
|
@ -51,28 +52,13 @@ export default function ChangeColorsInPng() {
|
|||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data: Uint8ClampedArray = imageData.data;
|
||||
|
||||
const colorDistance = (
|
||||
c1: [number, number, number],
|
||||
c2: [number, number, number]
|
||||
) => {
|
||||
return Math.sqrt(
|
||||
Math.pow(c1[0] - c2[0], 2) +
|
||||
Math.pow(c1[1] - c2[1], 2) +
|
||||
Math.pow(c1[2] - c2[2], 2)
|
||||
);
|
||||
};
|
||||
const maxColorDistance = Math.sqrt(
|
||||
Math.pow(255, 2) + Math.pow(255, 2) + Math.pow(255, 2)
|
||||
);
|
||||
const similarityThreshold = (similarity / 100) * maxColorDistance;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const currentColor: [number, number, number] = [
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2]
|
||||
];
|
||||
if (colorDistance(currentColor, fromColor) <= similarityThreshold) {
|
||||
if (areColorsSimilar(currentColor, fromColor, similarity)) {
|
||||
data[i + 3] = 0; // Set alpha to 0 (transparent)
|
||||
}
|
||||
}
|
||||
|
|
@ -118,8 +104,9 @@ export default function ChangeColorsInPng() {
|
|||
<Box>
|
||||
<ColorSelector
|
||||
value={values.fromColor}
|
||||
onChange={(val) => updateField('fromColor', val)}
|
||||
onColorChange={(val) => updateField('fromColor', val)}
|
||||
description={'Replace this color (from color)'}
|
||||
inputProps={{ 'data-testid': 'color-input' }}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.similarity}
|
||||
|
|
|
|||
BIN
src/pages/image/png/create-transparent/test.png
Normal file
BIN
src/pages/image/png/create-transparent/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -1,4 +1,9 @@
|
|||
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';
|
||||
|
||||
export const pngTools = [changeColorsInPng, pngCreateTransparent];
|
||||
export const pngTools = [
|
||||
changeColorsInPng,
|
||||
pngCreateTransparent,
|
||||
convertJgpToPng
|
||||
];
|
||||
|
|
|
|||
34
src/utils/color.ts
Normal file
34
src/utils/color.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export function areColorsSimilar(
|
||||
color1: [number, number, number],
|
||||
color2: [number, number, number],
|
||||
similarity: number
|
||||
): boolean {
|
||||
const colorDistance = (
|
||||
c1: [number, number, number],
|
||||
c2: [number, number, number]
|
||||
) => {
|
||||
return Math.sqrt(
|
||||
Math.pow(c1[0] - c2[0], 2) +
|
||||
Math.pow(c1[1] - c2[1], 2) +
|
||||
Math.pow(c1[2] - c2[2], 2)
|
||||
);
|
||||
};
|
||||
const maxColorDistance = Math.sqrt(
|
||||
Math.pow(255, 2) + Math.pow(255, 2) + Math.pow(255, 2)
|
||||
);
|
||||
const similarityThreshold = (similarity / 100) * maxColorDistance;
|
||||
|
||||
return colorDistance(color1, color2) <= similarityThreshold;
|
||||
}
|
||||
|
||||
export function convertHexToRGBA(color: string): number {
|
||||
// Remove the leading '#' if present
|
||||
if (color.startsWith('#')) {
|
||||
color = color.slice(1);
|
||||
}
|
||||
|
||||
// Convert the color to a number and add the alpha channel
|
||||
const colorValue = parseInt(color, 16);
|
||||
const alphaChannel = 0xff;
|
||||
return (colorValue << 8) | alphaChannel;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue