diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index a6fbbf28..4fd679c4 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -10,6 +10,19 @@ This project includes code and binaries from the following open source projects. - License: dual-licensed under MIT or the Unlicense - License texts: bundled beside the executable under `ripgrep-licenses/` +## pdf.js + +- Project: pdf.js (https://github.com/mozilla/pdf.js), distributed as `pdfjs-dist` +- Included as: the PDF engine and its worker, loaded when a PDF is opened in the workspace, and the run-time data files it reads, emitted beside the app under `assets/pdfjs-/` +- Adapted in: `desktop/src/components/workspace/surfaces/document/pdfPage.css` (the text-layer rules of `web/pdf_viewer.css`) +- Version: 6.3.289 +- License: Apache-2.0 +- Bundled data files, each folder shipped unmodified with its own license files: + - `cmaps/`: character maps, Copyright 1990-2009 Adobe Systems Incorporated (BSD-style license, `cmaps/LICENSE`) + - `standard_fonts/`: Foxit fonts (Copyright PDFium Authors, BSD-3-Clause, `LICENSE_FOXIT`) and Liberation Sans fonts (Liberation Font License, GNU GPL v2 with a font exception, `LICENSE_LIBERATION`), kept as separate data files + - `wasm/`: image decoders OpenJPEG (BSD-2-Clause), JBIG2 from PDFium (BSD-3-Clause) and qcms (MIT), with pdf.js' wrappers for them; each license is in a `LICENSE_*` file beside the decoder + - `iccs/`: an ICC colour profile (CC0 1.0, `iccs/LICENSE`) + ## claude-tap - Project: claude-tap (https://github.com/liaohch3/claude-tap) diff --git a/desktop/bun.lock b/desktop/bun.lock index daa151c6..966be746 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -49,15 +49,19 @@ "@typescript-eslint/parser": "^8", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", + "docx-preview": "0.4.1", "electron": "^42.4.0", "electron-builder": "^26.8.1", "eslint": "^9", "eslint-plugin-react-hooks": "^5", + "fflate": "^0.8.3", "jsdom": "^25.0.1", + "pdfjs-dist": "6.3.289", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^8.0.10", "vitest": "^3.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", }, }, }, @@ -332,6 +336,30 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.9.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.9", "@napi-rs/canvas-darwin-arm64": "1.0.9", "@napi-rs/canvas-darwin-x64": "1.0.9", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", "@napi-rs/canvas-linux-arm64-musl": "1.0.9", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-musl": "1.0.9", "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", "@napi-rs/canvas-win32-x64-msvc": "1.0.9" } }, "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", { "os": "android", "cpu": "arm64" }, "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", { "os": "linux", "cpu": "arm" }, "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "none" }, "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "x64" }, "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@ngrok/ngrok": ["@ngrok/ngrok@1.7.0", "https://registry.npmmirror.com/@ngrok/ngrok/-/ngrok-1.7.0.tgz", { "optionalDependencies": { "@ngrok/ngrok-android-arm64": "1.7.0", "@ngrok/ngrok-darwin-arm64": "1.7.0", "@ngrok/ngrok-darwin-universal": "1.7.0", "@ngrok/ngrok-darwin-x64": "1.7.0", "@ngrok/ngrok-freebsd-x64": "1.7.0", "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", "@ngrok/ngrok-linux-arm64-musl": "1.7.0", "@ngrok/ngrok-linux-x64-gnu": "1.7.0", "@ngrok/ngrok-linux-x64-musl": "1.7.0", "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", "@ngrok/ngrok-win32-x64-msvc": "1.7.0" } }, "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g=="], @@ -934,6 +962,8 @@ "dmg-license": ["dmg-license@1.0.11", "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "docx-preview": ["docx-preview@0.4.1", "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.4.1.tgz", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dompurify": ["dompurify@3.3.3", "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], @@ -1028,6 +1058,8 @@ "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], @@ -1122,6 +1154,8 @@ "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1156,6 +1190,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1196,6 +1232,8 @@ "jsonfile": ["jsonfile@6.2.1", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jszip": ["jszip@3.10.2", "https://registry.npmmirror.com/jszip/-/jszip-3.10.2.tgz", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "katex": ["katex@0.16.45", "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1210,6 +1248,8 @@ "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1404,6 +1444,8 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@1.0.11", "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1430,6 +1472,8 @@ "pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdfjs-dist": ["pdfjs-dist@6.3.289", "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw=="], + "pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1460,6 +1504,8 @@ "proc-log": ["proc-log@6.1.0", "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], @@ -1502,6 +1548,8 @@ "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "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" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "regex": ["regex@6.1.0", "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -1544,6 +1592,8 @@ "rw": ["rw@1.3.3", "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "sanitize-filename": ["sanitize-filename@1.6.4", "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], @@ -1562,6 +1612,8 @@ "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "setimmediate": ["setimmediate@1.0.5", "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1600,6 +1652,8 @@ "string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1764,6 +1818,8 @@ "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlbuilder": ["xmlbuilder@15.1.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], diff --git a/desktop/package.json b/desktop/package.json index 2899075f..e574345d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -154,14 +154,18 @@ "@typescript-eslint/parser": "^8", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", + "docx-preview": "0.4.1", "electron": "^42.4.0", "electron-builder": "^26.8.1", "eslint": "^9", "eslint-plugin-react-hooks": "^5", + "fflate": "^0.8.3", "jsdom": "^25.0.1", + "pdfjs-dist": "6.3.289", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^8.0.10", - "vitest": "^3.0.4" + "vitest": "^3.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" } } diff --git a/desktop/scripts/vite-pdfjs-assets.test.ts b/desktop/scripts/vite-pdfjs-assets.test.ts new file mode 100644 index 00000000..26e9a2b6 --- /dev/null +++ b/desktop/scripts/vite-pdfjs-assets.test.ts @@ -0,0 +1,236 @@ +// @vitest-environment node +import fs from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Plugin } from 'vite' +import { createPdfEngine } from '../src/components/workspace/surfaces/document/pdfEngine' +import { + PDFJS_ASSET_DIRS, + PDFJS_ASSET_FOLDERS, + pdfjsAssetsPrefix, +} from '../src/components/workspace/surfaces/document/pdfAssets' +import { listPdfjsAssets, pdfjsAssets } from './vite-pdfjs-assets' + +const realPackageDir = path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')) +const realVersion = (JSON.parse(fs.readFileSync(path.join(realPackageDir, 'package.json'), 'utf8')) as { version: string }).version + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) +}) + +/** A stand-in pdfjs-dist with a file or two in each data folder. */ +function fakePackage(options: { omit?: string } = {}): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfjs-assets-')) + tempDirs.push(dir) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'pdfjs-dist', version: '9.8.7' })) + fs.writeFileSync(path.join(dir, 'secret.txt'), 'not a data file') + for (const folder of PDFJS_ASSET_DIRS) { + if (folder === options.omit) continue + fs.mkdirSync(path.join(dir, folder)) + fs.writeFileSync(path.join(dir, folder, `${folder}-one.bin`), `${folder} one`) + fs.writeFileSync(path.join(dir, folder, 'LICENSE'), `${folder} licence`) + } + fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.wasm'), 'scripting sandbox') + fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.js'), 'scripting sandbox') + fs.writeFileSync(path.join(dir, 'wasm', 'decoder.wasm'), 'a decoder') + return dir +} + +type Emitted = { type: string; fileName: string; source: Buffer } + +function build(plugin: Plugin): Emitted[] { + const emitted: Emitted[] = [] + const generateBundle = plugin.generateBundle as (this: unknown, ...args: unknown[]) => void + generateBundle.call({ emitFile: (file: Emitted) => emitted.push(file) }, {}, {}, false) + return emitted +} + +type Middleware = (request: { url?: string }, response: unknown, next: () => void) => void + +function devServer(plugin: Plugin) { + const mounted: Array<{ at: string; handler: Middleware }> = [] + const configureServer = plugin.configureServer as (server: unknown) => void + configureServer({ middlewares: { use: (at: string, handler: Middleware) => mounted.push({ at, handler }) } }) + return mounted +} + +/** Ask the dev middleware for `url`; resolves with what it sent, or `null` if it passed the request on. */ +async function request(plugin: Plugin, url: string) { + const [middleware] = devServer(plugin) + const chunks: Buffer[] = [] + const headers: Record = {} + const response = Object.assign(new PassThrough(), { + setHeader: (name: string, value: string) => { headers[name.toLowerCase()] = value }, + }) + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + return new Promise<{ body: Buffer; headers: Record } | null>((resolve) => { + response.on('end', () => resolve({ body: Buffer.concat(chunks), headers })) + middleware!.handler({ url }, response, () => resolve(null)) + }) +} + +describe('listPdfjsAssets', () => { + it('lists the four data folders, licences included', () => { + const files = listPdfjsAssets(fakePackage()).sort() + + expect(files).toEqual([ + 'cmaps/LICENSE', + 'cmaps/cmaps-one.bin', + 'iccs/LICENSE', + 'iccs/iccs-one.bin', + 'standard_fonts/LICENSE', + 'standard_fonts/standard_fonts-one.bin', + 'wasm/LICENSE', + 'wasm/decoder.wasm', + 'wasm/wasm-one.bin', + ]) + }) + + it('leaves out the scripting sandbox, which a read-only preview never runs', () => { + const files = listPdfjsAssets(fakePackage()) + + expect(files.filter((file) => file.includes('quickjs'))).toEqual([]) + expect(files).toContain('wasm/decoder.wasm') + }) + + it('does not ship anything else in the package', () => { + expect(listPdfjsAssets(fakePackage()).some((file) => file.includes('secret') || file.includes('package.json'))).toBe(false) + }) + + it('fails the build by naming the missing folder, rather than shipping a viewer that cannot read Chinese', () => { + expect(() => listPdfjsAssets(fakePackage({ omit: 'cmaps' }))).toThrow(/no "cmaps" folder/) + }) + + it('finds what a real pdfjs-dist ships: CJK CMaps, the standard fonts, the JPEG 2000 decoder, and their licences', () => { + const files = listPdfjsAssets(realPackageDir) + + expect(files).toEqual(expect.arrayContaining([ + 'cmaps/UniGB-UCS2-H.bcmap', + 'cmaps/Adobe-GB1-UCS2.bcmap', + 'standard_fonts/LiberationSans-Regular.ttf', + 'standard_fonts/FoxitSerif.pfb', + 'standard_fonts/LICENSE_FOXIT', + 'standard_fonts/LICENSE_LIBERATION', + 'wasm/openjpeg.wasm', + 'wasm/qcms_bg.wasm', + 'wasm/LICENSE_OPENJPEG', + 'iccs/CGATS001Compat-v2-micro.icc', + ])) + expect(files.some((file) => file.includes('quickjs-eval'))).toBe(false) + }) +}) + +describe('the build', () => { + it('emits every file under a folder named for the installed version, with fixed names', () => { + const dir = fakePackage() + + const emitted = build(pdfjsAssets({ packageDir: dir })) + + expect(emitted.every((file) => file.type === 'asset')).toBe(true) + expect(emitted.map((file) => file.fileName).sort()).toEqual( + listPdfjsAssets(dir).map((file) => `assets/pdfjs-9.8.7/${file}`).sort(), + ) + }) + + it('ships the bytes of the files, unchanged', () => { + const dir = fakePackage() + + const emitted = build(pdfjsAssets({ packageDir: dir })) + + expect(emitted.find((file) => file.fileName === 'assets/pdfjs-9.8.7/wasm/decoder.wasm')!.source.toString()).toBe('a decoder') + }) + + it('names the folder for the version that is installed, so an upgrade cannot be served stale data', () => { + const emitted = build(pdfjsAssets()) + + expect(emitted.length).toBeGreaterThan(100) + expect(emitted.every((file) => file.fileName.startsWith(`assets/pdfjs-${realVersion}/`))).toBe(true) + }) + + it('uses the same version string the engine builds its URLs from at run time', async () => { + const { version } = await import('pdfjs-dist/legacy/build/pdf.mjs') + + expect(version).toBe(realVersion) + }) +}) + +describe('the dev server', () => { + it('serves the data at the URL the app will use once built', () => { + const [middleware] = devServer(pdfjsAssets({ packageDir: fakePackage() })) + + expect(middleware!.at).toBe(`/${pdfjsAssetsPrefix('9.8.7')}`) + }) + + it('serves a data file, uncached', async () => { + const plugin = pdfjsAssets({ packageDir: fakePackage() }) + + const served = await request(plugin, '/cmaps/cmaps-one.bin') + + expect(served!.body.toString()).toBe('cmaps one') + expect(served!.headers['content-type']).toBe('application/octet-stream') + expect(served!.headers['cache-control']).toBe('no-cache') + }) + + it('serves wasm as wasm, which streaming compilation insists on', async () => { + const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/wasm/decoder.wasm') + + expect(served!.headers['content-type']).toBe('application/wasm') + }) + + it('ignores a query string', async () => { + const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/iccs/iccs-one.bin?v=1') + + expect(served!.body.toString()).toBe('iccs one') + }) + + // Every way out here leads to a file that exists, so that it is the guard that stops + // it, not the file's absence. + it.each([ + ['a file outside the data folders', () => '/package.json'], + ['a sibling file, by way of the data folder', () => '/cmaps/../secret.txt'], + ['an encoded slash way out', () => '/cmaps/..%2fsecret.txt'], + ['an encoded way out and back in again', (dir: string) => `/%2e%2e/${path.basename(dir)}/secret.txt`], + ['a way out and back in again', (dir: string) => `/../${path.basename(dir)}/secret.txt`], + ['a file that does not exist', () => '/cmaps/nothing.bin'], + ['the scripting sandbox', () => '/wasm/quickjs-eval.wasm'], + ['a folder', () => '/cmaps'], + ['the root', () => '/'], + ['a malformed escape', () => '/cmaps/%E0%A4%A'], + ])('passes on %s', async (_label, urlFor) => { + const dir = fakePackage() + + const served = await request(pdfjsAssets({ packageDir: dir }), urlFor(dir)) + + expect(served).toBeNull() + }) +}) + +describe('the engine and the plugin agree on where the data is', () => { + it('asks for exactly the folders that are shipped, under the prefix that is served', async () => { + const getDocument = vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1 }), + destroy: async () => undefined, + })) + const pdfjs = { version: '9.8.7', getDocument } as never + const base = `http://app.test/${pdfjsAssetsPrefix('9.8.7')}` + const engine = createPdfEngine({ loadPdfjs: async () => pdfjs, assetBaseUrl: () => base }) + + await engine.open(new Uint8Array([1])) + + const options = getDocument.mock.calls[0]![0] as unknown as Record + const requested = Object.keys(PDFJS_ASSET_FOLDERS).map((option) => options[option]!) + expect(requested.sort()).toEqual([...PDFJS_ASSET_DIRS].map((dir) => `${base}${dir}/`).sort()) + // pdf.js ships CMaps compressed; without this it fetches names that do not exist. + expect(options.cMapPacked).toBe(true) + }) + + it('ships every folder that the engine can ask for', () => { + const shipped = new Set(listPdfjsAssets(realPackageDir).map((file) => file.split('/')[0])) + + expect([...shipped].sort()).toEqual([...PDFJS_ASSET_DIRS].sort()) + }) +}) diff --git a/desktop/scripts/vite-pdfjs-assets.ts b/desktop/scripts/vite-pdfjs-assets.ts new file mode 100644 index 00000000..7a4e9d69 --- /dev/null +++ b/desktop/scripts/vite-pdfjs-assets.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import type { Plugin } from 'vite' +import { PDFJS_ASSET_DIRS, pdfjsAssetsPrefix } from '../src/components/workspace/surfaces/document/pdfAssets' + +/** + * Not shipped: pdf.js' scripting sandbox (JavaScript embedded in PDF forms). A + * read-only preview never runs it, and it is a sizeable wasm the app would carry + * for nothing. + */ +const EXCLUDED = /^wasm\/quickjs-eval\./ + +const MIME_TYPES: Record = { + '.wasm': 'application/wasm', + '.js': 'text/javascript; charset=utf-8', + '.ttf': 'font/ttf', +} + +/** Every file to ship, as `dir/name` paths relative to the pdfjs-dist package. */ +export function listPdfjsAssets(packageDir: string): string[] { + return PDFJS_ASSET_DIRS.flatMap((dir) => { + let names: string[] + try { + names = fs.readdirSync(path.join(packageDir, dir)) + } catch (error) { + throw new Error( + `pdfjs-dist has no "${dir}" folder at ${packageDir}. This version lays its data out differently; ` + + 'update PDFJS_ASSET_FOLDERS in pdfAssets.ts to match.', + { cause: error }, + ) + } + return names.map((name) => `${dir}/${name}`).filter((relative) => !EXCLUDED.test(relative)) + }) +} + +/** + * Ships pdf.js' run-time data with the app, and serves it from the dev server at + * the same URL, so the renderer finds it identically in `vite dev`, in the + * packaged Electron app (`file://` inside the asar) and on the H5 static host. + * + * `packageDir` is for tests; by default the installed pdfjs-dist is used. + */ +export function pdfjsAssets(options: { packageDir?: string } = {}): Plugin { + const packageDir = options.packageDir + ?? path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')) + const { version } = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { version: string } + const prefix = pdfjsAssetsPrefix(version) + + return { + name: 'cc-haha:pdfjs-assets', + + configureServer(server) { + server.middlewares.use(`/${prefix}`, (request, response, next) => { + let relative: string + try { + relative = decodeURIComponent((request.url ?? '/').split('?')[0]!).replace(/^\/+/, '') + } catch { + next() // a malformed escape is not one of our files + return + } + const file = path.resolve(packageDir, relative) + // Only the data folders: nothing else in the package, and no way out of it. + const inDataDir = PDFJS_ASSET_DIRS.some((dir) => file.startsWith(path.join(packageDir, dir) + path.sep)) + if (!inDataDir || EXCLUDED.test(path.relative(packageDir, file).split(path.sep).join('/')) || !fs.existsSync(file)) { + next() + return + } + response.setHeader('Content-Type', MIME_TYPES[path.extname(file)] ?? 'application/octet-stream') + response.setHeader('Cache-Control', 'no-cache') + fs.createReadStream(file).pipe(response) + }) + }, + + generateBundle() { + for (const relative of listPdfjsAssets(packageDir)) { + this.emitFile({ + type: 'asset', + // A fixed name, not a content hash: the folder already carries the version. + fileName: prefix + relative, + source: fs.readFileSync(path.join(packageDir, relative)), + }) + } + }, + } +} diff --git a/desktop/src/__tests__/documentEngineImports.test.ts b/desktop/src/__tests__/documentEngineImports.test.ts new file mode 100644 index 00000000..9cebcb1e --- /dev/null +++ b/desktop/src/__tests__/documentEngineImports.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +/** + * pdf.js, docx-preview and SheetJS are hundreds of kilobytes apiece. The build does + * not warn when one of them slips into the main bundle (`INEFFECTIVE_DYNAMIC_IMPORT` + * is silenced in vite.config.ts), and every user would then pay for it on startup + * whether or not they ever open a document. + * + * So the rule is checked here, on the source: a document engine is reached by a + * dynamic `import()` from inside a function, never by an `import` at the top of a + * module. The one exception is the worker entry, which exists to be loaded on its + * own by `?worker` and is not part of any bundle that runs on the page. + */ + +const srcRoot = path.resolve(import.meta.dirname, '..') + +const HEAVY_PACKAGE = /^(pdfjs-dist|docx-preview|xlsx)(\/|$)/ + +/** Modules whose whole job is to be the far end of a dynamic import or a worker. */ +const ENTRY_POINTS = new Set(['components/workspace/surfaces/document/pdf.worker.ts']) + +/** `src/test`: fixtures and fakes that only tests import. They build documents with these very libraries. */ +const TEST_SUPPORT_DIRECTORY = path.join(srcRoot, 'test') + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) return entry.name === 'node_modules' || full === TEST_SUPPORT_DIRECTORY ? [] : sourceFiles(full) + return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts') ? [full] : [] + }) +} + +type StaticImport = { file: string; specifier: string; typeOnly: boolean } + +function staticImports(file: string): StaticImport[] { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + const found: StaticImport[] = [] + for (const node of source.statements) { + const isImport = ts.isImportDeclaration(node) + const isReExport = ts.isExportDeclaration(node) + if (!(isImport || isReExport) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) continue + const typeOnly = isImport ? node.importClause?.isTypeOnly === true : node.isTypeOnly + found.push({ file: path.relative(srcRoot, file), specifier: node.moduleSpecifier.text, typeOnly }) + } + return found +} + +const files = sourceFiles(srcRoot) +const imports = files.flatMap(staticImports) + +describe('document engines stay out of the main bundle', () => { + it('reads the source tree it is guarding', () => { + // A scan that found nothing would pass every rule below. + expect(files.length).toBeGreaterThan(500) + expect(files.some((file) => file.endsWith('pdfEngine.ts'))).toBe(true) + expect(imports.some((statement) => statement.specifier === 'react')).toBe(true) + }) + + it('imports a document engine only dynamically, or as a type', () => { + const eager = imports.filter( + (statement) => HEAVY_PACKAGE.test(statement.specifier) && !statement.typeOnly && !ENTRY_POINTS.has(statement.file), + ) + + expect(eager.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([]) + }) + + it('loads pdf.js through the engine, which fetches it on first use', () => { + const engine = readFileSync(path.join(srcRoot, 'components/workspace/surfaces/document/pdfEngine.ts'), 'utf8') + + expect(engine).toMatch(/import\('pdfjs-dist\/legacy\/build\/pdf\.mjs'\)/) + }) + + it('never imports the worker entry from the page, only through ?worker', () => { + const direct = imports.filter((statement) => /(^|\/)pdf\.worker(\.ts)?$/.test(statement.specifier)) + + expect(direct.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([]) + }) + + it('reaches every document viewer through a lazy import, so opening a workspace loads none of them', () => { + const viewers = imports.filter((statement) => statement.file.endsWith('document/documentViewers.ts')) + + // Only React (for `lazy`) and types: a viewer named here would be in the main bundle. + expect(viewers.filter((statement) => !statement.typeOnly).map((statement) => statement.specifier)).toEqual(['react']) + }) + + it('does not let the panel that hosts documents import a viewer or engine directly', () => { + const host = imports.filter( + (statement) => statement.file.endsWith('workbench/WorkspaceFileTab.tsx') && !statement.typeOnly, + ) + + expect(host.filter((statement) => /(Pdf|Docx|Spreadsheet)(Surface|Viewer|Engine)/.test(statement.specifier))).toEqual([]) + }) +}) + +describe('document libraries are declared as what they are', () => { + const manifest = JSON.parse(readFileSync(path.resolve(srcRoot, '../package.json'), 'utf8')) as { + dependencies: Record + devDependencies: Record + } + + it.each(['pdfjs-dist', 'docx-preview', 'xlsx', 'fflate'])( + '%s is a devDependency: the renderer bundles it, and electron-builder packs every "dependencies" entry into app.asar', + (name) => { + // Listed under `dependencies` these four shipped a second, unused copy of about 45 MB + // in every installer, beside the copy Vite had already put in dist/. + expect(manifest.devDependencies[name]).toBeDefined() + expect(manifest.dependencies[name]).toBeUndefined() + }, + ) + + it('takes SheetJS from its own CDN release, because the npm package stops at 0.18.5 and has unfixed advisories', () => { + expect(manifest.devDependencies.xlsx).toBe('https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz') + }) + + it.each(['pdfjs-dist', 'docx-preview'])('pins %s to one version: the viewers are written against its internals', (name) => { + expect(manifest.devDependencies[name]).toMatch(/^\d+\.\d+\.\d+$/) + }) +}) diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index 7e674d28..c3496118 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -246,6 +246,73 @@ describe('sessionsApi', () => { expect(init).toMatchObject({ method: 'GET' }) }) + it('downloads a workspace document as a blob from the raw route with an encoded path', async () => { + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]) + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(bytes, { + status: 200, + headers: { 'Content-Type': 'application/pdf' }, + })) + + const blob = await sessionsApi.getWorkspaceRaw('session-1', 'docs/论文 final.pdf') + + expect(blob.size).toBe(bytes.byteLength) + expect(blob.type).toBe('application/pdf') + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/workspace/raw?path=docs%2F%E8%AE%BA%E6%96%87+final.pdf') + expect(init).toMatchObject({ method: 'GET' }) + }) + + it('surfaces a refused document download as an API error instead of a blob', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('too large', { status: 413 })) + + await expect(sessionsApi.getWorkspaceRaw('session-1', 'big.docx')).rejects.toMatchObject({ status: 413 }) + }) + + describe('document download timing', () => { + // A hung transfer must be cut off, but a whole file over a remote link is + // far slower than the JSON the default two-minute limit is sized for. + function hangingFetch() { + const seen: { signal?: AbortSignal } = {} + vi.spyOn(globalThis, 'fetch').mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => { + seen.signal = init?.signal ?? undefined + seen.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + })) + return seen + } + + // What the promise rejected with, without leaving an unawaited assertion behind. + const settle = (promise: Promise) => promise.then(() => null, (error: unknown) => error) + + afterEach(() => { + vi.useRealTimers() + }) + + it('allows ten minutes rather than the default request timeout', async () => { + vi.useFakeTimers() + const seen = hangingFetch() + + const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf')) + await vi.advanceTimersByTimeAsync(10 * 60_000 - 1) + expect(seen.signal?.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + expect(seen.signal?.aborted).toBe(true) + expect(await settled).toMatchObject({ name: 'AbortError' }) + }) + + it('stops as soon as the caller cancels', async () => { + const seen = hangingFetch() + const caller = new AbortController() + + const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf', caller.signal)) + expect(seen.signal?.aborted).toBe(false) + + caller.abort() + expect(seen.signal?.aborted).toBe(true) + expect(await settled).toMatchObject({ name: 'AbortError' }) + }) + }) + it('preserves optional local index progress from session list responses', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 8d7ee9a7..60018070 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,4 +1,4 @@ -import { api, type ApiRequestOptions } from './client' +import { api, apiGetBlob, type ApiRequestOptions } from './client' import type { SlashCommandOption } from '../types/slashCommand' import type { AgentTaskNotification } from '../types/chat' import type { LocalIndexStatus, SessionListItem, MessageEntry } from '../types/session' @@ -325,10 +325,18 @@ export type WorkspaceStatusResult = { error?: string } +/** Formats the workspace renders itself from bytes fetched with {@link sessionsApi.getWorkspaceRaw}. */ +export type WorkspaceDocumentPreviewType = 'pdf' | 'docx' | 'xlsx' + export type WorkspaceReadFileResult = { state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' path: string - previewType?: 'text' | 'image' + /** + * `text` and `image` carry their payload in this response. A document type is + * metadata only — its bytes are fetched separately, so a watcher reload never + * re-downloads a large file as JSON. + */ + previewType?: 'text' | 'image' | WorkspaceDocumentPreviewType content?: string dataUrl?: string mimeType?: string @@ -336,6 +344,8 @@ export type WorkspaceReadFileResult = { size: number truncated?: boolean readBytes?: number + /** Document types only: changes whenever the bytes may have changed. */ + version?: string error?: string } @@ -400,9 +410,15 @@ function getSessionGitInfo(sessionId: string) { return trackedRequest } +/** + * A whole document is fetched in one request and can be large and slow to + * cross a remote link; the default request timeout is sized for JSON. + */ +const WORKSPACE_RAW_TIMEOUT_MS = 10 * 60_000 + function buildWorkspacePath( sessionId: string, - resource: 'status' | 'tree' | 'file' | 'diff', + resource: 'status' | 'tree' | 'file' | 'diff' | 'raw', workspacePath?: string, ) { const query = new URLSearchParams() @@ -606,6 +622,18 @@ export const sessionsApi = { return api.get(buildWorkspacePath(sessionId, 'file', workspacePath), { signal }) }, + /** + * The bytes of a document the workspace previews, as a Blob. Fetched with the + * bearer credential, the one form that works in the desktop shell, a LAN + * browser and remote access alike — an ``/`