From f75e6a19c4fc359dfdb30033488b6eee3764d045 Mon Sep 17 00:00:00 2001 From: achraf Date: Tue, 14 Jul 2026 15:16:52 +0200 Subject: [PATCH] Add client-side PDF conversion tools --- package.json | 3 + pnpm-lock.yaml | 366 +++++++++++++++++++++++++- src/core/plugins/plugin-registry.ts | 4 + src/tools/image-to-pdf/index.ts | 71 +++++ src/tools/image-to-pdf/run.test.ts | 47 ++++ src/tools/image-to-pdf/run.ts | 113 ++++++++ src/tools/pdf-to-docx-ocr/index.ts | 76 ++++++ src/tools/pdf-to-docx-ocr/run.test.ts | 37 +++ src/tools/pdf-to-docx-ocr/run.ts | 110 ++++++++ 9 files changed, 823 insertions(+), 4 deletions(-) create mode 100644 src/tools/image-to-pdf/index.ts create mode 100644 src/tools/image-to-pdf/run.test.ts create mode 100644 src/tools/image-to-pdf/run.ts create mode 100644 src/tools/pdf-to-docx-ocr/index.ts create mode 100644 src/tools/pdf-to-docx-ocr/run.test.ts create mode 100644 src/tools/pdf-to-docx-ocr/run.ts diff --git a/package.json b/package.json index 8c28b69..2ddf121 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e608119..bb7694f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@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 @@ -23,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 @@ -35,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) @@ -274,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: @@ -546,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: @@ -691,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} @@ -724,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'} @@ -766,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==} @@ -929,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==} @@ -951,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==} @@ -962,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'} @@ -989,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==} @@ -1027,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==} @@ -1034,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'} @@ -1131,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} @@ -1149,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==} @@ -1165,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==} @@ -1183,6 +1318,10 @@ packages: 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'} @@ -1215,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==} @@ -1254,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'} @@ -1295,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} @@ -1307,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'} @@ -1332,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'} @@ -1359,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==} @@ -1387,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==} @@ -1413,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'} @@ -1455,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 @@ -1555,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'} @@ -1572,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'} @@ -1601,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==} @@ -1615,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'} @@ -1860,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 @@ -2050,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 @@ -2227,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 @@ -2262,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 @@ -2301,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: @@ -2473,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: @@ -2505,6 +2771,8 @@ snapshots: safer-buffer: 2.1.2 optional: true + idb-keyval@6.3.0: {} + ieee754@1.2.1: optional: true @@ -2512,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 @@ -2533,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: {} @@ -2579,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 @@ -2588,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 @@ -2659,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 @@ -2673,6 +2959,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.16: {} + napi-build-utils@2.0.0: optional: true @@ -2691,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: @@ -2714,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 @@ -2751,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: {} @@ -2791,6 +3089,8 @@ snapshots: prettier@3.8.3: {} + process-nextick-args@2.0.1: {} + protobufjs@7.6.2: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -2843,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 @@ -2850,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 @@ -2874,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 @@ -2893,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 @@ -2917,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 @@ -2951,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: {} @@ -2975,6 +3313,8 @@ snapshots: tldts: 6.1.86 optional: true + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -3017,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 @@ -3027,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: @@ -3075,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 @@ -3092,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 @@ -3109,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 @@ -3119,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 diff --git a/src/core/plugins/plugin-registry.ts b/src/core/plugins/plugin-registry.ts index 1fd8323..ec310f0 100644 --- a/src/core/plugins/plugin-registry.ts +++ b/src/core/plugins/plugin-registry.ts @@ -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(plugin: PlimiPlugin): 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), diff --git a/src/tools/image-to-pdf/index.ts b/src/tools/image-to-pdf/index.ts new file mode 100644 index 0000000..3823d3d --- /dev/null +++ b/src/tools/image-to-pdf/index.ts @@ -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 = { + 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, +}; diff --git a/src/tools/image-to-pdf/run.test.ts b/src/tools/image-to-pdf/run.test.ts new file mode 100644 index 0000000..a0a9c6c --- /dev/null +++ b/src/tools/image-to-pdf/run.test.ts @@ -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); + }); +}); diff --git a/src/tools/image-to-pdf/run.ts b/src/tools/image-to-pdf/run.ts new file mode 100644 index 0000000..8424a98 --- /dev/null +++ b/src/tools/image-to-pdf/run.ts @@ -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 { + 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((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 { + 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, + }], + }; +} diff --git a/src/tools/pdf-to-docx-ocr/index.ts b/src/tools/pdf-to-docx-ocr/index.ts new file mode 100644 index 0000000..df6eaac --- /dev/null +++ b/src/tools/pdf-to-docx-ocr/index.ts @@ -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 = { + eng: "English", + fra: "French", + deu: "German", + spa: "Spanish", + ita: "Italian", + por: "Portuguese", + nld: "Dutch", +}; + +export const pdfToDocxOcrPlugin: PlimiPlugin = { + 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, +}; diff --git a/src/tools/pdf-to-docx-ocr/run.test.ts b/src/tools/pdf-to-docx-ocr/run.test.ts new file mode 100644 index 0000000..3895a32 --- /dev/null +++ b/src/tools/pdf-to-docx-ocr/run.test.ts @@ -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." + ); + }); +}); diff --git a/src/tools/pdf-to-docx-ocr/run.ts b/src/tools/pdf-to-docx-ocr/run.ts new file mode 100644 index 0000000..94f2fe6 --- /dev/null +++ b/src/tools/pdf-to-docx-ocr/run.ts @@ -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 { + 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> = []; + 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, + }], + }; +}