feat: svg change colors

This commit is contained in:
Ibrahima G. Coulibaly
2025-04-02 19:53:33 +00:00
parent 93275b459a
commit 92858f2e24
9 changed files with 213 additions and 136 deletions

37
.idea/workspace.xml generated
View File

@@ -4,8 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: compress image">
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/generic/compress/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/compress/index.tsx" afterDir="false" />
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: remove labels">
<change afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/change-colors/service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/generic/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/change-colors-in-png.e2e.spec.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/change-colors/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/meta.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/change-colors/meta.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/test.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/index.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -218,11 +225,11 @@
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\tools\image\generic" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\tools" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" />
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components" />
</key>
</component>
<component name="RunManager" selected="npm.dev">
@@ -400,15 +407,7 @@
<workItem from="1743569964813" duration="352000" />
<workItem from="1743570403937" duration="109000" />
<workItem from="1743607343305" duration="20000" />
<workItem from="1743619618671" duration="1888000" />
</task>
<task id="LOCAL-00134" summary="chore: prettify json in home">
<option name="closed" value="true" />
<created>1740662154978</created>
<option name="number" value="00134" />
<option name="presentableId" value="LOCAL-00134" />
<option name="project" value="LOCAL" />
<updated>1740662154978</updated>
<workItem from="1743619618671" duration="3827000" />
</task>
<task id="LOCAL-00135" summary="feat: jakarta font">
<option name="closed" value="true" />
@@ -794,7 +793,15 @@
<option name="project" value="LOCAL" />
<updated>1743621506634</updated>
</task>
<option name="localTasksCounter" value="183" />
<task id="LOCAL-00183" summary="chore: remove labels">
<option name="closed" value="true" />
<created>1743621602590</created>
<option name="number" value="00183" />
<option name="presentableId" value="LOCAL-00183" />
<option name="project" value="LOCAL" />
<updated>1743621602590</updated>
</task>
<option name="localTasksCounter" value="184" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -841,7 +848,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="refactor: use ToolContent" />
<MESSAGE value="fix: prettify json" />
<MESSAGE value="refactor: sum" />
<MESSAGE value="fix: tools by category scroll" />
@@ -866,7 +872,8 @@
<MESSAGE value="chore: add color" />
<MESSAGE value="docs: readme" />
<MESSAGE value="feat: compress image" />
<option name="LAST_COMMIT_MESSAGE" value="feat: compress image" />
<MESSAGE value="chore: remove labels" />
<option name="LAST_COMMIT_MESSAGE" value="chore: remove labels" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
export const imageGenericTools = [resizeImage, compressImage];
export const imageGenericTools = [resizeImage, compressImage, changeColors];

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -2,14 +2,12 @@ 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,