diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32a064..dd47b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,5 @@ jobs: - name: Typecheck run: bunx tsc --noEmit - - name: Python syntax check - run: python3 -m py_compile backend/pdfzen_backend.py + - name: Test + run: bun test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ce83607 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Build & Release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install cross-platform native packages + run: bun add --no-save @opentui/core-darwin-arm64@0.1.74 @opentui/core-darwin-x64@0.1.74 @opentui/core-linux-arm64@0.1.74 @opentui/core-win32-x64@0.1.74 + + - name: Build all targets + run: bun run build:release + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: release/artifacts/pdfzen-* diff --git a/.gitignore b/.gitignore index ded540e..27078fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ node_modules # output out dist -/release/* +/release/artifacts +/release/.build *.tgz # code coverage @@ -24,7 +25,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .env.local # caches -.eslintcache .cache *.tsbuildinfo @@ -34,10 +34,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Finder (MacOS) folder config .DS_Store -# Python virtual environment -backend/.venv -__pycache__ -*.pyc - # Agent skills .agents diff --git a/README.md b/README.md index 01ec052..a2e03f5 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,21 @@ ## Installation +### Quick install (macOS / Linux) + +```bash +curl -fsSL https://raw.githubusercontent.com/viralcodex/pdfzen/main/install.sh | bash +``` + +### Windows + +Download `pdfzen-windows-x64.exe` from the [latest release](https://github.com/viralcodex/pdfzen/releases/latest) and add it to your PATH. + +### From source + Prerequisites: - Bun (latest recommended) -- Python 3.10+ - -Install project dependencies: ```bash bun install @@ -19,7 +28,7 @@ bun install ## Setup -First-time setup (creates Python virtual environment and installs backend dependencies): +First-time setup (installs dependencies): ```bash bun run setup @@ -27,21 +36,21 @@ bun run setup ## Usage -Run the app (cross-platform): +Run the app: ```bash -bun run dev:all +bun run dev ``` Other commands: ```bash -bun run setup # Setup backend + frontend dependencies only -bun run dev:ui # Run UI only -bun run dev:backend # Run backend CLI +bun run setup # Install dependencies +bun run build # Build for distribution +bun run build:release # Build standalone binaries for all platforms ``` -Install a global `pdfzen` command (macOS/Linux/Windows): +Install a global `pdfzen` command (macOS/Linux): ```bash bun run install:global diff --git a/backend/pdfzen_backend.py b/backend/pdfzen_backend.py deleted file mode 100644 index 8399dab..0000000 --- a/backend/pdfzen_backend.py +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env python3 -""" -PDFZen Backend - Handles PDF operations for the TUI frontend. -All external dependencies are bundled via pip packages. -""" - -import argparse -import json -import sys -import os -import tempfile -from pathlib import Path -from typing import Optional - -# Lazy imports to speed up CLI startup -def get_fitz(): - import fitz # PyMuPDF - return fitz - -def get_pikepdf(): - import pikepdf - return pikepdf - -def get_pil(): - from PIL import Image - return Image - -def parse_bool(value: str) -> bool: - """Parse CLI boolean values like true/false, 1/0, yes/no.""" - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "on"}: - return True - if normalized in {"0", "false", "no", "off"}: - return False - raise argparse.ArgumentTypeError(f"Invalid boolean value: {value}") - - -def output_json(success: bool, data: Optional[dict] = None, error: Optional[str] = None): - """Output JSON result to stdout""" - result = {"success": success} - if data: - result.update(data) - if error: - result["error"] = error - print(json.dumps(result)) - sys.exit(0 if success else 1) - - -def cmd_pdf_to_images(args): - """Convert PDF pages to images""" - try: - fitz = get_fitz() - - doc = fitz.open(args.input) - os.makedirs(args.output_dir, exist_ok=True) - - base_name = Path(args.input).stem - output_files = [] - - # Determine which pages to convert - pages = range(len(doc)) - if args.pages: - pages = [int(p) - 1 for p in args.pages.split(",") if p.strip()] - pages = [p for p in pages if 0 <= p < len(doc)] - - # DPI to zoom factor (72 DPI is base) - zoom = args.dpi / 72 - matrix = fitz.Matrix(zoom, zoom) - - for page_num in pages: - page = doc[page_num] - pix = page.get_pixmap(matrix=matrix) - - ext = args.format.lower() - output_path = os.path.join(args.output_dir, f"{base_name}_page_{page_num + 1}.{ext}") - - if ext == "jpg" or ext == "jpeg": - pix.save(output_path, "jpeg") - else: - pix.save(output_path, "png") - - output_files.append(output_path) - - doc.close() - output_json(True, {"outputFiles": output_files, "totalImages": len(output_files)}) - - except Exception as e: - output_json(False, error=str(e)) - - -def cmd_images_to_pdf(args): - """Convert images to a single PDF""" - temp_files = [] # Track temp files for cleanup - try: - fitz = get_fitz() - Image = None - - doc = fitz.open() - image_paths = args.inputs.split("|") - - for img_path in image_paths: - if not os.path.exists(img_path): - continue - - # Fast metadata path via PyMuPDF (avoids full-byte reads and PIL import). - img_width = None - img_height = None - try: - img_doc = fitz.open(img_path) - if len(img_doc) > 0: - rect = img_doc[0].rect - img_width = rect.width - img_height = rect.height - img_doc.close() - except Exception: - pass - - # Fallback metadata path via PIL for unsupported formats. - if not img_width or not img_height: - if Image is None: - Image = get_pil() - with Image.open(img_path) as img: - img_width, img_height = img.size - - if args.page_size == "a4": - page = doc.new_page(width=595.28, height=841.89) - elif args.page_size == "letter": - page = doc.new_page(width=612, height=792) - else: # fit - page = doc.new_page(width=img_width, height=img_height) - - # Scale image to fit page if needed - if args.page_size != "fit": - scale = min(page.rect.width / img_width, page.rect.height / img_height) - new_width = img_width * scale - new_height = img_height * scale - x = (page.rect.width - new_width) / 2 - y = (page.rect.height - new_height) / 2 - target_rect = fitz.Rect(x, y, x + new_width, y + new_height) - else: - target_rect = page.rect - - # Fast path: insert the source file directly (no temp file, no re-encode). - try: - page.insert_image(target_rect, filename=img_path) - continue - except Exception: - pass - - # Fallback path: only convert to temp JPEG when direct insert fails. - if Image is None: - Image = get_pil() - with Image.open(img_path) as img: - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - - fd, temp_path = tempfile.mkstemp(suffix=".jpg") - os.close(fd) # Close file descriptor; PIL will reopen path - temp_files.append(temp_path) - img.save(temp_path, "JPEG", quality=95) - - page.insert_image(target_rect, filename=temp_path) - - doc.save(args.output) - total_pages = len(doc) - doc.close() - - output_json(True, {"outputPath": args.output, "totalPages": total_pages}) - - except Exception as e: - output_json(False, error=str(e)) - finally: - # Cleanup temp files - for temp_path in temp_files: - try: - if os.path.exists(temp_path): - os.remove(temp_path) - except: - pass - - -def cmd_protect_pdf(args): - """Add password protection to a PDF""" - try: - pikepdf = get_pikepdf() - - # Read passwords from stdin if --stdin-secrets is set (security: avoids exposing in process list) - user_password = args.user_password - owner_password = args.owner_password - - if args.stdin_secrets: - secrets = json.loads(sys.stdin.read()) - user_password = secrets.get("user_password", "") - owner_password = secrets.get("owner_password", "") - - pdf = pikepdf.open(args.input) - - # Build encryption settings - encryption = pikepdf.Encryption( - owner=owner_password or user_password or "", - user=user_password or "", - allow=pikepdf.Permissions( - print_lowres=args.allow_print, - print_highres=args.allow_print, - modify_other=args.allow_modify, - modify_annotation=args.allow_annotate, - modify_form=args.allow_modify, - modify_assembly=args.allow_modify, - extract=args.allow_copy, - accessibility=True, - ) - ) - - pdf.save(args.output, encryption=encryption) - pdf.close() - - output_json(True, {"outputPath": args.output}) - - except Exception as e: - output_json(False, error=str(e)) - - -def cmd_unprotect_pdf(args): - """Remove password protection from a PDF""" - try: - pikepdf = get_pikepdf() - - # Read password from stdin if --stdin-secrets is set (security: avoids exposing in process list) - password = args.password - if args.stdin_secrets: - secrets = json.loads(sys.stdin.read()) - password = secrets.get("password", "") - - pdf = pikepdf.open(args.input, password=password) - pdf.save(args.output) - pdf.close() - - output_json(True, {"outputPath": args.output}) - - except Exception as e: - output_json(False, error=str(e)) - - -def cmd_compress_pdf(args): - """Compress a PDF file""" - try: - fitz = get_fitz() - Image = get_pil() - import io - - original_size = os.path.getsize(args.input) - - doc = fitz.open(args.input) - - # Compress images in the PDF - for page_num in range(len(doc)): - page = doc[page_num] - image_list = page.get_images(full=True) - - for img_info in image_list: - xref = img_info[0] - try: - base_image = doc.extract_image(xref) - if base_image and base_image.get("image"): - img_bytes = base_image["image"] - img_ext = base_image.get("ext", "png") - - # Skip if already JPEG (already compressed) - if img_ext.lower() in ["jpeg", "jpg"]: - continue - - # Re-compress large images as JPEG - if len(img_bytes) > 50000: # Only compress images > 50KB - pil_img = Image.open(io.BytesIO(img_bytes)) - if pil_img.mode in ("RGBA", "P"): - pil_img = pil_img.convert("RGB") - - # Compress to JPEG - buffer = io.BytesIO() - pil_img.save(buffer, format="JPEG", quality=75, optimize=True) - compressed_bytes = buffer.getvalue() - - # Only use if actually smaller - if len(compressed_bytes) < len(img_bytes): - # Replace image in document - doc.update_stream(xref, compressed_bytes) - except Exception: - pass # Skip problematic images - - # Save with optimal compression settings from PyMuPDF docs - doc.save( - args.output, - garbage=4, # Maximum garbage collection (removes unused & dedupes streams) - deflate=True, # Compress uncompressed streams - deflate_images=True, # Compress uncompressed images - deflate_fonts=True, # Compress uncompressed fonts - clean=True, # Clean/sanitize content streams - ) - doc.close() - - compressed_size = os.path.getsize(args.output) - ratio = ((1 - compressed_size / original_size) * 100) if original_size > 0 else 0 - - output_json(True, { - "outputPath": args.output, - "originalSize": original_size, - "compressedSize": compressed_size, - "compressionRatio": f"{ratio:.2f}%" - }) - - except Exception as e: - output_json(False, error=str(e)) - - -def cmd_check_deps(args): - """Check if all dependencies are available""" - deps = {} - - try: - import fitz - deps["pymupdf"] = {"installed": True, "version": fitz.version[0]} - except ImportError: - deps["pymupdf"] = {"installed": False} - - try: - import pikepdf - deps["pikepdf"] = {"installed": True, "version": pikepdf.__version__} - except ImportError: - deps["pikepdf"] = {"installed": False} - - try: - import PIL - deps["pillow"] = {"installed": True, "version": PIL.__version__} - except ImportError: - deps["pillow"] = {"installed": False} - - all_installed = all(d["installed"] for d in deps.values()) - output_json(all_installed, {"dependencies": deps}) - - -def cmd_install_deps(args): - """Install missing dependencies into a virtual environment""" - import subprocess - - try: - backend_dir = Path(__file__).parent - venv_path = backend_dir / ".venv" - - # Create virtual environment if it doesn't exist - if not venv_path.exists(): - result = subprocess.run( - [sys.executable, "-m", "venv", str(venv_path)], - capture_output=True, - text=True - ) - if result.returncode != 0: - output_json(False, error=f"Failed to create venv: {result.stderr}") - return - - # Determine pip path - if sys.platform == "win32": - pip_path = venv_path / "Scripts" / "pip" - else: - pip_path = venv_path / "bin" / "pip" - - # Install requirements - result = subprocess.run( - [str(pip_path), "install", "-q", "-r", str(backend_dir / "requirements.txt")], - capture_output=True, - text=True - ) - - if result.returncode == 0: - output_json(True, {"message": "Dependencies installed successfully", "venv": str(venv_path)}) - else: - output_json(False, error=result.stderr or "Failed to install dependencies") - - except Exception as e: - output_json(False, error=str(e)) - -def cmd_warmup(args): - """Warm backend imports to reduce first-operation latency.""" - try: - fitz = get_fitz() - # Keep PIL lazy unless available; not required for all ops. - try: - Image = get_pil() - pil_version = getattr(Image, "__version__", None) - except Exception: - pil_version = None - - output_json(True, { - "warmed": True, - "pymupdf": fitz.version[0], - "pillow": pil_version, - }) - except Exception as e: - output_json(False, error=str(e)) - - -def build_parser(): - parser = argparse.ArgumentParser(description="PDFZen Backend") - subparsers = parser.add_subparsers(dest="command", help="Command to run") - - # pdf-to-images - p_to_img = subparsers.add_parser("pdf-to-images", help="Convert PDF to images") - p_to_img.add_argument("--input", required=True, help="Input PDF path") - p_to_img.add_argument("--output-dir", required=True, help="Output directory") - p_to_img.add_argument("--format", default="png", choices=["png", "jpg", "jpeg"]) - p_to_img.add_argument("--dpi", type=int, default=150) - p_to_img.add_argument("--pages", help="Comma-separated page numbers (1-indexed)") - p_to_img.set_defaults(func=cmd_pdf_to_images) - - # images-to-pdf - img_to_p = subparsers.add_parser("images-to-pdf", help="Convert images to PDF") - img_to_p.add_argument("--inputs", required=True, help="Pipe-separated image paths") - img_to_p.add_argument("--output", required=True, help="Output PDF path") - img_to_p.add_argument("--page-size", default="fit", choices=["fit", "a4", "letter"]) - img_to_p.set_defaults(func=cmd_images_to_pdf) - - # protect - protect = subparsers.add_parser("protect", help="Add password protection") - protect.add_argument("--input", required=True, help="Input PDF path") - protect.add_argument("--output", required=True, help="Output PDF path") - protect.add_argument("--user-password", help="Password to open PDF") - protect.add_argument("--owner-password", help="Password to modify PDF") - protect.add_argument("--allow-print", type=parse_bool, default=True) - protect.add_argument("--allow-copy", type=parse_bool, default=True) - protect.add_argument("--allow-modify", type=parse_bool, default=True) - protect.add_argument("--allow-annotate", type=parse_bool, default=True) - protect.add_argument("--stdin-secrets", action="store_true", help="Read passwords from stdin as JSON") - protect.set_defaults(func=cmd_protect_pdf) - - # unprotect - unprotect = subparsers.add_parser("unprotect", help="Remove password protection") - unprotect.add_argument("--input", required=True, help="Input PDF path") - unprotect.add_argument("--output", required=True, help="Output PDF path") - unprotect.add_argument("--password", help="PDF password") - unprotect.add_argument("--stdin-secrets", action="store_true", help="Read password from stdin as JSON") - unprotect.set_defaults(func=cmd_unprotect_pdf) - - # compress - compress = subparsers.add_parser("compress", help="Compress PDF") - compress.add_argument("--input", required=True, help="Input PDF path") - compress.add_argument("--output", required=True, help="Output PDF path") - compress.set_defaults(func=cmd_compress_pdf) - - # check-deps - check = subparsers.add_parser("check-deps", help="Check dependencies") - check.set_defaults(func=cmd_check_deps) - - # install-deps - install = subparsers.add_parser("install-deps", help="Install dependencies") - install.set_defaults(func=cmd_install_deps) - - # warmup - warmup = subparsers.add_parser("warmup", help="Warm backend imports") - warmup.set_defaults(func=cmd_warmup) - - return parser - - -def main(): - parser = build_parser() - args = parser.parse_args() - - if not args.command: - parser.print_help() - sys.exit(1) - - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 0ff9a9a..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# PDFZen Backend Dependencies -# PyMuPDF - handles PDF to images, compression, and more (no external deps!) -pymupdf>=1.24.0 -# pikepdf - for PDF encryption/protection (pure Python, uses qpdf internally but bundles it) -pikepdf>=8.0.0 -# Pillow - image processing -Pillow>=10.0.0 \ No newline at end of file diff --git a/bun.lock b/bun.lock index 6363264..5bfa9c4 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@opentui/core": "^0.1.74", "@opentui/solid": "^0.1.74", "babel-preset-solid": "1.9.9", + "mupdf": "^1.27.0", "oxfmt": "^0.28.0", "oxlint": "^1.43.0", "pdf-lib": "^1.17.1", @@ -321,6 +322,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mupdf": ["mupdf@1.27.0", "", {}, "sha512-vEPUYwZeu5NgiFLz4e20R7Vp2pNY7szirGEvTxHyQQpQs6ab4DeGdonwT6sH1JZG5EhyHSrojZrZn2/0ee6qZQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], diff --git a/dev.sh b/dev.sh deleted file mode 100755 index c53972d..0000000 --- a/dev.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -exec bun run --cwd '/Users/i527263/Desktop/projects/pdfzen' scripts/dev.ts "$@" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..55c7610 --- /dev/null +++ b/install.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +# PDFZen installer — download the latest release binary and put it in PATH. +# Usage: +# curl -fsSL https://raw.githubusercontent.com/viralcodex/pdfzen/main/install.sh | bash + +REPO="viralcodex/pdfzen" +INSTALL_DIR="${PDFZEN_INSTALL_DIR:-$HOME/.local/bin}" +BINARY_NAME="pdfzen" + +# ── helpers ────────────────────────────────────────────────────────────────── + +info() { printf '\033[34m%s\033[0m\n' "$*"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[33m!\033[0m %s\n' "$*"; } +die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" +} + +# ── detect platform ────────────────────────────────────────────────────────── + +detect_platform() { + local os arch + + case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) die "Unsupported OS: $(uname -s). Only macOS and Linux are supported." ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) die "Unsupported architecture: $(uname -m)" ;; + esac + + echo "${os}-${arch}" +} + +# ── find latest release tag ────────────────────────────────────────────────── + +latest_tag() { + local url="https://api.github.com/repos/${REPO}/releases/latest" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//' + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$url" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//' + else + die "Neither curl nor wget found" + fi +} + +# ── download ───────────────────────────────────────────────────────────────── + +download() { + local url="$1" dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$dest" "$url" + else + wget -qO "$dest" "$url" + fi +} + +# ── main ───────────────────────────────────────────────────────────────────── + +main() { + local platform tag download_url tmp_file + + info "Installing PDFZen..." + + platform="$(detect_platform)" + ok "Detected platform: ${platform}" + + info "Fetching latest release..." + tag="$(latest_tag)" + [ -n "$tag" ] || die "Could not determine latest release. Check https://github.com/${REPO}/releases" + ok "Latest release: ${tag}" + + download_url="https://github.com/${REPO}/releases/download/${tag}/pdfzen-${platform}" + info "Downloading pdfzen-${platform}..." + + tmp_file="$(mktemp)" + trap 'rm -f "$tmp_file"' EXIT + + download "$download_url" "$tmp_file" || die "Download failed. Is ${platform} supported in this release?" + + mkdir -p "$INSTALL_DIR" + mv "$tmp_file" "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + ok "Installed to ${INSTALL_DIR}/${BINARY_NAME}" + + # Check if install dir is in PATH + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + warn "${INSTALL_DIR} is not in your PATH" + local shell_name + shell_name="$(basename "${SHELL:-zsh}")" + local rc_file="$HOME/.zshrc" + [ "$shell_name" = "bash" ] && rc_file="$HOME/.bashrc" + + local path_line="export PATH=\"${INSTALL_DIR}:\$PATH\"" + if [ -f "$rc_file" ] && grep -qF "$path_line" "$rc_file"; then + warn "PATH entry exists in ${rc_file} but not active in this shell" + else + echo "$path_line" >> "$rc_file" + ok "Added ${INSTALL_DIR} to PATH in ${rc_file}" + fi + warn "Run: source ${rc_file} (or open a new terminal)" + ;; + esac + + echo "" + ok "PDFZen installed! Run 'pdfzen' to start." +} + +main diff --git a/package.json b/package.json index 74d312f..299b905 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,27 @@ "module": "src/index.tsx", "scripts": { "dev": "bun run --watch src/index.tsx", - "dev:all": "bun run scripts/dev.ts", - "dev:ui": "bun run scripts/dev.ts ui", - "dev:backend": "bun run scripts/dev.ts backend", "setup": "bun run scripts/dev.ts setup", "install:global": "bun run scripts/dev.ts install", - "build": "bun build ./src/index.tsx --outdir=dist --target=bun --minify --sourcemap", - "start": "bun run dist/index.js", + "build": "bun run scripts/build.ts", + "build:release": "bun run scripts/build.ts release", "test": "bun test", "lint": "oxlint .", "format": "oxfmt ." }, "dependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", "@opentui/core": "^0.1.74", "@opentui/solid": "^0.1.74", - "babel-preset-solid": "1.9.9", - "oxfmt": "^0.28.0", - "oxlint": "^1.43.0", + "mupdf": "^1.27.0", "pdf-lib": "^1.17.1", "solid-js": "1.9.9" }, "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "babel-preset-solid": "1.9.9", + "oxfmt": "^0.28.0", + "oxlint": "^1.43.0", "@types/bun": "latest", "@types/node": "^25.2.1" }, diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..df172d9 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,92 @@ +import solidTransformPlugin from "@opentui/solid/bun-plugin"; +import { mkdir, readdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; + +const projectDir = resolve(import.meta.dir, ".."); +const distDir = resolve(projectDir, "dist"); +const outDir = resolve(projectDir, "release/artifacts"); +const buildDir = resolve(projectDir, "release/.build"); + +const targets = [ + { name: "darwin-arm64", bunTarget: "bun-darwin-arm64" }, + { name: "darwin-x64", bunTarget: "bun-darwin-x64" }, + { name: "linux-x64", bunTarget: "bun-linux-x64" }, + { name: "linux-arm64", bunTarget: "bun-linux-arm64" }, + { name: "windows-x64", bunTarget: "bun-windows-x64" }, +] as const; + +const log = (msg: string) => console.log(`\x1b[34m→\x1b[0m ${msg}`); +const ok = (msg: string) => console.log(`\x1b[32m✓\x1b[0m ${msg}`); +const die = (msg: string): never => { console.error(`\x1b[31m✗\x1b[0m ${msg}`); process.exit(1); }; + +async function bundle(outdir: string): Promise { + log("Bundling..."); + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + + const result = await Bun.build({ + entrypoints: [resolve(projectDir, "src/index.tsx")], + outdir, + target: "bun", + minify: true, + sourcemap: "linked", + plugins: [solidTransformPlugin], + }); + + if (!result.success) { + for (const l of result.logs) console.error(l); + die("Bundle failed"); + } + ok(`Bundle → ${outdir}`); +} + +async function compile(toBuild: typeof targets[number][]): Promise { + await mkdir(outDir, { recursive: true }); + + // Collect external assets to embed (.wasm, .scm) + const entries = await readdir(buildDir); + const embedFiles = [ + ...entries.filter((e) => e.endsWith(".wasm") || e.endsWith(".scm")).map((e) => resolve(buildDir, e)), + resolve(projectDir, "node_modules/mupdf/dist/mupdf-wasm.wasm"), + ]; + + for (const t of toBuild) { + const ext = t.name.startsWith("windows") ? ".exe" : ""; + const outFile = resolve(outDir, `pdfzen-${t.name}${ext}`); + log(`Compiling ${t.name}...`); + + const proc = Bun.spawn( + [ + "bun", "build", "--compile", + "--no-compile-autoload-bunfig", + `--target=${t.bunTarget}`, + `--outfile=${outFile}`, + "--minify", + ...embedFiles.map((f) => `--embed=${f}`), + resolve(buildDir, "index.js"), + ], + { cwd: projectDir, stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + ); + + if ((await proc.exited) !== 0) die(`Failed to compile ${t.name}`); + ok(t.name); + } + + ok(`Artifacts in ${outDir}`); +} + +// ── CLI ───────────────────────────────────────────────────────────────────── + +const arg = process.argv[2]; + +if (!arg || arg === "dev") { + await bundle(distDir); +} else if (arg === "release") { + await bundle(buildDir); + await compile([...targets]); +} else { + const match = targets.filter((t) => t.name === arg); + if (match.length === 0) die(`Unknown target: ${arg}\nUsage: bun run build [dev|release|${targets.map((t) => t.name).join("|")}]`); + await bundle(buildDir); + await compile(match); +} diff --git a/scripts/dev.ts b/scripts/dev.ts index fe83c40..e4a0aff 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,18 +1,8 @@ import { mkdir, readFile, writeFile, chmod, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { homedir } from "node:os"; -type ExecOptions = { - cwd?: string; - env?: NodeJS.ProcessEnv; -}; - -type PythonCommand = { - executable: string; - prefixArgs: string[]; -}; - const colors = { red: "\x1b[31m", green: "\x1b[32m", @@ -22,8 +12,6 @@ const colors = { }; const projectDir = resolve(import.meta.dir, ".."); -const backendDir = resolve(projectDir, "backend"); -const venvDir = resolve(backendDir, ".venv"); function step(message: string): void { console.log(`${colors.blue}==>${colors.reset} ${message}`); @@ -42,136 +30,33 @@ function fail(message: string): never { process.exit(1); } -async function execOrFail(cmd: string[], options: ExecOptions = {}): Promise { +async function execOrFail(cmd: string[], cwd?: string): Promise { const proc = Bun.spawn(cmd, { - cwd: options.cwd, - env: options.env, + cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit", }); - const code = await proc.exited; - if (code !== 0) { - fail(`Command failed (${code}): ${cmd.join(" ")}`); + if ((await proc.exited) !== 0) { + fail(`Command failed: ${cmd.join(" ")}`); } } -async function commandWorks(cmd: string[]): Promise { - const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", stdin: "ignore" }); - return (await proc.exited) === 0; -} - -async function readCommandOutput(cmd: string[]): Promise { - const proc = Bun.spawn(cmd, { - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - }); - - const stdout = (await new Response(proc.stdout).text()).trim(); - const stderr = (await new Response(proc.stderr).text()).trim(); - await proc.exited; - return stdout || stderr; -} - function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } -function cmdQuote(value: string): string { - return `"${value.replace(/"/g, '""')}"`; -} - -function getVenvPythonPath(): string { - if (process.platform === "win32") { - return join(venvDir, "Scripts", "python.exe"); - } - return join(venvDir, "bin", "python3"); -} - -async function detectPythonCommand(): Promise { - const candidates: PythonCommand[] = - process.platform === "win32" - ? [ - { executable: "py", prefixArgs: ["-3"] }, - { executable: "python", prefixArgs: [] }, - { executable: "python3", prefixArgs: [] }, - ] - : [ - { executable: "python3", prefixArgs: [] }, - { executable: "python", prefixArgs: [] }, - ]; - - for (const candidate of candidates) { - if (await commandWorks([candidate.executable, ...candidate.prefixArgs, "--version"])) { - return candidate; - } - } - - fail("Python 3 not found. Install Python 3.10+ and retry."); -} - -async function checkDependencies(): Promise { - step("Checking dependencies..."); - - const python = await detectPythonCommand(); - const rawVersion = await readCommandOutput([python.executable, ...python.prefixArgs, "--version"]); - success(`Python found: ${rawVersion.trim()}`); - - if (!(await commandWorks(["bun", "--version"]))) { - fail("Bun not found. Install Bun and retry."); - } - const bunVersion = await readCommandOutput(["bun", "--version"]); - success(`Bun found: ${bunVersion}`); - - return python; -} - -async function setupBackend(python: PythonCommand): Promise { - step("Setting up Python backend..."); - - if (!existsSync(venvDir)) { - step("Creating virtual environment..."); - await execOrFail([python.executable, ...python.prefixArgs, "-m", "venv", ".venv"], { cwd: backendDir }); - success("Virtual environment created"); - } else { - success("Virtual environment exists"); - } - - const venvPython = getVenvPythonPath(); - step("Installing Python dependencies..."); - await execOrFail([venvPython, "-m", "pip", "install", "-q", "--upgrade", "pip"], { cwd: backendDir }); - await execOrFail([venvPython, "-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: backendDir }); - success("Python dependencies installed"); - - step("Verifying backend..."); - const ok = await commandWorks([venvPython, "pdfzen_backend.py", "check-deps"]); - if (ok) { - success("Backend verified"); - } else { - warning("Backend check-deps returned non-zero (may be okay)"); - } -} - -async function setupFrontend(): Promise { - step("Setting up frontend..."); - +async function setupDependencies(): Promise { if (!existsSync(resolve(projectDir, "node_modules"))) { - step("Installing Bun dependencies..."); - await execOrFail(["bun", "install"], { cwd: projectDir }); - success("Bun dependencies installed"); + step("Installing dependencies..."); + await execOrFail(["bun", "install"], projectDir); + success("Dependencies installed"); } else { - success("Bun dependencies exist"); + success("Dependencies already installed"); } } -async function runSetup(): Promise { - const python = await checkDependencies(); - await setupBackend(python); - await setupFrontend(); -} - function getShellRcPath(): string { const shell = basename(process.env.SHELL ?? "zsh"); if (shell === "bash") { @@ -183,65 +68,12 @@ function getShellRcPath(): string { } function pathHasEntry(pathValue: string, entry: string): boolean { - const normalized = process.platform === "win32" ? entry.toLowerCase() : entry; - return pathValue - .split(process.platform === "win32" ? ";" : ":") - .map((part) => (process.platform === "win32" ? part.toLowerCase() : part)) - .includes(normalized); -} - -async function updateWindowsUserPath(binDir: string): Promise { - if (binDir.includes(";")) { - warning(`Refusing to update PATH with invalid directory: ${binDir}`); - return; - } - - const script = [ - "$target = $env:PDFZEN_BIN_DIR", - "$current = [Environment]::GetEnvironmentVariable('Path', 'User')", - "if ([string]::IsNullOrWhiteSpace($current)) {", - " [Environment]::SetEnvironmentVariable('Path', $target, 'User')", - " exit 0", - "}", - "$parts = $current.Split(';') | Where-Object { $_ -ne '' }", - "if ($parts -contains $target) { exit 0 }", - "$updated = $current.TrimEnd(';') + ';' + $target", - "[Environment]::SetEnvironmentVariable('Path', $updated, 'User')", - ].join("\n"); - - const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], { - env: { ...process.env, PDFZEN_BIN_DIR: binDir }, - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - - const code = await proc.exited; - if (code !== 0) { - const stderr = await new Response(proc.stderr).text(); - warning(`Could not update Windows user PATH automatically: ${stderr.trim()}`); - warning(`Add this directory manually to PATH: ${binDir}`); - } + return pathValue.split(":").includes(entry); } async function installGlobalCommand(): Promise { step("Installing global 'pdfzen' command..."); - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA || resolve(homedir(), "AppData", "Local"); - const binDir = resolve(localAppData, "pdfzen", "bin"); - const cmdPath = resolve(binDir, "pdfzen.cmd"); - - await mkdir(binDir, { recursive: true }); - const script = `@echo off\r\nsetlocal\r\nset "PDFZEN_PROJECT=${projectDir.replace(/"/g, '""')}"\r\nbun run --cwd ${cmdQuote("%PDFZEN_PROJECT%") } scripts/dev.ts %*\r\n`; - await writeFile(cmdPath, script, "utf8"); - - await updateWindowsUserPath(binDir); - success(`Created launcher: ${cmdPath}`); - success("Install complete. Open a new terminal and run: pdfzen"); - return; - } - let binDir = ""; const pathValue = process.env.PATH ?? ""; for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) { @@ -302,20 +134,7 @@ async function installGlobalCommand(): Promise { async function startUi(): Promise { step("Starting TUI..."); - console.log("\nPDFZen is starting!\nPress Ctrl+C to stop\n"); - await execOrFail(["bun", "run", "dev"], { cwd: projectDir }); -} - -async function runBackend(args: string[]): Promise { - const venvPython = getVenvPythonPath(); - if (!existsSync(venvPython)) { - warning("Backend virtual environment not found. Running setup first..."); - await runSetup(); - } - - await execOrFail([venvPython, resolve(backendDir, "pdfzen_backend.py"), ...args], { - cwd: backendDir, - }); + await execOrFail(["bun", "run", "dev"], projectDir); } function printBanner(): void { @@ -328,10 +147,8 @@ function printUsage(): void { console.log(`Usage: bun run scripts/dev.ts [command] Commands: - setup Install backend/frontend dependencies + setup Install dependencies install Setup and install global "pdfzen" command - backend Run backend CLI directly - ui Run UI only help Show this message Default: @@ -341,30 +158,24 @@ Default: async function main(): Promise { printBanner(); - const [command = "", ...rest] = process.argv.slice(2); + const [command = ""] = process.argv.slice(2); switch (command) { case "setup": - await runSetup(); - success("Setup complete! Run 'bun run dev:all' to start."); + await setupDependencies(); + success("Setup complete! Run 'bun run dev' to start."); break; case "install": - await runSetup(); + await setupDependencies(); await installGlobalCommand(); break; - case "backend": - await runBackend(rest); - break; - case "ui": - await execOrFail(["bun", "run", "dev"], { cwd: projectDir }); - break; case "help": case "-h": case "--help": printUsage(); break; case "": - await runSetup(); + await setupDependencies(); await startUi(); break; default: diff --git a/src/index.tsx b/src/index.tsx index f6c7414..efbe795 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,8 +14,6 @@ import { ProtectUI } from "./components/protect"; import { DecryptUI } from "./components/decrypt"; import { toolsMenu } from "./constants/constants"; import Hero from "./components/hero"; -import { warmupBackend } from "./utils/backend"; -import { onMount } from "solid-js"; // Static tool component mapping - defined outside render for performance const toolComponents: Record any> = { @@ -35,13 +33,7 @@ render(() => { const [escapeCount, setEscapeCount] = createSignal(0); let escapeTimer: ReturnType | null = null; - onMount(() => { - const timer = setTimeout(() => { - void warmupBackend(); - }, 300); - return () => clearTimeout(timer); - }); const getToolName = (command: string) => toolsMenu.find((t) => t.command === command)?.name || command; diff --git a/src/model/models.ts b/src/model/models.ts index d9d90d4..bf697bf 100644 --- a/src/model/models.ts +++ b/src/model/models.ts @@ -10,13 +10,6 @@ export type FocusableElement = { export interface Status { msg: string; type: StatusType }; -// ============ Backend Types ============ -export interface BackendResult { - success: boolean; - error?: string; - [key: string]: any; -} - // ============ Hook Options ============ export interface FileListOptions { trackPageCount?: boolean; diff --git a/src/tools/compress.ts b/src/tools/compress.ts index bbb2516..bfcda9c 100644 --- a/src/tools/compress.ts +++ b/src/tools/compress.ts @@ -1,35 +1,41 @@ -import { backendCompressPdf } from "../utils/backend"; +import mupdf from "mupdf"; import type { CompressPDFInput, CompressPDFOutput } from "../model/models"; export type { CompressPDFInput, CompressPDFOutput }; /** - * Compresses a PDF file using the Python backend (PyMuPDF) - * Performs: garbage collection, image recompression, stream deflation, linearization + * Compresses a PDF file using MuPDF WASM + * Performs: garbage collection, image recompression, stream deflation, font compression * @param input - Input PDF path and output path * @returns Result with success status, file sizes, and compression ratio */ export async function compressPDF(input: CompressPDFInput): Promise { try { - const result = await backendCompressPdf({ - input: input.inputPath, - output: input.outputPath, - }); - - if (result.success) { - return { - success: true, - outputPath: result.outputPath, - originalSize: result.originalSize, - compressedSize: result.compressedSize, - compressionRatio: result.compressionRatio, - }; - } else { - return { - success: false, - error: result.error || "Compression failed", - }; + const originalSize = Bun.file(input.inputPath).size; + const pdfBytes = await Bun.file(input.inputPath).arrayBuffer(); + + const doc = mupdf.Document.openDocument(pdfBytes, "application/pdf"); + const pdfDoc = doc.asPDF(); + if (!pdfDoc) { + return { success: false, error: "Not a valid PDF document" }; } + + const buf = pdfDoc.saveToBuffer( + "garbage=deduplicate,compress,compress-images,compress-fonts,clean,sanitize", + ); + const compressed = buf.asUint8Array(); + await Bun.write(input.outputPath, compressed); + + const compressedSize = compressed.byteLength; + const ratio = originalSize > 0 ? (1 - compressedSize / originalSize) * 100 : 0; + + return { + success: true, + outputPath: input.outputPath, + originalSize, + compressedSize, + compressionRatio: `${ratio.toFixed(2)}%`, + }; } catch (error) { return { success: false, diff --git a/src/tools/images-to-pdf.ts b/src/tools/images-to-pdf.ts index 9bdca89..a036bc9 100644 --- a/src/tools/images-to-pdf.ts +++ b/src/tools/images-to-pdf.ts @@ -1,10 +1,10 @@ -import { backendImagesToPdf } from "../utils/backend"; +import { PDFDocument } from "pdf-lib"; import type { ImagesToPDFInput, ImagesToPDFOutput } from "../model/models"; export type { ImagesToPDFInput, ImagesToPDFOutput }; /** - * Converts multiple images into a single PDF file using Python backend + * Converts multiple images into a single PDF file using pdf-lib * @param input - Image paths, output path, and page size options * @returns Result with success status and page count */ @@ -25,24 +25,59 @@ export async function imagesToPDF(input: ImagesToPDFInput): Promise p >= 1 && p <= totalPages) + .map((p) => p - 1); + } else { + pageIndices = Array.from({ length: totalPages }, (_, i) => i); } - const result = await backendPdfToImages({ - input: input.inputPath, - outputDir: input.outputDir, - format: input.format || "png", - dpi: input.dpi || 150, - pages: pagesStr, - }); - - if (result.success) { - return { - success: true, - outputFiles: result.outputFiles, - totalImages: result.totalImages, - }; - } else { - return { - success: false, - error: result.error || "Unknown error", - }; + const outputFiles: string[] = []; + + for (const pageIdx of pageIndices) { + const page = doc.loadPage(pageIdx); + const ext = format.toLowerCase(); + const outputPath = join(input.outputDir, `${baseName}_page_${pageIdx + 1}.${ext}`); + + if (ext === "jpg" || ext === "jpeg") { + // JPEG: render without alpha (asJPEG throws on alpha pixmaps) + const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, false, true); + const buf = pixmap.asJPEG(90, false); + await Bun.write(outputPath, buf); + } else { + const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, true, true); + const buf = pixmap.asPNG(); + await Bun.write(outputPath, buf); + } + + outputFiles.push(outputPath); } + + return { + success: true, + outputFiles, + totalImages: outputFiles.length, + }; } catch (error) { return { success: false, diff --git a/src/tools/protect.ts b/src/tools/protect.ts index 8efa2a3..a2cd3fd 100644 --- a/src/tools/protect.ts +++ b/src/tools/protect.ts @@ -1,10 +1,46 @@ -import { backendProtectPdf, callBackend } from "../utils/backend"; +import mupdf from "mupdf"; import type { ProtectPDFInput, ProtectPDFOutput } from "../model/models"; export type { ProtectPDFInput, ProtectPDFOutput }; /** - * Protects a PDF with password encryption using Python backend (pikepdf) + * Compute the PDF permissions bitfield (P value) from permission flags. + * PDF spec: bits 1-2 must be 0, bits 7-8 and 13-32 must be 1. + */ +function computePermissions(opts: { + print?: boolean; + copy?: boolean; + modify?: boolean; + annotate?: boolean; +}): number { + // Start with all permissions granted (0xFFFFFFFC signed) + let p = -4; + if (opts.print === false) p &= ~((1 << 2) | (1 << 11)); // bits 3, 12 + if (opts.modify === false) p &= ~((1 << 3) | (1 << 8) | (1 << 10)); // bits 4, 9, 11 + if (opts.copy === false) p &= ~(1 << 4); // bit 5 + if (opts.annotate === false) p &= ~(1 << 5); // bit 6 + return p; +} + +/** + * Build the mupdf save options string for encryption. + * Passwords are interpolated into the options string; characters that would + * break the comma-delimited parser are rejected upfront. + */ +function buildEncryptOptions(opts: { + userPassword?: string; + ownerPassword?: string; + permissions: number; +}): string { + const parts: string[] = ["encrypt=aes-256"]; + if (opts.userPassword) parts.push(`user-password=${opts.userPassword}`); + if (opts.ownerPassword) parts.push(`owner-password=${opts.ownerPassword}`); + parts.push(`permissions=${opts.permissions}`); + return parts.join(","); +} + +/** + * Protects a PDF with password encryption using MuPDF WASM * @param input - Input PDF path, output path, and protection options * @returns Result with success status */ @@ -18,30 +54,39 @@ export async function protectPDF(input: ProtectPDFInput): Promise { const result = await pdfToImages({ - inputPath: "/tmp/in.pdf", + inputPath: "/tmp/missing-all.pdf", outputDir: "/tmp/out", pages: "all", }); expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toBe("Unknown error"); - } }); }); diff --git a/tests/tools/protect.branches.spec.ts b/tests/tools/protect.branches.spec.ts index 537c6bf..c8336f5 100644 --- a/tests/tools/protect.branches.spec.ts +++ b/tests/tools/protect.branches.spec.ts @@ -1,118 +1,63 @@ -import { describe, expect, it, mock } from "bun:test"; - -const backendModulePath = new URL("../../src/utils/backend.ts", import.meta.url).pathname; - -let protectMode: "success" | "fallback" = "success"; -let unprotectMode: "success" | "fallback" | "throw-error" = "success"; - -const backendProtectPdf = async () => { - if (protectMode === "success") { - return { - success: true, - outputPath: "/tmp/protected.pdf", - }; - } - - return { - success: false, - }; -}; - -const callBackend = async (command?: string) => { - if (unprotectMode === "throw-error" && command === "unprotect") { - throw new Error("mock unprotect failure"); - } - - if (unprotectMode === "success") { - return { - success: true, - outputPath: "/tmp/unprotected.pdf", - }; - } - - return { - success: false, - }; -}; - -const mockFactory = () => ({ - backendCompressPdf: async () => ({ success: false, error: "stub" }), - backendImagesToPdf: async () => ({ success: false, error: "stub" }), - backendPdfToImages: async () => ({ success: false, error: "stub" }), - backendProtectPdf, - callBackend, - checkBackendDeps: async () => ({ allInstalled: false, dependencies: {} }), - installBackendDeps: async () => ({ success: false, error: "stub" }), - warmupBackend: async () => ({ success: false, error: "stub" }), -}); - -mock.module("../../src/utils/backend", mockFactory); -mock.module(backendModulePath, mockFactory); -const { protectPDF, unprotectPDF } = await import("../../src/tools/protect"); -mock.restore(); +import { describe, expect, it } from "bun:test"; +import { protectPDF, unprotectPDF } from "../../src/tools/protect"; describe("protect tool branch coverage", () => { - it("maps protect backend success payload", async () => { - protectMode = "success"; - + it("validates password requirements", async () => { const result = await protectPDF({ inputPath: "/tmp/in.pdf", outputPath: "/tmp/out.pdf", - userPassword: "pw", }); - expect(result).toEqual({ - success: true, - outputPath: "/tmp/protected.pdf", - }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("At least one password"); + } }); - it("uses default protect error when backend omits error", async () => { - protectMode = "fallback"; - + it("rejects passwords with invalid characters", async () => { const result = await protectPDF({ inputPath: "/tmp/in.pdf", outputPath: "/tmp/out.pdf", - ownerPassword: "owner", + userPassword: "pass,word", }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe("Unknown error"); + expect(result.error).toContain("must not contain"); } }); - it("maps unprotect backend success payload", async () => { - unprotectMode = "success"; - - const result = await unprotectPDF("/tmp/in.pdf", "/tmp/out.pdf", "pw"); - - expect(result).toEqual({ - success: true, - outputPath: "/tmp/unprotected.pdf", + it("returns failure when protecting a missing file", async () => { + const result = await protectPDF({ + inputPath: "/tmp/missing.pdf", + outputPath: "/tmp/protected.pdf", + userPassword: "pw", }); + expect(result.success).toBe(false); }); - it("uses default unprotect error when backend omits error", async () => { - unprotectMode = "fallback"; - - const result = await unprotectPDF("/tmp/in.pdf", "/tmp/out.pdf", "pw"); - + it("returns failure when unprotecting a missing file", async () => { + const result = await unprotectPDF( + "/tmp/missing-protected.pdf", + "/tmp/unprotected.pdf", + "pw", + ); expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toBe("Unknown error"); - } }); - it("maps thrown unprotect backend error", async () => { - unprotectMode = "throw-error"; - - const result = await unprotectPDF("/tmp/in.pdf", "/tmp/out.pdf", "pw"); + it("catches thrown input getter errors in protectPDF", async () => { + const result = await protectPDF({ + get userPassword() { + throw new Error("bad-password-getter"); + }, + ownerPassword: "x", + inputPath: "/tmp/in.pdf", + outputPath: "/tmp/out.pdf", + } as unknown as Parameters[0]); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain("mock unprotect failure"); + expect(result.error).toContain("bad-password-getter"); } }); - }); diff --git a/tests/tools/protect.spec.ts b/tests/tools/protect.spec.ts index 0fdd0fd..1bf2440 100644 --- a/tests/tools/protect.spec.ts +++ b/tests/tools/protect.spec.ts @@ -33,7 +33,7 @@ describe("protect tool", () => { expect(unprotectedResult.success).toBe(false); }); - it("accepts owner password path and reaches backend call", async () => { + it("accepts owner password path and reaches protect logic", async () => { const { protectPDF } = await import("../../src/tools/protect"); const result = await protectPDF({ diff --git a/tests/utils/backend.spec.ts b/tests/utils/backend.spec.ts deleted file mode 100644 index 95a3eb6..0000000 --- a/tests/utils/backend.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - backendCompressPdf, - backendImagesToPdf, - backendPdfToImages, - backendProtectPdf, - callBackend, - checkBackendDeps, - warmupBackend, -} from "../../src/utils/backend"; - -describe("backend utils", () => { - it("returns a backend result shape for check-deps", async () => { - const result = await callBackend("check-deps", {}); - expect(typeof result.success).toBe("boolean"); - }); - - it("returns dependency check shape", async () => { - const result = await checkBackendDeps(); - expect(typeof result.allInstalled).toBe("boolean"); - expect(typeof result.dependencies).toBe("object"); - }); - - it("warms backend and returns result shape", async () => { - const result = await warmupBackend(); - expect(typeof result.success).toBe("boolean"); - }); - - it("backend wrappers return a result for missing files", async () => { - const a = await backendPdfToImages({ input: "/tmp/missing.pdf", outputDir: "/tmp/out" }); - const b = await backendImagesToPdf({ inputs: ["/tmp/missing.png"], output: "/tmp/out.pdf" }); - const c = await backendProtectPdf({ input: "/tmp/missing.pdf", output: "/tmp/out.pdf", userPassword: "pw" }); - const d = await backendCompressPdf({ input: "/tmp/missing.pdf", output: "/tmp/out.pdf" }); - - expect(typeof a.success).toBe("boolean"); - expect(typeof b.success).toBe("boolean"); - expect(typeof c.success).toBe("boolean"); - expect(typeof d.success).toBe("boolean"); - }); -});