Skill: tsukisama9292/pdf-ocr-tool (version 1.3.0)

--- name: pdf-ocr-tool description: Intelligent PDF and image to Markdown converter using Ollama GLM-OCR with smart content detection (text/table/figure) metadata: {"openclaw":{"emoji":"📄","requires":{"bins":["uv","ollama","pdftoppm"],"anyBins":[],"env":[],"config":[]},"install":[{"id":"uv-env","kind":"uv","path":".","bins":["ocr_tool.py"]}]}} --- # PDF OCR Tool - Intelligent PDF to Markdown Converter Uses the Ollama GLM-OCR model to intelligently recognize text, tables, and figures in PDF pages, applying the most appropriate prompts for OCR processing and outputting structured Markdown documents. ## Features - ✅ **Smart Content Detection**: Automatically identifies page content type (text/table/figure) - ✅ **Mixed Mode**: Splits pages into multiple regions for processing different content types - ✅ **Multiple Processing Modes**: Supports text, table, figure, mixed, and auto modes - ✅ **PDF Page-by-Page Processing**: Converts PDF to images and processes each page - ✅ **Image OCR**: Supports OCR for single images - ✅ **Custom Prompts**: Adjustable OCR prompts based on requirements - ✅ **Flexible Configuration**: Customizable Ollama host, port, and model - ✅ **uv Package Management**: Uses uv for Python dependency management ## Installation ### 1. Prerequisites ```bash # Install Ollama curl -fsSL https://ollama.com/install.sh | sh ollama pull glm-ocr:q8_0 # Install poppler-utils (for PDF to image conversion) sudo apt install poppler-utils # Debian/Ubuntu brew install poppler # macOS # Install uv package manager curl -LsSf https://astral.sh/uv/install.sh | sh ``` ### 2. Install with uv (Recommended) ```bash cd skills/pdf-ocr-tool uv venv source .venv/bin/activate uv add requests Pillow ``` ### 3. Install via ClawHub ```bash npx clawhub install pdf-ocr-tool ``` ### 4. Manual Installation ```bash # Clone or download skill git clone <repo> ~/.openclaw/workspace/skills/pdf-ocr-tool # Create virtual environment and install dependencies cd ~/.openclaw/workspace/skills/pdf-ocr-tool uv venv source .venv/bin/activate uv add requests Pillow # Run post-install script bash hooks/post-install.sh ``` ## Usage ### Basic Usage ```bash # Auto-detect content type (recommended) python ocr_tool.py --input document.pdf --output result.md # Specify processing mode python ocr_tool.py --input document.pdf --output result.md --mode text python ocr_tool.py --input document.pdf --output result.md --mode table python ocr_tool.py --input document.pdf --output result.md --mode figure # Mixed mode: split page into regions python ocr_tool.py --input document.pdf --output result.md --granularity region # Process a single image python ocr_tool.py --input image.png --output result.md --mode mixed ``` ### Advanced Configuration ```bash # Specify Ollama host and port python ocr_tool.py --input document.pdf --output result.md \ --host localhost --port 11434 # Use different model python ocr_tool.py --input document.pdf --output result.md \ --model glm-ocr:q8_0 # Custom prompt python ocr_tool.py --input image.png --output result.md \ --prompt "Convert this table to Markdown format, keeping rows and columns aligned" # Save figure region images python ocr_tool.py --input document.pdf --output result.md --save-images ``` ### Environment Configuration ```bash # Set default configuration export OLLAMA_HOST="localhost" export OLLAMA_PORT="11434" export OCR_MODEL="glm-ocr:q8_0" # Run python ocr_tool.py --input document.pdf --output result.md ``` ## Processing Modes | Mode | Description | Use Case | |------|-------------|----------| | `auto` | Auto-detect content type | General use (default) | | `text` | Pure text recognition | Academic papers, articles, reports | | `table` | Table recognition | Data tables, financial reports | | `figure` | Chart/figure recognition | Statistical charts, flowcharts, diagrams | | `mixed` | Mixed mode | Pages with multiple content types | ### Mixed Mode (Granularity) When using `--granularity region`: - Page is split vertically into multiple regions (default: 3) - Each region is independently analyzed for content type - Corresponding prompts are used for OCR - Final results are combined into complete Markdown ## Output Format ### PDF Output Example ```markdown # PDF to Markdown Result **Total Pages**: 15 **Model**: glm-ocr:q8_0 **Mode**: auto **Generated**: 2026-02-27T01:00:00+08:00 --- ## Page 1 *Type: mixed* ### Region 1 (text) [OCR recognized text content] ### Region 2 (table) <table> <tr><th>Column 1</th><th>Column 2</th></tr> <tr><td>Data 1</td><td>Data 2</td></tr> </table> ### Region 3 (figure) [Chart description] ![Chart](./images/page_1_region_3.png) --- ``` ### Image Output Example ```markdown # image.png OCR Result Model: glm-ocr:q8_0 Mode: table --- [OCR recognized result] ``` ## Prompt Templates The tool includes four built-in prompt templates in the `prompts/` directory: ### Text Mode (`prompts/text.md`) ``` Convert the text in this region to Markdown format. - Preserve paragraph structure and heading levels - Handle lists correctly - Preserve mathematical formulas - Maintain citations and references ``` ### Table Mode (`prompts/table.md`) ``` Convert the table in this region to Markdown table format. - Maintain row and column alignment - Preserve all data and values - Handle merged cells - Preserve headers and units ``` ### Figure Mode (`prompts/figure.md`) ``` Analyze the chart or image in this region: 1. Chart type (bar, line, pie, flowchart, etc.) 2. Titles and axis labels 3. Data trends and key observations 4. Important values and anomalies Describe in Markdown format. ``` ## Using in OpenClaw ```python import subprocess from pathlib import Path # Process PDF (auto mode) subprocess.run([ "python", "skills/pdf-ocr-tool/ocr_tool.py", "--input", "/path/to/document.pdf", "--output", "/tmp/result.md", "--mode", "auto" ]) # Read result with open("/tmp/result.md", "r") as f: markdown_content = f.read() # Process single image (table mode) subprocess.run([ "python", "skills/pdf-ocr-tool/ocr_tool.py", "--input", "/path/to/table.png", "--output", "/tmp/table.md", "--mode", "table" ]) # Mixed mode for complex PDF subprocess.run([ "python", "skills/pdf-ocr-tool/ocr_tool.py", "--input", "/path/to/mixed.pdf", "--output", "/tmp/mixed.md", "--granularity", "region", # Split into regions "--save-images" # Save figure images ]) ``` ## Troubleshooting ### Model Not Installed ```bash ollama pull glm-ocr:q8_0 ``` ### Service Not Running ```bash ollama serve ``` ### Missing pdftoppm ```bash sudo apt install poppler-utils # Debian/Ubuntu brew install poppler # macOS ``` ### Poor OCR Results - Try different modes: `--mode text` or `--mode mixed` - Use custom prompts: `--prompt "your prompt here"` - Check image quality (resolution, clarity) - Try mixed mode: `--granularity region` ### Dependency Issues ```bash cd skills/pdf-ocr-tool source .venv/bin/activate uv sync # Reinstall all dependencies ``` ## Related Resources - [Ollama API Documentation](https://docs.ollama.com/api/generate) - [GLM-OCR Model Page](https://ollama.com/library/glm-ocr) - [poppler-utils](https://poppler.freedesktop.org/) - [uv Package Manager](https://github.com/astral-sh/uv) ## Version History - **v1.2.0** - English prompts, install-deps.sh, fixed .gitignore - **v1.1.0** - Added mixed mode, region splitting, pyproject.toml - **v1.0.0** - Initial version with basic OCR functionality ## Credits This tool is developed and maintained by the OpenClaw community. ## License MIT License

path: README.md
content: # PDF OCR Tool

**Intelligent PDF and Image to Markdown Converter using Ollama GLM-OCR**

