feat(web): replace PDF.js conversion with WASM worker

- Process PDF attachments through reusable pdf-inspector WASM worker
- Transfer ArrayBuffers and initialize WASM once per worker
- Reject pending conversions and recreate workers after crashes
- Parse attachments without triggering duplicate chat requests
- Surface OCR, encoding, and empty-content conversion errors
- Remove PDF.js pipeline and obsolete transitive dependencies
- Add tests for conversion results and chat boundaries
This commit is contained in:
Kayvan Sylvan 2026-08-02 16:25:25 -07:00
parent 9e92bca148
commit b90a07bc19
13 changed files with 401 additions and 763 deletions

View file

@ -0,0 +1,7 @@
### PR [#2185](https://github.com/danielmiessler/Fabric/pull/2185) by [ksylvan](https://github.com/ksylvan): feat(web): replace PDF.js pipeline with pdf-inspector WASM worker
- Replaced the PDF.js processing pipeline with the new `pdf-inspector.worker.ts`, backed by `@firecrawl/pdf-inspector-wasm`. The worker initializes WASM one time and processes transferred ArrayBuffers.
- Added worker error handling. A worker crash now rejects all pending requests with a clear error and terminates the worker. The next request starts a new worker.
- Fixed the file attachment behavior. The app now parses each file and stores its content without a chat request. One submit sends exactly one `streamChat` call, not one call for each attached file.
- Removed the legacy PDF dependency chain: `pdf-to-markdown-core`, `pdfjs-dist`, `pdf-config.ts`, and the related build configuration. `@firecrawl/pdf-inspector-wasm` is now the only PDF dependency.
- Simplified the worker boundary in follow-up refactors: merged redundant methods, removed dead code, and inlined a single-use interface. The full test suite continued to pass.

353
web/package-lock.json generated
View file

@ -25,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@firecrawl/pdf-inspector-wasm": "0.1.3",
"@skeletonlabs/skeleton": "^5.0.0", "@skeletonlabs/skeleton": "^5.0.0",
"@skeletonlabs/skeleton-svelte": "^5.0.0", "@skeletonlabs/skeleton-svelte": "^5.0.0",
"@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-auto": "^3.3.1",
@ -41,8 +42,6 @@
"lucide-svelte": "^0.575.0", "lucide-svelte": "^0.575.0",
"mdsvex": "^0.12.8", "mdsvex": "^0.12.8",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"pdf-to-markdown-core": "github:jzillmann/pdf-to-markdown#modularize",
"pdfjs-dist": "^6.2.108",
"postcss": "^8.5.25", "postcss": "^8.5.25",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",
@ -266,6 +265,13 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@firecrawl/pdf-inspector-wasm": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@firecrawl/pdf-inspector-wasm/-/pdf-inspector-wasm-0.1.3.tgz",
"integrity": "sha512-8gz3s3H/WABomls64YCZ8W6zd+eIxFgNAfCXI9+a7axDekZQHX21rsng1uxImoBZD3mXpN7XqLH/LS68WnGWhw==",
"dev": true,
"license": "MIT"
},
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
@ -417,283 +423,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
"integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
"dev": true,
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.3",
"@napi-rs/canvas-darwin-arm64": "1.0.3",
"@napi-rs/canvas-darwin-x64": "1.0.3",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
"@napi-rs/canvas-linux-arm64-musl": "1.0.3",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
"@napi-rs/canvas-linux-x64-gnu": "1.0.3",
"@napi-rs/canvas-linux-x64-musl": "1.0.3",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
"@napi-rs/canvas-win32-x64-msvc": "1.0.3"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
"integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
"integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
"integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz",
@ -2041,13 +1770,6 @@
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/string-similarity": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.2.tgz",
"integrity": "sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@ -5633,32 +5355,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-to-markdown-core": {
"version": "0.5.0",
"resolved": "git+ssh://git@github.com/jzillmann/pdf-to-markdown.git#5e930fafd305644cff00a3824030f0d6741acde8",
"dev": true,
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
"@types/string-similarity": "^4.0.0",
"simple-statistics": "^7.7.0",
"string-similarity": "^4.0.4",
"uuid": "^9.0.1"
}
},
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -6392,16 +6088,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simple-statistics": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.9.3.tgz",
"integrity": "sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "*"
}
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@ -6461,14 +6147,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string-similarity": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz",
"integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "ISC"
},
"node_modules/stringify-entities": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -7016,21 +6694,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View file

