Merge branch 'main' into chesterkxng

This commit is contained in:
Chesterkxng
2025-04-05 03:56:27 +02:00
70 changed files with 8275 additions and 613 deletions

View File

@@ -1,20 +0,0 @@
[Coding Aider Plan]
## Overview
This plan outlines the refactoring of existing tools to utilize a `ToolContent` component. This will standardize the structure and styling of tool content across the application, improving maintainability and user experience.
## Problem Description
Currently, some tools directly render their content without using a common `ToolContent` component. This leads to inconsistencies in styling, layout, and overall structure. It also makes it harder to apply global changes or updates to the tool content areas.
## Goals
- Identify tools that do not currently use `ToolContent`.
- Implement `ToolContent` in these tools.
- Ensure consistent styling and layout across all tools.
## Additional Notes and Constraints
- The `ToolContent` component should be flexible enough to accommodate the different types of content used by each tool.
- Ensure that the refactoring does not introduce any regressions or break existing functionality.
- Consider creating a subplan if the number of tools requiring changes is large or if individual tools require complex modifications.
## References
- Existing tools that already use `ToolContent` can serve as examples.

View File

@@ -1,9 +0,0 @@
[Coding Aider Plan - Checklist]
- [ ] Create `ToolContent` component if it doesn't exist.
- [ ] Identify tools that do not use `ToolContent`.
- [x] For each identified tool:
- [x] Implement `ToolContent` wrapper.
- [ ] Adjust styling as needed to match existing design.
- [ ] Test the tool to ensure it functions correctly.
- [ ] Review all modified tools to ensure consistency.

View File

@@ -1,6 +0,0 @@
---
files:
- path: src\pages\tools\list\duplicate\index.tsx
readOnly: false
- path: src\pages\tools\list\index.ts
readOnly: false

547
.idea/workspace.xml generated
View File

