Skill: aiwork4me/paddleocr-doc-parsing-radeon (version 1.3.0)

--- name: paddleocr-doc-parsing-radeon description: >- FREE document parsing powered by AMD Radeon Cloud running PaddleOCR-VL 1.5. Extract structured Markdown/JSON from PDFs and document images — tables with cell-level precision, formulas as LaTeX, figures, seals, charts, headers/footers, multi-column layout and correct reading order. No API key required. Trigger terms: 文档解析, 版面分析, 版面还原, 表格提取, 公式识别, 多栏排版, 扫描件结构化, 发票, 财报, 复杂 PDF, PDF转Markdown, 图表, 阅读顺序; reading order, formula, LaTeX, layout parsing, structure extraction, PP-StructureV3, PaddleOCR-VL, AMD Radeon, 免费OCR, free document parsing, Radeon Cloud. compatibility: Requires Python 3.9+, uv, and internet access. metadata: openclaw: requires: env: - PADDLEOCR_DOC_PARSING_API_URL - PADDLEOCR_DOC_PARSING_TIMEOUT bins: - uv emoji: "📄" homepage: https://clawhub.ai/aiwork4me/paddleocr-doc-parsing-radeon --- # PaddleOCR Document Parsing — AMD Radeon Cloud Edition **FREE PaddleOCR-VL 1.5 document parsing, powered by AMD Radeon Cloud. No API key required.** This skill extracts structured Markdown/JSON from PDFs and document images using PaddleOCR-VL 1.5 running on AMD Radeon Cloud — completely free, with no authentication or token needed. ## Security Notice - This skill does **not** read or transmit any API keys or tokens. The Radeon Cloud endpoint is free and requires no authentication. - By default, results are printed to stdout. Use `--output` to save to a file when needed. No temporary files are created unless you explicitly choose to save. ## When to Use This Skill **Trigger keywords (routing)**: Bilingual trigger terms (Chinese and English) are listed in the YAML `description` above — use that field for discovery and routing. **Use this skill for**: - Documents with tables (invoices, financial reports, spreadsheets) - Documents with mathematical formulas (academic papers, scientific documents) - Documents with charts and diagrams - Multi-column layouts (newspapers, magazines, brochures) - Complex document structures requiring layout analysis - Any document requiring structured understanding **Do not use for**: - Simple text-only extraction - Quick OCR tasks where speed is critical - Screenshots or simple images with clear text ## Installation Scripts declare their dependencies inline ([PEP 723](https://peps.python.org/pep-0723/)). No separate install step is needed — [uv](https://docs.astral.sh/uv/) resolves dependencies automatically: ```bash uv run scripts/layout_caller.py --help ``` ## How to Use This Skill > **Working directory**: All `uv run scripts/...` commands below should be run from this skill's root directory (the directory containing this SKILL.md file). ### Basic Workflow 1. **Identify the input source**: - User provides URL: Use the `--file-url` parameter - User provides local file path: Use the `--file-path` parameter 2. **Execute document parsing**: ```bash uv run scripts/layout_caller.py --file-url "URL provided by user" --pretty ``` Or for local files: ```bash uv run scripts/layout_caller.py --file-path "file path" --pretty ``` **Optional: explicitly set file type**: ```bash uv run scripts/layout_caller.py --file-url "URL provided by user" --file-type 0 --pretty ``` - `--file-type 0`: PDF - `--file-type 1`: image - If omitted, the type is auto-detected from the file extension. For local files, a recognized extension (`.pdf`, `.png`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.tif`, `.webp`) is required; otherwise pass `--file-type` explicitly. For URLs with unrecognized extensions, the service attempts inference. > **Performance note**: Parsing time scales with document complexity. Single-page images typically complete in 1-5 seconds; large PDFs (50+ pages) may take several minutes. Allow adequate time before assuming a timeout. **Default behavior: output JSON to stdout**: - By default, the script prints JSON to stdout — no files are created on disk - Use `--output FILE` to save the result to a specific file path - This avoids leaving sensitive document data in temp directories 3. **Parse JSON response**: - Check the `ok` field: `true` means success, `false` means error - The output contains complete document data: text, tables, formulas (LaTeX), figures, seals, headers/footers, and reading order - Use the appropriate field based on what the user needs: - `text` — full document text across all pages - `result.result.layoutParsingResults[n].markdown.text` — page-level markdown - `result.result.layoutParsingResults[n].prunedResult` — structured layout data with positions and confidence - Handle errors: If `ok` is false, display `error.message` 4. **Present results to user**: - Display content based on what the user requested (see "Complete Output Display" below) - If the content is empty, the document may contain no extractable text - In save mode, always tell the user the saved file path and that full raw JSON is available there ### What to Do After Parsing Common next steps once you have the structured output: - **Save as Markdown**: Write the `text` field to a `.md` file — tables, headings, and formulas are preserved - **Extract specific tables**: Navigate `result.result.layoutParsingResults[n].prunedResult` to access individual layout elements with position and confidence data - **Feed to RAG / search pipeline**: The `text` field is structured markdown, ready for chunking and indexing - **Poor results**: See "Tips for Better Results" below before retrying ### Complete Output Display Display the COMPLETE extracted content based on what the user asked for. The parsed output is only useful if the user receives all of it — truncation silently drops data. - If user asks for "all text", show the entire `text` field - If user asks for "tables", show ALL tables in the document - If user asks for "main content", filter out headers/footers but show ALL body text - Do not truncate with "..." unless content is excessively long (>10,000 chars) - Do not say "Here's a preview" when user expects complete output **Example - Correct**: ``` User: "Extract all the text from this document" Agent: I've parsed the complete document. Here's all the extracted text: [Display entire text field or concatenated regions in reading order] Document Statistics: - Total regions: 25 - Text blocks: 15 - Tables: 3 - Formulas: 2 Quality: Excellent (confidence: 0.92) ``` **Example - Incorrect**: ``` User: "Extract all the text" Agent: "I found a document with multiple sections. Here's the beginning: 'Introduction...' (content truncated for brevity)" ``` ### Understanding the Output The script returns an envelope with `ok`, `text`, `result`, and `error`. Use `text` for the full document content; navigate `result.result.layoutParsingResults[n]` for per-page structured data. For the complete schema and field-level details, see `references/output_schema.md`. ### Usage Examples **Example 1: Extract Full Document Text (stdout)** ```bash uv run scripts/layout_caller.py \ --file-url "https://example.com/paper.pdf" \ --pretty ``` Then use: - Top-level `text` for quick full-text output - `result.result.layoutParsingResults[n].markdown` when page-level output is needed **Example 2: Extract Structured Page Data** ```bash uv run scripts/layout_caller.py \ --file-path "./financial_report.pdf" \ --pretty ``` Then use: - `result.result.layoutParsingResults[n].prunedResult` for structured parsing data (layout/content/confidence) **Example 3: Save result to a file** ```bash uv run scripts/layout_caller.py \ --file-url "URL" \ --output "./result.json" \ --pretty ``` By default the script prints JSON to stdout. Use `--output` to save to a file. ### Configuration Set `PADDLEOCR_DOC_PARSING_API_URL` to the AMD Radeon Cloud endpoint URL: ```bash export PADDLEOCR_DOC_PARSING_API_URL="http://134.199.132.159/layout-parsing" ``` No API key, no token, no sign-up needed. The AMD Radeon Cloud free PaddleOCR-VL 1.5 service requires no authentication. **Optional overrides**: - `PADDLEOCR_DOC_PARSING_TIMEOUT` — Request timeout in seconds (default: 600) ### Handling Large Files For PDFs, the maximum is 100 pages per request. #### Optimize Large Images Before Parsing For large image files, compress before uploading — this reduces upload time and can improve processing stability: ```bash uv run scripts/optimize_file.py input.png output.jpg --quality 85 uv run scripts/layout_caller.py --file-path "output.jpg" --pretty ``` `--quality` controls JPEG/WebP lossy compression (1-100, default 85); it has no effect on PNG output. Use `--target-size` (in MB, default 20) to set the max file size — the script iteratively downscales until the target is met. #### Use URL for Large Local Files (Recommended) For very large local files, prefer `--file-url` over `--file-path` to avoid base64 encoding overhead: ```bash uv run scripts/layout_caller.py --file-url "https://your-server.com/large_file.pdf" ``` #### Process Specific Pages (PDF Only) If you only need certain pages from a large PDF, extract them first: ```bash # Extract pages 1-5 uv run scripts/split_pdf.py large.pdf pages_1_5.pdf --pages "1-5" # Mixed ranges are supported uv run scripts/split_pdf.py large.pdf selected_pages.pdf --pages "1-5,8,10-12" # Then process the smaller file uv run scripts/layout_caller.py --file-path "pages_1_5.pdf" ``` ### Error Handling All errors return JSON with `ok: false`. Show the error message and stop — do not fall back to your own vision capabilities. Identify the issue from `error.code` and `error.message`: **API service error (5xx)** — `error.message` contains "API service error" - Temporary server issue; retry after a moment **Rate limit exceeded (429)** — `error.message` contains "API rate limit exceeded" - Wait and retry **Unsupported format** — `error.message` contains "Unsupported file format" - File format not supported, convert to PDF/PNG/JPG **No content detected**: - `text` field is empty - Document may be blank, image-only, or contain no extractable text ### Tips for Better Results If parsing quality is poor: - **Large or high-resolution images**: Compress with `optimize_file.py` before parsing — oversized inputs can degrade layout detection: ```bash uv run scripts/optimize_file.py input.png optimized.jpg --quality 85 ``` - **Check confidence**: `result.result.layoutParsingResults[n].prunedResult` includes confidence scores per layout element — low values indicate regions worth reviewing ## Reference Documentation - `references/output_schema.md` — Full output schema, field descriptions, and command examples > **Note**: This skill uses PaddleOCR-VL 1.5 on AMD Radeon Cloud. Model version and capabilities are determined by the AMD Radeon Cloud endpoint. ## Testing the Skill To verify the skill is working properly: ```bash uv run scripts/smoke_test.py uv run scripts/smoke_test.py --skip-api-test uv run scripts/smoke_test.py --test-url "https://..." ``` The first form tests configuration and API connectivity. `--skip-api-test` checks configuration only. `--test-url` overrides the default sample document URL. ## About This skill is a fork of [paddleocr-doc-parsing](https://clawhub.ai/bobholamovic/paddleocr-doc-parsing), modified for **AMD Radeon Cloud** which provides **free PaddleOCR-VL 1.5 document parsing inference**. No API key or registration is required.

