diff --git a/README.md b/README.md index e69de29..111faef 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,65 @@ +# XRayPDF 🔍✨ + +**XRayPDF** is a powerful web application designed to help students uncover hidden text in homework PDFs. Teachers sometimes embed text in tricky ways—using near‑invisible colors, extremely small fonts, or even steganographic tricks. PixelReveal renders each PDF page as an image, then applies a combination of pixel‑level analysis and Optical Character Recognition (OCR) to detect and reveal any hidden content. The result is a new, enhanced image where the hidden text becomes clearly visible. + +![XRayPDF Demo](https://via.placeholder.com/800x400.png?text=PixelReveal+Demo+Screenshot) +*will replace with actual demo screenshot)* + +--- + +## 🚀 Features + +- 📤 **Upload PDF** – Drag‑and‑drop or select a PDF file. +- 🖼️ **Page‑by‑Page Rendering** – Each page is converted to a high‑resolution image. +- 🔎 **Pixel‑Level Analysis** – Detects text with near‑background colors, low contrast, or steganographic patterns. +- 🔡 **OCR for Tiny Text** – Identifies and extracts extremely small fonts (e.g., <8pt). +- 🎨 **Revealed Image Generation** – Enhances low‑contrast areas, magnifies tiny text regions, or overlays OCR‑extracted text. +- 👁️ **Side‑by‑Side Comparison** – View original and revealed pages together. +- ⬇️ **Download Results** – Save the enhanced image for offline study. + +--- + +## 🧠 How It Works + +1. **Upload PDF** + The user uploads a PDF file. The backend uses `pdf.js` to render each page to a canvas at a high DPI, producing a sharp image buffer. + +2. **Pixel Analysis** + The image is processed with `sharp` to: + - Stretch contrast to amplify subtle differences. + - Invert colors to reveal light‑on‑light text. + - Detect edges that may indicate character shapes. + +3. **OCR for Tiny Text** + The same page image is fed to `tesseract.js`. The OCR engine identifies text regions and estimates font sizes. Text smaller than a configurable threshold (e.g., 8pt equivalent) is flagged as “tiny hidden text.” + +4. **Revealed Image Generation** + Based on the analysis: + - Low‑contrast areas are enhanced. + - Tiny text regions are cropped, upscaled, and blended back into the image. + - Optionally, the OCR‑extracted text can be overlaid in a large, clear font. + +5. **Display Results** + The original and revealed images are shown side‑by‑side. Users can download the revealed image or toggle different enhancement modes. + +--- + +## 🛠️ Tech Stack + +| Area | Technology | +|--------------------|----------------------------------------------------------------------------| +| Frontend | [Next.js](https://nextjs.org/) (App Router), [React](https://reactjs.org/), [Tailwind CSS](https://tailwindcss.com/) | +| Backend | Next.js API routes | +| PDF Rendering | [pdf.js](https://mozilla.github.io/pdf.js/) (Mozilla) | +| Image Processing | [sharp](https://sharp.pixelplumbing.com/) | +| OCR | [tesseract.js](https://tesseract.projectnaptha.com/) | +| Deployment | [Vercel](https://vercel.com/) (frontend) + optional self‑hosted worker | + +--- + +## 📦 Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/yourusername/pixelrevel.git + cd pixelrevel \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index f390d12..7007484 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -41,3 +41,7 @@ yarn-error.log* next-env.d.ts /src/generated/prisma + +# user uploads & results +/public/uploads +/public/results diff --git a/client/eng.traineddata b/client/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/client/eng.traineddata differ diff --git a/client/next.config.ts b/client/next.config.ts index 66e1566..4687ad7 100644 --- a/client/next.config.ts +++ b/client/next.config.ts @@ -3,6 +3,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + serverExternalPackages: ["canvas", "sharp", "tesseract.js", "pdfjs-dist"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + ], + }, }; export default nextConfig; diff --git a/client/package-lock.json b/client/package-lock.json index ce19754..d8626f6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,11 +10,17 @@ "dependencies": { "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", + "canvas": "^3.2.1", + "multer": "^2.0.2", "next": "16.1.6", "next-auth": "^4.24.13", + "pdfjs-dist": "^5.4.624", "pg": "^8.19.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sharp": "^0.34.5", + "tesseract.js": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -22,6 +28,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", @@ -1049,7 +1056,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -1574,6 +1580,256 @@ "node": ">=16" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", + "integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.95", + "@napi-rs/canvas-darwin-arm64": "0.1.95", + "@napi-rs/canvas-darwin-x64": "0.1.95", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.95", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.95", + "@napi-rs/canvas-linux-arm64-musl": "0.1.95", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.95", + "@napi-rs/canvas-linux-x64-gnu": "0.1.95", + "@napi-rs/canvas-linux-x64-musl": "0.1.95", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.95", + "@napi-rs/canvas-win32-x64-msvc": "0.1.95" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.95.tgz", + "integrity": "sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==", + "cpu": [ + "arm64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.95.tgz", + "integrity": "sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==", + "cpu": [ + "arm64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.95.tgz", + "integrity": "sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==", + "cpu": [ + "x64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.95.tgz", + "integrity": "sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==", + "cpu": [ + "arm" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.95.tgz", + "integrity": "sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==", + "cpu": [ + "arm64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.95.tgz", + "integrity": "sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==", + "cpu": [ + "arm64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.95.tgz", + "integrity": "sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==", + "cpu": [ + "riscv64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.95.tgz", + "integrity": "sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==", + "cpu": [ + "x64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.95.tgz", + "integrity": "sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==", + "cpu": [ + "x64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.95.tgz", + "integrity": "sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==", + "cpu": [ + "arm64" + ], + "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": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.95.tgz", + "integrity": "sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2349,6 +2605,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -2969,6 +3232,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3226,6 +3495,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3238,6 +3527,23 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3296,6 +3602,47 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -3405,6 +3752,20 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3453,6 +3814,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -3496,6 +3863,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3630,6 +4012,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3711,7 +4117,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3793,6 +4198,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -4479,6 +4893,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -4667,6 +5090,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4848,6 +5277,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5070,6 +5505,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5107,6 +5568,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5498,6 +5971,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -6089,6 +6568,15 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6113,6 +6601,39 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6130,12 +6651,29 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6143,6 +6681,24 @@ "dev": true, "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", @@ -6195,6 +6751,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6303,6 +6865,15 @@ } } }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6331,6 +6902,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -6350,6 +6951,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -6357,6 +6978,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6399,7 +7027,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6543,6 +7170,24 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -6691,6 +7336,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.624", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", + "integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.88", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -6932,6 +7590,33 @@ "preact": ">=10" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7013,6 +7698,16 @@ "devOptional": true, "license": "ISC" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7061,6 +7756,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -7100,6 +7819,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -7137,6 +7870,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/regexp-to-ast": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", @@ -7281,6 +8020,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7400,7 +8159,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -7444,7 +8202,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -7564,6 +8321,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7620,6 +8422,23 @@ "node": ">= 0.4" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -7826,6 +8645,58 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7897,6 +8768,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7962,6 +8839,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7975,6 +8864,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -8053,6 +8955,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -8193,13 +9101,23 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/valibot": { @@ -8217,6 +9135,28 @@ } } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8332,6 +9272,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -8372,6 +9318,15 @@ "graphmatch": "^1.1.0" } }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/client/package.json b/client/package.json index b27e8f0..c58f8e5 100644 --- a/client/package.json +++ b/client/package.json @@ -11,11 +11,17 @@ "dependencies": { "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", + "canvas": "^3.2.1", + "multer": "^2.0.2", "next": "16.1.6", "next-auth": "^4.24.13", + "pdfjs-dist": "^5.4.624", "pg": "^8.19.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sharp": "^0.34.5", + "tesseract.js": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -23,6 +29,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", diff --git a/client/prisma/schema.prisma b/client/prisma/schema.prisma index 3d1e0dd..3112c4d 100644 --- a/client/prisma/schema.prisma +++ b/client/prisma/schema.prisma @@ -13,10 +13,29 @@ datasource db { provider = "postgresql" } -model user{ - id String @id @default(uuid()) - email String @unique +model user { + id String @id @default(uuid()) + email String @unique name String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + documents pdfDocument[] +} + +model pdfDocument { + id String @id @default(uuid()) + userId String + user user @relation(fields: [userId], references: [id], onDelete: Cascade) + originalName String + filePath String + pixelResultDir String? + ocrResultDir String? + ocrRan Boolean @default(false) + fileSize Int + pageCount Int @default(1) + pixelPageCount Int @default(0) + ocrPageCount Int @default(0) + createdAt DateTime @default(now()) + + @@index([userId]) } \ No newline at end of file diff --git a/client/src/app/api/documents/[id]/delete/route.ts b/client/src/app/api/documents/[id]/delete/route.ts new file mode 100644 index 0000000..d0c57c4 --- /dev/null +++ b/client/src/app/api/documents/[id]/delete/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const doc = await prisma.pdfDocument.findUnique({ where: { id } }); + if (!doc) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user || doc.userId !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await prisma.pdfDocument.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/client/src/app/api/documents/[id]/ocr/route.ts b/client/src/app/api/documents/[id]/ocr/route.ts new file mode 100644 index 0000000..9b6f36d --- /dev/null +++ b/client/src/app/api/documents/[id]/ocr/route.ts @@ -0,0 +1,200 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; +import { readFile, writeFile, mkdir } from "fs/promises"; +import path from "path"; + +/** + * Tesseract OCR: runs OCR on EVERY page of the PDF. + * Streams progress back to the client via newline-delimited JSON. + * Only allowed ONCE per document. Must be explicitly triggered. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const doc = await prisma.pdfDocument.findUnique({ where: { id } }); + if (!doc) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + if (doc.ocrRan) { + // Return existing results + if (doc.ocrResultDir && doc.ocrPageCount > 0) { + const pages = Array.from({ length: doc.ocrPageCount }, (_, i) => ({ + page: i + 1, + path: `${doc.ocrResultDir}/page_${i + 1}.txt`, + })); + return NextResponse.json({ + pages, + pageCount: doc.ocrPageCount, + alreadyRan: true, + }); + } + return NextResponse.json( + { error: "OCR has already been run", alreadyRan: true }, + { status: 409 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + if (!user || doc.userId !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const pdfPath = path.join(process.cwd(), "public", doc.filePath); + const pdfBuffer = await readFile(pdfPath); + + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(pdfBuffer), + }); + const pdfDoc = await loadingTask.promise; + const numPages = pdfDoc.numPages; + + const resultsDir = path.join( + process.cwd(), + "public", + "results", + user.id, + id, + "ocr" + ); + await mkdir(resultsDir, { recursive: true }); + + const publicDir = `/results/${user.id}/${id}/ocr`; + + // Stream progress to the client via newline-delimited JSON + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + try { + const { createCanvas } = await import("canvas"); + const Tesseract = await import("tesseract.js"); + + const allPages: { page: number; path: string }[] = []; + + for (let pageNum = 1; pageNum <= numPages; pageNum++) { + // Send progress event + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: "progress", + page: pageNum, + totalPages: numPages, + }) + "\n" + ) + ); + + const page = await pdfDoc.getPage(pageNum); + const scale = 2.0; + const viewport = page.getViewport({ scale }); + const width = Math.floor(viewport.width); + const height = Math.floor(viewport.height); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + + await page.render({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvasContext: ctx as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvas: canvas as any, + viewport, + }).promise; + + const imageBuffer = canvas.toBuffer("image/png"); + const tempImgPath = path.join( + resultsDir, + `page_${pageNum}_temp.png` + ); + await writeFile(tempImgPath, imageBuffer); + + const { data } = await Tesseract.recognize(tempImgPath, "eng"); + + // Save per-page OCR text + const ocrFileName = `page_${pageNum}.txt`; + const ocrPath = path.join(resultsDir, ocrFileName); + await writeFile(ocrPath, data.text, "utf-8"); + + const publicPath = `${publicDir}/${ocrFileName}`; + allPages.push({ page: pageNum, path: publicPath }); + + // Send page-complete event + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: "page_done", + page: pageNum, + totalPages: numPages, + text: data.text, + confidence: data.confidence, + path: publicPath, + }) + "\n" + ) + ); + } + + // Update DB + await prisma.pdfDocument.update({ + where: { id }, + data: { + ocrRan: true, + ocrResultDir: publicDir, + ocrPageCount: numPages, + pageCount: numPages, + }, + }); + + // Send final complete event + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: "complete", + pages: allPages, + pageCount: numPages, + }) + "\n" + ) + ); + controller.close(); + } catch (err) { + console.error("OCR streaming error:", err); + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: "error", + error: "OCR processing failed", + }) + "\n" + ) + ); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + }, + }); + } catch (e) { + console.error("OCR error:", e); + return NextResponse.json( + { error: "OCR processing failed" }, + { status: 500 } + ); + } +} diff --git a/client/src/app/api/documents/[id]/pixel/route.ts b/client/src/app/api/documents/[id]/pixel/route.ts new file mode 100644 index 0000000..28e326e --- /dev/null +++ b/client/src/app/api/documents/[id]/pixel/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; +import sharp from "sharp"; +import { readFile, mkdir } from "fs/promises"; +import path from "path"; + +/** + * Pixel-level analysis: renders EVERY page of the PDF as a PNG, + * inverts colors and boosts contrast to reveal hidden white-on-white text. + * Returns an array of per-page result image paths. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const doc = await prisma.pdfDocument.findUnique({ where: { id } }); + if (!doc) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + if (!user || doc.userId !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // If already processed, return existing results + if (doc.pixelResultDir && doc.pixelPageCount > 0) { + const pages = Array.from({ length: doc.pixelPageCount }, (_, i) => ({ + page: i + 1, + path: `${doc.pixelResultDir}/page_${i + 1}.png`, + })); + return NextResponse.json({ pages, pageCount: doc.pixelPageCount }); + } + + try { + const pdfPath = path.join(process.cwd(), "public", doc.filePath); + const pdfBuffer = await readFile(pdfPath); + + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(pdfBuffer), + }); + const pdfDoc = await loadingTask.promise; + const numPages = pdfDoc.numPages; + + const resultsDir = path.join( + process.cwd(), + "public", + "results", + user.id, + id, + "pixel" + ); + await mkdir(resultsDir, { recursive: true }); + + const publicDir = `/results/${user.id}/${id}/pixel`; + const pages: { page: number; path: string }[] = []; + + const { createCanvas } = await import("canvas"); + + for (let pageNum = 1; pageNum <= numPages; pageNum++) { + const page = await pdfDoc.getPage(pageNum); + const scale = 2.0; + const viewport = page.getViewport({ scale }); + const width = Math.floor(viewport.width); + const height = Math.floor(viewport.height); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + + await page.render({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvasContext: ctx as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvas: canvas as any, + viewport, + }).promise; + + const imageBuffer = canvas.toBuffer("image/png"); + + const resultFileName = `page_${pageNum}.png`; + const resultPath = path.join(resultsDir, resultFileName); + + await sharp(imageBuffer) + .negate({ alpha: false }) + .normalize() + .sharpen() + .toFile(resultPath); + + pages.push({ + page: pageNum, + path: `${publicDir}/${resultFileName}`, + }); + } + + await prisma.pdfDocument.update({ + where: { id }, + data: { + pixelResultDir: publicDir, + pixelPageCount: numPages, + pageCount: numPages, + }, + }); + + return NextResponse.json({ pages, pageCount: numPages }); + } catch (e) { + console.error("Pixel analysis error:", e); + return NextResponse.json( + { error: "Pixel analysis failed" }, + { status: 500 } + ); + } +} diff --git a/client/src/app/api/documents/route.ts b/client/src/app/api/documents/route.ts new file mode 100644 index 0000000..3aea403 --- /dev/null +++ b/client/src/app/api/documents/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { + documents: { orderBy: { createdAt: "desc" } }, + }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ documents: user.documents }); +} diff --git a/client/src/app/api/upload/route.ts b/client/src/app/api/upload/route.ts new file mode 100644 index 0000000..1d602b8 --- /dev/null +++ b/client/src/app/api/upload/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; +import { writeFile, mkdir } from "fs/promises"; +import { v4 as uuidv4 } from "uuid"; +import path from "path"; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +const MAX_DOCUMENTS = 5; + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { documents: { orderBy: { createdAt: "asc" } } }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const formData = await req.formData(); + const file = formData.get("pdf") as File | null; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File exceeds 10 MB limit" }, + { status: 400 } + ); + } + + if (!file.name.toLowerCase().endsWith(".pdf")) { + return NextResponse.json( + { error: "Only PDF files are accepted" }, + { status: 400 } + ); + } + + // Enforce 5-document limit — delete oldest if at cap + if (user.documents.length >= MAX_DOCUMENTS) { + const oldest = user.documents[0]; + await prisma.pdfDocument.delete({ where: { id: oldest.id } }); + // Note: could also delete files from disk here + } + + const fileId = uuidv4(); + const uploadsDir = path.join(process.cwd(), "public", "uploads", user.id); + await mkdir(uploadsDir, { recursive: true }); + + const fileName = `${fileId}.pdf`; + const filePath = path.join(uploadsDir, fileName); + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(filePath, buffer); + + const publicPath = `/uploads/${user.id}/${fileName}`; + + // Detect page count from the uploaded PDF + let pageCount = 0; + try { + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) }); + const pdfDoc = await loadingTask.promise; + pageCount = pdfDoc.numPages; + } catch { + // fallback: if pdfjs fails, just store 0 + } + + const doc = await prisma.pdfDocument.create({ + data: { + userId: user.id, + originalName: file.name, + filePath: publicPath, + fileSize: file.size, + pageCount, + }, + }); + + return NextResponse.json({ document: doc }, { status: 201 }); +} diff --git a/client/src/app/dashboard/[id]/page.tsx b/client/src/app/dashboard/[id]/page.tsx new file mode 100644 index 0000000..b19a103 --- /dev/null +++ b/client/src/app/dashboard/[id]/page.tsx @@ -0,0 +1,636 @@ +"use client"; + +import { useEffect, useState, use, useCallback } from "react"; +import Link from "next/link"; + +type DocData = { + id: string; + originalName: string; + filePath: string; + pixelResultDir: string | null; + pixelPageCount: number; + ocrResultDir: string | null; + ocrPageCount: number; + ocrRan: boolean; + fileSize: number; + pageCount: number; + createdAt: string; +}; + +type PixelPage = { page: number; path: string }; +type OcrPage = { + page: number; + path: string; + text?: string; + confidence?: number; +}; + +export default function ViewerPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [doc, setDoc] = useState(null); + const [loading, setLoading] = useState(true); + + // Pixel state + const [pixelLoading, setPixelLoading] = useState(false); + const [pixelPages, setPixelPages] = useState([]); + const [pixelCurrentPage, setPixelCurrentPage] = useState(1); + + // OCR state + const [ocrLoading, setOcrLoading] = useState(false); + const [ocrPages, setOcrPages] = useState([]); + const [ocrCurrentPage, setOcrCurrentPage] = useState(1); + const [ocrProgress, setOcrProgress] = useState({ page: 0, total: 0 }); + + const [activeTab, setActiveTab] = useState<"original" | "pixel" | "ocr">( + "original" + ); + + const fetchDoc = useCallback(async () => { + setLoading(true); + const res = await fetch("/api/documents"); + if (res.ok) { + const data = await res.json(); + const found = data.documents.find((d: DocData) => d.id === id); + if (found) { + setDoc(found); + // Auto-trigger pixel if not done yet + if (!found.pixelResultDir || found.pixelPageCount === 0) { + runPixelAnalysis(found.id); + } else { + // Load cached pixel pages + const pages = Array.from( + { length: found.pixelPageCount }, + (_, i) => ({ + page: i + 1, + path: `${found.pixelResultDir}/page_${i + 1}.png`, + }) + ); + setPixelPages(pages); + } + // If OCR was already run, load the pages + if (found.ocrRan && found.ocrResultDir && found.ocrPageCount > 0) { + loadOcrPages(found.ocrResultDir, found.ocrPageCount); + } + } + } + setLoading(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + useEffect(() => { + fetchDoc(); + }, [fetchDoc]); + + const runPixelAnalysis = async (docId: string) => { + setPixelLoading(true); + try { + const res = await fetch(`/api/documents/${docId}/pixel`, { + method: "POST", + }); + if (res.ok) { + const data = await res.json(); + setPixelPages(data.pages); + setDoc((prev) => + prev + ? { + ...prev, + pixelResultDir: + data.pages[0]?.path?.replace(/\/page_\d+\.png$/, "") ?? null, + pixelPageCount: data.pageCount, + pageCount: data.pageCount, + } + : prev + ); + } + } catch (e) { + console.error("Pixel analysis error:", e); + } + setPixelLoading(false); + }; + + const loadOcrPages = async (dir: string, count: number) => { + const pages: OcrPage[] = []; + for (let i = 1; i <= count; i++) { + const pagePath = `${dir}/page_${i}.txt`; + try { + const res = await fetch(pagePath); + const text = res.ok ? await res.text() : "(failed to load)"; + pages.push({ page: i, path: pagePath, text }); + } catch { + pages.push({ page: i, path: pagePath, text: "(failed to load)" }); + } + } + setOcrPages(pages); + }; + + const runOcr = async () => { + if (!doc || doc.ocrRan) return; + setOcrLoading(true); + setOcrProgress({ page: 0, total: 0 }); + setOcrPages([]); + + try { + const res = await fetch(`/api/documents/${doc.id}/ocr`, { + method: "POST", + }); + + if (!res.ok) { + if (res.status === 409) { + const data = await res.json(); + if (data.pages) { + await loadOcrPages( + data.pages[0]?.path?.replace(/\/page_\d+\.txt$/, ""), + data.pageCount + ); + setDoc((prev) => (prev ? { ...prev, ocrRan: true } : prev)); + } + } + setOcrLoading(false); + return; + } + + // Stream reading (NDJSON) + const reader = res.body?.getReader(); + if (!reader) { + setOcrLoading(false); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event.type === "progress") { + setOcrProgress({ page: event.page, total: event.totalPages }); + } else if (event.type === "page_done") { + setOcrProgress({ page: event.page, total: event.totalPages }); + setOcrPages((prev) => [ + ...prev, + { + page: event.page, + path: event.path, + text: event.text, + confidence: event.confidence, + }, + ]); + } else if (event.type === "complete") { + setDoc((prev) => + prev + ? { + ...prev, + ocrRan: true, + ocrResultDir: + event.pages[0]?.path?.replace( + /\/page_\d+\.txt$/, + "" + ) ?? null, + ocrPageCount: event.pageCount, + } + : prev + ); + } + } catch { + // skip malformed lines + } + } + } + } catch (e) { + console.error("OCR error:", e); + } + setOcrLoading(false); + }; + + const downloadAll = (type: "pixel" | "ocr") => { + const pages = type === "pixel" ? pixelPages : ocrPages; + pages.forEach((p) => { + const a = document.createElement("a"); + a.href = p.path; + a.download = p.path.split("/").pop() || `page_${p.page}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!doc) { + return ( +
+

Document not found.

+ + Back to Dashboard + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + + + +
+

{doc.originalName}

+

+ {doc.pageCount > 0 ? `${doc.pageCount} pages \u00b7 ` : ""} + Uploaded {new Date(doc.createdAt).toLocaleString()} +

+
+
+ + {/* Mobile tab switcher */} +
+ {(["original", "pixel", "ocr"] as const).map((tab) => ( + + ))} +
+
+ + {/* 3-Panel Grid */} +
+ {/* Panel 1: Original */} + 0 ? `${doc.pageCount} pages` : "Source"} + badgeColor="bg-blue-500/10 text-blue-400" + visible={activeTab === "original"} + > +