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 00000000..c7018f59 Binary files /dev/null and b/extensions/pdf-tools/assets/pdftools-icon.png differ 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..4078cead --- /dev/null +++ b/extensions/pdf-tools/src/lib/qpdf.ts @@ -0,0 +1,288 @@ +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); + +/** + * Sanitizes errors to ensure sensitive data (such as passwords) and raw command strings + * are never leaked to user toasts, logs, or error messages. + */ +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); + } + + 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)."; + } + + // 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. + * Exit code 2 indicates file is NOT encrypted. + */ +export async function isPDFDocumentLocked(filePath: string): Promise { + try { + 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: ${sanitizeError(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 execFileAsync("qpdf", [ + "--requires-password", + `--password=${password}`, + 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 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)}"`); + } + return count; +} + +/** + * 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) { + 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 = getUniqueFilePath(outputDir, safeFilename); + + try { + await runQpdf(["--empty", "--pages", ...filePaths, "--", outputPath]); + return outputPath; + } catch (err: any) { + throw new Error(`Failed to merge PDF files: ${sanitizeError(err)}`); + } +} + +/** + * Protect a PDF file with AES-256 password encryption in place. + */ +export async function protectPDF(filePath: string, password: string): Promise { + try { + await runQpdf( + ["--encrypt", password, password, "256", "--", filePath, "--replace-input"], + [password] + ); + } catch (err: any) { + throw new Error( + `Failed to protect PDF "${path.basename(filePath)}": ${sanitizeError(err, [password])}` + ); + } +} + +/** + * 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)}"`); + } + + try { + await runQpdf( + ["--decrypt", `--password=${password}`, filePath, "--replace-input"], + [password] + ); + } catch (err: any) { + throw new Error( + `Failed to unlock PDF "${path.basename(filePath)}": ${sanitizeError(err, [password])}` + ); + } +} + +/** + * 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 = getUniqueFilePath(dir, outName); + + await runQpdf([filePath, "--pages", filePath, `${start}-${stop}`, "--", outPath]); + + 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 runQpdf([filePath, "--pages", filePath, `${start}-${start}`, "--", tempPath]); + const singlePageSize = fs.statSync(tempPath).size; + + if (singlePageSize >= maxSizeBytes || start === totalPages) { + const outName = `${baseName} [${suffix} ${partNumber}].pdf`; + const outPath = getUniqueFilePath(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 runQpdf([filePath, "--pages", filePath, `${start}-${mid}`, "--", tempPath]); + const currentSize = fs.statSync(tempPath).size; + + if (currentSize <= maxSizeBytes) { + bestStop = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + const outName = `${baseName} [${suffix} ${partNumber}].pdf`; + const outPath = getUniqueFilePath(dir, outName); + await runQpdf([filePath, "--pages", filePath, `${start}-${bestStop}`, "--", 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..c994e29f --- /dev/null +++ b/extensions/pdf-tools/src/lib/selection.ts @@ -0,0 +1,327 @@ +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; + 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; + + // Linux: Try zenity first, then fallback to kdialog only if zenity is unavailable + if (process.platform === "linux") { + let zenityUnavailable = false; + + try { + const patterns = allowedExtensions + .map((ext) => `*${ext} *${ext.toUpperCase()}`) + .join(" "); + const filter = `${filterName} | ${patterns}`; + 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"); + } + 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) { + return valid; + } + throw new Error("No valid files selected"); + } catch (err: any) { + // 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"); + } + // 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)}`); + } + } + + 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"); + } + + 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)}`); + } + } + } + + // 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 = `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(/\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 (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"; + 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..b5f4e581 --- /dev/null +++ b/extensions/pdf-tools/src/lib/watermark.ts @@ -0,0 +1,67 @@ +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); + + // 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; +} 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/**/*"] +}