@@ -4,33 +4,10 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: compress video icon">
<change afterPath="$PROJECT_DIR$/@types/theme.d.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/public/assets/background-dark.png" afterDir="false" />
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: readme img and fix broken link">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/App.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/App.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/Hero.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Hero.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/Navbar/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Navbar/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolHeader.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolHeader.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolLayout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolLayout.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/allTools/ToolCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/allTools/ToolCard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/examples/ExampleCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/examples/ExampleCard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/ToolTextInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolTextInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/options/ColorSelector.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ColorSelector.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/options/TextFieldWithDesc.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/TextFieldWithDesc.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/config/muiConfig.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/config/muiConfig.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/home/Categories.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/home/Categories.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/home/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/home/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/compress/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/compress/service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/gif/change-speed/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/gif/change-speed/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -47,7 +24,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="main" />
<entry key="$PROJECT_DIR$" value="chesterkxng" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -66,87 +43,94 @@
&quot;state&quot;: &quot;OPEN&quot;
}
}</component>
<component name="GitHubPullRequestState"><![CDATA[{
"prStates": [
<component name="GitHubPullRequestState">{
&quot;prStates&quot;: [
{
"id": {
"id": "PR_kwDOMJIfts51PkS9",
"number": 22
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts51PkS9&quot;,
&quot;number&quot;: 22
},
"lastSeen": 1741207144695
&quot;lastSeen&quot;: 1741207144695
},
{
"id": {
"id": "PR_kwDOMJIfts6NiNYl",
"number": 32
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NiNYl&quot;,
&quot;number&quot;: 32
},
"lastSeen": 1741209723869
&quot;lastSeen&quot;: 1741209723869
},
{
"id": {
"id": "PR_kwDOMJIfts6Nheyd",
"number": 31
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Nheyd&quot;,
&quot;number&quot;: 31
},
"lastSeen": 1741213371410
&quot;lastSeen&quot;: 1741213371410
},
{
"id": {
"id": "PR_kwDOMJIfts6NmRBs",
"number": 33
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NmRBs&quot;,
&quot;number&quot;: 33
},
"lastSeen": 1741282429036
&quot;lastSeen&quot;: 1741282429036
},
{
"id": {
"id": "PR_kwDOMJIfts5zyFTs",
"number": 15
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts5zyFTs&quot;,
&quot;number&quot;: 15
},
"lastSeen": 1741535540953
&quot;lastSeen&quot;: 1741535540953
},
{
"id": {
"id": "PR_kwDOMJIfts6QQB3c",
"number": 59
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QQB3c&quot;,
&quot;number&quot;: 59
},
"lastSeen": 1743018960900
&quot;lastSeen&quot;: 1743018960900
},
{
"id": {
"id": "PR_kwDOMJIfts6QMPEg",
"number": 58
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QMPEg&quot;,
&quot;number&quot;: 58
},
"lastSeen": 1743019452983
&quot;lastSeen&quot;: 1743019452983
},
{
"id": {
"id": "PR_kwDOMJIfts6QZvRI",
"number": 61
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QZvRI&quot;,
&quot;number&quot;: 61
},
"lastSeen": 1743103196866
&quot;lastSeen&quot;: 1743103196866
},
{
"id": {
"id": "PR_kwDOMJIfts6QqPrQ",
"number": 73
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QqPrQ&quot;,
&quot;number&quot;: 73
},
"lastSeen": 1743265865001
&quot;lastSeen&quot;: 1743265865001
},
{
"id": {
"id": "PR_kwDOMJIfts6Qp5nI",
"number": 72
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Qp5nI&quot;,
&quot;number&quot;: 72
},
"lastSeen": 1743338472110
&quot;lastSeen&quot;: 1743338472110
},
{
"id": {
"id": "PR_kwDOMJIfts6QsjlS",
"number": 76
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QsjlS&quot;,
&quot;number&quot;: 76
},
"lastSeen": 1743352150953
&quot;lastSeen&quot;: 1743352150953
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Q0JBe&quot;,
&quot;number&quot;: 82
},
&quot;lastSeen&quot;: 1743470267269
}
]
}]]></component>
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;,
@@ -175,56 +159,56 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_ADD_EXTERNAL_FILES": "true",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"Docker.Dockerfile build.executor": "Run",
"Docker.Dockerfile.executor": "Run",
"Playwright.Create transparent PNG.should make png color transparent.executor": "Run",
"Playwright.JoinText Component.executor": "Run",
"Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"Vitest.compute function (1).executor": "Run",
"Vitest.compute function.executor": "Run",
"Vitest.mergeText.executor": "Run",
"Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run",
"Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run",
"Vitest.parsePageRanges.executor": "Run",
"Vitest.removeDuplicateLines function.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
"Vitest.timeBetweenDates.executor": "Run",
"git-widget-placeholder": "dark-mode",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/@types",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"npm.build.executor": "Run",
"npm.dev.executor": "Run",
"npm.lint.executor": "Run",
"npm.prebuild.executor": "Run",
"npm.script:create:tool.executor": "Run",
"npm.test.executor": "Run",
"npm.test:e2e.executor": "Run",
"npm.test:e2e:run.executor": "Run",
"prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier",
"project.structure.last.edited": "Problems",
"project.structure.proportion": "0.0",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "refactai_advanced_settings",
"ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
&quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
&quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
&quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.parsePageRanges.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.timeBetweenDates.executor&quot;: &quot;Run&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;npm.dev.executor&quot;: &quot;Run&quot;,
&quot;npm.lint.executor&quot;: &quot;Run&quot;,
&quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
&quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
&quot;npm.test.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
&quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
&quot;project.structure.proportion&quot;: &quot;0.0&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="ReactDesignerToolWindowState">
<option name="myId2Visible">
<map>
@@ -236,18 +220,18 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\components\input" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.husky" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\lib\ghostscript" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\tools" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" />
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components" />
</key>
</component>
<component name="RunManager" selected="npm.dev">
@@ -421,142 +405,13 @@
<workItem from="1743047367993" duration="986000" />
<workItem from="1743103182313" duration="4264000" />
<workItem from="1743348610793" duration="21855000" />
</task>
<task id="LOCAL-00127" summary="chore: show tooloptions in example">
<option name="closed" value="true" />
<created>1740619610168</created>
<option name="number" value="00127" />
<option name="presentableId" value="LOCAL-00127" />
<option name="project" value="LOCAL" />
<updated>1740619610169</updated>
</task>
<task id="LOCAL-00128" summary="refact: examples">
<option name="closed" value="true" />
<created>1740620866551</created>
<option name="number" value="00128" />
<option name="presentableId" value="LOCAL-00128" />
<option name="project" value="LOCAL" />
<updated>1740620866551</updated>
</task>
<task id="LOCAL-00129" summary="feat: json pretty">
<option name="closed" value="true" />
<created>1740661424202</created>
<option name="number" value="00129" />
<option name="presentableId" value="LOCAL-00129" />
<option name="project" value="LOCAL" />
<updated>1740661424202</updated>
</task>
<task id="LOCAL-00130" summary="feat: json pretty">
<option name="closed" value="true" />
<created>1740661540908</created>
<option name="number" value="00130" />
<option name="presentableId" value="LOCAL-00130" />
<option name="project" value="LOCAL" />
<updated>1740661540908</updated>
</task>
<task id="LOCAL-00131" summary="style: tool categories">
<option name="closed" value="true" />
<created>1740661744828</created>
<option name="number" value="00131" />
<option name="presentableId" value="LOCAL-00131" />
<option name="project" value="LOCAL" />
<updated>1740661744828</updated>
</task>
<task id="LOCAL-00132" summary="chore: compute only if value">
<option name="closed" value="true" />
<created>1740661864615</created>
<option name="number" value="00132" />
<option name="presentableId" value="LOCAL-00132" />
<option name="project" value="LOCAL" />
<updated>1740661864615</updated>
</task>
<task id="LOCAL-00133" summary="chore: remove prettify test">
<option name="closed" value="true" />
<created>1740662016902</created>
<option name="number" value="00133" />
<option name="presentableId" value="LOCAL-00133" />
<option name="project" value="LOCAL" />
<updated>1740662016902</updated>
</task>
<task id="LOCAL-00134" summary="chore: prettify json in home">
<option name="closed" value="true" />
<created>1740662154978</created>
<option name="number" value="00134" />
<option name="presentableId" value="LOCAL-00134" />
<option name="project" value="LOCAL" />
<updated>1740662154978</updated>
</task>
<task id="LOCAL-00135" summary="feat: jakarta font">
<option name="closed" value="true" />
<created>1740665609483</created>
<option name="number" value="00135" />
<option name="presentableId" value="LOCAL-00135" />
<option name="project" value="LOCAL" />
<updated>1740665609483</updated>
</task>
<task id="LOCAL-00136" summary="chore: img">
<option name="closed" value="true" />
<created>1740680778110</created>
<option name="number" value="00136" />
<option name="presentableId" value="LOCAL-00136" />
<option name="project" value="LOCAL" />
<updated>1740680778110</updated>
</task>
<task id="LOCAL-00137" summary="docs: readme">
<option name="closed" value="true" />
<created>1740788899030</created>
<option name="number" value="00137" />
<option name="presentableId" value="LOCAL-00137" />
<option name="project" value="LOCAL" />
<updated>1740788899030</updated>
</task>
<task id="LOCAL-00138" summary="feat: remove duplicate lines">
<option name="closed" value="true" />
<created>1740884332734</created>
<option name="number" value="00138" />
<option name="presentableId" value="LOCAL-00138" />
<option name="project" value="LOCAL" />
<updated>1740884332735</updated>
</task>
<task id="LOCAL-00139" summary="fix: tsc">
<option name="closed" value="true" />
<created>1740884971377</created>
<option name="number" value="00139" />
<option name="presentableId" value="LOCAL-00139" />
<option name="project" value="LOCAL" />
<updated>1740884971378</updated>
</task>
<task id="LOCAL-00140" summary="style: optimizations">
<option name="closed" value="true" />
<created>1740936527951</created>
<option name="number" value="00140" />
<option name="presentableId" value="LOCAL-00140" />
<option name="project" value="LOCAL" />
<updated>1740936527951</updated>
</task>
<task id="LOCAL-00141" summary="feat: ToolContent.tsx">
<option name="closed" value="true" />
<created>1741211604972</created>
<option name="number" value="00141" />
<option name="presentableId" value="LOCAL-00141" />
<option name="project" value="LOCAL" />
<updated>1741211604972</updated>
</task>
<task id="LOCAL-00142" summary="chore: smooth scroll for use this tool and examles">
<option name="closed" value="true" />
<created>1741414797155</created>
<option name="number" value="00142" />
<option name="presentableId" value="LOCAL-00142" />
<option name="project" value="LOCAL" />
<updated>1741414797155</updated>
</task>
<task id="LOCAL-00143" summary="feat: minify json">
<option name="closed" value="true" />
<created>1741416193639</created>
<option name="number" value="00143" />
<option name="presentableId" value="LOCAL-00143" />
<option name="project" value="LOCAL" />
<updated>1741416193639</updated>
<workItem from="1743397561176" duration="25000" />
<workItem from="1743458576265" duration="13083000" />
<workItem from="1743690613245" duration="77000" />
<workItem from="1743691250813" duration="1550000" />
<workItem from="1743699386059" duration="11195000" />
<workItem from="1743782726563" duration="2444000" />
<workItem from="1743811558991" duration="1279000" />
</task>
<task id="LOCAL-00144" summary="feat: stringify json">
<option name="closed" value="true" />
@@ -814,7 +669,143 @@
<option name="project" value="LOCAL" />
<updated>1743355166426</updated>
</task>
<option name="localTasksCounter" value="176" />
<task id="LOCAL-00176" summary="fix: gif speed">
<option name="closed" value="true" />
<created>1743385388051</created>
<option name="number" value="00176" />
<option name="presentableId" value="LOCAL-00176" />
<option name="project" value="LOCAL" />
<updated>1743385388051</updated>
</task>
<task id="LOCAL-00177" summary="fix: tsc">
<option name="closed" value="true" />
<created>1743385467178</created>
<option name="number" value="00177" />
<option name="presentableId" value="LOCAL-00177" />
<option name="project" value="LOCAL" />
<updated>1743385467178</updated>
</task>
<task id="LOCAL-00178" summary="fix: background color">
<option name="closed" value="true" />
<created>1743385898871</created>
<option name="number" value="00178" />
<option name="presentableId" value="LOCAL-00178" />
<option name="project" value="LOCAL" />
<updated>1743385898871</updated>
</task>
<task id="LOCAL-00179" summary="docs: github trendings">
<option name="closed" value="true" />
<created>1743459110471</created>
<option name="number" value="00179" />
<option name="presentableId" value="LOCAL-00179" />
<option name="project" value="LOCAL" />
<updated>1743459110471</updated>
</task>
<task id="LOCAL-00180" summary="docs: optimize">
<option name="closed" value="true" />
<created>1743459205311</created>
<option name="number" value="00180" />
<option name="presentableId" value="LOCAL-00180" />
<option name="project" value="LOCAL" />
<updated>1743459205311</updated>
</task>
<task id="LOCAL-00181" summary="fix: stars button width for 1k+ 😊">
<option name="closed" value="true" />
<created>1743470832619</created>
<option name="number" value="00181" />
<option name="presentableId" value="LOCAL-00181" />
<option name="project" value="LOCAL" />
<updated>1743470832619</updated>
</task>
<task id="LOCAL-00182" summary="feat: compress pdf">
<option name="closed" value="true" />
<created>1743644598841</created>
<option name="number" value="00182" />
<option name="presentableId" value="LOCAL-00182" />
<option name="project" value="LOCAL" />
<updated>1743644598841</updated>
</task>
<task id="LOCAL-00183" summary="refactor: compress pdf">
<option name="closed" value="true" />
<created>1743644703041</created>
<option name="number" value="00183" />
<option name="presentableId" value="LOCAL-00183" />
<option name="project" value="LOCAL" />
<updated>1743644703042</updated>
</task>
<task id="LOCAL-00184" summary="refactor: lib">
<option name="closed" value="true" />
<created>1743644942488</created>
<option name="number" value="00184" />
<option name="presentableId" value="LOCAL-00184" />
<option name="project" value="LOCAL" />
<updated>1743644942488</updated>
</task>
<task id="LOCAL-00185" summary="fix: path">
<option name="closed" value="true" />
<created>1743645074051</created>
<option name="number" value="00185" />
<option name="presentableId" value="LOCAL-00185" />
<option name="project" value="LOCAL" />
<updated>1743645074051</updated>
</task>
<task id="LOCAL-00186" summary="fix: vite worker format">
<option name="closed" value="true" />
<created>1743647707334</created>
<option name="number" value="00186" />
<option name="presentableId" value="LOCAL-00186" />
<option name="project" value="LOCAL" />
<updated>1743647707334</updated>
</task>
<task id="LOCAL-00187" summary="fix: tests">
<option name="closed" value="true" />
<created>1743691399769</created>
<option name="number" value="00187" />
<option name="presentableId" value="LOCAL-00187" />
<option name="project" value="LOCAL" />
<updated>1743691399769</updated>
</task>
<task id="LOCAL-00188" summary="chore: uninstall @jspawn/ghostscript-wasm">
<option name="closed" value="true" />
<created>1743691471368</created>
<option name="number" value="00188" />
<option name="presentableId" value="LOCAL-00188" />
<option name="project" value="LOCAL" />
<updated>1743691471368</updated>
</task>
<task id="LOCAL-00189" summary="feat: protect pdf">
<option name="closed" value="true" />
<created>1743705749057</created>
<option name="number" value="00189" />
<option name="presentableId" value="LOCAL-00189" />
<option name="project" value="LOCAL" />
<updated>1743705749057</updated>
</task>
<task id="LOCAL-00190" summary="feat: image to text">
<option name="closed" value="true" />
<created>1743710133267</created>
<option name="number" value="00190" />
<option name="presentableId" value="LOCAL-00190" />
<option name="project" value="LOCAL" />
<updated>1743710133267</updated>
</task>
<task id="LOCAL-00191" summary="chore: hideCopy if video or audio">
<option name="closed" value="true" />
<created>1743710669869</created>
<option name="number" value="00191" />
<option name="presentableId" value="LOCAL-00191" />
<option name="project" value="LOCAL" />
<updated>1743710669869</updated>
</task>
<task id="LOCAL-00192" summary="chore: readme img and fix broken link">
<option name="closed" value="true" />
<created>1743811980098</created>
<option name="number" value="00192" />
<option name="presentableId" value="LOCAL-00192" />
<option name="project" value="LOCAL" />
<updated>1743811980098</updated>
</task>
<option name="localTasksCounter" value="193" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -861,23 +852,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="style: tools height" />
<MESSAGE value="chore: update meta" />
<MESSAGE value="feat: change pgn opacity" />
<MESSAGE value="feat: crop png" />
<MESSAGE value="chore: remove unnecessary files" />
<MESSAGE value="refactor: validateJson" />
<MESSAGE value="feat: missing tools" />
<MESSAGE value="refactor: use ToolContent" />
<MESSAGE value="fix: prettify json" />
<MESSAGE value="refactor: sum" />
<MESSAGE value="fix: tools by category scroll" />
<MESSAGE value="fix: missing meta" />
<MESSAGE value="feat: trim video" />
<MESSAGE value="refactor: file inputs" />
<MESSAGE value="feat: background removal" />
<MESSAGE value="feat: split pdf" />
<MESSAGE value="fix: typo" />
<MESSAGE value="chore: result file name" />
<MESSAGE value="chore: text result extensions" />
<MESSAGE value="chore: show new tools in landing" />
@@ -886,7 +860,24 @@
<MESSAGE value="fix: typos" />
<MESSAGE value="feat: compress video" />
<MESSAGE value="chore: compress video icon" />
<option name="LAST_COMMIT_MESSAGE" value="chore: compress video icon" />
<MESSAGE value="fix: gif speed" />
<MESSAGE value="fix: tsc" />
<MESSAGE value="fix: background color" />
<MESSAGE value="docs: github trendings" />
<MESSAGE value="docs: optimize" />
<MESSAGE value="fix: stars button width for 1k+ " />
<MESSAGE value="feat: compress pdf" />
<MESSAGE value="refactor: compress pdf" />
<MESSAGE value="refactor: lib" />
<MESSAGE value="fix: path" />
<MESSAGE value="fix: vite worker format" />
<MESSAGE value="fix: tests" />
<MESSAGE value="chore: uninstall @jspawn/ghostscript-wasm" />
<MESSAGE value="feat: protect pdf" />
<MESSAGE value="feat: image to text" />
<MESSAGE value="chore: hideCopy if video or audio" />
<MESSAGE value="chore: readme img and fix broken link" />
<option name="LAST_COMMIT_MESSAGE" value="chore: readme img and fix broken link" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

View File

@@ -1,7 +1,9 @@
<p align="center">
<div align="center">
<img src="src/assets/logo.png" width="300" />
<br /><br />
<a href="https://github.com/iib0011/omni-tools/releases">
<a href="https://trendshift.io/repositories/13055" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13055" alt="iib0011%2Fomni-tools | Trendshift" style="width: 200px;" width="200"/></a>
<br /><br />
<a href="https://github.com/iib0011/omni-tools/releases">
<img src="https://img.shields.io/badge/version-0.2.0-blue?style=for-the-badge" />
</a>
<a href="https://hub.docker.com/r/iib0011/omni-tools">
@@ -17,10 +19,11 @@
<img src="https://img.shields.io/discord/1342971141823664179?label=Discord&style=for-the-badge" />
</a>
<br /><br />
</p>
</div>
Welcome to OmniTools, a self-hosted web app offering a variety of online tools to simplify everyday tasks.
Whether you are coding, manipulating images or crunching numbers, OmniTools has you covered. Please don't forget to
Whether you are coding, manipulating images/videos, PDFs or crunching numbers, OmniTools has you covered. Please don't
forget to
star the repo to support us.
Here is the [demo](https://omnitools.app) website.
@@ -110,11 +113,17 @@ npm run dev
### Create a new tool
```bash
npm run script:create:tool my-tool-name folder1 # npm run script:create:tool split pdf
```
For tools located under multiple nested directories, use:
```bash
npm run script:create:tool my-tool-name folder1/folder2 # npm run script:create:tool compress image/png
```
Use `folder1\folder2` on Windows
Use `folder1\folder2` on Windows.
### Run tests

BIN
img.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 120 KiB

67
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",
"use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"
@@ -6297,6 +6298,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6817,6 +6824,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -8102,6 +8115,15 @@
"protobufjs": "^7.2.4"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10091,6 +10113,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tesseract.js": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.0.tgz",
"integrity": "sha512-tqYCod1HwJzkeZw1l6XWx+ly2hhisGcBtak9MArhYwDAxL0NgeVhLJcUjqPxZMQtpgtVUzWcpZPryi+hnaQGVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^6.0.0",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz",
"integrity": "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==",
"license": "Apache-2.0"
},
"node_modules/tesseract.js/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/text-extensions": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz",
@@ -10690,6 +10742,12 @@
"node": ">=12.0.0"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -11103,6 +11161,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",

View File

@@ -57,6 +57,7 @@
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",
"use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"

39
public/gs.js Normal file
View File

@@ -0,0 +1,39 @@
// This is a placeholder file for the actual Ghostscript WASM implementation
// In a real implementation, this would be the compiled Ghostscript WASM module
// You would need to download the actual Ghostscript WASM files from:
// https://github.com/ochachacha/ps2pdf-wasm or compile it yourself
// This simulates the Module loading process that would occur with the real WASM file
(function () {
// Simulate WASM loading
console.log('Loading Ghostscript WASM module...');
// Expose a simulated Module to the window
window.Module = window.Module || {};
// Simulate filesystem
window.FS = {
writeFile: function (name, data) {
console.log(`[Simulated] Writing file: ${name}`);
return true;
},
readFile: function (name, options) {
console.log(`[Simulated] Reading file: ${name}`);
// Return a sample Uint8Array that would represent a PDF
return new Uint8Array(10);
}
};
// Mark module as initialized after a delay to simulate loading
setTimeout(function () {
window.Module.calledRun = true;
console.log('Ghostscript WASM module loaded');
// Add callMain method for direct calling
window.Module.callMain = function (args) {
console.log('[Simulated] Running Ghostscript with args:', args);
// In a real implementation, this would execute the WASM module with the given arguments
};
}, 1000);
})();

View File

@@ -12,7 +12,7 @@ import { Icon } from '@iconify/react';
const exampleTools: { label: string; url: string }[] = [
{
label: 'Create a transparent image',
url: '/png/create-transparent'
url: '/image-generic/create-transparent'
},
{ label: 'Prettify JSON', url: '/json/prettify' },
{ label: 'Change GIF speed', url: '/gif/change-speed' },
@@ -35,7 +35,7 @@ export default function Hero() {
newInputValue: string
) => {
setInputValue(newInputValue);
setFilteredTools(_.shuffle(filterTools(tools, newInputValue)));
setFilteredTools(filterTools(tools, newInputValue));
};
return (

View File

@@ -48,7 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
src="https://ghbtns.com/github-btn.html?user=iib0011&repo=omni-tools&type=star&count=true&size=large"
frameBorder="0"
scrolling="0"
width="130"
width="150"
height="30"
title="GitHub"
></iframe>,

View File

@@ -5,6 +5,7 @@ import { capitalizeFirstLetter } from '../utils/string';
import Grid from '@mui/material/Grid';
import { Icon, IconifyIcon } from '@iconify/react';
import { categoriesColors } from '../config/uiConfig';
import { getToolsByCategory } from '@tools/index';
const StyledButton = styled(Button)(({ theme }) => ({
backgroundColor: 'white',
@@ -70,7 +71,9 @@ export default function ToolHeader({
items={[
{ title: 'All tools', link: '/' },
{
title: capitalizeFirstLetter(type),
title: getToolsByCategory().find(
(category) => category.type === type
)!.rawTitle,
link: '/categories/' + type
},
{ title }

View File

@@ -53,7 +53,10 @@ export default function ToolLayout({
{children}
<Separator backgroundColor="#5581b5" margin="50px" />
<AllTools
title={`All ${capitalizeFirstLetter(type)} tools`}
title={`All ${capitalizeFirstLetter(
getToolsByCategory().find((category) => category.type === type)!
.rawTitle
)} tools`}
toolCards={otherCategoryTools}
/>
</Box>

View File

@@ -7,11 +7,13 @@ import React from 'react';
export default function ResultFooter({
handleDownload,
handleCopy,
disabled
disabled,
hideCopy
}: {
handleDownload: () => void;
handleCopy: () => void;
disabled?: boolean;
hideCopy?: boolean;
}) {
return (
<Stack mt={1} direction={'row'} spacing={2}>
@@ -22,13 +24,15 @@ export default function ResultFooter({
>
Save as
</Button>
<Button
disabled={disabled}
onClick={handleCopy}
startIcon={<ContentPasteIcon />}
>
Copy to clipboard
</Button>
{!hideCopy && (
<Button
disabled={disabled}
onClick={handleCopy}
startIcon={<ContentPasteIcon />}
>
Copy to clipboard
</Button>
)}
</Stack>
);
}

View File

@@ -15,7 +15,7 @@ export default function ToolFileResult({
}: {
title?: string;
value: File | null;
extension: string;
extension?: string;
loading?: boolean;
loadingText?: string;
}) {
@@ -50,9 +50,20 @@ export default function ToolFileResult({
const handleDownload = () => {
if (value) {
const hasExtension = value.name.includes('.');
const filename = hasExtension ? value.name : `${value.name}.${extension}`;
let filename: string = value.name;
if (extension) {
// Split at the last period to separate filename and extension
const parts = filename.split('.');
// If there's more than one part (meaning there was a period)
if (parts.length > 1) {
// Remove the last part (the extension) and add the new extension
parts.pop();
filename = `${parts.join('.')}.${extension}`;
} else {
// No extension exists, just add it
filename = `${filename}.${extension}`;
}
}
const blob = new Blob([value], { type: value.type });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -162,6 +173,7 @@ export default function ToolFileResult({
disabled={!value}
handleCopy={handleCopy}
handleDownload={handleDownload}
hideCopy={fileType === 'video' || fileType === 'audio'}
/>
</Box>
);

View File

@@ -1,21 +1,24 @@
import { Box, TextField } from '@mui/material';
import { Box, CircularProgress, TextField, Typography } from '@mui/material';
import React, { useContext } from 'react';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import InputHeader from '../InputHeader';
import ResultFooter from './ResultFooter';
import { replaceSpecialCharacters } from '@utils/string';
import mime from 'mime';
import { globalInputHeight } from '../../config/uiConfig';
export default function ToolTextResult({
title = 'Result',
value,
extension = 'txt',
keepSpecialCharacters
keepSpecialCharacters,
loading
}: {
title?: string;
value: string;
extension?: string;
keepSpecialCharacters?: boolean;
loading?: boolean;
}) {
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleCopy = () => {
@@ -46,18 +49,37 @@ export default function ToolTextResult({
return (
<Box>
<InputHeader title={title} />
<TextField
value={keepSpecialCharacters ? value : replaceSpecialCharacters(value)}
fullWidth
multiline
sx={{
'&.MuiTextField-root': {
backgroundColor: 'background.paper'
{loading ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: globalInputHeight
}}
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
Loading... This may take a moment.
</Typography>
</Box>
) : (
<TextField
value={
keepSpecialCharacters ? value : replaceSpecialCharacters(value)
}
}}
rows={10}
inputProps={{ 'data-testid': 'text-result' }}
/>
fullWidth
multiline
sx={{
'&.MuiTextField-root': {
backgroundColor: 'background.paper'
}
}}
rows={10}
inputProps={{ 'data-testid': 'text-result' }}
/>
)}
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
</Box>
);

View File

@@ -3,6 +3,7 @@ export const globalDescriptionFontSize = 12;
export const categoriesColors: string[] = [
'#8FBC5D',
'#3CB6E2',
'#B17F59',
'#FFD400',
'#AB6993'
];

View File

@@ -0,0 +1,173 @@
import { COMPRESS_ACTION, PROTECT_ACTION } from './worker-init';
function loadScript() {
import('./gs-worker.js');
}
var Module;
function compressPdf(dataStruct, responseCallback) {
const compressionLevel = dataStruct.compressionLevel || 'medium';
// Set PDF settings based on compression level
let pdfSettings;
switch (compressionLevel) {
case 'low':
pdfSettings = '/printer'; // Higher quality, less compression
break;
case 'medium':
pdfSettings = '/ebook'; // Medium quality and compression
break;
case 'high':
pdfSettings = '/screen'; // Lower quality, higher compression
break;
default:
pdfSettings = '/ebook'; // Default to medium
}
// first download the ps data
var xhr = new XMLHttpRequest();
xhr.open('GET', dataStruct.psDataURL);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
console.log('onload');
// release the URL
self.URL.revokeObjectURL(dataStruct.psDataURL);
//set up EMScripten environment
Module = {
preRun: [
function () {
self.Module.FS.writeFile('input.pdf', new Uint8Array(xhr.response));
}
],
postRun: [
function () {
var uarray = self.Module.FS.readFile('output.pdf', {
encoding: 'binary'
});
var blob = new Blob([uarray], { type: 'application/octet-stream' });
var pdfDataURL = self.URL.createObjectURL(blob);
responseCallback({
pdfDataURL: pdfDataURL,
url: dataStruct.url,
type: COMPRESS_ACTION
});
}
],
arguments: [
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
`-dPDFSETTINGS=${pdfSettings}`,
'-DNOPAUSE',
'-dQUIET',
'-dBATCH',
'-sOutputFile=output.pdf',
'input.pdf'
],
print: function (text) {},
printErr: function (text) {},
totalDependencies: 0,
noExitRuntime: 1
};
// Module.setStatus("Loading Ghostscript...");
if (!self.Module) {
self.Module = Module;
loadScript();
} else {
self.Module['calledRun'] = false;
self.Module['postRun'] = Module.postRun;
self.Module['preRun'] = Module.preRun;
self.Module.callMain();
}
};
xhr.send();
}
function protectPdf(dataStruct, responseCallback) {
const password = dataStruct.password || '';
// Validate password
if (!password) {
responseCallback({
error: 'Password is required for encryption',
url: dataStruct.url
});
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', dataStruct.psDataURL);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
console.log('onload');
// release the URL
self.URL.revokeObjectURL(dataStruct.psDataURL);
//set up EMScripten environment
Module = {
preRun: [
function () {
self.Module.FS.writeFile('input.pdf', new Uint8Array(xhr.response));
}
],
postRun: [
function () {
var uarray = self.Module.FS.readFile('output.pdf', {
encoding: 'binary'
});
var blob = new Blob([uarray], { type: 'application/octet-stream' });
var pdfDataURL = self.URL.createObjectURL(blob);
responseCallback({
pdfDataURL: pdfDataURL,
url: dataStruct.url,
type: PROTECT_ACTION
});
}
],
arguments: [
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
`-sOwnerPassword=${password}`,
`-sUserPassword=${password}`,
// Permissions (prevent copying/printing/etc)
'-dEncryptionPermissions=-4',
'-DNOPAUSE',
'-dQUIET',
'-dBATCH',
'-sOutputFile=output.pdf',
'input.pdf'
],
print: function (text) {},
printErr: function (text) {},
totalDependencies: 0,
noExitRuntime: 1
};
// Module.setStatus("Loading Ghostscript...");
if (!self.Module) {
self.Module = Module;
loadScript();
} else {
self.Module['calledRun'] = false;
self.Module['postRun'] = Module.postRun;
self.Module['preRun'] = Module.preRun;
self.Module.callMain();
}
};
xhr.send();
}
self.addEventListener('message', function ({ data: e }) {
console.log('message', e);
// e.data contains the message sent to the worker.
if (e.target !== 'wasm') {
return;
}
console.log('Message received from main script', e.data);
const responseCallback = ({ pdfDataURL, type }) => {
self.postMessage(pdfDataURL);
};
if (e.data.type === COMPRESS_ACTION) {
compressPdf(e.data, responseCallback);
} else if (e.data.type === PROTECT_ACTION) {
protectPdf(e.data, responseCallback);
}
});
console.log('Worker ready');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
export const COMPRESS_ACTION = 'compress-pdf';
export const PROTECT_ACTION = 'protect-pdf';
export async function compressWithGhostScript(dataStruct: {
psDataURL: string;
}): Promise<string> {
const worker = getWorker();
worker.postMessage({
data: { ...dataStruct, type: COMPRESS_ACTION },
target: 'wasm'
});
return getListener(worker);
}
export async function protectWithGhostScript(dataStruct: {
psDataURL: string;
}): Promise<string> {
const worker = getWorker();
worker.postMessage({
data: { ...dataStruct, type: PROTECT_ACTION },
target: 'wasm'
});
return getListener(worker);
}
const getListener = (worker: Worker): Promise<string> => {
return new Promise((resolve, reject) => {
const listener = (e: MessageEvent) => {
resolve(e.data);
worker.removeEventListener('message', listener);
setTimeout(() => worker.terminate(), 0);
};
worker.addEventListener('message', listener);
});
};
const getWorker = () => {
return new Worker(new URL('./background-worker.js', import.meta.url), {
type: 'module'
});
};

View File

@@ -1,8 +1,8 @@
import { Box, Divider, Stack, useTheme } from '@mui/material';
import { Box, Divider, Stack, TextField, useTheme } from '@mui/material';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { getToolsByCategory } from '../../tools';
import { filterTools, getToolsByCategory } from '../../tools';
import Hero from 'components/Hero';
import { capitalizeFirstLetter } from '@utils/string';
import { Icon } from '@iconify/react';
@@ -12,12 +12,14 @@ import IconButton from '@mui/material/IconButton';
import { ArrowBack } from '@mui/icons-material';
import BackButton from '@components/BackButton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SearchIcon from '@mui/icons-material/Search';
export default function Home() {
export default function ToolsByCategory() {
const navigate = useNavigate();
const theme = useTheme();
const mainContentRef = React.useRef<HTMLDivElement>(null);
const { categoryName } = useParams();
const [searchTerm, setSearchTerm] = React.useState<string>('');
useEffect(() => {
if (mainContentRef.current) {
@@ -39,61 +41,81 @@ export default function Home() {
</Box>
<Divider sx={{ borderColor: theme.palette.primary.main }} />
<Box ref={mainContentRef} mt={3} ml={{ xs: 1, md: 2, lg: 3 }} padding={3}>
<Stack direction={'row'} alignItems={'center'} spacing={1}>
<IconButton onClick={() => navigate('/')}>
<ArrowBackIcon color={'primary'} />
</IconButton>
<Typography
fontSize={22}
color={theme.palette.primary.main}
>{`All ${capitalizeFirstLetter(categoryName)} Tools`}</Typography>
<Stack direction={'row'} justifyContent={'space-between'} spacing={2}>
<Stack direction={'row'} alignItems={'center'} spacing={1}>
<IconButton onClick={() => navigate('/')}>
<ArrowBackIcon color={'primary'} />
</IconButton>
<Typography
fontSize={22}
color={theme.palette.primary.main}
>{`All ${
getToolsByCategory().find(
(category) => category.type === categoryName
)!.rawTitle
} Tools`}</Typography>
</Stack>
<TextField
placeholder={'Search'}
InputProps={{
endAdornment: <SearchIcon />,
sx: {
borderRadius: 4,
backgroundColor: 'background.paper',
maxWidth: 400
}
}}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</Stack>
<Grid container spacing={2} mt={2}>
{getToolsByCategory()
.find(({ type }) => type === categoryName)
?.tools?.map((tool, index) => (
<Grid item xs={12} md={6} lg={4} key={tool.path}>
<Stack
sx={{
backgroundColor: 'background.paper',
boxShadow: `5px 4px 2px ${
theme.palette.mode === 'dark' ? 'black' : '#E9E9ED'
}`,
cursor: 'pointer',
height: '100%',
'&:hover': {
backgroundColor: theme.palette.background.hover
}
}}
onClick={() => navigate('/' + tool.path)}
direction={'row'}
alignItems={'center'}
spacing={2}
padding={2}
border={`1px solid ${theme.palette.background.default}`}
borderRadius={2}
>
<Icon
icon={tool.icon ?? 'ph:compass-tool-thin'}
fontSize={'60px'}
color={categoriesColors[index % categoriesColors.length]}
/>
<Box>
<Link
style={{
fontSize: 20
}}
to={'/' + tool.path}
>
{tool.name}
</Link>
<Typography sx={{ mt: 2 }}>
{tool.shortDescription}
</Typography>
</Box>
</Stack>
</Grid>
))}
{filterTools(
getToolsByCategory().find(({ type }) => type === categoryName)
?.tools ?? [],
searchTerm
).map((tool, index) => (
<Grid item xs={12} md={6} lg={4} key={tool.path}>
<Stack
sx={{
backgroundColor: 'background.paper',
boxShadow: `5px 4px 2px ${
theme.palette.mode === 'dark' ? 'black' : '#E9E9ED'
}`,
cursor: 'pointer',
height: '100%',
'&:hover': {
backgroundColor: theme.palette.background.hover
}
}}
onClick={() => navigate('/' + tool.path)}
direction={'row'}
alignItems={'center'}
spacing={2}
padding={2}
border={`1px solid ${theme.palette.background.default}`}
borderRadius={2}
>
<Icon
icon={tool.icon ?? 'ph:compass-tool-thin'}
fontSize={'60px'}
color={categoriesColors[index % categoriesColors.length]}
/>
<Box>
<Link
style={{
fontSize: 20
}}
to={'/' + tool.path}
>
{tool.name}
</Link>
<Typography sx={{ mt: 2 }}>
{tool.shortDescription}
</Typography>
</Box>
</Stack>
</Grid>
))}
</Grid>
</Box>
</Box>

View File

@@ -7,7 +7,7 @@ export const tool = defineTool('csv', {
icon: 'eva:swap-outline',
description:
'Just upload your CSV file in the form below, specify the columns to swap, and the tool will automatically change the positions of the specified columns in the output file. In the tool options, you can specify the column positions or names that you want to swap, as well as fix incomplete data and optionally remove empty records and records that have been commented out.',
shortDescription: 'Convert CSV data to TSV format',
shortDescription: 'Reorder CSV columns',
longDescription:
'This tool reorganizes CSV data by swapping the positions of its columns. Swapping columns can enhance the readability of a CSV file by placing frequently used data together or in the front for easier data comparison and editing. For example, you can swap the first column with the last or swap the second column with the third. To swap columns based on their positions, select the "Set Column Position" mode and enter the numbers of the "from" and "to" columns to be swapped in the first and second blocks of options. For example, if you have a CSV file with four columns "1, 2, 3, 4" and swap columns with positions "2" and "4", the output CSV will have columns in the order: "1, 4, 3, 2".As an alternative to positions, you can swap columns by specifying their headers (column names on the first row of data). If you enable this mode in the options, then you can enter the column names like "location" and "city", and the program will swap these two columns. If any of the specified columns have incomplete data (some fields are missing), you can choose to skip such data or fill the missing fields with empty values or custom values (specified in the options). Additionally, you can specify the symbol used for comments in the CSV data, such as "#" or "//". If you do not need the commented lines in the output, you can remove them by using the "Delete Comments" checkbox. You can also activate the checkbox "Delete Empty Lines" to get rid of empty lines that contain no visible information. Csv-abulous!',
keywords: ['csv', 'swap', 'columns'],

View File

@@ -6,10 +6,10 @@ import { GetGroupsType } from '@components/options/ToolOptions';
import ColorSelector from '@components/options/ColorSelector';
import Color from 'color';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import { areColorsSimilar } from 'utils/color';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
import { processImage } from './service';
const initialValues = {
fromColor: 'white',
@@ -19,7 +19,7 @@ const initialValues = {
const validationSchema = Yup.object({
// splitSeparator: Yup.string().required('The separator is required')
});
export default function ChangeColorsInPng({ title }: ToolComponentProps) {
export default function ChangeColorsInImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
@@ -36,54 +36,10 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
} catch (err) {
return;
}
const processImage = async (
file: File,
fromColor: [number, number, number],
toColor: [number, number, number],
similarity: number
) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
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;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: 'image/png'
});
setResult(newFile);
}
}, 'image/png');
};
processImage(input, fromRgb, toRgb, Number(similarity));
processImage(input, fromRgb, toRgb, Number(similarity), setResult);
};
const getGroups: GetGroupsType<typeof initialValues> = ({
values,
updateField
@@ -127,22 +83,11 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Transparent PNG'}
value={result}
extension={'png'}
/>
}
toolInfo={{
title: 'Make Colors Transparent',
description:
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
}}
resultComponent={<ToolFileResult title={'Result image'} value={result} />}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Change colors in image',
path: 'change-colors',
icon: 'cil:color-fill',
description:
"World's simplest online Image color changer. Just import your image (JPG, PNG, SVG) in the editor on the left, select which colors to change, and you'll instantly get a new image with the new colors on the right. Free, quick, and very powerful. Import an image replace its colors.",
shortDescription: 'Quickly swap colors in a image',
keywords: ['change', 'colors', 'in', 'png', 'image', 'jpg'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,169 @@
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> => {
if (file.type === 'image/svg+xml') {
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]);
// 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');
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(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;
});
// 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;
});
// 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
};
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;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
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;
for (let i = 0; i < data.length; i += 4) {
const currentColor: [number, number, number] = [
data[i],
data[i + 1],
data[i + 2]
];
if (areColorsSimilar(currentColor, fromColor, similarity)) {
data[i] = toColor[0]; // Red
data[i + 1] = toColor[1]; // Green
data[i + 2] = toColor[2]; // Blue
}
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: file.type
});
setResult(newFile);
}
}, 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('')
);
};
// Helper function to parse hex to RGB
const hexToRgb = (hex: string): [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)
]
: null;
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { changeOpacity } from './service';
@@ -97,16 +97,12 @@ export default function ChangeOpacity({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Changed PNG'}
value={result}
extension={'png'}
/>
<ToolFileResult title={'Changed image'} value={result} />
}
initialValues={initialValues}
// exampleCards={exampleCards}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Change image Opacity',
path: 'change-opacity',
icon: 'material-symbols:opacity',
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.',
shortDescription: 'Adjust transparency of images',
keywords: ['opacity', 'transparency', 'png', 'alpha', 'jpg', 'jpeg', 'image'],
component: lazy(() => import('./index'))
});

View File

@@ -9,7 +9,10 @@ interface OpacityOptions {
areaHeight: number;
}
export async function changeOpacity(file: File, options: OpacityOptions): Promise<File> {
export async function changeOpacity(
file: File,
options: OpacityOptions
): Promise<File> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
@@ -32,12 +35,12 @@ export async function changeOpacity(file: File, options: OpacityOptions): Promis
canvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, { type: 'image/png' });
const newFile = new File([blob], file.name, { type: file.type });
resolve(newFile);
} else {
reject(new Error('Failed to generate image blob'));
}
}, 'image/png');
}, file.type);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = event.target?.result as string;
@@ -67,9 +70,10 @@ function applyGradientOpacity(
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.drawImage(img, 0, 0);
const gradient = options.gradientType === 'linear'
? createLinearGradient(ctx, options)
: createRadialGradient(ctx, options);
const gradient =
options.gradientType === 'linear'
? createLinearGradient(ctx, options)
: createRadialGradient(ctx, options);
ctx.fillStyle = gradient;
ctx.fillRect(areaLeft, areaTop, areaWidth, areaHeight);

View File

@@ -0,0 +1,121 @@
import React, { useContext, useState } from 'react';
import { InitialValuesType } from './types';
import { compressImage } from './service';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import { ToolComponentProps } from '@tools/defineTool';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
import { updateNumberField } from '@utils/string';
const initialValues: InitialValuesType = {
maxFileSizeInMB: 1.0,
quality: 80
};
export default function CompressImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [originalSize, setOriginalSize] = useState<number | null>(null); // Store original file size
const [compressedSize, setCompressedSize] = useState<number | null>(null); // Store compressed file size
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
setOriginalSize(input.size);
try {
setIsProcessing(true);
const compressed = await compressImage(input, values);
if (compressed) {
setResult(compressed);
setCompressedSize(compressed.size);
} else {
showSnackBar('Failed to compress image. Please try again.', 'error');
}
} catch (err) {
console.error('Error in compression:', err);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
<ToolFileResult
title={'Compressed image'}
value={result}
loading={isProcessing}
/>
}
initialValues={initialValues}
getGroups={({ values, updateField }) => [
{
title: 'Compression options',
component: (
<Box>
<TextFieldWithDesc
name="maxFileSizeInMB"
type="number"
inputProps={{ min: 0.1, step: 0.1 }}
description="Maximum file size in megabytes"
onOwnChange={(value) =>
updateNumberField(value, 'maxFileSizeInMB', updateField)
}
value={values.maxFileSizeInMB}
/>
<TextFieldWithDesc
name="quality"
type="number"
inputProps={{ min: 10, max: 100, step: 1 }}
description="Image quality percentage (lower means smaller file size)"
onOwnChange={(value) =>
updateNumberField(value, 'quality', updateField)
}
value={values.quality}
/>
</Box>
)
},
{
title: 'File sizes',
component: (
<Box>
<Box>
{originalSize !== null && (
<Typography>
Original Size: {(originalSize / 1024).toFixed(2)} KB
</Typography>
)}
{compressedSize !== null && (
<Typography>
Compressed Size: {(compressedSize / 1024).toFixed(2)} KB
</Typography>
)}
</Box>
</Box>
)
}
]}
compute={compute}
setInput={setInput}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Compress Image',
path: 'compress',
component: lazy(() => import('./index')),
icon: 'material-symbols-light:compress-rounded',
description:
'Compress images to reduce file size while maintaining reasonable quality.',
shortDescription:
'Compress images to reduce file size while maintaining reasonable quality.',
keywords: ['image', 'compress', 'reduce', 'quality']
});

View File

@@ -0,0 +1,30 @@
import { InitialValuesType } from './types';
import imageCompression from 'browser-image-compression';
export const compressImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
try {
const { maxFileSizeInMB, quality } = options;
// Configuration for the compression library
const compressionOptions = {
maxSizeMB: maxFileSizeInMB,
maxWidthOrHeight: 1920, // Reasonable default for most use cases
useWebWorker: true,
initialQuality: quality / 100 // Convert percentage to decimal
};
// Compress the image
const compressedFile = await imageCompression(file, compressionOptions);
// Create a new file with the original name
return new File([compressedFile], file.name, {
type: compressedFile.type
});
} catch (error) {
console.error('Error compressing image:', error);
return null;
}
};

View File

@@ -0,0 +1,4 @@
export interface InitialValuesType {
maxFileSizeInMB: number;
quality: number;
}

View File

@@ -5,7 +5,7 @@ import Jimp from 'jimp';
test.describe('Create transparent PNG', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/png/create-transparent');
await page.goto('/image-generic/create-transparent');
});
//TODO check why failing

View File

@@ -112,8 +112,8 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
/>
}
resultComponent={
@@ -131,7 +131,7 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
toolInfo={{
title: 'Create Transparent PNG',
description:
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
'This tool allows you to make specific colors in an image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
}}
/>
);

View File

@@ -1,13 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
export const tool = defineTool('image-generic', {
name: 'Create transparent PNG',
path: 'create-transparent',
icon: 'mdi:circle-transparent',
shortDescription: 'Quickly make a PNG image transparent',
shortDescription: 'Quickly make an image transparent',
description:
"World's simplest online Portable Network Graphics transparency maker. Just import your PNG image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import a PNG get a transparent PNG.",
"World's simplest online Portable Network Graphics transparency maker. Just import your image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import an image get a transparent PNG.",
keywords: ['create', 'transparent'],
component: lazy(() => import('./index'))
});

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -32,7 +32,7 @@ const validationSchema = Yup.object({
.required('Height is required')
});
export default function CropPng({ title }: ToolComponentProps) {
export default function CropImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
@@ -101,11 +101,11 @@ export default function CropPng({ title }: ToolComponentProps) {
destCanvas.toBlob((blob) => {
if (blob) {
const newFile = new File([blob], file.name, {
type: 'image/png'
type: file.type
});
setResult(newFile);
}
}, 'image/png');
}, file.type);
};
processImage(input, x, y, width, height, isCircular);
@@ -180,13 +180,13 @@ export default function CropPng({ title }: ToolComponentProps) {
<SimpleRadio
onClick={() => updateField('cropShape', 'rectangular')}
checked={values.cropShape == 'rectangular'}
description={'Crop a rectangular fragment from a PNG.'}
description={'Crop a rectangular fragment from an image.'}
title={'Rectangular Crop Shape'}
/>
<SimpleRadio
onClick={() => updateField('cropShape', 'circular')}
checked={values.cropShape == 'circular'}
description={'Crop a circular fragment from a PNG.'}
description={'Crop a circular fragment from an image.'}
title={'Circular Crop Shape'}
/>
</Box>
@@ -200,8 +200,8 @@ export default function CropPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
accept={['image/*']}
title={'Input image'}
showCropOverlay={!!input}
cropShape={values.cropShape as 'rectangular' | 'circular'}
cropPosition={{
@@ -225,16 +225,12 @@ export default function CropPng({ title }: ToolComponentProps) {
validationSchema={validationSchema}
renderCustomInput={renderCustomInput}
resultComponent={
<ToolFileResult
title={'Cropped PNG'}
value={result}
extension={'png'}
/>
<ToolFileResult title={'Cropped image'} value={result} />
}
toolInfo={{
title: 'Crop PNG Image',
title: 'Crop Image',
description:
'This tool allows you to crop a PNG image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
'This tool allows you to crop an image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
}}
/>
);

View File

@@ -1,7 +1,7 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
export const tool = defineTool('image-generic', {
name: 'Crop',
path: 'crop',
icon: 'mdi:crop', // Iconify icon as a string

View File

@@ -0,0 +1,108 @@
import { Box } from '@mui/material';
import React, { useContext, useState } from 'react';
import * as Yup from 'yup';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import CircularProgress from '@mui/material/CircularProgress';
import { extractTextFromImage, getAvailableLanguages } from './service';
import { InitialValuesType } from './types';
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
const initialValues: InitialValuesType = {
language: 'eng',
detectParagraphs: true
};
const validationSchema = Yup.object({
language: Yup.string().required('Language is required')
});
export default function ImageToText({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<string>('');
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setIsProcessing(true);
try {
const extractedText = await extractTextFromImage(input, optionsValues);
setResult(extractedText);
} catch (err: any) {
showSnackBar(
err.message || 'An error occurred while processing the image',
'error'
);
setResult('');
} finally {
setIsProcessing(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'OCR Options',
component: (
<Box>
<SelectWithDesc
selected={values.language}
onChange={(val) => updateField('language', val)}
description={
'Select the primary language in the image for better accuracy'
}
options={getAvailableLanguages()}
/>
<CheckboxWithDesc
checked={values.detectParagraphs}
onChange={(value) => updateField('detectParagraphs', value)}
description={
'Attempt to preserve paragraph structure in the extracted text'
}
title={'Detect Paragraphs'}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/jpeg', 'image/png']}
title={'Input Image'}
/>
}
resultComponent={
<ToolTextResult
title={'Extracted Text'}
value={result}
loading={isProcessing}
/>
}
toolInfo={{
title: 'Image to Text (OCR)',
description:
'This tool extracts text from images using Optical Character Recognition (OCR). Upload an image containing text, select the primary language, and get the extracted text. For best results, use clear images with good contrast.'
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Image to Text (OCR)',
path: 'image-to-text',
icon: 'mdi:text-recognition', // Iconify icon as a string
description:
'Extract text from images (JPG, PNG) using optical character recognition (OCR).',
shortDescription: 'Extract text from images using OCR.',
keywords: [
'ocr',
'optical character recognition',
'image to text',
'extract text',
'scan',
'tesseract',
'jpg',
'png'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,56 @@
import { createWorker } from 'tesseract.js';
import { InitialValuesType } from './types';
export const extractTextFromImage = async (
file: File,
options: InitialValuesType
): Promise<string> => {
try {
const { language, detectParagraphs } = options;
// Create a Tesseract worker
const worker = await createWorker(language);
// Convert file to URL
const imageUrl = URL.createObjectURL(file);
// Recognize text
const { data } = await worker.recognize(imageUrl);
// Clean up
URL.revokeObjectURL(imageUrl);
await worker.terminate();
// Process the result based on options
if (detectParagraphs) {
// Return text with paragraph structure preserved
return data.text;
} else {
// Return plain text with basic formatting
return data.text;
}
} catch (error) {
console.error('Error extracting text from image:', error);
throw new Error(
'Failed to extract text from image. Please try again with a clearer image.'
);
}
};
// Helper function to get available languages
export const getAvailableLanguages = (): { value: string; label: string }[] => {
return [
{ value: 'eng', label: 'English' },
{ value: 'fra', label: 'French' },
{ value: 'deu', label: 'German' },
{ value: 'spa', label: 'Spanish' },
{ value: 'ita', label: 'Italian' },
{ value: 'por', label: 'Portuguese' },
{ value: 'rus', label: 'Russian' },
{ value: 'jpn', label: 'Japanese' },
{ value: 'chi_sim', label: 'Chinese (Simplified)' },
{ value: 'chi_tra', label: 'Chinese (Traditional)' },
{ value: 'kor', label: 'Korean' },
{ value: 'ara', label: 'Arabic' }
];
};

View File

@@ -0,0 +1,4 @@
export type InitialValuesType = {
language: string;
detectParagraphs: boolean;
};

View File

@@ -0,0 +1,19 @@
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
import { tool as removeBackground } from './remove-background/meta';
import { tool as cropImage } from './crop/meta';
import { tool as changeOpacity } from './change-opacity/meta';
import { tool as createTransparent } from './create-transparent/meta';
import { tool as imageToText } from './image-to-text/meta';
export const imageGenericTools = [
resizeImage,
compressImage,
removeBackground,
cropImage,
changeOpacity,
changeColors,
createTransparent,
imageToText
];

View File

@@ -1,4 +1,3 @@
import { Box, CircularProgress, Typography } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
@@ -11,7 +10,9 @@ const initialValues = {};
const validationSchema = Yup.object({});
export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
export default function RemoveBackgroundFromImage({
title
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
@@ -64,7 +65,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png', 'image/jpeg', 'image/jpg']}
accept={['image/*']}
title={'Input Image'}
/>
}
@@ -78,7 +79,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
/>
}
toolInfo={{
title: 'Remove Background from PNG',
title: 'Remove Background from Image',
description:
'This tool uses AI to automatically remove the background from your images, creating a transparent PNG. Perfect for product photos, profile pictures, and design assets.'
}}

View File

@@ -0,0 +1,21 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Remove Background from Image',
path: 'remove-background',
icon: 'mdi:image-remove',
description:
"World's simplest online tool to remove backgrounds from images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
shortDescription: 'Automatically remove backgrounds from images',
keywords: [
'remove',
'background',
'png',
'transparent',
'image',
'ai',
'jpg'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,203 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import SimpleRadio from '@components/options/SimpleRadio';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import { processImage } from './service';
import { InitialValuesType } from './types';
const initialValues: InitialValuesType = {
resizeMethod: 'pixels' as 'pixels' | 'percentage',
dimensionType: 'width' as 'width' | 'height',
width: '800',
height: '600',
percentage: '50',
maintainAspectRatio: true
};
const validationSchema = Yup.object({
width: Yup.number().when('resizeMethod', {
is: 'pixels',
then: (schema) =>
schema.min(1, 'Width must be at least 1px').required('Width is required')
}),
height: Yup.number().when('resizeMethod', {
is: 'pixels',
then: (schema) =>
schema
.min(1, 'Height must be at least 1px')
.required('Height is required')
}),
percentage: Yup.number().when('resizeMethod', {
is: 'percentage',
then: (schema) =>
schema
.min(1, 'Percentage must be at least 1%')
.max(1000, 'Percentage must be at most 1000%')
.required('Percentage is required')
})
});
export default function ResizeImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setResult(await processImage(input, optionsValues));
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Resize Method',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('resizeMethod', 'pixels')}
checked={values.resizeMethod === 'pixels'}
description={'Resize by specifying dimensions in pixels.'}
title={'Resize by Pixels'}
/>
<SimpleRadio
onClick={() => updateField('resizeMethod', 'percentage')}
checked={values.resizeMethod === 'percentage'}
description={
'Resize by specifying a percentage of the original size.'
}
title={'Resize by Percentage'}
/>
</Box>
)
},
...(values.resizeMethod === 'pixels'
? [
{
title: 'Dimension Type',
component: (
<Box>
<CheckboxWithDesc
checked={values.maintainAspectRatio}
onChange={(value) =>
updateField('maintainAspectRatio', value)
}
description={
'Maintain the original aspect ratio of the image.'
}
title={'Maintain Aspect Ratio'}
/>
{values.maintainAspectRatio && (
<Box>
<SimpleRadio
onClick={() => updateField('dimensionType', 'width')}
checked={values.dimensionType === 'width'}
description={
'Specify the width in pixels and calculate height based on aspect ratio.'
}
title={'Set Width'}
/>
<SimpleRadio
onClick={() => updateField('dimensionType', 'height')}
checked={values.dimensionType === 'height'}
description={
'Specify the height in pixels and calculate width based on aspect ratio.'
}
title={'Set Height'}
/>
</Box>
)}
<TextFieldWithDesc
value={values.width}
onOwnChange={(val) => updateField('width', val)}
description={'Width (in pixels)'}
disabled={
values.maintainAspectRatio &&
values.dimensionType === 'height'
}
inputProps={{
'data-testid': 'width-input',
type: 'number',
min: 1
}}
/>
<TextFieldWithDesc
value={values.height}
onOwnChange={(val) => updateField('height', val)}
description={'Height (in pixels)'}
disabled={
values.maintainAspectRatio &&
values.dimensionType === 'width'
}
inputProps={{
'data-testid': 'height-input',
type: 'number',
min: 1
}}
/>
</Box>
)
}
]
: [
{
title: 'Percentage',
component: (
<Box>
<TextFieldWithDesc
value={values.percentage}
onOwnChange={(val) => updateField('percentage', val)}
description={
'Percentage of original size (e.g., 50 for half size, 200 for double size)'
}
inputProps={{
'data-testid': 'percentage-input',
type: 'number',
min: 1,
max: 1000
}}
/>
</Box>
)
}
])
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/jpeg', 'image/png', 'image/svg+xml', 'image/gif']}
title={'Input Image'}
/>
}
resultComponent={
<ToolFileResult
title={'Resized Image'}
value={result}
extension={input?.name.split('.').pop() || 'png'}
/>
}
toolInfo={{
title: 'Resize Image',
description:
'This tool allows you to resize JPG, PNG, SVG, or GIF images. You can resize by specifying dimensions in pixels or by percentage, with options to maintain the original aspect ratio.'
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Resize Image',
path: 'resize',
icon: 'mdi:resize', // Iconify icon as a string
description:
'Resize JPG, PNG, SVG or GIF images by pixels or percentage while maintaining aspect ratio or not.',
shortDescription: 'Resize images easily.',
keywords: [
'resize',
'image',
'scale',
'jpg',
'png',
'svg',
'gif',
'dimensions'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,218 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
export const processImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
const {
width,
height,
resizeMethod,
percentage,
dimensionType,
maintainAspectRatio
} = options;
if (file.type === 'image/svg+xml') {
try {
// Read the SVG file
const fileText = await file.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(fileText, 'image/svg+xml');
const svgElement = svgDoc.documentElement;
// Get original dimensions
const viewBox = svgElement.getAttribute('viewBox');
let originalWidth: string | number | null =
svgElement.getAttribute('width');
let originalHeight: string | number | null =
svgElement.getAttribute('height');
// Parse viewBox if available and width/height are not explicitly set
let viewBoxValues = null;
if (viewBox) {
viewBoxValues = viewBox.split(' ').map(Number);
}
// Determine original dimensions from viewBox if not explicitly set
if (!originalWidth && viewBoxValues && viewBoxValues.length === 4) {
originalWidth = String(viewBoxValues[2]);
}
if (!originalHeight && viewBoxValues && viewBoxValues.length === 4) {
originalHeight = String(viewBoxValues[3]);
}
// Default dimensions if still not available
originalWidth = originalWidth ? parseFloat(originalWidth) : 300;
originalHeight = originalHeight ? parseFloat(originalHeight) : 150;
// Calculate new dimensions
let newWidth = originalWidth;
let newHeight = originalHeight;
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
newHeight = Math.round((newWidth / originalWidth) * originalHeight);
} else {
newHeight = parseInt(height);
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
newWidth = Math.round((newHeight / originalHeight) * originalWidth);
} else {
newWidth = parseInt(width);
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
newWidth = Math.round(originalWidth * scale);
newHeight = Math.round(originalHeight * scale);
}
// Update SVG attributes
svgElement.setAttribute('width', String(newWidth));
svgElement.setAttribute('height', String(newHeight));
// If viewBox isn't already set, add it to preserve scaling
if (!viewBox) {
svgElement.setAttribute(
'viewBox',
`0 0 ${originalWidth} ${originalHeight}`
);
}
// Serialize the modified SVG document
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgDoc);
// Create a new file
return new File([svgString], file.name, {
type: 'image/svg+xml'
});
} catch (error) {
console.error('Error processing SVG:', error);
// Fall back to canvas method if SVG processing fails
}
} else if (file.type === 'image/gif') {
try {
const ffmpeg = new FFmpeg();
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
// Write the input file to memory
await ffmpeg.writeFile('input.gif', await fetchFile(file));
// Calculate new dimensions
let newWidth = 0;
let newHeight = 0;
let scaleFilter = '';
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
scaleFilter = `scale=${newWidth}:-1`;
} else {
newHeight = parseInt(height);
scaleFilter = `scale=${newWidth}:${newHeight}`;
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
scaleFilter = `scale=-1:${newHeight}`;
} else {
newWidth = parseInt(width);
scaleFilter = `scale=${newWidth}:${newHeight}`;
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
scaleFilter = `scale=iw*${scale}:ih*${scale}`;
}
// Run FFmpeg command
await ffmpeg.exec(['-i', 'input.gif', '-vf', scaleFilter, 'output.gif']);
// Read the output file
const data = await ffmpeg.readFile('output.gif');
// Create a new File object
return new File([data], file.name, { type: 'image/gif' });
} catch (error) {
console.error('Error processing GIF with FFmpeg:', error);
// Fall back to canvas method if FFmpeg processing fails
}
}
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return null;
// Load image
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
// Calculate new dimensions
let newWidth = img.width;
let newHeight = img.height;
if (resizeMethod === 'pixels') {
if (dimensionType === 'width') {
newWidth = parseInt(width);
if (maintainAspectRatio) {
newHeight = Math.round((newWidth / img.width) * img.height);
} else {
newHeight = parseInt(height);
}
} else {
// height
newHeight = parseInt(height);
if (maintainAspectRatio) {
newWidth = Math.round((newHeight / img.height) * img.width);
} else {
newWidth = parseInt(width);
}
}
} else {
// percentage
const scale = parseInt(percentage) / 100;
newWidth = Math.round(img.width * scale);
newHeight = Math.round(img.height * scale);
}
// Set canvas dimensions
canvas.width = newWidth;
canvas.height = newHeight;
// Draw resized image
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// Determine output type based on input file
let outputType = 'image/png';
if (file.type) {
outputType = file.type;
}
// Convert canvas to blob and create file
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], file.name, { type: outputType }));
} else {
resolve(null);
}
}, outputType);
});
};

View File

@@ -0,0 +1,8 @@
export type InitialValuesType = {
resizeMethod: 'pixels' | 'percentage';
dimensionType: 'width' | 'height';
width: string;
height: string;
percentage: string;
maintainAspectRatio: boolean;
};

View File

@@ -1,3 +1,4 @@
import { pngTools } from './png';
import { imageGenericTools } from './generic';
export const imageTools = [...pngTools];
export const imageTools = [...imageGenericTools, ...pngTools];

View File

@@ -1,43 +0,0 @@
import { expect, test } from '@playwright/test';
import { Buffer } from 'buffer';
import path from 'path';
import Jimp from 'jimp';
import { convertHexToRGBA } from '@utils/color';
test.describe('Change colors in png', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/png/change-colors-in-png');
});
// test('should change pixel color', async ({ page }) => {
// // Upload image
// const fileInput = page.locator('input[type="file"]');
// const imagePath = path.join(__dirname, 'test.png');
// await fileInput?.setInputFiles(imagePath);
//
// await page.getByTestId('from-color-input').fill('#FF0000');
// const toColor = '#0000FF';
// await page.getByTestId('to-color-input').fill(toColor);
//
// // Click on download
// const downloadPromise = page.waitForEvent('download');
// await page.getByText('Save as').click();
//
// // Intercept and read downloaded PNG
// const download = await downloadPromise;
// const downloadStream = await download.createReadStream();
//
// const chunks = [];
// for await (const chunk of downloadStream) {
// chunks.push(chunk);
// }
// const fileContent = Buffer.concat(chunks);
//
// expect(fileContent.length).toBeGreaterThan(0);
//
// // Check that the first pixel is transparent
// const image = await Jimp.read(fileContent);
// const color = image.getPixelColor(0, 0);
// expect(color).toBe(convertHexToRGBA(toColor));
// });
});

View File

@@ -1,13 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Change colors in png',
path: 'change-colors-in-png',
icon: 'cil:color-fill',
description:
"World's simplest online Portable Network Graphics (PNG) color changer. Just import your PNG image in the editor on the left, select which colors to change, and you'll instantly get a new PNG with the new colors on the right. Free, quick, and very powerful. Import a PNG replace its colors.",
shortDescription: 'Quickly swap colors in a PNG image',
keywords: ['change', 'colors', 'in', 'png'],
component: lazy(() => import('./index'))
});

View File

@@ -1,12 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Change PNG Opacity',
path: 'change-opacity',
icon: 'material-symbols:opacity',
description: 'Easily adjust the transparency of your PNG images. Simply upload your PNG file, use the slider to set the desired opacity level between 0 (fully transparent) and 1 (fully opaque), and download the modified image.',
shortDescription: 'Adjust transparency of PNG images',
keywords: ['opacity', 'transparency', 'png', 'alpha'],
component: lazy(() => import('./index'))
});

