diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index cec7685..5292eb0 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -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": {
diff --git a/public/locales/en/image.json b/public/locales/en/image.json
index 8d25514..e99cb11 100644
--- a/public/locales/en/image.json
+++ b/public/locales/en/image.json
@@ -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)"
+ }
+ }
}
}
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 99b650c..6066359 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -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": {
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 6cbd44f..e27ddac 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -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": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 36da353..7575135 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -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": {
diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json
index 1c68ebb..d83c086 100644
--- a/public/locales/hi/translation.json
+++ b/public/locales/hi/translation.json
@@ -94,7 +94,10 @@
"splitText": "टेक्स्ट विभाजित करें",
"trimVideo": "वीडियो ट्रिम करें"
},
- "searchPlaceholder": "सभी टूल्स खोजें",
+ "search": {
+ "placeholder": "सभी टूल खोजें",
+ "noResult": "कोई परिणाम नहीं मिला, अपनी खोज या फ़िल्टर मानदंड समायोजित करने का प्रयास करें"
+ },
"title": "के साथ जल्दी काम करें"
},
"inputFooter": {
diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json
index b6ce855..89ea221 100644
--- a/public/locales/ja/translation.json
+++ b/public/locales/ja/translation.json
@@ -94,7 +94,10 @@
"splitText": "テキストを分割する",
"trimVideo": "ビデオをトリミングする"
},
- "searchPlaceholder": "すべてのツールを検索",
+ "search": {
+ "placeholder": "すべてのツールを検索",
+ "noResult": "結果が見つかりません。検索条件またはフィルター条件を調整してみてください"
+ },
"title": "物事を素早く終わらせる"
},
"inputFooter": {
diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json
index d117aae..12e11dc 100644
--- a/public/locales/nl/translation.json
+++ b/public/locales/nl/translation.json
@@ -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": {
diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json
index fecd11e..74cb324 100644
--- a/public/locales/pt/translation.json
+++ b/public/locales/pt/translation.json
@@ -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": {
diff --git a/public/locales/ru/image.json b/public/locales/ru/image.json
index 8fb7096..8290de1 100644
--- a/public/locales/ru/image.json
+++ b/public/locales/ru/image.json
@@ -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)"
+ }
+ }
}
}
diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json
index 90fdc26..7b4cf97 100644
--- a/public/locales/ru/translation.json
+++ b/public/locales/ru/translation.json
@@ -94,7 +94,10 @@
"splitText": "Разделить текст",
"trimVideo": "Обрезать видео"
},
- "searchPlaceholder": "Найти инструмент…",
+ "search": {
+ "placeholder": "Поиск по всем инструментам",
+ "noResult": "Результаты не найдены, попробуйте изменить параметры поиска или фильтрации"
+ },
"title": "Выполняйте задачи быстро с помощью"
},
"inputFooter": {
diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json
index 82f83c9..8f89155 100644
--- a/public/locales/zh/translation.json
+++ b/public/locales/zh/translation.json
@@ -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}}",
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index b239787..64d1226 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -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() {
,
diff --git a/src/pages/tools-by-category/index.tsx b/src/pages/tools-by-category/index.tsx
index 436311c..52b4c35 100644
--- a/src/pages/tools-by-category/index.tsx
+++ b/src/pages/tools-by-category/index.tsx
@@ -88,7 +88,8 @@ export default function ToolsByCategory() {
,
sx: {
diff --git a/src/pages/tools/image/generic/change-colors/index.tsx b/src/pages/tools/image/generic/change-colors/index.tsx
index 1eb5745..765fe89 100644
--- a/src/pages/tools/image/generic/change-colors/index.tsx
+++ b/src/pages/tools/image/generic/change-colors/index.tsx
@@ -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(null);
const [result, setResult] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(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 = ({
+ const getGroups: GetGroupsType = ({
values,
updateField
}) => [
{
- title: 'From color and to color',
+ title: t('changeColors.fromColor.title'),
component: (
- updateField('fromColor', val)}
- description={'Replace this color (from color)'}
- inputProps={{ 'data-testid': 'from-color-input' }}
+ updateField('fromColor', '#00000000')}
/>
- updateField('toColor', val)}
- description={'With this color (to color)'}
- inputProps={{ 'data-testid': 'to-color-input' }}
+ updateField('fromColor', '#ffffffff')}
/>
+ {values.fromColor !== '#00000000' && (
+ updateField('fromColor', val)}
+ description={t('changeColors.fromColor.selectorDescription')}
+ inputProps={{ 'data-testid': 'from-color-input' }}
+ />
+ )}
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')}
/>
)
+ },
+ {
+ title: t('changeColors.toColor.title'),
+ component: (
+
+ updateField('toColor', '#00000000')}
+ />
+ updateField('toColor', '#000000ff')}
+ />
+ {values.toColor !== '#00000000' && (
+ updateField('toColor', val)}
+ description={t('changeColors.toColor.selectorDescription')}
+ inputProps={{ 'data-testid': 'to-color-input' }}
+ />
+ )}
+
+ )
}
];
+
return (
+ }
+ resultComponent={
+
}
- resultComponent={}
/>
);
}
diff --git a/src/pages/tools/image/generic/change-colors/service.ts b/src/pages/tools/image/generic/change-colors/service.ts
index a35ed1d..5b69390 100644
--- a/src/pages/tools/image/generic/change-colors/service.ts
+++ b/src/pages/tools/image/generic/change-colors/service.ts
@@ -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 => {
+ fromColor: [number, number, number, number],
+ toColor: [number, number, number, number],
+ similarity: number
+): Promise => {
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 = {
+ 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;
};
diff --git a/src/pages/tools/image/generic/change-colors/types.ts b/src/pages/tools/image/generic/change-colors/types.ts
new file mode 100644
index 0000000..9060a05
--- /dev/null
+++ b/src/pages/tools/image/generic/change-colors/types.ts
@@ -0,0 +1,5 @@
+export type InitialValuesType = {
+ fromColor: string;
+ toColor: string;
+ similarity: number;
+};
diff --git a/src/pages/tools/image/generic/create-transparent/index.tsx b/src/pages/tools/image/generic/create-transparent/index.tsx
index ce91a07..2756db9 100644
--- a/src/pages/tools/image/generic/create-transparent/index.tsx
+++ b/src/pages/tools/image/generic/create-transparent/index.tsx
@@ -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 */}
+ updateField('backgroundColor', val)}
+ description={'Background color for replaced pixels'}
+ />
)
}
diff --git a/src/pages/tools/image/generic/index.ts b/src/pages/tools/image/generic/index.ts
index 6fa51c6..bb39bd8 100644
--- a/src/pages/tools/image/generic/index.ts
+++ b/src/pages/tools/image/generic/index.ts
@@ -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
];
diff --git a/src/pages/tools/image/generic/split/index.tsx b/src/pages/tools/image/generic/split/index.tsx
new file mode 100644
index 0000000..5bb7bd9
--- /dev/null
+++ b/src/pages/tools/image/generic/split/index.tsx
@@ -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(null);
+ const [result, setResult] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(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 | null = ({
+ values,
+ updateField
+ }) => [
+ {
+ title: t('image:split.pageParameters.title'),
+ component: (
+
+ {
+ updateField('pageFormat', val as keyof typeof PageSizes);
+ }}
+ description={t('image:split.pageParameters.selectPageFormat')}
+ options={Object.keys(PageSizes).map((key) => ({
+ value: key,
+ label: key
+ }))}
+ />
+ {
+ updateNumberField(value, 'padding', updateField);
+ }}
+ value={values.padding}
+ />
+
+ )
+ },
+ {
+ title: t('image:split.scaleParameters.title'),
+ component: (
+
+ {
+ updateNumberField(value, 'pxPerSquareQuantity', updateField);
+ }}
+ value={values.pxPerSquareQuantity}
+ />
+ {
+ updateNumberField(value, 'squareQuantity', updateField);
+ }}
+ value={values.squareQuantity}
+ />
+ {
+ updateNumberField(value, 'unitsPerOneSquare', updateField);
+ }}
+ value={values.unitsPerOneSquare}
+ />
+
+ )
+ },
+ {
+ title: t('image:split.units.title'),
+ component: (
+
+ {
+ 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') }
+ ]}
+ />
+
+ )
+ }
+ ];
+ return (
+
+ }
+ resultComponent={
+
+ }
+ initialValues={initialValues}
+ getGroups={getGroups}
+ setInput={setInput}
+ compute={debouncedCompute}
+ toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
+ />
+ );
+}
diff --git a/src/pages/tools/image/generic/split/meta.ts b/src/pages/tools/image/generic/split/meta.ts
new file mode 100644
index 0000000..45dd6f0
--- /dev/null
+++ b/src/pages/tools/image/generic/split/meta.ts
@@ -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'))
+});
diff --git a/src/pages/tools/image/generic/split/service.ts b/src/pages/tools/image/generic/split/service.ts
new file mode 100644
index 0000000..2408987
--- /dev/null
+++ b/src/pages/tools/image/generic/split/service.ts
@@ -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 {
+ const { width, height } = initImg;
+ const horParts = Math.ceil(width / widthOfEachPart);
+ const verParts = Math.ceil(height / heightOfEachPart);
+
+ const promises: Promise[] = [];
+
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/pages/tools/image/generic/split/types.ts b/src/pages/tools/image/generic/split/types.ts
new file mode 100644
index 0000000..0970a25
--- /dev/null
+++ b/src/pages/tools/image/generic/split/types.ts
@@ -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';
diff --git a/src/utils/color.ts b/src/utils/color.ts
index 692a78e..37e73dd 100644
--- a/src/utils/color.ts
+++ b/src/utils/color.ts
@@ -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 {