[![ClawHub](https://clawhub.ai/badge/pdf-ocr-tool)](https://clawhub.ai/skills/pdf-ocr-tool)
[![GitHub](https://img.shields.io/github/license/nala0222/pdf-ocr-tool)](https://github.com/nala0222/pdf-ocr-tool)

## Overview

PDF OCR Tool is an intelligent document conversion tool that uses the Ollama GLM-OCR model to convert PDFs and images into structured Markdown format. It automatically detects content types (text, tables, figures) and applies the most appropriate prompts for optimal OCR results.

## Features

- ✅ **Smart Content Detection**: Automatically identifies page content type (text/table/figure)
- ✅ **Mixed Mode**: Splits pages into multiple regions for processing different content types
- ✅ **Multiple Processing Modes**: Supports text, table, figure, mixed, and auto modes
- ✅ **PDF Page-by-Page Processing**: Converts PDF to images and processes each page
- ✅ **Image OCR**: Supports OCR for single images
- ✅ **Custom Prompts**: Adjustable OCR prompts based on requirements
- ✅ **Flexible Configuration**: Customizable Ollama host, port, and model
- ✅ **uv Package Management**: Uses uv for Python dependency management

## Installation

### Prerequisites

```bash
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
ollama pull glm-ocr:q8_0

# Install poppler-utils (for PDF to image conversion)
sudo apt install poppler-utils  # Debian/Ubuntu
brew install poppler            # macOS

# Install uv package manager
curl -LsSf https://astral.sh/uv/install.sh | sh
```

### Install via ClawHub (Recommended)

```bash
npx clawhub install pdf-ocr-tool
```

### Manual Installation

```bash
# Clone or download the skill
cd ~/.openclaw/workspace/skills/pdf-ocr-tool

# Create virtual environment and install dependencies
uv venv
source .venv/bin/activate
uv sync

# Run post-install script
bash hooks/post-install.sh
```

## Usage

### Basic Usage

```bash
# Auto-detect content type (recommended)
python ocr_tool.py --input document.pdf --output result.md

# Specify processing mode
python ocr_tool.py --input document.pdf --output result.md --mode text
python ocr_tool.py --input document.pdf --output result.md --mode table
python ocr_tool.py --input document.pdf --output result.md --mode figure

# Mixed mode: split page into regions
python ocr_tool.py --input document.pdf --output result.md --mode auto --granularity region

# Process a single image
python ocr_tool.py --input image.png --output result.md
```

### Advanced Configuration

```bash
# Specify Ollama host and port
python ocr_tool.py --input document.pdf --output result.md \
  --host localhost --port 11434

# Use different model
python ocr_tool.py --input document.pdf --output result.md \
  --model glm-ocr:q8_0

# Custom prompt
python ocr_tool.py --input image.png --output result.md \
  --prompt "Convert this table to Markdown format, keeping rows and columns aligned"

# Save figure region images
python ocr_tool.py --input document.pdf --output result.md --save-images
```

### Environment Variables

```bash
# Set default configuration
export OLLAMA_HOST="localhost"
export OLLAMA_PORT="11434"
export OCR_MODEL="glm-ocr:q8_0"

# Run
python ocr_tool.py --input document.pdf --output result.md
```

## Processing Modes

| Mode | Description | Use Case |
|------|-------------|----------|
| `auto` | Auto-detect content type | General use (default) |
| `text` | Pure text recognition | Academic papers, articles, reports |
| `table` | Table recognition | Data tables, financial reports |
| `figure` | Chart/figure recognition | Statistical charts, flowcharts, diagrams |
| `mixed` | Mixed mode | Pages with multiple content types |

### Mixed Mode (Granularity)

When using `--granularity region`:
- Page is split vertically into multiple regions (default: 3)
- Each region is independently analyzed for content type
- Corresponding prompts are used for OCR
- Final results are combined into complete Markdown

## Output Format

### PDF Output Example

```markdown
# PDF to Markdown Result
**Total Pages**: 15
**Model**: glm-ocr:q8_0
**Mode**: auto
**Generated**: 2026-02-27T01:00:00+08:00

---

## Page 1
*Type: mixed*

### Region 1 (text)
[OCR recognized text content]

### Region 2 (table)
<table>
<tr><th>Column 1</th><th>Column 2</th></tr>
<tr><td>Data 1</td><td>Data 2</td></tr>
</table>

### Region 3 (figure)
[Chart description]
![Chart](./images/page_1_region_3.png)

---
```

## Prompt Templates

The tool includes four built-in prompt templates in the `prompts/` directory:

- **text.md**: Text recognition prompts
- **table.md**: Table recognition prompts
- **figure.md**: Figure/chart analysis prompts
- **mixed.md**: Mixed content prompts

All prompts are in English for consistency and can be customized based on your needs.

## Troubleshooting

### Model Not Installed

```bash
ollama pull glm-ocr:q8_0
```

### Service Not Running

```bash
ollama serve
```

### Missing pdftoppm

```bash
sudo apt install poppler-utils  # Debian/Ubuntu
brew install poppler            # macOS
```

### Poor OCR Results

- Try different modes: `--mode text` or `--mode mixed`
- Use custom prompts: `--prompt "your prompt here"`
- Check image quality (resolution, clarity)
- Try mixed mode: `--granularity region`

### Dependency Issues

```bash
cd ~/.openclaw/workspace/skills/pdf-ocr-tool
source .venv/bin/activate
uv sync  # Reinstall all dependencies
```

## Project Structure

```
pdf-ocr-tool/
├── SKILL.md              # Skill definition
├── README.md             # This file
├── _meta.json            # ClawHub metadata
├── .clawhubignore        # Files to exclude from ClawHub
├── .gitignore            # Git ignore rules
├── pyproject.toml        # Python project config
├── uv.lock              # Dependency lock file
├── ocr_tool.py          # Main CLI entry point
├── analyzer.py          # Page analysis module
├── processor.py         # Region processing module
├── integrator.py        # Markdown integration module
├── prompts.py           # Prompt loader
├── prompts/             # Prompt templates
│   ├── text.md
│   ├── table.md
│   ├── figure.md
│   └── mixed.md
├── utils/               # Utility modules
│   ├── ollama_client.py
│   ├── image_utils.py
│   └── pdf_utils.py
├── hooks/               # Installation hooks
│   ├── post-install.sh
│   └── install-deps.sh
└── tests/               # Test suite
```

## Development

### Running Tests

```bash
cd ~/.openclaw/workspace/skills/pdf-ocr-tool
source .venv/bin/activate
pytest tests/ -v
```

### Building from Source

```bash
# Clone repository
git clone https://github.com/nala0222/pdf-ocr-tool.git
cd pdf-ocr-tool

# Install dependencies
uv venv
source .venv/bin/activate
uv sync
```

## License

MIT License - See LICENSE file for details.

## Credits

- **Ollama**: For the GLM-OCR model
- **OpenClaw**: For the skill framework
- **ClawHub**: For skill distribution

## Support

For issues and feature requests, please visit:
- GitHub: https://github.com/nala0222/pdf-ocr-tool
- ClawHub: https://clawhub.ai/skills/pdf-ocr-tool


path: _meta.json
content: {
  "ownerId": "tsukisama9292",
  "slug": "pdf-ocr-tool",
  "version": "1.3.0",
  "publishedAt": 1740585000,
  "description": "Intelligent PDF and image to Markdown converter using Ollama GLM-OCR with smart content detection for text, tables, and figures",
  "tags": ["ocr", "pdf", "markdown", "ollama", "ai", "document-conversion", "glm-ocr"]
}


path: analyzer.py
content: # Page Analyzer (進階版 - 混合內容處理)

"""頁面分析模組 - 支援混合內容的智能分割與識別"""

import os
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from enum import Enum

try:
    from .utils.ollama_client import OllamaClient
    from .utils.image_utils import get_image_size
except ImportError:
    from utils.ollama_client import OllamaClient
    from utils.image_utils import get_image_size


class RegionType(Enum):
    """區域類型列舉。"""
    TEXT = "text"
    TABLE = "table"
    FIGURE = "figure"
    MIXED = "mixed"


@dataclass
class BoundingBox:
    """邊界框資料結構。"""
    x: int
    y: int
    width: int
    height: int
    
    @property
    def area(self) -> int:
        """計算區域面積。"""
        return self.width * self.height
    
    def to_crop_tuple(self) -> Tuple[int, int, int, int]:
        """轉換為裁剪用的 tuple (left, upper, right, lower)。"""
        return (self.x, self.y, self.x + self.width, self.y + self.height)


@dataclass
class Region:
    """區域資料結構。"""
    bbox: BoundingBox
    region_type: RegionType
    confidence: float = 1.0
    content: Optional[str] = None
    image_path: Optional[str] = None
    page_number: int = 0
    
    def to_dict(self) -> dict:
        """轉換為字典。"""
        return {
            "bbox": {
                "x": self.bbox.x,
                "y": self.bbox.y,
                "width": self.bbox.width,
                "height": self.bbox.height,
            },
            "region_type": self.region_type.value,
            "confidence": self.confidence,
            "content": self.content,
            "image_path": self.image_path,
            "page_number": self.page_number,
        }


@dataclass
class PageAnalysis:
    """頁面分析結果資料結構。"""
    page_number: int
    image_path: str
    regions: List[Region] = field(default_factory=list)
    full_text: Optional[str] = None
    detected_type: Optional[str] = None  # 主要類型（text/table/figure/mixed）
    
    def to_dict(self) -> dict:
        """轉換為字典。"""
        return {
            "page_number": self.page_number,
            "image_path": self.image_path,
            "regions": [r.to_dict() for r in self.regions],
            "full_text": self.full_text,
            "detected_type": self.detected_type,
        }


class PageAnalyzer:
    """
    頁面分析器 - 支援混合內容的智能分割與識別
    
    策略：
    1. 如果頁面被檢測為 "mixed" 或指定使用混合模式，將頁面垂直分割成多個區塊
    2. 對每個區塊進行類型識別
    3. 分別處理每個區塊
    """
    
    def __init__(
        self,
        ollama_client=None,
        min_region_height: int = 100,
        detection_model: str = "glm-ocr:q8_0",
        split_strategy: str = "fixed"  # fixed: 固定分割, adaptive: 自適應分割
    ):
        """
        初始化頁面分析器。
        
        Args:
            ollama_client: Ollama 客戶端
            min_region_height: 最小區域高度（像素）
            detection_model: 用於檢測的模型
            split_strategy: 分割策略（fixed/adaptive）
        """
        self.ollama_client = ollama_client
        self.min_region_height = min_region_height
        self.detection_model = detection_model
        self.split_strategy = split_strategy
    
    def analyze_page(
        self,
        image_path: str,
        page_number: int = 0,
        auto_detect: bool = True,
        mixed_mode: bool = True,
        num_regions: int = 3  # 混合模式下的分割數量
    ) -> PageAnalysis:
        """
        分析頁面，識別其中的文字、表格、圖表區域。
        
        Args:
            image_path: 頁面圖片路徑
            page_number: 頁碼
            auto_detect: 是否自動檢測
            mixed_mode: 是否啟用混合模式（將頁面分割成多個區域）
            num_regions: 混合模式下分割的區域數量
            
        Returns:
            頁面分析結果
        """
        # 取得圖片尺寸
        width, height = get_image_size(image_path)
        
        if not auto_detect:
            # 不進行自動檢測，將整頁視為混合內容
            region = Region(
                bbox=BoundingBox(0, 0, width, height),
                region_type=RegionType.MIXED,
                confidence=1.0,
                page_number=page_number
            )
            return PageAnalysis(
                page_number=page_number,
                image_path=image_path,
                regions=[region]
            )
        
        # 使用 AI 檢測頁面主要類型
        detected_type = self.detect_page_type(image_path)
        
        # 如果是混合類型或強制使用混合模式
        if detected_type == "mixed" or (mixed_mode and detected_type in ["text", "table", "figure"]):
            # 將頁面分割成多個區域
            regions = self.split_page_into_regions(
                image_path, page_number, width, height, 
                num_regions=num_regions
            )
            detected_type = "mixed"
        else:
            # 單一類型：整頁作為一個區域
            if detected_type == "table":
                region_type = RegionType.TABLE
            elif detected_type == "figure":
                region_type = RegionType.FIGURE
            else:
                region_type = RegionType.TEXT
            
            region = Region(
                bbox=BoundingBox(0, 0, width, height),
                region_type=region_type,
                confidence=0.8,
                page_number=page_number
            )
            regions = [region]
        
        return PageAnalysis(
            page_number=page_number,
            image_path=image_path,
            regions=regions,
            detected_type=detected_type
        )
    
    def detect_page_type(self, image_path: str) -> str:
        """
        檢測頁面的主要內容類型。
        
        Returns:
            檢測到的類型（text/table/figure/mixed）
        """
        prompt = (
            "分析這個頁面的主要內容類型。請回答：\n"
            "- 'text' 如果主要是純文字段落\n"
            "- 'table' 如果主要是表格數據\n"
            "- 'figure' 如果主要是圖表、圖像或視覺元素\n"
            "- 'mixed' 如果同時包含文字、表格和圖表\n"
            "只回答 text、table、figure 或 mixed："
        )
        
        try:
            response = self.ollama_client.generate(
                prompt=prompt,
                image_path=image_path,
                model=self.detection_model
            )
            
            if response:
                response_lower = response.lower()
                if "mixed" in response_lower:
                    return "mixed"
                elif "table" in response_lower:
                    return "table"
                elif "figure" in response_lower or "chart" in response_lower or "graph" in response_lower:
                    return "figure"
                else:
                    return "text"
        except Exception as e:
            print(f"⚠️ 頁面類型檢測失敗：{e}")
        
        # 預設返回 text
        return "text"
    
    def split_page_into_regions(
        self,
        image_path: str,
        page_number: int,
        width: int,
        height: int,
        num_regions: int = 3
    ) -> List[Region]:
        """
        將頁面分割成多個區域（按垂直方向切割）。
        
        Args:
            image_path: 頁面圖片路徑
            page_number: 頁碼
            width: 圖片寬度
            height: 圖片高度
            num_regions: 分割的區域數量
            
        Returns:
            區域列表
        """
        regions = []
        
        # 計算每個區域的高度
        region_height = height // num_regions
        
        for i in range(num_regions):
            y_start = i * region_height
            # 最後一個區域包含剩餘所有高度
            y_end = height if i == num_regions - 1 else (i + 1) * region_height
            
            bbox = BoundingBox(
                x=0,
                y=y_start,
                width=width,
                height=y_end - y_start
            )
            
            # 檢測這個區域的類型
            region_type = self.detect_region_type(image_path, bbox)
            
            region = Region(
                bbox=bbox,
                region_type=region_type,
                confidence=0.7,
                page_number=page_number
            )
            regions.append(region)
        
        return regions
    
    def detect_region_type(
        self,
        image_path: str,
        bbox: BoundingBox
    ) -> RegionType:
        """
        檢測指定區域名稱的類型。
        
        Args:
            image_path: 頁面圖片路徑
            bbox: 邊界框
            
        Returns:
            區域類型
        """
        # 裁剪出該區域
        try:
            from PIL import Image
            with Image.open(image_path) as img:
                # 計算裁剪區域
                left = bbox.x
                upper = bbox.y
                right = min(bbox.x + bbox.width, img.width)
                lower = min(bbox.y + bbox.height, img.height)
                
                cropped = img.crop((left, upper, right, lower))
                
                # 保存到臨時檔案
                import tempfile
                with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
                    cropped.save(tmp.name)
                    temp_path = tmp.name
            
            # 對該區域進行類型檢測
            prompt = (
                "分析這個區域的主要內容。只回答一個單詞：\n"
                "- 'text' 如果主要是純文字\n"
                "- 'table' 如果主要是表格\n"
                "- 'figure' 如果主要是圖表或圖片\n"
                "只回答 text、table 或 figure："
            )
            
            response = self.ollama_client.generate(
                prompt=prompt,
                image_path=temp_path,
                model=self.detection_model
            )
            
            # 清理臨時檔案
            os.unlink(temp_path)
            
            if response:
                response_lower = response.lower()
                if "table" in response_lower:
                    return RegionType.TABLE
                elif "figure" in response_lower or "chart" in response_lower:
                    return RegionType.FIGURE
                else:
                    return RegionType.TEXT
        
        except Exception as e:
            print(f"⚠️ 區域類型檢測失敗：{e}")
            return RegionType.TEXT
        
        return RegionType.TEXT


path: hooks/install-deps.sh
content: #!/bin/bash
# install-deps.sh - Install Python dependencies for pdf-ocr-tool skill
# Priority: Local copy > GitHub raw URL

set -e

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SKILL_NAME="pdf-ocr-tool"
GITHUB_USER="nala0222"
GITHUB_REPO="pdf-ocr-tool"
GITHUB_BRANCH="master"

echo "📦 Installing dependencies for ${SKILL_NAME}..."

# Function to copy file from local or GitHub
copy_file() {
    local file=$1
    local local_path="${SKILL_DIR}/${file}"
    local github_url="https://raw.githubusercontent.com/${GITHUB_USER}/${GITHUB_REPO}/refs/heads/${GITHUB_BRANCH}/${file}"
    
    if [ -f "${local_path}" ]; then
        echo "✅ Found local ${file}"
        cp "${local_path}" "${SKILL_DIR}/.tmp_${file}"
        return 0
    else
        echo "⚠️  Local ${file} not found, trying GitHub..."
        if curl -sLf "${github_url}" -o "${SKILL_DIR}/.tmp_${file}"; then
            echo "✅ Downloaded ${file} from GitHub"
            return 0
        else
            echo "❌ Failed to get ${file} from both local and GitHub"
            return 1
        fi
    fi
}

# Check if uv is installed
if ! command -v uv &> /dev/null; then
    echo "❌ uv is not installed. Please install uv first:"
    echo "   curl -LsSf https://astral.sh/uv/install.sh | sh"
    exit 1
}

# Navigate to skill directory
cd "${SKILL_DIR}"

# Copy pyproject.toml and uv.lock
echo "📋 Copying dependency files..."
copy_file "pyproject.toml" || exit 1
copy_file "uv.lock" || exit 1

# Create virtual environment if it doesn't exist
if [ ! -d ".venv" ]; then
    echo "🔧 Creating virtual environment..."
    uv venv
fi

# Install dependencies
echo "📥 Installing dependencies..."
source .venv/bin/activate
uv sync

# Clean up temporary files
rm -f .tmp_pyproject.toml .tmp_uv.lock

# Verify installation
echo ""
echo "✅ Dependencies installed successfully!"
echo ""
echo "Verifying installation..."
python3 -c "import requests; import PIL; print('✅ All packages imported successfully')"

echo ""
echo "🎉 ${SKILL_NAME} setup complete!"
echo ""
echo "Usage:"
echo "  # Auto-detect content type (recommended)"
echo "  python ocr_tool.py --input document.pdf --output result.md"
echo ""
echo "  # Specific mode"
echo "  python ocr_tool.py --input document.pdf --output result.md --mode text"
echo "  python ocr_tool.py --input document.pdf --output result.md --mode table"
echo "  python ocr_tool.py --input document.pdf --output result.md --mode figure"
echo ""
echo "  # Mixed mode (split page into regions)"
echo "  python ocr_tool.py --input document.pdf --output result.md --granularity region"
echo ""
echo "  # Custom configuration"
echo "  python ocr_tool.py --input image.png --output result.md --host localhost --port 11434 --model glm-ocr:q8_0"


path: hooks/post-install.sh
content: #!/bin/bash
# post-install hook for pdf-ocr-tool skill

set -e

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

echo "📦 pdf-ocr-tool: Setting up environment..."

# Check if Ollama is installed
if ! command -v ollama &> /dev/null; then
    echo "❌ ollama is not installed. Please install ollama first:"
    echo "   curl -fsSL https://ollama.com/install.sh | sh"
    exit 1
fi

# Check if pdftoppm is installed
if ! command -v pdftoppm &> /dev/null; then
    echo "⚠️  pdftoppm not found. Install poppler-utils for PDF support:"
    echo "   sudo apt install poppler-utils # Debian/Ubuntu"
    echo "   brew install poppler # macOS"
    echo "   (Skipping PDF support, image OCR will still work)"
fi

# Navigate to skill directory
cd "${SKILL_DIR}"

# Run the main installation script
if [ -f "${SKILL_DIR}/hooks/install-deps.sh" ]; then
    bash "${SKILL_DIR}/hooks/install-deps.sh"
else
    echo "❌ install-deps.sh not found!"
    exit 1
fi

# Check Ollama service
echo ""
echo "Checking Ollama service..."
if curl -s "http://localhost:11434/api/tags" > /dev/null 2>&1; then
    echo "✅ Ollama service is running"
    # Check if GLM-OCR model is installed
    if ollama list | grep -q "glm-ocr"; then
        echo "✅ GLM-OCR model found"
    else
        echo "⚠️  GLM-OCR model not found. Install with:"
        echo "   ollama pull glm-ocr:q8_0"
    fi
else
    echo "⚠️  Ollama service is not running. Start with:"
    echo "   ollama serve"
fi

echo ""
echo "✅ pdf-ocr-tool setup complete!"


path: integrator.py
content: # Integrator

"""整合輸出模組 - 將各區域的 OCR 結果整合成完整的 Markdown 文件"""

import os
import shutil
from pathlib import Path
from typing import List, Optional, Dict
from datetime import datetime

try:
    from .analyzer import PageAnalysis, Region, RegionType
except ImportError:
    from analyzer import PageAnalysis, Region, RegionType


class MarkdownIntegrator:
    """Markdown 整合器 - 將多個頁面/區域的結果整合成 Markdown"""
    
    def __init__(
        self,
        output_dir: str = "./output",
        save_images: bool = True,
        image_dir: str = "images"
    ):
        """
        初始化整合器。
        
        Args:
            output_dir: 輸出目錄
            save_images: 是否保存圖表區域的圖片
            image_dir: 圖片保存目錄
        """
        self.output_dir = Path(output_dir)
        self.save_images = save_images
        self.image_dir = self.output_dir / image_dir
        
        if save_images:
            self.image_dir.mkdir(parents=True, exist_ok=True)
    
    def integrate(
        self,
        analyses: List[PageAnalysis],
        output_path: str,
        metadata: Optional[Dict] = None
    ) -> str:
        """
        整合所有頁面分析結果，生成完整的 Markdown 文件。
        
        Args:
            analyses: 頁面分析結果列表
            output_path: 輸出的 Markdown 檔案路徑
            metadata: 額外的中繼資料
            
        Returns:
            生成的 Markdown 內容
        """
        lines = []
        
        # 文件頭
        lines.append("# PDF 轉 Markdown 結果\n")
        
        # 中繼資料
        lines.append(f"**總頁數**: {len(analyses)}")
        if metadata:
            if "model" in metadata:
                lines.append(f"**模型**: {metadata['model']}")
            if "mode" in metadata:
                lines.append(f"**模式**: {metadata['mode']}")
            if "source" in metadata:
                lines.append(f"**來源**: {metadata['source']}")
            if "granularity" in metadata:
                lines.append(f"**粒度**: {metadata['granularity']}")
        lines.append(f"**生成時間**: {datetime.now().isoformat()}")
        lines.append("")
        lines.append("---\n")
        
        # 逐頁處理
        for analysis in analyses:
            lines.append(f"## 第 {analysis.page_number} 頁\n")
            
            # 如果有檢測到主要類型，加入標籤
            if analysis.detected_type:
                lines.append(f"*類型：{analysis.detected_type}*\n")
            
            # 處理每個區域
            for i, region in enumerate(analysis.regions):
                if len(analysis.regions) > 1:
                    # 多區域模式：加入區域標題
                    region_label = f"### 區域 {i+1} ({region.region_type.value})\n"
                    lines.append(region_label)
                
                if region.content:
                    lines.append(region.content)
                    lines.append("")
                
                # 如果是圖表區域且需要保存圖片
                if region.region_type == RegionType.FIGURE and self.save_images:
                    if region.image_path or True:  # 有圖片路徑或標記
                        img_name = f"page_{analysis.page_number}_region_{i+1}.png"
                        img_path = self.image_dir / img_name
                        
                        # 標記圖片位置（實際圖片在 OCR 時已保存）
                        lines.append(f"![圖表](./{self.image_dir.name}/{img_name})\n")
            
            lines.append("---\n")
        
        # 組合所有內容
        markdown_content = "\n".join(lines)
        
        # 寫入檔案
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(markdown_content)
        
        return markdown_content
    
    def integrate_simple(
        self,
        analyses: List[PageAnalysis],
        output_path: str,
        metadata: Optional[Dict] = None
    ) -> str:
        """
        簡化版整合：只輸出文字內容，不處理圖片。
        """
        lines = []
        
        # 文件頭
        lines.append("# PDF 轉 Markdown 結果\n")
        lines.append(f"**總頁數**: {len(analyses)}\n")
        
        if metadata:
            for key, value in metadata.items():
                lines.append(f"**{key}**: {value}\n")
        
        lines.append(f"**生成時間**: {datetime.now().isoformat()}\n")
        lines.append("")
        lines.append("---\n")
        
        # 逐頁處理
        for analysis in analyses:
            lines.append(f"## 第 {analysis.page_number} 頁\n")
            
            if analysis.detected_type:
                lines.append(f"*類型：{analysis.detected_type}*\n")
            
            for region in analysis.regions:
                if region.content:
                    lines.append(region.content)
                    lines.append("")
            
            lines.append("---\n")
        
        markdown_content = "\n".join(lines)
        
        # 寫入檔案
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(markdown_content)
        
        return markdown_content


def assemble_markdown(
    analyses: List[PageAnalysis],
    output_path: str,
    metadata: Optional[Dict] = None,
    simple_mode: bool = False
) -> str:
    """
    便捷函數：將頁面分析結果組合成 Markdown 文件。
    
    Args:
        analyses: 頁面分析結果列表
        output_path: 輸出的 Markdown 檔案路徑
        metadata: 額外的中繼資料
        simple_mode: 是否使用簡化模式
        
    Returns:
        生成的 Markdown 內容
    """
    integrator = MarkdownIntegrator(save_images=not simple_mode)
    
    if simple_mode:
        return integrator.integrate_simple(analyses, output_path, metadata)
    else:
        return integrator.integrate(analyses, output_path, metadata)


path: ocr_tool.py
content: #!/usr/bin/env python3
"""
PDF 和圖片 OCR 處理工具（進階版）
使用 Ollama GLM-OCR 模型，根據內容類型（文字/表格/圖表）智能轉換 PDF 和圖片為 Markdown
"""

import argparse
import sys
import os
from pathlib import Path
from typing import Optional

# 將當前目錄加入 Python 路徑
SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))

from utils.ollama_client import OllamaClient
from utils.pdf_utils import pdf_to_images, get_pdf_page_count
from analyzer import PageAnalyzer, PageAnalysis, RegionType
from processor import RegionProcessor
from integrator import MarkdownIntegrator, assemble_markdown
from prompts import get_prompt


def check_environment(args) -> bool:
    """檢查環境需求。"""
    errors = []
    
    # 檢查 Ollama
    client = OllamaClient(host=args.host, port=args.port, model=args.model)
    ok, errs = client.check_status()
    if not ok:
        errors.extend(errs)
    
    # 檢查 pdftoppm
    if args.input.lower().endswith('.pdf'):
        try:
            import subprocess
            subprocess.run(["pdftoppm", "-h"], capture_output=True, check=True)
        except (subprocess.CalledProcessError, FileNotFoundError):
            errors.append("⚠️ pdftoppm 未安裝，請安裝 poppler-utils 以支援 PDF 處理")
    
    if errors:
        print("環境檢查警告：")
        for err in errors:
            print(f"  {err}")
        print("\n繼續執行可能失敗...\n")
    
    return len(errors) == 0


def process_pdf(
    pdf_path: str,
    output_path: str,
    mode: str = "auto",
    granularity: str = "page",
    host: str = "localhost",
    port: str = "11434",
    model: str = "glm-ocr:q8_0",
    save_images: bool = False,
    custom_prompt: Optional[str] = None
) -> bool:
    """處理 PDF 檔案。"""
    print(f"📄 處理 PDF: {Path(pdf_path).name}")
    print(f"   模式：{mode}")
    print(f"   粒度：{granularity}")
    print(f"   模型：{model}")
    
    # 轉換 PDF 為圖片
    try:
        images = pdf_to_images(pdf_path, output_prefix=Path(pdf_path).stem + "_page")
        print(f"   轉換為 {len(images)} 張圖片")
    except Exception as e:
        print(f"❌ PDF 轉換失敗：{e}")
        return False
    
    # 初始化組件
    client = OllamaClient(host=host, port=port, model=model)
    analyzer = PageAnalyzer(ollama_client=client)
    processor = RegionProcessor(ollama_client=client, save_cropped_images=save_images)
    integrator = MarkdownIntegrator(save_images=save_images)
    
    # 逐頁處理
    analyses = []
    for i, img_path in enumerate(images, 1):
        page_num = Path(img_path).stem.split("_")[-1] if "_" in Path(img_path).stem else str(i)
        print(f"   📝 第 {i}/{len(images)} 頁 ({page_num})...", end=" ")
        
        # 分析頁面
        auto_detect = (mode == "auto")
        analysis = analyzer.analyze_page(img_path, page_number=i, auto_detect=auto_detect)
        
        # 處理區域
        processor.process_page_analysis(analysis)
        
        # 保存結果
        analyses.append(analysis)
        
        # 清理臨時圖片
        try:
            os.remove(img_path)
        except:
            pass
        
        print("✅")
    
    # 整合輸出
    metadata = {
        "model": model,
        "mode": mode,
        "granularity": granularity,
        "source": Path(pdf_path).name
    }
    
    assemble_markdown(
        analyses,
        output_path,
        metadata=metadata,
        simple_mode=(granularity == "page")
    )
    
    print(f"\n✅ 完成！結果已保存至：{output_path}")
    print(f"📊 文件大小：{Path(output_path).stat().st_size / 1024:.1f} KB")
    
    return True


def process_image(
    image_path: str,
    output_path: str,
    mode: str = "mixed",
    host: str = "localhost",
    port: str = "11434",
    model: str = "glm-ocr:q8_0",
    custom_prompt: Optional[str] = None
) -> bool:
    """處理單一圖片。"""
    print(f"🖼️ 處理圖片：{Path(image_path).name}")
    print(f"   模式：{mode}")
    print(f"   模型：{model}")
    
    # 初始化組件
    client = OllamaClient(host=host, port=port, model=model)
    analyzer = PageAnalyzer(ollama_client=client)
    processor = RegionProcessor(ollama_client=client)
    integrator = MarkdownIntegrator(save_images=False)
    
    # 分析頁面
    auto_detect = (mode == "auto")
    analysis = analyzer.analyze_page(image_path, page_number=1, auto_detect=auto_detect)
    
    # 處理區域
    processor.process_page_analysis(analysis)
    
    # 整合輸出
    metadata = {
        "model": model,
        "mode": mode,
        "source": Path(image_path).name
    }
    
    assemble_markdown(
        [analysis],
        output_path,
        metadata=metadata,
        simple_mode=True
    )
    
    print(f"✅ 完成！結果已保存至：{output_path}")
    print(f"📊 文件大小：{Path(output_path).stat().st_size / 1024:.1f} KB")
    
    return True


def main():
    """主函數。"""
    parser = argparse.ArgumentParser(
        description="使用 Ollama GLM-OCR 將 PDF 或圖片轉換為 Markdown（智能分類處理）"
    )
    
    # 必要參數
    parser.add_argument("--input", "-i", required=True, help="輸入檔案路徑 (PDF 或圖片)")
    parser.add_argument("--output", "-o", required=True, help="輸出 Markdown 檔案路徑")
    
    # 模式選擇
    parser.add_argument(
        "--mode", "-m",
        choices=["text", "table", "figure", "mixed", "auto"],
        default="auto",
        help="識別模式：text(純文字)/table(表格)/figure(圖表)/mixed(混合)/auto(自動檢測)"
    )
    
    # 處理粒度
    parser.add_argument(
        "--granularity", "-g",
        choices=["page", "region", "block"],
        default="page",
        help="處理粒度：page(整頁)/region(區域)/block(區塊)"
    )
    
    # 模型配置
    parser.add_argument(
        "--model",
        default="glm-ocr:q8_0",
        help=f"使用的模型 (預設：glm-ocr:q8_0)"
    )
    parser.add_argument(
        "--host",
        default="localhost",
        help=f"Ollama 主機位置 (預設：localhost)"
    )
    parser.add_argument(
        "--port",
        default="11434",
        help=f"Ollama 端口 (預設：11434)"
    )
    
    # 其他選項
    parser.add_argument(
        "--prompt", "-p",
        help="自訂提示詞（會覆蓋預設提示詞）"
    )
    parser.add_argument(
        "--check", "-c",
        action="store_true",
        help="僅檢查環境需求"
    )
    parser.add_argument(
        "--save-images",
        action="store_true",
        help="保存圖表區域的圖片"
    )
    
    args = parser.parse_args()
    
    # 檢查環境
    if args.check:
        check_environment(args)
        sys.exit(0)
    
    check_environment(args)
    
    # 處理檔案
    input_path = Path(args.input)
    
    if not input_path.exists():
        print(f"❌ 輸入檔案不存在：{input_path}")
        sys.exit(1)
    
    if input_path.suffix.lower() in [".pdf"]:
        success = process_pdf(
            str(input_path),
            args.output,
            mode=args.mode,
            granularity=args.granularity,
            host=args.host,
            port=args.port,
            model=args.model,
            save_images=args.save_images,
            custom_prompt=args.prompt
        )
    elif input_path.suffix.lower() in [".png", ".jpg", ".jpeg", ".webp", ".gif"]:
        success = process_image(
            str(input_path),
            args.output,
            mode=args.mode,
            host=args.host,
            port=args.port,
            model=args.model,
            custom_prompt=args.prompt
        )
    else:
        print(f"❌ 不支援的檔案格式：{input_path.suffix}")
        print("支援的格式：PDF, PNG, JPG, JPEG, WEBP, GIF")
        sys.exit(1)
    
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


path: processor.py
content: # Region Processor
"""區域處理模組 - 負責對不同類型的區域進行 OCR 處理"""

import os
import tempfile
from pathlib import Path
from typing import Optional, List

try:
    from .analyzer import Region, RegionType, PageAnalysis, BoundingBox
    from .utils.ollama_client import OllamaClient
    from .prompts import get_prompt
except ImportError:
    from analyzer import Region, RegionType, PageAnalysis, BoundingBox
    from utils.ollama_client import OllamaClient
    from prompts import get_prompt

try:
    from PIL import Image
    PILLOW_AVAILABLE = True
except ImportError:
    PILLOW_AVAILABLE = False


class RegionProcessor:
    """區域處理器 - 對不同類型的區域執行 OCR"""
    
    def __init__(
        self,
        ollama_client=None,
        output_dir: str = "./output",
        save_cropped_images: bool = False
    ):
        """
        初始化區域處理器。
        
        Args:
            ollama_client: Ollama 客戶端
            output_dir: 輸出目錄
            save_cropped_images: 是否保存裁剪的圖片
        """
        self.ollama_client = ollama_client
        self.output_dir = Path(output_dir)
        self.save_cropped_images = save_cropped_images
    
    def process_region(
        self,
        region: Region,
        page_image_path: str,
        custom_prompt: Optional[str] = None
    ) -> str:
        """
        處理單個區域。
        
        Args:
            region: 區域物件
            page_image_path: 頁面圖片路徑
            custom_prompt: 自訂提示詞
        
        Returns:
            OCR 結果文字
        """
        # 根據區域類型選擇提示詞
        if custom_prompt:
            prompt = custom_prompt
        else:
            prompt = self._get_prompt_for_type(region.region_type)
        
        # 檢查是否需要裁剪
        if region.bbox.x == 0 and region.bbox.y == 0:
            width, height = self._get_image_size(page_image_path)
            if region.bbox.width == width and region.bbox.height == height:
                # 整頁，直接使用原圖
                result = self.ollama_client.ocr_image(
                    image_path=page_image_path,
                    prompt=prompt
                )
                region.content = result or "❌ OCR 失敗"
                return region.content
        
        # 需要裁剪區域
        cropped_path = self._crop_region(page_image_path, region.bbox)
        try:
            result = self.ollama_client.ocr_image(
                image_path=cropped_path,
                prompt=prompt
            )
            region.content = result or "❌ OCR 失敗"
        finally:
            # 清理臨時檔案
            if not self.save_cropped_images and cropped_path != page_image_path:
                try:
                    os.remove(cropped_path)
                except:
                    pass
        
        return region.content
    
    def _get_prompt_for_type(self, region_type: RegionType) -> str:
        """根據區域類型取得對應的提示詞。"""
        prompt_map = {
            RegionType.TEXT: get_prompt("text"),
            RegionType.TABLE: get_prompt("table"),
            RegionType.FIGURE: get_prompt("figure"),
            RegionType.MIXED: get_prompt("mixed"),
        }
        return prompt_map.get(region_type, get_prompt("mixed"))
    
    def _crop_region(
        self,
        image_path: str,
        bbox: BoundingBox,
        output_path: Optional[str] = None
    ) -> str:
        """裁剪指定區域。"""
        if not PILLOW_AVAILABLE:
            raise ImportError("Pillow 未安裝，請執行：uv pip install Pillow")
        
        # 生成臨時輸出路徑
        if output_path is None:
            output_dir = Path(image_path).parent
            output_path = str(output_dir / f"cropped_{Path(image_path).stem}.png")
        
        with Image.open(image_path) as img:
            # 轉換 bbox 為 crop 格式 (left, upper, right, lower)
            left = bbox.x
            upper = bbox.y
            right = bbox.x + bbox.width
            lower = bbox.y + bbox.height
            
            # 確保不超出邊界
            right = min(right, img.width)
            lower = min(lower, img.height)
            
            cropped = img.crop((left, upper, right, lower))
            cropped.save(output_path)
        
        return output_path
    
    def _get_image_size(self, image_path: str) -> tuple:
        """取得圖片尺寸。"""
        if PILLOW_AVAILABLE:
            with Image.open(image_path) as img:
                return img.width, img.height
        else:
            # Fallback：使用 utils
            try:
                from .utils.image_utils import get_image_size
                return get_image_size(image_path)
            except:
                return (0, 0)
    
    def process_page_analysis(self, analysis: PageAnalysis) -> PageAnalysis:
        """
        處理整個頁面的分析結果。
        
        Args:
            analysis: 頁面分析結果
        
        Returns:
            處理後的頁面分析結果
        """
        for region in analysis.regions:
            self.process_region(region, analysis.image_path)
        return analysis


path: prompts/figure.md
content: Analyze the chart or image in this region and describe it in Markdown format.

## Analysis Items

### 1. Chart Type
Identify and specify the chart type:
- Bar Chart
- Line Chart
- Pie Chart
- Scatter Plot
- Flowchart
- Architecture Diagram
- Other (please specify)

### 2. Titles and Labels
- Chart title
- Axis labels (X-axis, Y-axis)
- Legend description
- Units

### 3. Data Analysis
- Data trends (increasing, decreasing, fluctuating)
- Maximum and minimum values
- Anomalies or special observations
- Key data points

### 4. Interpretation and Insights
- Main message the chart conveys
- Meaning behind the data
- Relationship with other content

## Output Format
Use Markdown format including:
- Appropriate heading levels
- Lists and emphasis
- Tables if necessary for data organization
- Clear and structured descriptions

## Notes
- Describe objectively, avoid over-interpretation
- Pay attention to numerical units
- Preserve technical terms and terminology


path: prompts/mixed.md
content: Convert the entire page to Markdown format.

## Requirements
- Include all text, tables, and figure descriptions
- Represent tables using Markdown table syntax
- Describe charts/figures in detail with content and trends
- Maintain the original page structure and order
- Correctly identify boundaries of different regions

## Notes
- Pay attention to transitions between different regions
- Preserve the logical structure of the page
- If uncertain about something, label it honestly


path: prompts/table.md
content: Convert the table in this region to Markdown table format.

## Requirements
- Maintain row and column alignment
- Preserve all data and numerical values
- Handle merged cells (use appropriate Markdown syntax)
- If multiple tables exist, label them separately
- Preserve headers and units
- Correctly identify number formats (thousands separators, decimals, etc.)

## Format Specifications
- Use standard Markdown table syntax
- Alignment: numbers right-aligned, text left-aligned
- Preserve the original table's hierarchical structure

## Notes
- Do not omit any cells
- Pay attention to numerical units
- Preserve table titles and captions


path: prompts/text.md
content: Convert the text in this region to Markdown format.

## Requirements
- Preserve paragraph structure and heading levels (#, ##, ###)
- Handle lists correctly (- or 1.)
- Preserve mathematical formulas (use $ or $$)
- Maintain citations and reference format
- Preserve code blocks (use ```)
- Pay attention to special symbols and formatting

## Notes
- Do not omit any text content
- Maintain original heading hierarchy
- Correctly identify technical terms and proper nouns
- Preserve the original language (do not translate)


path: prompts.py
content: # Prompts Loader
"""Load prompt templates from markdown files."""

from pathlib import Path
from typing import Optional

# Prompt cache (loaded on first access)
_prompts_cache = {}

def get_prompt(name: str, prompts_dir: Optional[Path] = None) -> str:
    """
    Load a prompt template from markdown file.
    
    Args:
        name: Prompt name (text, table, figure, mixed)
        prompts_dir: Custom prompts directory path (default: prompts/ in same directory)
    
    Returns:
        Prompt content as string
    """
    # Check cache first
    if name in _prompts_cache:
        return _prompts_cache[name]
    
    # Determine prompts directory
    if prompts_dir is None:
        prompts_dir = Path(__file__).parent / "prompts"
    
    prompt_file = prompts_dir / f"{name}.md"
    
    if prompt_file.exists():
        content = prompt_file.read_text(encoding="utf-8")
        _prompts_cache[name] = content
        return content
    
    # Fallback to default prompts
    return _get_default_prompt(name)

def _get_default_prompt(name: str) -> str:
    """Get built-in default prompt if markdown file not found."""
    prompts = {
        "text": (
            "Convert the text in this region to Markdown format.\n\n"
            "## Requirements\n"
            "- Preserve paragraph structure and heading levels (#, ##, ###)\n"
            "- Handle lists correctly (- or 1.)\n"
            "- Preserve mathematical formulas (use $ or $$)\n"
            "- Maintain citations and reference format\n"
            "- Preserve code blocks (use ```)\n"
            "- Pay attention to special symbols and formatting\n\n"
            "## Notes\n"
            "- Do not omit any text content\n"
            "- Maintain original heading hierarchy\n"
            "- Correctly identify technical terms and proper nouns\n"
            "- Preserve the original language (do not translate)\n"
        ),
        "table": (
            "Convert the table in this region to Markdown table format.\n\n"
            "## Requirements\n"
            "- Maintain row and column alignment\n"
            "- Preserve all data and numerical values\n"
            "- Handle merged cells (use appropriate Markdown syntax)\n"
            "- If multiple tables exist, label them separately\n"
            "- Preserve headers and units\n"
            "- Correctly identify number formats (thousands separators, decimals, etc.)\n\n"
            "## Format Specifications\n"
            "- Use standard Markdown table syntax\n"
            "- Alignment: numbers right-aligned, text left-aligned\n"
            "- Preserve the original table's hierarchical structure\n\n"
            "## Notes\n"
            "- Do not omit any cells\n"
            "- Pay attention to numerical units\n"
            "- Preserve table titles and captions\n"
        ),
        "figure": (
            "Analyze the chart or image in this region and describe it in Markdown format.\n\n"
            "## Analysis Items\n\n"
            "### 1. Chart Type\n"
            "Identify and specify the chart type:\n"
            "- Bar Chart\n"
            "- Line Chart\n"
            "- Pie Chart\n"
            "- Scatter Plot\n"
            "- Flowchart\n"
            "- Architecture Diagram\n"
            "- Other (please specify)\n\n"
            "### 2. Titles and Labels\n"
            "- Chart title\n"
            "- Axis labels (X-axis, Y-axis)\n"
            "- Legend description\n"
            "- Units\n\n"
            "### 3. Data Analysis\n"
            "- Data trends (increasing, decreasing, fluctuating)\n"
            "- Maximum and minimum values\n"
            "- Anomalies or special observations\n"
            "- Key data points\n\n"
            "### 4. Interpretation and Insights\n"
            "- Main message the chart conveys\n"
            "- Meaning behind the data\n"
            "- Relationship with other content\n\n"
            "## Output Format\n"
            "Use Markdown format including:\n"
            "- Appropriate heading levels\n"
            "- Lists and emphasis\n"
            "- Tables if necessary for data organization\n"
            "- Clear and structured descriptions\n\n"
            "## Notes\n"
            "- Describe objectively, avoid over-interpretation\n"
            "- Pay attention to numerical units\n"
            "- Preserve technical terms and terminology\n"
        ),
        "mixed": (
            "Convert the entire page to Markdown format.\n\n"
            "## Requirements\n"
            "- Include all text, tables, and figure descriptions\n"
            "- Represent tables using Markdown table syntax\n"
            "- Describe charts/figures in detail with content and trends\n"
            "- Maintain the original page structure and order\n"
            "- Correctly identify boundaries of different regions\n\n"
            "## Notes\n"
            "- Pay attention to transitions between different regions\n"
            "- Preserve the logical structure of the page\n"
            "- If uncertain about something, label it honestly\n"
        ),
    }
    return prompts.get(name, "")

