Skill: ashtonizmev/geminipdfocr (version 0.1.7)

--- name: PDF OCR using Gemini LLM description: Extract text from PDFs using Google Gemini OCR. Use when extracting text from PDFs, performing OCR on scanned documents, or processing image-based PDFs. metadata: openclaw: requires: env: - GOOGLE_API_KEY primaryEnv: GOOGLE_API_KEY install: - kind: uv package: google-genai label: "Python deps" - kind: uv package: pymupdf - kind: uv package: pydantic - kind: uv package: pydantic-settings --- ## Purpose Use geminipdfocr to extract text from PDF documents via OCR (Google Gemini). ## Data and privacy **Full page images/files are sent to Google's API.** PDFs are split into single-page files and each page is uploaded to Google Gemini for OCR. There are no hidden exfiltration endpoints or other data collection. Do not use with highly sensitive documents unless you accept that content is sent to Google. ## Setup (venv installation) Before first use, create and activate the virtual environment: ```bash cd geminipdfocr && python -m venv venv && source venv/bin/activate && pip install -r requirements.txt ``` Set `GOOGLE_API_KEY` in your environment before running (e.g. `export GOOGLE_API_KEY=your-key`). ## How to use When requested to extract text or perform OCR on a PDF: 1. Run: `cd geminipdfocr && source venv/bin/activate && python -m geminipdfocr <path-to-pdf> [--json] [--output <file>]` 2. Use `--json` for structured data. 3. Use `--max-pages N` for testing or very long documents. 4. Use `--quiet` to suppress progress logs. ## Requirements - A valid PDF file path. - `GOOGLE_API_KEY` set in the process environment (e.g. `export GOOGLE_API_KEY=your-key`). ## CLI options | Option | Description | |--------|-------------| | `pdf_path` | One or more PDF file paths (positional) | | `--max-pages N` | Limit pages per PDF | | `--json` | Output structured JSON instead of plain text | | `--output FILE` | Write result to file (default: stdout) | | `--quiet` | Suppress INFO/DEBUG logs |

path: __init__.py
content: """Geminipdf - OCR PDF processing using Google Gemini."""


path: __main__.py
content: """Allow running geminipdfocr as a module: python -m geminipdfocr."""

import sys
from pathlib import Path

# Ensure geminipdfocr directory is on path so flat imports (gemini_client, service, etc.) resolve
_skill_dir = Path(__file__).resolve().parent
if str(_skill_dir) not in sys.path:
    sys.path.insert(0, str(_skill_dir))

from main import main

if __name__ == "__main__":
    sys.exit(main())


