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 {