# Convenience functions for direct import
def TEXT_PROMPT(): return get_prompt("text")
def TABLE_PROMPT(): return get_prompt("table")
def FIGURE_PROMPT(): return get_prompt("figure")
def MIXED_PROMPT(): return get_prompt("mixed")

__all__ = ["get_prompt", "TEXT_PROMPT", "TABLE_PROMPT", "FIGURE_PROMPT", "MIXED_PROMPT"]


path: pyproject.toml
content: [project]
name = "pdf-ocr-tool"
version = "1.1.0"
description = "智能 PDF OCR 工具 - 使用 Ollama GLM-OCR 模型，根據內容類型（文字/表格/圖表）智能轉換 PDF 和圖片為 Markdown"
readme = "SKILL.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
    {name = "OpenClaw Community", email = "[REDACTED_SECRET]"}
]
keywords = [
    "ocr",
    "pdf",
    "markdown",
    "ollama",
    "glm-ocr",
    "document-conversion",
    "ai"
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Environment :: Console",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: Scientific/Engineering :: Artificial Intelligence",
    "Topic :: Text Processing",
]

dependencies = [
    "requests>=2.31.0",
    "pillow>=10.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "pytest-cov>=4.1.0",
]

[project.scripts]
pdf-ocr = "ocr_tool:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["."]