path: config.py
content: """Configuration settings for geminipdfocr."""

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Geminipdfocr configuration settings. Credentials from system env only."""

    google_api_key: str = ""
    ocr_max_concurrent: int = 4
    ocr_model: str = "gemini-2.0-flash"

    model_config = SettingsConfigDict(
        extra="ignore",
    )


settings = Settings()


path: file_service.py
content: """File storage service for temporary processing folders."""

import shutil
import tempfile
import uuid
from pathlib import Path


class FileService:
    """Service for temporary processing folders."""

    def __init__(self, base_dir: Path | None = None):
        self._base_dir = base_dir or Path(tempfile.gettempdir()) / "geminipdfocr"
        self._base_dir.mkdir(parents=True, exist_ok=True)

    @property
    def temp_dir(self) -> Path:
        """Directory for temporary processing files."""
        temp_path = self._base_dir / "temp"
        temp_path.mkdir(parents=True, exist_ok=True)
        return temp_path

    def create_temp_folder(self, prefix: str = "job") -> Path:
        """
        Create a temporary folder for processing.

        Args:
            prefix: Prefix for folder name

        Returns:
            Path to the created folder
        """
        folder_id = str(uuid.uuid4())[:8]
        folder_path = self.temp_dir / f"{prefix}_{folder_id}"
        folder_path.mkdir(parents=True, exist_ok=True)
        return folder_path

    def cleanup_temp_folder(self, folder_path: Path) -> None:
        """
        Remove a temporary folder and its contents.

        Args:
            folder_path: Path to the folder to remove
        """
        if folder_path.exists() and folder_path.is_dir():
            shutil.rmtree(folder_path)


# Singleton instance
_file_service: FileService | None = None


def get_file_service() -> FileService:
    """Get or create singleton FileService instance."""
    global _file_service
    if _file_service is None:
        _file_service = FileService()
    return _file_service


path: gemini_client.py
content: """Gemini Client Wrapper for OCR and LLM interactions."""

from pathlib import Path

from google import genai

from config import settings


class GeminiClient:
    """Wrapper for Google Gemini API interactions."""

    DEFAULT_MODEL = "gemini-2.0-flash"

    def __init__(self, api_key: str | None = None, model: str | None = None):
        self._api_key = api_key or settings.google_api_key
        self._model = (
            model or getattr(settings, "ocr_model", None) or self.DEFAULT_MODEL
        )
        self._client: genai.Client | None = None

    @property
    def client(self) -> genai.Client:
        """Lazy initialization of Gemini client."""
        if self._client is None:
            self._client = genai.Client([REDACTED_SECRET])
        return self._client

    async def ocr_pdf_page_async(
        self, file_path: str | Path, prompt: str | None = None
    ) -> str:
        """
        Async version using native genai async client.
        Uses a dedicated client per call to avoid httpx connection sharing
        when multiple pages are processed concurrently.
        """
        prompt = prompt or "Extract absolutely all the text from the pdf"
        file_path = str(file_path)

        client = genai.Client([REDACTED_SECRET])
        async with client.aio as aclient:
            file_to_ocr = await aclient.files.upload(file=file_path)
            response = await aclient.models.generate_content(
                model=self._model,
                contents=[prompt, file_to_ocr],
            )
            return response.candidates[0].content.parts[0].text


# Singleton instance
_gemini_client: GeminiClient | None = None


def get_gemini_client() -> GeminiClient:
    """Get or create singleton Gemini client instance."""
    global _gemini_client
    if _gemini_client is None:
        _gemini_client = GeminiClient()
    return _gemini_client


path: main.py
content: """CLI entry point for geminipdfocr - OCR PDF processing with Gemini."""

import argparse
import asyncio
import json
import logging
import sys
from pathlib import Path

from service import get_ocr_cloud_service
from schemas import OcrResultSchema


def _format_plain_text(results: list[OcrResultSchema]) -> str:
    """Concatenate extracted text from all pages of all documents."""
    parts = []
    for result in results:
        for page in sorted(result.pages, key=lambda p: p.page_number):
            if page.text:
                parts.append(page.text)
    return "\n\n".join(parts)


def _format_json(results: list[OcrResultSchema]) -> str:
    """Output results as JSON."""
    return json.dumps(
        [r.model_dump() for r in results],
        indent=2,
        ensure_ascii=False,
    )


async def _process_pdfs(
    pdf_paths: list[Path],
    max_pages: int | None,
) -> list[OcrResultSchema]:
    """Process multiple PDFs and return results."""
    service = get_ocr_cloud_service()
    results = await service.process_multiple_pdfs(pdf_paths, max_pages)
    return results


def main() -> int:
    """Run the CLI. Returns exit code (0 = success, 1 = failure)."""
    parser = argparse.ArgumentParser(
        description="Extract text from PDFs using Google Gemini OCR.",
        prog="geminipdfocr",
    )
    parser.add_argument(
        "pdf_path",
        type=Path,
        nargs="+",
        help="Path(s) to PDF file(s)",
    )
    parser.add_argument(
        "--max-pages",
        type=int,
        default=None,
        metavar="N",
        help="Limit pages per PDF (for testing)",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Output as JSON instead of plain text",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=None,
        metavar="FILE",
        help="Write result to file (default: stdout)",
    )
    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress progress logs",
    )
    args = parser.parse_args()

    if args.quiet:
        logging.basicConfig(level=logging.WARNING)
    else:
        logging.basicConfig(
            level=logging.INFO,
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        )

    # Validate paths exist
    missing = [p for p in args.pdf_path if not p.exists()]
    if missing:
        for p in missing:
            print(f"Error: File not found: {p}", file=sys.stderr)
        return 1

    try:
        results = asyncio.run(_process_pdfs(args.pdf_path, args.max_pages))
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1

    # Check overall success
    has_error = any(r.status == "error" for r in results)
    if has_error and all(r.status == "error" for r in results):
        return 1

    # Format output
    if args.json:
        output = _format_json(results)
    else:
        output = _format_plain_text(results)

    if args.output:
        args.output.write_text(output, encoding="utf-8")
    else:
        print(output)

    return 0


if __name__ == "__main__":
    sys.exit(main())


path: requirements.txt
content: # Geminipdf - Requirements
# OCR PDF processing with Google Gemini

# Gemini API
google-genai

# PDF Processing
pymupdf

# Pydantic
pydantic-settings
pydantic


path: schemas.py
content: """Schemas for OCR Cloud module - Gemini cloud OCR processing."""

from pydantic import BaseModel, ConfigDict, Field


class OcrPageSchema(BaseModel):
    """Schema for a single OCR-processed page."""

    model_config = ConfigDict(from_attributes=True)

    page_number: int = Field(..., description="Page number (1-indexed)")
    text: str = Field(..., description="Extracted text from the page")
    status: str = Field(..., description="Processing status: success, error, skipped")
    char_count: int = Field(0, description="Number of characters extracted")
    error: str | None = Field(None, description="Error message if status is error")


class OcrResultSchema(BaseModel):
    """Schema for OCR result of a complete document."""

    model_config = ConfigDict(from_attributes=True)

    pdf_name: str = Field(..., description="Original PDF filename")
    total_pages: int = Field(..., description="Total number of pages processed")
    pages: list[OcrPageSchema] = Field(
        default_factory=list, description="OCR results per page"
    )
    total_chars: int = Field(0, description="Total characters extracted")
    status: str = Field("success", description="Overall processing status")


path: service.py
content: """Service layer for OCR Cloud module - Gemini cloud OCR processing."""

import asyncio
import logging
from pathlib import Path

from gemini_client import get_gemini_client, GeminiClient
from utils import split_pdf_to_single_pages
from file_service import get_file_service, FileService
from config import settings

logger = logging.getLogger(__name__)

from schemas import OcrPageSchema, OcrResultSchema


class OcrCloudService:
    """Service for processing PDFs with Gemini cloud OCR."""

    def __init__(
        self,
        gemini_client: GeminiClient | None = None,
        file_service: FileService | None = None,
        max_concurrent: int | None = None,
    ):
        self._gemini_client = gemini_client
        self._file_service = file_service
        self._max_concurrent = max_concurrent or settings.ocr_max_concurrent

    @property
    def gemini_client(self) -> GeminiClient:
        """Lazy initialization of Gemini client."""
        if self._gemini_client is None:
            self._gemini_client = get_gemini_client()
        return self._gemini_client

    @property
    def file_service(self) -> FileService:
        """Lazy initialization of file service."""
        if self._file_service is None:
            self._file_service = get_file_service()
        return self._file_service

    async def process_page(
        self,
        page_path: Path,
        page_num: int,
        semaphore: asyncio.Semaphore,
    ) -> OcrPageSchema:
        """
        Process a single page with Gemini OCR.

        Args:
            page_path: Path to single-page PDF
            page_num: Page number (1-indexed)
            semaphore: Semaphore for concurrency control

        Returns:
            OcrPageSchema with extracted text
        """
        async with semaphore:
            try:
                logger.debug(f"Processing page {page_num}: {page_path}")

                # Run OCR (async wrapper around sync Gemini API)
                text = await self.gemini_client.ocr_pdf_page_async(page_path)

                return OcrPageSchema(
                    page_number=page_num,
                    text=text,
                    status="success",
                    char_count=len(text),
                )

            except Exception as e:
                logger.error(f"Error processing page {page_num}: {e}")
                return OcrPageSchema(
                    page_number=page_num,
                    text="",
                    status="error",
                    char_count=0,
                    error=str(e),
                )

    async def process_pdf(
        self,
        pdf_path: Path,
        max_pages: int | None = None,
    ) -> OcrResultSchema:
        """
        Process a PDF document with Gemini OCR.

        Args:
            pdf_path: Path to the PDF file
            max_pages: Maximum pages to process (for testing)

        Returns:
            OcrResultSchema with all page results
        """
        logger.info(f"Processing PDF: {pdf_path}")

        # Create temp folder for split pages
        temp_folder = self.file_service.create_temp_folder(prefix="ocr")

        try:
            # Split PDF into single pages (already in page order)
            page_paths = split_pdf_to_single_pages(pdf_path, temp_folder)
            logger.debug(f"Split PDF into {len(page_paths)} pages")

            # Apply max_pages limit if specified
            if max_pages and len(page_paths) > max_pages:
                logger.info(f"Limiting to {max_pages} pages (total: {len(page_paths)})")
                page_paths = page_paths[:max_pages]

            # Create semaphore for concurrency control
            semaphore = asyncio.Semaphore(self._max_concurrent)

            # Process all pages concurrently (page_num from list position, 1-indexed)
            tasks = []
            for page_num, page_path in enumerate(page_paths, start=1):
                task = self.process_page(page_path, page_num, semaphore)
                tasks.append(task)

            # Wait for all pages to complete
            logger.info(
                f"Processing {len(tasks)} pages concurrently (max {self._max_concurrent} concurrent)"
            )
            page_results = await asyncio.gather(*tasks)

            # Calculate totals
            total_chars = sum(p.char_count for p in page_results)
            failed_pages = sum(1 for p in page_results if p.status == "error")
            overall_status = "success" if failed_pages == 0 else "partial"
            logger.info(
                f"OCR processing complete: {len(page_results)} pages, {total_chars} chars extracted, {failed_pages} failed"
            )

            return OcrResultSchema(
                pdf_name=pdf_path.name,
                total_pages=len(page_results),
                pages=list(page_results),
                total_chars=total_chars,
                status=overall_status,
            )

        finally:
            # Cleanup temp folder
            self.file_service.cleanup_temp_folder(temp_folder)

    async def process_multiple_pdfs(
        self,
        pdf_paths: list[Path],
        max_pages: int | None = None,
    ) -> list[OcrResultSchema]:
        """
        Process multiple PDF documents.

        Args:
            pdf_paths: List of PDF file paths
            max_pages: Maximum pages per document (for testing)

        Returns:
            List of OcrResultSchema for each document
        """
        results = []
        for pdf_path in pdf_paths:
            try:
                result = await self.process_pdf(pdf_path, max_pages)
                results.append(result)
            except Exception as e:
                logger.error(f"Error processing {pdf_path}: {e}")
                results.append(
                    OcrResultSchema(
                        pdf_name=pdf_path.name,
                        total_pages=0,
                        pages=[],
                        total_chars=0,
                        status="error",
                    )
                )

        return results


# Singleton instance
_ocr_cloud_service: OcrCloudService | None = None


def get_ocr_cloud_service() -> OcrCloudService:
    """Get or create singleton OcrCloudService instance."""
    global _ocr_cloud_service
    if _ocr_cloud_service is None:
        _ocr_cloud_service = OcrCloudService()
    return _ocr_cloud_service


path: skill.json
content: {
    "name": "geminipdfocr",
    "description": "Extract text from PDFs using Google Gemini OCR",
    "entry_point": "python -m geminipdfocr",
    "arguments": ["pdf_path", "--max-pages", "--json", "--output", "--quiet"],
    "required_env": ["GOOGLE_API_KEY"],
    "install": "cd geminipdfocr && python -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
}


path: utils.py
content: """PDF utilities for splitting and processing PDF files."""

import logging
import unicodedata
from pathlib import Path

import fitz  # PyMuPDF

logger = logging.getLogger(__name__)


def sanitize_filename(filename: str) -> str:
    """
    Sanitize a filename by normalizing Unicode characters and transliterating to ASCII.

    This handles special characters like é, à, etc. by:
    1. Normalizing Unicode (NFD) to separate base characters from combining marks
    2. Transliterating accented characters to ASCII equivalents
    3. Removing spaces and filesystem-unsafe characters

    Args:
        filename: Original filename (without extension)

    Returns:
        Sanitized filename safe for filesystem and encoding operations
    """
    # Normalize Unicode to NFD (decomposed form)
    normalized = unicodedata.normalize("NFD", filename)

    # Remove combining marks (accents)
    ascii_chars = []
    for char in normalized:
        if unicodedata.category(char) != "Mn":
            ascii_chars.append(char)

    # Join and remove spaces
    sanitized = "".join(ascii_chars).replace(" ", "")

    # Replace filesystem-unsafe characters with underscore
    unsafe_chars = r'/\\:*?"<>|'
    sanitized = "".join("_" if c in unsafe_chars else c for c in sanitized)

    # Keep only alphanumeric, underscore, and hyphen
    final = "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in sanitized)

    return final


def split_pdf_to_single_pages(pdf_path: Path, output_folder: Path) -> list[Path]:
    """
    Split a PDF into single-page PDFs (idempotent - skips existing files).

    Args:
        pdf_path: Path to the source PDF file
        output_folder: Directory to save single-page PDFs

    Returns:
        List of paths to single-page PDF files
    """
    output_folder.mkdir(parents=True, exist_ok=True)

    pdf_document = fitz.open(str(pdf_path))
    output_paths = []

    base_filename = sanitize_filename(pdf_path.stem.strip())
    pages_created = 0
    pages_skipped = 0

    for page_num in range(pdf_document.page_count):
        output_path = output_folder / f"{base_filename}_page_{page_num + 1}.pdf"

        # Idempotent check: skip if already exists
        if output_path.exists():
            output_paths.append(output_path)
            pages_skipped += 1
            continue

        # Create new single-page PDF
        new_pdf = fitz.open()
        new_pdf.insert_pdf(pdf_document, from_page=page_num, to_page=page_num)
        new_pdf.save(str(output_path))
        new_pdf.close()

        output_paths.append(output_path)
        pages_created += 1

    pdf_document.close()

    if pages_skipped > 0:
        logger.debug(
            f"Split PDF: {pages_created} pages created, {pages_skipped} already existed"
        )

    return output_paths


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)