@ -17,6 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@firecrawl/pdf-inspector-wasm": "0.1.3",
"@skeletonlabs/skeleton": "^5.0.0", "@skeletonlabs/skeleton": "^5.0.0",
"@skeletonlabs/skeleton-svelte": "^5.0.0", "@skeletonlabs/skeleton-svelte": "^5.0.0",
"@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-auto": "^3.3.1",
@ -33,8 +34,6 @@
"lucide-svelte": "^0.575.0", "lucide-svelte": "^0.575.0",
"mdsvex": "^0.12.8", "mdsvex": "^0.12.8",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"pdf-to-markdown-core": "github:jzillmann/pdf-to-markdown#modularize",
"pdfjs-dist": "^6.2.108",
"postcss": "^8.5.25", "postcss": "^8.5.25",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",

View file

@ -71,6 +71,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^9.39.5 specifier: ^9.39.5
version: 9.39.5 version: 9.39.5
'@firecrawl/pdf-inspector-wasm':
specifier: 0.1.3
version: 0.1.3
'@skeletonlabs/skeleton': '@skeletonlabs/skeleton':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(tailwindcss@4.3.3) version: 5.0.0(tailwindcss@4.3.3)
@ -119,12 +122,6 @@ importers:
patch-package: patch-package:
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1 version: 8.0.1
pdf-to-markdown-core:
specifier: github:jzillmann/pdf-to-markdown#modularize
version: https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8
pdfjs-dist:
specifier: ^6.2.108
version: 6.2.108
postcss: postcss:
specifier: ^8.5.25 specifier: ^8.5.25
version: 8.5.25 version: 8.5.25
@ -382,6 +379,9 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@firecrawl/pdf-inspector-wasm@0.1.3':
resolution: {integrity: sha512-8gz3s3H/WABomls64YCZ8W6zd+eIxFgNAfCXI9+a7axDekZQHX21rsng1uxImoBZD3mXpN7XqLH/LS68WnGWhw==}
'@floating-ui/core@1.8.0': '@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@ -430,81 +430,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@napi-rs/canvas-android-arm64@1.0.3':
resolution: {integrity: sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@1.0.3':
resolution: {integrity: sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@1.0.3':
resolution: {integrity: sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.3':
resolution: {integrity: sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@1.0.3':
resolution: {integrity: sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.3':
resolution: {integrity: sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.3':
resolution: {integrity: sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@1.0.3':
resolution: {integrity: sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@1.0.3':
resolution: {integrity: sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.2.0': '@napi-rs/wasm-runtime@1.2.0':
resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@ -834,9 +759,6 @@ packages:
'@types/node@26.1.2': '@types/node@26.1.2':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
'@types/string-similarity@4.0.2':
resolution: {integrity: sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -1919,14 +1841,6 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-to-markdown-core@https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8:
resolution: {gitHosted: true, tarball: https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8}
version: 0.5.0
pdfjs-dist@6.2.108:
resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==}
engines: {node: '>=22.13.0 || >=24'}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -2084,9 +1998,6 @@ packages:
siginfo@2.0.0: siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-statistics@7.9.3:
resolution: {integrity: sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ==}
sirv@3.0.2: sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2108,10 +2019,6 @@ packages:
std-env@4.2.0: std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
string-similarity@4.0.4:
resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
stringify-entities@4.0.4: stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -2277,11 +2184,6 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vfile-location@5.0.3: vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -2568,6 +2470,8 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@firecrawl/pdf-inspector-wasm@0.1.3': {}
'@floating-ui/core@1.8.0': '@floating-ui/core@1.8.0':
dependencies: dependencies:
'@floating-ui/utils': 0.2.12 '@floating-ui/utils': 0.2.12
@ -2618,54 +2522,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@napi-rs/canvas-android-arm64@1.0.3':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.3':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.3':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.3':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.3':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.3':
optional: true
'@napi-rs/canvas@1.0.3':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.3
'@napi-rs/canvas-darwin-arm64': 1.0.3
'@napi-rs/canvas-darwin-x64': 1.0.3
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.3
'@napi-rs/canvas-linux-arm64-gnu': 1.0.3
'@napi-rs/canvas-linux-arm64-musl': 1.0.3
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.3
'@napi-rs/canvas-linux-x64-gnu': 1.0.3
'@napi-rs/canvas-linux-x64-musl': 1.0.3
'@napi-rs/canvas-win32-arm64-msvc': 1.0.3
'@napi-rs/canvas-win32-x64-msvc': 1.0.3
optional: true
'@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies: dependencies:
'@emnapi/core': 1.11.1 '@emnapi/core': 1.11.1
@ -2964,8 +2820,6 @@ snapshots:
dependencies: dependencies:
undici-types: 8.3.0 undici-types: 8.3.0
'@types/string-similarity@4.0.2': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
@ -4257,17 +4111,6 @@ snapshots:
pathe@2.0.3: {} pathe@2.0.3: {}
pdf-to-markdown-core@https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8:
dependencies:
'@types/string-similarity': 4.0.2
simple-statistics: 7.9.3
string-similarity: 4.0.4
uuid: 9.0.1
pdfjs-dist@6.2.108:
optionalDependencies:
'@napi-rs/canvas': 1.0.3
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.2: {} picomatch@2.3.2: {}
@ -4451,8 +4294,6 @@ snapshots:
siginfo@2.0.0: {} siginfo@2.0.0: {}
simple-statistics@7.9.3: {}
sirv@3.0.2: sirv@3.0.2:
dependencies: dependencies:
'@polka/url': 1.0.0-next.29 '@polka/url': 1.0.0-next.29
@ -4469,8 +4310,6 @@ snapshots:
std-env@4.2.0: {} std-env@4.2.0: {}
string-similarity@4.0.4: {}
stringify-entities@4.0.4: stringify-entities@4.0.4:
dependencies: dependencies:
character-entities-html4: 2.1.0 character-entities-html4: 2.1.0
@ -4660,8 +4499,6 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@9.0.1: {}
vfile-location@5.0.3: vfile-location@5.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3

View file

@ -46,8 +46,3 @@ peerDependencyRules:
allowBuilds: allowBuilds:
esbuild: true esbuild: true
svelte-preprocess: true svelte-preprocess: true
# A git-hosted package needs the full dependency path as the key, because
# pnpm does not trust the identity of a package that has no semver version.
# The commit hash must agree with the version in pnpm-lock.yaml. If you move
# the "modularize" branch, update this key and the lockfile together.
'pdf-to-markdown-core@https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8': true

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import { sendMessage, messageStore } from '$lib/store/chat-store'; import { messageStore } from '$lib/store/chat-store';
import { systemPrompt, selectedPatternName } from '$lib/store/pattern-store'; import { systemPrompt, selectedPatternName } from '$lib/store/pattern-store';
import { toastStore } from '$lib/store/toast-store'; import { toastStore } from '$lib/store/toast-store';
import { Paperclip, Send, FileCheck } from 'lucide-svelte'; import { Paperclip, Send, FileCheck } from 'lucide-svelte';
@ -14,6 +14,7 @@
import { obsidianSettings, updateObsidianSettings } from '$lib/store/obsidian-store'; import { obsidianSettings, updateObsidianSettings } from '$lib/store/obsidian-store';
import { PdfConversionService } from '$lib/services/PdfConversionService'; import { PdfConversionService } from '$lib/services/PdfConversionService';
import { formatErrorMessage } from '$lib/utils/error-message'; import { formatErrorMessage } from '$lib/utils/error-message';
import { readFileContent } from './read-file-content';
const pdfService = new PdfConversionService(); const pdfService = new PdfConversionService();
@ -101,7 +102,7 @@
for (let i = 0; i < files.length && uploadedFiles.length < 5; i++) { for (let i = 0; i < files.length && uploadedFiles.length < 5; i++) {
const file = files[i]; const file = files[i];
const content = await readFileContent(file); const content = await readFileContent(file, pdfService, toastStore.warning);
fileContents.push(content); fileContents.push(content);
uploadedFiles = [...uploadedFiles, file.name]; uploadedFiles = [...uploadedFiles, file.name];
@ -134,104 +135,6 @@
} }
async function readFileContent(file: File): Promise<string> {
// Log initial file metadata
console.log('Reading file:', {
name: file.name,
type: file.type,
size: file.size,
lastModified: new Date(file.lastModified).toISOString()
});
// Handle PDF files
if (file.type === 'application/pdf') {
try {
// Start PDF processing
console.log('Starting PDF conversion process');
const markdown = await pdfService.convertToMarkdown(file);
// Validate conversion result
console.log('PDF conversion completed:', {
resultLength: markdown.length,
preview: markdown.substring(0, 100)
});
// Ensure we have valid content
if (!markdown || markdown.trim().length === 0) {
throw new Error('PDF conversion returned empty content');
}
// Add to fileContents for pattern processing
fileContents.push(markdown);
// Prepare enhanced prompt with system instructions
const enhancedPrompt = `${$systemPrompt}\nAnalyze and process the provided content according to these instructions.`;
// Format final content with proper labeling
const finalContent = `${userInput}\n\nFile Contents (PDF):\n${markdown}`;
// Process through pattern system
await sendMessage(finalContent, enhancedPrompt);
return markdown;
} catch (error) {
console.error('PDF Conversion error:', {
error,
fileName: file.name,
fileSize: file.size
});
const errorMessage = error instanceof Error
? error.message
: 'Unknown error during PDF conversion';
throw new Error(`Failed to convert PDF ${file.name}: ${errorMessage}`);
}
}
// Handle text files
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target?.result as string;
console.log('Text file processed:', {
fileName: file.name,
contentLength: content.length,
preview: content.substring(0, 100)
});
// resolve(content);
const enhancedPrompt = `${$systemPrompt}\nAnalyze and process the provided content according to these instructions.`;
const finalContent = `${userInput}\n\nFile Contents (Text):\n${content}`;
await sendMessage(finalContent, enhancedPrompt);
resolve(content);
};
reader.onerror = (e) => {
console.error('FileReader error:', {
error: reader.error,
fileName: file.name
});
reject(new Error(`Failed to read ${file.name}: ${reader.error?.message}`));
};
// Start reading the file
reader.readAsText(file);
});
}
async function saveToObsidian(content: string) { async function saveToObsidian(content: string) {
if (!$obsidianSettings.saveToObsidian) { if (!$obsidianSettings.saveToObsidian) {
console.log('Obsidian saving is disabled'); console.log('Obsidian saving is disabled');
@ -440,54 +343,6 @@ async function readFileContent(file: File): Promise<string> {
} }
} }
/* async function handleSubmit() {
if (!userInput.trim()) return;
try {
console.log('\n=== Submit Handler Start ===');
if (isYouTubeURL) {
console.log('2a. Starting YouTube flow');
await processYouTubeURL(userInput);
return;
}
const enhancedPrompt = fileContents.length > 0
? `${$systemPrompt}\nAnalyze and process the provided content according to these instructions.`
: $systemPrompt;
// Hide raw content from display but keep it for processing
messageStore.update(messages => [...messages, {
role: 'system',
content: 'Processing content...',
format: 'loading'
}]);
// Store the user input before clearing it
const inputText = userInput;
// Construct finalContent BEFORE clearing userInput
const finalContent = fileContents.length > 0
? `${inputText}\n\nFile Contents (${uploadedFiles.map(f => f.endsWith('.pdf') ? 'PDF' : 'Text').join(', ')}):\n${fileContents.join('\n\n---\n\n')}`
: inputText;
// Now clear the input fields
userInput = "";
uploadedFiles = [];
fileContents = [];
fileButtonKey = !fileButtonKey;
await sendMessage(finalContent, enhancedPrompt);
} catch (error) {
console.error('Chat submission error:', error);
}
} */
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();

View file

@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
import { readFileContent } from './read-file-content';
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
describe('readFileContent', () => {
const noPdf = {
convertToMarkdown: () => Promise.reject(new Error('unexpected PDF conversion'))
};
it('resolves with the text of a text file', async () => {
const file = new File(['# Notes\n\nPlain text.\n'], 'notes.md', { type: 'text/markdown' });
await expect(readFileContent(file, noPdf, () => {})).resolves.toBe('# Notes\n\nPlain text.\n');
});
it('resolves with the converted Markdown of a PDF file', async () => {
const pdf = {
convertToMarkdown: vi.fn().mockResolvedValue({ markdown: '# Title\n' })
};
const file = new File([pdfBytes], 'report.pdf', { type: 'application/pdf' });
await expect(readFileContent(file, pdf, () => {})).resolves.toBe('# Title\n');
expect(pdf.convertToMarkdown).toHaveBeenCalledWith(file);
});
it('forwards the conversion warning of a PDF file', async () => {
const pdf = {
convertToMarkdown: () => Promise.resolve({ markdown: '# Title\n', warning: 'partial text' })
};
const warn = vi.fn();
const file = new File([pdfBytes], 'report.pdf', { type: 'application/pdf' });
await readFileContent(file, pdf, warn);
expect(warn).toHaveBeenCalledWith('partial text');
});
});
// The repository has no browser test runner, so these tests pin the chat
// boundary at the source level: the component has no sendMessage path, and
// handleSubmit holds the one streamChat call. Together with the seam tests
// above, this proves that attachment parses without a chat request and that
// one submit sends one request.
describe('ChatInput chat boundary', () => {
const source = readFileSync(new URL('./ChatInput.svelte', import.meta.url), 'utf8');
it('has no sendMessage path, so a file attachment cannot send a chat request', () => {
expect(source.includes('sendMessage')).toBe(false);
});
it('sends through exactly one streamChat call, in handleSubmit', () => {
expect(source.match(/\.streamChat\(/g) ?? []).toHaveLength(1);
});
});

View file

@ -0,0 +1,17 @@
import type { PdfConversion } from '../../services/PdfConversionService';
// The parser seam for ChatInput. It converts one attached file to text.
// Keep this module free of chat and store imports: that constraint is what
// makes a file attachment unable to send a chat request.
export async function readFileContent(
file: File,
pdf: { convertToMarkdown(file: File): Promise<PdfConversion> },
warn: (message: string) => void
): Promise<string> {
if (file.type === 'application/pdf') {
const { markdown, warning } = await pdf.convertToMarkdown(file);
if (warning) warn(warning);
return markdown;
}
return file.text();
}

View file

@ -0,0 +1,199 @@
import type { PdfProcessResult } from '@firecrawl/pdf-inspector-wasm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { interpretPdfResult, PdfConversionService } from './PdfConversionService';
// Plain result objects replace the worker and the WASM module in these tests.
// The shape copies the PdfProcessResult fields that the spike measured.
function pdfResult(overrides: Partial<PdfProcessResult> = {}): PdfProcessResult {
return {
pdfType: 'TextBased',
markdown: '# Title\n\nBody text.\n',
pageCount: 1,
processingTimeMs: 5,
pagesNeedingOcr: [],
ocrReasonsByPage: [],
confidence: 0.5,
layout: { isComplex: false, pagesWithTables: [], pagesWithColumns: [] },
hasEncodingIssues: false,
...overrides
};
}
describe('interpretPdfResult', () => {
it('returns the Markdown with no warning for a TextBased result', () => {
const conversion = interpretPdfResult(pdfResult(), 'report.pdf');
expect(conversion).toEqual({ markdown: '# Title\n\nBody text.\n' });
});
it('throws the OCR message for a Scanned result', () => {
const scanned = pdfResult({ pdfType: 'Scanned', markdown: undefined });
expect(() => interpretPdfResult(scanned, 'report.pdf')).toThrowError(
'report.pdf contains no machine-readable text. OCR is necessary, and the Fabric web interface does not include OCR.'
);
});
it('throws the OCR message for an ImageBased result', () => {
const imageBased = pdfResult({ pdfType: 'ImageBased', markdown: undefined });
expect(() => interpretPdfResult(imageBased, 'report.pdf')).toThrowError(
'report.pdf contains no machine-readable text. OCR is necessary, and the Fabric web interface does not include OCR.'
);
});
it('keeps the Markdown and warns with the 1-indexed page list for a Mixed result', () => {
const mixed = pdfResult({ pdfType: 'Mixed', pagesNeedingOcr: [2, 5] });
const conversion = interpretPdfResult(mixed, 'report.pdf');
expect(conversion.markdown).toBe('# Title\n\nBody text.\n');
expect(conversion.warning).toBe(
'report.pdf: pages 2, 5 contain no machine-readable text. OCR is necessary for those pages, and their content is not included.'
);
});
it('adds the decode warning when hasEncodingIssues is true', () => {
const conversion = interpretPdfResult(pdfResult({ hasEncodingIssues: true }), 'report.pdf');
expect(conversion.markdown).toBe('# Title\n\nBody text.\n');
expect(conversion.warning).toBe('Some text in report.pdf did not decode correctly.');
});
it('joins the Mixed warning and the decode warning with one space', () => {
const mixed = pdfResult({
pdfType: 'Mixed',
pagesNeedingOcr: [3],
hasEncodingIssues: true
});
const conversion = interpretPdfResult(mixed, 'report.pdf');
expect(conversion.warning).toBe(
'report.pdf: pages 3 contain no machine-readable text. OCR is necessary for those pages, and their content is not included. ' +
'Some text in report.pdf did not decode correctly.'
);
});
it('throws when the markdown is absent', () => {
const noMarkdown = pdfResult({ markdown: undefined });
expect(() => interpretPdfResult(noMarkdown, 'report.pdf')).toThrowError(
'report.pdf: the conversion returned no text.'
);
});
it('throws when the markdown is blank', () => {
const blank = pdfResult({ markdown: ' \n' });
expect(() => interpretPdfResult(blank, 'report.pdf')).toThrowError(
'report.pdf: the conversion returned no text.'
);
});
});
// The fake worker records each posted request and lets a test send a response.
// This specifies the message contract without the WASM module.
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: ((event: { message?: string }) => void) | null = null;
terminated = false;
posted: Array<{ data: { id: number; buffer: ArrayBuffer }; transfer: Transferable[] }> = [];
constructor() {
FakeWorker.instances.push(this);
}
postMessage(data: { id: number; buffer: ArrayBuffer }, transfer: Transferable[]) {
this.posted.push({ data, transfer });
}
terminate() {
this.terminated = true;
}
}
describe('PdfConversionService worker messages', () => {
afterEach(() => {
vi.unstubAllGlobals();
FakeWorker.instances.length = 0;
});
function setup() {
vi.stubGlobal('Worker', FakeWorker);
const service = new PdfConversionService();
const file = new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], 'report.pdf', {
type: 'application/pdf'
});
return { service, file };
}
it('posts the buffer in the transfer list and resolves on a success response', async () => {
const { service, file } = setup();
const promise = service.convertToMarkdown(file);
await vi.waitFor(() => expect(FakeWorker.instances[0]?.posted).toHaveLength(1));
const worker = FakeWorker.instances[0];
const { data, transfer } = worker.posted[0];
expect(transfer).toEqual([data.buffer]);
worker.onmessage?.({ data: { id: data.id, ok: true, result: pdfResult() } });
await expect(promise).resolves.toEqual({ markdown: '# Title\n\nBody text.\n' });
});
it('rejects with the worker error text on a parser error response', async () => {
const { service, file } = setup();
const promise = service.convertToMarkdown(file);
await vi.waitFor(() => expect(FakeWorker.instances[0]?.posted).toHaveLength(1));
const worker = FakeWorker.instances[0];
const { data } = worker.posted[0];
worker.onmessage?.({
data: {
id: data.id,
ok: false,
error: 'Error: process PDF: Not a PDF: file appears to be plain text'
}
});
await expect(promise).rejects.toThrowError('Not a PDF: file appears to be plain text');
});
it('rejects all pending requests and terminates the worker on a worker error', async () => {
const { service, file } = setup();
const first = service.convertToMarkdown(file);
const second = service.convertToMarkdown(file);
await vi.waitFor(() => expect(FakeWorker.instances[0]?.posted).toHaveLength(2));
const worker = FakeWorker.instances[0];
worker.onerror?.({ message: 'worker script did not load' });
await expect(first).rejects.toThrowError('The PDF worker failed.');
await expect(second).rejects.toThrowError('The PDF worker failed.');
expect(worker.terminated).toBe(true);
});
it('creates a new worker for the request that follows a worker error', async () => {
const { service, file } = setup();
const failed = service.convertToMarkdown(file);
await vi.waitFor(() => expect(FakeWorker.instances[0]?.posted).toHaveLength(1));
FakeWorker.instances[0].onerror?.({ message: 'worker script did not load' });
await expect(failed).rejects.toThrowError();
const retry = service.convertToMarkdown(file);
await vi.waitFor(() => expect(FakeWorker.instances).toHaveLength(2));
const worker = FakeWorker.instances[1];
await vi.waitFor(() => expect(worker.posted).toHaveLength(1));
const { data } = worker.posted[0];
worker.onmessage?.({ data: { id: data.id, ok: true, result: pdfResult() } });
await expect(retry).resolves.toEqual({ markdown: '# Title\n\nBody text.\n' });
});
});

View file

@ -1,74 +1,81 @@
import { createPipeline, transformers } from "pdf-to-markdown-core/lib/src"; import type { PdfProcessResult } from '@firecrawl/pdf-inspector-wasm';
import { PARSE_SCHEMA } from "pdf-to-markdown-core/lib/src/PdfParser"; import type { PdfRequest, PdfResponse } from '../workers/pdf-inspector.worker';
// pdfjs-dist v5+ requires browser APIs at import time, so we use dynamic imports export interface PdfConversion {
let pdfjs: typeof import("pdfjs-dist") | null = null; markdown: string;
warning?: string;
}
// Maps the raw worker result to UI behavior. Throws when there is no usable text.
export function interpretPdfResult(result: PdfProcessResult, fileName: string): PdfConversion {
if (result.pdfType === 'Scanned' || result.pdfType === 'ImageBased') {
throw new Error(
`${fileName} contains no machine-readable text. OCR is necessary, and the Fabric web interface does not include OCR.`
);
}
const markdown = result.markdown;
if (!markdown || markdown.trim().length === 0) {
throw new Error(`${fileName}: the conversion returned no text.`);
}
const warnings: string[] = [];
if (result.pdfType === 'Mixed') {
warnings.push(
`${fileName}: pages ${result.pagesNeedingOcr.join(', ')} contain no machine-readable text. OCR is necessary for those pages, and their content is not included.`
);
}
if (result.hasEncodingIssues) {
warnings.push(`Some text in ${fileName} did not decode correctly.`);
}
return warnings.length > 0 ? { markdown, warning: warnings.join(' ') } : { markdown };
}
export class PdfConversionService { export class PdfConversionService {
private async ensureInitialized(): Promise<typeof import("pdfjs-dist")> { private worker: Worker | null = null;
if (!pdfjs) { private nextId = 1;
// Dynamic import to avoid SSR issues with pdfjs-dist v5+ private pending = new Map<
pdfjs = await import("pdfjs-dist"); number,
const pdfConfig = (await import("./pdf-config")).default; { resolve: (result: PdfProcessResult) => void; reject: (error: Error) => void }
console.log("PDF.js version:", pdfjs.version); >();
await pdfConfig.initialize();
console.log("Worker configuration complete"); async convertToMarkdown(file: File): Promise<PdfConversion> {
} const buffer = await file.arrayBuffer();
return pdfjs; const worker = this.getWorker();
const id = this.nextId++;
const result = await new Promise<PdfProcessResult>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
worker.postMessage({ id, buffer } satisfies PdfRequest, [buffer]);
});
return interpretPdfResult(result, file.name);
} }
async convertToMarkdown(file: File): Promise<string> { // Lazy creation delays the WASM download until the first PDF attachment.
console.log("Starting PDF conversion:", { private getWorker(): Worker {
fileName: file.name, if (this.worker) return this.worker;
fileSize: file.size, const worker = new Worker(new URL('../workers/pdf-inspector.worker.ts', import.meta.url), {
type: 'module'
}); });
worker.onmessage = (event: MessageEvent<PdfResponse>) => {
const pdfjsLib = await this.ensureInitialized(); const response = event.data;
const entry = this.pending.get(response.id);
const buffer = await file.arrayBuffer(); if (!entry) return;
console.log("Buffer created:", buffer.byteLength); this.pending.delete(response.id);
if (response.ok) {
const pipeline = createPipeline(pdfjsLib, { entry.resolve(response.result);
transformConfig: { } else {
transformers, entry.reject(new Error(response.error));
}, }
}); };
console.log("Pipeline created"); // Fires only when the worker script itself does not load. The handler
// terminates its own worker, so a later replacement worker is safe.
const result = await pipeline.parse(buffer, (progress) => worker.onerror = () => {
console.log("Processing:", { for (const { reject } of this.pending.values()) {
stage: progress.stages, reject(new Error('The PDF worker failed.'));
details: progress.stageDetails, }
progress: progress.stageProgress, this.pending.clear();
}), worker.terminate();
); this.worker = null;
console.log("Parse complete, validating result"); };
this.worker = worker;
const transformed = result.transform(); return worker;
console.log("Transform applied:", transformed);
const markdown = transformed.convert({
convert: (items) => {
console.log("PDF Structure:", {
itemCount: items.length,
firstItem: items[0],
schema: PARSE_SCHEMA, // ['transform', 'width', 'height', 'str', 'fontName', 'dir']
});
const text = items
.map((item) => item.value("str")) // Using 'str' instead of 'text' based on PARSE_SCHEMA
.filter(Boolean)
.join("\n");
console.log("Converted text:", {
length: text.length,
preview: text.substring(0, 100),
});
return text;
},
});
return markdown;
} }
} }

View file

@ -1,19 +0,0 @@
import { browser } from "$app/environment";
// Export the configuration - accepts pdfjs module to avoid top-level import
// This is necessary because pdfjs-dist v5+ uses browser APIs at import time
export default {
initialize: async () => {
if (browser) {
// Dynamic import to avoid SSR issues
const pdfjs = await import("pdfjs-dist");
const { GlobalWorkerOptions, version } = pdfjs;
// Use CDN-hosted worker to avoid bundling third-party minified code in the repo
const workerSrc = `https://unpkg.com/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
GlobalWorkerOptions.workerSrc = workerSrc;
console.log(`PDF.js worker v${version} initialized from CDN`);
}
},
};

View file

@ -0,0 +1,29 @@
import init, { processPdf, type PdfProcessResult } from '@firecrawl/pdf-inspector-wasm';
// Request, service → worker. The buffer moves through the transfer list.
export interface PdfRequest {
id: number;
buffer: ArrayBuffer;
}
// Response, worker → service.
export type PdfResponse =
{ id: number; ok: true; result: PdfProcessResult } | { id: number; ok: false; error: string };
// init() runs one time, at module evaluation. Each message handler awaits
// the same promise. An initialization failure becomes an error response.
const ready = init();
self.onmessage = async (event: MessageEvent<PdfRequest>) => {
const { id, buffer } = event.data;
try {
await ready;
const result = processPdf(new Uint8Array(buffer), {
profile: 'compact',
includePageMarkers: true
});
self.postMessage({ id, ok: true, result } satisfies PdfResponse);
} catch (err) {
self.postMessage({ id, ok: false, error: String(err) } satisfies PdfResponse);
}
};

View file

@ -17,13 +17,6 @@ export default defineConfig({
// Tailwind 4 gives a Vite plugin, which replaces the PostCSS setup. It must // Tailwind 4 gives a Vite plugin, which replaces the PostCSS setup. It must
// come before sveltekit() so that it can process the styles in components. // come before sveltekit() so that it can process the styles in components.
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
optimizeDeps: {
// Vite 8 optimizes dependencies with Rolldown, which supports top-level
// await and modern syntax. The `esbuildOptions` block that was here set
// `target` and `supported`, and Vite 8 reads neither key. Rolldown needs no
// replacement option. `build.target` below keeps the setting for the build.
include: ['pdfjs-dist']
},
define: { define: {
'process.env': { 'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV) NODE_ENV: JSON.stringify(process.env.NODE_ENV)