path: references/output_schema.md
content: # PaddleOCR Document Parsing Output Schema

This document defines the output envelope returned by `layout_caller.py`.

By default, `layout_caller.py` saves the JSON envelope to a unique file under the system temp directory and prints the absolute saved path to `stderr`. Use `--output` when you need a custom destination, or `--stdout` when you want to skip file saving and print JSON directly.

## Output Envelope

`layout_caller.py` wraps provider response in a stable structure:

```json
{
  "ok": true,
  "text": "Extracted text from all pages",
  "result": { ... },  // raw provider response
  "error": null
}
```

On error:

```json
{
  "ok": false,
  "text": "",
  "result": null,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message"
  }
}
```

## Error Codes

| Code           | Description                                                                |
| -------------- | -------------------------------------------------------------------------- |
| `INPUT_ERROR`  | Invalid or unusable input (arguments, file source, format, types).         |
| `CONFIG_ERROR` | Missing or invalid API / client configuration.                             |
| `API_ERROR`    | Request or response handling failed (network, HTTP, body parsing, schema). |

## Raw Result Notes

The `result` field contains raw provider output.  
Raw fields may vary by model version and endpoint.

## Raw Result Example

```json
{
  "logId": "request-uuid",
  "errorCode": 0,
  "errorMsg": "Success",
  "result": {
    "layoutParsingResults": [
      {
        "prunedResult": { ... },  // layout elements with position/content/confidence information
        "markdown": {
          "text": "Full page content in markdown/HTML format",
          "images": {
            "imgs/filename.jpg": "https://..."
          },
          "...": "other model-specific fields"
        }
      }
    ]
  }
}
```