[tool.uv]
dev-dependencies = [
    "pytest>=7.4.0",
    "pytest-cov>=4.1.0",
]


path: tests/__init__.py
content: # Tests Module

"""測試模組初始化"""

__version__ = "1.1.0"


path: tests/test_analyzer.py
content: """
測試頁面分析模組
"""

import pytest
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from analyzer import PageAnalyzer, PageAnalysis, Region, RegionType, BoundingBox
from utils.ollama_client import OllamaClient


class TestBoundingBox:
    """測試 BoundingBox 資料結構"""
    
    def test_init(self):
        """測試初始化"""
        bbox = BoundingBox(x=10, y=20, width=100, height=200)
        assert bbox.x == 10
        assert bbox.y == 20
        assert bbox.width == 100
        assert bbox.height == 200
    
    def test_area(self):
        """測試面積計算"""
        bbox = BoundingBox(x=0, y=0, width=10, height=20)
        assert bbox.area == 200
    
    def test_to_crop_tuple(self):
        """測試轉換為裁剪 tuple"""
        bbox = BoundingBox(x=10, y=20, width=100, height=200)
        result = bbox.to_crop_tuple()
        assert result == (10, 20, 110, 220)


class TestRegion:
    """測試 Region 資料結構"""
    
    def test_init(self):
        """測試初始化"""
        bbox = BoundingBox(0, 0, 100, 100)
        region = Region(bbox=bbox, region_type=RegionType.TEXT)
        
        assert region.bbox == bbox
        assert region.region_type == RegionType.TEXT
        assert region.confidence == 1.0
        assert region.content is None
    
    def test_to_dict(self):
        """測試轉換為字典"""
        bbox = BoundingBox(0, 0, 100, 100)
        region = Region(
            bbox=bbox,
            region_type=RegionType.TABLE,
            confidence=0.9,
            page_number=1
        )
        
        result = region.to_dict()
        assert "bbox" in result
        assert result["region_type"] == "table"
        assert result["confidence"] == 0.9
        assert result["page_number"] == 1


