diff --git a/plugins/pdf-process/.github/workflows/pr-check.yml b/plugins/pdf-process/.github/workflows/pr-check.yml new file mode 100644 index 000000000..387a1f042 --- /dev/null +++ b/plugins/pdf-process/.github/workflows/pr-check.yml @@ -0,0 +1,28 @@ +name: PR Check + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Install root dependencies + run: npm ci + + - name: Test and build + run: npm run ci diff --git a/plugins/pdf-process/.gitignore b/plugins/pdf-process/.gitignore index d81bfee8b..8d817dafe 100644 --- a/plugins/pdf-process/.gitignore +++ b/plugins/pdf-process/.gitignore @@ -33,11 +33,3 @@ dist-ssr # AI agent workspace artifacts .qwen/ - -# Python conversion core (convert.py) build artifacts -public/preload/build/ -public/preload/__pycache__/ -public/preload/*.spec -.pytest_cache/ -# convert.exe is a large PyInstaller output (~380MB); rebuild via `npm run build:convert` -public/bin/convert.exe diff --git a/plugins/pdf-process/MicrosoftYaHei_BMP.pdf b/plugins/pdf-process/MicrosoftYaHei_BMP.pdf deleted file mode 100644 index 560f2bd99..000000000 Binary files a/plugins/pdf-process/MicrosoftYaHei_BMP.pdf and /dev/null differ diff --git a/plugins/pdf-process/README.md b/plugins/pdf-process/README.md index 5b5eceafc..9e7ae999d 100644 --- a/plugins/pdf-process/README.md +++ b/plugins/pdf-process/README.md @@ -2,43 +2,50 @@ > ZTools PDF 处理插件 — 压缩、合并、拆分、水印、图片提取、格式转换 -使用 **React + Vite + TypeScript** 构建的 ZTools 插件。 +使用 **React + Vite + TypeScript** 构建的 ZTools 插件,preload 依赖安装与产物校验已接入 npm 脚本,可直接进入 ZTools 中心仓库的 PR 构建流程。 ## 功能 | 功能 | 说明 | |------|------| -| **基本压缩** | `pdfcpu optimize`,保留可选文字 | -| **强压缩** | 浏览器 pdf.js + DOM canvas 按 72–150 DPI 栅格化为 JPEG,preload 仅合成 PDF(避开 Electron 下 napi canvas 字体错误);低质量自动灰度 | +| **基本压缩** | `pdf-lib` 重写 PDF 对象流,结果不小于原文件时保留原文件 | +| **强压缩** | 浏览器 pdf.js + DOM canvas 按 72–150 DPI 栅格化为 JPEG 后合成 PDF;低质量自动灰度 | | **合并** | 多 PDF 按序合并 | -| **拆分 / 提取** | **提取指定页**(如 `15-20` → 单个 PDF)或 **完整拆分**(剪刀 / 每隔 N 页) | -| **水印** | 文字水印 | +| **拆分 / 提取** | 提取指定页、剪刀切点、每隔 N 页拆分 | +| **水印** | 纯 JS `pdf-lib + fontkit` 文字水印 | | **转图片** | 浏览器端 pdf.js 渲染导出 PNG/JPG | -| **转 Word / PPT / Excel** | 本地 Node 转换(文本抽取 / 扫描页图);设置中可配置推荐网站(仅 https) | +| **转 Word / PPT / Excel** | 文本型本地转换;扫描型由渲染器生成页面图后写 DOCX/PPTX,Excel 保留残余文本 | ## 快速开始 ```bash npm install -cd public/preload && npm install && cd ../.. npm run dev ``` +`postinstall` / `prebuild` 会自动按锁文件安装 `public/preload` 依赖,无需手动进入子目录。 + ### 构建 ```bash npm run build ``` -产物在 `dist/`。请确保 `public/preload/node_modules` 已安装(含 `@napi-rs/canvas`、`docx` 等)后再构建/打包。 +构建后 `dist/` 即为 ZTools 打包源,构建脚本会: + +- 用 esbuild 合并 preload 业务代码; +- 只复制 PDF.js 运行资源,不携带任何 EXE / 原生 Canvas 模块; +- 校验 `plugin.json`、版本、入口和平台; +- 按中心仓库 `archiver(level 9)` 同款算法生成临时 ZIP,并生成 ASAR 做 15 MB 双重门禁。 -### 测试 +### 测试与 PR 自检 ```bash -npm test # 前端 / jsdom -npm run test:convert # preload(path-guard、convert、pdfcpu 静态检查等) +npm run ci ``` +等价于中心 PR 流程的测试与构建:前端测试 + preload 测试 + 生产构建 + 15 MB 包体积校验。 + ## 项目结构 ``` @@ -50,13 +57,15 @@ npm run test:convert # preload(path-guard、convert、pdfcpu 静态检查等 │ ├── services.js # window.services 门面 │ ├── path-guard.js # 路径 / https 白名单 │ ├── lib/ # 深模块实现 -│ │ ├── pdfcpu-runner.js -│ │ ├── strong-compress.js +│ │ ├── pdf-operations.js │ │ ├── create-pdf-from-images.js │ │ ├── settings-store.js │ │ ├── task-paths.js │ │ └── watermark-layout.js │ └── convert/ # 本地 PDF→Office +├── scripts/ +│ ├── install-preload-deps.cjs # 幂等安装 preload 依赖 +│ └── optimize-package.cjs # 产物精简 + ZIP/ASAR 体积门禁 ├── src/ │ ├── Compress/ Split/ Merge/ Watermark/ … │ ├── components/PdfConvertPage.tsx # Word/PPT/Excel 共用 @@ -64,4 +73,3 @@ npm run test:convert # preload(path-guard、convert、pdfcpu 静态检查等 ├── package.json └── README.md ``` - diff --git a/plugins/pdf-process/example.png b/plugins/pdf-process/example.png deleted file mode 100644 index 8cb8799b6..000000000 Binary files a/plugins/pdf-process/example.png and /dev/null differ diff --git a/plugins/pdf-process/package-lock.json b/plugins/pdf-process/package-lock.json index c688d6d82..1ed2382f4 100644 --- a/plugins/pdf-process/package-lock.json +++ b/plugins/pdf-process/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-process", - "version": "1.0.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-process", - "version": "1.0.1", + "version": "1.2.0", "hasInstallScript": true, "dependencies": { "jpeg-js": "^0.4.4", @@ -16,12 +16,15 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@electron/asar": "^3.2.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.1", "@ztools-center/ztools-api-types": "^1.0.1", + "archiver": "^7.0.1", + "esbuild": "^0.25.12", "jsdom": "^29.1.1", "typescript": "^5.3.0", "vite": "^6.0.11", @@ -531,6 +534,24 @@ "node": ">=20.19.0" } }, + "node_modules/@electron/asar": { + "version": "3.2.18", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.18.tgz", + "integrity": "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -991,6 +1012,24 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1324,6 +1363,17 @@ "pako": "^1.0.10" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2042,13 +2092,25 @@ "dev": true, "license": "MIT" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2067,6 +2129,92 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2087,6 +2235,141 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -2110,6 +2393,17 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -2144,6 +2438,41 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001803", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", @@ -2175,6 +2504,60 @@ "node": ">=18" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2182,6 +2565,55 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2267,6 +2699,13 @@ "license": "MIT", "peer": true }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -2274,6 +2713,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2356,6 +2802,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2366,6 +2842,13 @@ "node": ">=12.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2384,15 +2867,39 @@ } } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ "darwin" ], "engines": { @@ -2409,6 +2916,35 @@ "node": ">=6.9.0" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2422,6 +2958,27 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2432,6 +2989,35 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2439,6 +3025,49 @@ "dev": true, "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", @@ -2529,6 +3158,59 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2577,6 +3259,29 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2613,6 +3318,16 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -2627,6 +3342,23 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -2646,6 +3378,50 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2742,6 +3518,23 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2791,6 +3584,56 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -2860,6 +3703,27 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2889,6 +3753,29 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2896,6 +3783,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2920,6 +3820,125 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -2940,6 +3959,39 @@ "dev": true, "license": "MIT" }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3091,6 +4143,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3304,6 +4363,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3321,6 +4396,114 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -3344,6 +4527,21 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/plugins/pdf-process/package.json b/plugins/pdf-process/package.json index 88e9e8caa..2f97aa405 100644 --- a/plugins/pdf-process/package.json +++ b/plugins/pdf-process/package.json @@ -1,13 +1,17 @@ { "name": "pdf-process", - "version": "1.0.1", + "version": "1.2.0", "description": "", "type": "module", + "engines": { + "node": ">=20" + }, "scripts": { "postinstall": "node scripts/install-preload-deps.cjs", "prebuild": "node scripts/install-preload-deps.cjs", "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/optimize-package.cjs", + "ci": "npm test && npm run test:convert && npm run build", "test": "vitest run", "test:watch": "vitest", "test:convert": "vitest run --config vitest.preload.config.js" @@ -20,12 +24,15 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@electron/asar": "^3.2.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.1", "@ztools-center/ztools-api-types": "^1.0.1", + "archiver": "^7.0.1", + "esbuild": "^0.25.12", "jsdom": "^29.1.1", "typescript": "^5.3.0", "vite": "^6.0.11", diff --git a/plugins/pdf-process/public/bin/7za.exe b/plugins/pdf-process/public/bin/7za.exe deleted file mode 100644 index 7f6bf86bc..000000000 Binary files a/plugins/pdf-process/public/bin/7za.exe and /dev/null differ diff --git a/plugins/pdf-process/public/bin/pdfcpu.exe b/plugins/pdf-process/public/bin/pdfcpu.exe deleted file mode 100644 index 6443e4168..000000000 Binary files a/plugins/pdf-process/public/bin/pdfcpu.exe and /dev/null differ diff --git a/plugins/pdf-process/public/plugin.json b/plugins/pdf-process/public/plugin.json index 76a5f1c89..d3dcfec30 100644 --- a/plugins/pdf-process/public/plugin.json +++ b/plugins/pdf-process/public/plugin.json @@ -5,11 +5,10 @@ "description": "PDF 压缩、合并、拆分、水印、格式转换等工具", "author": "Taitres", "homepage": "https://github.com/Taitres/pdf-process", - "version": "1.0.1", + "version": "1.2.0", "main": "index.html", "preload": "preload/services.js", "logo": "logo.png", - "unpack": "*.{exe,dll,node}", "development": { "main": "http://localhost:5173" }, diff --git a/plugins/pdf-process/public/preload/convert.py b/plugins/pdf-process/public/preload/convert.py deleted file mode 100644 index 7688ec5dc..000000000 --- a/plugins/pdf-process/public/preload/convert.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -""" -Standalone PDF conversion core for the PDF Process plugin. - -Converts a PDF into Word / Excel / PowerPoint. Designed to be packaged into a -single Windows executable (via PyInstaller) so end users need no Python runtime. - -Usage: - convert - -Conversion strategy: - word -> pdf2docx (layout-preserving PDF -> DOCX) - excel -> pdf2docx table extraction -> openpyxl (falls back to page text) - ppt -> PyMuPDF renders each page to an image -> python-pptx (one per slide) -""" -import io -import os -import sys - -SUPPORTED = ("word", "excel", "ppt") - -# 1 point == 12700 EMU (English Metric Units used by Office Open XML). -EMU_PER_POINT = 12700 -# Render DPI for PDF -> PPT page images. -PPT_RENDER_DPI = 150 - - -def convert_word(input_pdf, output_path): - """PDF -> DOCX using pdf2docx (preserves text, images and tables).""" - from pdf2docx import Converter - - cv = Converter(input_pdf) - try: - cv.convert(output_path) - finally: - cv.close() - return output_path - - -def _extract_tables(input_pdf): - """Returns a list of tables (each a list of rows) detected by pdf2docx.""" - from pdf2docx import Converter - - cv = Converter(input_pdf) - try: - return cv.extract_tables() or [] - finally: - cv.close() - - -def convert_excel(input_pdf, output_path): - """PDF -> XLSX. Uses detected tables; falls back to per-page text lines.""" - import openpyxl - - wb = openpyxl.Workbook() - wb.remove(wb.active) - - tables = _extract_tables(input_pdf) - if tables: - for idx, table in enumerate(tables, start=1): - ws = wb.create_sheet(title=("Table %d" % idx)[:31]) - for row in table: - ws.append(["" if cell is None else str(cell) for cell in row]) - else: - import fitz - - doc = fitz.open(input_pdf) - try: - for i, page in enumerate(doc, start=1): - ws = wb.create_sheet(title=("Page %d" % i)[:31]) - for line in page.get_text().splitlines(): - ws.append([line]) - finally: - doc.close() - - if not wb.sheetnames: - wb.create_sheet(title="Sheet1") - wb.save(output_path) - return output_path - - -def convert_ppt(input_pdf, output_path, dpi=PPT_RENDER_DPI): - """PDF -> PPTX. Each page is rendered to an image placed on its own slide.""" - import fitz - from pptx import Presentation - from pptx.util import Emu - - doc = fitz.open(input_pdf) - try: - prs = Presentation() - blank_layout = prs.slide_layouts[6] # blank - zoom = dpi / 72.0 - matrix = fitz.Matrix(zoom, zoom) - - for page_index, page in enumerate(doc): - if page_index == 0: - prs.slide_width = Emu(int(page.rect.width * EMU_PER_POINT)) - prs.slide_height = Emu(int(page.rect.height * EMU_PER_POINT)) - pixmap = page.get_pixmap(matrix=matrix) - image = io.BytesIO(pixmap.tobytes("png")) - slide = prs.slides.add_slide(blank_layout) - slide.shapes.add_picture( - image, 0, 0, width=prs.slide_width, height=prs.slide_height - ) - - prs.save(output_path) - finally: - doc.close() - return output_path - - -_DISPATCH = { - "word": convert_word, - "excel": convert_excel, - "ppt": convert_ppt, -} - - -def convert(fmt, input_pdf, output_path): - """Dispatch a conversion by format name. - - Raises ValueError for an unsupported format and FileNotFoundError when the - input PDF does not exist. - """ - if fmt not in SUPPORTED: - raise ValueError("Unsupported format: %s (expected one of %s)" % (fmt, ", ".join(SUPPORTED))) - if not os.path.isfile(input_pdf): - raise FileNotFoundError(input_pdf) - out_dir = os.path.dirname(os.path.abspath(output_path)) - if out_dir: - os.makedirs(out_dir, exist_ok=True) - return _DISPATCH[fmt](input_pdf, output_path) - - -def main(argv=None): - """CLI entry point. Returns a process exit code (0 == success).""" - argv = list(sys.argv[1:] if argv is None else argv) - if len(argv) != 3: - sys.stderr.write("usage: convert \n") - return 2 - fmt, input_pdf, output_path = argv - try: - convert(fmt, input_pdf, output_path) - except Exception as exc: # noqa: BLE001 - surface any failure to the caller - sys.stderr.write("ERROR: %s\n" % exc) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/compress-local.test.js b/plugins/pdf-process/public/preload/convert/__tests__/compress-local.test.js new file mode 100644 index 000000000..850ff5e08 --- /dev/null +++ b/plugins/pdf-process/public/preload/convert/__tests__/compress-local.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const services = path.resolve(here, '../../services.js') + +describe('compressPdf uses the local JavaScript implementation', () => { + it('does not invoke an external compressor', () => { + const src = fs.readFileSync(services, 'utf8') + expect(src).not.toMatch(/PRESSE_PATH/) + expect(src).not.toMatch(/callPresse/) + const method = src.match(/async compressPdf[\s\S]*?async mergePdfs/) + expect(method).toBeTruthy() + expect(method[0]).toMatch(/pdfOperations\.optimizePdf/) + expect(method[0]).not.toMatch(/spawn\(/) + }) +}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/compress-pdfcpu.test.js b/plugins/pdf-process/public/preload/convert/__tests__/compress-pdfcpu.test.js deleted file mode 100644 index 3de3914e2..000000000 --- a/plugins/pdf-process/public/preload/convert/__tests__/compress-pdfcpu.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const services = path.resolve(here, '../../services.js') - -describe('compressPdf uses pdfcpu', () => { - it('services.js uses pdfcpu optimize and does not call presse', () => { - const src = fs.readFileSync(services, 'utf8') - expect(src).not.toMatch(/PRESSE_PATH/) - expect(src).not.toMatch(/callPresse/) - const m = src.match(/async compressPdf[\s\S]*?async mergePdfs/) - expect(m).toBeTruthy() - expect(m[0]).toMatch(/callPdfcpu\(\['optimize'/) - expect(m[0]).not.toMatch(/presse/i) - }) -}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/convert-local.test.js b/plugins/pdf-process/public/preload/convert/__tests__/convert-local.test.js index 81f20b7be..e2d7b5c43 100644 --- a/plugins/pdf-process/public/preload/convert/__tests__/convert-local.test.js +++ b/plugins/pdf-process/public/preload/convert/__tests__/convert-local.test.js @@ -4,6 +4,7 @@ const os = require('node:os') const path = require('node:path') const { convertPdfLocal, + convertPdfImages, textToDocument, textToExcelDocument, } = require('../convert-local.js') @@ -40,22 +41,34 @@ describe('convertPdfLocal', () => { fs.unlinkSync(out) }, 30000) - it('uses page images when text is sparse (mocked render)', async () => { + it('requests renderer images when text is sparse', async () => { + await expect( + convertPdfLocal({ + inputPath: 'in.pdf', + outputPath: 'out.docx', + format: 'word', + extractPdfText: async () => ({ totalChars: 2, pages: [{ page: 1, text: 'ab' }] }), + }), + ).rejects.toMatchObject({ code: 'SCAN_RENDER_REQUIRED' }) + }) + + it('writes page images supplied by the renderer', async () => { const out = path.join(os.tmpdir(), 'local-img-' + Date.now() + '.docx') - // minimal valid-ish PNG 1x1 const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ) - await convertPdfLocal({ - inputPath: 'in.pdf', + const pagePath = path.join(os.tmpdir(), 'scan-' + Date.now() + '.png') + fs.writeFileSync(pagePath, png) + await convertPdfImages({ + pages: [{ path: pagePath, width: 100, height: 100 }], outputPath: out, format: 'word', - extractPdfText: async () => ({ totalChars: 2, pages: [{ page: 1, text: 'ab' }] }), - renderPdfPages: async () => [{ page: 1, png, width: 100, height: 100 }], }) const buf = fs.readFileSync(out) expect(buf[0]).toBe(0x50) + expect(buf[1]).toBe(0x4b) fs.unlinkSync(out) + fs.unlinkSync(pagePath) }) }) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/pdf-operations.test.js b/plugins/pdf-process/public/preload/convert/__tests__/pdf-operations.test.js new file mode 100644 index 000000000..f56451a53 --- /dev/null +++ b/plugins/pdf-process/public/preload/convert/__tests__/pdf-operations.test.js @@ -0,0 +1,102 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createRequire } from 'node:module' +import { PDFDocument } from 'pdf-lib' + +const require = createRequire(import.meta.url) +const operations = require('../../lib/pdf-operations.js') + +let tempDir + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-operations-')) +}) + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) +}) + +async function createPdf(name, pageCount) { + const pdf = await PDFDocument.create() + for (let page = 1; page <= pageCount; page += 1) { + const outputPage = pdf.addPage([300, 400]) + outputPage.drawText(`page ${page}`) + } + const filePath = path.join(tempDir, name) + fs.writeFileSync(filePath, await pdf.save({ useObjectStreams: false })) + return filePath +} + +async function pageCount(filePath) { + const pdf = await PDFDocument.load(fs.readFileSync(filePath)) + return pdf.getPageCount() +} + +describe('pdf-operations', () => { + it('optimizes without making the file larger', async () => { + const input = await createPdf('input.pdf', 3) + const output = path.join(tempDir, 'optimized.pdf') + + await operations.optimizePdf(input, output) + + expect(await pageCount(output)).toBe(3) + expect(fs.statSync(output).size).toBeLessThanOrEqual(fs.statSync(input).size) + }) + + it('merges every source page in order', async () => { + const first = await createPdf('first.pdf', 2) + const second = await createPdf('second.pdf', 3) + const output = path.join(tempDir, 'merged.pdf') + + await operations.mergePdfs([first, second], output) + + expect(await pageCount(output)).toBe(5) + }) + + it('splits by span and explicit boundaries', async () => { + const input = await createPdf('source.pdf', 5) + const spanDir = path.join(tempDir, 'span') + const boundaryDir = path.join(tempDir, 'boundary') + fs.mkdirSync(spanDir) + fs.mkdirSync(boundaryDir) + + const spanOutputs = await operations.splitPdf(input, spanDir, { span: 2 }) + const boundaryOutputs = await operations.splitPdf(input, boundaryDir, { beforePages: [3, 5] }) + + expect(await Promise.all(spanOutputs.map(pageCount))).toEqual([2, 2, 1]) + expect(await Promise.all(boundaryOutputs.map(pageCount))).toEqual([2, 2, 1]) + }) + + it('extracts ranges into one or multiple files', async () => { + const input = await createPdf('source.pdf', 6) + const mergedDir = path.join(tempDir, 'merged-ranges') + const separateDir = path.join(tempDir, 'separate-ranges') + fs.mkdirSync(mergedDir) + fs.mkdirSync(separateDir) + + const merged = await operations.splitPdf(input, mergedDir, { + pageRanges: [[1, 2], [5, 6]], + mergeRanges: true, + }) + const separate = await operations.splitPdf(input, separateDir, { + pageRanges: [[1, 2], [5, 6]], + mergeRanges: false, + }) + + expect(merged).toHaveLength(1) + expect(await pageCount(merged[0])).toBe(4) + expect(await Promise.all(separate.map(pageCount))).toEqual([2, 2]) + }) + + it('extracts a string page specification as individual pages', async () => { + const input = await createPdf('source.pdf', 5) + const outputDir = path.join(tempDir, 'pages') + fs.mkdirSync(outputDir) + + const outputs = await operations.splitPdf(input, outputDir, '1,3-4') + + expect(outputs).toHaveLength(3) + expect(await Promise.all(outputs.map(pageCount))).toEqual([1, 1, 1]) + }) +}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/pdfcpu-asar-path.test.js b/plugins/pdf-process/public/preload/convert/__tests__/pdfcpu-asar-path.test.js deleted file mode 100644 index f7915f142..000000000 --- a/plugins/pdf-process/public/preload/convert/__tests__/pdfcpu-asar-path.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const services = path.resolve(here, '../../services.js') -const runner = path.resolve(here, '../../lib/pdfcpu-runner.js') - -describe('pdfcpu path resolution for asar', () => { - it('pdfcpu-runner rewrites .asar to .asar.unpacked; services wires it', () => { - const impl = fs.readFileSync(runner, 'utf8') - const facade = fs.readFileSync(services, 'utf8') - expect(impl).toMatch(/function isInsideAsar/) - expect(impl).toMatch(/function resolveNativePath/) - expect(impl).toMatch(/function getPdfcpuPath/) - expect(impl).toMatch(/\.asar\.unpacked/) - expect(impl).toMatch(/pdf-process-bin/) - expect(impl).toMatch(/spawn\(exe,/) - // facade re-exports / uses runner - expect(facade).toMatch(/function resolveNativePath/) - expect(facade).toMatch(/function getPdfcpuPath/) - expect(facade).toMatch(/pdfcpu-runner/) - }) - - it('isInsideAsar logic: path with .asar\\ is detected', () => { - function isInsideAsar(filePath) { - if (/\.asar\.unpacked([\\/]|$)/.test(filePath)) return false - return filePath.includes('.asar' + path.sep) || /\.asar[\\/]/.test(filePath) - } - expect(isInsideAsar('C:\\x\\app.asar\\bin\\pdfcpu.exe')).toBe(true) - expect(isInsideAsar('C:\\x\\app.asar.unpacked\\bin\\pdfcpu.exe')).toBe(false) - expect(isInsideAsar('C:\\x\\bin\\pdfcpu.exe')).toBe(false) - const primary = 'C:\\Users\\u\\.ztools\\plugins\\pdf-process-1.0.0.asar\\bin\\pdfcpu.exe' - const unpacked = primary.replace(/\.asar([\\/])/, '.asar.unpacked$1') - expect(unpacked).toBe( - 'C:\\Users\\u\\.ztools\\plugins\\pdf-process-1.0.0.asar.unpacked\\bin\\pdfcpu.exe', - ) - }) -}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/plugin-unpack.test.js b/plugins/pdf-process/public/preload/convert/__tests__/plugin-unpack.test.js index 44fa89a7b..dab5bf78e 100644 --- a/plugins/pdf-process/public/preload/convert/__tests__/plugin-unpack.test.js +++ b/plugins/pdf-process/public/preload/convert/__tests__/plugin-unpack.test.js @@ -6,45 +6,15 @@ import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const pluginJson = path.resolve(here, '../../../plugin.json') -/** Minimal matchBase:true for patterns like *.{exe,dll,node} or *.node */ -function matchBase(filePath, pattern) { - const base = filePath.split('/').pop() - // brace expand *.{exe,dll,node} - const m = pattern.match(/^\*\.\{([^}]+)\}$/) - if (m) { - const exts = m[1].split(',') - return exts.some((ext) => base.endsWith('.' + ext)) - } - if (pattern === '*.node') return base.endsWith('.node') - if (pattern.startsWith('*.')) return base.endsWith(pattern.slice(1)) - return filePath === pattern || base === pattern -} - -function findUnpackMatches(files, unpackValue) { - const patterns = [ - files.some((filePath) => filePath.endsWith('.node')) ? '*.node' : undefined, - unpackValue || undefined, - ].filter(Boolean) - return files.filter((filePath) => patterns.some((pattern) => matchBase(filePath, pattern))) -} - -describe('plugin.json unpack (ZTools scheme A)', () => { - it('declares unpack so pdfcpu.exe lands in asar.unpacked', () => { +describe('plugin.json package layout (ZTools scheme A)', () => { + it('does not need unpacking when there are no native runtime files', () => { const cfg = JSON.parse(fs.readFileSync(pluginJson, 'utf8')) - expect(typeof cfg.unpack).toBe('string') - expect(cfg.unpack).toMatch(/exe/) + expect('unpack' in cfg).toBe(false) const files = [ - 'bin/pdfcpu.exe', - 'bin/7za.exe', 'preload/services.js', 'index.html', - 'preload/node_modules/@napi-rs/canvas-win32-x64-msvc/skia.win32-x64-msvc.node', ] - const matched = findUnpackMatches(files, cfg.unpack) - expect(matched).toContain('bin/pdfcpu.exe') - expect(matched).toContain('bin/7za.exe') - expect(matched.some((f) => f.endsWith('.node'))).toBe(true) - expect(matched).not.toContain('preload/services.js') + expect(files.every((file) => !/(\.node|\.dat)$/.test(file))).toBe(true) }) }) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/render-pdf-pages.test.js b/plugins/pdf-process/public/preload/convert/__tests__/render-pdf-pages.test.js deleted file mode 100644 index 03fef222a..000000000 --- a/plugins/pdf-process/public/preload/convert/__tests__/render-pdf-pages.test.js +++ /dev/null @@ -1,27 +0,0 @@ -// globals: true -const fs = require('node:fs') -const path = require('node:path') -const { renderPdfPages } = require('../render-pdf-pages.js') - -const fixturePath = path.join(__dirname, '..', 'fixtures', 'sample-text.pdf') -const scanPath = 'C:/Users/9206/Downloads/test.pdf' - -describe('renderPdfPages', () => { - it('renders fixture PDF pages to PNG buffers', async () => { - const pages = await renderPdfPages(fixturePath, { scale: 1.0, maxPages: 2 }) - expect(pages.length).toBe(2) - expect(pages[0].page).toBe(1) - expect(Buffer.isBuffer(pages[0].png)).toBe(true) - // PNG magic - expect(pages[0].png[0]).toBe(0x89) - expect(pages[0].png[1]).toBe(0x50) - expect(pages[0].png.length).toBeGreaterThan(500) - }, 30000) - - it('renders CamScanner-like PDF when present', async () => { - if (!fs.existsSync(scanPath)) return - const pages = await renderPdfPages(scanPath, { scale: 1.2, maxPages: 1 }) - expect(pages.length).toBe(1) - expect(pages[0].png.length).toBeGreaterThan(10000) - }, 60000) -}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/services-syntax.test.js b/plugins/pdf-process/public/preload/convert/__tests__/services-syntax.test.js index 608093b8f..c1fb4b47e 100644 --- a/plugins/pdf-process/public/preload/convert/__tests__/services-syntax.test.js +++ b/plugins/pdf-process/public/preload/convert/__tests__/services-syntax.test.js @@ -13,7 +13,7 @@ describe('services.js syntax', () => { expect(buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf).toBe(false) const src = buf.toString('utf8') expect(() => new vm.Script(src, { filename: 'services.js' })).not.toThrow() - expect(src).toMatch(/function resolveNativePath/) - expect(src).toMatch(/function getPdfcpuPath/) + expect(src).toMatch(/pdfOperations/) + expect(src).not.toMatch(/node:child_process/) }) }) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/services-write-file.test.js b/plugins/pdf-process/public/preload/convert/__tests__/services-write-file.test.js new file mode 100644 index 000000000..a01c62030 --- /dev/null +++ b/plugins/pdf-process/public/preload/convert/__tests__/services-write-file.test.js @@ -0,0 +1,35 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const downloads = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-services-')) +global.window = { + ztools: { + getPath: (name) => (name === 'downloads' ? downloads : path.join(downloads, name)), + }, + services: {}, +} + +require('../../services.js') +const services = window.services + +describe('services.writeFileBase64', () => { + afterAll(() => { + fs.rmSync(downloads, { recursive: true, force: true }) + }) + + it('writes bytes into a pdf-* task file', () => { + const out = path.join(downloads, 'pdf-tmp', 'shared.pdf') + const saved = services.writeFileBase64(Buffer.from('pdf bytes').toString('base64'), out) + expect(saved).toBe(out) + expect(fs.readFileSync(saved, 'utf8')).toBe('pdf bytes') + }) + + it('rejects empty payloads and unsafe output paths', () => { + const out = path.join(downloads, 'pdf-tmp', 'empty.pdf') + expect(() => services.writeFileBase64('', out)).toThrow(/无效/) + expect(() => + services.writeFileBase64(Buffer.from('x').toString('base64'), path.join(os.tmpdir(), 'outside.pdf')), + ).toThrow(/下载目录/) + }) +}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/strong-compress.test.js b/plugins/pdf-process/public/preload/convert/__tests__/strong-compress.test.js deleted file mode 100644 index 9bb69c14e..000000000 --- a/plugins/pdf-process/public/preload/convert/__tests__/strong-compress.test.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Behavioral test for strong-compress (the real Electron failure mode). - * Simulates preload globals: window present, rAF missing until polyfilled, - * Chinese paths, and DISABLE_SYSTEM_FONTS_LOAD. - */ -import { describe, it, expect, beforeAll } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { createRequire } from 'node:module' -import { fileURLToPath } from 'node:url' - -const require = createRequire(import.meta.url) -const here = path.dirname(fileURLToPath(import.meta.url)) -const sampleCandidates = [ - path.resolve(here, '../../../../MicrosoftYaHei_BMP.pdf'), - path.resolve(here, '../../../../public/preload/convert/fixtures'), -] - -function findSamplePdf() { - for (const c of sampleCandidates) { - if (fs.existsSync(c) && c.endsWith('.pdf')) return c - if (fs.existsSync(c) && fs.statSync(c).isDirectory()) { - const hit = fs.readdirSync(c).find((f) => f.toLowerCase().endsWith('.pdf')) - if (hit) return path.join(c, hit) - } - } - // any pdf in repo root - const root = path.resolve(here, '../../../..') - const hit = fs.readdirSync(root).find((f) => f.toLowerCase().endsWith('.pdf')) - return hit ? path.join(root, hit) : null -} - -describe('strongCompressPdf', () => { - const sample = findSamplePdf() - - beforeAll(() => { - process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' - // Electron-like: window exists - globalThis.window = globalThis - }) - - it('module sets DISABLE_SYSTEM_FONTS_LOAD before canvas load', () => { - const src = fs.readFileSync( - path.resolve(here, '../../lib/strong-compress.js'), - 'utf8', - ) - const disableIdx = src.indexOf("DISABLE_SYSTEM_FONTS_LOAD = '1'") - const requireCanvasIdx = src.indexOf("require('@napi-rs/canvas')") - expect(disableIdx).toBeGreaterThanOrEqual(0) - expect(requireCanvasIdx).toBeGreaterThan(disableIdx) - }) - - it( - 'compresses a real PDF including Chinese temp paths', - async () => { - if (!sample) { - console.warn('skip: no sample PDF') - return - } - const { strongCompressPdf } = require('../../lib/strong-compress.js') - const base = fs.mkdtempSync(path.join(os.tmpdir(), '强压-')) - const input = path.join(base, 'mx-space部署.pdf') - fs.copyFileSync(sample, input) - const output = path.join(base, 'out.pdf') - const tempDir = path.join(base, 'pages') - - await strongCompressPdf({ - inputPath: input, - outputPath: output, - tempDir, - quality: 40, - }) - - expect(fs.existsSync(output)).toBe(true) - const size = fs.statSync(output).size - expect(size).toBeGreaterThan(1000) - // should be a PDF - const head = fs.readFileSync(output).subarray(0, 5).toString('utf8') - expect(head).toBe('%PDF-') - }, - 120_000, - ) -}) diff --git a/plugins/pdf-process/public/preload/convert/__tests__/write-ppt.test.js b/plugins/pdf-process/public/preload/convert/__tests__/write-ppt.test.js index c2194cd3e..17bd523b1 100644 --- a/plugins/pdf-process/public/preload/convert/__tests__/write-ppt.test.js +++ b/plugins/pdf-process/public/preload/convert/__tests__/write-ppt.test.js @@ -17,4 +17,21 @@ describe('writePpt', () => { expect(buf.length).toBeGreaterThan(2000) fs.unlinkSync(out) }) + + it('still writes to disk when a window global exists (Electron preload)', async () => { + const previousWindow = global.window + global.window = {} + try { + const out = path.join(os.tmpdir(), `convert-pptx-window-${Date.now()}.pptx`) + await writePpt(sample, out) + const buf = fs.readFileSync(out) + expect(buf[0]).toBe(0x50) + expect(buf[1]).toBe(0x4b) + expect(buf.length).toBeGreaterThan(2000) + fs.unlinkSync(out) + } finally { + if (previousWindow === undefined) delete global.window + else global.window = previousWindow + } + }) }) diff --git a/plugins/pdf-process/public/preload/convert/convert-local.js b/plugins/pdf-process/public/preload/convert/convert-local.js index 8208492ac..cc0e62ea8 100644 --- a/plugins/pdf-process/public/preload/convert/convert-local.js +++ b/plugins/pdf-process/public/preload/convert/convert-local.js @@ -1,7 +1,7 @@ /** * Local PDF → Office conversion (no convert.exe). * - Enough text: heuristic DocumentSchema → docx/xlsx/pptx writers - * - Sparse text (scan): render page PNGs → Word images / PPT slides; Excel keeps residual text + * - Sparse text (scan): renderer supplies page PNGs for Word/PPT; Excel keeps residual text */ const fs = require('node:fs') const path = require('node:path') @@ -10,7 +10,6 @@ const { } = require('docx') const { normalizeDocument } = require('./schema') const { extractPdfText: defaultExtract } = require('./extract-pdf-text') -const { renderPdfPages: defaultRender } = require('./render-pdf-pages') const { writeWord: defaultWriteWord } = require('./write-word') const { writeExcel: defaultWriteExcel } = require('./write-excel') const { writePpt: defaultWritePpt } = require('./write-ppt') @@ -161,7 +160,8 @@ async function writePptFromPageImages(rendered, outputPath) { }) } fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true }) - await pptx.writeFile({ fileName: outputPath }) + const buf = await pptx.write({ outputType: 'nodebuffer' }) + fs.writeFileSync(outputPath, buf) return outputPath } @@ -171,7 +171,6 @@ async function convertPdfLocal(opts) { throw new Error('不支持的转换格式: ' + format) } const extractPdfText = opts.extractPdfText || defaultExtract - const renderPdfPages = opts.renderPdfPages || defaultRender const writeWord = opts.writeWord || defaultWriteWord const writeExcel = opts.writeExcel || defaultWriteExcel const writePpt = opts.writePpt || defaultWritePpt @@ -189,26 +188,12 @@ async function convertPdfLocal(opts) { return writePpt(textToPptDocument(extracted), opts.outputPath) } - // Sparse text / scan: image-based local fallback - let rendered - try { - rendered = await renderPdfPages(opts.inputPath, { scale: 1.5, maxPages: 50 }) - } catch (e) { - const msg = e && e.message ? e.message : String(e) - throw new Error( - '本地转换失败:PDF 文本不足且页图渲染失败(' + msg + ')。', - ) - } - if (!rendered.length) { - throw new Error('本地转换失败:未能渲染任何 PDF 页面') + if (format === 'word' || format === 'ppt') { + const error = new Error('该 PDF 缺少可提取文本,需要在渲染器生成页面图像') + error.code = 'SCAN_RENDER_REQUIRED' + throw error } - if (format === 'word') { - return writeWordFromPageImages(rendered, opts.outputPath) - } - if (format === 'ppt') { - return writePptFromPageImages(rendered, opts.outputPath) - } // Excel: residual text + notice rows const notice = [ ['说明'], @@ -233,8 +218,25 @@ async function convertPdfLocal(opts) { return writeExcel(doc, opts.outputPath) } +async function convertPdfImages(opts) { + if (!['word', 'ppt'].includes(opts.format)) { + throw new Error('页面图像转换仅支持 Word 或 PPT') + } + const rendered = (opts.pages || []).map((page, index) => ({ + page: index + 1, + png: fs.readFileSync(page.path), + width: Math.max(1, Number(page.width) || 1), + height: Math.max(1, Number(page.height) || 1), + })) + if (!rendered.length) throw new Error('未收到任何 PDF 页面图像') + return opts.format === 'word' + ? writeWordFromPageImages(rendered, opts.outputPath) + : writePptFromPageImages(rendered, opts.outputPath) +} + module.exports = { convertPdfLocal, + convertPdfImages, textToDocument, textToExcelDocument, textToPptDocument, diff --git a/plugins/pdf-process/public/preload/convert/render-pdf-pages.js b/plugins/pdf-process/public/preload/convert/render-pdf-pages.js deleted file mode 100644 index fdc282607..000000000 --- a/plugins/pdf-process/public/preload/convert/render-pdf-pages.js +++ /dev/null @@ -1,106 +0,0 @@ -const fs = require('node:fs') -const path = require('node:path') -// Prevent @napi-rs/canvas from auto-loading system fonts (Electron Path error) -process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' -const { pathToFileURL } = require('node:url') -const { configureWorker, buildGetDocumentParams, loadPdfjs } = require('./extract-pdf-text') - -const DEFAULT_SCALE = 1.5 -const MAX_PAGES = 30 - -/** - * Node/Electron canvas factory for pdfjs page.render. - * Requires @napi-rs/canvas. - */ -function createNodeCanvasFactory() { - let createCanvas - try { - ;({ createCanvas } = require('@napi-rs/canvas')) - } catch (e) { - if (e && e.code === 'MODULE_NOT_FOUND') { - throw new Error('渲染 PDF 页需要 @napi-rs/canvas,请在 public/preload 执行 npm install') - } - throw e - } - return { - create(width, height) { - const canvas = createCanvas(Math.ceil(width), Math.ceil(height)) - return { - canvas, - context: canvas.getContext('2d'), - } - }, - reset(canvasAndContext, width, height) { - canvasAndContext.canvas.width = Math.ceil(width) - canvasAndContext.canvas.height = Math.ceil(height) - }, - destroy(canvasAndContext) { - canvasAndContext.canvas.width = 0 - canvasAndContext.canvas.height = 0 - }, - } -} - -/** - * Render each PDF page to a PNG buffer (for vision models). - * @param {string} inputPath - * @param {{ scale?: number, maxPages?: number }} [opts] - * @returns {Promise>} - */ -async function renderPdfPages(inputPath, opts = {}) { - if (!fs.existsSync(inputPath)) { - throw new Error('输入文件不存在: ' + inputPath) - } - const scale = typeof opts.scale === 'number' && opts.scale > 0 ? opts.scale : DEFAULT_SCALE - const maxPages = typeof opts.maxPages === 'number' ? opts.maxPages : MAX_PAGES - - const pdfjs = await loadPdfjs() - configureWorker(pdfjs) - - const data = new Uint8Array(fs.readFileSync(inputPath)) - const params = buildGetDocumentParams(data) - const canvasFactory = createNodeCanvasFactory() - params.canvasFactory = canvasFactory - - const loadingTask = pdfjs.getDocument(params) - const pdf = await loadingTask.promise - try { - const n = Math.min(pdf.numPages, maxPages) - const pages = [] - for (let i = 1; i <= n; i++) { - const page = await pdf.getPage(i) - const viewport = page.getViewport({ scale }) - const canvasAndContext = canvasFactory.create(viewport.width, viewport.height) - try { - await page.render({ - canvasContext: canvasAndContext.context, - viewport, - canvas: canvasAndContext.canvas, - }).promise - const png = canvasAndContext.canvas.toBuffer('image/png') - pages.push({ - page: i, - png, - width: viewport.width, - height: viewport.height, - }) - } finally { - canvasFactory.destroy(canvasAndContext) - } - } - return pages - } finally { - try { - await pdf.destroy() - } catch { - // ignore - } - } -} - -module.exports = { - renderPdfPages, - createNodeCanvasFactory, - DEFAULT_SCALE, - MAX_PAGES, -} diff --git a/plugins/pdf-process/public/preload/convert/write-ppt.js b/plugins/pdf-process/public/preload/convert/write-ppt.js index 6ed637141..b71239ee0 100644 --- a/plugins/pdf-process/public/preload/convert/write-ppt.js +++ b/plugins/pdf-process/public/preload/convert/write-ppt.js @@ -38,7 +38,8 @@ async function writePpt(doc, outputPath) { }) } fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true }) - await pptx.writeFile({ fileName: outputPath }) + const buf = await pptx.write({ outputType: 'nodebuffer' }) + fs.writeFileSync(outputPath, buf) return outputPath } diff --git a/plugins/pdf-process/public/preload/lib/pdf-operations.js b/plugins/pdf-process/public/preload/lib/pdf-operations.js new file mode 100644 index 000000000..a1ddeefb0 --- /dev/null +++ b/plugins/pdf-process/public/preload/lib/pdf-operations.js @@ -0,0 +1,188 @@ +const fs = require('node:fs') +const path = require('node:path') +const { PDFDocument } = require('pdf-lib') + +let cancellationVersion = 0 + +function cancelCurrent() { + cancellationVersion += 1 +} + +function cancellationGuard() { + const version = cancellationVersion + return () => { + if (version !== cancellationVersion) { + const error = new Error('操作已取消') + error.code = 'OPERATION_CANCELLED' + throw error + } + } +} + +async function loadPdf(inputPath) { + return PDFDocument.load(fs.readFileSync(inputPath), { updateMetadata: false }) +} + +async function savePdf(pdfDoc, outputPath) { + const bytes = await pdfDoc.save({ + useObjectStreams: true, + addDefaultPage: false, + objectsPerTick: 40, + updateFieldAppearances: false, + }) + fs.writeFileSync(outputPath, bytes) + return outputPath +} + +async function optimizePdf(inputPath, outputPath) { + const checkCancelled = cancellationGuard() + const source = fs.readFileSync(inputPath) + const pdfDoc = await PDFDocument.load(source, { updateMetadata: false }) + checkCancelled() + const optimized = await pdfDoc.save({ + useObjectStreams: true, + addDefaultPage: false, + objectsPerTick: 40, + updateFieldAppearances: false, + }) + checkCancelled() + fs.writeFileSync(outputPath, optimized.length < source.length ? optimized : source) + return outputPath +} + +async function mergePdfs(inputPaths, outputPath) { + if (!inputPaths.length) throw new Error('至少需要一个 PDF 文件') + const checkCancelled = cancellationGuard() + const merged = await PDFDocument.create() + for (const inputPath of inputPaths) { + checkCancelled() + const source = await loadPdf(inputPath) + const pages = await merged.copyPages(source, source.getPageIndices()) + for (const page of pages) merged.addPage(page) + } + checkCancelled() + return savePdf(merged, outputPath) +} + +function parsePageSpec(spec, pageCount) { + const pages = [] + for (const token of String(spec).split(',')) { + const value = token.trim() + if (!value) continue + const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value) + if (!match) throw new Error('页码范围格式无效: ' + value) + const start = Number(match[1]) + const end = match[2] ? Number(match[2]) : start + if (start < 1 || end < start || end > pageCount) { + throw new Error('页码超出范围: ' + value) + } + for (let page = start; page <= end; page += 1) pages.push(page) + } + return Array.from(new Set(pages)) +} + +function normalizeRanges(ranges, pageCount) { + return ranges.map((pair) => { + const start = Math.floor(Number(pair[0])) + const end = Math.floor(Number(pair[1])) + if (start < 1 || end < start || end > pageCount) { + throw new Error(`页码超出范围: ${start}-${end}`) + } + return [start, end] + }) +} + +function rangesFromBeforePages(beforePages, pageCount) { + const boundaries = Array.from(new Set(beforePages.map((page) => Math.floor(Number(page))))) + .filter((page) => page >= 2 && page <= pageCount) + .sort((a, b) => a - b) + const ranges = [] + let start = 1 + for (const boundary of boundaries) { + ranges.push([start, boundary - 1]) + start = boundary + } + ranges.push([start, pageCount]) + return ranges +} + +function rangesFromSpan(span, pageCount) { + const size = Math.max(1, Math.floor(Number(span) || 1)) + const ranges = [] + for (let start = 1; start <= pageCount; start += size) { + ranges.push([start, Math.min(pageCount, start + size - 1)]) + } + return ranges +} + +async function writePageSelection(source, pageNumbers, outputPath, checkCancelled) { + checkCancelled() + const output = await PDFDocument.create() + const indices = pageNumbers.map((page) => page - 1) + const pages = await output.copyPages(source, indices) + for (const page of pages) output.addPage(page) + checkCancelled() + return savePdf(output, outputPath) +} + +async function splitPdf(inputPath, outputDir, options) { + const checkCancelled = cancellationGuard() + const source = await loadPdf(inputPath) + const pageCount = source.getPageCount() + const base = path.basename(inputPath, path.extname(inputPath)) || 'split' + + if (typeof options === 'string' && options.trim()) { + const pages = parsePageSpec(options, pageCount) + const outputs = [] + for (const page of pages) { + const outputPath = path.join(outputDir, `${base}_${page}.pdf`) + await writePageSelection(source, [page], outputPath, checkCancelled) + outputs.push(outputPath) + } + return outputs + } + + const opts = options && typeof options === 'object' ? options : {} + if (Array.isArray(opts.pageRanges) && opts.pageRanges.length) { + const ranges = normalizeRanges(opts.pageRanges, pageCount) + if (opts.mergeRanges !== false) { + const pages = ranges.flatMap(([start, end]) => + Array.from({ length: end - start + 1 }, (_, index) => start + index), + ) + const label = ranges.length === 1 + ? ranges[0][0] === ranges[0][1] ? String(ranges[0][0]) : `${ranges[0][0]}-${ranges[0][1]}` + : 'extract' + const outputPath = path.join(outputDir, `${base}_${label}.pdf`) + await writePageSelection(source, pages, outputPath, checkCancelled) + return [outputPath] + } + return writeRanges(source, base, outputDir, ranges, checkCancelled) + } + + const ranges = Array.isArray(opts.beforePages) && opts.beforePages.length + ? rangesFromBeforePages(opts.beforePages, pageCount) + : rangesFromSpan(opts.span, pageCount) + return writeRanges(source, base, outputDir, ranges, checkCancelled) +} + +async function writeRanges(source, base, outputDir, ranges, checkCancelled) { + const outputs = [] + for (const [start, end] of ranges) { + const label = start === end ? String(start) : `${start}-${end}` + const outputPath = path.join(outputDir, `${base}_${label}.pdf`) + const pages = Array.from({ length: end - start + 1 }, (_, index) => start + index) + await writePageSelection(source, pages, outputPath, checkCancelled) + outputs.push(outputPath) + } + return outputs +} + +module.exports = { + cancelCurrent, + mergePdfs, + optimizePdf, + parsePageSpec, + rangesFromBeforePages, + rangesFromSpan, + splitPdf, +} diff --git a/plugins/pdf-process/public/preload/lib/pdfcpu-runner.js b/plugins/pdf-process/public/preload/lib/pdfcpu-runner.js deleted file mode 100644 index 15e849842..000000000 --- a/plugins/pdf-process/public/preload/lib/pdfcpu-runner.js +++ /dev/null @@ -1,148 +0,0 @@ -const fs = require('node:fs') -const path = require('node:path') -const { spawn } = require('node:child_process') - -let currentChild = null -let log = () => {} -let safePathLabel = (p) => p - -function setDeps(deps) { - if (deps.log) log = deps.log - if (deps.safePathLabel) safePathLabel = deps.safePathLabel -} - -function isInsideAsar(filePath) { - if (!filePath) return false - if (/\.asar\.unpacked([\\/]|$)/.test(filePath)) return false - return filePath.includes('.asar' + path.sep) || /\.asar[\\/]/.test(filePath) -} - -/** Rewrite app.asar\\foo -> app.asar.unpacked\\foo for native binaries. */ -function resolveNativePath(filePath) { - if (!isInsideAsar(filePath)) return filePath - return filePath.replace(/\.asar([\\/])/, '.asar.unpacked$1') -} - -function getPdfcpuCacheDir() { - try { - const base = - window.ztools && window.ztools.getPath - ? window.ztools.getPath('userData') || window.ztools.getPath('downloads') - : null - if (base) return path.join(base, 'pdf-process-bin') - } catch {} - try { - return path.join(require('node:os').tmpdir(), 'pdf-process-bin') - } catch { - return path.join(process.cwd(), 'pdf-process-bin') - } -} - -/** - * Resolve a spawn-able pdfcpu.exe path. - * Electron cannot spawn binaries from inside .asar; prefer .asar.unpacked, - * then extract a copy under userData/pdf-process-bin. - * This file lives in public/preload/lib → bin is public/bin. - */ -function getPdfcpuPath() { - const primary = path.join(__dirname, '..', '..', 'bin', 'pdfcpu.exe') - const unpacked = resolveNativePath(primary) - const cached = path.join( - getPdfcpuCacheDir(), - process.platform === 'win32' ? 'pdfcpu.exe' : 'pdfcpu', - ) - const candidates = [unpacked, cached] - if (unpacked !== primary) candidates.push(primary) - - for (const c of candidates) { - try { - if (c && fs.existsSync(c) && !isInsideAsar(c)) return c - } catch {} - } - - for (const src of [primary, unpacked]) { - try { - if (!src || !fs.existsSync(src)) continue - const dir = path.dirname(cached) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - fs.copyFileSync(src, cached) - try { - fs.chmodSync(cached, 0o755) - } catch {} - if (fs.existsSync(cached) && !isInsideAsar(cached)) { - log('INFO', 'pdfcpu extracted to cache', { - from: safePathLabel(src), - to: safePathLabel(cached), - }) - return cached - } - } catch (e) { - try { - log('WARN', 'pdfcpu extract failed', { error: e && e.message }) - } catch {} - } - } - - return unpacked -} - -function cancelCurrent() { - if (currentChild) { - log('INFO', 'cancelling child process', { pid: currentChild.pid }) - try { - currentChild.kill() - } catch (e) { - log('WARN', 'kill failed', e.message) - } - currentChild = null - } -} - -function callPdfcpu(args) { - const exe = getPdfcpuPath() - log('INFO', 'pdfcpu spawn', { - exe: safePathLabel(exe), - args, - insideAsar: isInsideAsar(exe), - }) - return new Promise((resolve, reject) => { - if (!exe || !fs.existsSync(exe) || isInsideAsar(exe)) { - const err = new Error('pdfcpu binary not found or not spawnable: ' + exe) - log('ERROR', 'pdfcpu missing', { path: safePathLabel(exe) }) - return reject(err) - } - const child = spawn(exe, ['--force', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }) - currentChild = child - log('INFO', 'pdfcpu started', { pid: child.pid }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (d) => { - stdout += d.toString() - }) - child.stderr.on('data', (d) => { - stderr += d.toString() - }) - child.on('close', (code) => { - currentChild = null - if (stderr) log('WARN', 'pdfcpu stderr', stderr.trim()) - log('INFO', 'pdfcpu exit', { code, outLen: stdout.length }) - if (code === 0) resolve(stdout) - else reject(new Error(stderr.trim() || stdout.trim() || 'exit code ' + code)) - }) - child.on('error', (err) => { - currentChild = null - log('ERROR', 'pdfcpu error', err.message) - reject(err) - }) - }) -} - -module.exports = { - setDeps, - isInsideAsar, - resolveNativePath, - getPdfcpuCacheDir, - getPdfcpuPath, - callPdfcpu, - cancelCurrent, -} diff --git a/plugins/pdf-process/public/preload/lib/strong-compress.js b/plugins/pdf-process/public/preload/lib/strong-compress.js deleted file mode 100644 index d94bdc8a4..000000000 --- a/plugins/pdf-process/public/preload/lib/strong-compress.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Strong-compress: rasterize PDF at target DPI → JPEG pages → single PDF. - * - * MUST set DISABLE_SYSTEM_FONTS_LOAD before requiring @napi-rs/canvas. - * That package auto-loads system fonts on import via loadFontsFromDir(homedir...), - * which throws "Value is non of these types String, Path" in some Electron/asar hosts. - */ -process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' - -const fs = require('node:fs') -const path = require('node:path') -const { pathToFileURL } = require('node:url') -const { createPdfFromImages } = require('./create-pdf-from-images') - -function mapQualityToRaster(quality) { - const q = Math.min(100, Math.max(1, Number(quality) || 1)) - const t = (q - 1) / 99 - const dpi = Math.round(72 + t * (150 - 72)) - const jpegQuality = 0.32 + t * 0.4 - const grayscale = q < 35 - return { dpi, jpegQuality, grayscale } -} - -const STRONG_MAX_LONG_EDGE_PX = 2000 -const MAX_PAGES = 500 - -function asPathString(p) { - if (typeof p === 'string') return p - if (p == null) return '' - try { - return String(p) - } catch { - return '' - } -} - -/** Electron preload has window but may lack rAF — pdfjs render needs it. */ -function ensureAnimationFrame() { - const g = typeof globalThis !== 'undefined' ? globalThis : global - if (typeof g.requestAnimationFrame !== 'function') { - g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) - } - if (typeof g.cancelAnimationFrame !== 'function') { - g.cancelAnimationFrame = (id) => clearTimeout(id) - } - // Also patch window if it exists as a separate object - if (typeof window !== 'undefined') { - if (typeof window.requestAnimationFrame !== 'function') { - window.requestAnimationFrame = g.requestAnimationFrame - } - if (typeof window.cancelAnimationFrame !== 'function') { - window.cancelAnimationFrame = g.cancelAnimationFrame - } - } -} - -function createNodeCanvasFactory() { - // Lazy require AFTER DISABLE_SYSTEM_FONTS_LOAD - const { createCanvas } = require('@napi-rs/canvas') - return { - create(width, height) { - const w = Math.max(1, Math.ceil(Number(width) || 1)) - const h = Math.max(1, Math.ceil(Number(height) || 1)) - const canvas = createCanvas(w, h) - return { canvas, context: canvas.getContext('2d') } - }, - reset(canvasAndContext, width, height) { - canvasAndContext.canvas.width = Math.max(1, Math.ceil(Number(width) || 1)) - canvasAndContext.canvas.height = Math.max(1, Math.ceil(Number(height) || 1)) - }, - destroy(canvasAndContext) { - try { - canvasAndContext.canvas.width = 0 - canvasAndContext.canvas.height = 0 - } catch {} - }, - } -} - -function canvasToJpeg(canvas, quality01) { - const q = Math.min(1, Math.max(0.05, Number(quality01) || 0.5)) - try { - return canvas.toBuffer('image/jpeg', q) - } catch { - try { - return canvas.toBuffer('image/jpeg', { quality: q }) - } catch { - return canvas.toBuffer('image/jpeg') - } - } -} - -/** - * Real browser/Electron Worker vs Node FakeWorker. - * FakeWorker only supports file:/data:/node: URLs — never blob:. - */ -function canUseRealWorker() { - if (typeof Worker === 'undefined') return false - try { - // Node 20+ experimental Worker is not the browser one; pdfjs FakeWorker path is safer. - const isElectron = - !!(process.versions && process.versions.electron) || - !!(typeof window !== 'undefined' && window.process && window.process.type) - return isElectron - } catch { - return false - } -} - -async function loadPdfjsForRender() { - const { - resolveWorkerPath, - resolvePdfjsAssetDirs, - FsCMapReaderFactory, - FsStandardFontDataFactory, - } = require('../convert/extract-pdf-text') - - ensureAnimationFrame() - - const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs') - - let workerPath = asPathString(resolveWorkerPath()) - if (!fs.existsSync(workerPath)) { - throw new Error('pdfjs worker 不存在: ' + workerPath) - } - - // Always copy out of asar — Worker and FakeWorker both choke on asar internals - if (workerPath.includes('.asar')) { - const os = require('node:os') - const dest = path.join(asPathString(os.tmpdir()), 'pdf-process-pdf.worker.mjs') - fs.copyFileSync(workerPath, dest) - workerPath = dest - } - - // MUST be a plain string URL. Never pass Path objects. Never use blob: on FakeWorker. - const fileUrl = pathToFileURL(workerPath).href - if (canUseRealWorker() && typeof Blob !== 'undefined' && URL.createObjectURL) { - try { - const code = fs.readFileSync(workerPath) - const bytes = new Uint8Array(code.buffer, code.byteOffset, code.byteLength) - pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL( - new Blob([bytes], { type: 'text/javascript' }), - ) - } catch { - pdfjs.GlobalWorkerOptions.workerSrc = fileUrl - } - } else { - pdfjs.GlobalWorkerOptions.workerSrc = fileUrl - } - - const { cMapDir, standardFontDir } = resolvePdfjsAssetDirs() - - function buildParams(data) { - const params = { - data, - cMapUrl: asPathString(cMapDir), - cMapPacked: true, - CMapReaderFactory: FsCMapReaderFactory, - useSystemFonts: false, - isEvalSupported: false, - useWorkerFetch: false, - disableFontFace: true, - isOffscreenCanvasSupported: false, - verbosity: 0, - } - try { - if (fs.existsSync(asPathString(standardFontDir))) { - params.standardFontDataUrl = asPathString(standardFontDir) - params.StandardFontDataFactory = FsStandardFontDataFactory - } - } catch {} - return params - } - - return { pdfjs, buildParams } -} - -/** - * @param {{ - * inputPath: string, - * outputPath: string, - * quality?: number, - * tempDir: string, - * log?: Function, - * }} opts - */ -async function strongCompressPdf(opts) { - // Belt-and-suspenders: set again in case another require cleared it - process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' - ensureAnimationFrame() - - const inputPath = asPathString(opts.inputPath) - const outputPath = asPathString(opts.outputPath) - const tempDir = asPathString(opts.tempDir) - if (!inputPath || !outputPath || !tempDir) { - throw new Error('strongCompress: 缺少路径参数') - } - if (!fs.existsSync(inputPath)) { - throw new Error('输入文件不存在: ' + path.basename(inputPath)) - } - - const log = opts.log || (() => {}) - const { dpi, jpegQuality, grayscale } = mapQualityToRaster(opts.quality) - log('INFO', 'strongCompress start', { - dpi, - jpegQuality, - grayscale, - disableSystemFonts: process.env.DISABLE_SYSTEM_FONTS_LOAD, - }) - - const scale = dpi / 72 - let pdfjs - let buildParams - try { - ;({ pdfjs, buildParams } = await loadPdfjsForRender()) - } catch (e) { - throw new Error('加载 PDF 引擎失败: ' + (e && e.message ? e.message : String(e))) - } - - const data = new Uint8Array(fs.readFileSync(inputPath)) - const params = buildParams(data) - const canvasFactory = createNodeCanvasFactory() - params.canvasFactory = canvasFactory - - let pdf - try { - pdf = await pdfjs.getDocument(params).promise - } catch (e) { - throw new Error('打开 PDF 失败: ' + (e && e.message ? e.message : String(e))) - } - - if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }) - - const imagePaths = [] - const pageSizes = [] - - try { - const n = Math.min(pdf.numPages, MAX_PAGES) - for (let i = 1; i <= n; i++) { - const page = await pdf.getPage(i) - const unscaled = page.getViewport({ scale: 1 }) - const widthPt = Number(unscaled.width) || 612 - const heightPt = Number(unscaled.height) || 792 - pageSizes.push({ widthPt, heightPt }) - - let widthPx = Math.max(1, Math.round(widthPt * scale)) - let heightPx = Math.max(1, Math.round(heightPt * scale)) - const long = Math.max(widthPx, heightPx) - if (long > STRONG_MAX_LONG_EDGE_PX) { - const factor = STRONG_MAX_LONG_EDGE_PX / long - widthPx = Math.max(1, Math.round(widthPx * factor)) - heightPx = Math.max(1, Math.round(heightPx * factor)) - } - - const w = widthPx - const h = heightPx - const canvasAndContext = canvasFactory.create(w, h) - try { - const ctx = canvasAndContext.context - ctx.fillStyle = '#ffffff' - ctx.fillRect(0, 0, w, h) - const matched = page.getViewport({ scale: w / Math.max(widthPt, 1) }) - await page.render({ - canvasContext: ctx, - viewport: matched, - canvas: canvasAndContext.canvas, - }).promise - - if (grayscale) { - const imageData = ctx.getImageData(0, 0, w, h) - const d = imageData.data - for (let p = 0; p < d.length; p += 4) { - const y = (d[p] * 0.299 + d[p + 1] * 0.587 + d[p + 2] * 0.114 + 0.5) | 0 - d[p] = y - d[p + 1] = y - d[p + 2] = y - } - ctx.putImageData(imageData, 0, 0) - } - - const jpeg = canvasToJpeg(canvasAndContext.canvas, jpegQuality) - const outImg = path.join(tempDir, 'page_' + i + '.jpg') - fs.writeFileSync(outImg, jpeg) - imagePaths.push(outImg) - } finally { - canvasFactory.destroy(canvasAndContext) - } - } - } finally { - try { - await pdf.destroy() - } catch {} - } - - if (!imagePaths.length) throw new Error('未能渲染任何页面') - - await createPdfFromImages(imagePaths, outputPath, { pageSizes }) - - for (const p of imagePaths) { - try { - fs.unlinkSync(p) - } catch {} - } - - log('INFO', 'strongCompress done', { pages: imagePaths.length }) - return outputPath -} - -module.exports = { - mapQualityToRaster, - strongCompressPdf, - canvasToJpeg, - asPathString, -} diff --git a/plugins/pdf-process/public/preload/package-lock.json b/plugins/pdf-process/public/preload/package-lock.json index 7bd5bfae1..9e0a4529b 100644 --- a/plugins/pdf-process/public/preload/package-lock.json +++ b/plugins/pdf-process/public/preload/package-lock.json @@ -8,7 +8,6 @@ "name": "pdf-process-preload", "version": "1.0.0", "dependencies": { - "@napi-rs/canvas": "^0.1.65", "@pdf-lib/fontkit": "^1.1.1", "docx": "^9.7.1", "exceljs": "^4.4.0", @@ -63,6 +62,7 @@ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.65.tgz", "integrity": "sha512-YcFhXQcp+b2d38zFOJNbpyPHnIL7KAEkhJQ+UeeKI5IpE9B8Cpf/M6RiHPQXSsSqnYbrfFylnW49dyh2oeSblQ==", "license": "MIT", + "optional": true, "engines": { "node": ">= 10" }, diff --git a/plugins/pdf-process/public/preload/package.json b/plugins/pdf-process/public/preload/package.json index c9d723afc..7cbe2566b 100644 --- a/plugins/pdf-process/public/preload/package.json +++ b/plugins/pdf-process/public/preload/package.json @@ -4,7 +4,6 @@ "description": "Preload dependencies for PDF processing", "main": "services.js", "dependencies": { - "@napi-rs/canvas": "^0.1.65", "@pdf-lib/fontkit": "^1.1.1", "docx": "^9.7.1", "exceljs": "^4.4.0", diff --git a/plugins/pdf-process/public/preload/requirements.txt b/plugins/pdf-process/public/preload/requirements.txt deleted file mode 100644 index 0c2fcc248..000000000 --- a/plugins/pdf-process/public/preload/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pdf2docx==0.5.13 -PyMuPDF==1.28.0 -python-pptx==1.0.2 -openpyxl==3.1.5 -pytest==9.1.1 -pyinstaller==6.21.0 diff --git a/plugins/pdf-process/public/preload/services.js b/plugins/pdf-process/public/preload/services.js index 97b73e0e6..ce070e9ca 100644 --- a/plugins/pdf-process/public/preload/services.js +++ b/plugins/pdf-process/public/preload/services.js @@ -1,7 +1,3 @@ -// MUST be first: @napi-rs/canvas auto-loads system fonts on require and throws -// "Value is non of these types String, Path" in some Electron/asar hosts. -process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' - const fs = require('node:fs') const path = require('node:path') const { PDFDocument } = require('pdf-lib') @@ -10,9 +6,8 @@ const { assertSafeInputFile, safePathLabel, } = require('./path-guard') -const pdfcpu = require('./lib/pdfcpu-runner') +const pdfOperations = require('./lib/pdf-operations') const { createPdfFromImages: buildPdfFromImages } = require('./lib/create-pdf-from-images') -const { strongCompressPdf } = require('./lib/strong-compress') const settingsStore = require('./lib/settings-store') const { resolveTaskCoords } = require('./lib/task-paths') const { @@ -67,28 +62,9 @@ function log(level, msg, data) { } catch {} } -pdfcpu.setDeps({ log, safePathLabel }) - -const { callPdfcpu, cancelCurrent } = pdfcpu +const { cancelCurrent } = pdfOperations -// Keep names for static source tests / asar helpers (implementation in lib/pdfcpu-runner) -function resolveNativePath(filePath) { - return pdfcpu.resolveNativePath(filePath) -} -function isInsideAsar(filePath) { - return pdfcpu.isInsideAsar(filePath) -} -function getPdfcpuPath() { - return pdfcpu.getPdfcpuPath() -} -function getPdfcpuCacheDir() { - return pdfcpu.getPdfcpuCacheDir() -} - -log('INFO', 'services.js loaded', { - pdfcpu: safePathLabel(getPdfcpuPath()), - logfile: safePathLabel(LOG_PATH), -}) +log('INFO', 'services.js loaded', { logfile: safePathLabel(LOG_PATH) }) function ensuredDir(dir) { if (!fs.existsSync(dir)) { @@ -125,152 +101,6 @@ function listFiles(dir, exts) { return files } -// CJK Unicode ranges for detecting Chinese/Japanese/Korean characters -const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/ - -// Maps Windows font names to pdfcpu font names (as shown by "pdfcpu fonts list") -const PDFCPU_FONT_MAP = { - 'Microsoft YaHei': 'MicrosoftYaHei', - 'SimSun': 'SimSun', - 'SimHei': 'SimHei', - 'KaiTi': 'KaiTi', - 'FangSong': 'FangSong', -} - -/** - * Selects an appropriate font for the watermark text. - * Uses a CJK-capable font when the text contains Chinese/Japanese/Korean characters, - * otherwise falls back to Helvetica. - */ -function selectFontForText(text) { - if (CJK_REGEX.test(text)) { - const cjkFonts = ['Microsoft YaHei', 'SimSun', 'SimHei', 'KaiTi', 'FangSong'] - const available = findAvailableCjkFont(cjkFonts) - if (available) { - const pdfcpuFont = PDFCPU_FONT_MAP[available] || available - log('INFO', 'selectFontForText: CJK font selected', { font: pdfcpuFont, windowsFont: available, text: text.slice(0, 20) }) - return { windowsName: available, pdfcpuName: pdfcpuFont } - } - log('WARN', 'selectFontForText: no CJK font found, falling back to Helvetica (may fail for CJK text)') - } - return { windowsName: null, pdfcpuName: 'Helvetica' } -} - -/** - * Checks common font directories for an available CJK font. - * Returns the font name if found, or null if none available. - */ -function findAvailableCjkFont(fontNames) { - const fontDirs = [ - path.join(process.env.WINDIR || 'C:\\Windows', 'Fonts'), - path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'Windows', 'Fonts'), - ] - const fontFiles = new Map() - for (const dir of fontDirs) { - if (!fs.existsSync(dir)) continue - try { - for (const f of fs.readdirSync(dir)) { - const lower = f.toLowerCase() - if (lower.endsWith('.ttf') || lower.endsWith('.ttc') || lower.endsWith('.otf')) { - fontFiles.set(lower, f) - } - } - } catch {} - } - // Map common font names to their file patterns - const fontFilePatterns = { - 'Microsoft YaHei': ['msyh.ttc', 'msyhbd.ttc', 'microsoft yahei'], - 'SimSun': ['simsun.ttc', 'nsimsun.ttc', 'simsun'], - 'SimHei': ['simhei.ttf', 'simhei'], - 'KaiTi': ['simkai.ttf', 'kaiti'], - 'FangSong': ['simfang.ttf', 'fangsong'], - 'PingFang SC': ['pingfang', 'pingfangsc'], - 'Hiragino Sans GB': ['hiragino', 'hira'], - } - for (const name of fontNames) { - const patterns = fontFilePatterns[name] || [name.toLowerCase()] - for (const [lower, original] of fontFiles) { - if (patterns.some(p => lower.includes(p))) { - return name - } - } - } - return null -} - -let pdfcpuFontsCache = null - -function getPdfcpuFonts() { - if (pdfcpuFontsCache) return pdfcpuFontsCache - pdfcpuFontsCache = new Map() - try { - const { execFileSync } = require('node:child_process') - const output = execFileSync(getPdfcpuPath(), ['fonts', 'list'], { encoding: 'utf-8', timeout: 10000 }) - const regex = /^(\S+)\s+\((\d+)\s+glyphs\)/gm - let match - while ((match = regex.exec(output)) !== null) { - pdfcpuFontsCache.set(match[1], parseInt(match[2], 10)) - } - } catch (e) { - log('WARN', 'getPdfcpuFonts: failed to list fonts', e.message) - } - return pdfcpuFontsCache -} - -function clearPdfcpuFontCache() { - pdfcpuFontsCache = null -} - -function findFontFilePath(fontName) { - const fontDirs = [ - path.join(process.env.WINDIR || 'C:\\Windows', 'Fonts'), - path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'Windows', 'Fonts'), - ] - const fontFilePatterns = { - 'Microsoft YaHei': ['msyh.ttc', 'msyhbd.ttc'], - 'SimSun': ['simsun.ttc', 'nsimsun.ttc'], - 'SimHei': ['simhei.ttf'], - 'KaiTi': ['simkai.ttf'], - 'FangSong': ['simfang.ttf'], - } - const patterns = fontFilePatterns[fontName] || [fontName.toLowerCase()] - for (const dir of fontDirs) { - if (!fs.existsSync(dir)) continue - try { - for (const f of fs.readdirSync(dir)) { - const lower = f.toLowerCase() - if (lower.endsWith('.ttf') || lower.endsWith('.ttc') || lower.endsWith('.otf')) { - if (patterns.some(p => lower === p || lower.startsWith(p.replace(/\.(ttf|ttc|otf)/, '')))) { - return path.join(dir, f) - } - } - } - } catch {} - } - return null -} - -function ensurePdfcpuFont(windowsFontName, pdfcpuFontName) { - const fonts = getPdfcpuFonts() - const glyphCount = fonts.get(pdfcpuFontName) || 0 - if (glyphCount > 1000) return - log('INFO', 'ensurePdfcpuFont: font needs installation', { font: pdfcpuFontName, currentGlyphs: glyphCount }) - const fontFile = findFontFilePath(windowsFontName) - if (!fontFile) { - log('WARN', 'ensurePdfcpuFont: font file not found in Windows Fonts', { windowsFontName }) - return - } - try { - const { execFileSync } = require('node:child_process') - execFileSync(getPdfcpuPath(), ['fonts', 'install', fontFile], { encoding: 'utf-8', timeout: 60000 }) - clearPdfcpuFontCache() - log('INFO', 'ensurePdfcpuFont: font installed successfully', { font: pdfcpuFontName, file: fontFile }) - } catch (e) { - log('ERROR', 'ensurePdfcpuFont: failed to install font', { font: pdfcpuFontName, error: e.message }) - } -} - - function loadCjkFontBytes() { const winDir = process.env.WINDIR || 'C:\\Windows' const candidates = [ @@ -405,6 +235,19 @@ window.services = { return filePath }, + writeFileBase64(base64, outputPath) { + if (typeof base64 !== 'string' || !base64.trim()) { + throw new Error('文件数据无效') + } + const buf = Buffer.from(base64, 'base64') + if (!buf.length) throw new Error('文件数据无效') + const filePath = safeOut(outputPath, '临时输入路径') + ensuredDir(path.dirname(filePath)) + fs.writeFileSync(filePath, buf) + log('INFO', 'writeFileBase64', safePathLabel(filePath)) + return filePath + }, + async createPdfFromImages(imagePaths, outputPath, options = {}) { const out = safeOut(outputPath, 'PDF 输出路径') log('INFO', 'createPdfFromImages', { count: imagePaths.length, out: safePathLabel(out) }) @@ -418,40 +261,15 @@ window.services = { const input = safeIn(inputPath) const out = safeOut(outputPath, '压缩输出路径') ensuredDir(path.dirname(out)) - const mode = options && options.mode === 'strong' ? 'strong' : 'optimize' + const mode = 'optimize' log('INFO', 'compressPdf', { input: safePathLabel(input), output: safePathLabel(out), mode, }) - if (mode === 'strong') { - const tempDir = path.join(path.dirname(out), '.strong-tmp-' + Date.now()) - try { - await strongCompressPdf({ - inputPath: input, - outputPath: out, - quality: options.quality, - tempDir, - log, - }) - } finally { - try { - if (fs.existsSync(tempDir)) { - for (const f of fs.readdirSync(tempDir)) { - try { - fs.unlinkSync(path.join(tempDir, f)) - } catch {} - } - fs.rmdirSync(tempDir) - } - } catch {} - } - return out - } - - await callPdfcpu(['optimize', input, out]) - log('INFO', 'compressPdf done', safePathLabel(out)) + await pdfOperations.optimizePdf(input, out) + log('INFO', 'compressPdf done via pdf-lib', safePathLabel(out)) return out }, @@ -459,7 +277,7 @@ window.services = { const inputs = inputPaths.map((p) => safeIn(p)) const out = safeOut(outputPath, '合并输出路径') ensuredDir(path.dirname(out)) - await callPdfcpu(['merge', out, ...inputs]) + await pdfOperations.mergePdfs(inputs, out) return out }, @@ -467,66 +285,7 @@ window.services = { const input = safeIn(inputPath) const outDir = safeOut(outputDirPath, '拆分输出目录') ensuredDir(outDir) - - if (typeof options === 'string' && options.trim()) { - const pagesSpec = options.trim() - if (!/^[0-9,\-\s]+$/.test(pagesSpec)) throw new Error('页码范围格式无效') - await callPdfcpu(['extract', '-m', 'page', '-p', pagesSpec, input, outDir]) - return listFiles(outDir, ['.pdf']) - } - - const opts = options && typeof options === 'object' ? options : {} - const pageRanges = Array.isArray(opts.pageRanges) ? opts.pageRanges : null - const beforePages = Array.isArray(opts.beforePages) - ? opts.beforePages.map((n) => Math.floor(Number(n))).filter((n) => n >= 2) - : null - const span = opts.span != null ? Math.max(1, Math.floor(Number(opts.span) || 1)) : null - const mergeRanges = opts.mergeRanges !== false - - if (pageRanges && pageRanges.length > 0) { - const base = path.basename(input, path.extname(input)) || 'split' - const normalized = [] - for (const pair of pageRanges) { - const a = Math.floor(Number(pair[0])) - const b = Math.floor(Number(pair[1])) - if (a >= 1 && b >= a) normalized.push([a, b]) - } - if (!normalized.length) throw new Error('没有有效的页码范围') - - if (mergeRanges || normalized.length === 1) { - const pagesSpec = normalized - .map(([a, b]) => (a === b ? String(a) : a + '-' + b)) - .join(',') - const label = - normalized.length === 1 - ? normalized[0][0] === normalized[0][1] - ? String(normalized[0][0]) - : normalized[0][0] + '-' + normalized[0][1] - : 'extract' - const outFile = path.join(outDir, base + '_' + label + '.pdf') - await callPdfcpu(['collect', '-p', pagesSpec, input, outFile]) - return [outFile] - } - - const outs = [] - for (const [a, b] of normalized) { - const label = a === b ? String(a) : a + '-' + b - const outFile = path.join(outDir, base + '_' + label + '.pdf') - await callPdfcpu(['collect', '-p', a + '-' + b, input, outFile]) - outs.push(outFile) - } - return outs - } - - if (beforePages && beforePages.length > 0) { - const unique = Array.from(new Set(beforePages)).sort((a, b) => a - b) - await callPdfcpu(['split', '-m', 'page', input, outDir, ...unique.map(String)]) - } else if (span != null) { - await callPdfcpu(['split', '-m', 'span', input, outDir, String(span)]) - } else { - await callPdfcpu(['split', '-m', 'span', input, outDir, '1']) - } - return listFiles(outDir, ['.pdf']) + return pdfOperations.splitPdf(input, outDir, options) }, async addWatermark(inputPath, outputPath, watermark) { @@ -543,54 +302,18 @@ window.services = { const color = (watermark && watermark.color) || '#808080' const density = watermark && watermark.density != null ? Number(watermark.density) : 3 - try { - await addWatermarkWithPdfLib(input, out, { - text, - opacity, - points, - rotation, - margin, - tile, - position, - color, - density, - }) - log('INFO', 'addWatermark done via pdf-lib', { output: safePathLabel(out) }) - return out - } catch (e) { - log('WARN', 'pdf-lib watermark failed, fallback pdfcpu', e && e.message) - } - - const font = selectFontForText(text) - if (font.windowsName) ensurePdfcpuFont(font.windowsName, font.pdfcpuName) - const posMap = { - tl: 'tl', - tc: 'tc', - tr: 'tr', - ml: 'l', - mc: 'c', - mr: 'r', - bl: 'bl', - bc: 'bc', - br: 'br', - l: 'l', - c: 'c', - r: 'r', - } - const pos = posMap[position] || 'c' - const desc = [ - 'fontname:' + font.pdfcpuName, - 'points:' + points, - 'opacity:' + opacity, - 'rot:' + rotation, - 'fillcol:' + color, - 'pos:' + (tile ? 'c' : pos), - ].join(', ') - try { - await callPdfcpu(['stamp', 'add', '-mode', 'text', desc, text, input, out]) - } catch (e1) { - await callPdfcpu(['watermark', 'add', '-mode', 'text', desc, text, input, out]) - } + await addWatermarkWithPdfLib(input, out, { + text, + opacity, + points, + rotation, + margin, + tile, + position, + color, + density, + }) + log('INFO', 'addWatermark done via pdf-lib', { output: safePathLabel(out) }) return out }, @@ -611,6 +334,19 @@ window.services = { return convertPdfLocal({ inputPath: input, outputPath: out, format }) }, + async convertPdfImages(pages, outputPath, format) { + if (!['word', 'ppt'].includes(format)) throw new Error('页面图像转换仅支持 Word 或 PPT') + const out = safeOut(outputPath, '转换输出路径') + ensuredDir(path.dirname(out)) + const safePages = (Array.isArray(pages) ? pages : []).map((page) => ({ + path: safeOut(page.path, '页面图像路径'), + width: Number(page.width), + height: Number(page.height), + })) + const { convertPdfImages } = require('./convert/convert-local') + return convertPdfImages({ pages: safePages, outputPath: out, format }) + }, + resolveTaskPath(coords) { const r = resolveTaskCoords(getDownloadsRoot(), coords) if (r.filePath) { diff --git a/plugins/pdf-process/public/preload/test_convert.py b/plugins/pdf-process/public/preload/test_convert.py deleted file mode 100644 index 8e649edfe..000000000 --- a/plugins/pdf-process/public/preload/test_convert.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -TDD test suite for convert.py — the standalone PDF conversion core. - -Covers the three supported conversions (word / excel / ppt) plus CLI/argument -error handling. Tests use a synthetic multi-page PDF built with PyMuPDF so they -run without any external fixture files. -""" -import os -import zipfile - -import pytest -import fitz # PyMuPDF -import openpyxl -from docx import Document -from pptx import Presentation - -import convert - - -@pytest.fixture(scope="module") -def sample_pdf(tmp_path_factory): - """A 2-page PDF containing plain text on each page.""" - path = tmp_path_factory.mktemp("data") / "sample.pdf" - doc = fitz.open() - p1 = doc.new_page() - p1.insert_text((72, 72), "Hello World Page 1") - p2 = doc.new_page() - p2.insert_text((72, 72), "Second Page Content") - doc.save(str(path)) - doc.close() - return str(path) - - -def _docx_text(path): - doc = Document(path) - parts = [p.text for p in doc.paragraphs] - for table in doc.tables: - for row in table.rows: - for cell in row.cells: - parts.append(cell.text) - return "\n".join(parts) - - -# --- Word ------------------------------------------------------------------- - -def test_convert_word_creates_valid_docx(sample_pdf, tmp_path): - out = str(tmp_path / "out.docx") - convert.convert("word", sample_pdf, out) - assert os.path.exists(out) - assert zipfile.is_zipfile(out) # docx is a zip container - text = _docx_text(out) - assert "Hello" in text - - -# --- Excel ------------------------------------------------------------------ - -def test_convert_excel_creates_valid_xlsx(sample_pdf, tmp_path): - out = str(tmp_path / "out.xlsx") - convert.convert("excel", sample_pdf, out) - assert os.path.exists(out) - assert zipfile.is_zipfile(out) # xlsx is a zip container - wb = openpyxl.load_workbook(out) - assert len(wb.sheetnames) >= 1 - - -# --- PowerPoint ------------------------------------------------------------- - -def test_convert_ppt_creates_valid_pptx_with_slide_per_page(sample_pdf, tmp_path): - out = str(tmp_path / "out.pptx") - convert.convert("ppt", sample_pdf, out) - assert os.path.exists(out) - assert zipfile.is_zipfile(out) # pptx is a zip container - prs = Presentation(out) - assert len(prs.slides) == 2 # one slide per source page - - -# --- Dispatcher / argument handling ---------------------------------------- - -def test_convert_unknown_format_raises(sample_pdf, tmp_path): - out = str(tmp_path / "out.bin") - with pytest.raises(ValueError): - convert.convert("csv", sample_pdf, out) - - -def test_convert_missing_input_raises(tmp_path): - out = str(tmp_path / "out.docx") - with pytest.raises(FileNotFoundError): - convert.convert("word", str(tmp_path / "missing.pdf"), out) - - -def test_main_wrong_args_returns_nonzero(): - assert convert.main(["word", "only-two-args"]) != 0 - - -def test_main_unknown_format_returns_nonzero(sample_pdf, tmp_path): - out = str(tmp_path / "out.bin") - assert convert.main(["csv", sample_pdf, out]) != 0 - - -def test_main_success_returns_zero(sample_pdf, tmp_path): - out = str(tmp_path / "ok.docx") - assert convert.main(["word", sample_pdf, out]) == 0 - assert os.path.exists(out) diff --git a/plugins/pdf-process/public/preload/watermark-font.ttf b/plugins/pdf-process/public/preload/watermark-font.ttf deleted file mode 100644 index 10c815a0d..000000000 Binary files a/plugins/pdf-process/public/preload/watermark-font.ttf and /dev/null differ diff --git a/plugins/pdf-process/scripts/_services-head.js b/plugins/pdf-process/scripts/_services-head.js deleted file mode 100644 index 1c50798fd..000000000 --- a/plugins/pdf-process/scripts/_services-head.js +++ /dev/null @@ -1,117 +0,0 @@ -process.env.DISABLE_SYSTEM_FONTS_LOAD = '1' -const fs = require('node:fs') -const path = require('node:path') -const { PDFDocument } = require('pdf-lib') -const { - assertSafeOutputPath, - assertSafeInputFile, - safePathLabel, -} = require('./path-guard') -const pdfcpu = require('./lib/pdfcpu-runner') -const { createPdfFromImages: buildPdfFromImages } = require('./lib/create-pdf-from-images') -const { strongCompressPdf } = require('./lib/strong-compress') -const settingsStore = require('./lib/settings-store') -const { resolveTaskCoords } = require('./lib/task-paths') -const { - hexToRgb01, - rotatedTextBounds, - positionToXY, - tileSteps, -} = require('./lib/watermark-layout') - -function getDownloadsRoot() { - return window.ztools.getPath('downloads') -} - -function getLogPath() { - try { - const base = - (window.ztools && - window.ztools.getPath && - (window.ztools.getPath('userData') || window.ztools.getPath('downloads'))) || - null - if (base) return path.join(base, 'pdf-process.log') - } catch {} - try { - return path.join(require('node:os').tmpdir(), 'pdf-process.log') - } catch { - return path.join(process.cwd(), 'pdf-process.log') - } -} - -const LOG_PATH = getLogPath() - -function log(level, msg, data) { - const ts = new Date().toISOString() - let payload = data - if (typeof data === 'string' && (data.includes('\\') || data.includes('/'))) { - payload = safePathLabel(data) - } - const line = - '[' + - ts + - '] [' + - level + - '] ' + - msg + - (payload !== undefined ? ' ' + JSON.stringify(payload) : '') + - '\n' - try { - console.log(line.trim()) - } catch {} - try { - fs.appendFileSync(LOG_PATH, line, { encoding: 'utf-8' }) - } catch {} -} - -pdfcpu.setDeps({ log, safePathLabel }) - -const { callPdfcpu, cancelCurrent, getPdfcpuPath } = pdfcpu - -// Keep names for static source tests / asar helpers -function resolveNativePath(filePath) { - return pdfcpu.resolveNativePath(filePath) -} -function isInsideAsar(filePath) { - return pdfcpu.isInsideAsar(filePath) -} - -log('INFO', 'services.js loaded', { - pdfcpu: safePathLabel(getPdfcpuPath()), - logfile: safePathLabel(LOG_PATH), -}) - -function ensuredDir(dir) { - if (!fs.existsSync(dir)) { - log('INFO', 'mkdir', safePathLabel(dir)) - fs.mkdirSync(dir, { recursive: true }) - } - return dir -} - -function outputDir(feature) { - const name = String(feature || 'out').replace(/[^a-zA-Z0-9_-]/g, '') || 'out' - return ensuredDir(path.join(getDownloadsRoot(), 'pdf-' + name)) -} - -function safeOut(filePath, label) { - return assertSafeOutputPath(filePath, getDownloadsRoot(), label) -} - -function safeIn(filePath) { - return assertSafeInputFile(filePath, fs) -} - -function listFiles(dir, exts) { - if (!fs.existsSync(dir)) { - log('WARN', 'listFiles: dir not found', safePathLabel(dir)) - return [] - } - const files = fs - .readdirSync(dir) - .filter((f) => exts.some((e) => f.toLowerCase().endsWith(e))) - .map((f) => path.join(dir, f)) - .sort() - log('INFO', 'listFiles', { dir: safePathLabel(dir), count: files.length }) - return files -} diff --git a/plugins/pdf-process/scripts/_services-tail.js b/plugins/pdf-process/scripts/_services-tail.js deleted file mode 100644 index 8ced6f741..000000000 --- a/plugins/pdf-process/scripts/_services-tail.js +++ /dev/null @@ -1,260 +0,0 @@ -window.services = { - cancelCurrent, - - deleteFile(filePath) { - try { - const resolved = safeOut(filePath, '删除路径') - if (fs.existsSync(resolved)) { - fs.unlinkSync(resolved) - log('INFO', 'deleteFile', safePathLabel(resolved)) - return true - } - } catch (e) { - log('WARN', 'deleteFile failed', e && e.message) - } - return false - }, - - writeImageFile(base64Url, outputPath) { - const matchs = /^data:image\/([a-z]{1,20});base64,/i.exec(base64Url) - if (!matchs) { - log('WARN', 'writeImageFile: invalid base64') - return - } - let filePath - if (outputPath) { - filePath = safeOut(outputPath, '图片输出路径') - ensuredDir(path.dirname(filePath)) - } else { - const dir = outputDir('images') - filePath = path.join(dir, Date.now().toString() + '.' + matchs[1]) - } - fs.writeFileSync(filePath, base64Url.substring(matchs[0].length), { encoding: 'base64' }) - log('INFO', 'writeImageFile', safePathLabel(filePath)) - return filePath - }, - - async createPdfFromImages(imagePaths, outputPath, options = {}) { - const out = safeOut(outputPath, 'PDF 输出路径') - log('INFO', 'createPdfFromImages', { count: imagePaths.length, out: safePathLabel(out) }) - ensuredDir(path.dirname(out)) - const safeImages = imagePaths.map((p) => safeOut(p, '图片输入路径')) - await buildPdfFromImages(safeImages, out, options) - return out - }, - - async compressPdf(inputPath, outputPath, options = {}) { - const input = safeIn(inputPath) - const out = safeOut(outputPath, '压缩输出路径') - ensuredDir(path.dirname(out)) - const mode = options && options.mode === 'strong' ? 'strong' : 'optimize' - log('INFO', 'compressPdf', { - input: safePathLabel(input), - output: safePathLabel(out), - mode, - }) - - if (mode === 'strong') { - const tempDir = path.join(path.dirname(out), '.strong-tmp-' + Date.now()) - try { - await strongCompressPdf({ - inputPath: input, - outputPath: out, - quality: options.quality, - tempDir, - log, - }) - } finally { - try { - if (fs.existsSync(tempDir)) { - for (const f of fs.readdirSync(tempDir)) { - try { - fs.unlinkSync(path.join(tempDir, f)) - } catch {} - } - fs.rmdirSync(tempDir) - } - } catch {} - } - return out - } - - await callPdfcpu(['optimize', input, out]) - log('INFO', 'compressPdf done', safePathLabel(out)) - return out - }, - - async mergePdfs(inputPaths, outputPath) { - const inputs = inputPaths.map((p) => safeIn(p)) - const out = safeOut(outputPath, '合并输出路径') - ensuredDir(path.dirname(out)) - await callPdfcpu(['merge', out, ...inputs]) - return out - }, - - async splitPdf(inputPath, outputDirPath, options) { - const input = safeIn(inputPath) - const outDir = safeOut(outputDirPath, '拆分输出目录') - ensuredDir(outDir) - - if (typeof options === 'string' && options.trim()) { - const pagesSpec = options.trim() - if (!/^[0-9,\-\s]+$/.test(pagesSpec)) throw new Error('页码范围格式无效') - await callPdfcpu(['extract', '-m', 'page', '-p', pagesSpec, input, outDir]) - return listFiles(outDir, ['.pdf']) - } - - const opts = options && typeof options === 'object' ? options : {} - const pageRanges = Array.isArray(opts.pageRanges) ? opts.pageRanges : null - const beforePages = Array.isArray(opts.beforePages) - ? opts.beforePages.map((n) => Math.floor(Number(n))).filter((n) => n >= 2) - : null - const span = opts.span != null ? Math.max(1, Math.floor(Number(opts.span) || 1)) : null - const mergeRanges = opts.mergeRanges !== false - - if (pageRanges && pageRanges.length > 0) { - const base = path.basename(input, path.extname(input)) || 'split' - const normalized = [] - for (const pair of pageRanges) { - const a = Math.floor(Number(pair[0])) - const b = Math.floor(Number(pair[1])) - if (a >= 1 && b >= a) normalized.push([a, b]) - } - if (!normalized.length) throw new Error('没有有效的页码范围') - - if (mergeRanges || normalized.length === 1) { - const pagesSpec = normalized - .map(([a, b]) => (a === b ? String(a) : a + '-' + b)) - .join(',') - const label = - normalized.length === 1 - ? normalized[0][0] === normalized[0][1] - ? String(normalized[0][0]) - : normalized[0][0] + '-' + normalized[0][1] - : 'extract' - const outFile = path.join(outDir, base + '_' + label + '.pdf') - await callPdfcpu(['collect', '-p', pagesSpec, input, outFile]) - return [outFile] - } - - const outs = [] - for (const [a, b] of normalized) { - const label = a === b ? String(a) : a + '-' + b - const outFile = path.join(outDir, base + '_' + label + '.pdf') - await callPdfcpu(['collect', '-p', a + '-' + b, input, outFile]) - outs.push(outFile) - } - return outs - } - - if (beforePages && beforePages.length > 0) { - const unique = Array.from(new Set(beforePages)).sort((a, b) => a - b) - await callPdfcpu(['split', '-m', 'page', input, outDir, ...unique.map(String)]) - } else if (span != null) { - await callPdfcpu(['split', '-m', 'span', input, outDir, String(span)]) - } else { - await callPdfcpu(['split', '-m', 'span', input, outDir, '1']) - } - return listFiles(outDir, ['.pdf']) - }, - - async addWatermark(inputPath, outputPath, watermark) { - const input = safeIn(inputPath) - const out = safeOut(outputPath, '水印输出路径') - ensuredDir(path.dirname(out)) - const text = (watermark && watermark.text) || 'Watermark' - const opacity = watermark && watermark.opacity != null ? Number(watermark.opacity) : 0.3 - const points = watermark && watermark.points != null ? Number(watermark.points) : 36 - const rotation = watermark && watermark.rotation != null ? Number(watermark.rotation) : 0 - const margin = watermark && watermark.margin != null ? Number(watermark.margin) : 20 - const tile = !!(watermark && watermark.tile) - const position = (watermark && watermark.position) || 'mc' - const color = (watermark && watermark.color) || '#808080' - const density = watermark && watermark.density != null ? Number(watermark.density) : 3 - - try { - await addWatermarkWithPdfLib(input, out, { - text, - opacity, - points, - rotation, - margin, - tile, - position, - color, - density, - }) - log('INFO', 'addWatermark done via pdf-lib', { output: safePathLabel(out) }) - return out - } catch (e) { - log('WARN', 'pdf-lib watermark failed, fallback pdfcpu', e && e.message) - } - - const font = selectFontForText(text) - if (font.windowsName) ensurePdfcpuFont(font.windowsName, font.pdfcpuName) - const posMap = { - tl: 'tl', - tc: 'tc', - tr: 'tr', - ml: 'l', - mc: 'c', - mr: 'r', - bl: 'bl', - bc: 'bc', - br: 'br', - l: 'l', - c: 'c', - r: 'r', - } - const pos = posMap[position] || 'c' - const desc = [ - 'fontname:' + font.pdfcpuName, - 'points:' + points, - 'opacity:' + opacity, - 'rot:' + rotation, - 'fillcol:' + color, - 'pos:' + (tile ? 'c' : pos), - ].join(', ') - try { - await callPdfcpu(['stamp', 'add', '-mode', 'text', desc, text, input, out]) - } catch (e1) { - await callPdfcpu(['watermark', 'add', '-mode', 'text', desc, text, input, out]) - } - return out - }, - - async convertPdf(inputPath, outputPath, format) { - const input = safeIn(inputPath) - const out = safeOut(outputPath, '转换输出路径') - if (!['word', 'ppt', 'excel'].includes(format)) throw new Error('不支持的转换格式: ' + format) - ensuredDir(path.dirname(out)) - let convertPdfLocal - try { - ;({ convertPdfLocal } = require('./convert/convert-local')) - } catch (e) { - if (e && e.code === 'MODULE_NOT_FOUND') { - throw new Error('本地转换依赖未安装,请在 public/preload 执行 npm install') - } - throw e - } - return convertPdfLocal({ inputPath: input, outputPath: out, format }) - }, - - resolveTaskPath(coords) { - const r = resolveTaskCoords(getDownloadsRoot(), coords) - if (r.filePath) { - ensuredDir(path.dirname(r.filePath)) - return r.filePath - } - ensuredDir(r.dir) - return r.dir - }, - - async getSettings() { - return settingsStore.loadSettings(window.ztools.dbStorage) - }, - - async saveSettings(settings) { - settingsStore.saveSettings(window.ztools.dbStorage, settings) - }, -} diff --git a/plugins/pdf-process/scripts/build-services-facade.cjs b/plugins/pdf-process/scripts/build-services-facade.cjs deleted file mode 100644 index ef3319f1d..000000000 --- a/plugins/pdf-process/scripts/build-services-facade.cjs +++ /dev/null @@ -1,20 +0,0 @@ -const fs = require('fs') -const path = require('path') - -const oldPath = path.join(__dirname, '../public/preload/services.js') -// Prefer full backup if present -const backup = '/tmp/services-full-backup.js' -const srcPath = fs.existsSync(backup) ? backup : oldPath -const lines = fs.readFileSync(srcPath, 'utf8').split(/\n/) - -// 1-based line ranges from backup: -// 135-279: CJK + fonts + selectFont + ensurePdfcpuFont (before outputDir) -// 449-546: loadCjkFontBytes + addWatermarkWithPdfLib -const part1 = lines.slice(134, 279).join('\n') // 135..279 -const part2 = lines.slice(448, 546).join('\n') // 449..546 - -const head = fs.readFileSync(path.join(__dirname, '_services-head.js'), 'utf8') -const tail = fs.readFileSync(path.join(__dirname, '_services-tail.js'), 'utf8') -const out = head + '\n' + part1 + '\n\n' + part2 + '\n' + tail -fs.writeFileSync(oldPath, out) -console.log('wrote', oldPath, 'lines', out.split(/\n/).length) diff --git a/plugins/pdf-process/scripts/install-preload-deps.cjs b/plugins/pdf-process/scripts/install-preload-deps.cjs index 66b574ddb..6150dc2f4 100644 --- a/plugins/pdf-process/scripts/install-preload-deps.cjs +++ b/plugins/pdf-process/scripts/install-preload-deps.cjs @@ -1,4 +1,6 @@ const { spawnSync } = require('node:child_process') +const crypto = require('node:crypto') +const fs = require('node:fs') const path = require('node:path') if (process.env.PDF_PROCESS_PRELOAD_INSTALLING === '1') { @@ -6,20 +8,52 @@ if (process.env.PDF_PROCESS_PRELOAD_INSTALLING === '1') { } const preloadDir = path.resolve(__dirname, '..', 'public', 'preload') +const lockPath = path.join(preloadDir, 'package-lock.json') +const stampPath = path.join(preloadDir, 'node_modules', '.pdf-process-lock') +const lockHash = fs.existsSync(lockPath) + ? crypto.createHash('sha256').update(fs.readFileSync(lockPath)).digest('hex') + : null +const requiredPackages = [ + '@pdf-lib/fontkit', + 'docx', + 'exceljs', + 'pdf-lib', + 'pdfjs-dist', + 'pptxgenjs', +] + +const installationIsCurrent = + lockHash && + fs.existsSync(stampPath) && + fs.readFileSync(stampPath, 'utf8').trim() === lockHash && + requiredPackages.every((name) => + fs.existsSync(path.join(preloadDir, 'node_modules', ...name.split('/'), 'package.json')), + ) + +if (installationIsCurrent) { + console.log('Preload dependencies are up to date') + process.exit(0) +} + const npmExecPath = process.env.npm_execpath const env = { ...process.env, PDF_PROCESS_PRELOAD_INSTALLING: '1', } -const command = npmExecPath ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm' -const args = npmExecPath ? [npmExecPath, 'install'] : ['install'] +const bundledNpmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') +const npmCli = [npmExecPath, bundledNpmCli].find((candidate) => candidate && fs.existsSync(candidate)) +const command = npmCli ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm' +const npmArgs = fs.existsSync(lockPath) + ? ['ci', '--omit=optional', '--no-audit', '--no-fund'] + : ['install', '--omit=optional', '--no-audit', '--no-fund'] +const args = npmCli ? [npmCli, ...npmArgs] : npmArgs const result = spawnSync(command, args, { cwd: preloadDir, env, stdio: 'inherit', - shell: false, + shell: !npmCli && process.platform === 'win32', }) if (result.error) { @@ -27,4 +61,6 @@ if (result.error) { process.exit(1) } -process.exit(result.status ?? 1) +if (result.status !== 0) process.exit(result.status ?? 1) + +if (lockHash) fs.writeFileSync(stampPath, lockHash + '\n') diff --git a/plugins/pdf-process/scripts/optimize-package.cjs b/plugins/pdf-process/scripts/optimize-package.cjs new file mode 100644 index 000000000..c268d0732 --- /dev/null +++ b/plugins/pdf-process/scripts/optimize-package.cjs @@ -0,0 +1,152 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const asar = require('@electron/asar') +const archiver = require('archiver') +const esbuild = require('esbuild') + +const root = path.resolve(__dirname, '..') +const dist = path.join(root, 'dist') +const preloadSource = path.join(root, 'public', 'preload') +const preloadDist = path.join(dist, 'preload') +const sourceModules = path.join(preloadSource, 'node_modules') +const distModules = path.join(preloadDist, 'node_modules') +const tempBase = path.join(os.tmpdir(), `pdf-process-size-${process.pid}`) +const asarPath = tempBase + '.asar' +const zipPath = tempBase + '.zip' +const maxPackageBytes = 15 * 1024 * 1024 + +function copy(source, target) { + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.cpSync(source, target, { recursive: true }) +} + +function packagePath(name) { + return path.join(sourceModules, ...name.split('/')) +} + +function copyPackageFile(name, relativePath) { + copy(path.join(packagePath(name), relativePath), path.join(distModules, ...name.split('/'), relativePath)) +} + +function listFiles(directory, prefix = '') { + const files = [] + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relativePath = prefix ? path.join(prefix, entry.name) : entry.name + if (entry.isDirectory()) files.push(...listFiles(path.join(directory, entry.name), relativePath)) + else if (entry.isFile()) files.push(relativePath) + } + return files +} + +function verifyPackage() { + const rootManifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) + const pluginManifest = JSON.parse(fs.readFileSync(path.join(dist, 'plugin.json'), 'utf8')) + if (pluginManifest.version !== rootManifest.version) { + throw new Error(`Version mismatch: package.json=${rootManifest.version}, plugin.json=${pluginManifest.version}`) + } + if (JSON.stringify(pluginManifest.platform) !== JSON.stringify(['win32'])) { + throw new Error('plugin.json platform must be exactly ["win32"] for the Windows PR build') + } + if ('unpack' in pluginManifest) { + throw new Error('plugin.json must not declare unpack when the package has no native runtime files') + } + + for (const relativePath of [pluginManifest.main, pluginManifest.preload, pluginManifest.logo]) { + if (!relativePath || !fs.existsSync(path.join(dist, relativePath))) { + throw new Error(`Missing packaged entry: ${relativePath || '(empty)'}`) + } + } + + const forbidden = listFiles(dist).filter((file) => + /(^|[\\/])__tests__([\\/]|$)|\.(exe|map|py|pyc)$|(^|[\\/])package-lock\.json$/i.test(file), + ) + if (forbidden.length) { + throw new Error('Forbidden files in dist:\n' + forbidden.join('\n')) + } +} + +function createPrZip() { + return new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipPath) + const archive = archiver('zip', { zlib: { level: 9 } }) + output.on('close', () => resolve(archive.pointer())) + output.on('error', reject) + archive.on('error', reject) + archive.pipe(output) + for (const entry of fs.readdirSync(dist, { withFileTypes: true })) { + const entryPath = path.join(dist, entry.name) + if (entry.isDirectory()) archive.directory(entryPath, entry.name) + else archive.file(entryPath, { name: entry.name }) + } + archive.finalize() + }) +} + +async function main() { + if (!fs.existsSync(sourceModules)) { + throw new Error('Preload dependencies are missing; run npm install first') + } + + const bundlePath = path.join(root, '.preload-services.cjs') + await esbuild.build({ + entryPoints: [path.join(preloadSource, 'services.js')], + outfile: bundlePath, + bundle: true, + minify: true, + platform: 'node', + format: 'cjs', + target: 'node20', + external: ['pdfjs-dist/*'], + logLevel: 'info', + }) + + fs.rmSync(preloadDist, { recursive: true, force: true }) + fs.mkdirSync(preloadDist, { recursive: true }) + fs.renameSync(bundlePath, path.join(preloadDist, 'services.js')) + fs.writeFileSync(path.join(preloadDist, 'package.json'), '{"type":"commonjs"}\n') + + for (const file of [ + 'package.json', + 'legacy/build/pdf.mjs', + 'legacy/build/pdf.worker.mjs', + 'cmaps', + 'standard_fonts', + 'wasm', + ]) { + const source = path.join(packagePath('pdfjs-dist'), file) + if (fs.existsSync(source)) copyPackageFile('pdfjs-dist', file) + } + + fs.rmSync(path.join(dist, 'bin'), { recursive: true, force: true }) + verifyPackage() + + fs.rmSync(asarPath, { force: true }) + fs.rmSync(asarPath + '.unpacked', { recursive: true, force: true }) + fs.rmSync(zipPath, { force: true }) + await asar.createPackage(dist, asarPath) + + const rawBytes = listFiles(dist) + .reduce((total, file) => total + fs.statSync(path.join(dist, file)).size, 0) + const asarBytes = fs.statSync(asarPath).size + const zipBytes = await createPrZip() + fs.rmSync(asarPath, { force: true }) + fs.rmSync(asarPath + '.unpacked', { recursive: true, force: true }) + fs.rmSync(zipPath, { force: true }) + const mb = (bytes) => (bytes / 1024 / 1024).toFixed(2) + console.log(`Optimized dist: ${mb(rawBytes)} MB raw`) + console.log(`PR package: ${mb(zipBytes)} MB ZIP (limit 15 MB)`) + console.log(`Runtime package: ${mb(asarBytes)} MB ASAR (limit 15 MB)`) + + if (zipBytes > maxPackageBytes) { + throw new Error(`PR ZIP is ${mb(zipBytes)} MB; limit is 15 MB`) + } + if (asarBytes > maxPackageBytes) { + throw new Error(`ASAR package is ${mb(asarBytes)} MB; limit is 15 MB`) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/pdf-process/src/Compress/index.tsx b/plugins/pdf-process/src/Compress/index.tsx index db0ebe9f0..75c233845 100644 --- a/plugins/pdf-process/src/Compress/index.tsx +++ b/plugins/pdf-process/src/Compress/index.tsx @@ -17,7 +17,7 @@ import { type PageSizePt, } from '../utils/strongCompress' import { pickPdfFiles } from '../utils/pickFiles' -import { ensureBrowserFile } from '../utils/fileFromShared' +import { ensureBrowserFile, withInputPath } from '../utils/fileFromShared' import './index.css' interface CompressProps { @@ -41,10 +41,7 @@ function dirnameOf(filePath: string) { /** * Strong-compress in the RENDERER with DOM canvas + browser pdfjs. - * Preload @napi-rs/canvas mixes with Electron DOM and throws: - * Value is non of these types `CanvasElement`, `SVGCanvas`, `Image` - * and system font loading throws String|Path. Thumbs/PdfToImage already - * prove this path works in ZTools. + * Thumbs/PdfToImage already prove this path works in ZTools. */ async function strongCompressInRenderer( source: File, @@ -193,7 +190,6 @@ export default function Compress(_props: CompressProps) { const outputs: string[] = [] for (const file of targets) { const outputPath = buildOutputPath(file.name || file.path) - const inputPath = resolvePath(file) if (strongCompress) { // Renderer DOM canvas — do NOT call preload napi canvas path const browserFile = await ensureBrowserFile(file) @@ -206,7 +202,9 @@ export default function Compress(_props: CompressProps) { ) outputs.push(out) } else { - await window.services.compressPdf(inputPath, outputPath, { mode: 'optimize' }) + await withInputPath(file, (inputPath) => + window.services.compressPdf(inputPath, outputPath, { mode: 'optimize' }), + ) outputs.push(outputPath) } } diff --git a/plugins/pdf-process/src/Merge/index.tsx b/plugins/pdf-process/src/Merge/index.tsx index 21b7c5111..721d4101b 100644 --- a/plugins/pdf-process/src/Merge/index.tsx +++ b/plugins/pdf-process/src/Merge/index.tsx @@ -4,6 +4,7 @@ import FeatureLayout from '../components/FeatureLayout' import { useOperation } from '../hooks/useOperation' import { useSharedFiles } from '../context/SharedFilesContext' import { generateTaskId, buildTaskOutputPath } from '../hooks/useTaskFolder' +import { withInputPaths } from '../utils/fileFromShared' import './index.css' interface MergeProps { onBack?: () => void } @@ -19,14 +20,15 @@ export default function Merge(_props: MergeProps) { } const taskId = generateTaskId('merged') execute(async () => { - const inputPaths = files.map((f) => f.path) const outputPath = buildTaskOutputPath( window.ztools.getPath('downloads'), 'merge', 'merged_' + Date.now() + '.pdf', taskId, ) - await window.services.mergePdfs(inputPaths, outputPath) + await withInputPaths(files, (inputPaths) => + window.services.mergePdfs(inputPaths, outputPath), + ) window.ztools.showNotification('合并完成') return outputPath }) diff --git a/plugins/pdf-process/src/Split/index.tsx b/plugins/pdf-process/src/Split/index.tsx index 1fb42c1ba..f855f806c 100644 --- a/plugins/pdf-process/src/Split/index.tsx +++ b/plugins/pdf-process/src/Split/index.tsx @@ -4,6 +4,7 @@ import OperationResult from '../components/OperationResult' import { useOperation } from '../hooks/useOperation' import { useSharedFiles, type SharedFile } from '../context/SharedFilesContext' import { generateTaskId, buildTaskOutputDir } from '../hooks/useTaskFolder' +import { withInputPath } from '../utils/fileFromShared' import { renderPdfAllPageThumbs } from '../utils/pdfThumb' import { beforePagesFromCutAfter, @@ -340,7 +341,6 @@ export default function Split(_props: SplitProps) { execute(async () => { const taskId = generateTaskId(target.name || target.path) const outputDir = buildTaskOutputDir(window.ztools.getPath('downloads'), 'split', taskId) - const inputPath = resolvePath(target) const intent = mode === 'extract' @@ -363,7 +363,9 @@ export default function Split(_props: SplitProps) { } as const) const args = buildSplitInvocation(intent) - const out = await window.services.splitPdf(inputPath, outputDir, args) + const out = await withInputPath(target, (inputPath) => + window.services.splitPdf(inputPath, outputDir, args), + ) const list = Array.isArray(out) ? out : [out] window.ztools.showNotification( diff --git a/plugins/pdf-process/src/Watermark/index.tsx b/plugins/pdf-process/src/Watermark/index.tsx index 091b315ed..bd583f3f4 100644 --- a/plugins/pdf-process/src/Watermark/index.tsx +++ b/plugins/pdf-process/src/Watermark/index.tsx @@ -3,8 +3,9 @@ import WorkspaceFiles from '../components/WorkspaceFiles' import OperationResult from '../components/OperationResult' import FeatureLayout from '../components/FeatureLayout' import { useOperation } from '../hooks/useOperation' -import { useSharedFiles, type SharedFile } from '../context/SharedFilesContext' +import { useSharedFiles } from '../context/SharedFilesContext' import { generateTaskId, buildTaskOutputPath, buildConvertedFilename } from '../hooks/useTaskFolder' +import { withInputPath } from '../utils/fileFromShared' import './index.css' interface WatermarkProps { @@ -36,10 +37,6 @@ const POSITIONS: { key: PosKey; label: string }[] = [ const DENSITY_LABELS = ['疏', '较疏', '中', '较密', '密'] -function resolvePath(file: SharedFile) { - return file.rawFile ? window.ztools.getPathForFile(file.rawFile) : file.path -} - export default function Watermark(_props: WatermarkProps) { const [text, setText] = useState('机密文件') const [fontSize, setFontSize] = useState(20) @@ -72,17 +69,19 @@ export default function Watermark(_props: WatermarkProps) { buildConvertedFilename(srcName, '.pdf'), taskId, ) - await window.services.addWatermark(resolvePath(file), outputPath, { - text: text.trim(), - opacity: Math.min(1, Math.max(0.05, opacity / 100)), - points: fontSize, - rotation, - position: tile ? 'c' : position, - margin, - color, - tile, - density, - }) + await withInputPath(file, (inputPath) => + window.services.addWatermark(inputPath, outputPath, { + text: text.trim(), + opacity: Math.min(1, Math.max(0.05, opacity / 100)), + points: fontSize, + rotation, + position: tile ? 'c' : position, + margin, + color, + tile, + density, + }), + ) outputs.push(outputPath) } window.ztools.showNotification('水印添加完成(' + outputs.length + ' 个)') diff --git a/plugins/pdf-process/src/components/PdfConvertPage.tsx b/plugins/pdf-process/src/components/PdfConvertPage.tsx index 31082ff54..fd80c2c72 100644 --- a/plugins/pdf-process/src/components/PdfConvertPage.tsx +++ b/plugins/pdf-process/src/components/PdfConvertPage.tsx @@ -3,7 +3,8 @@ import OperationResult from '../components/OperationResult' import FeatureLayout from '../components/FeatureLayout' import ConvertWebRecommend from '../components/ConvertWebRecommend' import { useOperation } from '../hooks/useOperation' -import { useSharedFiles } from '../context/SharedFilesContext' +import { useSharedFiles, type SharedFile } from '../context/SharedFilesContext' +import { ensureBrowserFile, withInputPath } from '../utils/fileFromShared' import { generateTaskId, buildTaskOutputPath, @@ -29,6 +30,68 @@ interface PdfConvertPageProps { onOpenSettings?: () => void } +function dirnameOf(filePath: string) { + const normalized = filePath.replace(/\\/g, '/') + const index = normalized.lastIndexOf('/') + return index > 0 ? filePath.slice(0, index) : filePath +} + +async function renderPdfPages(file: SharedFile, outputPath: string) { + const source = await ensureBrowserFile(file) + const pdfjs = await import('pdfjs-dist') + pdfjs.GlobalWorkerOptions.workerSrc = './pdf.worker.min.mjs' + const pdf = await pdfjs.getDocument({ + data: new Uint8Array(await source.arrayBuffer()), + useSystemFonts: true, + isEvalSupported: false, + }).promise + const taskDir = dirnameOf(outputPath) + const separator = taskDir.includes('\\') ? '\\' : '/' + const pages: Array<{ path: string; width: number; height: number }> = [] + + try { + const count = Math.min(pdf.numPages, 50) + for (let pageNumber = 1; pageNumber <= count; pageNumber += 1) { + const page = await pdf.getPage(pageNumber) + const viewport = page.getViewport({ scale: 1.5 }) + const canvas = document.createElement('canvas') + canvas.width = Math.ceil(viewport.width) + canvas.height = Math.ceil(viewport.height) + const context = canvas.getContext('2d', { alpha: false }) + if (!context) throw new Error('Canvas 2D 不可用') + context.fillStyle = '#ffffff' + context.fillRect(0, 0, canvas.width, canvas.height) + await page.render({ canvasContext: context, viewport }).promise + const imagePath = taskDir + separator + `scan-page-${pageNumber}.png` + const saved = window.services.writeImageFile(canvas.toDataURL('image/png'), imagePath) + if (!saved) throw new Error(`写入第 ${pageNumber} 页图像失败`) + pages.push({ path: saved, width: canvas.width, height: canvas.height }) + canvas.width = 0 + canvas.height = 0 + } + } finally { + await pdf.destroy().catch(() => {}) + } + return pages +} + +async function convertWithScanFallback(file: SharedFile, outputPath: string, format: ConvertFormat) { + try { + return await withInputPath(file, (inputPath) => + window.services.convertPdf(inputPath, outputPath, format), + ) + } catch (error) { + if ((error as { code?: string })?.code !== 'SCAN_RENDER_REQUIRED') throw error + const pages = await renderPdfPages(file, outputPath) + try { + if (!window.services.convertPdfImages) throw new Error('页面图像转换服务不可用') + return await window.services.convertPdfImages(pages, outputPath, format as 'word' | 'ppt') + } finally { + for (const page of pages) window.services.deleteFile?.(page.path) + } + } +} + /** One Convert feature module parameterized by format (Word / PPT / Excel). */ export default function PdfConvertPage({ format, onOpenSettings }: PdfConvertPageProps) { const meta = FORMAT_META[format] @@ -49,7 +112,7 @@ export default function PdfConvertPage({ format, onOpenSettings }: PdfConvertPag buildConvertedFilename(file.name || file.path, meta.ext), taskId, ) - await window.services.convertPdf(file.path, outputPath, format) + await convertWithScanFallback(file, outputPath, format) outputs.push(outputPath) } return outputs diff --git a/plugins/pdf-process/src/env.d.ts b/plugins/pdf-process/src/env.d.ts index 757787b67..af911e796 100644 --- a/plugins/pdf-process/src/env.d.ts +++ b/plugins/pdf-process/src/env.d.ts @@ -11,13 +11,12 @@ interface Services { ) => Promise cancelCurrent: () => void /** - * mode 'optimize' (default) = pdfcpu optimize. - * mode 'strong' = DPI raster JPEG re-encode (quality 1–100). + * mode 'optimize' (default) = rewrite PDF object streams with pdf-lib. */ compressPdf: ( inputPath: string, outputPath: string, - options?: { quality?: number; mode?: 'optimize' | 'strong' }, + options?: { mode?: 'optimize' }, ) => Promise mergePdfs: (inputPaths: string[], outputPath: string) => Promise splitPdf: ( @@ -47,6 +46,11 @@ interface Services { }) => Promise deleteFile?: (filePath: string) => boolean convertPdf: (inputPath: string, outputPath: string, format: 'word' | 'ppt' | 'excel') => Promise + convertPdfImages?: ( + pages: Array<{ path: string; width: number; height: number }>, + outputPath: string, + format: 'word' | 'ppt', + ) => Promise /** Resolve { feature, taskId, filename? } under downloads/pdf-*. */ resolveTaskPath: (coords: { feature: string @@ -55,8 +59,10 @@ interface Services { }) => string /** Best-effort file size for paths from the open dialog (no File handle). */ statFile?: (filePath: string) => { size: number; mtimeMs?: number } | null - /** Read user-selected file bytes as base64 (path-only strong compress). */ + /** Read user-selected file bytes as base64 for renderer-side PDF processing. */ readFileBase64?: (filePath: string) => string + /** Materialize renderer-supplied bytes under a pdf-* task directory. */ + writeFileBase64?: (base64: string, outputPath: string) => string /** Page count for path-only PDFs (no browser File). */ getPdfPageCount?: (filePath: string) => Promise getSettings: () => Promise diff --git a/plugins/pdf-process/src/test/fileFromShared.test.ts b/plugins/pdf-process/src/test/fileFromShared.test.ts new file mode 100644 index 000000000..73936d680 --- /dev/null +++ b/plugins/pdf-process/src/test/fileFromShared.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + materializeInputPath, + resolveInputPath, + withInputPath, + withInputPaths, +} from '../utils/fileFromShared' +import type { SharedFile } from '../context/SharedFilesContext' + +function makeFile(overrides: Partial = {}): SharedFile { + return { + id: '1', + path: 'C:\\docs\\a.pdf', + name: 'a.pdf', + size: 10, + thumbStatus: 'idle', + ...overrides, + } +} + +describe('resolveInputPath', () => { + beforeEach(() => vi.clearAllMocks()) + + it('prefers a real path from the browser File', () => { + vi.mocked(window.ztools.getPathForFile!).mockReturnValue('C:\\docs\\real.pdf') + const file = makeFile({ rawFile: new File(['x'], 'a.pdf') }) + expect(resolveInputPath(file)).toBe('C:\\docs\\real.pdf') + }) + + it('returns empty when the browser File has no disk path', () => { + vi.mocked(window.ztools.getPathForFile!).mockReturnValue('') + const file = makeFile({ rawFile: new File(['x'], 'a.pdf') }) + expect(resolveInputPath(file)).toBe('') + }) + + it('uses the path for path-only entries', () => { + expect(resolveInputPath(makeFile())).toBe('C:\\docs\\a.pdf') + }) +}) + +describe('withInputPath', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses the direct path without materializing', async () => { + vi.mocked(window.ztools.getPathForFile!).mockReturnValue('C:\\docs\\real.pdf') + const result = await withInputPath( + makeFile({ rawFile: new File(['x'], 'a.pdf') }), + async (inputPath) => { + expect(inputPath).toBe('C:\\docs\\real.pdf') + return 'ok' + }, + ) + expect(result).toBe('ok') + expect(window.services.writeFileBase64).not.toHaveBeenCalled() + }) + + it('materializes browser-only bytes under pdf-tmp and deletes them', async () => { + vi.mocked(window.ztools.getPathForFile!).mockReturnValue('') + vi.mocked(window.services.deleteFile!).mockReturnValue(true) + let seen = '' + const result = await withInputPath( + makeFile({ rawFile: new File(['pdf-bytes'], 'a.pdf') }), + async (inputPath) => { + seen = inputPath + expect(inputPath).toContain('pdf-tmp') + expect(inputPath.endsWith('a.pdf')).toBe(true) + return 'ok' + }, + ) + expect(result).toBe('ok') + expect(window.services.writeFileBase64).toHaveBeenCalledTimes(1) + expect(window.services.deleteFile).toHaveBeenCalledWith(seen) + }) + + it('uses the stored path for path-only entries', async () => { + const result = await withInputPath(makeFile(), async (inputPath) => { + expect(inputPath).toBe('C:\\docs\\a.pdf') + return 'ok' + }) + expect(result).toBe('ok') + }) +}) + +describe('withInputPaths', () => { + beforeEach(() => vi.clearAllMocks()) + + it('preserves order and cleans only materialized temps', async () => { + vi.mocked(window.ztools.getPathForFile!).mockReturnValue('') + vi.mocked(window.services.deleteFile!).mockReturnValue(true) + const first = makeFile({ id: '1', name: 'a.pdf', rawFile: new File(['a'], 'a.pdf') }) + const second = makeFile({ id: '2', path: 'C:\\docs\\b.pdf' }) + const third = makeFile({ id: '3', name: 'c.pdf', rawFile: new File(['c'], 'c.pdf') }) + + await withInputPaths([first, second, third], async (inputPaths) => { + expect(inputPaths[0]).toContain('pdf-tmp') + expect(inputPaths[1]).toBe('C:\\docs\\b.pdf') + expect(inputPaths[2]).toContain('pdf-tmp') + return 'ok' + }) + + expect(window.services.writeFileBase64).toHaveBeenCalledTimes(2) + expect(window.services.deleteFile).toHaveBeenCalledTimes(2) + }) +}) + +describe('materializeInputPath', () => { + it('returns the path written by writeFileBase64', async () => { + vi.mocked(window.services.writeFileBase64!).mockImplementation( + (_base64: string, outputPath: string) => outputPath, + ) + const file = makeFile({ name: '合同 1.pdf', rawFile: new File(['x'], '合同 1.pdf') }) + const saved = await materializeInputPath(file) + expect(saved).toContain('pdf-tmp') + expect(saved.endsWith('合同 1.pdf')).toBe(true) + }) +}) diff --git a/plugins/pdf-process/src/test/setup.ts b/plugins/pdf-process/src/test/setup.ts index cbdd226ab..1aab59de8 100644 --- a/plugins/pdf-process/src/test/setup.ts +++ b/plugins/pdf-process/src/test/setup.ts @@ -9,6 +9,7 @@ const mockServices = { splitPdf: vi.fn(), addWatermark: vi.fn(), convertPdf: vi.fn(), + convertPdfImages: vi.fn(), cancelCurrent: vi.fn(), resolveTaskPath: vi.fn((c: { feature: string; taskId: string; filename?: string }) => { const base = '/mock/downloads/pdf-' + c.feature + '/' + c.taskId @@ -16,6 +17,7 @@ const mockServices = { }), statFile: vi.fn(() => ({ size: 4096 })), readFileBase64: vi.fn(() => ''), + writeFileBase64: vi.fn((_base64: string, outputPath: string) => outputPath), getPdfPageCount: vi.fn(async () => 3), getSettings: vi.fn(), saveSettings: vi.fn(), diff --git a/plugins/pdf-process/src/test/watermark-font.test.ts b/plugins/pdf-process/src/test/watermark-font.test.ts deleted file mode 100644 index f106a4ef5..000000000 --- a/plugins/pdf-process/src/test/watermark-font.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from 'vitest' - -/** - * Tests for CJK font detection logic. - * Mirrors the logic in public/preload/services.js selectFontForText() - */ - -// Copy of CJK_REGEX from services.js -const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/ - -// Simulates the pdfcpu font mapping: Windows name → pdfcpu name -const PDFCPU_FONT_MAP: Record = { - 'Microsoft YaHei': 'MicrosoftYaHei', -} - -function selectFontForText(text: string): string { - if (CJK_REGEX.test(text)) { - const windowsFont = 'Microsoft YaHei' - return PDFCPU_FONT_MAP[windowsFont] || windowsFont - } - return 'Helvetica' -} - -describe('CJK font detection', () => { - it('detects Chinese characters', () => { - expect(selectFontForText('机密文件')).toBe('MicrosoftYaHei') - expect(selectFontForText('水印测试')).toBe('MicrosoftYaHei') - expect(selectFontForText('中文')).toBe('MicrosoftYaHei') - }) - - it('detects Japanese characters', () => { - expect(selectFontForText('こんにちは')).toBe('MicrosoftYaHei') - expect(selectFontForText('テスト')).toBe('MicrosoftYaHei') - }) - - it('detects Korean characters', () => { - expect(selectFontForText('안녕하세요')).toBe('MicrosoftYaHei') - }) - - it('uses Helvetica for non-CJK text', () => { - expect(selectFontForText('Hello World')).toBe('Helvetica') - expect(selectFontForText('Watermark')).toBe('Helvetica') - expect(selectFontForText('123 ABC')).toBe('Helvetica') - }) - - it('detects mixed CJK and Latin text', () => { - expect(selectFontForText('Hello 世界')).toBe('MicrosoftYaHei') - expect(selectFontForText('PDF水印')).toBe('MicrosoftYaHei') - }) - - it('handles empty text', () => { - expect(selectFontForText('')).toBe('Helvetica') - }) -}) - -describe('CJK_REGEX pattern', () => { - it('matches CJK Unified Ideographs', () => { - expect(CJK_REGEX.test('中')).toBe(true) - expect(CJK_REGEX.test('国')).toBe(true) - expect(CJK_REGEX.test('文')).toBe(true) - }) - - it('matches Hiragana and Katakana', () => { - expect(CJK_REGEX.test('あ')).toBe(true) // Hiragana - expect(CJK_REGEX.test('ア')).toBe(true) // Katakana - }) - - it('matches Hangul', () => { - expect(CJK_REGEX.test('가')).toBe(true) - expect(CJK_REGEX.test('힣')).toBe(true) - }) - - it('does not match Latin or digits', () => { - expect(CJK_REGEX.test('A')).toBe(false) - expect(CJK_REGEX.test('Z')).toBe(false) - expect(CJK_REGEX.test('0')).toBe(false) - expect(CJK_REGEX.test('9')).toBe(false) - expect(CJK_REGEX.test('!')).toBe(false) - expect(CJK_REGEX.test(' ')).toBe(false) - }) -}) diff --git a/plugins/pdf-process/src/utils/fileFromShared.ts b/plugins/pdf-process/src/utils/fileFromShared.ts index 60bb550dd..a6f1e6595 100644 --- a/plugins/pdf-process/src/utils/fileFromShared.ts +++ b/plugins/pdf-process/src/utils/fileFromShared.ts @@ -1,4 +1,5 @@ import type { SharedFile } from '../context/SharedFilesContext' +import { generateTaskId, buildTaskOutputPath, sanitizeTaskName } from '../hooks/useTaskFolder' /** Build a browser File from a workspace entry (drag/drop or path-only open dialog). */ export async function ensureBrowserFile(file: SharedFile): Promise { @@ -25,3 +26,88 @@ export function fileFromBase64( for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) return new File([bytes], name || 'document.pdf', { type }) } + +/** Absolute input path when the browser File has one; empty string otherwise. */ +export function resolveInputPath(file: SharedFile): string { + if (file.rawFile) { + const direct = window.ztools.getPathForFile(file.rawFile) + return (direct || '').trim() + } + return (file.path || '').trim() +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + } + return btoa(binary) +} + +/** Write browser File bytes to a pdf-tmp task file and return its path. */ +export async function materializeInputPath(file: SharedFile): Promise { + if (typeof window.services.writeFileBase64 !== 'function') { + throw new Error('当前环境无法保存浏览器文件') + } + const browserFile = await ensureBrowserFile(file) + const base64 = arrayBufferToBase64(await browserFile.arrayBuffer()) + const filename = sanitizeTaskName(file.name || 'document.pdf') + '.pdf' + const tempPath = buildTaskOutputPath( + window.ztools.getPath('downloads'), + 'tmp', + filename, + generateTaskId('shared-input'), + ) + return window.services.writeFileBase64(base64, tempPath) +} + +/** Resolve a real input path, materializing browser-only bytes when needed. */ +export async function withInputPath( + file: SharedFile, + run: (inputPath: string) => Promise, +): Promise { + const direct = resolveInputPath(file) + if (direct) return run(direct) + const tempPath = await materializeInputPath(file) + try { + return await run(tempPath) + } finally { + try { + window.services.deleteFile?.(tempPath) + } catch { + // ignore + } + } +} + +/** Batch variant of withInputPath that preserves file order. */ +export async function withInputPaths( + files: SharedFile[], + run: (inputPaths: string[]) => Promise, +): Promise { + const inputPaths: string[] = [] + const temps: string[] = [] + try { + for (const file of files) { + const direct = resolveInputPath(file) + if (direct) { + inputPaths.push(direct) + } else { + const tempPath = await materializeInputPath(file) + temps.push(tempPath) + inputPaths.push(tempPath) + } + } + return await run(inputPaths) + } finally { + for (const tempPath of temps) { + try { + window.services.deleteFile?.(tempPath) + } catch { + // ignore + } + } + } +} diff --git a/plugins/pdf-process/src/utils/splitPlan.ts b/plugins/pdf-process/src/utils/splitPlan.ts index e51492d7b..3241c1e37 100644 --- a/plugins/pdf-process/src/utils/splitPlan.ts +++ b/plugins/pdf-process/src/utils/splitPlan.ts @@ -36,7 +36,7 @@ export function cutAfterFromEvery(pageCount: number, every: number): number[] { } /** - * Convert cut-after pages to pdfcpu "split before" page numbers. + * Convert cut-after pages to backend "split before" page numbers. * cut after 1 → split before 2. */ export function beforePagesFromCutAfter(cutAfterPages: Iterable): number[] { @@ -196,7 +196,7 @@ export type SplitPdfArgs = /** * Map UI split intent → window.services.splitPdf options. - * Keeps pdfcpu option shapes out of React. + * Keeps backend option shapes out of React. */ export function buildSplitInvocation(intent: SplitIntent): SplitPdfArgs { if (intent.mode === 'extract') { diff --git a/plugins/pdf-process/test-output/.~lock.test.pdf# b/plugins/pdf-process/test-output/.~lock.test.pdf# deleted file mode 100644 index 196aa2af7..000000000 --- a/plugins/pdf-process/test-output/.~lock.test.pdf# +++ /dev/null @@ -1 +0,0 @@ -,TAITRES/9206,Taitres,22.07.2026 15:23,file:///C:/Users/9206/AppData/Local/Temp/lo-uno-profile-1784704996856; \ No newline at end of file diff --git a/plugins/pdf-process/test-output/cjk_watermark.pdf b/plugins/pdf-process/test-output/cjk_watermark.pdf deleted file mode 100644 index 2a4a4487f..000000000 Binary files a/plugins/pdf-process/test-output/cjk_watermark.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/compressed.pdf b/plugins/pdf-process/test-output/compressed.pdf deleted file mode 100644 index dd8788ffd..000000000 Binary files a/plugins/pdf-process/test-output/compressed.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/converted.docx b/plugins/pdf-process/test-output/converted.docx deleted file mode 100644 index dd8788ffd..000000000 Binary files a/plugins/pdf-process/test-output/converted.docx and /dev/null differ diff --git a/plugins/pdf-process/test-output/converted.pptx b/plugins/pdf-process/test-output/converted.pptx deleted file mode 100644 index dd8788ffd..000000000 Binary files a/plugins/pdf-process/test-output/converted.pptx and /dev/null differ diff --git a/plugins/pdf-process/test-output/converted.xlsx b/plugins/pdf-process/test-output/converted.xlsx deleted file mode 100644 index dd8788ffd..000000000 Binary files a/plugins/pdf-process/test-output/converted.xlsx and /dev/null differ diff --git a/plugins/pdf-process/test-output/exe_excel.xlsx b/plugins/pdf-process/test-output/exe_excel.xlsx deleted file mode 100644 index 76a3c38f1..000000000 Binary files a/plugins/pdf-process/test-output/exe_excel.xlsx and /dev/null differ diff --git a/plugins/pdf-process/test-output/exe_ppt.pptx b/plugins/pdf-process/test-output/exe_ppt.pptx deleted file mode 100644 index d0fe859a2..000000000 Binary files a/plugins/pdf-process/test-output/exe_ppt.pptx and /dev/null differ diff --git a/plugins/pdf-process/test-output/exe_word.docx b/plugins/pdf-process/test-output/exe_word.docx deleted file mode 100644 index 4875d4154..000000000 Binary files a/plugins/pdf-process/test-output/exe_word.docx and /dev/null differ diff --git a/plugins/pdf-process/test-output/final_watermark.pdf b/plugins/pdf-process/test-output/final_watermark.pdf deleted file mode 100644 index df05b64bb..000000000 Binary files a/plugins/pdf-process/test-output/final_watermark.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/merged.pdf b/plugins/pdf-process/test-output/merged.pdf deleted file mode 100644 index d74b19704..000000000 Binary files a/plugins/pdf-process/test-output/merged.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/page_1.pdf b/plugins/pdf-process/test-output/page_1.pdf deleted file mode 100644 index ffe661956..000000000 Binary files a/plugins/pdf-process/test-output/page_1.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/page_2.pdf b/plugins/pdf-process/test-output/page_2.pdf deleted file mode 100644 index b8a4057ba..000000000 Binary files a/plugins/pdf-process/test-output/page_2.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/scan-page1.png b/plugins/pdf-process/test-output/scan-page1.png deleted file mode 100644 index c89e2743f..000000000 Binary files a/plugins/pdf-process/test-output/scan-page1.png and /dev/null differ diff --git a/plugins/pdf-process/test-output/smoke.docx b/plugins/pdf-process/test-output/smoke.docx deleted file mode 100644 index 831e5c003..000000000 Binary files a/plugins/pdf-process/test-output/smoke.docx and /dev/null differ diff --git a/plugins/pdf-process/test-output/smoke.pptx b/plugins/pdf-process/test-output/smoke.pptx deleted file mode 100644 index ae6fb121a..000000000 Binary files a/plugins/pdf-process/test-output/smoke.pptx and /dev/null differ diff --git a/plugins/pdf-process/test-output/smoke.xlsx b/plugins/pdf-process/test-output/smoke.xlsx deleted file mode 100644 index bd3b1b272..000000000 Binary files a/plugins/pdf-process/test-output/smoke.xlsx and /dev/null differ diff --git a/plugins/pdf-process/test-output/split_1.pdf b/plugins/pdf-process/test-output/split_1.pdf deleted file mode 100644 index ffe661956..000000000 Binary files a/plugins/pdf-process/test-output/split_1.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/split_2.pdf b/plugins/pdf-process/test-output/split_2.pdf deleted file mode 100644 index b8a4057ba..000000000 Binary files a/plugins/pdf-process/test-output/split_2.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/test.pdf b/plugins/pdf-process/test-output/test.pdf deleted file mode 100644 index dd8788ffd..000000000 Binary files a/plugins/pdf-process/test-output/test.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/test2.pdf b/plugins/pdf-process/test-output/test2.pdf deleted file mode 100644 index bdef0d75d..000000000 Binary files a/plugins/pdf-process/test-output/test2.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/watermarked.pdf b/plugins/pdf-process/test-output/watermarked.pdf deleted file mode 100644 index 5fc0bf05c..000000000 Binary files a/plugins/pdf-process/test-output/watermarked.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-output/with_image.pdf b/plugins/pdf-process/test-output/with_image.pdf deleted file mode 100644 index 69c4a3b71..000000000 Binary files a/plugins/pdf-process/test-output/with_image.pdf and /dev/null differ diff --git a/plugins/pdf-process/test-services.cjs b/plugins/pdf-process/test-services.cjs deleted file mode 100644 index 38ff726df..000000000 --- a/plugins/pdf-process/test-services.cjs +++ /dev/null @@ -1,181 +0,0 @@ -const fs = require('fs') -const path = require('path') -const { PDFDocument, StandardFonts, rgb, PDFName } = require('pdf-lib') - -const TEST_DIR = path.join(__dirname, 'test-output') -if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR, { recursive: true }) - -let passed = 0 -let failed = 0 - -function pass(name) { passed++; console.log(` ✅ PASS: ${name}`) } -function fail(name, err) { failed++; console.log(` ❌ FAIL: ${name} - ${err.message || err}`) } - -async function createTestPDF() { - const pdfDoc = await PDFDocument.create() - const font = await pdfDoc.embedFont(StandardFonts.Helvetica) - const page = pdfDoc.addPage([600, 400]) - page.drawText('Hello World - Page 1', { x: 50, y: 350, size: 30, font }) - const page2 = pdfDoc.addPage([600, 400]) - page2.drawText('Page 2 content', { x: 50, y: 350, size: 30, font }) - const pdfBytes = await pdfDoc.save() - const pdfPath = path.join(TEST_DIR, 'test.pdf') - fs.writeFileSync(pdfPath, pdfBytes) - const pdfDoc2 = await PDFDocument.create() - const p = pdfDoc2.addPage([600, 400]) - p.drawText('Merged page', { x: 50, y: 350, size: 30, font }) - const pdfBytes2 = await pdfDoc2.save() - fs.writeFileSync(path.join(TEST_DIR, 'test2.pdf'), pdfBytes2) - return pdfPath -} - -async function main() { - console.log('Creating test PDFs...') - const testPdf = await createTestPDF() - console.log(`Test PDF: ${testPdf}\n`) - - // Test 1: compressPdf - console.log('1. PDF压缩 (compressPdf)') - try { - const pdfBytes = fs.readFileSync(testPdf) - const pdfDoc = await PDFDocument.load(pdfBytes) - const compressed = await pdfDoc.save({ useObjectStreams: true }) - const outPath = path.join(TEST_DIR, 'compressed.pdf') - fs.writeFileSync(outPath, compressed) - const reloaded = await PDFDocument.load(fs.readFileSync(outPath)) - if (reloaded.getPageCount() === 2) pass('compressPdf') - else fail('compressPdf', 'Page count mismatch') - } catch(e) { fail('compressPdf', e) } - - // Test 2: mergePdfs - console.log('\n2. PDF合并 (mergePdfs)') - try { - const merged = await PDFDocument.create() - const pdf1 = await PDFDocument.load(fs.readFileSync(testPdf)) - const pdf2 = await PDFDocument.load(fs.readFileSync(path.join(TEST_DIR, 'test2.pdf'))) - const pages1 = await merged.copyPages(pdf1, pdf1.getPageIndices()) - const pages2 = await merged.copyPages(pdf2, pdf2.getPageIndices()) - pages1.forEach(p => merged.addPage(p)) - pages2.forEach(p => merged.addPage(p)) - const mergedBytes = await merged.save() - const outPath = path.join(TEST_DIR, 'merged.pdf') - fs.writeFileSync(outPath, mergedBytes) - const reloaded = await PDFDocument.load(fs.readFileSync(outPath)) - if (reloaded.getPageCount() === 3) pass('mergePdfs') - else fail('mergePdfs', `Expected 3 pages, got ${reloaded.getPageCount()}`) - } catch(e) { fail('mergePdfs', e) } - - // Test 3: splitPdf - console.log('\n3. PDF拆分 (splitPdf)') - try { - const pdfBytes = fs.readFileSync(testPdf) - const pdfDoc = await PDFDocument.load(pdfBytes) - for (let i = 0; i < pdfDoc.getPageCount(); i++) { - const newDoc = await PDFDocument.create() - const [copied] = await newDoc.copyPages(pdfDoc, [i]) - newDoc.addPage(copied) - fs.writeFileSync(path.join(TEST_DIR, `split_${i+1}.pdf`), await newDoc.save()) - } - const reloaded = await PDFDocument.load(fs.readFileSync(path.join(TEST_DIR, 'split_1.pdf'))) - if (reloaded.getPageCount() === 1) pass('splitPdf') - else fail('splitPdf', `Expected 1 page per split, got ${reloaded.getPageCount()}`) - } catch(e) { fail('splitPdf', e) } - - // Test 4: addWatermark - console.log('\n4. PDF水印 (addWatermark)') - try { - const pdfBytes = fs.readFileSync(testPdf) - const pdfDoc = await PDFDocument.load(pdfBytes) - const font = await pdfDoc.embedFont(StandardFonts.Helvetica) - const pages = pdfDoc.getPages() - for (const page of pages) { - const { width, height } = page.getSize() - const text = 'CONFIDENTIAL' - const textWidth = font.widthOfTextAtSize(text, 50) - page.drawText(text, { - x: (width - textWidth) / 2, - y: height / 2, - size: 50, - font, - opacity: 0.3, - }) - } - const outPath = path.join(TEST_DIR, 'watermarked.pdf') - fs.writeFileSync(outPath, await pdfDoc.save()) - const reloaded = await PDFDocument.load(fs.readFileSync(outPath)) - if (reloaded.getPageCount() === 2) pass('addWatermark') - else fail('addWatermark', 'Page count mismatch') - } catch(e) { fail('addWatermark', e) } - - // Test 5: extractImages - console.log('\n5. 提取图片 (extractImages)') - try { - const pdfBytes = fs.readFileSync(testPdf) - const pdfDoc = await PDFDocument.load(pdfBytes) - // The test PDF has no embedded images, just text. - // Use pdf-lib internals to enumerate objects and look for /Image subtypes. - let imageCount = 0 - try { - for (const [ref, obj] of pdfDoc.context.enumerateIndirectObjects()) { - if (obj && obj.dict && typeof obj.dict.get === 'function') { - const subtype = obj.dict.get(PDFName.of('Subtype')) - if (subtype && subtype.toString() === '/Image') imageCount++ - } - } - } catch (ctxErr) { - // context API may not be accessible; that's OK for a text-only test PDF - } - if (imageCount === 0) pass('extractImages (no images in test PDF - correct)') - else pass(`extractImages (found ${imageCount} images)`) - } catch(e) { fail('extractImages', e) } - - // Test 6: pdfToImage (split each page to individual PDFs as a proxy) - console.log('\n6. PDF转图片 (pdfToImage)') - try { - const pdfBytes = fs.readFileSync(testPdf) - const pdfDoc = await PDFDocument.load(pdfBytes) - for (let i = 0; i < pdfDoc.getPageCount(); i++) { - const singleDoc = await PDFDocument.create() - const [copied] = await singleDoc.copyPages(pdfDoc, [i]) - singleDoc.addPage(copied) - const outPath = path.join(TEST_DIR, `page_${i+1}.pdf`) - fs.writeFileSync(outPath, await singleDoc.save()) - } - const reloaded = await PDFDocument.load(fs.readFileSync(path.join(TEST_DIR, 'page_1.pdf'))) - if (reloaded.getPageCount() === 1) pass('pdfToImage') - else fail('pdfToImage', 'Page count mismatch') - } catch(e) { fail('pdfToImage', e) } - - // Test 7: convertToWord (stub) - console.log('\n7. PDF转Word (convertToWord)') - try { - const outPath = path.join(TEST_DIR, 'converted.docx') - fs.copyFileSync(testPdf, outPath) - if (fs.existsSync(outPath)) pass('convertToWord') - else fail('convertToWord', 'Output file does not exist') - } catch(e) { fail('convertToWord', e) } - - // Test 8: convertToPpt (stub) - console.log('\n8. PDF转PPT (convertToPpt)') - try { - const outPath = path.join(TEST_DIR, 'converted.pptx') - fs.copyFileSync(testPdf, outPath) - if (fs.existsSync(outPath)) pass('convertToPpt') - else fail('convertToPpt', 'Output file does not exist') - } catch(e) { fail('convertToPpt', e) } - - // Test 9: convertToExcel (stub) - console.log('\n9. PDF转Excel (convertToExcel)') - try { - const outPath = path.join(TEST_DIR, 'converted.xlsx') - fs.copyFileSync(testPdf, outPath) - if (fs.existsSync(outPath)) pass('convertToExcel') - else fail('convertToExcel', 'Output file does not exist') - } catch(e) { fail('convertToExcel', e) } - - console.log(`\n${'='.repeat(40)}`) - console.log(` Total: ${passed + failed} | Passed: ${passed} | Failed: ${failed}`) - console.log('='.repeat(40)) -} - -main().catch(console.error)