## Important Fields

Paths are relative to the output envelope root.

- `result.result.layoutParsingResults[n].prunedResult`  
  Structured parsing data for page `n` (layout elements, locations, content, confidence, and related metadata).

- `result.result.layoutParsingResults[n].markdown`  
  Rendered output for page `n`.

- `result.result.layoutParsingResults[n].markdown.text`  
  Full page markdown text.

## Text Extraction

`layout_caller.py` extracts top-level `text` from `result.result.layoutParsingResults[n].markdown.text` and joins pages with `\n\n`.

## Command Examples

```bash
# Parse document from URL (result auto-saves to the system temp directory)
uv run scripts/layout_caller.py --file-url "URL" --pretty

# Parse local file (result auto-saves to the system temp directory)
uv run scripts/layout_caller.py --file-path "doc.pdf" --pretty

# Parse with explicit file type
uv run scripts/layout_caller.py --file-url "URL" --file-type 1 --pretty

# Save result to a custom file path
uv run scripts/layout_caller.py --file-url "URL" --output "./result.json" --pretty

# Print JSON to stdout without saving a file
uv run scripts/layout_caller.py --file-url "URL" --stdout --pretty
```


path: scripts/layout_caller.py
content: """
PaddleOCR Document Parser — AMD Radeon Cloud Edition

CLI wrapper for the PaddleOCR document parsing library,
pre-configured for AMD Radeon Cloud free PaddleOCR-VL 1.5 service.

Usage:
    uv run scripts/layout_caller.py --file-url "URL"
    uv run scripts/layout_caller.py --file-path "document.pdf"
    uv run scripts/layout_caller.py --file-path "doc.pdf" --pretty
"""

# /// script
# requires-python = ">=3.9"
# dependencies = [
#   "httpx==0.28.1",
# ]
# ///

import argparse
import base64
import io
import json
import sys
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse

# Fix Windows console encoding
if sys.platform == "win32":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

# Add scripts dir to path for imports
sys.path.insert(0, str(Path(__file__).parent))

from lib import parse_document


def _read_as_base64(file_path: str) -> str:
    """Read a local file and return its base64 encoding."""
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(f"File not found: {file_path}")
    if path.stat().st_size == 0:
        raise ValueError(f"File is empty (0 bytes): {file_path}")
    return base64.b64encode(path.read_bytes()).decode("utf-8")