class TestPageAnalysis:
    """測試 PageAnalysis 資料結構"""
    
    def test_init(self):
        """測試初始化"""
        analysis = PageAnalysis(
            page_number=1,
            image_path="/test.png"
        )
        
        assert analysis.page_number == 1
        assert analysis.image_path == "/test.png"
        assert len(analysis.regions) == 0
        assert analysis.full_text is None
    
    def test_to_dict(self):
        """測試轉換為字典"""
        bbox = BoundingBox(0, 0, 100, 100)
        region = Region(bbox=bbox, region_type=RegionType.TEXT)
        
        analysis = PageAnalysis(
            page_number=1,
            image_path="/test.png",
            regions=[region],
            detected_type="text"
        )
        
        result = analysis.to_dict()
        assert result["page_number"] == 1
        assert len(result["regions"]) == 1
        assert result["detected_type"] == "text"


class TestPageAnalyzer:
    """測試 PageAnalyzer 類別"""
    
    def test_init_default(self):
        """測試預設初始化"""
        analyzer = PageAnalyzer()
        assert analyzer.min_region_height == 100
        assert analyzer.detection_model == "glm-ocr:q8_0"
    
    def test_init_custom(self):
        """測試自訂初始化"""
        client = OllamaClient()
        analyzer = PageAnalyzer(
            ollama_client=client,
            min_region_height=200,
            detection_model="test-model"
        )
        assert analyzer.ollama_client == client
        assert analyzer.min_region_height == 200
        assert analyzer.detection_model == "test-model"
    
    def test_analyze_page_no_auto_detect(self, tmp_path):
        """測試不自動檢測模式"""
        # 建立真實的測試圖片（1x1 像素）
        from PIL import Image
        test_img = tmp_path / "test.png"
        img = Image.new('RGB', (1, 1), color='red')
        img.save(str(test_img))
        
        analyzer = PageAnalyzer()
        analysis = analyzer.analyze_page(
            str(test_img),
            page_number=1,
            auto_detect=False
        )
        
        assert analysis.page_number == 1
        assert len(analysis.regions) == 1
        assert analysis.regions[0].region_type == RegionType.MIXED


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_integration.py
content: """
端到端整合測試
"""