View File

@@ -8,7 +8,7 @@ export const tool = defineTool('png', {
icon: 'material-symbols-light:compress',
description:
'This is a program that compresses PNG pictures. As soon as you paste your PNG picture in the input area, the program will compress it and show the result in the output area. In the options, you can adjust the compression level, as well as find the old and new picture file sizes.',
shortDescription: 'Quicly compress a PNG',
shortDescription: 'Quickly compress a PNG',
keywords: ['compress', 'png'],
component: lazy(() => import('./index'))
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,17 +1,4 @@
import { tool as pngCrop } from './crop/meta';
import { tool as pngCompressPng } from './compress-png/meta';
import { tool as convertJgpToPng } from './convert-jgp-to-png/meta';
import { tool as pngCreateTransparent } from './create-transparent/meta';
import { tool as changeColorsInPng } from './change-colors-in-png/meta';
import { tool as changeOpacity } from './change-opacity/meta';
import { tool as removeBackground } from './remove-background/meta';
export const pngTools = [
pngCompressPng,
pngCreateTransparent,
changeColorsInPng,
convertJgpToPng,
changeOpacity,
pngCrop,
removeBackground
];
export const pngTools = [pngCompressPng, convertJgpToPng];

View File

@@ -1,13 +0,0 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('png', {
name: 'Remove Background from PNG',
path: 'remove-background',
icon: 'mdi:image-remove',
description:
"World's simplest online tool to remove backgrounds from PNG images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
shortDescription: 'Automatically remove backgrounds from images',
keywords: ['remove', 'background', 'png', 'transparent', 'image', 'ai'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,222 @@
import { Box, Typography } from '@mui/material';
import React, { useContext, useEffect, useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolPdfInput from '@components/input/ToolPdfInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import { PDFDocument } from 'pdf-lib';
import { CompressionLevel, InitialValuesType } from './types';
import { compressPdf } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import { CustomSnackBarContext } from '../../../../contexts/CustomSnackBarContext';
const initialValues: InitialValuesType = {
compressionLevel: 'medium'
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Low Compression',
description: 'Slightly reduce file size with minimal quality loss',
sampleText: '',
sampleResult: '',
sampleOptions: {
compressionLevel: 'low'
}
},
{
title: 'Medium Compression',
description: 'Balance between file size and quality',
sampleText: '',
sampleResult: '',
sampleOptions: {
compressionLevel: 'medium'
}
},
{
title: 'High Compression',
description: 'Maximum file size reduction with some quality loss',
sampleText: '',
sampleResult: '',
sampleOptions: {
compressionLevel: 'high'
}
}
];
export default function CompressPdf({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [resultSize, setResultSize] = useState<string>('');
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [fileInfo, setFileInfo] = useState<{
size: string;
pages: number;
} | null>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
// Get the PDF info when a file is uploaded
useEffect(() => {
const getPdfInfo = async () => {
if (!input) {
setFileInfo(null);
return;
}
try {
const arrayBuffer = await input.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const pages = pdf.getPageCount();
const size = formatFileSize(input.size);
setFileInfo({ size, pages });
} catch (error) {
console.error('Error getting PDF info:', error);
setFileInfo(null);
showSnackBar(
'Error reading PDF file. Please make sure it is a valid PDF.',
'error'
);
}
};
getPdfInfo();
}, [input]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setIsProcessing(true);
const compressedPdf = await compressPdf(input, values);
setResult(compressedPdf);
// Log compression results
const compressionRatio = (compressedPdf.size / input.size) * 100;
console.log(`Compression Ratio: ${compressionRatio.toFixed(2)}%`);
setResultSize(formatFileSize(compressedPdf.size));
} catch (error) {
console.error('Error compressing PDF:', error);
showSnackBar(
`Failed to compress PDF: ${
error instanceof Error ? error.message : String(error)
}`,
'error'
);
setResult(null);
} finally {
setIsProcessing(false);
}
};
const compressionOptions: {
value: CompressionLevel;
label: string;
description: string;
}[] = [
{
value: 'low',
label: 'Low Compression',
description: 'Slightly reduce file size with minimal quality loss'
},
{
value: 'medium',
label: 'Medium Compression',
description: 'Balance between file size and quality'
},
{
value: 'high',
label: 'High Compression',
description: 'Maximum file size reduction with some quality loss'
}
];
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
inputComponent={
<ToolPdfInput
value={input}
onChange={setInput}
accept={['application/pdf']}
title={'Input PDF'}
/>
}
resultComponent={
<ToolFileResult
title={'Compressed PDF'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Compressing PDF'}
/>
}
getGroups={({ values, updateField }) => [
{
title: 'Compression Settings',
component: (
<Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Compression Level
</Typography>
{compressionOptions.map((option) => (
<SimpleRadio
key={option.value}
title={option.label}
description={option.description}
checked={values.compressionLevel === option.value}
onClick={() => {
updateField('compressionLevel', option.value);
}}
/>
))}
</Box>
{fileInfo && (
<Box
sx={{
mt: 2,
p: 2,
bgcolor: 'background.paper',
borderRadius: 1
}}
>
<Typography variant="body2">
File size: <strong>{fileInfo.size}</strong>
</Typography>
<Typography variant="body2">
Pages: <strong>{fileInfo.pages}</strong>
</Typography>
{resultSize && (
<Typography variant="body2">
Compressed file size: <strong>{resultSize}</strong>
</Typography>
)}
</Box>
)}
</Box>
)
}
]}
/>
);
}

View File

@@ -0,0 +1,28 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('pdf', {
name: 'Compress PDF',
path: 'compress-pdf',
icon: 'material-symbols:compress',
description:
'Reduce PDF file size while maintaining quality using Ghostscript',
shortDescription: 'Compress PDF files securely in your browser',
keywords: [
'pdf',
'compress',
'reduce',
'size',
'optimize',
'shrink',
'file size',
'ghostscript',
'secure',
'private',
'browser',
'webassembly'
],
longDescription:
'Compress PDF files securely in your browser using Ghostscript. Your files never leave your device, ensuring complete privacy while reducing file sizes for email sharing, uploading to websites, or saving storage space. Powered by WebAssembly technology.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,28 @@
import { InitialValuesType } from './types';
import { compressWithGhostScript } from '../../../../lib/ghostscript/worker-init';
import { loadPDFData } from '../utils';
/**
* Compresses a PDF file using either Ghostscript WASM (preferred)
* or falls back to pdf-lib if WASM fails
*
* @param pdfFile - The PDF file to compress
* @param options - Compression options including compression level
* @returns A Promise that resolves to a compressed PDF File
*/
export async function compressPdf(
pdfFile: File,
options: InitialValuesType
): Promise<File> {
// Check if file is a PDF
if (pdfFile.type !== 'application/pdf') {
throw new Error('The provided file is not a PDF');
}
const dataObject = {
psDataURL: URL.createObjectURL(pdfFile),
compressionLevel: options.compressionLevel
};
const compressedFileUrl: string = await compressWithGhostScript(dataObject);
return await loadPDFData(compressedFileUrl, pdfFile.name);
}

View File

@@ -0,0 +1,5 @@
export type CompressionLevel = 'low' | 'medium' | 'high';
export type InitialValuesType = {
compressionLevel: CompressionLevel;
};

View File

@@ -1,5 +1,12 @@
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta';
import { tool as compressPdfTool } from './compress-pdf/meta';
import { tool as protectPdfTool } from './protect-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [splitPdfMeta, pdfRotatePdf];
export const pdfTools: DefinedTool[] = [
splitPdfMeta,
pdfRotatePdf,
compressPdfTool,
protectPdfTool
];

View File

@@ -0,0 +1,110 @@
import { Box } from '@mui/material';
import React, { useContext, useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolPdfInput from '@components/input/ToolPdfInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { InitialValuesType } from './types';
import { protectPdf } from './service';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { CustomSnackBarContext } from '../../../../contexts/CustomSnackBarContext';
const initialValues: InitialValuesType = {
password: '',
confirmPassword: ''
};
export default function ProtectPdf({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
// Validate passwords match
if (values.password !== values.confirmPassword) {
showSnackBar('Passwords do not match', 'error');
return;
}
// Validate password is not empty
if (!values.password) {
showSnackBar('Password cannot be empty', 'error');
return;
}
setIsProcessing(true);
const protectedPdf = await protectPdf(input, values);
setResult(protectedPdf);
} catch (error) {
console.error('Error protecting PDF:', error);
showSnackBar(
`Failed to protect PDF: ${
error instanceof Error ? error.message : String(error)
}`,
'error'
);
setResult(null);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
inputComponent={
<ToolPdfInput
value={input}
onChange={setInput}
accept={['application/pdf']}
title={'Input PDF'}
/>
}
resultComponent={
<ToolFileResult
title={'Protected PDF'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Protecting PDF'}
/>
}
getGroups={({ values, updateField }) => [
{
title: 'Password Settings',
component: (
<Box>
<TextFieldWithDesc
title="Password"
description="Enter a password to protect your PDF"
placeholder="Enter password"
type="password"
value={values.password}
onOwnChange={(value) => updateField('password', value)}
/>
<TextFieldWithDesc
title="Confirm Password"
description="Re-enter your password to confirm"
placeholder="Confirm password"
type="password"
value={values.confirmPassword}
onOwnChange={(value) => updateField('confirmPassword', value)}
/>
</Box>
)
}
]}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('pdf', {
name: 'Protect PDF',
path: 'protect-pdf',
icon: 'material-symbols:lock',
description:
'Add password protection to your PDF files securely in your browser',
shortDescription: 'Password protect PDF files securely',
keywords: [
'pdf',
'protect',
'password',
'secure',
'encrypt',
'lock',
'private',
'confidential',
'security',
'browser',
'encryption'
],
longDescription:
'Add password protection to your PDF files securely in your browser. Your files never leave your device, ensuring complete privacy while securing your documents with password encryption. Perfect for protecting sensitive information, confidential documents, or personal data.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,45 @@
import { PDFDocument } from 'pdf-lib';
import { InitialValuesType } from './types';
import {
compressWithGhostScript,
protectWithGhostScript
} from '../../../../lib/ghostscript/worker-init';
import { loadPDFData } from '../utils';
/**
* Protects a PDF file with a password
*
* @param pdfFile - The PDF file to protect
* @param options - Protection options including password and protection type
* @returns A Promise that resolves to a password-protected PDF File
*/
export async function protectPdf(
pdfFile: File,
options: InitialValuesType
): Promise<File> {
// Check if file is a PDF
if (pdfFile.type !== 'application/pdf') {
throw new Error('The provided file is not a PDF');
}
// Check if passwords match
if (options.password !== options.confirmPassword) {
throw new Error('Passwords do not match');
}
// Check if password is empty
if (!options.password) {
throw new Error('Password cannot be empty');
}
const dataObject = {
psDataURL: URL.createObjectURL(pdfFile),
password: options.password
};
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
console.log('protected', protectedFileUrl);
return await loadPDFData(
protectedFileUrl,
pdfFile.name.replace('.pdf', '-protected.pdf')
);
}

View File

@@ -0,0 +1,6 @@
export type ProtectionType = 'owner' | 'user';
export type InitialValuesType = {
password: string;
confirmPassword: string;
};

View File

@@ -0,0 +1,16 @@
export function loadPDFData(url: string, filename: string): Promise<File> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
window.URL.revokeObjectURL(url);
const blob = new Blob([xhr.response], { type: 'application/pdf' });
const newFile = new File([blob], filename, {
type: 'application/pdf'
});
resolve(newFile);
};
xhr.send();
});
}

View File

@@ -23,8 +23,8 @@ export type ToolCategory =
| 'json'
| 'time'
| 'csv'
| 'time'
| 'pdf';
| 'pdf'
| 'image-generic';
export interface DefinedTool {
type: ToolCategory;

View File

@@ -12,6 +12,19 @@ import { timeTools } from '../pages/tools/time';
import { IconifyIcon } from '@iconify/react';
import { pdfTools } from '../pages/tools/pdf';
const toolCategoriesOrder: ToolCategory[] = [
'image-generic',
'string',
'json',
'pdf',
'video',
'list',
'csv',
'number',
'png',
'time',
'gif'
];
export const tools: DefinedTool[] = [
...imageTools,
...stringTools,
@@ -95,6 +108,13 @@ const categoriesConfig: {
icon: 'fluent-mdl2:date-time',
value:
'Tools for working with time and date draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.'
},
{
type: 'image-generic',
title: 'Image',
icon: 'material-symbols-light:image-outline-rounded',
value:
'Tools for working with pictures compress, resize, crop, convert to JPG, rotate, remove background and much more.'
}
];
// use for changelogs
@@ -123,20 +143,22 @@ export const filterTools = (
export const getToolsByCategory = (): {
title: string;
rawTitle: string;
description: string;
icon: IconifyIcon | string;
type: string;
type: ToolCategory;
example: { title: string; path: string };
tools: DefinedTool[];
}[] => {
const groupedByType: Partial<Record<ToolCategory, DefinedTool[]>> =
Object.groupBy(tools, ({ type }) => type);
return (Object.entries(groupedByType) as Entries<typeof groupedByType>).map(
([type, tools]) => {
return (Object.entries(groupedByType) as Entries<typeof groupedByType>)
.map(([type, tools]) => {
const categoryConfig = categoriesConfig.find(
(config) => config.type === type
);
return {
rawTitle: categoryConfig?.title ?? capitalizeFirstLetter(type),
title: `${categoryConfig?.title ?? capitalizeFirstLetter(type)} Tools`,
description: categoryConfig?.value ?? '',
type,
@@ -146,6 +168,10 @@ export const getToolsByCategory = (): {
? { title: tools[0].name, path: tools[0].path }
: { title: '', path: '' }
};
}
);
})
.sort(
(a, b) =>
toolCategoriesOrder.indexOf(a.type) -
toolCategoriesOrder.indexOf(b.type)
);
};

View File

@@ -17,5 +17,6 @@ export default defineConfig({
environment: 'happy-dom',
setupFiles: '.vitest/setup',
include: ['**/*.test.{ts,tsx}']
}
},
worker: { format: 'es' }
});