Merge branch 'main' into pr/Sushanth012/325

This commit is contained in:
Chesterkxng
2026-04-28 03:27:20 +02:00
24 changed files with 756 additions and 187 deletions
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Einen Text teilen",
"trimVideo": "Video zuschneiden"
},
"searchPlaceholder": "Alle Tools durchsuchen",
"search": {
"placeholder": "Alle Tools durchsuchen",
"noResult": "Keine Ergebnisse gefunden, versuchen Sie Ihre Suche oder Filterkriterien anzupassen"
},
"title": "Erledigen Sie Dinge schnell mit"
},
"inputFooter": {
+56 -1
View File
@@ -2,7 +2,22 @@
"changeColors": {
"description": "Change specific colors in an image by selecting a source color and replacing it with a new one.",
"shortDescription": "Quickly swap colors in a image",
"title": "Change colors in image"
"title": "Change colors in image",
"inputTitle": "Input image",
"resultTitle": "Result image",
"fromColor": {
"title": "From color",
"colorOption": "Replace a specific color",
"transparentOption": "Replace transparent pixels",
"selectorDescription": "Replace this color (from color)",
"similarityDescription": "Match this % of similar colors of the from color. For example, 10% white will match white and a little bit of gray."
},
"toColor": {
"title": "To color",
"colorOption": "Replace with a specific color",
"transparentOption": "Replace with transparent",
"selectorDescription": "With this color (to color)"
}
},
"changeOpacity": {
"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.",
@@ -119,5 +134,45 @@
"description": "Rotate an image by a specified angle.",
"shortDescription": "Rotate an image easily.",
"title": "Rotate Image"
},
"split": {
"title": "Split Image into Pages",
"shortDescription": "Split a large image into multiple pages (PDF).",
"description": "Split a map or large image into pages matching the selected paper format and padding. Specify pixels per printing square and square count to scale the image correctly.",
"inputTitle": "Input image",
"resultTitle": "Split PDF",
"loadingText": "Loading... This may take a moment.",
"pageParameters": {
"title": "Page parameters",
"selectPageFormat": "Select page format",
"padding": {
"label": "Padding",
"description": "Padding around the map on each page (in selected units)"
}
},
"scaleParameters": {
"title": "Scale parameters",
"pxPerSquareQuantity": {
"label": "Pixels per squares quantity",
"description": "How many pixels correspond to the specified number of squares on the original image (horizontal or vertical)"
},
"squareQuantity": {
"label": "Squares quantity",
"description": "Number of play squares across (horizontal or vertical) used for scaling"
},
"unitsPerOneSquare": {
"label": "Units per square",
"description": "Physical size of one square in the selected units"
}
},
"units": {
"title": "Units",
"select": "Select measurement units",
"options": {
"pt": "Points (pt)",
"mm": "Millimeters (mm)",
"in": "Inches (in)"
}
}
}
}
+4 -1
View File
@@ -106,7 +106,10 @@
"splitText": "Split a text",
"trimVideo": "Trim video"
},
"searchPlaceholder": "Search all tools",
"search": {
"placeholder": "Search all tools",
"noResult": "No results found, Try adjusting your search or filter criteria"
},
"title": "Get Things Done Quickly with"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Dividir un texto",
"trimVideo": "Recortar vídeo"
},
"searchPlaceholder": "Buscar en todas las herramientas",
"search": {
"placeholder": "Buscar todas las herramientas",
"noResult": "No se encontraron resultados, intenta ajustar tu búsqueda o los criterios de filtro"
},
"title": "Haga las cosas rápidamente con"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Diviser un texte",
"trimVideo": "Découper la vidéo"
},
"searchPlaceholder": "Rechercher tous les outils",
"search": {
"placeholder": "Rechercher tous les outils",
"noResult": "Aucun résultat trouvé, essayez d'ajuster votre recherche ou vos critères de filtre"
},
"title": "Faites avancer les choses rapidement avec"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "टेक्स्ट विभाजित करें",
"trimVideo": "वीडियो ट्रिम करें"
},
"searchPlaceholder": "सभी टूल्स खोजें",
"search": {
"placeholder": "सभी टूल खोजें",
"noResult": "कोई परिणाम नहीं मिला, अपनी खोज या फ़िल्टर मानदंड समायोजित करने का प्रयास करें"
},
"title": "के साथ जल्दी काम करें"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "テキストを分割する",
"trimVideo": "ビデオをトリミングする"
},
"searchPlaceholder": "すべてのツールを検索",
"search": {
"placeholder": "すべてのツールを検索",
"noResult": "結果が見つかりません。検索条件またはフィルター条件を調整してみてください"
},
"title": "物事を素早く終わらせる"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Een tekst splitsen",
"trimVideo": "Video bijsnijden"
},
"searchPlaceholder": "Zoek in alle tools",
"search": {
"placeholder": "Alle tools doorzoeken",
"noResult": "Geen resultaten gevonden, probeer uw zoekopdracht of filtercriteria aan te passen"
},
"title": "Krijg dingen snel gedaan met"
},
"inputFooter": {
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Dividir um texto",
"trimVideo": "Cortar vídeo"
},
"searchPlaceholder": "Pesquisar todas as ferramentas",
"search": {
"placeholder": "Pesquisar todas as ferramentas",
"noResult": "Nenhum resultado encontrado, tente ajustar a sua pesquisa ou os critérios de filtro"
},
"title": "Faça as coisas rapidamente com"
},
"inputFooter": {
+40
View File
@@ -96,5 +96,45 @@
"description": "Повернуть изображение на указанный угол.",
"shortDescription": "Легко поворачивайте изображение.",
"title": "Повернуть изображение"
},
"split": {
"title": "Разбить изображение по страницам",
"shortDescription": "Разбивает большое изображение на несколько страниц (PDF).",
"description": "Разбивает карту или большое изображение на страницы выбранного формата с учётом полей. Укажите количество пикселей на заданное число клеток и их количество для корректного масштабирования.",
"inputTitle": "Входное изображение",
"resultTitle": "PDF с разрезанными страницами",
"loadingText": "Загрузка... Это может занять некоторое время.",
"pageParameters": {
"title": "Параметры страницы",
"selectPageFormat": "Выберите формат страницы",
"padding": {
"label": "Отступ",
"description": "Отступ вокруг карты на каждой странице (в выбранных единицах)"
}
},
"scaleParameters": {
"title": "Параметры масштаба",
"pxPerSquareQuantity": {
"label": "Пикселей на количество клеток",
"description": "Сколько пикселей соответствует указанному количеству клеток на исходном изображении (по горизонтали или вертикали)"
},
"squareQuantity": {
"label": "Количество клеток",
"description": "Число игровых клеток по горизонтали или вертикали, используемое для масштабирования"
},
"unitsPerOneSquare": {
"label": "Размер клетки",
"description": "Физический размер одной клетки в выбранных единицах"
}
},
"units": {
"title": "Единицы измерения",
"select": "Выберите единицы измерения",
"options": {
"pt": "Пункты (pt)",
"mm": "Миллиметры (мм)",
"in": "Дюймы (in)"
}
}
}
}
+4 -1
View File
@@ -94,7 +94,10 @@
"splitText": "Разделить текст",
"trimVideo": "Обрезать видео"
},
"searchPlaceholder": "Найти инструмент…",
"search": {
"placeholder": "Поиск по всем инструментам",
"noResult": "Результаты не найдены, попробуйте изменить параметры поиска или фильтрации"
},
"title": "Выполняйте задачи быстро с помощью"
},
"inputFooter": {
+8 -1
View File
@@ -98,7 +98,10 @@
"splitText": "拆分文本",
"trimVideo": "修剪视频"
},
"searchPlaceholder": "搜索所有工具",
"search": {
"placeholder": "搜索所有工具",
"noResult": "未找到结果 请尝试调整搜索或筛选条件"
},
"title": "快速完成工作"
},
"inputFooter": {
@@ -223,6 +226,10 @@
"toolLayout": {
"allToolsTitle": "全部 {{type}}"
},
"toolsByCategory": {
"noResultsTitle": "未找到工具",
"noResultsDescription": "请尝试调整搜索条件或筛选条件"
},
"toolMultiFileResult": {
"copied": "文件已复制",
"copyFailed": "复制失败: {{error}}",
+2 -1
View File
@@ -150,6 +150,7 @@ export default function Hero() {
sx={{ mb: 2 }}
autoHighlight
options={filteredTools}
noOptionsText={t('translation:hero.search.noResult')}
// Disable default MUI filtering since we already apply custom filterTools
filterOptions={(options) => options}
groupBy={(option) => option.type}
@@ -167,7 +168,7 @@ export default function Hero() {
<TextField
{...params}
fullWidth
placeholder={t('translation:hero.searchPlaceholder')}
placeholder={t('translation:hero.search.placeholder')}
InputProps={{
...params.InputProps,
endAdornment: <SearchIcon />,
+2 -1
View File
@@ -88,7 +88,8 @@ export default function ToolsByCategory() {
</Typography>
</Stack>
<TextField
placeholder={'Search'}
placeholder={t('translation:hero.search.placeholder')}
aria-label={t('translation:hero.search.placeholder')}
InputProps={{
endAdornment: <SearchIcon />,
sx: {
@@ -4,73 +4,129 @@ import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import ColorSelector from '@components/options/ColorSelector';
import { updateNumberField } from '@utils/string';
import Color from 'color';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
import { processImage } from './service';
import { InitialValuesType } from './types';
import { useTranslation } from 'react-i18next';
const initialValues = {
fromColor: 'white',
toColor: 'black',
similarity: '10'
const initialValues: InitialValuesType = {
fromColor: '#ffffffff',
toColor: '#000000ff',
similarity: 10
};
const validationSchema = Yup.object({
// splitSeparator: Yup.string().required('The separator is required')
similarity: Yup.number()
.min(0, 'Similarity must be between 0 and 100')
.max(100, 'Similarity must be between 0 and 100')
.required()
});
export default function ChangeColorsInImage({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = (optionsValues: typeof initialValues, input: any) => {
const compute = async (
optionsValues: InitialValuesType,
input: File | null
) => {
if (!input) return;
const { fromColor, toColor, similarity } = optionsValues;
let fromRgb: [number, number, number];
let toRgb: [number, number, number];
let fromRgb: [number, number, number, number];
let toRgb: [number, number, number, number];
try {
//@ts-ignore
fromRgb = Color(fromColor).rgb().array();
//@ts-ignore
toRgb = Color(toColor).rgb().array();
fromRgb = [
...Color(fromColor).rgb().array(),
Color(fromColor).alpha() * 255
] as [number, number, number, number];
toRgb = [
...Color(toColor).rgb().array(),
Color(toColor).alpha() * 255
] as [number, number, number, number];
} catch (err) {
return;
}
processImage(input, fromRgb, toRgb, Number(similarity), setResult);
setIsProcessing(true);
try {
const image = await processImage(input, fromRgb, toRgb, similarity);
setResult(image);
} finally {
setIsProcessing(false);
}
};
const getGroups: GetGroupsType<typeof initialValues> = ({
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'From color and to color',
title: t('changeColors.fromColor.title'),
component: (
<Box>
<ColorSelector
value={values.fromColor}
onColorChange={(val) => updateField('fromColor', val)}
description={'Replace this color (from color)'}
inputProps={{ 'data-testid': 'from-color-input' }}
<SimpleRadio
title={t('changeColors.fromColor.transparentOption')}
checked={values.fromColor === '#00000000'}
onClick={() => updateField('fromColor', '#00000000')}
/>
<ColorSelector
value={values.toColor}
onColorChange={(val) => updateField('toColor', val)}
description={'With this color (to color)'}
inputProps={{ 'data-testid': 'to-color-input' }}
<SimpleRadio
title={t('changeColors.fromColor.colorOption')}
checked={values.fromColor !== '#00000000'}
onClick={() => updateField('fromColor', '#ffffffff')}
/>
{values.fromColor !== '#00000000' && (
<ColorSelector
value={values.fromColor}
onColorChange={(val) => updateField('fromColor', val)}
description={t('changeColors.fromColor.selectorDescription')}
inputProps={{ 'data-testid': 'from-color-input' }}
/>
)}
<TextFieldWithDesc
value={values.similarity}
onOwnChange={(val) => updateField('similarity', val)}
description={
'Match this % of similar colors of the from color. For example, 10% white will match white and a little bit of gray.'
onOwnChange={(val) =>
updateNumberField(val, 'similarity', updateField)
}
description={t('changeColors.fromColor.similarityDescription')}
/>
</Box>
)
},
{
title: t('changeColors.toColor.title'),
component: (
<Box>
<SimpleRadio
title={t('changeColors.toColor.transparentOption')}
checked={values.toColor === '#00000000'}
onClick={() => updateField('toColor', '#00000000')}
/>
<SimpleRadio
title={t('changeColors.toColor.colorOption')}
checked={values.toColor !== '#00000000'}
onClick={() => updateField('toColor', '#000000ff')}
/>
{values.toColor !== '#00000000' && (
<ColorSelector
value={values.toColor}
onColorChange={(val) => updateField('toColor', val)}
description={t('changeColors.toColor.selectorDescription')}
inputProps={{ 'data-testid': 'to-color-input' }}
/>
)}
</Box>
)
}
];
return (
<ToolContent
title={title}
@@ -84,10 +140,16 @@ export default function ChangeColorsInImage({ title }: ToolComponentProps) {
value={input}
onChange={setInput}
accept={['image/*']}
title={'Input image'}
title={t('changeColors.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('changeColors.resultTitle')}
value={result}
loading={isProcessing}
/>
}
resultComponent={<ToolFileResult title={'Result image'} value={result} />}
/>
);
}
@@ -2,168 +2,181 @@ 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> => {
fromColor: [number, number, number, number],
toColor: [number, number, number, number],
similarity: number
): Promise<File> => {
if (file.type === 'image/svg+xml') {
const reader = new FileReader();
reader.onload = (e) => {
if (!e.target?.result) return;
return new Promise((resolve) => {
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]);
let svgContent = e.target.result as string;
const toColorHex = rgbToHex(
toColor[0],
toColor[1],
toColor[2],
toColor[3]
);
// 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');
const hexRegexShort = new RegExp(`#[0-9a-f]{3}\\b`, 'gi');
const hexRegexLong = new RegExp(`#[0-9a-f]{6}\\b`, 'gi');
const hexRegexAlpha = new RegExp(`#[0-9a-f]{8}\\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(hexRegexShort, (match) => {
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;
});
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;
});
svgContent = svgContent.replace(hexRegexAlpha, (match) => {
const matchRgba = hexToRgba(match);
if (matchRgba && areColorsSimilar(matchRgba, fromColor, similarity))
return toColorHex;
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;
});
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, number] = [
parseInt(r),
parseInt(g),
parseInt(b),
255
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgb(${toColor[0]}, ${toColor[1]}, ${toColor[2]})`;
}
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
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 matchRgba: [number, number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b),
Math.round(parseFloat(a) * 255)
];
if (areColorsSimilar(matchRgba, fromColor, similarity)) {
return `rgba(${toColor[0]}, ${toColor[1]}, ${toColor[2]}, ${
toColor[3] / 255
})`;
}
return match;
});
const namedColors: Record<string, [number, number, number, number]> = {
red: [255, 0, 0, 255],
green: [0, 128, 0, 255],
blue: [0, 0, 255, 255],
black: [0, 0, 0, 255],
white: [255, 255, 255, 255]
};
Object.entries(namedColors).forEach(([name, rgba]) => {
if (areColorsSimilar(rgba, fromColor, similarity)) {
const colorRegex = new RegExp(`\\b${name}\\b`, 'gi');
svgContent = svgContent.replace(colorRegex, toColorHex);
}
});
resolve(new File([svgContent], file.name, { type: 'image/svg+xml' }));
};
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;
reader.readAsText(file);
});
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
if (!ctx) throw new Error('Canvas not supported');
img.src = URL.createObjectURL(file);
const img = new Image();
const url = URL.createObjectURL(file);
img.src = url;
await img.decode();
URL.revokeObjectURL(url);
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;
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
const currentColor: [number, number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
data[i + 2],
data[i + 3]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
data[i] = toColor[0];
data[i + 1] = toColor[1];
data[i + 2] = toColor[2];
data[i + 3] = toColor[3];
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: file.type
});
setResult(newFile);
}
}, file.type);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(new File([blob], file.name, { type: file.type }));
else reject(new Error('Failed to create blob'));
}, 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('')
);
};
const rgbToHex = (r: number, g: number, b: number, a = 255): string =>
'#' + [r, g, b, a].map((x) => x.toString(16).padStart(2, '0')).join('');
// Helper function to parse hex to RGB
const hexToRgb = (hex: string): [number, number, number] | null => {
const hexToRgb = (hex: string): [number, 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)
parseInt(result[3], 16),
255
]
: null;
};
const hexToRgba = (hex: string): [number, number, number, number] | null => {
const result = /^#?([a-f\d]{2})([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),
parseInt(result[4], 16)
]
: null;
};
@@ -0,0 +1,5 @@
export type InitialValuesType = {
fromColor: string;
toColor: string;
similarity: number;
};
@@ -13,7 +13,8 @@ import { GetGroupsType } from '@components/options/ToolOptions';
const initialValues = {
fromColor: 'white',
similarity: '10'
similarity: '10',
backgroundColor: '#ffffff' // NEW
};
const validationSchema = Yup.object({
@@ -26,12 +27,16 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
const compute = (optionsValues: typeof initialValues, input: any) => {
if (!input) return;
const { fromColor, similarity } = optionsValues;
const { fromColor, similarity, backgroundColor } = optionsValues;
let fromRgb: [number, number, number];
let bgRgb: [number, number, number];
try {
//@ts-ignore
fromRgb = Color(fromColor).rgb().array();
//@ts-ignore
bgRgb = Color(backgroundColor).rgb().array();
} catch (err) {
return;
}
@@ -62,7 +67,12 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i + 3] = 0; // Set alpha to 0 (transparent)
// data[i + 3] = 0; // Set alpha to 0 (transparent)
// NEW LOGIC: replace with background color
data[i] = bgRgb[0];
data[i + 1] = bgRgb[1];
data[i + 2] = bgRgb[2];
data[i + 3] = 255;
}
}
@@ -100,6 +110,13 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
'Match this % of similar colors of the from color. For example, 10% white will match white and a little bit of gray.'
}
/>
{/* NEW FEATURE */}
<ColorSelector
value={values.backgroundColor}
onColorChange={(val) => updateField('backgroundColor', val)}
description={'Background color for replaced pixels'}
/>
</Box>
)
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { tool as splitImage } from './split/meta';
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
@@ -21,5 +22,6 @@ export const imageGenericTools = [
createTransparent,
imageToText,
qrCodeGenerator,
rotateImage
rotateImage,
splitImage
];
@@ -0,0 +1,176 @@
import { Box } from '@mui/material';
import { useState, useCallback } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolImageInput from '@components/input/ToolImageInput';
import { t } from 'i18next';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import { splitImagesToPDF, fromPts, toPts } from './service';
import { debounce } from 'lodash';
import { PageSizes } from 'pdf-lib';
import ToolFileResult from '@components/result/ToolFileResult';
import SelectWithDesc from '@components/options/SelectWithDesc';
const initialValues: InitialValuesType = {
pageFormat: 'A4',
pageWidth: 210,
pageHeight: 297,
pxPerSquareQuantity: 1620,
squareQuantity: 20,
unitsPerOneSquare: fromPts(72, 'mm'),
unitKind: 'mm',
padding: 5
};
export default function Split({ title, longDescription }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
setIsProcessing(true);
try {
const pdfFile = await splitImagesToPDF(input, values);
setResult(pdfFile);
} finally {
setIsProcessing(false);
}
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: t('image:split.pageParameters.title'),
component: (
<Box>
<SelectWithDesc
selected={values.pageFormat}
onChange={(val) => {
updateField('pageFormat', val as keyof typeof PageSizes);
}}
description={t('image:split.pageParameters.selectPageFormat')}
options={Object.keys(PageSizes).map((key) => ({
value: key,
label: key
}))}
/>
<TextFieldWithDesc
name="padding"
type="number"
inputProps={{ min: 0, step: 1 }}
description={t('image:split.pageParameters.padding.description')}
onOwnChange={(value) => {
updateNumberField(value, 'padding', updateField);
}}
value={values.padding}
/>
</Box>
)
},
{
title: t('image:split.scaleParameters.title'),
component: (
<Box>
<TextFieldWithDesc
name="pxPerSquareQuantity"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.pxPerSquareQuantity.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'pxPerSquareQuantity', updateField);
}}
value={values.pxPerSquareQuantity}
/>
<TextFieldWithDesc
name="squareQuantity"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.squareQuantity.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'squareQuantity', updateField);
}}
value={values.squareQuantity}
/>
<TextFieldWithDesc
name="mmPerOneSquare"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.unitsPerOneSquare.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'unitsPerOneSquare', updateField);
}}
value={values.unitsPerOneSquare}
/>
</Box>
)
},
{
title: t('image:split.units.title'),
component: (
<Box>
<SelectWithDesc
selected={values.unitKind}
onChange={(newUnitKind) => {
updateField('unitKind', newUnitKind);
updateField('unitsPerOneSquare', fromPts(72, newUnitKind));
updateField(
'padding',
Math.round(fromPts(toPts(5, 'mm'), newUnitKind) * 100) / 100
);
}}
description={t('image:split.units.select')}
options={[
{ value: 'pt', label: t('image:split.units.options.pt') },
{ value: 'mm', label: t('image:split.units.options.mm') },
{ value: 'in', label: t('image:split.units.options.in') }
]}
/>
</Box>
)
}
];
return (
<ToolContent
title={t('image:split.title')}
input={input}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/*']}
title={t('image:split.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('image:split.resultTitle')}
extension={'pdf'}
value={result}
loading={isProcessing}
loadingText={t('image:split.loadingText')}
/>
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={debouncedCompute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}
@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
i18n: {
name: 'image:split.title',
description: 'image:split.description',
shortDescription: 'image:split.shortDescription'
},
path: 'split-to-pages',
icon: 'mdi:scissors',
keywords: ['split', 'images', 'pages', 'large', 'map'],
component: lazy(() => import('./index'))
});
@@ -0,0 +1,136 @@
import { InitialValuesType, units } from './types';
import { PDFDocument, PageSizes } from 'pdf-lib';
async function splitImage(
initImg: HTMLImageElement,
widthOfEachPart: number,
heightOfEachPart: number
): Promise<File[]> {
const { width, height } = initImg;
const horParts = Math.ceil(width / widthOfEachPart);
const verParts = Math.ceil(height / heightOfEachPart);
const promises: Promise<File>[] = [];
for (let y = 0; y < verParts; y++) {
for (let x = 0; x < horParts; x++) {
const tileW = Math.min(widthOfEachPart, width - x * widthOfEachPart);
const tileH = Math.min(heightOfEachPart, height - y * heightOfEachPart);
const canvas = document.createElement('canvas');
canvas.width = tileW;
canvas.height = tileH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.drawImage(
initImg,
x * widthOfEachPart,
y * heightOfEachPart,
tileW,
tileH,
0,
0,
tileW,
tileH
);
promises.push(canvasToFile(canvas, `part-${x}-${y}.png`));
}
}
return Promise.all(promises);
}
export async function splitImagesToPDF(
input: File,
values: InitialValuesType
): Promise<File> {
const initImg = await loadImage(input);
const [pageWidth, pageHeight] = PageSizes[values.pageFormat];
const ptPerOneSquare = toPts(values.unitsPerOneSquare, values.unitKind);
const pxPerPt =
values.pxPerSquareQuantity / (ptPerOneSquare * values.squareQuantity);
const paddingInPts = toPts(values.padding, values.unitKind);
const pageWidthWithPadding = pageWidth - 2 * paddingInPts;
const pageHeightWithPadding = pageHeight - 2 * paddingInPts;
const imgParts = await splitImage(
initImg,
Math.round(pageWidthWithPadding * pxPerPt),
Math.round(pageHeightWithPadding * pxPerPt)
);
const pdfDoc = await PDFDocument.create();
for (const imgFile of imgParts) {
const img = await pdfDoc.embedPng(await imgFile.arrayBuffer());
const page = pdfDoc.addPage([pageWidth, pageHeight]);
page.drawImage(img, {
x: paddingInPts,
y: paddingInPts,
width: pageWidthWithPadding,
height: pageHeightWithPadding
});
}
const pdfBytes = await pdfDoc.save();
return new File(
[pdfBytes as BlobPart],
input.name.replace(/\.([^.]+)?$/i, `-${Date.now()}.pdf`),
{ type: 'application/pdf' }
);
}
export function loadImage(input: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(input);
img.src = url;
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = (err) => {
URL.revokeObjectURL(url);
reject(err);
};
});
}
function canvasToFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], name, { type: 'image/png' }));
} else {
reject(new Error('Failed to create blob'));
}
}, 'image/png');
});
}
export function toPts(number: number, units: units): number {
switch (units) {
case 'pt':
return number;
case 'mm':
return (number * 72) / 25.4;
case 'in':
return number * 72;
}
}
export function fromPts(number: number, units: units): number {
switch (units) {
case 'pt':
return number;
case 'mm':
return (number * 25.4) / 72;
case 'in':
return number / 72;
}
}
@@ -0,0 +1,16 @@
import { PageSizes } from 'pdf-lib';
export type InitialValuesType = {
pageFormat: keyof typeof PageSizes;
widthOfEachPart?: number;
heightOfEachPart?: number;
pageWidth: number;
pageHeight: number;
pxPerSquareQuantity: number;
squareQuantity: number;
unitsPerOneSquare: number;
unitKind: units;
padding: number;
};
export type units = 'pt' | 'mm' | 'in';
+14 -17
View File
@@ -1,24 +1,21 @@
export function areColorsSimilar(
color1: [number, number, number],
color2: [number, number, number],
color1: [number, number, number, number?],
color2: [number, 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;
const a1 = color1[3] ?? 255;
const a2 = color2[3] ?? 255;
return colorDistance(color1, color2) <= similarityThreshold;
const distance = Math.sqrt(
Math.pow(color1[0] - color2[0], 2) +
Math.pow(color1[1] - color2[1], 2) +
Math.pow(color1[2] - color2[2], 2) +
Math.pow(a1 - a2, 2)
);
const maxDistance = Math.sqrt(Math.pow(255, 2) * 4);
return distance <= (similarity / 100) * maxDistance;
}
export function convertHexToRGBA(color: string): number {