import pytest
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))


class TestCLI:
    """測試 CLI 功能"""
    
    def test_help_message(self):
        """測試幫助訊息"""
        result = subprocess.run(
            [sys.executable, "-m", "ocr_tool", "--help"],
            capture_output=True,
            text=True,
            cwd=Path(__file__).parent.parent
        )
        # 應該有幫助訊息
        assert "usage" in result.stdout.lower() or "usage" in result.stderr.lower()
    
    def test_check_environment(self, tmp_path):
        """測試環境檢查"""
        output_md = tmp_path / "test_output.md"
        
        result = subprocess.run(
            [
                sys.executable, "-m", "ocr_tool",
                "--check",
                "--input", str(tmp_path / "dummy.pdf"),
                "--output", str(output_md)
            ],
            capture_output=True,
            text=True,
            cwd=Path(__file__).parent.parent
        )
        
        # 環境檢查應該執行（不管成功與否）
        # 如果有缺少依賴，應該有警告
        assert result.returncode in [0, 1]


class TestPrompts:
    """測試提示詞模組"""
    
    def test_load_text_prompt(self):
        """測試加載文字提示詞"""
        from prompts import load_prompt, TEXT_PROMPT
        
        prompt = load_prompt("text")
        assert isinstance(prompt, str)
        assert len(prompt) > 0
    
    def test_load_table_prompt(self):
        """測試加載表格提示詞"""
        from prompts import load_prompt, TABLE_PROMPT
        
        prompt = load_prompt("table")
        assert isinstance(prompt, str)
        assert len(prompt) > 0
    
    def test_load_figure_prompt(self):
        """測試加載圖表提示詞"""
        from prompts import load_prompt, FIGURE_PROMPT
        
        prompt = load_prompt("figure")
        assert isinstance(prompt, str)
        assert len(prompt) > 0
    
    def test_default_prompts(self):
        """測試預設提示詞"""
        from prompts import TEXT_PROMPT, TABLE_PROMPT, FIGURE_PROMPT, MIXED_PROMPT
        
        assert "markdown" in TEXT_PROMPT.lower()
        assert "table" in TABLE_PROMPT.lower() or "表格" in TABLE_PROMPT
        assert "figure" in FIGURE_PROMPT.lower() or "圖表" in FIGURE_PROMPT


class TestIntegration:
    """整合測試"""
    
    def test_import_all_modules(self):
        """測試所有模組可導入"""
        try:
            from utils.ollama_client import OllamaClient
            from utils.image_utils import crop_image, save_image
            from utils.pdf_utils import pdf_to_images
            from analyzer import PageAnalyzer, PageAnalysis
            from processor import RegionProcessor
            from integrator import MarkdownIntegrator
            from prompts import TEXT_PROMPT, TABLE_PROMPT
            assert True
        except ImportError as e:
            pytest.fail(f"模組導入失敗：{e}")


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_ollama_client.py
content: """
測試 Ollama 客戶端模組
"""

import pytest
import sys
from pathlib import Path

# 加入上層目錄到路徑
sys.path.insert(0, str(Path(__file__).parent.parent))

from utils.ollama_client import OllamaClient


