diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a20bdfb..ff0e8b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,15 +8,6 @@ on: pull_request: branches: - main - -env: - REGISTRY: ghcr.io - IMAGE_NAME: iib0011/omni-tools - -permissions: - contents: read - packages: write - jobs: test-and-build: runs-on: ubuntu-latest @@ -74,19 +65,11 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Log in to the Github Container registry - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v4 with: - images: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - iib0011/omni-tools + images: iib0011/omni-tools tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} @@ -130,4 +113,4 @@ jobs: env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - timeout-minutes: 20 + timeout-minutes: 20 \ No newline at end of file diff --git a/README.md b/README.md index 2ac7a54..29623f9 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,6 @@ Stay tuned as we continue to expand and improve our collection! ## Self-host/Run -> [!TIP] -> You can use either the DockerHub Image `iib0011/omni-tools`
-> or use the GitHub Container Registry `ghcr.io/iib0011/omni-tools` - ### Docker ```bash @@ -192,4 +188,4 @@ For any questions or suggestions, feel free to open an issue or contact me at: ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/public/locales/de/image.json b/public/locales/de/image.json index 13f8db4..e388114 100644 --- a/public/locales/de/image.json +++ b/public/locales/de/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Welt", + "description": "Ersetze ausgewahlte Farben in einem Bild schnell und prazise durch neue Farben.", "shortDescription": "Schnelles Austauschen der Farben in einem Bild", "title": "Farben im Bild ändern" }, @@ -34,7 +34,7 @@ "title": "Konvertieren Sie JPG in PNG" }, "createTransparent": { - "description": "Welt", + "description": "Mache den Hintergrund eines Bildes transparent, indem du die ausgewahlte Farbe entfernst.", "shortDescription": "Machen Sie ein Bild schnell transparent", "title": "Transparentes PNG erstellen" }, @@ -61,7 +61,7 @@ "title": "QR-Code-Generator" }, "removeBackground": { - "description": "Welt", + "description": "Entferne den Hintergrund eines Bildes automatisch und behalte das Hauptmotiv transparent bei.", "shortDescription": "Hintergründe automatisch aus Bildern entfernen", "title": "Hintergrund aus Bild entfernen" }, diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 659feb6..5292eb0 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -228,13 +228,10 @@ "loading": "Wird geladen... Dies kann einen Moment dauern.", "result": "Ergebnis" }, - "toolMultipleAudioInput": { - "inputTitle": "Eingang {{type}}", - "noFilesSelected": "Keine Dateien ausgewählt" - }, - "toolMultiplePdfInput": { - "inputTitle": "Eingang {{type}}", - "noFilesSelected": "Keine Dateien ausgewählt" + "toolMultipleInput": { + "inputTitle": "Eingabe {{type}}", + "noFilesSelected": "Keine Dateien ausgewählt", + "deleteFile": "Datei löschen" }, "toolOptions": { "title": "Werkzeugoptionen" diff --git a/public/locales/en/image.json b/public/locales/en/image.json index a2e9414..e99cb11 100644 --- a/public/locales/en/image.json +++ b/public/locales/en/image.json @@ -1,8 +1,23 @@ { "changeColors": { - "description": "World", + "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.", @@ -36,7 +51,7 @@ "title": "Convert JPG to PNG" }, "createTransparent": { - "description": "World", + "description": "Create a transparent version of an image by removing the chosen background color.", "shortDescription": "Quickly make an image transparent", "title": "Create transparent PNG" }, @@ -84,7 +99,7 @@ "title": "QR Code Generator" }, "removeBackground": { - "description": "World", + "description": "Remove the background from an image to isolate the main subject with transparency.", "shortDescription": "Automatically remove backgrounds from images", "title": "Remove Background from 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 b69e675..6066359 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -240,17 +240,10 @@ "loading": "Loading... This may take a moment.", "result": "Result" }, - "toolMultipleAudioInput": { + "toolMultipleInput": { "inputTitle": "Input {{type}}", - "noFilesSelected": "No files selected" - }, - "toolMultipleImageInput": { - "inputTitle": "Images Input", - "noFilesSelected": "No files selected" - }, - "toolMultiplePdfInput": { - "inputTitle": "Input {{type}}", - "noFilesSelected": "No files selected" + "noFilesSelected": "No files selected", + "deleteFile": "Delete file" }, "toolOptions": { "title": "Tool options" diff --git a/public/locales/es/image.json b/public/locales/es/image.json index f01d3bd..5e1f12b 100644 --- a/public/locales/es/image.json +++ b/public/locales/es/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Mundo", + "description": "Reemplaza colores seleccionados en una imagen por nuevos colores de forma rapida y precisa.", "shortDescription": "Intercambia colores rápidamente en una imagen", "title": "Cambiar colores en la imagen" }, @@ -34,7 +34,7 @@ "title": "Convertir JPG a PNG" }, "createTransparent": { - "description": "Mundo", + "description": "Haz transparente el fondo de una imagen eliminando el color seleccionado.", "shortDescription": "Hacer que una imagen sea transparente rápidamente", "title": "Crear PNG transparente" }, @@ -61,7 +61,7 @@ "title": "Generador de códigos QR" }, "removeBackground": { - "description": "Mundo", + "description": "Elimina automaticamente el fondo de una imagen y deja el sujeto principal listo con transparencia.", "shortDescription": "Eliminar automáticamente los fondos de las imágenes", "title": "Quitar el fondo de la imagen" }, diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 0bfebb0..e27ddac 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -228,13 +228,10 @@ "loading": "Cargando... Esto puede tardar un momento.", "result": "Resultado" }, - "toolMultipleAudioInput": { - "inputTitle": "Aporte {{type}}", - "noFilesSelected": "No hay archivos seleccionados" - }, - "toolMultiplePdfInput": { - "inputTitle": "Aporte {{type}}", - "noFilesSelected": "No hay archivos seleccionados" + "toolMultipleInput": { + "inputTitle": "Entrada {{type}}", + "noFilesSelected": "Ningún archivo seleccionado", + "deleteFile": "Eliminar archivo" }, "toolOptions": { "title": "Opciones de herramientas" diff --git a/public/locales/fr/image.json b/public/locales/fr/image.json index c1713af..ba0d2c3 100644 --- a/public/locales/fr/image.json +++ b/public/locales/fr/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Monde", + "description": "Remplace rapidement et precisement les couleurs selectionnees dans une image par de nouvelles couleurs.", "shortDescription": "Échanger rapidement les couleurs d'une image", "title": "Changer une couleur dans une image" }, @@ -34,7 +34,7 @@ "title": "Convertir un JPG en PNG" }, "createTransparent": { - "description": "Monde", + "description": "Rends l'arriere-plan d'une image transparent en supprimant la couleur choisie.", "shortDescription": "Rendre rapidement une image transparente", "title": "Créer un PNG transparent" }, @@ -61,7 +61,7 @@ "title": "Générateur de code QR" }, "removeBackground": { - "description": "Monde", + "description": "Supprime automatiquement l'arriere-plan d'une image et conserve le sujet principal avec transparence.", "shortDescription": "Supprimer automatiquement les arrière-plans des images", "title": "Supprimer l'arrière-plan de l'image" }, diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 2a9f2b9..7575135 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -228,13 +228,10 @@ "loading": "Chargement... Cela peut prendre un moment.", "result": "Résultat" }, - "toolMultipleAudioInput": { - "inputTitle": "Saisir {{type}}", - "noFilesSelected": "Aucun fichier sélectionné" - }, - "toolMultiplePdfInput": { - "inputTitle": "Saisir {{type}}", - "noFilesSelected": "Aucun fichier sélectionné" + "toolMultipleInput": { + "inputTitle": "Entrée {{type}}", + "noFilesSelected": "Aucun fichier sélectionné", + "deleteFile": "Supprimer le fichier" }, "toolOptions": { "title": "Options d'outils" diff --git a/public/locales/hi/image.json b/public/locales/hi/image.json index c6f7616..2a1b5d9 100644 --- a/public/locales/hi/image.json +++ b/public/locales/hi/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "दुनिया", + "description": "चित्र में चुने गए रंगों को जल्दी और सही तरीके से नए रंगों से बदलें।", "shortDescription": "किसी छवि में रंगों को शीघ्रता से बदलें", "title": "छवि में रंग बदलें" }, @@ -59,7 +59,7 @@ "title": "JPG को PNG में बदलें" }, "createTransparent": { - "description": "दुनिया", + "description": "चुने गए रंग को हटाकर चित्र की पृष्ठभूमि को पारदर्शी बनाएं।", "shortDescription": "किसी छवि को शीघ्रता से पारदर्शी बनाएँ", "title": "पारदर्शी PNG बनाएँ" }, @@ -133,7 +133,7 @@ "title": "क्यूआर कोड जनरेटर" }, "removeBackground": { - "description": "दुनिया", + "description": "चित्र की पृष्ठभूमि को अपने आप हटाएं और मुख्य विषय को पारदर्शी रूप में रखें।", "shortDescription": "छवियों से पृष्ठभूमि स्वचालित रूप से हटाएँ", "title": "छवि से पृष्ठभूमि हटाएँ" }, diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index b54243c..d83c086 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -228,13 +228,10 @@ "loading": "लोड हो रहा है... इसमें कुछ समय लग सकता है।", "result": "परिणाम" }, - "toolMultipleAudioInput": { - "inputTitle": "इनपुट {{type}}", - "noFilesSelected": "कोई फ़ाइल चयनित नहीं" - }, - "toolMultiplePdfInput": { - "inputTitle": "इनपुट {{type}}", - "noFilesSelected": "कोई फ़ाइल चयनित नहीं" + "toolMultipleInput": { + "inputTitle": "{{type}} इनपुट करें", + "noFilesSelected": "कोई फ़ाइल नहीं चुनी गई", + "deleteFile": "फ़ाइल हटाएं" }, "toolOptions": { "title": "टूल विकल्प" diff --git a/public/locales/ja/image.json b/public/locales/ja/image.json index 547da21..1a01f8c 100644 --- a/public/locales/ja/image.json +++ b/public/locales/ja/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "世界", + "description": "画像内の選択した色をすばやく正確に新しい色へ置き換えます。", "shortDescription": "画像内の色を素早く入れ替える", "title": "画像の色を変更する" }, @@ -34,7 +34,7 @@ "title": "JPGをPNGに変換する" }, "createTransparent": { - "description": "世界", + "description": "選択した色を削除して、画像の背景を透明にします。", "shortDescription": "画像を素早く透明にする", "title": "透明なPNGを作成する" }, @@ -61,7 +61,7 @@ "title": "QRコードジェネレーター" }, "removeBackground": { - "description": "世界", + "description": "画像の背景を自動で削除し、主要な被写体を透過状態で残します。", "shortDescription": "画像から背景を自動的に削除する", "title": "画像から背景を削除する" }, diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json index 41cc9eb..89ea221 100644 --- a/public/locales/ja/translation.json +++ b/public/locales/ja/translation.json @@ -228,13 +228,10 @@ "loading": "読み込み中... 少々時間がかかる場合があります。", "result": "結果" }, - "toolMultipleAudioInput": { - "inputTitle": "入力 {{type}}", - "noFilesSelected": "ファイルが選択されていません" - }, - "toolMultiplePdfInput": { - "inputTitle": "入力 {{type}}", - "noFilesSelected": "ファイルが選択されていません" + "toolMultipleInput": { + "inputTitle": "{{type}}を入力", + "noFilesSelected": "ファイルが選択されていません", + "deleteFile": "ファイルを削除" }, "toolOptions": { "title": "ツールオプション" diff --git a/public/locales/nl/image.json b/public/locales/nl/image.json index 25a9e3e..eaf5b3a 100644 --- a/public/locales/nl/image.json +++ b/public/locales/nl/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Wereld", + "description": "Vervang geselecteerde kleuren in een afbeelding snel en nauwkeurig door nieuwe kleuren.", "shortDescription": "Snel kleuren in een afbeelding verwisselen", "title": "Kleuren in afbeelding wijzigen" }, @@ -34,7 +34,7 @@ "title": "JPG naar PNG converteren" }, "createTransparent": { - "description": "Wereld", + "description": "Maak de achtergrond van een afbeelding transparant door de gekozen kleur te verwijderen.", "shortDescription": "Snel een afbeelding transparant maken", "title": "Transparante PNG maken" }, @@ -61,7 +61,7 @@ "title": "QR-codegenerator" }, "removeBackground": { - "description": "Wereld", + "description": "Verwijder automatisch de achtergrond van een afbeelding en behoud het hoofdonderwerp met transparantie.", "shortDescription": "Achtergronden automatisch uit afbeeldingen verwijderen", "title": "Achtergrond uit afbeelding verwijderen" }, diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json index a042400..12e11dc 100644 --- a/public/locales/nl/translation.json +++ b/public/locales/nl/translation.json @@ -228,13 +228,10 @@ "loading": "Bezig met laden... Dit kan even duren.", "result": "Resultaat" }, - "toolMultipleAudioInput": { + "toolMultipleInput": { "inputTitle": "Invoer {{type}}", - "noFilesSelected": "Geen bestanden geselecteerd" - }, - "toolMultiplePdfInput": { - "inputTitle": "Invoer {{type}}", - "noFilesSelected": "Geen bestanden geselecteerd" + "noFilesSelected": "Geen bestanden geselecteerd", + "deleteFile": "Bestand verwijderen" }, "toolOptions": { "title": "Gereedschapsopties" diff --git a/public/locales/pt/image.json b/public/locales/pt/image.json index adbc7af..d53f8af 100644 --- a/public/locales/pt/image.json +++ b/public/locales/pt/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Mundo", + "description": "Substitua cores selecionadas em uma imagem por novas cores de forma rapida e precisa.", "shortDescription": "Troque cores rapidamente em uma imagem", "title": "Alterar cores na imagem" }, @@ -34,7 +34,7 @@ "title": "Converter JPG para PNG" }, "createTransparent": { - "description": "Mundo", + "description": "Torne o fundo de uma imagem transparente removendo a cor escolhida.", "shortDescription": "Tornar uma imagem transparente rapidamente", "title": "Criar PNG transparente" }, @@ -61,7 +61,7 @@ "title": "Gerador de código QR" }, "removeBackground": { - "description": "Mundo", + "description": "Remova automaticamente o fundo de uma imagem e mantenha o assunto principal com transparencia.", "shortDescription": "Remover fundos de imagens automaticamente", "title": "Remover fundo da imagem" }, diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 35a1807..74cb324 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -228,13 +228,10 @@ "loading": "Carregando... Isso pode demorar um momento.", "result": "Resultado" }, - "toolMultipleAudioInput": { + "toolMultipleInput": { "inputTitle": "Entrada {{type}}", - "noFilesSelected": "Nenhum arquivo selecionado" - }, - "toolMultiplePdfInput": { - "inputTitle": "Entrada {{type}}", - "noFilesSelected": "Nenhum arquivo selecionado" + "noFilesSelected": "Nenhum ficheiro selecionado", + "deleteFile": "Eliminar ficheiro" }, "toolOptions": { "title": "Opções de ferramentas" diff --git a/public/locales/ru/image.json b/public/locales/ru/image.json index 689968f..8290de1 100644 --- a/public/locales/ru/image.json +++ b/public/locales/ru/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "Мир", + "description": "Быстро и точно заменяйте выбранные цвета на изображении новыми цветами.", "shortDescription": "Быстро поменяйте цвета на изображении", "title": "Изменить цвета на изображении" }, @@ -34,7 +34,7 @@ "title": "Конвертировать JPG в PNG" }, "createTransparent": { - "description": "Мир", + "description": "Сделайте фон изображения прозрачным, удалив выбранный цвет.", "shortDescription": "Быстро сделать изображение прозрачным", "title": "Создать прозрачный PNG" }, @@ -61,7 +61,7 @@ "title": "Генератор QR-кода" }, "removeBackground": { - "description": "Мир", + "description": "Автоматически удалите фон изображения и сохраните основной объект на прозрачном фоне.", "shortDescription": "Автоматически удалять фоны с изображений", "title": "Удалить фон из изображения" }, @@ -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 f0bfca4..7b4cf97 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -228,13 +228,10 @@ "loading": "Загрузка... Это может занять некоторое время.", "result": "Результат" }, - "toolMultipleAudioInput": { - "inputTitle": "Вход {{type}}", - "noFilesSelected": "Файлы не выбраны." - }, - "toolMultiplePdfInput": { - "inputTitle": "Вход {{type}}", - "noFilesSelected": "Файлы не выбраны." + "toolMultipleInput": { + "inputTitle": "Ввод {{type}}", + "noFilesSelected": "Файлы не выбраны", + "deleteFile": "Удалить файл" }, "toolOptions": { "title": "Параметры инструмента" diff --git a/public/locales/zh/image.json b/public/locales/zh/image.json index 7fa45ab..dc1887c 100644 --- a/public/locales/zh/image.json +++ b/public/locales/zh/image.json @@ -1,6 +1,6 @@ { "changeColors": { - "description": "世界", + "description": "快速准确地将图像中的选定颜色替换为新颜色。", "shortDescription": "快速交换图像中的颜色", "title": "改变图像的颜色" }, @@ -34,7 +34,7 @@ "title": "JPG 转换为 PNG" }, "createTransparent": { - "description": "世界", + "description": "通过移除所选颜色,使图像背景变为透明。", "shortDescription": "快速使图像透明", "title": "创建透明 PNG" }, @@ -61,7 +61,7 @@ "title": "二维码生成器" }, "removeBackground": { - "description": "世界", + "description": "自动移除图像背景,并保留主体为透明背景。", "shortDescription": "自动删除图像的背景", "title": "从图像中删除背景" }, diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 25646db..8f89155 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -236,13 +236,10 @@ "loading": "正在加载...这可能需要一点时间。", "result": "结果" }, - "toolMultipleAudioInput": { + "toolMultipleInput": { "inputTitle": "输入 {{type}}", - "noFilesSelected": "未选择任何文件" - }, - "toolMultiplePdfInput": { - "inputTitle": "输入 {{type}}", - "noFilesSelected": "未选择任何文件" + "noFilesSelected": "未选择任何文件", + "deleteFile": "删除文件" }, "toolOptions": { "title": "工具选项" diff --git a/src/components/input/ToolMultipleAudioInput.tsx b/src/components/input/ToolMultipleAudioInput.tsx index f6729cc..e1682e7 100644 --- a/src/components/input/ToolMultipleAudioInput.tsx +++ b/src/components/input/ToolMultipleAudioInput.tsx @@ -1,11 +1,10 @@ -import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; -import { Box, useTheme } from '@mui/material'; +import { useRef } from 'react'; +import { Box, IconButton, Tooltip, useTheme } from '@mui/material'; import Typography from '@mui/material/Typography'; import InputHeader from '../InputHeader'; import InputFooter from './InputFooter'; -import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; -import { isArray } from 'lodash'; import MusicNoteIcon from '@mui/icons-material/MusicNote'; +import ClearIcon from '@mui/icons-material/Clear'; import { useTranslation } from 'react-i18next'; interface MultiAudioInputComponentProps { @@ -31,7 +30,6 @@ export default function ToolMultipleAudioInput({ const { t } = useTranslation(); const theme = useTheme(); const fileInputRef = useRef(null); - const { showSnackBar } = useContext(CustomSnackBarContext); const handleFileChange = (event: React.ChangeEvent) => { const files = event.target.files; @@ -99,7 +97,7 @@ export default function ToolMultipleAudioInput({ - - - - {fileNameTruncate(file.file.name)} - - - { - const updatedFiles = value.filter((_, i) => i !== index); - onChange(updatedFiles); - }} - > - ✖ - + + + + + {fileNameTruncate(file.file.name)} + + + + + { + const updatedFiles = value.filter((_, i) => i !== index); + onChange(updatedFiles); + }} + > + + + )) ) : ( - {t('toolMultipleAudioInput.noFilesSelected')} + {t('toolMultipleInput.noFilesSelected')} )} diff --git a/src/components/input/ToolMultipleImageInput.tsx b/src/components/input/ToolMultipleImageInput.tsx index 13094dc..6804caa 100644 --- a/src/components/input/ToolMultipleImageInput.tsx +++ b/src/components/input/ToolMultipleImageInput.tsx @@ -104,7 +104,7 @@ export default function ToolMultiImageInput({ - {t('toolMultipleImageInput.noFilesSelected')} + {t('toolMultipleInput.noFilesSelected')} )} diff --git a/src/components/input/ToolMultiplePdfInput.tsx b/src/components/input/ToolMultiplePdfInput.tsx index 67f874f..304d28f 100644 --- a/src/components/input/ToolMultiplePdfInput.tsx +++ b/src/components/input/ToolMultiplePdfInput.tsx @@ -1,10 +1,9 @@ -import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; -import { Box, useTheme } from '@mui/material'; +import { useRef } from 'react'; +import { Box, IconButton, Tooltip, useTheme } from '@mui/material'; import Typography from '@mui/material/Typography'; import InputHeader from '../InputHeader'; import InputFooter from './InputFooter'; -import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; -import { isArray } from 'lodash'; +import ClearIcon from '@mui/icons-material/Clear'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import { useTranslation } from 'react-i18next'; @@ -31,7 +30,6 @@ export default function ToolMultiFileInput({ const { t } = useTranslation(); const theme = useTheme(); const fileInputRef = useRef(null); - const { showSnackBar } = useContext(CustomSnackBarContext); const handleFileChange = (event: React.ChangeEvent) => { const files = event.target.files; @@ -65,7 +63,6 @@ export default function ToolMultiFileInput({ }; const reorderList = (sourceIndex: number, destinationIndex: number) => { - console.log(sourceIndex, destinationIndex); if (destinationIndex === sourceIndex) { return; } @@ -100,7 +97,7 @@ export default function ToolMultiFileInput({ - - - - {fileNameTruncate(file.file.name)} - - - { - const updatedFiles = value.filter((_, i) => i !== index); - onChange(updatedFiles); - }} - > - ✖ - + + + + + {fileNameTruncate(file.file.name)} + + + + + { + const updatedFiles = value.filter((_, i) => i !== index); + onChange(updatedFiles); + }} + > + + + )) ) : ( - {t('toolMultiplePdfInput.noFilesSelected')} + {t('toolMultipleInput.noFilesSelected')} )} diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx index 9f42b8a..d861df1 100644 --- a/src/components/input/ToolMultipleVideoInput.tsx +++ b/src/components/input/ToolMultipleVideoInput.tsx @@ -1,11 +1,11 @@ -import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; -import { Box, useTheme } from '@mui/material'; +import { useRef } from 'react'; +import { Box, IconButton, Tooltip } from '@mui/material'; import Typography from '@mui/material/Typography'; import InputHeader from '../InputHeader'; import InputFooter from './InputFooter'; -import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; -import { isArray } from 'lodash'; import VideoFileIcon from '@mui/icons-material/VideoFile'; +import ClearIcon from '@mui/icons-material/Clear'; +import { useTranslation } from 'react-i18next'; interface MultiVideoInputComponentProps { accept: string[]; @@ -27,13 +27,11 @@ export default function ToolMultipleVideoInput({ title, type }: MultiVideoInputComponentProps) { - console.log('ToolMultipleVideoInput rendering with value:', value); - + const { t } = useTranslation(); const fileInputRef = useRef(null); const handleFileChange = (event: React.ChangeEvent) => { const files = event.target.files; - console.log('File change event:', files); if (files) onChange([ ...value, @@ -42,12 +40,10 @@ export default function ToolMultipleVideoInput({ }; const handleImportClick = () => { - console.log('Import clicked'); fileInputRef.current?.click(); }; function handleClear() { - console.log('Clear clicked'); onChange([]); } @@ -98,7 +94,12 @@ export default function ToolMultipleVideoInput({ return ( - - - - {fileNameTruncate(file.file.name)} - - - { - const updatedFiles = value.filter((_, i) => i !== index); - onChange(updatedFiles); - }} - > - ✖ - + + + + + {fileNameTruncate(file.file.name)} + + + + + { + const updatedFiles = value.filter((_, i) => i !== index); + onChange(updatedFiles); + }} + > + + + )) ) : ( - No files selected + {t('toolMultipleInput.noFilesSelected')} )} 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 {