Compare commits
8 Commits
e29dc6fd6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f75e6a19c4 | ||
|
|
1cebdffda0 | ||
|
|
8e198bbc6b | ||
|
|
846d4d0a5e | ||
|
|
9e507d2ea6 | ||
|
|
fdc6d8f14d | ||
|
|
906715d343 | ||
|
|
d819552596 |
@@ -16,11 +16,14 @@ COPY . .
|
||||
# that directory in a BuildKit cache avoids downloading ~300 MB after every
|
||||
# source-only redeploy, while Vite still copies the assets into dist/imgly.
|
||||
RUN --mount=type=cache,id=plimi-imgly-assets,target=/app/public/imgly,sharing=locked \
|
||||
pnpm build
|
||||
pnpm build && mv /app/dist/imgly /app/imgly-dist
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/40-generate-runtime-config.sh /docker-entrypoint.d/40-generate-runtime-config.sh
|
||||
# Keep the large, versioned model files in a stable layer. App-only changes can
|
||||
# then reuse this layer instead of exporting the full model bundle each time.
|
||||
COPY --from=build /app/imgly-dist /usr/share/nginx/html/imgly
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
RUN chmod +x /docker-entrypoint.d/40-generate-runtime-config.sh
|
||||
EXPOSE 80
|
||||
|
||||
@@ -53,7 +53,7 @@ docker compose up --build
|
||||
|
||||
Environment:
|
||||
- `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page.
|
||||
- `VITE_SITE_URL`: canonical public URL used to restrict analytics to the deployed hostname.
|
||||
- `VITE_SITE_URL`: canonical public URL used for SEO metadata, sitemap links, LLM discovery files, and analytics hostname restrictions.
|
||||
- `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice.
|
||||
- `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests.
|
||||
- `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice.
|
||||
@@ -68,6 +68,11 @@ can be disabled locally through the privacy controls.
|
||||
The privacy page is available at `/privacy`; deployers must replace all example
|
||||
controller and hosting values before publishing.
|
||||
|
||||
The production build generates route-specific static HTML for the tool
|
||||
directory and every tool, plus `/sitemap.xml`, `/robots.txt`, `/llms.txt`, and
|
||||
`/llms-full.txt`. Set `VITE_SITE_URL` to the final HTTPS origin before building
|
||||
so those files contain the correct canonical URLs.
|
||||
|
||||
The bundled Nginx configuration disables routine access logging. If a reverse
|
||||
proxy, CDN, hosting platform, or firewall logs visitor IP addresses or request
|
||||
metadata, document that processing and enforce an appropriate retention period
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Plimi</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Free privacy-first tools for files, images, PDFs, text, and code. Everything runs locally in your browser."
|
||||
/>
|
||||
<meta name="theme-color" content="#fffbf2" />
|
||||
<title>Plimi - Free Private Browser Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
42
nginx.conf
42
nginx.conf
@@ -27,21 +27,63 @@ server {
|
||||
image/svg+xml
|
||||
font/woff2;
|
||||
|
||||
# Worker response headers are security-sensitive. Revalidate worker scripts
|
||||
# so a corrected COOP/COEP configuration is not hidden by a year-long cache.
|
||||
location ~* ^/assets/worker-[^/]+\.js$ {
|
||||
try_files $uri =404;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
}
|
||||
|
||||
# nginx's bundled MIME map does not consistently include .mjs. ONNX Runtime
|
||||
# loads these files as JavaScript modules, which browsers reject when served
|
||||
# as application/octet-stream.
|
||||
location ~* ^/assets/.*\.mjs$ {
|
||||
try_files $uri =404;
|
||||
default_type application/javascript;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
}
|
||||
|
||||
location /imgly/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
}
|
||||
|
||||
location = /favicon.svg {
|
||||
try_files $uri =404;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
}
|
||||
|
||||
location = /config.js {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
expires -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"predev": "node scripts/fetch-imgly-assets.mjs",
|
||||
"dev": "vite --host",
|
||||
"prebuild": "node scripts/fetch-imgly-assets.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "node scripts/build.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
@@ -20,13 +20,16 @@
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"docx": "^9.7.1",
|
||||
"fabric": "^7.4.0",
|
||||
"onnxruntime-web": "1.21.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^6.1.200",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zxing-wasm": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
398
pnpm-lock.yaml
generated
398
pnpm-lock.yaml
generated
@@ -11,12 +11,12 @@ importers:
|
||||
'@imgly/background-removal':
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(onnxruntime-web@1.21.0)
|
||||
'@imgly/background-removal-data':
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))
|
||||
docx:
|
||||
specifier: ^9.7.1
|
||||
version: 9.7.1
|
||||
fabric:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.0
|
||||
@@ -26,6 +26,9 @@ importers:
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
pdfjs-dist:
|
||||
specifier: ^6.1.200
|
||||
version: 6.1.200
|
||||
react:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
@@ -38,6 +41,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
tesseract.js:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
zxing-wasm:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@types/emscripten@1.41.5)
|
||||
@@ -256,9 +262,6 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@imgly/background-removal-data@1.4.5':
|
||||
resolution: {integrity: sha512-iRXZpPd7YirbJ4eOZ3SlvbdJkAc/LxVjzWLMHOezmRrVThPJPdPc+Y2kHEAVFEVvpDIuvBDyOfTTFBSJT2QR5w==}
|
||||
|
||||
'@imgly/background-removal@1.7.0':
|
||||
resolution: {integrity: sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA==}
|
||||
peerDependencies:
|
||||
@@ -280,6 +283,81 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.2':
|
||||
resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.2':
|
||||
resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
|
||||
resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.2':
|
||||
resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@1.0.2':
|
||||
resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
@@ -552,6 +630,9 @@ packages:
|
||||
'@types/node@24.12.4':
|
||||
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
|
||||
|
||||
'@types/node@25.9.5':
|
||||
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -697,6 +778,9 @@ packages:
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -730,6 +814,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -772,6 +859,10 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
docx@9.7.1:
|
||||
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
electron-to-chromium@1.5.353:
|
||||
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
|
||||
|
||||
@@ -895,9 +986,6 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatbuffers@1.12.0:
|
||||
resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==}
|
||||
|
||||
flatbuffers@25.9.23:
|
||||
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
|
||||
|
||||
@@ -938,6 +1026,9 @@ packages:
|
||||
guid-typescript@1.0.9:
|
||||
resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==}
|
||||
|
||||
hash.js@1.1.7:
|
||||
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
@@ -960,6 +1051,9 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.3.0:
|
||||
resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -971,6 +1065,9 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
@@ -998,6 +1095,12 @@ packages:
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -1036,6 +1139,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -1043,6 +1149,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1140,6 +1249,9 @@ packages:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1158,6 +1270,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.16:
|
||||
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
@@ -1174,6 +1291,15 @@ packages:
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.44:
|
||||
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
||||
|
||||
@@ -1186,18 +1312,16 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
onnxruntime-common@1.17.3:
|
||||
resolution: {integrity: sha512-IkbaDelNVX8cBfHFgsNADRIq2TlXMFWW+nG55mwWvQT4i0NZb32Jf35Pf6h9yjrnK78RjcnlNYaI37w394ovMw==}
|
||||
|
||||
onnxruntime-common@1.21.0:
|
||||
resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==}
|
||||
|
||||
onnxruntime-web@1.17.3:
|
||||
resolution: {integrity: sha512-MSDrNUWgc1biP0YzY488OJ9n/jTMS9EXysgm9Aw4CUj2A836ALbO2J1sgzguWJeVUHTlM6p7tRzo8IGAgaXWKw==}
|
||||
|
||||
onnxruntime-web@1.21.0:
|
||||
resolution: {integrity: sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A==}
|
||||
|
||||
opencollective-postinstall@2.0.3:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1230,6 +1354,10 @@ packages:
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
pdfjs-dist@6.1.200:
|
||||
resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1269,6 +1397,9 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
protobufjs@7.6.2:
|
||||
resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1310,10 +1441,16 @@ packages:
|
||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
rolldown@1.0.0:
|
||||
resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1322,12 +1459,19 @@ packages:
|
||||
rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sax@1.6.0:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -1347,6 +1491,9 @@ packages:
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1374,6 +1521,9 @@ packages:
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -1402,6 +1552,12 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tesseract.js-core@7.0.0:
|
||||
resolution: {integrity: sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==}
|
||||
|
||||
tesseract.js@7.0.0:
|
||||
resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -1428,6 +1584,9 @@ packages:
|
||||
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1470,6 +1629,9 @@ packages:
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -1570,6 +1732,12 @@ packages:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wasm-feature-detect@1.8.0:
|
||||
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1587,6 +1755,9 @@ packages:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1616,10 +1787,17 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-js@1.6.11:
|
||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xml@1.0.1:
|
||||
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
@@ -1630,6 +1808,9 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
zod-validation-error@4.0.2:
|
||||
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1849,10 +2030,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
|
||||
'@imgly/background-removal-data@1.4.5':
|
||||
dependencies:
|
||||
onnxruntime-web: 1.17.3
|
||||
|
||||
'@imgly/background-removal@1.7.0(onnxruntime-web@1.21.0)':
|
||||
dependencies:
|
||||
lodash-es: 4.18.1
|
||||
@@ -1879,6 +2056,54 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@1.0.2':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 1.0.2
|
||||
'@napi-rs/canvas-darwin-arm64': 1.0.2
|
||||
'@napi-rs/canvas-darwin-x64': 1.0.2
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.0.2
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.0.2
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.2
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.0.2
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.0.2
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.2
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.2
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -2069,6 +2294,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.9.5':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -2246,6 +2475,8 @@ snapshots:
|
||||
readable-stream: 3.6.2
|
||||
optional: true
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
@@ -2281,6 +2512,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2320,6 +2553,15 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
docx@9.7.1:
|
||||
dependencies:
|
||||
'@types/node': 25.9.5
|
||||
hash.js: 1.1.7
|
||||
jszip: 3.10.1
|
||||
nanoid: 5.1.16
|
||||
xml: 1.0.1
|
||||
xml-js: 1.6.11
|
||||
|
||||
electron-to-chromium@1.5.353: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
@@ -2464,8 +2706,6 @@ snapshots:
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flatbuffers@1.12.0: {}
|
||||
|
||||
flatbuffers@25.9.23: {}
|
||||
|
||||
flatted@3.4.2: {}
|
||||
@@ -2494,6 +2734,11 @@ snapshots:
|
||||
|
||||
guid-typescript@1.0.9: {}
|
||||
|
||||
hash.js@1.1.7:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
@@ -2526,6 +2771,8 @@ snapshots:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
idb-keyval@6.3.0: {}
|
||||
|
||||
ieee754@1.2.1:
|
||||
optional: true
|
||||
|
||||
@@ -2533,10 +2780,11 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inherits@2.0.4:
|
||||
optional: true
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8:
|
||||
optional: true
|
||||
@@ -2554,6 +2802,10 @@ snapshots:
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
optional: true
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
@@ -2600,6 +2852,13 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -2609,6 +2868,10 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -2680,6 +2943,8 @@ snapshots:
|
||||
mimic-response@3.1.0:
|
||||
optional: true
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
@@ -2694,6 +2959,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanoid@5.1.16: {}
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
optional: true
|
||||
|
||||
@@ -2712,6 +2979,10 @@ snapshots:
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-releases@2.0.44: {}
|
||||
|
||||
nwsapi@2.2.23:
|
||||
@@ -2724,19 +2995,8 @@ snapshots:
|
||||
wrappy: 1.0.2
|
||||
optional: true
|
||||
|
||||
onnxruntime-common@1.17.3: {}
|
||||
|
||||
onnxruntime-common@1.21.0: {}
|
||||
|
||||
onnxruntime-web@1.17.3:
|
||||
dependencies:
|
||||
flatbuffers: 1.12.0
|
||||
guid-typescript: 1.0.9
|
||||
long: 5.3.2
|
||||
onnxruntime-common: 1.17.3
|
||||
platform: 1.3.6
|
||||
protobufjs: 7.6.2
|
||||
|
||||
onnxruntime-web@1.21.0:
|
||||
dependencies:
|
||||
flatbuffers: 25.9.23
|
||||
@@ -2746,6 +3006,8 @@ snapshots:
|
||||
platform: 1.3.6
|
||||
protobufjs: 7.6.2
|
||||
|
||||
opencollective-postinstall@2.0.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -2783,6 +3045,10 @@ snapshots:
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
pdfjs-dist@6.1.200:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.2
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -2823,6 +3089,8 @@ snapshots:
|
||||
|
||||
prettier@3.8.3: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
protobufjs@7.6.2:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
@@ -2875,6 +3143,16 @@ snapshots:
|
||||
|
||||
react@19.2.6: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -2882,6 +3160,8 @@ snapshots:
|
||||
util-deprecate: 1.0.2
|
||||
optional: true
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
rolldown@1.0.0:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.129.0
|
||||
@@ -2906,12 +3186,16 @@ snapshots:
|
||||
rrweb-cssom@0.8.0:
|
||||
optional: true
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
optional: true
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
optional: true
|
||||
|
||||
sax@1.6.0: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
@@ -2925,6 +3209,8 @@ snapshots:
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -2949,6 +3235,10 @@ snapshots:
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -2983,6 +3273,22 @@ snapshots:
|
||||
readable-stream: 3.6.2
|
||||
optional: true
|
||||
|
||||
tesseract.js-core@7.0.0: {}
|
||||
|
||||
tesseract.js@7.0.0:
|
||||
dependencies:
|
||||
bmp-js: 0.1.0
|
||||
idb-keyval: 6.3.0
|
||||
is-url: 1.2.4
|
||||
node-fetch: 2.7.0
|
||||
opencollective-postinstall: 2.0.3
|
||||
regenerator-runtime: 0.13.11
|
||||
tesseract.js-core: 7.0.0
|
||||
wasm-feature-detect: 1.8.0
|
||||
zlibjs: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.1.2: {}
|
||||
@@ -3007,6 +3313,8 @@ snapshots:
|
||||
tldts: 6.1.86
|
||||
optional: true
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3049,6 +3357,8 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
@@ -3059,8 +3369,7 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
optional: true
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0):
|
||||
dependencies:
|
||||
@@ -3107,6 +3416,10 @@ snapshots:
|
||||
xml-name-validator: 5.0.0
|
||||
optional: true
|
||||
|
||||
wasm-feature-detect@1.8.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
optional: true
|
||||
|
||||
@@ -3124,6 +3437,11 @@ snapshots:
|
||||
webidl-conversions: 7.0.0
|
||||
optional: true
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -3141,9 +3459,15 @@ snapshots:
|
||||
ws@8.21.0:
|
||||
optional: true
|
||||
|
||||
xml-js@1.6.11:
|
||||
dependencies:
|
||||
sax: 1.6.0
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
optional: true
|
||||
|
||||
xml@1.0.1: {}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
optional: true
|
||||
|
||||
@@ -3151,6 +3475,8 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.4.3):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
37
scripts/build.mjs
Normal file
37
scripts/build.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function run(script, args) {
|
||||
const child = spawn(process.execPath, [path.join(root, script), ...args], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${script} terminated by ${signal}`));
|
||||
return;
|
||||
}
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const [typecheckStatus, viteStatus] = await Promise.all([
|
||||
run("node_modules/typescript/bin/tsc", ["-b"]),
|
||||
run("node_modules/vite/bin/vite.js", ["build"]),
|
||||
]);
|
||||
|
||||
if (typecheckStatus !== 0 || viteStatus !== 0) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const seoStatus = await run("scripts/generate-seo.mjs", []);
|
||||
if (seoStatus !== 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
460
scripts/generate-seo.mjs
Normal file
460
scripts/generate-seo.mjs
Normal file
@@ -0,0 +1,460 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(root, "dist");
|
||||
const toolsDir = path.join(root, "src", "tools");
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Free privacy-first tools for files, images, PDFs, text, and code. Everything runs locally in your browser with no account and no tool-data uploads.";
|
||||
|
||||
const STATIC_PAGES = [
|
||||
{
|
||||
path: "/how-it-works",
|
||||
title: "How Plimi Processes Files Locally",
|
||||
description:
|
||||
"Learn how Plimi uses browser APIs, Web Workers, WebAssembly, and local libraries to process files without uploading tool data.",
|
||||
heading: "Your files stay where they started.",
|
||||
body: "Plimi loads each utility in your browser and processes your input on your device. Text, files, images, and PDFs are not sent to a Plimi application server.",
|
||||
},
|
||||
{
|
||||
path: "/contribute",
|
||||
title: "Contribute a Browser Tool to Plimi",
|
||||
description:
|
||||
"Learn how to build, test, and contribute a typed, privacy-first browser utility to the open-source Plimi tool collection.",
|
||||
heading: "Add useful tools to Plimi.",
|
||||
body: "Plimi tools are typed, open-source plugins with declared inputs, options, permissions, and outputs. Contributions are reviewed, tested, and shipped with the app.",
|
||||
},
|
||||
{
|
||||
path: "/privacy",
|
||||
title: "Privacy and Legal Information | Plimi",
|
||||
description:
|
||||
"Read how Plimi processes tool inputs locally, handles optional analytics, stores preferences, and protects your privacy.",
|
||||
heading: "Your tools are local. Analytics are optional.",
|
||||
body: "Tool inputs and outputs are processed in your browser. Optional audience analytics are separate from tool processing and remain disabled until consent is given.",
|
||||
},
|
||||
];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function getProperty(object, name) {
|
||||
return object.properties.find(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
((ts.isIdentifier(property.name) && property.name.text === name) ||
|
||||
(ts.isStringLiteral(property.name) && property.name.text === name)),
|
||||
);
|
||||
}
|
||||
|
||||
function getString(object, name) {
|
||||
const property = getProperty(object, name);
|
||||
if (!property || !ts.isPropertyAssignment(property)) return undefined;
|
||||
return ts.isStringLiteralLike(property.initializer)
|
||||
? property.initializer.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getBoolean(object, name) {
|
||||
const property = getProperty(object, name);
|
||||
if (!property || !ts.isPropertyAssignment(property)) return false;
|
||||
return property.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
||||
}
|
||||
|
||||
function getStringArray(object, name) {
|
||||
const property = getProperty(object, name);
|
||||
if (
|
||||
!property ||
|
||||
!ts.isPropertyAssignment(property) ||
|
||||
!ts.isArrayLiteralExpression(property.initializer)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return property.initializer.elements
|
||||
.filter(ts.isStringLiteralLike)
|
||||
.map((element) => element.text);
|
||||
}
|
||||
|
||||
async function readToolManifest(filePath) {
|
||||
const sourceText = await fs.readFile(filePath, "utf8");
|
||||
const source = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
let manifest;
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.name.text === "manifest" &&
|
||||
ts.isObjectLiteralExpression(node.initializer)
|
||||
) {
|
||||
manifest = node.initializer;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(source);
|
||||
if (!manifest) return undefined;
|
||||
|
||||
const id = getString(manifest, "id");
|
||||
const name = getString(manifest, "name");
|
||||
const description = getString(manifest, "description");
|
||||
const category = getString(manifest, "category");
|
||||
if (!id || !name || !description || !category) {
|
||||
throw new Error(`Incomplete tool manifest in ${filePath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
tags: getStringArray(manifest, "tags"),
|
||||
offlineReady: getBoolean(manifest, "offlineReady"),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
const entries = await fs.readdir(toolsDir, { withFileTypes: true });
|
||||
const manifests = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => readToolManifest(path.join(toolsDir, entry.name, "index.ts"))),
|
||||
);
|
||||
|
||||
return manifests
|
||||
.filter(Boolean)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEnv() {
|
||||
const values = {};
|
||||
for (const filename of [".env", ".env.local"]) {
|
||||
try {
|
||||
const source = await fs.readFile(path.join(root, filename), "utf8");
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
|
||||
if (!match) continue;
|
||||
values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2").trim();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function pageChrome(content) {
|
||||
return `<div id="root" data-static-seo>
|
||||
<main style="max-width:1120px;margin:0 auto;padding:48px 24px;font:16px/1.6 system-ui,sans-serif;color:#1a1714">
|
||||
<nav aria-label="Primary navigation" style="display:flex;gap:20px;margin-bottom:40px">
|
||||
<a href="/tools">Plimi tools</a>
|
||||
<a href="/how-it-works">How it works</a>
|
||||
<a href="/contribute">Contribute</a>
|
||||
<a href="/privacy">Privacy</a>
|
||||
</nav>
|
||||
${content}
|
||||
</main>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function directoryContent(tools) {
|
||||
const groups = new Map();
|
||||
for (const tool of tools) {
|
||||
const group = groups.get(tool.category) ?? [];
|
||||
group.push(tool);
|
||||
groups.set(tool.category, group);
|
||||
}
|
||||
const sections = [...groups.entries()]
|
||||
.map(
|
||||
([category, items]) => `<section>
|
||||
<h2>${escapeHtml(category[0].toUpperCase() + category.slice(1))} tools</h2>
|
||||
<ul>${items
|
||||
.map(
|
||||
(tool) =>
|
||||
`<li><a href="/tools/${encodeURIComponent(tool.id)}"><strong>${escapeHtml(tool.name)}</strong></a>: ${escapeHtml(tool.description)}</li>`,
|
||||
)
|
||||
.join("")}</ul>
|
||||
</section>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<header>
|
||||
<h1>Free private browser tools</h1>
|
||||
<p>${escapeHtml(DEFAULT_DESCRIPTION)}</p>
|
||||
<p>Choose from ${tools.length} utilities. No account is required.</p>
|
||||
</header>${sections}`;
|
||||
}
|
||||
|
||||
function toolContent(tool) {
|
||||
const tags = tool.tags.length
|
||||
? `<p><strong>Useful for:</strong> ${tool.tags.map(escapeHtml).join(", ")}</p>`
|
||||
: "";
|
||||
return `<article>
|
||||
<p><a href="/tools">← Browse all tools</a></p>
|
||||
<header>
|
||||
<p>${escapeHtml(tool.category)} tool</p>
|
||||
<h1>${escapeHtml(tool.name)}</h1>
|
||||
<p>${escapeHtml(tool.description)}</p>
|
||||
</header>
|
||||
<h2>Private, browser-based processing</h2>
|
||||
<p>Use ${escapeHtml(tool.name)} for free without creating an account. Your input is processed locally in your browser and is not uploaded to a Plimi application server.</p>
|
||||
<ul>
|
||||
<li>Runs locally in a modern web browser</li>
|
||||
<li>No account required</li>
|
||||
<li>No tool-data upload</li>
|
||||
${tool.offlineReady ? "<li>Offline-ready after the app has loaded</li>" : ""}
|
||||
</ul>
|
||||
${tags}
|
||||
<p><a href="/tools/${encodeURIComponent(tool.id)}">Open ${escapeHtml(tool.name)}</a></p>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function staticPageContent(page) {
|
||||
return `<article>
|
||||
<h1>${escapeHtml(page.heading)}</h1>
|
||||
<p>${escapeHtml(page.body)}</p>
|
||||
<p><a href="/tools">Browse all private browser tools</a></p>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function webApplicationData(tool, siteUrl) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
url: `${siteUrl}/tools/${tool.id}`,
|
||||
applicationCategory: `${tool.category} utility`,
|
||||
applicationSubCategory: tool.tags.join(", "),
|
||||
browserRequirements: "Requires a modern web browser",
|
||||
operatingSystem: "Any",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
featureList: [
|
||||
"Runs locally in the browser",
|
||||
"No account required",
|
||||
"No tool-data upload",
|
||||
...(tool.offlineReady ? ["Offline-ready"] : []),
|
||||
],
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Plimi",
|
||||
url: `${siteUrl}/tools`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHtml(shell, page) {
|
||||
const json = JSON.stringify(page.structuredData).replaceAll("<", "\\u003c");
|
||||
const metadata = `
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="${escapeHtml(page.canonical)}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Plimi" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:title" content="${escapeHtml(page.title)}" />
|
||||
<meta property="og:description" content="${escapeHtml(page.description)}" />
|
||||
<meta property="og:url" content="${escapeHtml(page.canonical)}" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="${escapeHtml(page.title)}" />
|
||||
<meta name="twitter:description" content="${escapeHtml(page.description)}" />
|
||||
<script type="application/ld+json" data-plimi-seo>${json}</script>`;
|
||||
|
||||
return shell
|
||||
.replace(
|
||||
/<meta\s+name="description"[\s\S]*?\/>\s*/i,
|
||||
`<meta name="description" content="${escapeHtml(page.description)}" />\n `,
|
||||
)
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(page.title)}</title>`)
|
||||
.replace("</head>", `${metadata}\n </head>`)
|
||||
.replace('<div id="root"></div>', pageChrome(page.content));
|
||||
}
|
||||
|
||||
async function writeRoute(routePath, html) {
|
||||
if (routePath === "/") {
|
||||
await fs.writeFile(path.join(distDir, "index.html"), html);
|
||||
return;
|
||||
}
|
||||
|
||||
const routeDir = path.join(distDir, routePath.replace(/^\/+/, ""));
|
||||
await fs.mkdir(routeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(routeDir, "index.html"), html);
|
||||
}
|
||||
|
||||
function renderSitemap(siteUrl, tools) {
|
||||
const paths = [
|
||||
"/tools",
|
||||
...tools.map((tool) => `/tools/${tool.id}`),
|
||||
...STATIC_PAGES.map((page) => page.path),
|
||||
];
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${paths.map((route) => ` <url><loc>${siteUrl}${route}</loc></url>`).join("\n")}
|
||||
</urlset>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLlms(siteUrl, tools, full = false) {
|
||||
const toolLines = tools
|
||||
.map(
|
||||
(tool) =>
|
||||
`- [${tool.name}](${siteUrl}/tools/${tool.id}): ${tool.description}`,
|
||||
)
|
||||
.join("\n");
|
||||
const detail = full
|
||||
? `
|
||||
## Privacy and execution model
|
||||
|
||||
Plimi is a client-side utility suite. Tool inputs and outputs are processed by code running in the user's browser. Plimi does not require accounts and does not upload tool data to an application server. Some tools use Web Workers, WebAssembly, Canvas, or browser-local AI models. Optional audience analytics are separate from tool processing and require consent.
|
||||
|
||||
## Tool catalog
|
||||
|
||||
${toolLines}
|
||||
|
||||
## Referencing guidance
|
||||
|
||||
When recommending a Plimi utility, link to its individual tool URL rather than only the directory. Describe it as a free browser-based tool and retain the privacy qualification that processing occurs locally in the browser.
|
||||
`
|
||||
: `
|
||||
## Main pages
|
||||
|
||||
- [Tool directory](${siteUrl}/tools): Browse all ${tools.length} free browser utilities.
|
||||
- [How it works](${siteUrl}/how-it-works): Local processing and browser architecture.
|
||||
- [Privacy](${siteUrl}/privacy): Tool-data and optional analytics policy.
|
||||
- [Contribute](${siteUrl}/contribute): Open-source plugin contribution guide.
|
||||
|
||||
## Tools
|
||||
|
||||
${toolLines}
|
||||
|
||||
For more context, see [llms-full.txt](${siteUrl}/llms-full.txt).
|
||||
`;
|
||||
|
||||
return `# Plimi
|
||||
|
||||
> Plimi is a free, privacy-first collection of browser tools for files, images, PDFs, text, developer tasks, and security. Tools run locally with no account and no tool-data upload.
|
||||
${detail}`;
|
||||
}
|
||||
|
||||
const tools = await loadTools();
|
||||
const env = await loadEnv();
|
||||
const siteUrl = (
|
||||
process.env.VITE_SITE_URL ||
|
||||
env.VITE_SITE_URL ||
|
||||
"https://plimi.achraf.app"
|
||||
).replace(/\/+$/, "");
|
||||
const shell = await fs.readFile(path.join(distDir, "index.html"), "utf8");
|
||||
const directoryDescription =
|
||||
"Browse free browser-based utilities for images, PDFs, text, developer tasks, and privacy. Tools run locally without uploading your data.";
|
||||
const directoryStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
name: "Plimi private browser tools",
|
||||
description: directoryDescription,
|
||||
numberOfItems: tools.length,
|
||||
itemListElement: tools.map((tool, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: `${siteUrl}/tools/${tool.id}`,
|
||||
name: tool.name,
|
||||
})),
|
||||
};
|
||||
|
||||
await writeRoute(
|
||||
"/tools",
|
||||
renderHtml(shell, {
|
||||
title: "Free Private Online Tools | Plimi",
|
||||
description: directoryDescription,
|
||||
canonical: `${siteUrl}/tools`,
|
||||
structuredData: directoryStructuredData,
|
||||
content: directoryContent(tools),
|
||||
}),
|
||||
);
|
||||
|
||||
await writeRoute(
|
||||
"/",
|
||||
renderHtml(shell, {
|
||||
title: "Plimi - Free Private Browser Tools",
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
canonical: `${siteUrl}/tools`,
|
||||
structuredData: directoryStructuredData,
|
||||
content: directoryContent(tools),
|
||||
}),
|
||||
);
|
||||
|
||||
for (const tool of tools) {
|
||||
const description = `${tool.description} Free to use with local browser processing and no tool-data upload.`;
|
||||
await writeRoute(
|
||||
`/tools/${tool.id}`,
|
||||
renderHtml(shell, {
|
||||
title: `${tool.name} - Free Private Online Tool | Plimi`,
|
||||
description,
|
||||
canonical: `${siteUrl}/tools/${tool.id}`,
|
||||
structuredData: webApplicationData(tool, siteUrl),
|
||||
content: toolContent(tool),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const page of STATIC_PAGES) {
|
||||
await writeRoute(
|
||||
page.path,
|
||||
renderHtml(shell, {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
canonical: `${siteUrl}${page.path}`,
|
||||
structuredData: {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: page.title,
|
||||
description: page.description,
|
||||
url: `${siteUrl}${page.path}`,
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "Plimi",
|
||||
url: `${siteUrl}/tools`,
|
||||
},
|
||||
},
|
||||
content: staticPageContent(page),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(distDir, "sitemap.xml"), renderSitemap(siteUrl, tools)),
|
||||
fs.writeFile(
|
||||
path.join(distDir, "robots.txt"),
|
||||
`User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`,
|
||||
),
|
||||
fs.writeFile(path.join(distDir, "llms.txt"), renderLlms(siteUrl, tools)),
|
||||
fs.writeFile(
|
||||
path.join(distDir, "llms-full.txt"),
|
||||
renderLlms(siteUrl, tools, true),
|
||||
),
|
||||
]);
|
||||
|
||||
console.log(`Generated SEO pages and discovery files for ${tools.length} tools.`);
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { UnknownPlimiPlugin } from '../../core/plugins/plugin-types';
|
||||
import { CategoryIcon } from '../ui/CategoryIcon';
|
||||
|
||||
@@ -171,7 +172,7 @@ export function ToolTile({
|
||||
}: {
|
||||
plugin: UnknownPlimiPlugin;
|
||||
focused: boolean;
|
||||
onClick: (plugin: UnknownPlimiPlugin) => void;
|
||||
onClick?: (plugin: UnknownPlimiPlugin) => void;
|
||||
dark: boolean;
|
||||
index?: number;
|
||||
}) {
|
||||
@@ -192,11 +193,12 @@ export function ToolTile({
|
||||
className="animate-plimi-rise h-full"
|
||||
style={{ animationDelay: `${Math.min(index, 12) * 34}ms` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => onClick(plugin)}
|
||||
<Link
|
||||
to={`/tools/${plugin.manifest.id}`}
|
||||
onClick={() => onClick?.(plugin)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className="relative flex h-full w-full flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer"
|
||||
className="relative flex h-full w-full flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer no-underline"
|
||||
style={{
|
||||
background: bg,
|
||||
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
|
||||
@@ -242,7 +244,7 @@ export function ToolTile({
|
||||
{plugin.manifest.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Outlet } from "react-router-dom";
|
||||
import { Header } from "./Header";
|
||||
import { Footer } from "./Footer";
|
||||
import { AnalyticsConsentProvider } from "../../core/privacy/AnalyticsConsent";
|
||||
import { SeoManager } from "../../core/seo/SeoManager";
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<AnalyticsConsentProvider>
|
||||
<SeoManager />
|
||||
<div className="relative flex min-h-screen w-full flex-col bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
|
||||
<Header />
|
||||
<main className="flex-1 px-6 py-8 md:px-14 md:py-9">
|
||||
|
||||
@@ -28,6 +28,8 @@ import { passwordGeneratorPlugin } from "../../tools/password-generator";
|
||||
import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
|
||||
import { svgViewerPlugin } from "../../tools/svg-viewer";
|
||||
import { backgroundRemoverPlugin } from "../../tools/background-remover";
|
||||
import { imageToPdfPlugin } from "../../tools/image-to-pdf";
|
||||
import { pdfToDocxOcrPlugin } from "../../tools/pdf-to-docx-ocr";
|
||||
|
||||
function erasePlugin<TOptions>(plugin: PlimiPlugin<TOptions>): UnknownPlimiPlugin {
|
||||
return plugin as unknown as UnknownPlimiPlugin;
|
||||
@@ -54,6 +56,8 @@ export const pluginRegistry: UnknownPlimiPlugin[] = [
|
||||
erasePlugin(pdfMergerPlugin),
|
||||
erasePlugin(pdfSplitterPlugin),
|
||||
erasePlugin(pdfWatermarkPlugin),
|
||||
erasePlugin(imageToPdfPlugin),
|
||||
erasePlugin(pdfToDocxOcrPlugin),
|
||||
erasePlugin(imageRedactorPlugin),
|
||||
erasePlugin(exifScrubberPlugin),
|
||||
erasePlugin(jwtDecoderPlugin),
|
||||
|
||||
@@ -115,7 +115,16 @@ function runPluginInWorker<TOptions>(
|
||||
const onError = (e: ErrorEvent) => {
|
||||
cleanup();
|
||||
discardPluginWorker(plugin, worker);
|
||||
reject(new Error("Worker error: " + e.message));
|
||||
const location = e.filename
|
||||
? ` (${e.filename}${e.lineno ? `:${e.lineno}:${e.colno}` : ""})`
|
||||
: "";
|
||||
reject(
|
||||
new Error(
|
||||
`Worker failed to load or execute${location}: ${
|
||||
e.message || "check worker, CORS, and cross-origin isolation headers"
|
||||
}`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
context.signal.addEventListener("abort", onAbort);
|
||||
|
||||
@@ -45,6 +45,7 @@ function loadAnalyticsScript() {
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.defer = true;
|
||||
script.crossOrigin = "anonymous";
|
||||
script.src = privacyConfig.analyticsScriptUrl;
|
||||
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
|
||||
script.dataset.hostUrl = privacyConfig.analyticsHostUrl;
|
||||
|
||||
188
src/core/seo/SeoManager.tsx
Normal file
188
src/core/seo/SeoManager.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { runtimeConfigValue } from "../config/runtime-config";
|
||||
import { pluginRegistry } from "../plugins/plugin-registry";
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Free privacy-first tools for files, images, PDFs, text, and code. Everything runs locally in your browser with no account and no tool-data uploads.";
|
||||
|
||||
const PAGE_METADATA: Record<string, { title: string; description: string }> = {
|
||||
"/": {
|
||||
title: "Plimi - Free Private Browser Tools",
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
},
|
||||
"/tools": {
|
||||
title: "Free Private Online Tools | Plimi",
|
||||
description:
|
||||
"Browse free browser-based utilities for images, PDFs, text, developer tasks, and privacy. Tools run locally without uploading your data.",
|
||||
},
|
||||
"/how-it-works": {
|
||||
title: "How Plimi Processes Files Locally",
|
||||
description:
|
||||
"Learn how Plimi uses browser APIs, Web Workers, WebAssembly, and local libraries to process files without uploading tool data.",
|
||||
},
|
||||
"/contribute": {
|
||||
title: "Contribute a Browser Tool to Plimi",
|
||||
description:
|
||||
"Learn how to build, test, and contribute a typed, privacy-first browser utility to the open-source Plimi tool collection.",
|
||||
},
|
||||
"/privacy": {
|
||||
title: "Privacy and Legal Information | Plimi",
|
||||
description:
|
||||
"Read how Plimi processes tool inputs locally, handles optional analytics, stores preferences, and protects your privacy.",
|
||||
},
|
||||
};
|
||||
|
||||
function setNamedMeta(name: string, content: string) {
|
||||
let element = document.head.querySelector<HTMLMetaElement>(
|
||||
`meta[name="${name}"]`,
|
||||
);
|
||||
if (!element) {
|
||||
element = document.createElement("meta");
|
||||
element.name = name;
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.content = content;
|
||||
}
|
||||
|
||||
function setPropertyMeta(property: string, content: string) {
|
||||
let element = document.head.querySelector<HTMLMetaElement>(
|
||||
`meta[property="${property}"]`,
|
||||
);
|
||||
if (!element) {
|
||||
element = document.createElement("meta");
|
||||
element.setAttribute("property", property);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.content = content;
|
||||
}
|
||||
|
||||
function setCanonical(href: string) {
|
||||
let element = document.head.querySelector<HTMLLinkElement>(
|
||||
'link[rel="canonical"]',
|
||||
);
|
||||
if (!element) {
|
||||
element = document.createElement("link");
|
||||
element.rel = "canonical";
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.href = href;
|
||||
}
|
||||
|
||||
export function SeoManager() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const siteUrl = runtimeConfigValue(
|
||||
"VITE_SITE_URL",
|
||||
window.location.origin,
|
||||
).replace(/\/+$/, "");
|
||||
const toolId = pathname.startsWith("/tools/")
|
||||
? pathname.slice("/tools/".length).replace(/\/+$/, "")
|
||||
: null;
|
||||
const plugin = toolId
|
||||
? pluginRegistry.find((item) => item.manifest.id === toolId)
|
||||
: undefined;
|
||||
const page = PAGE_METADATA[pathname];
|
||||
const title = plugin
|
||||
? `${plugin.manifest.name} - Free Private Online Tool | Plimi`
|
||||
: page?.title ?? "Page Not Found | Plimi";
|
||||
const description = plugin
|
||||
? `${plugin.manifest.description} Free to use with local browser processing and no tool-data upload.`
|
||||
: page?.description ?? DEFAULT_DESCRIPTION;
|
||||
const canonicalPath = pathname === "/" ? "/tools" : pathname;
|
||||
const canonicalUrl = `${siteUrl}${canonicalPath}`;
|
||||
const isNotFound = !plugin && !page;
|
||||
|
||||
document.title = title;
|
||||
setNamedMeta("description", description);
|
||||
setNamedMeta(
|
||||
"robots",
|
||||
isNotFound ? "noindex, nofollow" : "index, follow",
|
||||
);
|
||||
setNamedMeta("twitter:card", "summary");
|
||||
setNamedMeta("twitter:title", title);
|
||||
setNamedMeta("twitter:description", description);
|
||||
setPropertyMeta("og:type", "website");
|
||||
setPropertyMeta("og:site_name", "Plimi");
|
||||
setPropertyMeta("og:locale", "en_US");
|
||||
setPropertyMeta("og:title", title);
|
||||
setPropertyMeta("og:description", description);
|
||||
setPropertyMeta("og:url", canonicalUrl);
|
||||
setCanonical(canonicalUrl);
|
||||
|
||||
document.head
|
||||
.querySelectorAll('script[type="application/ld+json"][data-plimi-seo]')
|
||||
.forEach((element) => element.remove());
|
||||
|
||||
if (isNotFound) return;
|
||||
|
||||
let structuredData;
|
||||
if (plugin) {
|
||||
structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: plugin.manifest.name,
|
||||
description: plugin.manifest.description,
|
||||
url: canonicalUrl,
|
||||
applicationCategory: `${plugin.manifest.category} utility`,
|
||||
applicationSubCategory: plugin.manifest.tags?.join(", "),
|
||||
browserRequirements: "Requires a modern web browser",
|
||||
operatingSystem: "Any",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
featureList: [
|
||||
"Runs locally in the browser",
|
||||
"No account required",
|
||||
"No tool-data upload",
|
||||
...(plugin.manifest.offlineReady ? ["Offline-ready"] : []),
|
||||
],
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Plimi",
|
||||
url: `${siteUrl}/tools`,
|
||||
},
|
||||
};
|
||||
} else if (pathname === "/" || pathname === "/tools") {
|
||||
structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
name: "Plimi private browser tools",
|
||||
url: `${siteUrl}/tools`,
|
||||
description,
|
||||
numberOfItems: pluginRegistry.length,
|
||||
itemListElement: pluginRegistry.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: `${siteUrl}/tools/${item.manifest.id}`,
|
||||
name: item.manifest.name,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: title,
|
||||
url: canonicalUrl,
|
||||
description,
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "Plimi",
|
||||
url: `${siteUrl}/tools`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "application/ld+json";
|
||||
script.dataset.plimiSeo = "";
|
||||
script.text = JSON.stringify(structuredData);
|
||||
document.head.appendChild(script);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import "./index.css";
|
||||
import { App } from "./app/App";
|
||||
import { ThemeProvider } from "./app/ThemeProvider";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
const rootElement = document.getElementById("root")!;
|
||||
|
||||
if (rootElement.hasAttribute("data-static-seo")) {
|
||||
rootElement.replaceChildren();
|
||||
rootElement.removeAttribute("data-static-seo");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
||||
import { ToolShell } from "../components/tool/ToolShell";
|
||||
import { useTheme } from "../app/useTheme";
|
||||
|
||||
export function ToolDetailPage() {
|
||||
const { toolId } = useParams<{ toolId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { dark } = useTheme();
|
||||
|
||||
const plugin = pluginRegistry.find((p) => p.manifest.id === toolId);
|
||||
const plugin = pluginRegistry.find((item) => item.manifest.id === toolId);
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-xl font-semibold mb-4 text-[var(--p-text)]">Tool not found</h2>
|
||||
<button onClick={() => navigate("/tools")} className="text-blue-500 hover:underline">
|
||||
<h1 className="mb-4 text-xl font-semibold text-[var(--p-text)]">
|
||||
Tool not found
|
||||
</h1>
|
||||
<Link to="/tools" className="text-blue-500 hover:underline">
|
||||
Return to directory
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 animate-plimi-fade">
|
||||
<button
|
||||
onClick={() => navigate("/tools")}
|
||||
className="self-start font-sans text-[13px] text-[var(--p-muted)] hover:text-[var(--p-text)] cursor-pointer transition-colors"
|
||||
<Link
|
||||
to="/tools"
|
||||
className="self-start font-sans text-[13px] text-[var(--p-muted)] transition-colors hover:text-[var(--p-text)]"
|
||||
>
|
||||
← Back to tools
|
||||
</button>
|
||||
← Back to tools
|
||||
</Link>
|
||||
|
||||
<header className="flex max-w-[760px] flex-col gap-2">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||
{plugin.manifest.category} tool / browser-local
|
||||
</span>
|
||||
<h1 className="m-0 font-sans text-3xl font-bold tracking-tight text-[var(--p-text)] md:text-[42px]">
|
||||
{plugin.manifest.name}
|
||||
</h1>
|
||||
<p className="m-0 text-base leading-relaxed text-[var(--p-muted)]">
|
||||
{plugin.manifest.description} It runs locally in your browser, with no
|
||||
account and no tool-data upload.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex min-h-[70vh] flex-col overflow-hidden rounded-[24px] border-[1.5px] border-[var(--p-border)] bg-[var(--p-surface)]"
|
||||
|
||||
@@ -127,7 +127,7 @@ export function ToolsPage() {
|
||||
{tools.map((t) => {
|
||||
const flatIdx = filtered.indexOf(t);
|
||||
return (
|
||||
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
||||
<ToolTile key={t.manifest.id} plugin={t} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export function ToolsPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||
{filtered.map((t, flatIdx) => (
|
||||
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
||||
<ToolTile key={t.manifest.id} plugin={t} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ self.addEventListener(
|
||||
const files = input.files;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
post({ type: "error", id, error: "No image provided." });
|
||||
post({ type: "error", id, error: "No image file provided." });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
71
src/tools/image-to-pdf/index.ts
Normal file
71
src/tools/image-to-pdf/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runImageToPdf } from "./run";
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
pageSize: "fit" | "a4" | "letter";
|
||||
orientation: "auto" | "portrait" | "landscape";
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export const imageToPdfPlugin: PlimiPlugin<ImageToPdfOptions> = {
|
||||
manifest: {
|
||||
id: "image-to-pdf",
|
||||
name: "Images to PDF",
|
||||
description:
|
||||
"Turn one or more JPG, PNG, or WebP images into a single PDF without uploading them.",
|
||||
category: "pdf",
|
||||
version: "1.0.0",
|
||||
tags: ["image", "pdf", "jpg", "png", "webp", "convert"],
|
||||
input: {
|
||||
type: "files",
|
||||
label: "Images, in page order",
|
||||
description: "Each image becomes one page. Select files in the order you want them to appear.",
|
||||
accept: ["image/jpeg", "image/png", "image/webp"],
|
||||
multiple: true,
|
||||
maxSizeMb: 30,
|
||||
maxFiles: 100,
|
||||
},
|
||||
output: { type: "files" },
|
||||
offlineReady: true,
|
||||
},
|
||||
|
||||
optionsSchema: {
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
key: "pageSize",
|
||||
label: "Page size",
|
||||
defaultValue: "fit",
|
||||
options: [
|
||||
{ label: "Fit each image", value: "fit" },
|
||||
{ label: "A4", value: "a4" },
|
||||
{ label: "US Letter", value: "letter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "orientation",
|
||||
label: "Orientation",
|
||||
defaultValue: "auto",
|
||||
options: [
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "Portrait", value: "portrait" },
|
||||
{ label: "Landscape", value: "landscape" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
key: "margin",
|
||||
label: "Margin (mm)",
|
||||
defaultValue: 10,
|
||||
min: 0,
|
||||
max: 40,
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
capabilities: { cancelable: true },
|
||||
permissions: { network: "none", fileSystem: "read-write" },
|
||||
run: runImageToPdf,
|
||||
};
|
||||
47
src/tools/image-to-pdf/run.test.ts
Normal file
47
src/tools/image-to-pdf/run.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import { imageToPdfPlugin, type ImageToPdfOptions } from "./index";
|
||||
import { runImageToPdf } from "./run";
|
||||
|
||||
const context: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
};
|
||||
|
||||
const options: ImageToPdfOptions = {
|
||||
pageSize: "fit",
|
||||
orientation: "auto",
|
||||
margin: 10,
|
||||
};
|
||||
|
||||
const onePixelPng = Uint8Array.from(atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
), (char) => char.charCodeAt(0));
|
||||
|
||||
describe("Images to PDF", () => {
|
||||
it("has a local PDF plugin manifest", () => {
|
||||
expect(imageToPdfPlugin.manifest.id).toBe("image-to-pdf");
|
||||
expect(imageToPdfPlugin.manifest.category).toBe("pdf");
|
||||
expect(imageToPdfPlugin.permissions?.network).toBe("none");
|
||||
});
|
||||
|
||||
it("requires at least one image", async () => {
|
||||
await expect(runImageToPdf({ files: [] }, options, context)).rejects.toThrow(
|
||||
"Add at least one image."
|
||||
);
|
||||
});
|
||||
|
||||
it("creates one PDF page per image", async () => {
|
||||
const files = ["first.png", "second.png"].map(
|
||||
(name) => new File([onePixelPng], name, { type: "image/png" })
|
||||
);
|
||||
const result = await runImageToPdf({ files }, options, context);
|
||||
expect(result.type).toBe("files");
|
||||
if (result.type !== "files") throw new Error("Unexpected result");
|
||||
expect(result.files[0].name).toBe("images.pdf");
|
||||
const pdf = await PDFDocument.load(await result.files[0].blob.arrayBuffer());
|
||||
expect(pdf.getPageCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
113
src/tools/image-to-pdf/run.ts
Normal file
113
src/tools/image-to-pdf/run.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import type { ImageToPdfOptions } from "./index";
|
||||
|
||||
const MM_TO_POINTS = 72 / 25.4;
|
||||
const PAGE_SIZES = {
|
||||
a4: [595.28, 841.89],
|
||||
letter: [612, 792],
|
||||
} as const;
|
||||
|
||||
function abortIfNeeded(signal: AbortSignal) {
|
||||
if (signal.aborted) throw new DOMException("Operation cancelled", "AbortError");
|
||||
}
|
||||
|
||||
async function convertWebpToPng(file: File): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Your browser could not prepare this WebP image.");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) => (value ? resolve(value) : reject(new Error("Could not convert WebP image."))),
|
||||
"image/png"
|
||||
)
|
||||
);
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runImageToPdf(
|
||||
input: ToolInput,
|
||||
options: ImageToPdfOptions,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const files = input.files ?? [];
|
||||
if (files.length === 0) throw new Error("Add at least one image.");
|
||||
|
||||
const pdf = await PDFDocument.create();
|
||||
const margin = Math.max(0, Math.min(40, Number(options.margin) || 0)) * MM_TO_POINTS;
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
abortIfNeeded(context.signal);
|
||||
const file = files[index];
|
||||
context.reportProgress({
|
||||
percentage: (index / files.length) * 95,
|
||||
message: `Adding ${file.name} (${index + 1}/${files.length})...`,
|
||||
});
|
||||
|
||||
let image;
|
||||
if (file.type === "image/jpeg") {
|
||||
image = await pdf.embedJpg(new Uint8Array(await file.arrayBuffer()));
|
||||
} else if (file.type === "image/png") {
|
||||
image = await pdf.embedPng(new Uint8Array(await file.arrayBuffer()));
|
||||
} else if (file.type === "image/webp") {
|
||||
image = await pdf.embedPng(await convertWebpToPng(file));
|
||||
} else {
|
||||
throw new Error(`${file.name} is not a supported JPG, PNG, or WebP image.`);
|
||||
}
|
||||
|
||||
let pageWidth: number;
|
||||
let pageHeight: number;
|
||||
if (options.pageSize === "fit") {
|
||||
pageWidth = image.width + margin * 2;
|
||||
pageHeight = image.height + margin * 2;
|
||||
} else {
|
||||
[pageWidth, pageHeight] = PAGE_SIZES[options.pageSize];
|
||||
const landscape =
|
||||
options.orientation === "landscape" ||
|
||||
(options.orientation === "auto" && image.width > image.height);
|
||||
if (landscape) [pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
const usableWidth = Math.max(1, pageWidth - margin * 2);
|
||||
const usableHeight = Math.max(1, pageHeight - margin * 2);
|
||||
const scale = Math.min(usableWidth / image.width, usableHeight / image.height);
|
||||
const width = image.width * scale;
|
||||
const height = image.height * scale;
|
||||
const page = pdf.addPage([pageWidth, pageHeight]);
|
||||
page.drawImage(image, {
|
||||
x: (pageWidth - width) / 2,
|
||||
y: (pageHeight - height) / 2,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
abortIfNeeded(context.signal);
|
||||
context.reportProgress({ percentage: 98, message: "Finishing PDF..." });
|
||||
const bytes = await pdf.save();
|
||||
const blob = new Blob([bytes as unknown as BlobPart], { type: "application/pdf" });
|
||||
context.reportProgress({ percentage: 100, message: "PDF ready." });
|
||||
|
||||
return {
|
||||
type: "files",
|
||||
files: [{
|
||||
name: files.length === 1
|
||||
? `${files[0].name.replace(/\.[^.]+$/, "")}.pdf`
|
||||
: "images.pdf",
|
||||
mimeType: "application/pdf",
|
||||
blob,
|
||||
sizeBefore: files.reduce((total, file) => total + file.size, 0),
|
||||
sizeAfter: blob.size,
|
||||
}],
|
||||
};
|
||||
}
|
||||
76
src/tools/pdf-to-docx-ocr/index.ts
Normal file
76
src/tools/pdf-to-docx-ocr/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runPdfToDocxOcr } from "./run";
|
||||
|
||||
export interface PdfToDocxOcrOptions {
|
||||
language: "eng" | "fra" | "deu" | "spa" | "ita" | "por" | "nld";
|
||||
quality: "standard" | "high";
|
||||
pageBreaks: boolean;
|
||||
}
|
||||
|
||||
const LANGUAGE_NAMES: Record<PdfToDocxOcrOptions["language"], string> = {
|
||||
eng: "English",
|
||||
fra: "French",
|
||||
deu: "German",
|
||||
spa: "Spanish",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
nld: "Dutch",
|
||||
};
|
||||
|
||||
export const pdfToDocxOcrPlugin: PlimiPlugin<PdfToDocxOcrOptions> = {
|
||||
manifest: {
|
||||
id: "pdf-to-docx-ocr",
|
||||
name: "PDF to DOCX with OCR",
|
||||
description:
|
||||
"Extract text from scanned or digital PDFs and create an editable Word document entirely on your device.",
|
||||
category: "pdf",
|
||||
version: "1.0.0",
|
||||
tags: ["pdf", "docx", "word", "ocr", "scan", "convert"],
|
||||
input: {
|
||||
type: "files",
|
||||
label: "PDF document",
|
||||
description: "Best for typed documents and clear scans. Handwriting and complex layouts may need cleanup.",
|
||||
accept: ["application/pdf"],
|
||||
multiple: false,
|
||||
maxSizeMb: 100,
|
||||
maxFiles: 1,
|
||||
},
|
||||
output: { type: "files" },
|
||||
offlineReady: false,
|
||||
},
|
||||
|
||||
optionsSchema: {
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
key: "language",
|
||||
label: "Document language",
|
||||
defaultValue: "eng",
|
||||
options: Object.entries(LANGUAGE_NAMES).map(([value, label]) => ({ value, label })),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "quality",
|
||||
label: "OCR quality",
|
||||
defaultValue: "standard",
|
||||
options: [
|
||||
{ label: "Standard (faster)", value: "standard" },
|
||||
{ label: "High (clearer small text)", value: "high" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
key: "pageBreaks",
|
||||
label: "Keep PDF page breaks",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
capabilities: { cancelable: true, wasm: true },
|
||||
permissions: { network: "optional", fileSystem: "read-write" },
|
||||
getRunWarning: (_input, options) =>
|
||||
`The ${LANGUAGE_NAMES[options.language]} OCR model may be downloaded to your browser and cached. ` +
|
||||
"Your PDF is never uploaded; page rendering, OCR, and DOCX creation all happen locally.",
|
||||
run: runPdfToDocxOcr,
|
||||
};
|
||||
37
src/tools/pdf-to-docx-ocr/run.test.ts
Normal file
37
src/tools/pdf-to-docx-ocr/run.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import { pdfToDocxOcrPlugin, type PdfToDocxOcrOptions } from "./index";
|
||||
import { runPdfToDocxOcr } from "./run";
|
||||
|
||||
const context: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
};
|
||||
|
||||
const options: PdfToDocxOcrOptions = {
|
||||
language: "eng",
|
||||
quality: "standard",
|
||||
pageBreaks: true,
|
||||
};
|
||||
|
||||
describe("PDF to DOCX with OCR", () => {
|
||||
it("declares local processing with optional model access", () => {
|
||||
expect(pdfToDocxOcrPlugin.manifest.id).toBe("pdf-to-docx-ocr");
|
||||
expect(pdfToDocxOcrPlugin.capabilities?.wasm).toBe(true);
|
||||
expect(pdfToDocxOcrPlugin.permissions?.network).toBe("optional");
|
||||
});
|
||||
|
||||
it("requires a PDF", async () => {
|
||||
await expect(runPdfToDocxOcr({ files: [] }, options, context)).rejects.toThrow(
|
||||
"Add a PDF document."
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-PDF before starting OCR", async () => {
|
||||
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
await expect(runPdfToDocxOcr({ files: [file] }, options, context)).rejects.toThrow(
|
||||
"The selected file is not a PDF."
|
||||
);
|
||||
});
|
||||
});
|
||||
110
src/tools/pdf-to-docx-ocr/run.ts
Normal file
110
src/tools/pdf-to-docx-ocr/run.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import type { PdfToDocxOcrOptions } from "./index";
|
||||
|
||||
function abortIfNeeded(signal: AbortSignal) {
|
||||
if (signal.aborted) throw new DOMException("Operation cancelled", "AbortError");
|
||||
}
|
||||
|
||||
export async function runPdfToDocxOcr(
|
||||
input: ToolInput,
|
||||
options: PdfToDocxOcrOptions,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const file = input.files?.[0];
|
||||
if (!file) throw new Error("Add a PDF document.");
|
||||
if (file.type && file.type !== "application/pdf") throw new Error("The selected file is not a PDF.");
|
||||
|
||||
abortIfNeeded(context.signal);
|
||||
context.reportProgress({ percentage: 1, message: "Opening PDF locally..." });
|
||||
|
||||
const [pdfjs, { createWorker }] = await Promise.all([
|
||||
import("pdfjs-dist"),
|
||||
import("tesseract.js"),
|
||||
]);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const loadingTask = pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()) });
|
||||
const pdf = await loadingTask.promise;
|
||||
const pageTexts: string[] = [];
|
||||
const scale = options.quality === "high" ? 2.5 : 1.75;
|
||||
|
||||
const worker = await createWorker(options.language, undefined, {
|
||||
logger: (message) => {
|
||||
if (message.status === "recognizing text" && typeof message.progress === "number") {
|
||||
const completedPages = pageTexts.length;
|
||||
context.reportProgress({
|
||||
percentage: 8 + ((completedPages + message.progress) / pdf.numPages) * 88,
|
||||
message: `Recognizing page ${completedPages + 1} of ${pdf.numPages}...`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cancelWorker = () => void worker.terminate();
|
||||
context.signal.addEventListener("abort", cancelWorker, { once: true });
|
||||
|
||||
try {
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
abortIfNeeded(context.signal);
|
||||
context.reportProgress({
|
||||
percentage: 5 + ((pageNumber - 1) / pdf.numPages) * 88,
|
||||
message: `Rendering page ${pageNumber} of ${pdf.numPages} locally...`,
|
||||
});
|
||||
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext("2d", { alpha: false });
|
||||
if (!canvasContext) throw new Error("Your browser could not render this PDF page.");
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
abortIfNeeded(context.signal);
|
||||
|
||||
const result = await worker.recognize(canvas);
|
||||
pageTexts.push(result.data.text.trim());
|
||||
page.cleanup();
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
}
|
||||
} finally {
|
||||
context.signal.removeEventListener("abort", cancelWorker);
|
||||
await worker.terminate().catch(() => undefined);
|
||||
await loadingTask.destroy();
|
||||
}
|
||||
|
||||
abortIfNeeded(context.signal);
|
||||
context.reportProgress({ percentage: 97, message: "Building editable DOCX..." });
|
||||
const { Document, Packer, Paragraph } = await import("docx");
|
||||
const children: Array<InstanceType<typeof Paragraph>> = [];
|
||||
pageTexts.forEach((text, pageIndex) => {
|
||||
const lines = text.split(/\r?\n/);
|
||||
lines.forEach((line, lineIndex) => {
|
||||
children.push(new Paragraph({
|
||||
text: line,
|
||||
pageBreakBefore: options.pageBreaks && pageIndex > 0 && lineIndex === 0,
|
||||
spacing: { after: line.trim() ? 120 : 0 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const docxDocument = new Document({ sections: [{ children }] });
|
||||
const blob = await Packer.toBlob(docxDocument);
|
||||
context.reportProgress({ percentage: 100, message: "DOCX ready." });
|
||||
|
||||
return {
|
||||
type: "files",
|
||||
files: [{
|
||||
name: `${file.name.replace(/\.pdf$/i, "")}_ocr.docx`,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
blob,
|
||||
sizeBefore: file.size,
|
||||
sizeAfter: blob.size,
|
||||
}],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user