class TestOllamaClient:
    """測試 OllamaClient 類別"""
    
    def test_init_default(self):
        """測試預設初始化"""
        client = OllamaClient()
        assert client.host == "localhost"
        assert client.port == "11434"
        assert client.model == "glm-ocr:q8_0"
        assert client.base_url == "http://localhost:11434"
    
    def test_init_custom(self):
        """測試自訂初始化"""
        client = OllamaClient(
            host="192.168.1.100",
            port="11435",
            model="test-model",
            timeout=60,
            max_retries=5
        )
        assert client.host == "192.168.1.100"
        assert client.port == "11435"
        assert client.model == "test-model"
        assert client.timeout == 60
        assert client.max_retries == 5
        assert client.base_url == "http://192.168.1.100:11435"
    
    def test_check_status(self):
        """測試服務狀態檢查（需要 Ollama 服務運行）"""
        client = OllamaClient()
        ok, errors = client.check_status()
        
        # 如果有 Ollama 服務運行，應該成功
        if ok:
            assert len(errors) == 0
        else:
            assert len(errors) > 0
    
    def test_encode_image_to_base64(self, tmp_path):
        """測試圖片編碼"""
        # 建立測試圖片
        test_img = tmp_path / "test.png"
        test_img.write_bytes(b"fake_image_data")
        
        result = OllamaClient.encode_image_to_base64(str(test_img))
        assert isinstance(result, str)
        assert len(result) > 0
    
    def test_generate_no_image(self):
        """測試不含圖片的生成（需要 Ollama 服務）"""
        client = OllamaClient()
        # 測試基本生成（不含圖片）
        result = client.generate(
            prompt="Say hello",
            stream=False
        )
        # 如果有服務，應該有回應
        # 如果沒服務，會是錯誤訊息
        assert isinstance(result, (str, type(None)))


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_stress.py
content: """
壓力測試與邊界條件測試
用於找出潛在問題
"""

import pytest
import sys
from pathlib import Path
from PIL import Image

sys.path.insert(0, str(Path(__file__).parent.parent))

from analyzer import PageAnalyzer, RegionType, BoundingBox
from processor import RegionProcessor
from utils.ollama_client import OllamaClient


class TestStress:
    """壓力測試"""
    
    def test_large_image(self, tmp_path):
        """測試大圖片處理"""
        # 建立大圖片（1000x1000）
        test_img = tmp_path / "large.png"
        img = Image.new('RGB', (1000, 1000), color='blue')
        img.save(str(test_img))
        
        analyzer = PageAnalyzer()
        # 應該能正常分析
        analysis = analyzer.analyze_page(
            str(test_img),
            page_number=1,
            auto_detect=False  # 避免實際调用 Ollama
        )
        
        assert analysis.page_number == 1
        assert len(analysis.regions) == 1
    
    def test_very_small_image(self, tmp_path):
        """測試極小圖片處理"""
        # 建立 1x1 像素的圖片
        test_img = tmp_path / "tiny.png"
        img = Image.new('RGB', (1, 1), color='red')
        img.save(str(test_img))
        
        analyzer = PageAnalyzer()
        analysis = analyzer.analyze_page(
            str(test_img),
            page_number=1,
            auto_detect=False
        )
        
        assert analysis.page_number == 1
    
    def test_many_regions(self, tmp_path):
        """測試多區域分割"""
        test_img = tmp_path / "test.png"
        img = Image.new('RGB', (300, 900), color='green')  # 300x900，分成 3 個 300x300
        img.save(str(test_img))
        
        analyzer = PageAnalyzer()
        # 強制分成 5 個區域
        analysis = analyzer.analyze_page(
            str(test_img),
            page_number=1,
            auto_detect=True,
            mixed_mode=True,
            num_regions=5
        )
        
        # 應該有 5 個區域
        assert len(analysis.regions) == 5
        
        # 檢查每個區域的高度應該是 180 (900/5)
        for region in analysis.regions:
            assert region.bbox.height == 180


class TestEdgeCases:
    """邊界條件測試"""
    
    def test_empty_bbox(self):
        """測試空邊界框"""
        bbox = BoundingBox(0, 0, 0, 0)
        assert bbox.area == 0
        assert bbox.to_crop_tuple() == (0, 0, 0, 0)
    
    def test_negative_coordinates(self, tmp_path):
        """測試負坐標（應該被處理）"""
        test_img = tmp_path / "test.png"
        img = Image.new('RGB', (100, 100), color='white')
        img.save(str(test_img))
        
        # 建立有負坐標的區域（不應該發生，但要測試容錯）
        bbox = BoundingBox(-10, -10, 100, 100)
        
        analyzer = PageAnalyzer()
        # 應該能處理，不會崩潰
        region = analyzer.detect_region_type(str(test_img), bbox)
        assert region in [RegionType.TEXT, RegionType.TABLE, RegionType.FIGURE]
    
    def test_very_large_bbox(self, tmp_path):
        """測試超大邊界框"""
        test_img = tmp_path / "test.png"
        img = Image.new('RGB', (100, 100), color='white')
        img.save(str(test_img))
        
        # 建立超出圖片的邊界框
        bbox = BoundingBox(0, 0, 10000, 10000)
        
        analyzer = PageAnalyzer()
        # 應該能處理，不會崩潰
        region = analyzer.detect_region_type(str(test_img), bbox)
        assert region in [RegionType.TEXT, RegionType.TABLE, RegionType.FIGURE]


class TestPromptLoading:
    """測試提示詞加載"""
    
    def test_all_prompts_exist(self):
        """測試所有提示詞都存在"""
        from prompts import TEXT_PROMPT, TABLE_PROMPT, FIGURE_PROMPT, MIXED_PROMPT
        
        assert TEXT_PROMPT is not None
        assert TABLE_PROMPT is not None
        assert FIGURE_PROMPT is not None
        assert MIXED_PROMPT is not None
        
        assert len(TEXT_PROMPT) > 0
        assert len(TABLE_PROMPT) > 0
        assert len(FIGURE_PROMPT) > 0
        assert len(MIXED_PROMPT) > 0
    
    def test_prompt_content(self):
        """測試提示詞內容"""
        from prompts import load_prompt
        
        # 測試加載所有提示詞
        for name in ["text", "table", "figure", "mixed"]:
            prompt = load_prompt(name)
            assert isinstance(prompt, str)
            assert len(prompt) > 0


class TestIntegration:
    """整合測試"""
    
    def test_full_pipeline_mock(self, tmp_path):
        """測試完整流程（使用假圖片，不調用 Ollama）"""
        # 建立測試圖片
        test_img = tmp_path / "test.png"
        img = Image.new('RGB', (200, 600), color='gray')
        img.save(str(test_img))
        
        # 1. 分析
        analyzer = PageAnalyzer()
        analysis = analyzer.analyze_page(
            str(test_img),
            page_number=1,
            auto_detect=False  # 不調用 Ollama
        )
        
        assert analysis.page_number == 1
        assert len(analysis.regions) == 1
        
        # 2. 處理（這裡不實際調用 OCR，只測試流程）
        # processor 需要 Ollama client，所以跳過實際 OCR
        
        # 3. 整合
        from integrator import MarkdownIntegrator
        integrator = MarkdownIntegrator(save_images=False)
        
        # 手動設置內容以測試整合
        analysis.regions[0].content = "測試內容"
        analysis.regions[0].region_type = RegionType.TEXT
        
        output_md = tmp_path / "output.md"
        integrator.integrate([analysis], str(output_md), metadata={"test": True})
        
        # 檢查輸出
        assert output_md.exists()
        content = output_md.read_text()
        assert "測試內容" in content
        assert "PDF 轉 Markdown" in content


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: utils/__init__.py
content: # PDF OCR Tool Utils

"""工具模組，提供 Ollama 客戶端、圖片處理和 PDF 工具功能。"""

from .ollama_client import OllamaClient
from .image_utils import crop_image, save_image, encode_image_to_base64
from .pdf_utils import pdf_to_images, get_pdf_page_count

__all__ = [
    'OllamaClient',
    'crop_image',
    'save_image',
    'encode_image_to_base64',
    'pdf_to_images',
    'get_pdf_page_count',
]


path: utils/image_utils.py
content: # Image Utilities

"""圖片處理工具，提供裁剪、保存、編碼等功能。"""

import os
from pathlib import Path
from typing import Optional, Tuple

try:
    from PIL import Image
    PILLOW_AVAILABLE = True
except ImportError:
    PILLOW_AVAILABLE = False


def crop_image(
    image_path: str,
    bbox: Tuple[int, int, int, int],
    output_path: Optional[str] = None
) -> str:
    """
    裁剪圖片的指定區域。
    
    Args:
        image_path: 原始圖片路徑
        bbox: 邊界框 (x, y, width, height)
        output_path: 輸出圖片路徑（可選，預設為臨時檔案）
        
    Returns:
        裁剪後的圖片路徑
    """
    if not PILLOW_AVAILABLE:
        raise ImportError("Pillow 未安裝，請執行：uv pip install Pillow")
    
    x, y, width, height = bbox
    
    with Image.open(image_path) as img:
        # 計算裁剪區域（left, upper, right, lower）
        left = x
        upper = y
        right = x + width
        lower = y + height
        
        # 確保不超出圖片邊界
        right = min(right, img.width)
        lower = min(lower, img.height)
        
        cropped = img.crop((left, upper, right, lower))
        
        if output_path is None:
            # 生成臨時檔案路徑
            output_path = Path(image_path).parent / f"cropped_{Path(image_path).name}"
        
        cropped.save(output_path)
    
    return output_path


def save_image(
    image_path: str,
    output_path: str,
    format: Optional[str] = None,
    quality: int = 95
) -> str:
    """
    保存圖片，可轉換格式。
    
    Args:
        image_path: 原始圖片路徑
        output_path: 輸出圖片路徑
        format: 輸出格式（可選，預設從副檔名判斷）
        quality: 品質（1-100，僅對 JPEG 有效）
        
    Returns:
        輸出的圖片路徑
    """
    if not PILLOW_AVAILABLE:
        raise ImportError("Pillow 未安裝，請執行：uv pip install Pillow")
    
    with Image.open(image_path) as img:
        # 轉換格式（如果需要）
        if format:
            img.save(output_path, format=format.upper(), quality=quality)
        else:
            img.save(output_path, quality=quality)
    
    return output_path