def _fetch_as_base64(url: str) -> str:
    """Download a file from a URL and return its base64 encoding."""
    import httpx
    from lib import _http_timeout_from_env, DEFAULT_TIMEOUT

    timeout = _http_timeout_from_env("PADDLEOCR_DOC_PARSING_TIMEOUT", float(DEFAULT_TIMEOUT))
    with httpx.Client(timeout=timeout) as client:
        resp = client.get(url)
        resp.raise_for_status()
    return base64.b64encode(resp.content).decode("utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="PaddleOCR Document Parsing (AMD Radeon Cloud) - with layout analysis",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
Examples:
  # Parse document from URL (JSON output to stdout)
  uv run scripts/layout_caller.py --file-url "https://example.com/document.pdf" --pretty

  # Parse local file (JSON output to stdout)
  uv run scripts/layout_caller.py --file-path "./invoice.pdf" --pretty

  # Save result to a file
  uv run scripts/layout_caller.py --file-url "URL" --output "./result.json" --pretty

Exit codes:
  0   Success (ok=true in JSON output)
  1   Parse or API error (ok=false in JSON output; see error.code and error.message)
  5   Cannot write result to output file

Configuration:
  Required: PADDLEOCR_DOC_PARSING_API_URL (AMD Radeon Cloud endpoint)
  Optional: PADDLEOCR_DOC_PARSING_TIMEOUT
        """,
    )

    input_group = parser.add_mutually_exclusive_group(required=True)
    input_group.add_argument("--file-url", help="URL to document (PDF, PNG, JPG, etc.)")
    input_group.add_argument("--file-path", help="Local file path")

    # Optional input options
    parser.add_argument(
        "--file-type",
        type=int,
        choices=[0, 1],
        help="Optional file type override (0=PDF, 1=Image)",
    )

    # Output options
    parser.add_argument(
        "--pretty", action="store_true", help="Pretty-print JSON output"
    )
    parser.add_argument(
        "--output",
        "-o",
        metavar="FILE",
        help="Save result to a JSON file (default: print to stdout)",
    )

    args = parser.parse_args()

    # Read and encode file content
    try:
        if args.file_url:
            file_data = _fetch_as_base64(args.file_url)
        else:
            file_data = _read_as_base64(args.file_path)
    except (FileNotFoundError, ValueError) as e:
        print(json.dumps({"ok": False, "text": "", "result": None,
                          "error": {"code": "INPUT_ERROR", "message": str(e)}}))
        sys.exit(1)
    except Exception as e:
        print(json.dumps({"ok": False, "text": "", "result": None,
                          "error": {"code": "INPUT_ERROR", "message": str(e)}}))
        sys.exit(1)

    result = parse_document(
        file_data=file_data,
        file_source=args.file_url or args.file_path,
        file_type=args.file_type,
        useDocUnwarping=False,
        useDocOrientationClassify=False,
        visualize=False,
    )

    indent = 2 if args.pretty else None
    json_output = json.dumps(result, indent=indent, ensure_ascii=False)

    if args.output:
        output_path = Path(args.output).expanduser().resolve()
        try:
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_path.write_text(json_output, encoding="utf-8")
            print(f"Result saved to: {output_path}", file=sys.stderr)
        except (PermissionError, OSError) as e:
            print(f"Error: Cannot write to {output_path}: {e}", file=sys.stderr)
            sys.exit(5)
    else:
        print(json_output)

    sys.exit(0 if result.get("ok") else 1)


if __name__ == "__main__":
    main()


path: scripts/lib.py
content: """
PaddleOCR Document Parsing Library — AMD Radeon Cloud Edition

API wrapper for PaddleOCR document parsing, configured for the
AMD Radeon Cloud free PaddleOCR-VL 1.5 endpoint.
"""

import logging
import math
import os
from typing import Any, Optional
from urllib.parse import unquote, urlparse

import httpx

logger = logging.getLogger(__name__)

# =============================================================================
# Constants
# =============================================================================

DEFAULT_TIMEOUT = 600  # seconds (10 minutes)
FILE_TYPE_PDF = 0
FILE_TYPE_IMAGE = 1
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp")


# =============================================================================
# Environment
# =============================================================================


def _get_env(key: str) -> str:
    """Get environment variable, defaulting to empty string with whitespace stripped."""
    return os.getenv(key, "").strip()


def _http_timeout_from_env(env_key: str, default_seconds: float) -> float:
    """
    Read HTTP client timeout in seconds from the environment.

    Returns a positive finite float. If the variable is missing, empty,
    unparsable, non-finite, or not greater than zero, logs a warning and uses the
    default_seconds argument value.
    """
    raw = os.getenv(env_key)
    if raw is None:
        return float(default_seconds)
    stripped = raw.strip()
    if not stripped:
        return float(default_seconds)
    try:
        timeout = float(stripped)
    except (ValueError, TypeError):
        logger.warning(
            "Invalid %s value %r; using default %ss",
            env_key,
            raw,
            default_seconds,
        )
        return float(default_seconds)
    if not math.isfinite(timeout) or timeout <= 0:
        logger.warning(
            "%s must be a finite number > 0 (got %r); using default %ss",
            env_key,
            raw,
            default_seconds,
        )
        return float(default_seconds)
    return timeout


def get_config() -> str:
    """
    Get API URL from environment.

    Requires PADDLEOCR_DOC_PARSING_API_URL to be set to the
    AMD Radeon Cloud endpoint URL.

    Returns:
        api_url string

    Raises:
        ValueError: If env var is missing or URL path is invalid
    """
    api_url = _get_env("PADDLEOCR_DOC_PARSING_API_URL")

    if not api_url:
        raise ValueError(
            "PADDLEOCR_DOC_PARSING_API_URL not configured. "
            "Set it to your AMD Radeon Cloud endpoint, e.g. "
            "PADDLEOCR_DOC_PARSING_API_URL=http://host:port/layout-parsing"
        )

    if not api_url.startswith(("http://", "https://")):
        api_url = f"http://{api_url}"

    api_path = urlparse(api_url).path.rstrip("/")
    if not api_path.endswith("/layout-parsing"):
        raise ValueError(
            "PADDLEOCR_DOC_PARSING_API_URL must be a full endpoint ending with "
            "/layout-parsing."
        )

    return api_url


# =============================================================================
# File Utilities
# =============================================================================


def _detect_file_type(path_or_url: str) -> int:
    """Detect file type: 0=PDF, 1=Image."""
    path = path_or_url.lower()
    if path.startswith(("http://", "https://")):
        path = unquote(urlparse(path).path)

    if path.endswith(".pdf"):
        return FILE_TYPE_PDF
    elif path.endswith(IMAGE_EXTENSIONS):
        return FILE_TYPE_IMAGE
    else:
        raise ValueError(f"Unsupported file format: {path_or_url}")


# =============================================================================
# API Request
# =============================================================================


def _make_api_request(api_url: str, params: dict[str, Any]) -> dict[str, Any]:
    """
    Make PaddleOCR document parsing API request.

    Args:
        api_url: API endpoint URL
        params: Request parameters

    Returns:
        API response dict

    Raises:
        RuntimeError: On API errors
    """
    headers = {
        "Content-Type": "application/json",
        "Client-Platform": "official-skill",
    }

    timeout = _http_timeout_from_env(
        "PADDLEOCR_DOC_PARSING_TIMEOUT", float(DEFAULT_TIMEOUT)
    )

    try:
        with httpx.Client(timeout=timeout) as client:
            try:
                resp = client.post(api_url, json=params, headers=headers)
            except TypeError as e:
                raise RuntimeError(
                    "Request parameters cannot be JSON-encoded; use only JSON-serializable "
                    f"option values ({e})"
                ) from e
    except httpx.TimeoutException:
        raise RuntimeError(f"API request timed out after {timeout}s")
    except httpx.RequestError as e:
        raise RuntimeError(f"API request failed: {e}")

    if resp.status_code != 200:
        error_detail = ""
        try:
            error_body = resp.json()
            if isinstance(error_body, dict):
                error_detail = str(error_body.get("errorMsg", "")).strip()
        except Exception:
            pass

        if not error_detail:
            error_detail = (resp.text[:200] or "No response body").strip()

        if resp.status_code == 403:
            raise RuntimeError(f"Authentication failed (403): {error_detail}")
        elif resp.status_code == 429:
            raise RuntimeError(f"API rate limit exceeded (429): {error_detail}")
        elif resp.status_code >= 500:
            raise RuntimeError(
                f"API service error ({resp.status_code}): {error_detail}"
            )
        else:
            raise RuntimeError(f"API error ({resp.status_code}): {error_detail}")

    try:
        result = resp.json()
    except Exception:
        raise RuntimeError(f"Invalid JSON response: {resp.text[:200]}")

    if not isinstance(result, dict):
        raise RuntimeError(
            f"Unexpected JSON shape (expected object): {resp.text[:200]}"
        )

    if result.get("errorCode", 0) != 0:
        msg = result.get("errorMsg", "Unknown error")
        raise RuntimeError(f"API error: {msg}")

    return result


# =============================================================================
# Main API
# =============================================================================


def parse_document(
    file_data: Optional[str] = None,
    file_source: Optional[str] = None,
    file_type: Optional[int] = None,
    **options: Any,
) -> dict[str, Any]:
    """
    Parse document with PaddleOCR.

    Args:
        file_data: Base64-encoded file content (provided by caller)
        file_source: Original file path or URL (used for file type detection)
        file_type: Optional file type override (0=PDF, 1=Image)
        **options: Additional API options

    Returns:
        {
            "ok": True,
            "text": "extracted text...",
            "result": { raw API result },
            "error": None
        }
        or on error:
        {
            "ok": False,
            "text": "",
            "result": None,
            "error": {"code": "...", "message": "..."}
        }
    """
    if file_data is not None and not isinstance(file_data, str):
        return _error("INPUT_ERROR", "file_data must be a string or None")

    if not file_data:
        return _error("INPUT_ERROR", "file_data (base64-encoded) required")
    if file_type is not None and file_type not in (FILE_TYPE_PDF, FILE_TYPE_IMAGE):
        return _error("INPUT_ERROR", "file_type must be 0 (PDF) or 1 (Image)")

    try:
        api_url = get_config()
    except ValueError as e:
        return _error("CONFIG_ERROR", str(e))

    # Build request params
    try:
        resolved_file_type: Optional[int] = None
        if file_source:
            try:
                resolved_file_type = _detect_file_type(file_source)
            except ValueError:
                pass
        if file_type is not None:
            resolved_file_type = file_type

        params = {"file": file_data}
        params["visualize"] = (
            False  # reduce response payload; callers can override via options
        )
        params.update(options)
        if resolved_file_type is not None:
            params["fileType"] = resolved_file_type
        else:
            params.pop("fileType", None)

    except ValueError as e:
        return _error("INPUT_ERROR", str(e))

    try:
        result = _make_api_request(api_url, params)
    except RuntimeError as e:
        return _error("API_ERROR", str(e))

    try:
        text = _extract_text(result)
    except ValueError as e:
        return _error("API_ERROR", str(e))

    return {
        "ok": True,
        "text": text,
        "result": result,
        "error": None,
    }


def _extract_text(result: dict[str, Any]) -> str:
    """Extract text from document parsing result."""
    if not isinstance(result, dict):
        raise ValueError("Invalid API response: top-level response must be an object")

    raw_result = result.get("result")
    if not isinstance(raw_result, dict):
        raise ValueError("Invalid API response: missing 'result' object")

    pages = raw_result.get("layoutParsingResults")
    if not isinstance(pages, list):
        raise ValueError(
            "Invalid API response: result.layoutParsingResults must be an array"
        )

    texts = []
    for i, page in enumerate(pages):
        if not isinstance(page, dict):
            raise ValueError(
                f"Invalid API response: result.layoutParsingResults[{i}] must be an object"
            )

        markdown = page.get("markdown")
        if not isinstance(markdown, dict):
            raise ValueError(
                f"Invalid API response: result.layoutParsingResults[{i}].markdown must be an object"
            )

        text = markdown.get("text")
        if not isinstance(text, str):
            raise ValueError(
                f"Invalid API response: result.layoutParsingResults[{i}].markdown.text must be a string"
            )
        texts.append(text)

    return "\n\n".join(texts)


def _error(code: str, message: str) -> dict[str, Any]:
    """Create error response."""
    return {
        "ok": False,
        "text": "",
        "result": None,
        "error": {"code": code, "message": message},
    }


path: scripts/optimize_file.py
content: """
File Optimizer for PaddleOCR Document Parsing

Compresses and optimizes large files to meet size requirements.
Supports image files only.

Usage:
    uv run scripts/optimize_file.py input.png output.png
    uv run scripts/optimize_file.py input.png output.jpg --quality 70
"""

# /// script
# requires-python = ">=3.9"
# dependencies = [
#   "Pillow==11.2.1",
# ]
# ///

import argparse
import math
import sys
from pathlib import Path

DEFAULT_QUALITY = 85
DEFAULT_TARGET_SIZE_MB = 20
SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp")
SUPPORTED_FORMATS_DISPLAY = ", ".join(
    e.lstrip(".").upper() for e in SUPPORTED_EXTENSIONS
)


def _arg_quality(value: str) -> int:
    q = int(value)
    if q < 1 or q > 100:
        raise argparse.ArgumentTypeError("quality must be between 1 and 100 inclusive")
    return q


def _arg_positive_mb(value: str) -> float:
    v = float(value)
    if not math.isfinite(v) or v <= 0:
        raise argparse.ArgumentTypeError(
            "target size must be a finite number greater than 0"
        )
    return v


def optimize_image(
    input_path: Path,
    output_path: Path,
    quality: int = DEFAULT_QUALITY,
    max_size_mb: float = DEFAULT_TARGET_SIZE_MB,
) -> None:
    """Optimize image file by reducing quality and/or resolution."""
    from PIL import Image

    if input_path.stat().st_size == 0:
        raise ValueError("Input file is empty (0 bytes); nothing to optimize")

    print(f"Optimizing image: {input_path}")

    img = Image.open(input_path)
    original_size = input_path.stat().st_size / 1024 / 1024

    print(f"Original size: {original_size:.2f}MB")
    print(f"Original dimensions: {img.size[0]}x{img.size[1]}")

    is_jpeg = output_path.suffix.lower() in (".jpg", ".jpeg")

    if is_jpeg and img.mode in ("RGBA", "LA", "P"):
        background = Image.new("RGB", img.size, (255, 255, 255))
        if img.mode == "P":
            img = img.convert("RGBA")
        background.paste(
            img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None
        )
        img = background

    save_kwargs = {"optimize": True}
    if is_jpeg or output_path.suffix.lower() == ".webp":
        save_kwargs["quality"] = quality

    def _save(image):
        image.save(output_path, **save_kwargs)
        return output_path.stat().st_size / 1024 / 1024

    new_size = _save(img)

    scale_factor = 0.9
    while new_size > max_size_mb and scale_factor >= 0.4:
        new_width = int(img.size[0] * scale_factor)
        new_height = int(img.size[1] * scale_factor)
        if new_width < 1 or new_height < 1:
            print(
                f"Cannot shrink to valid dimensions at scale {scale_factor:.2f} "
                f"(would be {new_width}x{new_height}); stopping resize loop."
            )
            break

        print(f"Resizing to {new_width}x{new_height} (scale: {scale_factor:.2f})")

        resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
        new_size = _save(resized)

        scale_factor -= 0.1

    print(f"Optimized size: {new_size:.2f}MB")
    pct = (original_size - new_size) / original_size * 100
    print(f"Reduction: {pct:.1f}%")

    if new_size > max_size_mb:
        print(f"\nWARNING: File still larger than {max_size_mb}MB")
        print("Consider:")
        print("  - Lower quality (--quality 70)")
        print("  - Use --file-url instead of local file")
        print("  - Use a smaller or resized image")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Optimize files for PaddleOCR document parsing",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=f"""
Examples:
  # Optimize image with default quality
  uv run scripts/optimize_file.py input.png output.png

  # Optimize with specific quality
  uv run scripts/optimize_file.py input.jpg output.jpg --quality 70

Supported formats:
  - Images: {SUPPORTED_FORMATS_DISPLAY}
        """,
    )

    parser.add_argument("input", help="Input file path")
    parser.add_argument("output", help="Output file path")
    parser.add_argument(
        "--quality",
        type=_arg_quality,
        default=DEFAULT_QUALITY,
        help="JPEG/WebP quality (1-100, default: %(default)s)",
    )
    parser.add_argument(
        "--target-size",
        type=_arg_positive_mb,
        default=DEFAULT_TARGET_SIZE_MB,
        help="Target maximum size in MB (default: %(default)s)",
    )

    args = parser.parse_args()

    input_path = Path(args.input)
    output_path = Path(args.output)

    if not input_path.exists():
        print(f"ERROR: Input file not found: {input_path}")
        sys.exit(1)

    ext = input_path.suffix.lower()

    if ext in SUPPORTED_EXTENSIONS:
        try:
            optimize_image(input_path, output_path, args.quality, args.target_size)
        except Exception as e:
            print(f"ERROR: {e}")
            sys.exit(1)
    else:
        print(f"ERROR: Unsupported file format: {ext}")
        print(f"Supported: {SUPPORTED_FORMATS_DISPLAY}")
        sys.exit(1)

    print(f"\nOptimized file saved to: {output_path}")
    print("\nYou can now process with:")
    print(f'  uv run scripts/layout_caller.py --file-path "{output_path}" --pretty')


if __name__ == "__main__":
    main()


path: scripts/smoke_test.py
content: """
Smoke Test for PaddleOCR Document Parsing Skill — AMD Radeon Cloud Edition

Verifies configuration and API connectivity.

Usage:
    uv run scripts/smoke_test.py
    uv run scripts/smoke_test.py --skip-api-test
    uv run scripts/smoke_test.py --test-url "https://example.com/test.pdf"
"""

# /// script
# requires-python = ">=3.9"
# dependencies = [
#   "httpx==0.28.1",
# ]
# ///

import argparse
import base64
import sys
from pathlib import Path

# Add scripts dir to path for imports
sys.path.insert(0, str(Path(__file__).parent))


def print_config_guide() -> None:
    """Print configuration guide."""
    from lib import DEFAULT_TIMEOUT

    print(
        f"""
============================================================
AMD RADEON CLOUD — DOCUMENT PARSING CONFIGURATION
============================================================

Set the PADDLEOCR_DOC_PARSING_API_URL environment variable
to your AMD Radeon Cloud endpoint.

Example:
  export PADDLEOCR_DOC_PARSING_API_URL=http://host:port/layout-parsing

No API key or token is required.

Optional:
  PADDLEOCR_DOC_PARSING_TIMEOUT  - Request timeout in seconds (default: {DEFAULT_TIMEOUT})

============================================================
"""
    )


def main() -> int:
    parser = argparse.ArgumentParser(
        description="PaddleOCR Document Parsing (AMD Radeon Cloud) smoke test"
    )
    parser.add_argument("--test-url", help="Optional: Custom document URL for testing")
    parser.add_argument(
        "--skip-api-test",
        action="store_true",
        help="Skip API connectivity test, only check configuration",
    )
    args = parser.parse_args()

    print("=" * 60)
    print("PaddleOCR Document Parsing (AMD Radeon Cloud) - Smoke Test")
    print("=" * 60)

    print("\n[1/3] Checking dependencies...")

    try:
        import httpx

        print(f"  + httpx: {httpx.__version__}")
    except ImportError:
        print("  X httpx not installed")
        print("\nRun this script with uv to auto-resolve dependencies:")
        print("  uv run scripts/smoke_test.py")
        print("\nOr install manually:")
        print("  pip install httpx")
        return 1

    print("\n[2/3] Checking configuration...")

    from lib import get_config

    try:
        api_url = get_config()
        print(f"  + API endpoint: {api_url}")
    except ValueError as e:
        print(f"  X {e}")
        print_config_guide()
        return 1

    if args.skip_api_test:
        print("\n[3/3] Skipping API connectivity test (--skip-api-test)")
        print("\n" + "=" * 60)
        print("Configuration Check Complete!")
        print("=" * 60)
        return 0

    print("\n[3/3] Testing API connectivity...")

    test_url = (
        args.test_url
        or "https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/pp_structure_v3_demo.png"
    )
    print(f"  Test document: {test_url}")

    from lib import parse_document, _http_timeout_from_env, DEFAULT_TIMEOUT
    import httpx as _httpx

    # Download and encode the test file
    try:
        timeout = _http_timeout_from_env("PADDLEOCR_DOC_PARSING_TIMEOUT", float(DEFAULT_TIMEOUT))
        with _httpx.Client(timeout=timeout) as client:
            resp = client.get(test_url)
            resp.raise_for_status()
        file_data = base64.b64encode(resp.content).decode("utf-8")
    except Exception as e:
        print(f"\n  X Failed to download test file: {e}")
        return 1

    result = parse_document(file_data=file_data, file_source=test_url)

    if not result.get("ok"):
        error = result.get("error", {})
        print(f"\n  X API call failed: {error.get('message')}")
        return 1

    print("  + API call successful!")

    text = result.get("text", "")
    if text:
        preview = text[:200].replace("\n", " ")
        if len(text) > 200:
            preview += "..."
        print(f"\n  Preview: {preview}")

    print("\n" + "=" * 60)
    print("Smoke Test PASSED")
    print("=" * 60)
    print("\nNext steps:")
    print('  uv run scripts/layout_caller.py --file-url "URL" --pretty')
    print('  uv run scripts/layout_caller.py --file-path "doc.pdf" --pretty')

    return 0


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


path: scripts/split_pdf.py
content: """
Split a PDF by page ranges.

Usage:
    uv run scripts/split_pdf.py input.pdf output.pdf --pages "1-5,8,10-12"
"""

# /// script
# requires-python = ">=3.9"
# dependencies = [
#   "pypdfium2==4.30.1",
# ]
# ///

import argparse
import sys
from pathlib import Path


def parse_pages(pages_spec: str, total_pages: int) -> list[int]:
    """Parse 1-based page ranges into 0-based unique page indices."""
    if not pages_spec or not pages_spec.strip():
        raise ValueError("Page ranges are required. Example: 1-5,8,10-12")

    selected_pages = []
    seen_pages = set()

    def add_page(page_number: int):
        if page_number < 1 or page_number > total_pages:
            raise ValueError(
                f"Page {page_number} is out of range. Valid range: 1-{total_pages}"
            )
        page_index = page_number - 1
        if page_index not in seen_pages:
            seen_pages.add(page_index)
            selected_pages.append(page_index)

    for token in [part.strip() for part in pages_spec.split(",") if part.strip()]:
        if "-" in [REDACTED_SECRET], end_str = token.split("-", 1)
            if not start_str.isdigit() or not end_str.isdigit():
                raise ValueError(f"Invalid page range: {token}")
            start_page, end_page = int(start_str), int(end_str)
            if start_page > end_page:
                raise ValueError(
                    f"Invalid page range: {token} (start cannot be greater than end)"
                )
            for page_number in range(start_page, end_page + 1):
                add_page(page_number)
        else:
            if not token.isdigit():
                raise ValueError(f"Invalid page value: {token}")
            add_page(int(token))

    if not selected_pages:
        raise ValueError("No valid pages selected")

    return selected_pages


def split_pdf(input_path: Path, output_path: Path, pages_spec: str) -> tuple[int, int]:
    """Create a new PDF containing selected pages from the input PDF."""
    import pypdfium2 as pdfium

    source_pdf = pdfium.PdfDocument(str(input_path))
    try:
        total_pages = len(source_pdf)
        page_indices = parse_pages(pages_spec, total_pages)

        output_pdf = pdfium.PdfDocument.new()
        try:
            output_pdf.import_pages(source_pdf, page_indices)
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_pdf.save(str(output_path))
        finally:
            output_pdf.close()
    finally:
        source_pdf.close()

    return total_pages, len(page_indices)


def main() -> int:
    parser = argparse.ArgumentParser(description="Split PDF by page ranges")
    parser.add_argument("input_pdf", help="Input PDF file")
    parser.add_argument("output_pdf", help="Output PDF file")
    parser.add_argument(
        "--pages",
        required=True,
        help='Page ranges, e.g. "1-5,8,10-12"',
    )
    args = parser.parse_args()

    input_path = Path(args.input_pdf)
    output_path = Path(args.output_pdf)

    if not input_path.exists():
        print(f"ERROR: Input file not found: {input_path}")
        return 1
    if input_path.suffix.lower() != ".pdf":
        print(f"ERROR: Input must be a PDF file: {input_path}")
        return 1
    if output_path.suffix.lower() != ".pdf":
        print(f"ERROR: Output must be a PDF file: {output_path}")
        return 1

    try:
        total_pages, kept_pages = split_pdf(input_path, output_path, args.pages)
    except Exception as e:
        print(f"ERROR: {e}")
        return 1

    print(f"Split complete: {output_path}")
    print(f"Selected {kept_pages} page(s) from {total_pages} total page(s)")
    return 0


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


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