Merge branch 'main' into pr/Steve65535/362

This commit is contained in:
Chesterkxng
2026-04-27 16:26:11 +02:00
36 changed files with 900 additions and 372 deletions
+2 -19
View File
@@ -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
+1 -5
View File
@@ -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` <br />
> 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.
+3 -3
View File
@@ -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"
},
+4 -7
View File
@@ -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"
+59 -4
View File
@@ -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)"
}
}
}
}
+3 -10
View File
@@ -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"
+3 -3
View File
@@ -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"
},
+4 -7
View File
@@ -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"
+3 -3
View File
@@ -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"
},
+4 -7
View File
@@ -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"
+3 -3
View File
@@ -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": "छवि से पृष्ठभूमि हटाएँ"
},
+4 -7
View File
@@ -228,13 +228,10 @@
"loading": "लोड हो रहा है... इसमें कुछ समय लग सकता है।",
"result": "परिणाम"
},
"toolMultipleAudioInput": {
"inputTitle": "इनपुट {{type}}",
"noFilesSelected": "कोई फ़ाइल चयनित नहीं"
},
"toolMultiplePdfInput": {
"inputTitle": "इनपुट {{type}}",
"noFilesSelected": "कोई फ़ाइल चयनित नहीं"
"toolMultipleInput": {
"inputTitle": "{{type}} इनपुट करें",
"noFilesSelected": "कोई फ़ाइल नहीं चुनी गई",
"deleteFile": "फ़ाइल हटाएं"
},
"toolOptions": {
"title": "टूल विकल्प"
+3 -3
View File
@@ -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": "画像から背景を削除する"
},
+4 -7
View File
@@ -228,13 +228,10 @@
"loading": "読み込み中... 少々時間がかかる場合があります。",
"result": "結果"
},
"toolMultipleAudioInput": {
"inputTitle": "入力 {{type}}",
"noFilesSelected": "ファイルが選択されていません"
},
"toolMultiplePdfInput": {
"inputTitle": "入力 {{type}}",
"noFilesSelected": "ファイルが選択されていません"
"toolMultipleInput": {
"inputTitle": "{{type}}を入力",
"noFilesSelected": "ファイルが選択されていません",
"deleteFile": "ファイルを削除"
},
"toolOptions": {
"title": "ツールオプション"
+3 -3
View File
@@ -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"
},
+3 -6
View File
@@ -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"
+3 -3
View File
@@ -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"
},
+3 -6
View File
@@ -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"
+43 -3
View File
@@ -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)"
}
}
}
}
+4 -7
View File
@@ -228,13 +228,10 @@
"loading": "Загрузка... Это может занять некоторое время.",
"result": "Результат"
},
"toolMultipleAudioInput": {
"inputTitle": "Вход {{type}}",
"noFilesSelected": "Файлы не выбраны."
},
"toolMultiplePdfInput": {
"inputTitle": "Вход {{type}}",
"noFilesSelected": "Файлы не выбраны."
"toolMultipleInput": {
"inputTitle": "Ввод {{type}}",
"noFilesSelected": "Файлы не выбраны",
"deleteFile": "Удалить файл"
},
"toolOptions": {
"title": "Параметры инструмента"
+3 -3
View File
@@ -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": "从图像中删除背景"
},
+3 -6
View File
@@ -236,13 +236,10 @@
"loading": "正在加载...这可能需要一点时间。",
"result": "结果"
},
"toolMultipleAudioInput": {
"toolMultipleInput": {
"inputTitle": "输入 {{type}}",
"noFilesSelected": "未选择任何文件"
},
"toolMultiplePdfInput": {
"inputTitle": "输入 {{type}}",
"noFilesSelected": "未选择任何文件"
"noFilesSelected": "未选择任何文件",
"deleteFile": "删除文件"
},
"toolOptions": {
"title": "工具选项"
+38 -22
View File
@@ -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<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
@@ -99,7 +97,7 @@ export default function ToolMultipleAudioInput({
<InputHeader
title={
title ||
t('toolMultipleAudioInput.inputTitle', {
t('toolMultipleInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
@@ -142,26 +140,44 @@ export default function ToolMultipleAudioInput({
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MusicNoteIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
✖
</Box>
<Tooltip title={file.file.name}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
overflow: 'hidden'
}}
>
<MusicNoteIcon />
<Typography
sx={{
marginLeft: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
</Tooltip>
<Tooltip title={t('toolMultipleInput.deleteFile')}>
<IconButton
size="small"
aria-label={t('toolMultipleInput.deleteFile')}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
{t('toolMultipleAudioInput.noFilesSelected')}
{t('toolMultipleInput.noFilesSelected')}
</Typography>
)}
</Box>
@@ -104,7 +104,7 @@ export default function ToolMultiImageInput({
<InputHeader
title={
title ||
t('toolMultipleImageInput.inputTitle', {
t('toolMultipleInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
@@ -187,7 +187,7 @@ export default function ToolMultiImageInput({
))
) : (
<Typography variant="body2" color="text.secondary">
{t('toolMultipleImageInput.noFilesSelected')}
{t('toolMultipleInput.noFilesSelected')}
</Typography>
)}
</Box>
+38 -23
View File
@@ -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<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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({
<InputHeader
title={
title ||
t('toolMultiplePdfInput.inputTitle', {
t('toolMultipleInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
@@ -144,26 +141,44 @@ export default function ToolMultiFileInput({
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PictureAsPdfIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
✖
</Box>
<Tooltip title={file.file.name}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
overflow: 'hidden'
}}
>
<PictureAsPdfIcon />
<Typography
sx={{
marginLeft: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
</Tooltip>
<Tooltip title={t('toolMultipleInput.deleteFile')}>
<IconButton
size="small"
aria-label={t('toolMultipleInput.deleteFile')}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
{t('toolMultiplePdfInput.noFilesSelected')}
{t('toolMultipleInput.noFilesSelected')}
</Typography>
)}
</Box>
+45 -26
View File
@@ -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<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
title={
title ||
t('toolMultipleInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
/>
<Box
sx={{
@@ -138,26 +139,44 @@ export default function ToolMultipleVideoInput({
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<VideoFileIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
✖
</Box>
<Tooltip title={file.file.name}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
overflow: 'hidden'
}}
>
<VideoFileIcon />
<Typography
sx={{
marginLeft: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
</Tooltip>
<Tooltip title={t('toolMultipleInput.deleteFile')}>
<IconButton
size="small"
aria-label={t('toolMultipleInput.deleteFile')}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
{t('toolMultipleInput.noFilesSelected')}
</Typography>
)}
</Box>
@@ -4,73 +4,129 @@ import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import ColorSelector from '@components/options/ColorSelector';
import { updateNumberField } from '@utils/string';
import Color from 'color';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
import { processImage } from './service';
import { InitialValuesType } from './types';
import { useTranslation } from 'react-i18next';
const initialValues = {
fromColor: 'white',
toColor: 'black',
similarity: '10'
const initialValues: InitialValuesType = {
fromColor: '#ffffffff',
toColor: '#000000ff',
similarity: 10
};
const validationSchema = Yup.object({
// splitSeparator: Yup.string().required('The separator is required')
similarity: Yup.number()
.min(0, 'Similarity must be between 0 and 100')
.max(100, 'Similarity must be between 0 and 100')
.required()
});
export default function ChangeColorsInImage({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = (optionsValues: typeof initialValues, input: any) => {
const compute = async (
optionsValues: InitialValuesType,
input: File | null
) => {
if (!input) return;
const { fromColor, toColor, similarity } = optionsValues;
let fromRgb: [number, number, number];
let toRgb: [number, number, number];
let fromRgb: [number, number, number, number];
let toRgb: [number, number, number, number];
try {
//@ts-ignore
fromRgb = Color(fromColor).rgb().array();
//@ts-ignore
toRgb = Color(toColor).rgb().array();
fromRgb = [
...Color(fromColor).rgb().array(),
Color(fromColor).alpha() * 255
] as [number, number, number, number];
toRgb = [
...Color(toColor).rgb().array(),
Color(toColor).alpha() * 255
] as [number, number, number, number];
} catch (err) {
return;
}
processImage(input, fromRgb, toRgb, Number(similarity), setResult);
setIsProcessing(true);
try {
const image = await processImage(input, fromRgb, toRgb, similarity);
setResult(image);
} finally {
setIsProcessing(false);
}
};
const getGroups: GetGroupsType<typeof initialValues> = ({
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'From color and to color',
title: t('changeColors.fromColor.title'),
component: (
<Box>
<ColorSelector
value={values.fromColor}
onColorChange={(val) => updateField('fromColor', val)}
description={'Replace this color (from color)'}
inputProps={{ 'data-testid': 'from-color-input' }}
<SimpleRadio
title={t('changeColors.fromColor.transparentOption')}
checked={values.fromColor === '#00000000'}
onClick={() => updateField('fromColor', '#00000000')}
/>
<ColorSelector
value={values.toColor}
onColorChange={(val) => updateField('toColor', val)}
description={'With this color (to color)'}
inputProps={{ 'data-testid': 'to-color-input' }}
<SimpleRadio
title={t('changeColors.fromColor.colorOption')}
checked={values.fromColor !== '#00000000'}
onClick={() => updateField('fromColor', '#ffffffff')}
/>
{values.fromColor !== '#00000000' && (
<ColorSelector
value={values.fromColor}
onColorChange={(val) => updateField('fromColor', val)}
description={t('changeColors.fromColor.selectorDescription')}
inputProps={{ 'data-testid': 'from-color-input' }}
/>
)}
<TextFieldWithDesc
value={values.similarity}
onOwnChange={(val) => updateField('similarity', val)}
description={
'Match this % of similar colors of the from color. For example, 10% white will match white and a little bit of gray.'
onOwnChange={(val) =>
updateNumberField(val, 'similarity', updateField)
}
description={t('changeColors.fromColor.similarityDescription')}
/>
</Box>
)
},
{
title: t('changeColors.toColor.title'),
component: (
<Box>
<SimpleRadio
title={t('changeColors.toColor.transparentOption')}
checked={values.toColor === '#00000000'}
onClick={() => updateField('toColor', '#00000000')}
/>
<SimpleRadio
title={t('changeColors.toColor.colorOption')}
checked={values.toColor !== '#00000000'}
onClick={() => updateField('toColor', '#000000ff')}
/>
{values.toColor !== '#00000000' && (
<ColorSelector
value={values.toColor}
onColorChange={(val) => updateField('toColor', val)}
description={t('changeColors.toColor.selectorDescription')}
inputProps={{ 'data-testid': 'to-color-input' }}
/>
)}
</Box>
)
}
];
return (
<ToolContent
title={title}
@@ -84,10 +140,16 @@ export default function ChangeColorsInImage({ title }: ToolComponentProps) {
value={input}
onChange={setInput}
accept={['image/*']}
title={'Input image'}
title={t('changeColors.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('changeColors.resultTitle')}
value={result}
loading={isProcessing}
/>
}
resultComponent={<ToolFileResult title={'Result image'} value={result} />}
/>
);
}
@@ -2,168 +2,181 @@ import { areColorsSimilar } from '@utils/color';
export const processImage = async (
file: File,
fromColor: [number, number, number],
toColor: [number, number, number],
similarity: number,
setResult: (result: File | null) => void
): Promise<void> => {
fromColor: [number, number, number, number],
toColor: [number, number, number, number],
similarity: number
): Promise<File> => {
if (file.type === 'image/svg+xml') {
const reader = new FileReader();
reader.onload = (e) => {
if (!e.target?.result) return;
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
if (!e.target?.result) return;
let svgContent = e.target.result as string;
const toColorHex = rgbToHex(toColor[0], toColor[1], toColor[2]);
let svgContent = e.target.result as string;
const toColorHex = rgbToHex(
toColor[0],
toColor[1],
toColor[2],
toColor[3]
);
// Replace hex colors with various formats (#fff, #ffffff)
const hexRegexShort = new RegExp(`#[0-9a-f]{3}\\b`, 'gi');
const hexRegexLong = new RegExp(`#[0-9a-f]{6}\\b`, 'gi');
const hexRegexShort = new RegExp(`#[0-9a-f]{3}\\b`, 'gi');
const hexRegexLong = new RegExp(`#[0-9a-f]{6}\\b`, 'gi');
const hexRegexAlpha = new RegExp(`#[0-9a-f]{8}\\b`, 'gi');
svgContent = svgContent.replace(hexRegexShort, (match) => {
// Expand short hex to full form for comparison
const expanded =
'#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3];
const matchRgb = hexToRgb(expanded);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
return toColorHex;
}
return match;
});
svgContent = svgContent.replace(hexRegexShort, (match) => {
const expanded =
'#' +
match[1] +
match[1] +
match[2] +
match[2] +
match[3] +
match[3];
const matchRgb = hexToRgb(expanded);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity))
return toColorHex;
return match;
});
svgContent = svgContent.replace(hexRegexLong, (match) => {
const matchRgb = hexToRgb(match);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
return toColorHex;
}
return match;
});
svgContent = svgContent.replace(hexRegexLong, (match) => {
const matchRgb = hexToRgb(match);
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity))
return toColorHex;
return match;
});
// Replace RGB colors
const rgbRegex = new RegExp(
`rgb\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbRegex, (match, r, g, b) => {
const matchRgb: [number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b)
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgb(${toColor[0]}, ${toColor[1]}, ${toColor[2]})`;
}
return match;
});
svgContent = svgContent.replace(hexRegexAlpha, (match) => {
const matchRgba = hexToRgba(match);
if (matchRgba && areColorsSimilar(matchRgba, fromColor, similarity))
return toColorHex;
return match;
});
// Replace RGBA colors (preserving alpha)
const rgbaRegex = new RegExp(
`rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*([\\d.]+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbaRegex, (match, r, g, b, a) => {
const matchRgb: [number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b)
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgba(${toColor[0]}, ${toColor[1]}, ${toColor[2]}, ${a})`;
}
return match;
});
const rgbRegex = new RegExp(
`rgb\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbRegex, (match, r, g, b) => {
const matchRgb: [number, number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b),
255
];
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
return `rgb(${toColor[0]}, ${toColor[1]}, ${toColor[2]})`;
}
return match;
});
// Replace named SVG colors if they match our target color
const namedColors = {
red: [255, 0, 0],
green: [0, 128, 0],
blue: [0, 0, 255],
black: [0, 0, 0],
white: [255, 255, 255]
// Add more named colors as needed
const rgbaRegex = new RegExp(
`rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*([\\d.]+)\\s*\\)`,
'gi'
);
svgContent = svgContent.replace(rgbaRegex, (match, r, g, b, a) => {
const matchRgba: [number, number, number, number] = [
parseInt(r),
parseInt(g),
parseInt(b),
Math.round(parseFloat(a) * 255)
];
if (areColorsSimilar(matchRgba, fromColor, similarity)) {
return `rgba(${toColor[0]}, ${toColor[1]}, ${toColor[2]}, ${
toColor[3] / 255
})`;
}
return match;
});
const namedColors: Record<string, [number, number, number, number]> = {
red: [255, 0, 0, 255],
green: [0, 128, 0, 255],
blue: [0, 0, 255, 255],
black: [0, 0, 0, 255],
white: [255, 255, 255, 255]
};
Object.entries(namedColors).forEach(([name, rgba]) => {
if (areColorsSimilar(rgba, fromColor, similarity)) {
const colorRegex = new RegExp(`\\b${name}\\b`, 'gi');
svgContent = svgContent.replace(colorRegex, toColorHex);
}
});
resolve(new File([svgContent], file.name, { type: 'image/svg+xml' }));
};
Object.entries(namedColors).forEach(([name, rgb]) => {
if (
areColorsSimilar(
rgb as [number, number, number],
fromColor,
similarity
)
) {
const colorRegex = new RegExp(`\\b${name}\\b`, 'gi');
svgContent = svgContent.replace(colorRegex, toColorHex);
}
});
// Create new file with modified content
const newFile = new File([svgContent], file.name, {
type: 'image/svg+xml'
});
setResult(newFile);
};
reader.readAsText(file);
return;
reader.readAsText(file);
});
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
if (!ctx) throw new Error('Canvas not supported');
img.src = URL.createObjectURL(file);
const img = new Image();
const url = URL.createObjectURL(file);
img.src = url;
await img.decode();
URL.revokeObjectURL(url);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data: Uint8ClampedArray = imageData.data;
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
const currentColor: [number, number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
data[i + 2],
data[i + 3]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
data[i] = toColor[0];
data[i + 1] = toColor[1];
data[i + 2] = toColor[2];
data[i + 3] = toColor[3];
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: file.type
});
setResult(newFile);
}
}, file.type);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(new File([blob], file.name, { type: file.type }));
else reject(new Error('Failed to create blob'));
}, file.type);
});
};
const rgbToHex = (r: number, g: number, b: number): string => {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
};
const rgbToHex = (r: number, g: number, b: number, a = 255): string =>
'#' + [r, g, b, a].map((x) => x.toString(16).padStart(2, '0')).join('');
// Helper function to parse hex to RGB
const hexToRgb = (hex: string): [number, number, number] | null => {
const hexToRgb = (hex: string): [number, number, number, number] | null => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
parseInt(result[3], 16),
255
]
: null;
};
const hexToRgba = (hex: string): [number, number, number, number] | null => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(
hex
);
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
parseInt(result[4], 16)
]
: null;
};
@@ -0,0 +1,5 @@
export type InitialValuesType = {
fromColor: string;
toColor: string;
similarity: number;
};
@@ -13,7 +13,8 @@ import { GetGroupsType } from '@components/options/ToolOptions';
const initialValues = {
fromColor: 'white',
similarity: '10'
similarity: '10',
backgroundColor: '#ffffff' // NEW
};
const validationSchema = Yup.object({
@@ -26,12 +27,16 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
const compute = (optionsValues: typeof initialValues, input: any) => {
if (!input) return;
const { fromColor, similarity } = optionsValues;
const { fromColor, similarity, backgroundColor } = optionsValues;
let fromRgb: [number, number, number];
let bgRgb: [number, number, number];
try {
//@ts-ignore
fromRgb = Color(fromColor).rgb().array();
//@ts-ignore
bgRgb = Color(backgroundColor).rgb().array();
} catch (err) {
return;
}
@@ -62,7 +67,12 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i + 3] = 0; // Set alpha to 0 (transparent)
// data[i + 3] = 0; // Set alpha to 0 (transparent)
// NEW LOGIC: replace with background color
data[i] = bgRgb[0];
data[i + 1] = bgRgb[1];
data[i + 2] = bgRgb[2];
data[i + 3] = 255;
}
}
@@ -100,6 +110,13 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
'Match this % of similar colors of the from color. For example, 10% white will match white and a little bit of gray.'
}
/>
{/* NEW FEATURE */}
<ColorSelector
value={values.backgroundColor}
onColorChange={(val) => updateField('backgroundColor', val)}
description={'Background color for replaced pixels'}
/>
</Box>
)
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { tool as splitImage } from './split/meta';
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
@@ -21,5 +22,6 @@ export const imageGenericTools = [
createTransparent,
imageToText,
qrCodeGenerator,
rotateImage
rotateImage,
splitImage
];
@@ -0,0 +1,176 @@
import { Box } from '@mui/material';
import { useState, useCallback } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolImageInput from '@components/input/ToolImageInput';
import { t } from 'i18next';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import { splitImagesToPDF, fromPts, toPts } from './service';
import { debounce } from 'lodash';
import { PageSizes } from 'pdf-lib';
import ToolFileResult from '@components/result/ToolFileResult';
import SelectWithDesc from '@components/options/SelectWithDesc';
const initialValues: InitialValuesType = {
pageFormat: 'A4',
pageWidth: 210,
pageHeight: 297,
pxPerSquareQuantity: 1620,
squareQuantity: 20,
unitsPerOneSquare: fromPts(72, 'mm'),
unitKind: 'mm',
padding: 5
};
export default function Split({ title, longDescription }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
setIsProcessing(true);
try {
const pdfFile = await splitImagesToPDF(input, values);
setResult(pdfFile);
} finally {
setIsProcessing(false);
}
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: t('image:split.pageParameters.title'),
component: (
<Box>
<SelectWithDesc
selected={values.pageFormat}
onChange={(val) => {
updateField('pageFormat', val as keyof typeof PageSizes);
}}
description={t('image:split.pageParameters.selectPageFormat')}
options={Object.keys(PageSizes).map((key) => ({
value: key,
label: key
}))}
/>
<TextFieldWithDesc
name="padding"
type="number"
inputProps={{ min: 0, step: 1 }}
description={t('image:split.pageParameters.padding.description')}
onOwnChange={(value) => {
updateNumberField(value, 'padding', updateField);
}}
value={values.padding}
/>
</Box>
)
},
{
title: t('image:split.scaleParameters.title'),
component: (
<Box>
<TextFieldWithDesc
name="pxPerSquareQuantity"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.pxPerSquareQuantity.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'pxPerSquareQuantity', updateField);
}}
value={values.pxPerSquareQuantity}
/>
<TextFieldWithDesc
name="squareQuantity"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.squareQuantity.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'squareQuantity', updateField);
}}
value={values.squareQuantity}
/>
<TextFieldWithDesc
name="mmPerOneSquare"
type="number"
inputProps={{ min: 1, step: 1 }}
description={t(
'image:split.scaleParameters.unitsPerOneSquare.description'
)}
onOwnChange={(value) => {
updateNumberField(value, 'unitsPerOneSquare', updateField);
}}
value={values.unitsPerOneSquare}
/>
</Box>
)
},
{
title: t('image:split.units.title'),
component: (
<Box>
<SelectWithDesc
selected={values.unitKind}
onChange={(newUnitKind) => {
updateField('unitKind', newUnitKind);
updateField('unitsPerOneSquare', fromPts(72, newUnitKind));
updateField(
'padding',
Math.round(fromPts(toPts(5, 'mm'), newUnitKind) * 100) / 100
);
}}
description={t('image:split.units.select')}
options={[
{ value: 'pt', label: t('image:split.units.options.pt') },
{ value: 'mm', label: t('image:split.units.options.mm') },
{ value: 'in', label: t('image:split.units.options.in') }
]}
/>
</Box>
)
}
];
return (
<ToolContent
title={t('image:split.title')}
input={input}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/*']}
title={t('image:split.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('image:split.resultTitle')}
extension={'pdf'}
value={result}
loading={isProcessing}
loadingText={t('image:split.loadingText')}
/>
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={debouncedCompute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}
@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
i18n: {
name: 'image:split.title',
description: 'image:split.description',
shortDescription: 'image:split.shortDescription'
},
path: 'split-to-pages',
icon: 'mdi:scissors',
keywords: ['split', 'images', 'pages', 'large', 'map'],
component: lazy(() => import('./index'))
});
@@ -0,0 +1,136 @@
import { InitialValuesType, units } from './types';
import { PDFDocument, PageSizes } from 'pdf-lib';
async function splitImage(
initImg: HTMLImageElement,
widthOfEachPart: number,
heightOfEachPart: number
): Promise<File[]> {
const { width, height } = initImg;
const horParts = Math.ceil(width / widthOfEachPart);
const verParts = Math.ceil(height / heightOfEachPart);
const promises: Promise<File>[] = [];
for (let y = 0; y < verParts; y++) {
for (let x = 0; x < horParts; x++) {
const tileW = Math.min(widthOfEachPart, width - x * widthOfEachPart);
const tileH = Math.min(heightOfEachPart, height - y * heightOfEachPart);
const canvas = document.createElement('canvas');
canvas.width = tileW;
canvas.height = tileH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.drawImage(
initImg,
x * widthOfEachPart,
y * heightOfEachPart,
tileW,
tileH,
0,
0,
tileW,
tileH
);
promises.push(canvasToFile(canvas, `part-${x}-${y}.png`));
}
}
return Promise.all(promises);
}
export async function splitImagesToPDF(
input: File,
values: InitialValuesType
): Promise<File> {
const initImg = await loadImage(input);
const [pageWidth, pageHeight] = PageSizes[values.pageFormat];
const ptPerOneSquare = toPts(values.unitsPerOneSquare, values.unitKind);
const pxPerPt =
values.pxPerSquareQuantity / (ptPerOneSquare * values.squareQuantity);
const paddingInPts = toPts(values.padding, values.unitKind);
const pageWidthWithPadding = pageWidth - 2 * paddingInPts;
const pageHeightWithPadding = pageHeight - 2 * paddingInPts;
const imgParts = await splitImage(
initImg,
Math.round(pageWidthWithPadding * pxPerPt),
Math.round(pageHeightWithPadding * pxPerPt)
);
const pdfDoc = await PDFDocument.create();
for (const imgFile of imgParts) {
const img = await pdfDoc.embedPng(await imgFile.arrayBuffer());
const page = pdfDoc.addPage([pageWidth, pageHeight]);
page.drawImage(img, {
x: paddingInPts,
y: paddingInPts,
width: pageWidthWithPadding,
height: pageHeightWithPadding
});
}
const pdfBytes = await pdfDoc.save();
return new File(
[pdfBytes as BlobPart],
input.name.replace(/\.([^.]+)?$/i, `-${Date.now()}.pdf`),
{ type: 'application/pdf' }
);
}
export function loadImage(input: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(input);
img.src = url;
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = (err) => {
URL.revokeObjectURL(url);
reject(err);
};
});
}
function canvasToFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], name, { type: 'image/png' }));
} else {
reject(new Error('Failed to create blob'));
}
}, 'image/png');
});
}
export function toPts(number: number, units: units): number {
switch (units) {
case 'pt':
return number;
case 'mm':
return (number * 72) / 25.4;
case 'in':
return number * 72;
}
}
export function fromPts(number: number, units: units): number {
switch (units) {
case 'pt':
return number;
case 'mm':
return (number * 25.4) / 72;
case 'in':
return number / 72;
}
}
@@ -0,0 +1,16 @@
import { PageSizes } from 'pdf-lib';
export type InitialValuesType = {
pageFormat: keyof typeof PageSizes;
widthOfEachPart?: number;
heightOfEachPart?: number;
pageWidth: number;
pageHeight: number;
pxPerSquareQuantity: number;
squareQuantity: number;
unitsPerOneSquare: number;
unitKind: units;
padding: number;
};
export type units = 'pt' | 'mm' | 'in';
+14 -17
View File
@@ -1,24 +1,21 @@
export function areColorsSimilar(
color1: [number, number, number],
color2: [number, number, number],
color1: [number, number, number, number?],
color2: [number, number, number, number?],
similarity: number
): boolean {
const colorDistance = (
c1: [number, number, number],
c2: [number, number, number]
) => {
return Math.sqrt(
Math.pow(c1[0] - c2[0], 2) +
Math.pow(c1[1] - c2[1], 2) +
Math.pow(c1[2] - c2[2], 2)
);
};
const maxColorDistance = Math.sqrt(
Math.pow(255, 2) + Math.pow(255, 2) + Math.pow(255, 2)
);
const similarityThreshold = (similarity / 100) * maxColorDistance;
const a1 = color1[3] ?? 255;
const a2 = color2[3] ?? 255;
return colorDistance(color1, color2) <= similarityThreshold;
const distance = Math.sqrt(
Math.pow(color1[0] - color2[0], 2) +
Math.pow(color1[1] - color2[1], 2) +
Math.pow(color1[2] - color2[2], 2) +
Math.pow(a1 - a2, 2)
);
const maxDistance = Math.sqrt(Math.pow(255, 2) * 4);
return distance <= (similarity / 100) * maxDistance;
}
export function convertHexToRGBA(color: string): number {