From b8d17a22395c8506cf20ce140d2b1ddd51801e24 Mon Sep 17 00:00:00 2001 From: manuelcontrera Date: Wed, 23 Sep 2026 10:22:06 -0500 Subject: [PATCH 1/3] feat(pdf-tools): add pdf-tools extension --- extensions/pdf-tools/.gitignore | 5 + extensions/pdf-tools/README.md | 69 ++ extensions/pdf-tools/assets/pdftools-icon.png | Bin 0 -> 14285 bytes extensions/pdf-tools/package-lock.json | 653 ++++++++++++++++++ extensions/pdf-tools/package.json | 237 +++++++ extensions/pdf-tools/src/images-to-pdf.ts | 45 ++ extensions/pdf-tools/src/lib/images-to-pdf.ts | 144 ++++ extensions/pdf-tools/src/lib/qpdf.ts | 243 +++++++ extensions/pdf-tools/src/lib/selection.ts | 243 +++++++ extensions/pdf-tools/src/lib/watermark.ts | 58 ++ extensions/pdf-tools/src/merge.ts | 49 ++ extensions/pdf-tools/src/protect.ts | 56 ++ .../pdf-tools/src/split-by-file-size.ts | 72 ++ .../pdf-tools/src/split-by-page-count.ts | 72 ++ extensions/pdf-tools/src/unlock.ts | 56 ++ extensions/pdf-tools/src/watermark.ts | 79 +++ extensions/pdf-tools/tsconfig.json | 15 + 17 files changed, 2096 insertions(+) create mode 100644 extensions/pdf-tools/.gitignore create mode 100644 extensions/pdf-tools/README.md create mode 100644 extensions/pdf-tools/assets/pdftools-icon.png create mode 100644 extensions/pdf-tools/package-lock.json create mode 100644 extensions/pdf-tools/package.json create mode 100644 extensions/pdf-tools/src/images-to-pdf.ts create mode 100644 extensions/pdf-tools/src/lib/images-to-pdf.ts create mode 100644 extensions/pdf-tools/src/lib/qpdf.ts create mode 100644 extensions/pdf-tools/src/lib/selection.ts create mode 100644 extensions/pdf-tools/src/lib/watermark.ts create mode 100644 extensions/pdf-tools/src/merge.ts create mode 100644 extensions/pdf-tools/src/protect.ts create mode 100644 extensions/pdf-tools/src/split-by-file-size.ts create mode 100644 extensions/pdf-tools/src/split-by-page-count.ts create mode 100644 extensions/pdf-tools/src/unlock.ts create mode 100644 extensions/pdf-tools/src/watermark.ts create mode 100644 extensions/pdf-tools/tsconfig.json diff --git a/extensions/pdf-tools/.gitignore b/extensions/pdf-tools/.gitignore new file mode 100644 index 00000000..ffc6e870 --- /dev/null +++ b/extensions/pdf-tools/.gitignore @@ -0,0 +1,5 @@ +node_modules +vicinae-env.d.ts +dist +.DS_Store +*.log diff --git a/extensions/pdf-tools/README.md b/extensions/pdf-tools/README.md new file mode 100644 index 00000000..7c8576e4 --- /dev/null +++ b/extensions/pdf-tools/README.md @@ -0,0 +1,69 @@ +# PDF Tools for Vicinae + +An all-in-one PDF toolkit extension for **Vicinae**, ported and expanded from Raycast's PDF Tools. Works seamlessly across **Linux**, **macOS**, and **Windows**. + +--- + +## Features & Commands + +### `images-to-pdf` - Convert Images to PDF +Merge and convert multiple images of any format (`.png`, `.jpg`, `.jpeg`, `.tiff`, `.webp`, `.bmp`, `.gif`, `.avif`, etc.) into a single PDF document. +- Preserves original image quality and handles EXIF rotation. +- Blends transparent images cleanly over a solid white background. +- Sorts pages naturally according to image file names (e.g. `img1`, `img2`, `img10`). +- Saves the generated PDF directly in the source directory. + +### `merge` - Merge PDF Files +Combines two or more PDF files into a single unified document. +- Retains source page order and saves the merged file in the same directory. + +### `protect` - Protect PDF +Encrypts a PDF document with military-grade AES-256 password protection in place. + +### `unlock` - Unlock PDF +Removes password encryption and decrypts the document in place. + +### `split-by-file-size` - Split PDF by File Size +Intelligently splits a large PDF into multiple parts based on a maximum file size threshold (in MB) using an optimized binary search algorithm. + +### `split-by-page-count` - Split PDF by Page Count +Divides a PDF document into smaller parts containing a specified number of pages each. + +### `watermark` - Add Watermark +Applies a crisp vector text watermark across all pages of a PDF document with custom text, rotation angle (0° or 45°), and opacity. + +--- + +## Universal File Selection + +PDF Tools provides a frictionless file selection experience across all operating systems: +1. **Active Finder / Explorer Selection**: Directly picks files currently selected in macOS Finder. +2. **Clipboard Detection**: Automatically detects files copied to the clipboard (GNOME/Wayland clipboard, Windows Explorer clipboard, or copied file paths). +3. **Native File Picker Fallback**: If no files were previously selected or copied, an interactive native file dialog opens automatically (`zenity` / `kdialog` on Linux, AppleScript dialog on macOS, `OpenFileDialog` on Windows). + +--- + +## Prerequisites & Installation + +PDF Tools relies on lightweight system tools for cryptographic operations and image processing. + +### 1. `qpdf` (Required for merge, protect, unlock, split) +- **Linux (Ubuntu/Debian)**: `sudo apt install qpdf` +- **Linux (Arch/Manjaro)**: `sudo pacman -S qpdf` +- **Linux (Fedora)**: `sudo dnf install qpdf` +- **macOS**: `brew install qpdf` +- **Windows**: `winget install qpdf` (or `choco install qpdf`) + +### 2. Python & Pillow (Required for `images-to-pdf`) +- Ensure Python is installed on your system. +- Install Pillow: + ```bash + pip install Pillow + ``` + +--- + +## Credits + +- Ported, enhanced, and maintained for Vicinae by [manuelcontrera](https://github.com/manuelcontrera). +- Original Raycast extension created by [xilopaint](https://github.com/xilopaint). diff --git a/extensions/pdf-tools/assets/pdftools-icon.png b/extensions/pdf-tools/assets/pdftools-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7018f59732225391e9ff65177215d543ee4d138 GIT binary patch literal 14285 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
  • MGX{S^ph9fhQs!rI2)Qe1pOI~0*%CIE7wab?4XB1 z=iLr8Pj1^-Q2O9Z=m}x-3EZV>n?DG7`n^1czZ-U8)lH|-2+sww#>1UH>tofweked~?KioLl*4p6793`jKj0Ynn2 t{(tfcMtT4M literal 0 HcmV?d00001 diff --git a/extensions/pdf-tools/package-lock.json b/extensions/pdf-tools/package-lock.json new file mode 100644 index 00000000..7491fcf5 --- /dev/null +++ b/extensions/pdf-tools/package-lock.json @@ -0,0 +1,653 @@ +{ + "name": "pdf-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pdf-tools", + "license": "MIT", + "dependencies": { + "@vicinae/api": "^0.29.0", + "pdf-lib": "^1.17.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "esbuild": "^0.25.0", + "typescript": "^5.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@vicinae/api": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.29.0.tgz", + "integrity": "sha512-NDXl/oUZkBcO9/YSdR4Q3P9iePmtJDecwYMBiQnL94VsHcOP2+5HayShEjVzcZ3va7+lB5G2bxLeDsMrbwdeiQ==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "typescript": "5.9.2", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@vicinae/api/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/pdf-tools/package.json b/extensions/pdf-tools/package.json new file mode 100644 index 00000000..2bc3d1e9 --- /dev/null +++ b/extensions/pdf-tools/package.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "pdf-tools", + "title": "PDF Tools", + "description": "Your all-in-one PDF toolkit: split, merge, protect, unlock, and watermark PDFs", + "icon": "pdftools-icon.png", + "author": "manuelcontrera", + "pastContributors": [ + "xilopaint" + ], + "categories": [ + "Productivity", + "Developer Tools" + ], + "platforms": [ + "Linux", + "macOS", + "Windows" + ], + "license": "MIT", + "keywords": [ + "pdf", + "split", + "merge", + "protect", + "unlock", + "encrypt", + "decrypt", + "password", + "watermark", + "images" + ], + "commands": [ + { + "name": "images-to-pdf", + "title": "Convert Images to PDF", + "description": "Merge and convert images into a single PDF ordered by filename", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "outputFilename", + "type": "text", + "placeholder": "Output filename", + "required": true + } + ] + }, + { + "name": "merge", + "title": "Merge PDF Files", + "description": "Merge multiple PDF files into one", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "outputFilename", + "type": "text", + "placeholder": "Output filename", + "required": true + } + ] + }, + { + "name": "protect", + "title": "Protect PDF", + "description": "Protect PDF files with a password", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "password", + "type": "password", + "placeholder": "Password", + "required": true + } + ] + }, + { + "name": "unlock", + "title": "Unlock PDF", + "description": "Unlock password-protected PDF files", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "password", + "type": "password", + "placeholder": "Password", + "required": true + } + ] + }, + { + "name": "split-by-file-size", + "title": "Split PDF by File Size", + "description": "Split PDF files into multiple parts based on file size", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "maxSizeMB", + "type": "text", + "placeholder": "Max file size in MB", + "required": true + } + ], + "preferences": [ + { + "name": "suffix", + "title": "Output PDF Filename Suffix", + "description": "Suffix for split PDF filenames (e.g., 'part')", + "type": "textfield", + "default": "part", + "required": false + } + ] + }, + { + "name": "split-by-page-count", + "title": "Split PDF by Page Count", + "description": "Split PDF files into multiple parts based on the number of pages", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "pageCount", + "type": "text", + "placeholder": "Number of pages", + "required": true + } + ], + "preferences": [ + { + "name": "suffix", + "title": "Output PDF Filename Suffix", + "description": "Suffix for split PDF filenames (e.g., 'part')", + "type": "textfield", + "default": "part", + "required": false + } + ] + }, + { + "name": "watermark", + "title": "Watermark PDF", + "description": "Add watermarks to PDF files", + "mode": "no-view", + "icon": "pdftools-icon.png", + "arguments": [ + { + "name": "text", + "type": "text", + "placeholder": "Watermark text", + "required": true + }, + { + "name": "fontSize", + "type": "dropdown", + "placeholder": "Font size", + "required": false, + "data": [ + { + "title": "Small", + "value": "48" + }, + { + "title": "Medium", + "value": "64" + }, + { + "title": "Large", + "value": "72" + } + ] + } + ], + "preferences": [ + { + "name": "transparency", + "title": "Transparency", + "description": "Transparency level of the watermark", + "type": "dropdown", + "required": true, + "default": "0.25", + "data": [ + { + "title": "Light", + "value": "0.125" + }, + { + "title": "Medium", + "value": "0.25" + }, + { + "title": "Dark", + "value": "0.5" + } + ] + }, + { + "name": "rotation", + "title": "Rotation", + "description": "Rotation angle of the watermark", + "type": "dropdown", + "required": true, + "default": "45", + "data": [ + { + "title": "No Rotation", + "value": "0" + }, + { + "title": "45°", + "value": "45" + } + ] + } + ] + } + ], + "scripts": { + "build": "vici build", + "dev": "vici develop", + "lint": "vici lint" + }, + "dependencies": { + "@vicinae/api": "^0.29.0", + "pdf-lib": "^1.17.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "esbuild": "^0.25.0", + "typescript": "^5.8.0" + } +} diff --git a/extensions/pdf-tools/src/images-to-pdf.ts b/extensions/pdf-tools/src/images-to-pdf.ts new file mode 100644 index 00000000..b61a3556 --- /dev/null +++ b/extensions/pdf-tools/src/images-to-pdf.ts @@ -0,0 +1,45 @@ +import * as path from "node:path"; +import { closeMainWindow, showToast, Toast } from "@vicinae/api"; +import { convertImagesToPDF, SUPPORTED_IMAGE_EXTENSIONS } from "./lib/images-to-pdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +export default async function Command(props: { arguments: { outputFilename: string } }) { + try { + const { outputFilename } = props.arguments; + + if (!outputFilename || !outputFilename.trim()) { + throw new Error("Output filename cannot be empty"); + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + allowedExtensions: SUPPORTED_IMAGE_EXTENSIONS, + filterName: "Image files", + title: "Select image(s) to merge into PDF", + }); + + if (selectedFiles.length === 0) { + throw new Error("No images have been selected"); + } + + await closeMainWindow(); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Converting ${selectedFiles.length} image(s) to PDF...`, + }); + + const outputPath = await convertImagesToPDF(selectedFiles, outputFilename.trim()); + + toast.style = Toast.Style.Success; + toast.title = "Images converted to PDF successfully"; + toast.message = path.basename(outputPath); + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Conversion failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/lib/images-to-pdf.ts b/extensions/pdf-tools/src/lib/images-to-pdf.ts new file mode 100644 index 00000000..3f5231e2 --- /dev/null +++ b/extensions/pdf-tools/src/lib/images-to-pdf.ts @@ -0,0 +1,144 @@ +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export const SUPPORTED_IMAGE_EXTENSIONS = [ + ".png", + ".jpg", + ".jpeg", + ".tiff", + ".tif", + ".webp", + ".bmp", + ".gif", + ".ico", + ".avif", +]; + +/** + * Natural sort files according to their basename, e.g. [img1, img2, img10] + */ +export function naturalSort(files: string[]): string[] { + return [...files].sort((a, b) => + path.basename(a).localeCompare(path.basename(b), undefined, { + numeric: true, + sensitivity: "base", + }) + ); +} + +/** + * Python script to convert multiple images to a single PDF using Pillow. + * Preserves resolution, handles EXIF orientation, and renders transparency over white background. + */ +const PYTHON_CONVERTER_SCRIPT = ` +import sys +from PIL import Image, ImageOps + +output_pdf = sys.argv[1] +input_images = sys.argv[2:] + +processed_images = [] + +for file_path in input_images: + try: + im = Image.open(file_path) + # Apply EXIF rotation if present + try: + im = ImageOps.exif_transpose(im) + except Exception: + pass + + # Handle RGBA/transparency by pasting on a clean white canvas + if im.mode in ('RGBA', 'LA') or (im.mode == 'P' and 'transparency' in im.info): + rgba = im.convert('RGBA') + canvas = Image.new('RGB', rgba.size, (255, 255, 255)) + canvas.paste(rgba, mask=rgba.split()[3]) + processed_images.append(canvas) + else: + processed_images.append(im.convert('RGB')) + except Exception as e: + print(f"Error loading {file_path}: {e}", file=sys.stderr) + raise + +if not processed_images: + sys.exit("No valid images could be processed") + +processed_images[0].save( + output_pdf, + save_all=True, + append_images=processed_images[1:], + quality=95 +) +`; + +async function getPythonExecutable(): Promise { + const candidates = + process.platform === "win32" + ? ["python", "py", "python3"] + : ["python3", "python"]; + + for (const cmd of candidates) { + try { + await execFileAsync(cmd, ["--version"]); + return cmd; + } catch { + // continue to next candidate + } + } + return process.platform === "win32" ? "python" : "python3"; +} + +export async function convertImagesToPDF( + imagePaths: string[], + outputFilename: string +): Promise { + if (imagePaths.length === 0) { + throw new Error("No images were selected for conversion"); + } + + // 1. Sort images naturally by filename + const sortedImages = naturalSort(imagePaths); + + // 2. Determine target directory from first image + const targetDir = path.dirname(sortedImages[0]); + const safeFilename = outputFilename.toLowerCase().endsWith(".pdf") + ? outputFilename + : `${outputFilename}.pdf`; + const outputPath = path.join(targetDir, safeFilename); + + // 3. Resolve python executable across platforms + const pythonCmd = await getPythonExecutable(); + + // 4. Execute python converter + try { + await execFileAsync(pythonCmd, [ + "-c", + PYTHON_CONVERTER_SCRIPT, + outputPath, + ...sortedImages, + ]); + + if (!fs.existsSync(outputPath)) { + throw new Error("Target PDF was not generated"); + } + + return outputPath; + } catch (err: any) { + const errorDetails = err.stderr || err.message || String(err); + if (errorDetails.includes("No module named 'PIL'") || errorDetails.includes("No module named PIL")) { + throw new Error( + "Pillow (PIL) is not installed. Please install it using 'pip install Pillow' to enable image conversion." + ); + } + if (errorDetails.includes("ENOENT") || errorDetails.includes("not found")) { + throw new Error( + "Python is not installed or not found in PATH. Please install Python and Pillow ('pip install Pillow')." + ); + } + throw new Error(`Failed to convert images to PDF: ${errorDetails}`); + } +} diff --git a/extensions/pdf-tools/src/lib/qpdf.ts b/extensions/pdf-tools/src/lib/qpdf.ts new file mode 100644 index 00000000..02c2e6be --- /dev/null +++ b/extensions/pdf-tools/src/lib/qpdf.ts @@ -0,0 +1,243 @@ +import { exec } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +/** + * Escapes shell argument safely + */ +function escapeArg(arg: string): string { + return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`; +} + +export function formatQpdfError(err: any): string { + const msg = err.message || String(err); + if ( + msg.includes("not found") || + msg.includes("is not recognized") || + err.code === 127 || + err.code === "ENOENT" + ) { + return "qpdf is not installed or not found in PATH. Please install qpdf ('sudo apt install qpdf' on Linux, 'brew install qpdf' on macOS, or 'winget install qpdf' on Windows)."; + } + return msg; +} + +/** + * Check if PDF document is locked / password protected. + * Exit code 0 from qpdf --is-encrypted indicates file is encrypted. + * Exit code 2 indicates file is NOT encrypted. + */ +export async function isPDFDocumentLocked(filePath: string): Promise { + try { + await execAsync(`qpdf --is-encrypted ${escapeArg(filePath)}`); + return true; // Exit code 0: encrypted + } catch (err: any) { + if (err.code === 2) { + return false; // Exit code 2: not encrypted + } + throw new Error(`Failed to inspect PDF: ${formatQpdfError(err)}`); + } +} + +/** + * Check if the provided password can unlock the PDF document. + * Exit code 3 indicates correct password. + * Exit code 0 indicates password required / wrong password. + * Exit code 2 indicates file is not encrypted. + */ +export async function verifyPassword(filePath: string, password: string): Promise { + try { + await execAsync( + `qpdf --requires-password --password=${escapeArg(password)} ${escapeArg(filePath)}` + ); + return false; + } catch (err: any) { + if (err.code === 3 || err.code === 2) { + return true; + } + return false; + } +} + +/** + * Get total number of pages in a PDF document. + */ +export async function getPDFPageCount(filePath: string): Promise { + const { stdout } = await execAsync(`qpdf --show-npages ${escapeArg(filePath)}`); + const count = parseInt(stdout.trim(), 10); + if (isNaN(count) || count <= 0) { + throw new Error(`Could not determine page count for "${path.basename(filePath)}"`); + } + return count; +} + +/** + * Merge multiple PDF files into one. + */ +export async function mergePDFs(filePaths: string[], outputFilename: string): Promise { + if (filePaths.length < 2) { + throw new Error("You must select at least two PDF files"); + } + + const outputDir = path.dirname(filePaths[0]); + const safeFilename = outputFilename.toLowerCase().endsWith(".pdf") + ? outputFilename + : `${outputFilename}.pdf`; + const outputPath = path.join(outputDir, safeFilename); + + const escapedInputs = filePaths.map((p) => escapeArg(p)).join(" "); + const cmd = `qpdf --empty --pages ${escapedInputs} -- ${escapeArg(outputPath)}`; + + try { + await execAsync(cmd); + return outputPath; + } catch (err: any) { + throw new Error(`Failed to merge PDF files: ${formatQpdfError(err)}`); + } +} + +/** + * Protect a PDF file with AES-256 password encryption in place. + */ +export async function protectPDF(filePath: string, password: string): Promise { + const cmd = `qpdf --encrypt ${escapeArg(password)} ${escapeArg(password)} 256 -- ${escapeArg(filePath)} --replace-input`; + try { + await execAsync(cmd); + } catch (err: any) { + throw new Error(`Failed to protect PDF "${path.basename(filePath)}": ${formatQpdfError(err)}`); + } +} + +/** + * Unlock a password-protected PDF file in place. + */ +export async function unlockPDF(filePath: string, password: string): Promise { + const isValid = await verifyPassword(filePath, password); + if (!isValid) { + throw new Error(`Incorrect password for "${path.basename(filePath)}"`); + } + + const cmd = `qpdf --decrypt --password=${escapeArg(password)} ${escapeArg(filePath)} --replace-input`; + try { + await execAsync(cmd); + } catch (err: any) { + throw new Error(`Failed to unlock PDF "${path.basename(filePath)}": ${formatQpdfError(err)}`); + } +} + +/** + * Split a PDF file by page count. + */ +export async function splitByPageCount( + filePath: string, + pageCount: number, + suffix: string = "part" +): Promise { + const totalPages = await getPDFPageCount(filePath); + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + + const outputFiles: string[] = []; + let partNumber = 1; + + for (let start = 1; start <= totalPages; start += pageCount) { + const stop = Math.min(start + pageCount - 1, totalPages); + const outName = `${baseName} [${suffix} ${partNumber}].pdf`; + const outPath = path.join(dir, outName); + + const cmd = `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${stop} -- ${escapeArg(outPath)}`; + await execAsync(cmd); + + outputFiles.push(outPath); + partNumber++; + } + + return outputFiles; +} + +/** + * Split a PDF file by file size in MB. + */ +export async function splitByFileSize( + filePath: string, + maxSizeMB: number, + suffix: string = "part" +): Promise { + const maxSizeBytes = Math.round(maxSizeMB * 1_000_000); + const totalPages = await getPDFPageCount(filePath); + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + + const outputFiles: string[] = []; + const tempPath = path.join(dir, `.temp_split_${Date.now()}.pdf`); + + let start = 1; + let partNumber = 1; + + try { + while (start <= totalPages) { + let low = start; + let high = totalPages; + let bestStop = start; + + // First check if single page alone exceeds or fits + await execAsync( + `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${start} -- ${escapeArg(tempPath)}` + ); + const singlePageSize = fs.statSync(tempPath).size; + + if (singlePageSize >= maxSizeBytes || start === totalPages) { + // Single page exceeds or is last page + const outName = `${baseName} [${suffix} ${partNumber}].pdf`; + const outPath = path.join(dir, outName); + fs.renameSync(tempPath, outPath); + outputFiles.push(outPath); + start++; + partNumber++; + continue; + } + + // Binary search for maximum number of pages fitting within maxSizeBytes + while (low <= high) { + const mid = Math.floor((low + high) / 2); + await execAsync( + `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${mid} -- ${escapeArg(tempPath)}` + ); + const currentSize = fs.statSync(tempPath).size; + + if (currentSize <= maxSizeBytes) { + bestStop = mid; + low = mid + 1; // Try more pages + } else { + high = mid - 1; // Too big, reduce pages + } + } + + // Generate the final part with bestStop + const outName = `${baseName} [${suffix} ${partNumber}].pdf`; + const outPath = path.join(dir, outName); + await execAsync( + `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${bestStop} -- ${escapeArg(outPath)}` + ); + + outputFiles.push(outPath); + start = bestStop + 1; + partNumber++; + } + } finally { + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // ignore + } + } + } + + return outputFiles; +} diff --git a/extensions/pdf-tools/src/lib/selection.ts b/extensions/pdf-tools/src/lib/selection.ts new file mode 100644 index 00000000..434282d1 --- /dev/null +++ b/extensions/pdf-tools/src/lib/selection.ts @@ -0,0 +1,243 @@ +import { exec } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { Clipboard } from "@vicinae/api"; + +const execAsync = promisify(exec); + +export interface SelectionOptions { + minFiles?: number; + multiple?: boolean; + title?: string; + promptIfNone?: boolean; + allowedExtensions?: string[]; + filterName?: string; +} + +function cleanFilePath(rawPath: string, allowedExtensions: string[] = [".pdf"]): string | null { + let cleaned = rawPath.trim(); + if (!cleaned) return null; + + if (cleaned.startsWith("file://")) { + cleaned = decodeURIComponent(cleaned.replace(/^file:\/\//, "")); + } + + // Remove surrounding quotes if any + cleaned = cleaned.replace(/^['"]|['"]$/g, ""); + + const hasAllowedExt = allowedExtensions.some((ext) => + cleaned.toLowerCase().endsWith(ext.toLowerCase()) + ); + + if (hasAllowedExt && fs.existsSync(cleaned)) { + return path.resolve(cleaned); + } + return null; +} + +async function getClipboardFiles(allowedExtensions: string[]): Promise { + const found = new Set(); + + // 1. Try Linux Wayland wl-paste (text/uri-list and x-special/gnome-copied-files) + if (process.platform === "linux") { + try { + const { stdout } = await execAsync("wl-paste -t text/uri-list 2>/dev/null || true"); + if (stdout) { + for (const line of stdout.split(/\r?\n/)) { + const cleaned = cleanFilePath(line, allowedExtensions); + if (cleaned) found.add(cleaned); + } + } + } catch { + // ignore + } + + try { + const { stdout } = await execAsync( + "wl-paste -t x-special/gnome-copied-files 2>/dev/null || true" + ); + if (stdout) { + for (const line of stdout.split(/\r?\n/)) { + if (line.startsWith("copy") || line.startsWith("cut")) continue; + const cleaned = cleanFilePath(line, allowedExtensions); + if (cleaned) found.add(cleaned); + } + } + } catch { + // ignore + } + } + + // 2. Try Windows PowerShell clipboard (FileDropList) + if (process.platform === "win32") { + try { + const { stdout } = await execAsync( + 'powershell.exe -NoProfile -NonInteractive -Command "Get-Clipboard -Format FileDropList"' + ); + if (stdout) { + for (const line of stdout.split(/\r?\n/)) { + const cleaned = cleanFilePath(line, allowedExtensions); + if (cleaned) found.add(cleaned); + } + } + } catch { + // ignore + } + } + + // 3. Try @vicinae/api Clipboard.readText() + try { + const text = await Clipboard.readText(); + if (text) { + for (const line of text.split(/\r?\n/)) { + const cleaned = cleanFilePath(line, allowedExtensions); + if (cleaned) found.add(cleaned); + } + } + } catch { + // ignore + } + + return Array.from(found); +} + +async function getMacOSFinderSelection(allowedExtensions: string[]): Promise { + try { + const script = ` + tell application "Finder" + set theSelection to selection + set posixPaths to {} + repeat with aFile in theSelection + set end of posixPaths to POSIX path of (aFile as alias) + end repeat + return posixPaths + end tell + `; + const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`); + if (!stdout) return []; + return stdout + .split(",") + .map((s) => cleanFilePath(s.trim(), allowedExtensions)) + .filter((s): s is string => s !== null); + } catch { + return []; + } +} + +async function pickFilesWithDialog(options: SelectionOptions): Promise { + const { + multiple = false, + title = "Select File(s)", + allowedExtensions = [".pdf"], + filterName = "Supported files", + } = options; + + if (process.platform === "linux") { + try { + const multFlag = multiple ? '--multiple --separator="|"' : ""; + const patterns = allowedExtensions + .map((ext) => `*${ext} *${ext.toUpperCase()}`) + .join(" "); + const filter = `${filterName} | ${patterns}`; + const cmd = `zenity --file-selection ${multFlag} --file-filter="${filter}" --title="${title.replace(/"/g, '\\"')}"`; + const { stdout } = await execAsync(cmd); + if (!stdout || !stdout.trim()) { + throw new Error("File selection cancelled"); + } + const rawPaths = multiple ? stdout.trim().split("|") : [stdout.trim()]; + const valid = rawPaths + .map((p) => cleanFilePath(p, allowedExtensions)) + .filter((p): p is string => p !== null); + + if (valid.length === 0) { + throw new Error("No valid files selected"); + } + return valid; + } catch (err: any) { + if (err.message && err.message.includes("cancelled")) { + throw new Error("File selection cancelled"); + } + throw new Error(`File selection failed: ${err.message || String(err)}`); + } + } + + if (process.platform === "darwin") { + try { + const prompt = title.replace(/"/g, '\\"'); + const mult = multiple ? "with multiple selections allowed" : ""; + const typeList = allowedExtensions.map((e) => `"${e.replace(/^\./, "")}"`).join(","); + const script = `choose file with prompt "${prompt}" of type {${typeList}} ${mult}`; + const { stdout } = await execAsync(`osascript -e '${script}'`); + if (!stdout.trim()) { + throw new Error("File selection cancelled"); + } + const paths = stdout + .split(",") + .map((p) => cleanFilePath(p.trim(), allowedExtensions)) + .filter((p): p is string => p !== null); + return paths; + } catch { + throw new Error("File selection cancelled"); + } + } + + if (process.platform === "win32") { + try { + const multBool = multiple ? "$true" : "$false"; + const filterStr = `${filterName} (${allowedExtensions.map((e) => `*${e}`).join(", ")})|${allowedExtensions.map((e) => `*${e}`).join(";")}`; + const psScript = `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.OpenFileDialog; $f.Title = '${title.replace(/'/g, "''")}'; $f.Filter = '${filterStr}'; $f.Multiselect = ${multBool}; if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.FileNames -join [Environment]::NewLine }`; + const { stdout } = await execAsync( + `powershell.exe -NoProfile -NonInteractive -Command "${psScript}"` + ); + if (!stdout || !stdout.trim()) { + throw new Error("File selection cancelled"); + } + const rawPaths = stdout.trim().split(/\r?\n/); + const valid = rawPaths + .map((p) => cleanFilePath(p, allowedExtensions)) + .filter((p): p is string => p !== null); + if (valid.length === 0) { + throw new Error("No valid files selected"); + } + return valid; + } catch (err: any) { + if (err.message && err.message.includes("cancelled")) { + throw new Error("File selection cancelled"); + } + throw new Error(`File selection failed: ${err.message || String(err)}`); + } + } + + throw new Error("Unsupported platform for file dialog"); +} + +export async function getSelectedOrPickedFiles(options: SelectionOptions = {}): Promise { + const minFiles = options.minFiles ?? 1; + const allowedExtensions = options.allowedExtensions ?? [".pdf"]; + + // 1. Try macOS Finder selection first if on macOS + if (process.platform === "darwin") { + const finderFiles = await getMacOSFinderSelection(allowedExtensions); + if (finderFiles.length >= minFiles) { + return finderFiles; + } + } + + // 2. Try clipboard (Nautilus copy, copied paths) + const clipboardFiles = await getClipboardFiles(allowedExtensions); + if (clipboardFiles.length >= minFiles) { + return clipboardFiles; + } + + // 3. Fallback to interactive native dialog + if (options.promptIfNone !== false) { + return await pickFilesWithDialog(options); + } + + throw new Error( + minFiles > 1 + ? `You must select at least ${minFiles} files` + : "No file has been selected" + ); +} diff --git a/extensions/pdf-tools/src/lib/watermark.ts b/extensions/pdf-tools/src/lib/watermark.ts new file mode 100644 index 00000000..9e17c7fa --- /dev/null +++ b/extensions/pdf-tools/src/lib/watermark.ts @@ -0,0 +1,58 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { PDFDocument, rgb, degrees, StandardFonts } from "pdf-lib"; + +export async function watermarkPDF( + filePath: string, + text: string, + transparency: number, + rotation: number, + fontSize: number = 72 +): Promise { + const existingPdfBytes = fs.readFileSync(filePath); + const pdfDoc = await PDFDocument.load(existingPdfBytes); + const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const pages = pdfDoc.getPages(); + + for (const page of pages) { + const { width, height } = page.getSize(); + const textWidth = font.widthOfTextAtSize(text, fontSize); + const textHeight = font.heightAtSize(fontSize); + + // Calculate rotation in radians + const rad = (rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + // Page center + const centerX = width / 2; + const centerY = height / 2; + + // In pdf-lib, drawText rotates the text around its origin (x, y). + // The center of text relative to origin is: + // cx' = (textWidth / 2) * cos - (textHeight / 2) * sin + // cy' = (textWidth / 2) * sin + (textHeight / 2) * cos + // To position the rotated center at (centerX, centerY): + const x = centerX - (textWidth / 2) * cos + (textHeight / 2) * sin; + const y = centerY - (textWidth / 2) * sin - (textHeight / 2) * cos; + + page.drawText(text, { + x, + y, + size: fontSize, + font, + color: rgb(0, 0, 0), + opacity: Math.max(0.01, Math.min(1.0, transparency)), + rotate: degrees(rotation), + }); + } + + const pdfBytes = await pdfDoc.save(); + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + const outputPath = path.join(dir, `${baseName} [watermarked].pdf`); + + fs.writeFileSync(outputPath, pdfBytes); + return outputPath; +} diff --git a/extensions/pdf-tools/src/merge.ts b/extensions/pdf-tools/src/merge.ts new file mode 100644 index 00000000..b1360c6c --- /dev/null +++ b/extensions/pdf-tools/src/merge.ts @@ -0,0 +1,49 @@ +import * as path from "node:path"; +import { closeMainWindow, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked, mergePDFs } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +export default async function Command(props: { arguments: { outputFilename: string } }) { + try { + const { outputFilename } = props.arguments; + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 2, + multiple: true, + title: "Select at least 2 PDF files to merge", + }); + + if (selectedFiles.length < 2) { + throw new Error("You must select at least two PDF files"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (await isPDFDocumentLocked(filePath)) { + throw new Error(`"${path.basename(filePath)}" is password-protected`); + } + } + + await closeMainWindow(); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Merging PDF files...", + }); + + const outputPath = await mergePDFs(selectedFiles, outputFilename); + + toast.style = Toast.Style.Success; + toast.title = "PDF files merged successfully"; + toast.message = path.basename(outputPath); + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Merge failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/protect.ts b/extensions/pdf-tools/src/protect.ts new file mode 100644 index 00000000..918792af --- /dev/null +++ b/extensions/pdf-tools/src/protect.ts @@ -0,0 +1,56 @@ +import * as path from "node:path"; +import { closeMainWindow, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked, protectPDF } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +export default async function Command(props: { arguments: { password: string } }) { + try { + const { password } = props.arguments; + + if (!password) { + throw new Error("Password cannot be empty"); + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + title: "Select PDF file(s) to protect with password", + }); + + if (selectedFiles.length === 0) { + throw new Error("No files have been selected"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (await isPDFDocumentLocked(filePath)) { + throw new Error(`"${path.basename(filePath)}" is already password-protected`); + } + } + + await closeMainWindow(); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Protecting ${selectedFiles.length} file(s)...`, + }); + + for (const filePath of selectedFiles) { + toast.message = path.basename(filePath); + await protectPDF(filePath, password); + } + + toast.style = Toast.Style.Success; + toast.title = `PDF file${selectedFiles.length > 1 ? "s" : ""} protected successfully`; + toast.message = ""; + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Protection failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/split-by-file-size.ts b/extensions/pdf-tools/src/split-by-file-size.ts new file mode 100644 index 00000000..b7f153b0 --- /dev/null +++ b/extensions/pdf-tools/src/split-by-file-size.ts @@ -0,0 +1,72 @@ +import * as path from "node:path"; +import { closeMainWindow, getPreferenceValues, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked, splitByFileSize } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +interface Preferences { + suffix?: string; +} + +export default async function Command(props: { arguments: { maxSizeMB: string } }) { + try { + const maxSizeMB = parseFloat(props.arguments.maxSizeMB); + + if (isNaN(maxSizeMB) || maxSizeMB <= 0) { + throw new Error("A positive number is required for max file size"); + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + title: "Select PDF file(s) to split by size", + }); + + if (selectedFiles.length === 0) { + throw new Error("You must select at least one PDF file"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (await isPDFDocumentLocked(filePath)) { + throw new Error(`"${path.basename(filePath)}" is password-protected`); + } + } + + await closeMainWindow(); + + let suffix = "part"; + try { + const preferences = getPreferenceValues(); + if (preferences.suffix && preferences.suffix.trim()) { + suffix = preferences.suffix.trim(); + } + } catch { + // default "part" + } + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Splitting ${selectedFiles.length} file(s)...`, + }); + + let totalParts = 0; + for (const filePath of selectedFiles) { + toast.message = path.basename(filePath); + const parts = await splitByFileSize(filePath, maxSizeMB, suffix); + totalParts += parts.length; + } + + toast.style = Toast.Style.Success; + toast.title = `PDF file${selectedFiles.length > 1 ? "s" : ""} split successfully`; + toast.message = `Generated ${totalParts} part(s)`; + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Split failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/split-by-page-count.ts b/extensions/pdf-tools/src/split-by-page-count.ts new file mode 100644 index 00000000..48978263 --- /dev/null +++ b/extensions/pdf-tools/src/split-by-page-count.ts @@ -0,0 +1,72 @@ +import * as path from "node:path"; +import { closeMainWindow, getPreferenceValues, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked, splitByPageCount } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +interface Preferences { + suffix?: string; +} + +export default async function Command(props: { arguments: { pageCount: string } }) { + try { + const pageCount = Number(props.arguments.pageCount); + + if (!Number.isInteger(pageCount) || pageCount <= 0) { + throw new Error("A positive integer is required for page count"); + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + title: "Select PDF file(s) to split by page count", + }); + + if (selectedFiles.length === 0) { + throw new Error("You must select at least one PDF file"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (await isPDFDocumentLocked(filePath)) { + throw new Error(`"${path.basename(filePath)}" is password-protected`); + } + } + + await closeMainWindow(); + + let suffix = "part"; + try { + const preferences = getPreferenceValues(); + if (preferences.suffix && preferences.suffix.trim()) { + suffix = preferences.suffix.trim(); + } + } catch { + // default "part" + } + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Splitting ${selectedFiles.length} file(s)...`, + }); + + let totalParts = 0; + for (const filePath of selectedFiles) { + toast.message = path.basename(filePath); + const parts = await splitByPageCount(filePath, pageCount, suffix); + totalParts += parts.length; + } + + toast.style = Toast.Style.Success; + toast.title = `PDF file${selectedFiles.length > 1 ? "s" : ""} split successfully`; + toast.message = `Generated ${totalParts} part(s)`; + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Split failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/unlock.ts b/extensions/pdf-tools/src/unlock.ts new file mode 100644 index 00000000..989b3579 --- /dev/null +++ b/extensions/pdf-tools/src/unlock.ts @@ -0,0 +1,56 @@ +import * as path from "node:path"; +import { closeMainWindow, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked, unlockPDF } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; + +export default async function Command(props: { arguments: { password: string } }) { + try { + const { password } = props.arguments; + + if (!password) { + throw new Error("Password cannot be empty"); + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + title: "Select password-protected PDF file(s) to unlock", + }); + + if (selectedFiles.length === 0) { + throw new Error("No files have been selected"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (!(await isPDFDocumentLocked(filePath))) { + throw new Error(`"${path.basename(filePath)}" is not password-protected`); + } + } + + await closeMainWindow(); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Unlocking ${selectedFiles.length} file(s)...`, + }); + + for (const filePath of selectedFiles) { + toast.message = path.basename(filePath); + await unlockPDF(filePath, password); + } + + toast.style = Toast.Style.Success; + toast.title = `PDF file${selectedFiles.length > 1 ? "s" : ""} unlocked successfully`; + toast.message = ""; + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Unlock failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/src/watermark.ts b/extensions/pdf-tools/src/watermark.ts new file mode 100644 index 00000000..1ca384a8 --- /dev/null +++ b/extensions/pdf-tools/src/watermark.ts @@ -0,0 +1,79 @@ +import * as path from "node:path"; +import { closeMainWindow, getPreferenceValues, showToast, Toast } from "@vicinae/api"; +import { isPDFDocumentLocked } from "./lib/qpdf"; +import { getSelectedOrPickedFiles } from "./lib/selection"; +import { watermarkPDF } from "./lib/watermark"; + +interface Preferences { + transparency?: string; + rotation?: string; +} + +export default async function Command(props: { + arguments: { + text: string; + fontSize?: string; + }; +}) { + try { + const { text, fontSize } = props.arguments; + + if (!text || !text.trim()) { + throw new Error("Watermark text cannot be empty"); + } + + let transparency = 0.25; + let rotation = 45; + try { + const preferences = getPreferenceValues(); + if (preferences.transparency) transparency = parseFloat(preferences.transparency); + if (preferences.rotation) rotation = parseInt(preferences.rotation, 10); + } catch { + // defaults + } + + const selectedFiles = await getSelectedOrPickedFiles({ + minFiles: 1, + multiple: true, + title: "Select PDF file(s) to watermark", + }); + + if (selectedFiles.length === 0) { + throw new Error("No files have been selected"); + } + + for (const filePath of selectedFiles) { + if (path.extname(filePath).toLowerCase() !== ".pdf") { + throw new Error("Only PDF files should be selected"); + } + + if (await isPDFDocumentLocked(filePath)) { + throw new Error(`"${path.basename(filePath)}" is password-protected`); + } + } + + await closeMainWindow(); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Watermarking ${selectedFiles.length} file(s)...`, + }); + + const parsedFontSize = fontSize ? parseInt(fontSize, 10) : 72; + + for (const filePath of selectedFiles) { + toast.message = path.basename(filePath); + await watermarkPDF(filePath, text.trim(), transparency, rotation, parsedFontSize); + } + + toast.style = Toast.Style.Success; + toast.title = `PDF file${selectedFiles.length > 1 ? "s" : ""} watermarked successfully`; + toast.message = ""; + } catch (error: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Watermark failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/extensions/pdf-tools/tsconfig.json b/extensions/pdf-tools/tsconfig.json new file mode 100644 index 00000000..f66bb6ef --- /dev/null +++ b/extensions/pdf-tools/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} From 0233489f08b34b60764e7a493e35ad5fbc5a077e Mon Sep 17 00:00:00 2001 From: manuelcontrera Date: Wed, 23 Sep 2026 10:30:37 -0500 Subject: [PATCH 2/3] fix(pdf-tools): address automated review feedback on security, collisions, and dialogs --- extensions/pdf-tools/src/lib/qpdf.ts | 135 ++++++++++++++-------- extensions/pdf-tools/src/lib/selection.ts | 70 +++++++++-- extensions/pdf-tools/src/lib/watermark.ts | 11 +- 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/extensions/pdf-tools/src/lib/qpdf.ts b/extensions/pdf-tools/src/lib/qpdf.ts index 02c2e6be..4078cead 100644 --- a/extensions/pdf-tools/src/lib/qpdf.ts +++ b/extensions/pdf-tools/src/lib/qpdf.ts @@ -1,19 +1,27 @@ -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { promisify } from "node:util"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); /** - * Escapes shell argument safely + * Sanitizes errors to ensure sensitive data (such as passwords) and raw command strings + * are never leaked to user toasts, logs, or error messages. */ -function escapeArg(arg: string): string { - return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`; -} +function sanitizeError(err: any, sensitive: string[] = []): string { + let msg = ""; + if (err.stderr && typeof err.stderr === "string" && err.stderr.trim()) { + msg = err.stderr.trim(); + } else if (err.message && typeof err.message === "string") { + // Strip "Command failed: qpdf ..." lines + const lines = err.message.split(/\r?\n/); + const filtered = lines.filter((line: string) => !line.startsWith("Command failed:")); + msg = filtered.join("\n").trim() || "An error occurred during operation"; + } else { + msg = String(err); + } -export function formatQpdfError(err: any): string { - const msg = err.message || String(err); if ( msg.includes("not found") || msg.includes("is not recognized") || @@ -22,9 +30,31 @@ export function formatQpdfError(err: any): string { ) { return "qpdf is not installed or not found in PATH. Please install qpdf ('sudo apt install qpdf' on Linux, 'brew install qpdf' on macOS, or 'winget install qpdf' on Windows)."; } + + // Redact any sensitive passwords or arguments + for (const secret of sensitive) { + if (secret && secret.length > 0) { + msg = msg.split(secret).join("********"); + } + } + return msg; } +/** + * Executes qpdf with arguments safely without shell interpolation. + */ +async function runQpdf( + args: string[], + sensitive: string[] = [] +): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync("qpdf", args); + } catch (err: any) { + throw new Error(sanitizeError(err, sensitive)); + } +} + /** * Check if PDF document is locked / password protected. * Exit code 0 from qpdf --is-encrypted indicates file is encrypted. @@ -32,13 +62,13 @@ export function formatQpdfError(err: any): string { */ export async function isPDFDocumentLocked(filePath: string): Promise { try { - await execAsync(`qpdf --is-encrypted ${escapeArg(filePath)}`); + await execFileAsync("qpdf", ["--is-encrypted", filePath]); return true; // Exit code 0: encrypted } catch (err: any) { if (err.code === 2) { return false; // Exit code 2: not encrypted } - throw new Error(`Failed to inspect PDF: ${formatQpdfError(err)}`); + throw new Error(`Failed to inspect PDF: ${sanitizeError(err)}`); } } @@ -50,9 +80,11 @@ export async function isPDFDocumentLocked(filePath: string): Promise { */ export async function verifyPassword(filePath: string, password: string): Promise { try { - await execAsync( - `qpdf --requires-password --password=${escapeArg(password)} ${escapeArg(filePath)}` - ); + await execFileAsync("qpdf", [ + "--requires-password", + `--password=${password}`, + filePath, + ]); return false; } catch (err: any) { if (err.code === 3 || err.code === 2) { @@ -66,7 +98,7 @@ export async function verifyPassword(filePath: string, password: string): Promis * Get total number of pages in a PDF document. */ export async function getPDFPageCount(filePath: string): Promise { - const { stdout } = await execAsync(`qpdf --show-npages ${escapeArg(filePath)}`); + const { stdout } = await runQpdf(["--show-npages", filePath]); const count = parseInt(stdout.trim(), 10); if (isNaN(count) || count <= 0) { throw new Error(`Could not determine page count for "${path.basename(filePath)}"`); @@ -75,7 +107,24 @@ export async function getPDFPageCount(filePath: string): Promise { } /** - * Merge multiple PDF files into one. + * Generates a collision-free output file path if a file already exists. + */ +export function getUniqueFilePath(outputDir: string, safeFilename: string): string { + const targetPath = path.join(outputDir, safeFilename); + if (!fs.existsSync(targetPath)) { + return targetPath; + } + const ext = path.extname(safeFilename); + const base = path.basename(safeFilename, ext); + let counter = 1; + while (fs.existsSync(path.join(outputDir, `${base} (${counter})${ext}`))) { + counter++; + } + return path.join(outputDir, `${base} (${counter})${ext}`); +} + +/** + * Merge multiple PDF files into one without overwriting existing files. */ export async function mergePDFs(filePaths: string[], outputFilename: string): Promise { if (filePaths.length < 2) { @@ -86,16 +135,13 @@ export async function mergePDFs(filePaths: string[], outputFilename: string): Pr const safeFilename = outputFilename.toLowerCase().endsWith(".pdf") ? outputFilename : `${outputFilename}.pdf`; - const outputPath = path.join(outputDir, safeFilename); - - const escapedInputs = filePaths.map((p) => escapeArg(p)).join(" "); - const cmd = `qpdf --empty --pages ${escapedInputs} -- ${escapeArg(outputPath)}`; + const outputPath = getUniqueFilePath(outputDir, safeFilename); try { - await execAsync(cmd); + await runQpdf(["--empty", "--pages", ...filePaths, "--", outputPath]); return outputPath; } catch (err: any) { - throw new Error(`Failed to merge PDF files: ${formatQpdfError(err)}`); + throw new Error(`Failed to merge PDF files: ${sanitizeError(err)}`); } } @@ -103,11 +149,15 @@ export async function mergePDFs(filePaths: string[], outputFilename: string): Pr * Protect a PDF file with AES-256 password encryption in place. */ export async function protectPDF(filePath: string, password: string): Promise { - const cmd = `qpdf --encrypt ${escapeArg(password)} ${escapeArg(password)} 256 -- ${escapeArg(filePath)} --replace-input`; try { - await execAsync(cmd); + await runQpdf( + ["--encrypt", password, password, "256", "--", filePath, "--replace-input"], + [password] + ); } catch (err: any) { - throw new Error(`Failed to protect PDF "${path.basename(filePath)}": ${formatQpdfError(err)}`); + throw new Error( + `Failed to protect PDF "${path.basename(filePath)}": ${sanitizeError(err, [password])}` + ); } } @@ -120,11 +170,15 @@ export async function unlockPDF(filePath: string, password: string): Promise= maxSizeBytes || start === totalPages) { - // Single page exceeds or is last page const outName = `${baseName} [${suffix} ${partNumber}].pdf`; - const outPath = path.join(dir, outName); + const outPath = getUniqueFilePath(dir, outName); fs.renameSync(tempPath, outPath); outputFiles.push(outPath); start++; @@ -205,25 +255,20 @@ export async function splitByFileSize( // Binary search for maximum number of pages fitting within maxSizeBytes while (low <= high) { const mid = Math.floor((low + high) / 2); - await execAsync( - `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${mid} -- ${escapeArg(tempPath)}` - ); + await runQpdf([filePath, "--pages", filePath, `${start}-${mid}`, "--", tempPath]); const currentSize = fs.statSync(tempPath).size; if (currentSize <= maxSizeBytes) { bestStop = mid; - low = mid + 1; // Try more pages + low = mid + 1; } else { - high = mid - 1; // Too big, reduce pages + high = mid - 1; } } - // Generate the final part with bestStop const outName = `${baseName} [${suffix} ${partNumber}].pdf`; - const outPath = path.join(dir, outName); - await execAsync( - `qpdf ${escapeArg(filePath)} --pages ${escapeArg(filePath)} ${start}-${bestStop} -- ${escapeArg(outPath)}` - ); + const outPath = getUniqueFilePath(dir, outName); + await runQpdf([filePath, "--pages", filePath, `${start}-${bestStop}`, "--", outPath]); outputFiles.push(outPath); start = bestStop + 1; diff --git a/extensions/pdf-tools/src/lib/selection.ts b/extensions/pdf-tools/src/lib/selection.ts index 434282d1..d06cb616 100644 --- a/extensions/pdf-tools/src/lib/selection.ts +++ b/extensions/pdf-tools/src/lib/selection.ts @@ -133,6 +133,7 @@ async function pickFilesWithDialog(options: SelectionOptions): Promise filterName = "Supported files", } = options; + // Linux: Try zenity first, then fallback to kdialog for KDE environments if (process.platform === "linux") { try { const multFlag = multiple ? '--multiple --separator="|"' : ""; @@ -150,38 +151,89 @@ async function pickFilesWithDialog(options: SelectionOptions): Promise .map((p) => cleanFilePath(p, allowedExtensions)) .filter((p): p is string => p !== null); - if (valid.length === 0) { - throw new Error("No valid files selected"); + if (valid.length > 0) { + return valid; } - return valid; } catch (err: any) { if (err.message && err.message.includes("cancelled")) { throw new Error("File selection cancelled"); } - throw new Error(`File selection failed: ${err.message || String(err)}`); + // Continue to kdialog fallback if zenity failed or was not found } + + try { + const multFlag = multiple ? "--multiple --separate-output" : ""; + const patterns = allowedExtensions + .map((ext) => `*${ext} *${ext.toUpperCase()}`) + .join(" "); + const filter = `${patterns}|${filterName}`; + const cmd = `kdialog --title "${title.replace(/"/g, '\\"')}" --getopenfilename . "${filter}" ${multFlag}`; + const { stdout } = await execAsync(cmd); + if (!stdout || !stdout.trim()) { + throw new Error("File selection cancelled"); + } + const rawPaths = stdout.trim().split(/\r?\n/); + const valid = rawPaths + .map((p) => cleanFilePath(p, allowedExtensions)) + .filter((p): p is string => p !== null); + + if (valid.length > 0) { + return valid; + } + } catch (err: any) { + if (err.message && err.message.includes("cancelled")) { + throw new Error("File selection cancelled"); + } + } + + throw new Error( + "File selection failed: Neither zenity nor kdialog is installed, or no valid files were selected." + ); } + // macOS: Use AppleScript System Events to choose files and convert aliases to POSIX paths if (process.platform === "darwin") { try { const prompt = title.replace(/"/g, '\\"'); const mult = multiple ? "with multiple selections allowed" : ""; const typeList = allowedExtensions.map((e) => `"${e.replace(/^\./, "")}"`).join(","); - const script = `choose file with prompt "${prompt}" of type {${typeList}} ${mult}`; - const { stdout } = await execAsync(`osascript -e '${script}'`); + const script = `tell application "System Events" +activate +set theFiles to choose file with prompt "${prompt}" of type {${typeList}} ${mult} +set posixList to {} +if class of theFiles is list then +repeat with aFile in theFiles +set end of posixList to POSIX path of aFile +end repeat +else +set end of posixList to POSIX path of theFiles +end if +set AppleScript's text item delimiters to ASCII character 10 +return posixList as text +end tell`; + + const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`); if (!stdout.trim()) { throw new Error("File selection cancelled"); } const paths = stdout - .split(",") + .split(/\r?\n/) .map((p) => cleanFilePath(p.trim(), allowedExtensions)) .filter((p): p is string => p !== null); + + if (paths.length === 0) { + throw new Error("No valid files selected"); + } return paths; - } catch { - throw new Error("File selection cancelled"); + } catch (err: any) { + if (err.message && err.message.includes("cancelled")) { + throw new Error("File selection cancelled"); + } + throw new Error(`File selection failed: ${err.message || String(err)}`); } } + // Windows: Use PowerShell OpenFileDialog if (process.platform === "win32") { try { const multBool = multiple ? "$true" : "$false"; diff --git a/extensions/pdf-tools/src/lib/watermark.ts b/extensions/pdf-tools/src/lib/watermark.ts index 9e17c7fa..b5f4e581 100644 --- a/extensions/pdf-tools/src/lib/watermark.ts +++ b/extensions/pdf-tools/src/lib/watermark.ts @@ -51,7 +51,16 @@ export async function watermarkPDF( const dir = path.dirname(filePath); const ext = path.extname(filePath); const baseName = path.basename(filePath, ext); - const outputPath = path.join(dir, `${baseName} [watermarked].pdf`); + + // Generate a collision-free output file path to avoid silent overwrites + let outputPath = path.join(dir, `${baseName} [watermarked]${ext}`); + if (fs.existsSync(outputPath)) { + let counter = 1; + while (fs.existsSync(path.join(dir, `${baseName} [watermarked] (${counter})${ext}`))) { + counter++; + } + outputPath = path.join(dir, `${baseName} [watermarked] (${counter})${ext}`); + } fs.writeFileSync(outputPath, pdfBytes); return outputPath; From 2d9f0019e49c1973264cc4fae3d1948f22d2aa18 Mon Sep 17 00:00:00 2001 From: manuelcontrera Date: Wed, 23 Sep 2026 10:41:07 -0500 Subject: [PATCH 3/3] fix(pdf-tools): handle Linux dialog exit codes and prevent secondary dialog on cancel --- extensions/pdf-tools/src/lib/selection.ts | 96 +++++++++++++++-------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/extensions/pdf-tools/src/lib/selection.ts b/extensions/pdf-tools/src/lib/selection.ts index d06cb616..c994e29f 100644 --- a/extensions/pdf-tools/src/lib/selection.ts +++ b/extensions/pdf-tools/src/lib/selection.ts @@ -1,10 +1,11 @@ -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { promisify } from "node:util"; import { Clipboard } from "@vicinae/api"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export interface SelectionOptions { minFiles?: number; @@ -133,16 +134,25 @@ async function pickFilesWithDialog(options: SelectionOptions): Promise filterName = "Supported files", } = options; - // Linux: Try zenity first, then fallback to kdialog for KDE environments + // Linux: Try zenity first, then fallback to kdialog only if zenity is unavailable if (process.platform === "linux") { + let zenityUnavailable = false; + try { - const multFlag = multiple ? '--multiple --separator="|"' : ""; const patterns = allowedExtensions .map((ext) => `*${ext} *${ext.toUpperCase()}`) .join(" "); const filter = `${filterName} | ${patterns}`; - const cmd = `zenity --file-selection ${multFlag} --file-filter="${filter}" --title="${title.replace(/"/g, '\\"')}"`; - const { stdout } = await execAsync(cmd); + const args = [ + "--file-selection", + `--file-filter=${filter}`, + `--title=${title}`, + ]; + if (multiple) { + args.push("--multiple", "--separator=|"); + } + + const { stdout } = await execFileAsync("zenity", args); if (!stdout || !stdout.trim()) { throw new Error("File selection cancelled"); } @@ -154,41 +164,63 @@ async function pickFilesWithDialog(options: SelectionOptions): Promise if (valid.length > 0) { return valid; } + throw new Error("No valid files selected"); } catch (err: any) { - if (err.message && err.message.includes("cancelled")) { + // Exit code 1 from zenity indicates explicit user cancellation (Cancel button or Esc) + if (err.code === 1 || (err.message && err.message.includes("cancelled"))) { throw new Error("File selection cancelled"); } - // Continue to kdialog fallback if zenity failed or was not found + // If zenity was not found (ENOENT or code 127), mark unavailable and try kdialog + if (err.code === "ENOENT" || err.code === 127 || (err.message && err.message.includes("not found"))) { + zenityUnavailable = true; + } else { + throw new Error(`File selection failed: ${err.message || String(err)}`); + } } - try { - const multFlag = multiple ? "--multiple --separate-output" : ""; - const patterns = allowedExtensions - .map((ext) => `*${ext} *${ext.toUpperCase()}`) - .join(" "); - const filter = `${patterns}|${filterName}`; - const cmd = `kdialog --title "${title.replace(/"/g, '\\"')}" --getopenfilename . "${filter}" ${multFlag}`; - const { stdout } = await execAsync(cmd); - if (!stdout || !stdout.trim()) { - throw new Error("File selection cancelled"); - } - const rawPaths = stdout.trim().split(/\r?\n/); - const valid = rawPaths - .map((p) => cleanFilePath(p, allowedExtensions)) - .filter((p): p is string => p !== null); + if (zenityUnavailable) { + try { + const patterns = allowedExtensions + .map((ext) => `*${ext} *${ext.toUpperCase()}`) + .join(" "); + const filter = `${patterns}|${filterName}`; + const args = [ + "--title", + title, + "--getopenfilename", + ".", + filter, + ]; + if (multiple) { + args.push("--multiple", "--separate-output"); + } - if (valid.length > 0) { - return valid; - } - } catch (err: any) { - if (err.message && err.message.includes("cancelled")) { - throw new Error("File selection cancelled"); + const { stdout } = await execFileAsync("kdialog", args); + if (!stdout || !stdout.trim()) { + throw new Error("File selection cancelled"); + } + const rawPaths = stdout.trim().split(/\r?\n/); + const valid = rawPaths + .map((p) => cleanFilePath(p, allowedExtensions)) + .filter((p): p is string => p !== null); + + if (valid.length > 0) { + return valid; + } + throw new Error("No valid files selected"); + } catch (err: any) { + // Exit code 1 from kdialog indicates explicit user cancellation + if (err.code === 1 || (err.message && err.message.includes("cancelled"))) { + throw new Error("File selection cancelled"); + } + if (err.code === "ENOENT" || err.code === 127) { + throw new Error( + "File selection failed: Neither zenity nor kdialog is installed on this system." + ); + } + throw new Error(`File selection failed: ${err.message || String(err)}`); } } - - throw new Error( - "File selection failed: Neither zenity nor kdialog is installed, or no valid files were selected." - ); } // macOS: Use AppleScript System Events to choose files and convert aliases to POSIX paths