def encode_image_to_base64(image_path: str) -> str:
    """
    將圖片編碼為 base64 字串。
    
    Args:
        image_path: 圖片路徑
        
    Returns:
        base64 編碼的字串
    """
    import base64
    with open(image_path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")


def get_image_size(image_path: str) -> Tuple[int, int]:
    """
    取得圖片尺寸。
    
    Args:
        image_path: 圖片路徑
        
    Returns:
        (寬度，高度)
    """
    if PILLOW_AVAILABLE:
        with Image.open(image_path) as img:
            return img.width, img.height
    else:
        # 使用 PIL 的基本功能（不需要完整安裝）
        import struct
        
        with open(image_path, "rb") as f:
            # PNG
            if f.read(8)[:8] == b'\x89PNG\r\n\x1a\n':
                f.seek(16)
                width = struct.unpack(">I", f.read(4))[0]
                height = struct.unpack(">I", f.read(4))[0]
                return width, height
            
            # JPEG
            f.seek(0)
            data = f.read()
            if data[:2] == b'\xff\xd8':
                import re
                matches = re.finditer(b'\xff\xc0(.{7})', data)
                for match in matches:
                    height, width = struct.unpack(">HH", match.group(1)[5:9])
                    return width, height
        
        raise ValueError("無法識別圖片格式")


def resize_image(
    image_path: str,
    max_width: Optional[int] = None,
    max_height: Optional[int] = None,
    output_path: Optional[str] = None
) -> str:
    """
    調整圖片大小。
    
    Args:
        image_path: 原始圖片路徑
        max_width: 最大寬度
        max_height: 最大高度
        output_path: 輸出路徑（可選）
        
    Returns:
        調整後的圖片路徑
    """
    if not PILLOW_AVAILABLE:
        raise ImportError("Pillow 未安裝，請執行：uv pip install Pillow")
    
    with Image.open(image_path) as img:
        width, height = img.size
        
        # 計算縮放比例
        scale = 1.0
        if max_width and width > max_width:
            scale = min(scale, max_width / width)
        if max_height and height > max_height:
            scale = min(scale, max_height / height)
        
        if scale < 1.0:
            new_size = (int(width * scale), int(height * scale))
            img = img.resize(new_size, Image.Resampling.LANCZOS)
        
        if output_path is None:
            output_path = Path(image_path).parent / f"resized_{Path(image_path).name}"
        
        img.save(output_path)
    
    return output_path


path: utils/ollama_client.py
content: # Ollama Client

"""Ollama API 客戶端，提供 OCR 識別功能。"""

import base64
import requests
import time
from typing import Optional, List
from pathlib import Path


class OllamaClient:
    """Ollama API 客戶端，支援超時、重試機制。"""
    
    def __init__(
        self,
        host: str = "localhost",
        port: str = "11434",
        model: str = "glm-ocr:q8_0",
        timeout: int = 120,
        max_retries: int = 3,
        retry_delay: float = 1.0
    ):
        """
        初始化 Ollama 客戶端。
        
        Args:
            host: Ollama 主機位置
            port: Ollama 端口
            model: 預設使用的模型
            timeout: 請求超時時間（秒）
            max_retries: 最大重試次數
            retry_delay: 重試延遲時間（秒）
        """
        self.host = host
        self.port = port
        self.model = model
        self.timeout = timeout
        self.max_retries = max_retries
        self.retry_delay = retry_delay
        self.base_url = f"http://{host}:{port}"
    
    def check_status(self) -> tuple[bool, List[str]]:
        """
        檢查 Ollama 服務狀態。
        
        Returns:
            (是否成功，錯誤訊息列表)
        """
        errors = []
        try:
            resp = requests.get(f"{self.base_url}/api/tags", timeout=5)
            if resp.status_code == 200:
                models = resp.json().get("models", [])
                model_names = [m["name"] for m in models]
                
                if not any(self.model.split(":")[0] in m for m in model_names):
                    errors.append(f"⚠️ 模型 {self.model} 未安裝")
                    errors.append(f"   執行：ollama pull {self.model}")
                
                return True, errors
            else:
                errors.append(f"❌ Ollama 服務異常：{resp.status_code}")
                return False, errors
        except Exception as e:
            errors.append(f"❌ 無法連接 Ollama 服務 ({self.base_url}): {e}")
            return False, errors
    
    @staticmethod
    def encode_image_to_base64(image_path: str) -> str:
        """
        將圖片編碼為 base64。
        
        Args:
            image_path: 圖片路徑
            
        Returns:
            base64 編碼的字串
        """
        with open(image_path, "rb") as f:
            return base64.b64encode(f.read()).decode("utf-8")
    
    def generate(
        self,
        prompt: str,
        image_path: Optional[str] = None,
        model: Optional[str] = None,
        stream: bool = False
    ) -> Optional[str]:
        """
        調用 Ollama API 生成回應。
        
        Args:
            prompt: 提示詞
            image_path: 圖片路徑（可選）
            model: 使用的模型（可選，預設為實例模型）
            stream: 是否使用流式傳輸
            
        Returns:
            生成的文字內容，失敗時返回 None
        """
        model = model or self.model
        
        # 準備請求負載
        payload = {
            "model": model,
            "prompt": prompt,
            "stream": stream,
        }
        
        # 如果有圖片，加入圖片數據
        if image_path:
            try:
                image_base64 = self.encode_image_to_base64(image_path)
                payload["images"] = [image_base64]
            except Exception as e:
                return f"❌ 無法讀取圖片：{e}"
        
        # 發送請求（含重試機制）
        last_error = None
        for attempt in range(self.max_retries):
            try:
                resp = requests.post(
                    f"{self.base_url}/api/generate",
                    json=payload,
                    timeout=self.timeout
                )
                
                if resp.status_code == 200:
                    result = resp.json()
                    if result.get("done"):
                        return result.get("response", "")
                    else:
                        return "❌ OCR 未完成"
                else:
                    last_error = f"API 錯誤：{resp.status_code} - {resp.text[:200]}"
                    
            except requests.exceptions.Timeout:
                last_error = f"請求超時（第 {attempt + 1}/{self.max_retries} 次）"
            except requests.exceptions.RequestException as e:
                last_error = f"請求失敗：{e}"
            except Exception as e:
                last_error = f"發生意外錯誤：{e}"
            
            # 重試延遲
            if attempt < self.max_retries - 1:
                time.sleep(self.retry_delay)
        
        return f"❌ {last_error}"
    
    def ocr_image(
        self,
        image_path: str,
        prompt: str,
        model: Optional[str] = None
    ) -> Optional[str]:
        """
        對圖片進行 OCR 識別。
        
        Args:
            image_path: 圖片路徑
            prompt: 提示詞
            model: 使用的模型（可選）
            
        Returns:
            OCR 識別結果
        """
        return self.generate(prompt=prompt, image_path=image_path, model=model)


path: utils/pdf_utils.py
content: # PDF Utilities

"""PDF 處理工具，提供 PDF 轉圖片、頁數計算等功能。"""

import os
import subprocess
import glob
from pathlib import Path
from typing import List, Optional


def pdf_to_images(
    pdf_path: str,
    output_prefix: Optional[str] = None,
    dpi: int = 150
) -> List[str]:
    """
    將 PDF 轉換為圖片序列。
    
    Args:
        pdf_path: PDF 檔案路徑
        output_prefix: 輸出圖片前綴名（預設為 PDF 檔名）
        dpi: 解析度（DPI）
        
    Returns:
        圖片檔案路徑列表
    """
    pdf_path = Path(pdf_path)
    
    if output_prefix is None:
        output_prefix = pdf_path.stem
    
    # 建立臨時輸出目錄
    output_dir = pdf_path.parent
    output_base = str(output_dir / output_prefix)
    
    try:
        # 使用 pdftoppm 轉換
        cmd = [
            "pdftoppm",
            "-png",
            "-r", str(dpi),
            str(pdf_path),
            output_base
        ]
        
        result = subprocess.run(
            cmd,
            check=True,
            capture_output=True,
            text=True
        )
        
        # 找出所有生成的圖片
        images = sorted(glob.glob(f"{output_base}*.png"))
        return images
        
    except subprocess.CalledProcessError as e:
        error_msg = f"PDF 轉換失敗：{e}"
        if e.stderr:
            error_msg += f"\n{e.stderr}"
        raise RuntimeError(error_msg)
    
    except FileNotFoundError:
        raise RuntimeError(
            "未找到 pdftoppm，請安裝 poppler-utils:\n"
            "  sudo apt install poppler-utils  # Debian/Ubuntu\n"
            "  brew install poppler  # macOS"
        )


def get_pdf_page_count(pdf_path: str) -> int:
    """
    取得 PDF 頁數。
    
    Args:
        pdf_path: PDF 檔案路徑
        
    Returns:
        頁數
    """
    try:
        # 使用 pdfinfo 取得頁數
        result = subprocess.run(
            ["pdfinfo", str(pdf_path)],
            capture_output=True,
            text=True
        )
        
        if result.returncode == 0:
            for line in result.stdout.split("\n"):
                if line.startswith("Pages:"):
                    return int(line.split(":")[1].strip())
        
        # 如果 pdfinfo 失敗，嘗試用 pdftoppm 間接計算
        images = pdf_to_images(pdf_path, output_prefix="temp_count_")
        # 清理臨時檔案
        for img in images:
            os.remove(img)
        return len(images)
        
    except FileNotFoundError:
        # 如果 pdfinfo 和 pdftoppm 都沒有，嘗試用 pdfplumber
        try:
            import pdfplumber
            with pdfplumber.open(pdf_path) as pdf:
                return len(pdf.pages)
        except:
            raise RuntimeError(
                "無法取得 PDF 頁數，請安裝 poppler-utils 或 pdfplumber"
            )


def get_pdf_info(pdf_path: str) -> dict:
    """
    取得 PDF 詳細資訊。
    
    Args:
        pdf_path: PDF 檔案路徑
        
    Returns:
        PDF 資訊字典
    """
    info = {
        "path": str(pdf_path),
        "pages": 0,
        "title": None,
        "author": None,
        "subject": None,
        "creator": None,
        "producer": None,
        "creation_date": None,
        "mod_date": None,
    }
    
    try:
        result = subprocess.run(
            ["pdfinfo", str(pdf_path)],
            capture_output=True,
            text=True
        )
        
        if result.returncode == 0:
            for line in result.stdout.split("\n"):
                if ":" in line:
                    key, value = line.split(":", 1)
                    key = key.strip().lower().replace(" ", "_")
                    value = value.strip()
                    
                    if key == "pages":
                        info["pages"] = int(value)
                    elif key in info:
                        info[key] = value
    except:
        pass
    
    return info


def is_pdf_valid(pdf_path: str) -> bool:
    """
    檢查 PDF 是否有效。
    
    Args:
        pdf_path: PDF 檔案路徑
        
    Returns:
        是否有效
    """
    try:
        # 嘗試取得頁數
        page_count = get_pdf_page_count(pdf_path)
        return page_count > 0
    except:
        return False


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