From 3ba5af3b4aec5c197b5edb88e5b05103a6776497 Mon Sep 17 00:00:00 2001 From: DmitriyS Date: Thu, 18 Sep 2025 18:27:21 +0300 Subject: [PATCH 01/18] refactor: extract zip logic and deps into ./utils --- src/pages/tools/pdf/pdf-to-png/service.ts | 12 ++++-------- src/utils/zip.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 src/utils/zip.ts diff --git a/src/pages/tools/pdf/pdf-to-png/service.ts b/src/pages/tools/pdf/pdf-to-png/service.ts index 5f6839d..a32e3f1 100644 --- a/src/pages/tools/pdf/pdf-to-png/service.ts +++ b/src/pages/tools/pdf/pdf-to-png/service.ts @@ -1,6 +1,6 @@ import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min?url'; -import JSZip from 'jszip'; +import { zipFiles } from '@utils/zip'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; @@ -16,7 +16,6 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{ }> { const arrayBuffer = await pdfFile.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; - const zip = new JSZip(); const images: ImagePreview[] = []; for (let i = 1; i <= pdf.numPages; i++) { @@ -37,14 +36,11 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{ const filename = `page-${i}.png`; const url = URL.createObjectURL(blob); images.push({ blob, url, filename }); - zip.file(filename, blob); } - const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' }); - const zipFile = new File( - [zipBuffer], - pdfFile.name.replace(/\.pdf$/i, '-pages.zip'), - { type: 'application/zip' } + const zipFile = await zipFiles( + images.map(({ blob, filename }) => ({ blob, filename })), + pdfFile.name.replace(/\.pdf$/i, '-pages.zip') ); return { images, zipFile }; diff --git a/src/utils/zip.ts b/src/utils/zip.ts new file mode 100644 index 0000000..fe248dc --- /dev/null +++ b/src/utils/zip.ts @@ -0,0 +1,11 @@ +import JSZip from 'jszip'; + +export async function zipFiles( + blobs: { blob: Blob; filename: string }[], + zipFilename: string +): Promise { + const zip = new JSZip(); + blobs.forEach(({ blob, filename }) => zip.file(filename, blob)); + const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' }); + return new File([zipBuffer], zipFilename, { type: 'application/zip' }); +} From f13130a9af146aa26cd4ea91568a7afd3a87b059 Mon Sep 17 00:00:00 2001 From: DmitriyS Date: Fri, 24 Oct 2025 15:02:24 +0300 Subject: [PATCH 02/18] feat: add split tool divides images into PDF pages ready for print --- public/locales/en/image.json | 49 +++++ public/locales/ru/image.json | 49 +++++ src/pages/tools/image/generic/index.ts | 4 +- src/pages/tools/image/generic/split/index.tsx | 200 ++++++++++++++++++ src/pages/tools/image/generic/split/meta.ts | 14 ++ .../tools/image/generic/split/service.ts | 85 ++++++++ src/pages/tools/image/generic/split/types.ts | 16 ++ 7 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/image/generic/split/index.tsx create mode 100644 src/pages/tools/image/generic/split/meta.ts create mode 100644 src/pages/tools/image/generic/split/service.ts create mode 100644 src/pages/tools/image/generic/split/types.ts diff --git a/public/locales/en/image.json b/public/locales/en/image.json index 9a03c73..00bdfc1 100644 --- a/public/locales/en/image.json +++ b/public/locales/en/image.json @@ -101,5 +101,54 @@ "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", + "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)" + } + }, + "input": { + "imagePlaceholder": "Drop or select an image with a tactical map" + }, + "pdfOptions": { + "downloadLabel": "Download PDF" + }, + "toolInfo": { + "title": "About Split", + "description": "This tool slices a large image into pages of the selected paper format and packs them into a PDF. Useful for printing maps across multiple sheets." + } } } diff --git a/public/locales/ru/image.json b/public/locales/ru/image.json index c49ec00..64a0383 100644 --- a/public/locales/ru/image.json +++ b/public/locales/ru/image.json @@ -101,5 +101,54 @@ "description": "Повернуть изображение на указанный угол.", "shortDescription": "Легко поворачивайте изображение.", "title": "Повернуть изображение" + }, + "split": { + "title": "Разбить изображение по страницам", + "shortDescription": "Разбивает большое изображение на несколько страниц (PDF).", + "description": "Разбивает карту или большое изображение на страницы выбранного формата с учётом полей. Укажите количество пикселей на заданное число клеток и их количество для корректного масштабирования.", + "inputTitle": "Входное изображение", + "resultTitle": "PDF с разрезанными страницами", + "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)" + } + }, + "input": { + "imagePlaceholder": "Перетащите или выберите изображение с тактической картой" + }, + "pdfOptions": { + "downloadLabel": "Скачать PDF" + }, + "toolInfo": { + "title": "О разрезе", + "description": "Этот инструмент нарезает большое изображение на страницы выбранного формата и упаковывает их в PDF. Удобно для печати карт на нескольких листах." + } } } diff --git a/src/pages/tools/image/generic/index.ts b/src/pages/tools/image/generic/index.ts index d87de31..6a35529 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'; @@ -22,5 +23,6 @@ export const imageGenericTools = [ imageToText, qrCodeGenerator, rotateImage, - convertToJpg + convertToJpg, + 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..cb7b4bf --- /dev/null +++ b/src/pages/tools/image/generic/split/index.tsx @@ -0,0 +1,200 @@ +import { Box } from '@mui/material'; +import { useEffect, useState } 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 { fromPts, loadImage, splitImage, toPts } from './service'; +import { debounce } from 'lodash'; +import { PDFDocument, 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 compute = async (values: InitialValuesType, input: File | null) => { + if (input) { + 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 widthOfEachPart = Math.round(pageWidthWithPadding * pxPerPt); + console.log('widthOfEachPart', widthOfEachPart); + const heightOfEachPart = Math.round(pageHeightWithPadding * pxPerPt); + const imgParts = await splitImage( + initImg, + widthOfEachPart, + heightOfEachPart + ); + + const pdfDoc = await PDFDocument.create(); + + for (const imgFile of imgParts) { + const imgArrayBuffer = await imgFile.arrayBuffer(); + const img = await pdfDoc.embedPng(imgArrayBuffer); + const page = pdfDoc.addPage([pageWidth, pageHeight]); + page.drawImage(img, { + x: paddingInPts, + y: paddingInPts, + width: pageWidthWithPadding, + height: pageHeightWithPadding + }); + } + + const pdfBytes = await pdfDoc.save(); + const pdfFile = new File( + [pdfBytes as BlobPart], + input.name.replace(/\.([^.]+)?$/i, `-${Date.now()}.pdf`), + { type: 'application/pdf' } + ); + setResult(pdfFile); + } + }; + + const debouncedCompute = debounce(compute, 800); + + 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.entries(PageSizes).map(([key, value]) => ({ + 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..e5676a7 --- /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', + icon: 'mdi:scissors', + keywords: ['split'], + 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..3596500 --- /dev/null +++ b/src/pages/tools/image/generic/split/service.ts @@ -0,0 +1,85 @@ +import { units } from './types'; + +export 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 canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas not supported'); + + const promises: Promise[] = []; + + for (let y = 0; y < verParts; y++) { + for (let x = 0; x < horParts; x++) { + canvas.width = widthOfEachPart; + canvas.height = heightOfEachPart; + + ctx.clearRect(0, 0, widthOfEachPart, heightOfEachPart); + ctx.drawImage( + initImg, + x * widthOfEachPart, + y * heightOfEachPart, + widthOfEachPart, + heightOfEachPart, + 0, + 0, + widthOfEachPart, + heightOfEachPart + ); + + const promise = canvasToFile(canvas, `part-${x}-${y}.png`); + promises.push(promise); + } + } + + return Promise.all(promises); +} + +export function loadImage(input: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = URL.createObjectURL(input); + img.onload = () => resolve(img); + img.onerror = (err) => 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'; From 99e2e8e812ff4e53a51c35eb522e96ce0d86bdab Mon Sep 17 00:00:00 2001 From: yts20031209 <703987939@qq.com> Date: Fri, 17 Apr 2026 18:33:40 +0800 Subject: [PATCH 03/18] feat: improve category search feedback --- public/locales/en/translation.json | 4 + public/locales/zh/translation.json | 4 + src/pages/tools-by-category/index.tsx | 122 ++++++++++++++++---------- 3 files changed, 82 insertions(+), 48 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 71f10e6..3ef9a70 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -231,6 +231,10 @@ "toolLayout": { "allToolsTitle": "All {{type}}" }, + "toolsByCategory": { + "noResultsTitle": "No tools found", + "noResultsDescription": "Try adjusting your search or filter criteria" + }, "toolMultiFileResult": { "copied": "File copied", "copyFailed": "Failed to copy: {{error}}", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 085d983..efed7f7 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -223,6 +223,10 @@ "toolLayout": { "allToolsTitle": "全部 {{type}}" }, + "toolsByCategory": { + "noResultsTitle": "未找到工具", + "noResultsDescription": "请尝试调整搜索条件或筛选条件" + }, "toolMultiFileResult": { "copied": "文件已复制", "copyFailed": "复制失败: {{error}}", diff --git a/src/pages/tools-by-category/index.tsx b/src/pages/tools-by-category/index.tsx index 436311c..1b3eb63 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: { @@ -113,53 +114,78 @@ export default function ToolsByCategory() { onUserTypesChange={setSelectedUserTypes} /> - - {categoryTools.map((tool, index) => ( - - navigate('/' + tool.path)} - direction={'row'} - alignItems={'center'} - spacing={2} - padding={2} - border={`1px solid ${theme.palette.background.default}`} - borderRadius={2} - > - - - - {/*@ts-ignore*/} - {t(tool.name)} - - - {/*@ts-ignore*/} - {t(tool.shortDescription)} - - - - - ))} - + {categoryTools.length > 0 ? ( + + {categoryTools.map((tool, index) => ( + + navigate('/' + tool.path)} + direction={'row'} + alignItems={'center'} + spacing={2} + padding={2} + border={`1px solid ${theme.palette.background.default}`} + borderRadius={2} + > + + + + {/*@ts-ignore*/} + {t(tool.name)} + + + {/*@ts-ignore*/} + {t(tool.shortDescription)} + + + + + ))} + + ) : ( + + + + {t('translation:toolsByCategory.noResultsTitle')} + + + {t('translation:toolsByCategory.noResultsDescription')} + + + )} ); From 31abe4f30757f603fe7a019d3cff5f7bda0b1d3b Mon Sep 17 00:00:00 2001 From: Ankita Arya Date: Sat, 25 Apr 2026 23:27:08 +0530 Subject: [PATCH 04/18] Added transparent to color change feature --- .../generic/create-transparent/index.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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'} + /> ) } From 3558dee411b1b2c38718bab3f16d05c0af384d01 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 00:00:02 +0200 Subject: [PATCH 05/18] chore:: loading text added to locales --- public/locales/en/image.json | 1 + public/locales/ru/image.json | 1 + 2 files changed, 2 insertions(+) diff --git a/public/locales/en/image.json b/public/locales/en/image.json index 00bdfc1..8119c32 100644 --- a/public/locales/en/image.json +++ b/public/locales/en/image.json @@ -108,6 +108,7 @@ "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", diff --git a/public/locales/ru/image.json b/public/locales/ru/image.json index 64a0383..3c130e2 100644 --- a/public/locales/ru/image.json +++ b/public/locales/ru/image.json @@ -108,6 +108,7 @@ "description": "Разбивает карту или большое изображение на страницы выбранного формата с учётом полей. Укажите количество пикселей на заданное число клеток и их количество для корректного масштабирования.", "inputTitle": "Входное изображение", "resultTitle": "PDF с разрезанными страницами", + "loadingText": "Загрузка... Это может занять некоторое время.", "pageParameters": { "title": "Параметры страницы", "selectPageFormat": "Выберите формат страницы", From a04982eb751b3c48c7dbd46c5fbd409b7cf9a69f Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 00:01:58 +0200 Subject: [PATCH 06/18] chore: - computing function moved to service.ts - loading state added to resultcomponent --- src/pages/tools/image/generic/split/index.tsx | 61 ++++++------------- 1 file changed, 18 insertions(+), 43 deletions(-) diff --git a/src/pages/tools/image/generic/split/index.tsx b/src/pages/tools/image/generic/split/index.tsx index cb7b4bf..97b776a 100644 --- a/src/pages/tools/image/generic/split/index.tsx +++ b/src/pages/tools/image/generic/split/index.tsx @@ -1,5 +1,5 @@ import { Box } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useState, useCallback } from 'react'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; import { GetGroupsType } from '@components/options/ToolOptions'; @@ -8,9 +8,9 @@ import ToolImageInput from '@components/input/ToolImageInput'; import { t } from 'i18next'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; import { updateNumberField } from '@utils/string'; -import { fromPts, loadImage, splitImage, toPts } from './service'; +import { splitImagesToPDF, fromPts, toPts } from './service'; import { debounce } from 'lodash'; -import { PDFDocument, PageSizes } from 'pdf-lib'; +import { PageSizes } from 'pdf-lib'; import ToolFileResult from '@components/result/ToolFileResult'; import SelectWithDesc from '@components/options/SelectWithDesc'; @@ -28,52 +28,22 @@ const initialValues: InitialValuesType = { 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) { - const initImg = await loadImage(input); + if (!input) return; - 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 widthOfEachPart = Math.round(pageWidthWithPadding * pxPerPt); - console.log('widthOfEachPart', widthOfEachPart); - const heightOfEachPart = Math.round(pageHeightWithPadding * pxPerPt); - const imgParts = await splitImage( - initImg, - widthOfEachPart, - heightOfEachPart - ); + setIsProcessing(true); - const pdfDoc = await PDFDocument.create(); - - for (const imgFile of imgParts) { - const imgArrayBuffer = await imgFile.arrayBuffer(); - const img = await pdfDoc.embedPng(imgArrayBuffer); - const page = pdfDoc.addPage([pageWidth, pageHeight]); - page.drawImage(img, { - x: paddingInPts, - y: paddingInPts, - width: pageWidthWithPadding, - height: pageHeightWithPadding - }); - } - - const pdfBytes = await pdfDoc.save(); - const pdfFile = new File( - [pdfBytes as BlobPart], - input.name.replace(/\.([^.]+)?$/i, `-${Date.now()}.pdf`), - { type: 'application/pdf' } - ); + try { + const pdfFile = await splitImagesToPDF(input, values); setResult(pdfFile); + } finally { + setIsProcessing(false); } }; - const debouncedCompute = debounce(compute, 800); + const debouncedCompute = useCallback(debounce(compute, 1000), []); const getGroups: GetGroupsType | null = ({ values, @@ -89,7 +59,7 @@ export default function Split({ title, longDescription }: ToolComponentProps) { updateField('pageFormat', val as keyof typeof PageSizes); }} description={t('image:split.pageParameters.selectPageFormat')} - options={Object.entries(PageSizes).map(([key, value]) => ({ + options={Object.keys(PageSizes).map((key) => ({ value: key, label: key }))} @@ -188,7 +158,12 @@ export default function Split({ title, longDescription }: ToolComponentProps) { /> } resultComponent={ - + } initialValues={initialValues} getGroups={getGroups} From 254b1ac316d8b35d7b5c0557c5630738df790769 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 00:03:44 +0200 Subject: [PATCH 07/18] chore: - new splitImagesToPdf function - functions optimized --- .../tools/image/generic/split/service.ts | 87 +++++++++++++++---- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/src/pages/tools/image/generic/split/service.ts b/src/pages/tools/image/generic/split/service.ts index 3596500..2408987 100644 --- a/src/pages/tools/image/generic/split/service.ts +++ b/src/pages/tools/image/generic/split/service.ts @@ -1,6 +1,7 @@ -import { units } from './types'; +import { InitialValuesType, units } from './types'; +import { PDFDocument, PageSizes } from 'pdf-lib'; -export async function splitImage( +async function splitImage( initImg: HTMLImageElement, widthOfEachPart: number, heightOfEachPart: number @@ -9,44 +10,94 @@ export async function splitImage( const horParts = Math.ceil(width / widthOfEachPart); const verParts = Math.ceil(height / heightOfEachPart); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Canvas not supported'); - const promises: Promise[] = []; for (let y = 0; y < verParts; y++) { for (let x = 0; x < horParts; x++) { - canvas.width = widthOfEachPart; - canvas.height = heightOfEachPart; + 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.clearRect(0, 0, widthOfEachPart, heightOfEachPart); ctx.drawImage( initImg, x * widthOfEachPart, y * heightOfEachPart, - widthOfEachPart, - heightOfEachPart, + tileW, + tileH, 0, 0, - widthOfEachPart, - heightOfEachPart + tileW, + tileH ); - const promise = canvasToFile(canvas, `part-${x}-${y}.png`); - promises.push(promise); + 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(); - img.src = URL.createObjectURL(input); - img.onload = () => resolve(img); - img.onerror = (err) => reject(err); + const url = URL.createObjectURL(input); + img.src = url; + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (err) => { + URL.revokeObjectURL(url); + reject(err); + }; }); } From 6d7da0eaacbcb5e629926eeccf6c5bd0986302cc Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 00:26:33 +0200 Subject: [PATCH 08/18] chore: zip util file deleted --- src/pages/tools/pdf/pdf-to-png/service.ts | 12 ++++++++---- src/utils/zip.ts | 11 ----------- 2 files changed, 8 insertions(+), 15 deletions(-) delete mode 100644 src/utils/zip.ts diff --git a/src/pages/tools/pdf/pdf-to-png/service.ts b/src/pages/tools/pdf/pdf-to-png/service.ts index a32e3f1..5f6839d 100644 --- a/src/pages/tools/pdf/pdf-to-png/service.ts +++ b/src/pages/tools/pdf/pdf-to-png/service.ts @@ -1,6 +1,6 @@ import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min?url'; -import { zipFiles } from '@utils/zip'; +import JSZip from 'jszip'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; @@ -16,6 +16,7 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{ }> { const arrayBuffer = await pdfFile.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + const zip = new JSZip(); const images: ImagePreview[] = []; for (let i = 1; i <= pdf.numPages; i++) { @@ -36,11 +37,14 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{ const filename = `page-${i}.png`; const url = URL.createObjectURL(blob); images.push({ blob, url, filename }); + zip.file(filename, blob); } - const zipFile = await zipFiles( - images.map(({ blob, filename }) => ({ blob, filename })), - pdfFile.name.replace(/\.pdf$/i, '-pages.zip') + const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' }); + const zipFile = new File( + [zipBuffer], + pdfFile.name.replace(/\.pdf$/i, '-pages.zip'), + { type: 'application/zip' } ); return { images, zipFile }; diff --git a/src/utils/zip.ts b/src/utils/zip.ts deleted file mode 100644 index fe248dc..0000000 --- a/src/utils/zip.ts +++ /dev/null @@ -1,11 +0,0 @@ -import JSZip from 'jszip'; - -export async function zipFiles( - blobs: { blob: Blob; filename: string }[], - zipFilename: string -): Promise { - const zip = new JSZip(); - blobs.forEach(({ blob, filename }) => zip.file(filename, blob)); - const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' }); - return new File([zipBuffer], zipFilename, { type: 'application/zip' }); -} From 1e8fd7d4f432e0a330f709fd3fabcb92a6121b9a Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 00:51:08 +0200 Subject: [PATCH 09/18] chore: - unused keys removal - pdf extension specified --- public/locales/en/image.json | 10 ---------- public/locales/ru/image.json | 10 ---------- src/pages/tools/image/generic/split/index.tsx | 1 + 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/public/locales/en/image.json b/public/locales/en/image.json index fdc35de..a1cffd5 100644 --- a/public/locales/en/image.json +++ b/public/locales/en/image.json @@ -158,16 +158,6 @@ "mm": "Millimeters (mm)", "in": "Inches (in)" } - }, - "input": { - "imagePlaceholder": "Drop or select an image with a tactical map" - }, - "pdfOptions": { - "downloadLabel": "Download PDF" - }, - "toolInfo": { - "title": "About Split", - "description": "This tool slices a large image into pages of the selected paper format and packs them into a PDF. Useful for printing maps across multiple sheets." } } } diff --git a/public/locales/ru/image.json b/public/locales/ru/image.json index f2fd05a..8290de1 100644 --- a/public/locales/ru/image.json +++ b/public/locales/ru/image.json @@ -135,16 +135,6 @@ "mm": "Миллиметры (мм)", "in": "Дюймы (in)" } - }, - "input": { - "imagePlaceholder": "Перетащите или выберите изображение с тактической картой" - }, - "pdfOptions": { - "downloadLabel": "Скачать PDF" - }, - "toolInfo": { - "title": "О разрезе", - "description": "Этот инструмент нарезает большое изображение на страницы выбранного формата и упаковывает их в PDF. Удобно для печати карт на нескольких листах." } } } diff --git a/src/pages/tools/image/generic/split/index.tsx b/src/pages/tools/image/generic/split/index.tsx index 97b776a..5bb7bd9 100644 --- a/src/pages/tools/image/generic/split/index.tsx +++ b/src/pages/tools/image/generic/split/index.tsx @@ -160,6 +160,7 @@ export default function Split({ title, longDescription }: ToolComponentProps) { resultComponent={ Date: Sun, 26 Apr 2026 01:01:41 +0200 Subject: [PATCH 10/18] chore: updated meta --- src/pages/tools/image/generic/split/meta.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/tools/image/generic/split/meta.ts b/src/pages/tools/image/generic/split/meta.ts index e5676a7..45dd6f0 100644 --- a/src/pages/tools/image/generic/split/meta.ts +++ b/src/pages/tools/image/generic/split/meta.ts @@ -7,8 +7,8 @@ export const tool = defineTool('image-generic', { description: 'image:split.description', shortDescription: 'image:split.shortDescription' }, - path: 'split', + path: 'split-to-pages', icon: 'mdi:scissors', - keywords: ['split'], + keywords: ['split', 'images', 'pages', 'large', 'map'], component: lazy(() => import('./index')) }); From 87318639d451168be3bc835f6077c45bcd532716 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 18:03:09 +0200 Subject: [PATCH 11/18] chore: color util function updated to take 4 params color --- src/utils/color.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) 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 { From 8da79dd2c40e2bf0778b76a71f5825d3f38ba02c Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 18:04:30 +0200 Subject: [PATCH 12/18] chore: initial values as type --- src/pages/tools/image/generic/change-colors/types.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/pages/tools/image/generic/change-colors/types.ts 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..02324e6 --- /dev/null +++ b/src/pages/tools/image/generic/change-colors/types.ts @@ -0,0 +1,5 @@ +export type InitialValuesType = { + fromColor: string; + toColor: string; + similarity: string; +}; From 90c185575c4cd83558ec8704d5323dc88afda3ab Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 18:17:54 +0200 Subject: [PATCH 13/18] chore: full i8n keys --- public/locales/en/image.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/public/locales/en/image.json b/public/locales/en/image.json index 8d25514..fa5b75a 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.", From 3146f74b603294b5bd8f752fcb179050367d8a67 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 18:18:44 +0200 Subject: [PATCH 14/18] chore: service updated to match requirements --- .../image/generic/change-colors/service.ts | 259 +++++++++--------- 1 file changed, 136 insertions(+), 123 deletions(-) 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; }; From 11ecf3144d72d031ca62206c8f477944a6329b2d Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 26 Apr 2026 18:39:37 +0200 Subject: [PATCH 15/18] chore: updated index to match requirements --- .../image/generic/change-colors/index.tsx | 122 +++++++++++++----- .../image/generic/change-colors/types.ts | 2 +- 2 files changed, 93 insertions(+), 31 deletions(-) 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/types.ts b/src/pages/tools/image/generic/change-colors/types.ts index 02324e6..9060a05 100644 --- a/src/pages/tools/image/generic/change-colors/types.ts +++ b/src/pages/tools/image/generic/change-colors/types.ts @@ -1,5 +1,5 @@ export type InitialValuesType = { fromColor: string; toColor: string; - similarity: string; + similarity: number; }; From abf1280b40523770f7a4fa1911cb5a2d820933c0 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 27 Apr 2026 16:10:41 +0200 Subject: [PATCH 16/18] chore: - remove edge case (handled elsewhere) - update i18n keys --- src/pages/tools-by-category/index.tsx | 123 ++++++++++---------------- 1 file changed, 49 insertions(+), 74 deletions(-) diff --git a/src/pages/tools-by-category/index.tsx b/src/pages/tools-by-category/index.tsx index 1b3eb63..52b4c35 100644 --- a/src/pages/tools-by-category/index.tsx +++ b/src/pages/tools-by-category/index.tsx @@ -88,8 +88,8 @@ export default function ToolsByCategory() { , sx: { @@ -114,78 +114,53 @@ export default function ToolsByCategory() { onUserTypesChange={setSelectedUserTypes} /> - {categoryTools.length > 0 ? ( - - {categoryTools.map((tool, index) => ( - - navigate('/' + tool.path)} - direction={'row'} - alignItems={'center'} - spacing={2} - padding={2} - border={`1px solid ${theme.palette.background.default}`} - borderRadius={2} - > - - - - {/*@ts-ignore*/} - {t(tool.name)} - - - {/*@ts-ignore*/} - {t(tool.shortDescription)} - - - - - ))} - - ) : ( - - - - {t('translation:toolsByCategory.noResultsTitle')} - - - {t('translation:toolsByCategory.noResultsDescription')} - - - )} + + {categoryTools.map((tool, index) => ( + + navigate('/' + tool.path)} + direction={'row'} + alignItems={'center'} + spacing={2} + padding={2} + border={`1px solid ${theme.palette.background.default}`} + borderRadius={2} + > + + + + {/*@ts-ignore*/} + {t(tool.name)} + + + {/*@ts-ignore*/} + {t(tool.shortDescription)} + + + + + ))} + ); From dd8303cbc75864c5d7ea3423ed4e39c00f551bbf Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 27 Apr 2026 16:12:11 +0200 Subject: [PATCH 17/18] chore: - update i8n keys - No options props added to handle the case --- src/components/Hero.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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() { , From 7da5b064b97248dfb06d1a1d94b4b4999a8a8069 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 27 Apr 2026 16:23:53 +0200 Subject: [PATCH 18/18] chore: locales updated to match requirements --- public/locales/de/translation.json | 5 ++++- public/locales/en/translation.json | 9 ++++----- public/locales/es/translation.json | 5 ++++- public/locales/fr/translation.json | 5 ++++- public/locales/hi/translation.json | 5 ++++- public/locales/ja/translation.json | 5 ++++- public/locales/nl/translation.json | 5 ++++- public/locales/pt/translation.json | 5 ++++- public/locales/ru/translation.json | 5 ++++- public/locales/zh/translation.json | 5 ++++- 10 files changed, 40 insertions(+), 14 deletions(-) diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index e907627..659feb6 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/translation.json b/public/locales/en/translation.json index 3ef9a70..b69e675 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": { @@ -231,10 +234,6 @@ "toolLayout": { "allToolsTitle": "All {{type}}" }, - "toolsByCategory": { - "noResultsTitle": "No tools found", - "noResultsDescription": "Try adjusting your search or filter criteria" - }, "toolMultiFileResult": { "copied": "File copied", "copyFailed": "Failed to copy: {{error}}", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index c89fa74..0bfebb0 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 c07a3b7..2a9f2b9 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 f30c7ee..b54243c 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 4acaebc..41cc9eb 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 b95d610..a042400 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 0dbf4be..35a1807 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/translation.json b/public/locales/ru/translation.json index 7a79aa8..f0bfca4 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 efed7f7..25646db 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": {