mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-10-02 04:19:33 +02:00
merge upstream
This commit is contained in:
@@ -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.
|
@@ -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.
|
@@ -1,6 +0,0 @@
|
||||
---
|
||||
files:
|
||||
- path: src\pages\tools\list\duplicate\index.tsx
|
||||
readOnly: false
|
||||
- path: src\pages\tools\list\index.ts
|
||||
readOnly: false
|
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@@ -49,6 +49,49 @@ jobs:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
build-and-push-docker:
|
||||
name: Build and Push Multi-Platform Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test-and-build
|
||||
- e2e-test
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
platforms: 'arm64,amd64'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: iib0011/omni-tools
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
|
394
.idea/workspace.xml
generated
394
.idea/workspace.xml
generated
@@ -4,9 +4,10 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: gif resize">
|
||||
<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/tools/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/tools/index.ts" 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/pages/tools-by-category/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -23,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$" />
|
||||
@@ -120,6 +121,13 @@
|
||||
"number": 76
|
||||
},
|
||||
"lastSeen": 1743352150953
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6Q0JBe",
|
||||
"number": 82
|
||||
},
|
||||
"lastSeen": 1743470267269
|
||||
}
|
||||
]
|
||||
}</component>
|
||||
@@ -151,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": "image-resize",
|
||||
"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">{
|
||||
"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": "main",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
|
||||
"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>
|
||||
}</component>
|
||||
<component name="ReactDesignerToolWindowState">
|
||||
<option name="myId2Visible">
|
||||
<map>
|
||||
@@ -212,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">
|
||||
@@ -397,111 +405,13 @@
|
||||
<workItem from="1743047367993" duration="986000" />
|
||||
<workItem from="1743103182313" duration="4264000" />
|
||||
<workItem from="1743348610793" duration="21855000" />
|
||||
<workItem from="1743556259185" duration="7150000" />
|
||||
</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" />
|
||||
@@ -759,39 +669,143 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743355166426</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00176" summary="chore: tool description">
|
||||
<task id="LOCAL-00176" summary="fix: gif speed">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743560690570</created>
|
||||
<created>1743385388051</created>
|
||||
<option name="number" value="00176" />
|
||||
<option name="presentableId" value="LOCAL-00176" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743560690571</updated>
|
||||
<updated>1743385388051</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00177" summary="feat: image resize init">
|
||||
<task id="LOCAL-00177" summary="fix: tsc">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743565606951</created>
|
||||
<created>1743385467178</created>
|
||||
<option name="number" value="00177" />
|
||||
<option name="presentableId" value="LOCAL-00177" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743565606951</updated>
|
||||
<updated>1743385467178</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00178" summary="feat: svg resize">
|
||||
<task id="LOCAL-00178" summary="fix: background color">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743566704552</created>
|
||||
<created>1743385898871</created>
|
||||
<option name="number" value="00178" />
|
||||
<option name="presentableId" value="LOCAL-00178" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743566704552</updated>
|
||||
<updated>1743385898871</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00179" summary="feat: gif resize">
|
||||
<task id="LOCAL-00179" summary="docs: github trendings">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743567906528</created>
|
||||
<created>1743459110471</created>
|
||||
<option name="number" value="00179" />
|
||||
<option name="presentableId" value="LOCAL-00179" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743567906528</updated>
|
||||
<updated>1743459110471</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="180" />
|
||||
<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">
|
||||
@@ -838,19 +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="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" />
|
||||
@@ -859,11 +860,24 @@
|
||||
<MESSAGE value="fix: typos" />
|
||||
<MESSAGE value="feat: compress video" />
|
||||
<MESSAGE value="chore: compress video icon" />
|
||||
<MESSAGE value="chore: tool description" />
|
||||
<MESSAGE value="feat: image resize init" />
|
||||
<MESSAGE value="feat: svg resize" />
|
||||
<MESSAGE value="feat: gif resize" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="feat: gif resize" />
|
||||
<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 />
|
||||
|
BIN
img.png
BIN
img.png
Binary file not shown.
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 120 KiB |
67
package-lock.json
generated
67
package-lock.json
generated
@@ -41,6 +41,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"
|
||||
@@ -6304,6 +6305,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",
|
||||
@@ -6824,6 +6831,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",
|
||||
@@ -8109,6 +8122,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",
|
||||
@@ -10098,6 +10120,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",
|
||||
@@ -10697,6 +10749,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",
|
||||
@@ -11110,6 +11168,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",
|
||||
|
@@ -58,6 +58,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
39
public/gs.js
Normal 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);
|
||||
})();
|
@@ -130,6 +130,7 @@ export default function ${capitalizeFirstLetter(toolNameCamelCase)}({
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: \`What is a \${title}?\`, description: longDescription }}
|
||||
/>
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
|
173
src/lib/ghostscript/background-worker.js
Normal file
173
src/lib/ghostscript/background-worker.js
Normal 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');
|
5894
src/lib/ghostscript/gs-worker.js
Normal file
5894
src/lib/ghostscript/gs-worker.js
Normal file
File diff suppressed because it is too large
Load Diff
41
src/lib/ghostscript/worker-init.ts
Normal file
41
src/lib/ghostscript/worker-init.ts
Normal 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'
|
||||
});
|
||||
};
|
@@ -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,62 +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 ${
|
||||
getToolsByCategory().find(
|
||||
(category) => category.type === categoryName
|
||||
)!.rawTitle
|
||||
} 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>
|
||||
|
@@ -1,9 +1,4 @@
|
||||
function unquoteIfQuoted(value: string, quoteCharacter: string): string {
|
||||
if (value.startsWith(quoteCharacter) && value.endsWith(quoteCharacter)) {
|
||||
return value.slice(1, -1); // Remove first and last character
|
||||
}
|
||||
return value;
|
||||
}
|
||||
import { unquoteIfQuoted } from '@utils/string';
|
||||
export function csvToTsv(
|
||||
input: string,
|
||||
delimiter: string,
|
||||
|
48
src/pages/tools/csv/csv-to-yaml/csv-to-yaml.service.test.ts
Normal file
48
src/pages/tools/csv/csv-to-yaml/csv-to-yaml.service.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { main } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
// filepath: c:\CODE\omni-tools\src\pages\tools\csv\csv-to-yaml\csv-to-yaml.service.test.ts
|
||||
describe('main', () => {
|
||||
const defaultOptions: InitialValuesType = {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
headerRow: true,
|
||||
spaces: 2
|
||||
};
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
const result = main('', defaultOptions);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should return this if header is set to false', () => {
|
||||
const options = { ...defaultOptions, headerRow: false };
|
||||
const result = main('John,30\nEmma,50', options);
|
||||
expect(result).toEqual('-\n - John\n - 30\n-\n - Emma\n - 50');
|
||||
});
|
||||
|
||||
it('should return this header is set to true', () => {
|
||||
const options = { ...defaultOptions };
|
||||
const result = main('Name,Age\nJohn,30\nEmma,50', options);
|
||||
expect(result).toEqual(
|
||||
'-\n Name: John\n Age: 30\n-\n Name: Emma\n Age: 50'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return this header is set to true and comment flag set', () => {
|
||||
const options = { ...defaultOptions, commentcharacter: '#' };
|
||||
const result = main('Name,Age\nJohn,30\n#Emma,50', options);
|
||||
expect(result).toEqual('-\n Name: John\n Age: 30');
|
||||
});
|
||||
|
||||
it('should return this header is set to true and spaces is set to 3', () => {
|
||||
const options = { ...defaultOptions, spaces: 3 };
|
||||
const result = main('Name,Age\nJohn,30\nEmma,50', options);
|
||||
expect(result).toEqual(
|
||||
'-\n Name: John\n Age: 30\n-\n Name: Emma\n Age: 50'
|
||||
);
|
||||
});
|
||||
});
|
206
src/pages/tools/csv/csv-to-yaml/index.tsx
Normal file
206
src/pages/tools/csv/csv-to-yaml/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolTextInput from '@components/input/ToolTextInput';
|
||||
import ToolTextResult from '@components/result/ToolTextResult';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { main } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
headerRow: true,
|
||||
spaces: 2
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Convert Music Playlist CSV to YAML',
|
||||
description:
|
||||
'In this example, we transform a short CSV file containing a music playlist into structured YAML data. The input CSV contains five records with three columns each and the output YAML contains five lists of lists (one list for each CSV record). In YAML, lists start with the "-" symbol and the nested lists are indented with two spaces',
|
||||
sampleText: `The Beatles,"Yesterday",Pop Rock
|
||||
Queen,"Bohemian Rhapsody",Rock
|
||||
Nirvana,"Smells Like Teen Spirit",Grunge
|
||||
Michael Jackson,"Billie Jean",Pop
|
||||
Stevie Wonder,"Superstition",Funk`,
|
||||
sampleResult: `-
|
||||
- The Beatles
|
||||
- Yesterday
|
||||
- Pop Rock
|
||||
-
|
||||
- Queen
|
||||
- Bohemian Rhapsody
|
||||
- Rock
|
||||
-
|
||||
- Nirvana
|
||||
- Smells Like Teen Spirit
|
||||
- Grunge
|
||||
-
|
||||
- Michael Jackson
|
||||
- Billie Jean
|
||||
- Pop
|
||||
-
|
||||
- Stevie Wonder
|
||||
- Superstition
|
||||
- Funk`,
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
headerRow: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Planetary CSV Data',
|
||||
description:
|
||||
'In this example, we are working with CSV data that summarizes key properties of three planets in our solar system. The data consists of three columns with headers "planet", "relative mass" (with "1" being the mass of earth), and "satellites". To preserve the header names in the output YAML data, we enable the "Transform Headers" option, creating a YAML file that contains a list of YAML objects, where each object has three keys: "planet", "relative mass", and "satellites".',
|
||||
sampleText: `planet,relative mass,satellites
|
||||
Venus,0.815,0
|
||||
Earth,1.000,1
|
||||
Mars,0.107,2`,
|
||||
sampleResult: `-
|
||||
planet: Venus
|
||||
relative mass: 0.815
|
||||
satellites: '0'
|
||||
-
|
||||
planet: Earth
|
||||
relative mass: 1.000
|
||||
satellites: '1'
|
||||
-
|
||||
planet: Mars
|
||||
relative mass: 0.107
|
||||
satellites: '2'`,
|
||||
sampleOptions: {
|
||||
...initialValues
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Convert Non-standard CSV to YAML',
|
||||
description:
|
||||
'In this example, we convert a CSV file with non-standard formatting into a regular YAML file. The input data uses a semicolon as a separator for the "product", "quantity", and "price" fields. It also contains empty lines and lines that are commented out. To make the program work with this custom CSV file, we input the semicolon symbol in the CSV delimiter options. To skip comments, we specify "#" as the symbol that starts comments. And to remove empty lines, we activate the option for skipping blank lines (that do not contain any symbols). In the output, we obtain a YAML file that contains a list of three objects, which use CSV headers as keys. Additionally, the objects in the YAML file are indented with four spaces.',
|
||||
sampleText: `item;quantity;price
|
||||
milk;2;3.50
|
||||
|
||||
#eggs;12;2.99
|
||||
bread;1;4.25
|
||||
#apples;4;1.99
|
||||
cheese;1;8.99`,
|
||||
sampleResult: `-
|
||||
item: milk
|
||||
quantity: 2
|
||||
price: 3.50
|
||||
-
|
||||
item: bread
|
||||
quantity: 1
|
||||
price: 4.25
|
||||
-
|
||||
item: cheese
|
||||
quantity: 1
|
||||
price: 8.99`,
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
csvSeparator: ';'
|
||||
}
|
||||
}
|
||||
];
|
||||
export default function CsvToYaml({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (optionsValues: InitialValuesType, input: string) => {
|
||||
setResult(main(input, optionsValues));
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Adjust CSV input',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.csvSeparator}
|
||||
onOwnChange={(val) => updateField('csvSeparator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV file.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.quoteCharacter}
|
||||
onOwnChange={(val) => updateField('quoteCharacter', val)}
|
||||
description={
|
||||
'Enter the quote character used to quote the CSV fields.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.commentCharacter}
|
||||
onOwnChange={(val) => updateField('commentCharacter', val)}
|
||||
description={
|
||||
'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Conversion Options',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.headerRow}
|
||||
onChange={(value) => updateField('headerRow', value)}
|
||||
title="Use Headers"
|
||||
description="Keep the first row as column names."
|
||||
/>
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyLines}
|
||||
onChange={(value) => updateField('emptyLines', value)}
|
||||
title="Ignore Lines with No Data"
|
||||
description="Enable to prevent the conversion of empty lines in the input CSV file."
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Adjust YAML indentation',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.spaces}
|
||||
type="number"
|
||||
onOwnChange={(val) => updateField('spaces', Number(val))}
|
||||
inputProps={{ min: 1 }}
|
||||
description={
|
||||
'Set the number of spaces to use for YAML indentation.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput title={'Input CSV'} value={input} onChange={setInput} />
|
||||
}
|
||||
resultComponent={<ToolTextResult title={'Output YAML'} value={result} />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/csv/csv-to-yaml/meta.ts
Normal file
15
src/pages/tools/csv/csv-to-yaml/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('csv', {
|
||||
name: 'Csv to yaml',
|
||||
path: 'csv-to-yaml',
|
||||
icon: 'nonicons:yaml-16',
|
||||
description:
|
||||
'Just upload your CSV file in the form below and it will automatically get converted to a YAML file. In the tool options, you can specify the field delimiter character, field quote character, and comment character to adapt the tool to custom CSV formats. Additionally, you can select the output YAML format: one that preserves CSV headers or one that excludes CSV headers.',
|
||||
shortDescription: 'Quickly convert a CSV file to a YAML file.',
|
||||
keywords: ['csv', 'to', 'yaml'],
|
||||
longDescription:
|
||||
'This tool transforms CSV (Comma Separated Values) data into the YAML (Yet Another Markup Language) data. CSV is a simple, tabular format that is used to represent matrix-like data types consisting of rows and columns. YAML, on the other hand, is a more advanced format (actually a superset of JSON), which creates more human-readable data for serialization, and it supports lists, dictionaries, and nested objects. This program supports various input CSV formats – the input data can be comma-separated (default), semicolon-separated, pipe-separated, or use another completely different delimiter. You can specify the exact delimiter your data uses in the options. Similarly, in the options, you can specify the quote character that is used to wrap CSV fields (by default a double-quote symbol). You can also skip lines that start with comments by specifying the comment symbols in the options. This allows you to keep your data clean by skipping unnecessary lines. There are two ways to convert CSV to YAML. The first method converts each CSV row into a YAML list. The second method extracts headers from the first CSV row and creates YAML objects with keys based on these headers. You can also customize the output YAML format by specifying the number of spaces for indenting YAML structures. If you need to perform the reverse conversion, that is, transform YAML into CSV, you can use our Convert YAML to CSV tool. Csv-abulous!',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
85
src/pages/tools/csv/csv-to-yaml/service.ts
Normal file
85
src/pages/tools/csv/csv-to-yaml/service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { getCsvHeaders, splitCsv } from '@utils/csv';
|
||||
import { unquoteIfQuoted } from '@utils/string';
|
||||
|
||||
function toYaml(
|
||||
input: Record<string, string>[] | string[][],
|
||||
indentSpaces: number = 2
|
||||
): string {
|
||||
if (indentSpaces == 0) {
|
||||
throw new Error('Indent spaces must be greater than zero');
|
||||
}
|
||||
const indent = ' '.repeat(indentSpaces);
|
||||
|
||||
if (
|
||||
Array.isArray(input) &&
|
||||
input.length > 0 &&
|
||||
typeof input[0] === 'object' &&
|
||||
!Array.isArray(input[0])
|
||||
) {
|
||||
return (input as Record<string, string>[])
|
||||
.map((obj) => {
|
||||
const lines = Object.entries(obj)
|
||||
.map(([key, value]) => `${indent}${key}: ${value}`)
|
||||
.join('\n');
|
||||
return `-\n${lines}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// If input is string[][].
|
||||
if (Array.isArray(input) && Array.isArray(input[0])) {
|
||||
return (input as string[][])
|
||||
.map((row) => {
|
||||
const inner = row.map((cell) => `${indent}- ${cell}`).join('\n');
|
||||
return `-\n${inner}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return 'invalid input';
|
||||
}
|
||||
|
||||
export function main(input: string, options: InitialValuesType): string {
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rows = splitCsv(
|
||||
input,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
options.emptyLines,
|
||||
options.csvSeparator,
|
||||
options.quoteCharacter
|
||||
);
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.forEach((cell, cellIndex) => {
|
||||
row[cellIndex] = unquoteIfQuoted(cell, options.quoteCharacter);
|
||||
});
|
||||
});
|
||||
|
||||
if (options.headerRow) {
|
||||
const headerRow = getCsvHeaders(
|
||||
input,
|
||||
options.csvSeparator,
|
||||
options.quoteCharacter,
|
||||
options.commentCharacter
|
||||
);
|
||||
headerRow.forEach((header, headerIndex) => {
|
||||
headerRow[headerIndex] = unquoteIfQuoted(header, options.quoteCharacter);
|
||||
});
|
||||
|
||||
const result: Record<string, string>[] = rows.slice(1).map((row) => {
|
||||
const entry: Record<string, string> = {};
|
||||
headerRow.forEach((header, headerIndex) => {
|
||||
entry[header] = row[headerIndex] ?? '';
|
||||
});
|
||||
return entry;
|
||||
});
|
||||
return toYaml(result, options.spaces);
|
||||
}
|
||||
|
||||
return toYaml(rows, options.spaces);
|
||||
}
|
8
src/pages/tools/csv/csv-to-yaml/types.ts
Normal file
8
src/pages/tools/csv/csv-to-yaml/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type InitialValuesType = {
|
||||
csvSeparator: string;
|
||||
quoteCharacter: string;
|
||||
commentCharacter: string;
|
||||
emptyLines: boolean;
|
||||
headerRow: boolean;
|
||||
spaces: number;
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import { tool as csvToYaml } from './csv-to-yaml/meta';
|
||||
import { tool as csvToJson } from './csv-to-json/meta';
|
||||
import { tool as csvToXml } from './csv-to-xml/meta';
|
||||
import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
|
||||
@@ -9,5 +10,6 @@ export const csvTools = [
|
||||
csvToXml,
|
||||
csvToRowsColumns,
|
||||
csvToTsv,
|
||||
swapCsvColumns
|
||||
swapCsvColumns,
|
||||
csvToYaml
|
||||
];
|
||||
|
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal file
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal 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'))
|
||||
});
|
169
src/pages/tools/image/generic/change-colors/service.ts
Normal file
169
src/pages/tools/image/generic/change-colors/service.ts
Normal 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;
|
||||
};
|
@@ -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}
|
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal file
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal 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'))
|
||||
});
|
@@ -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);
|
121
src/pages/tools/image/generic/compress/index.tsx
Normal file
121
src/pages/tools/image/generic/compress/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
14
src/pages/tools/image/generic/compress/meta.ts
Normal file
14
src/pages/tools/image/generic/compress/meta.ts
Normal 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']
|
||||
});
|
30
src/pages/tools/image/generic/compress/service.ts
Normal file
30
src/pages/tools/image/generic/compress/service.ts
Normal 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;
|
||||
}
|
||||
};
|
4
src/pages/tools/image/generic/compress/types.ts
Normal file
4
src/pages/tools/image/generic/compress/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface InitialValuesType {
|
||||
maxFileSizeInMB: number;
|
||||
quality: number;
|
||||
}
|
@@ -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
|
@@ -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.'
|
||||
}}
|
||||
/>
|
||||
);
|
@@ -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'))
|
||||
});
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -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.'
|
||||
}}
|
||||
/>
|
||||
);
|
@@ -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
|
108
src/pages/tools/image/generic/image-to-text/index.tsx
Normal file
108
src/pages/tools/image/generic/image-to-text/index.tsx
Normal 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.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/pages/tools/image/generic/image-to-text/meta.ts
Normal file
22
src/pages/tools/image/generic/image-to-text/meta.ts
Normal 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'))
|
||||
});
|
56
src/pages/tools/image/generic/image-to-text/service.ts
Normal file
56
src/pages/tools/image/generic/image-to-text/service.ts
Normal 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' }
|
||||
];
|
||||
};
|
4
src/pages/tools/image/generic/image-to-text/types.ts
Normal file
4
src/pages/tools/image/generic/image-to-text/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type InitialValuesType = {
|
||||
language: string;
|
||||
detectParagraphs: boolean;
|
||||
};
|
@@ -1,3 +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];
|
||||
export const imageGenericTools = [
|
||||
resizeImage,
|
||||
compressImage,
|
||||
removeBackground,
|
||||
cropImage,
|
||||
changeOpacity,
|
||||
changeColors,
|
||||
createTransparent,
|
||||
imageToText
|
||||
];
|
||||
|
@@ -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.'
|
||||
}}
|
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal file
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal 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'))
|
||||
});
|
@@ -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));
|
||||
// });
|
||||
});
|
@@ -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'))
|
||||
});
|
@@ -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'))
|
||||
});
|
Binary file not shown.
Before Width: | Height: | Size: 3.6 KiB |
@@ -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];
|
||||
|
@@ -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'))
|
||||
});
|
222
src/pages/tools/pdf/compress-pdf/index.tsx
Normal file
222
src/pages/tools/pdf/compress-pdf/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
28
src/pages/tools/pdf/compress-pdf/meta.ts
Normal file
28
src/pages/tools/pdf/compress-pdf/meta.ts
Normal 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'))
|
||||
});
|
28
src/pages/tools/pdf/compress-pdf/service.ts
Normal file
28
src/pages/tools/pdf/compress-pdf/service.ts
Normal 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);
|
||||
}
|
5
src/pages/tools/pdf/compress-pdf/types.ts
Normal file
5
src/pages/tools/pdf/compress-pdf/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type CompressionLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export type InitialValuesType = {
|
||||
compressionLevel: CompressionLevel;
|
||||
};
|
@@ -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
|
||||
];
|
||||
|
110
src/pages/tools/pdf/protect-pdf/index.tsx
Normal file
110
src/pages/tools/pdf/protect-pdf/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
27
src/pages/tools/pdf/protect-pdf/meta.ts
Normal file
27
src/pages/tools/pdf/protect-pdf/meta.ts
Normal 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'))
|
||||
});
|
45
src/pages/tools/pdf/protect-pdf/service.ts
Normal file
45
src/pages/tools/pdf/protect-pdf/service.ts
Normal 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')
|
||||
);
|
||||
}
|
6
src/pages/tools/pdf/protect-pdf/types.ts
Normal file
6
src/pages/tools/pdf/protect-pdf/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ProtectionType = 'owner' | 'user';
|
||||
|
||||
export type InitialValuesType = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
16
src/pages/tools/pdf/utils.ts
Normal file
16
src/pages/tools/pdf/utils.ts
Normal 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();
|
||||
});
|
||||
}
|
@@ -13,17 +13,17 @@ import { IconifyIcon } from '@iconify/react';
|
||||
import { pdfTools } from '../pages/tools/pdf';
|
||||
|
||||
const toolCategoriesOrder: ToolCategory[] = [
|
||||
'png',
|
||||
'image-generic',
|
||||
'string',
|
||||
'json',
|
||||
'pdf',
|
||||
'video',
|
||||
'list',
|
||||
'csv',
|
||||
'video',
|
||||
'number',
|
||||
'gif',
|
||||
'png',
|
||||
'time',
|
||||
'image-generic'
|
||||
'gif'
|
||||
];
|
||||
export const tools: DefinedTool[] = [
|
||||
...imageTools,
|
||||
|
@@ -1,3 +1,41 @@
|
||||
/**
|
||||
* Splits a CSV line into string[], handling quoted string.
|
||||
* @param {string} input - The CSV input string.
|
||||
* @param {string} delimiter - The character used to split csvlines.
|
||||
* @param {string} quoteChar - The character used to quotes csv values.
|
||||
* @returns {string[][]} - The CSV line as a 1D array.
|
||||
*/
|
||||
function splitCsvLine(
|
||||
line: string,
|
||||
delimiter: string = ',',
|
||||
quoteChar: string = '"'
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === quoteChar) {
|
||||
if (inQuotes && nextChar === quoteChar) {
|
||||
current += quoteChar;
|
||||
i++; // Skip the escaped quote
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a CSV string into rows, skipping any blank lines.
|
||||
* @param {string} input - The CSV input string.
|
||||
@@ -8,9 +46,13 @@ export function splitCsv(
|
||||
input: string,
|
||||
deleteComment: boolean,
|
||||
commentCharacter: string,
|
||||
deleteEmptyLines: boolean
|
||||
deleteEmptyLines: boolean,
|
||||
delimiter: string = ',',
|
||||
quoteChar: string = '"'
|
||||
): string[][] {
|
||||
let rows = input.split('\n').map((row) => row.split(','));
|
||||
let rows = input
|
||||
.split('\n')
|
||||
.map((row) => splitCsvLine(row, delimiter, quoteChar));
|
||||
|
||||
// Remove comments if deleteComment is true
|
||||
if (deleteComment && commentCharacter) {
|
||||
@@ -28,9 +70,32 @@ export function splitCsv(
|
||||
/**
|
||||
* get the headers from a CSV string .
|
||||
* @param {string} input - The CSV input string.
|
||||
* @param {string} csvSeparator - The character used to separate values in the CSV.
|
||||
* @param {string} quoteChar - The character used to quotes csv values.
|
||||
* @param {string} commentCharacter - The character used to denote comments.
|
||||
* @returns {string[]} - The CSV header as a 1D array.
|
||||
*/
|
||||
export function getCsvHeaders(csvString: string): string[] {
|
||||
const rows = csvString.split('\n').map((row) => row.split(','));
|
||||
return rows.length > 0 ? rows[0].map((header) => header.trim()) : [];
|
||||
export function getCsvHeaders(
|
||||
csvString: string,
|
||||
csvSeparator: string = ',',
|
||||
quoteChar: string = '"',
|
||||
commentCharacter?: string
|
||||
): string[] {
|
||||
const lines = csvString.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (
|
||||
trimmed === '' ||
|
||||
(commentCharacter && trimmed.startsWith(commentCharacter))
|
||||
) {
|
||||
continue; // skip empty or commented lines
|
||||
}
|
||||
|
||||
const headerLine = splitCsvLine(trimmed, csvSeparator, quoteChar);
|
||||
return headerLine.map((h) => h.replace(/^\uFEFF/, '').trim());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
@@ -46,3 +46,20 @@ export function reverseString(input: string): string {
|
||||
export function containsOnlyDigits(input: string): boolean {
|
||||
return /^\d+(\.\d+)?$/.test(input.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* unquote a string if properly quoted.
|
||||
* @param value - The string to unquote.
|
||||
* @param quoteCharacter - The character used for quoting (e.g., '"', "'").
|
||||
* @returns The unquoted string if it was quoted, otherwise the original string.
|
||||
*/
|
||||
export function unquoteIfQuoted(value: string, quoteCharacter: string): string {
|
||||
if (
|
||||
quoteCharacter &&
|
||||
value.startsWith(quoteCharacter) &&
|
||||
value.endsWith(quoteCharacter)
|
||||
) {
|
||||
return value.slice(1, -1); // Remove first and last character
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
@@ -17,5 +17,6 @@ export default defineConfig({
|
||||
environment: 'happy-dom',
|
||||
setupFiles: '.vitest/setup',
|
||||
include: ['**/*.test.{ts,tsx}']
|
||||
}
|
||||
},
|
||||
worker: { format: 'es' }
|
||||
});
|
||||
|
Reference in New Issue
Block a user