Skill: a851445115/pdf-extractor-skill (version 1.0.0)

# PDF Extractor Skill Extract text and mathematical formulas from academic PDF papers. Supports both English and Chinese content. ## When to Use This Skill Use this skill when: - User needs to extract text and LaTeX formulas from PDF papers - User mentions "PDF转文本", "PDF提取公式", "论文OCR" - User wants to convert academic papers to Markdown format ## Tool Selection | Tool | Best For | Languages | Math Quality | |------|----------|-----------|--------------| | **Marker** (推荐) | 中英文论文、复杂公式 | Chinese + English | Excellent | | **Nougat** | 纯英文论文、arXiv | English only | Excellent | **推荐使用 Marker**：支持中英文混排，公式识别效果更好。 --- ## Environment Setup **Conda Environment**: `pdf-extractor` **Python Path**: `D:\anaconda3\envs\pdf-extractor\python.exe` ### Key Dependencies - PyTorch 2.10.0+cu128 (CUDA 12.8) - marker-pdf (Surya OCR + Texify) - nougat-ocr 0.1.17 - transformers ## Important: Keep This Skill Self-Contained (No Extra Installs) This skill is expected to run using ONLY the existing `pdf-extractor` conda environment and the scripts in `scripts/`. Rules: - Do NOT run `pip install ...` / `conda install ...` / download random libraries during extraction. - If a dependency is missing (e.g., Nougat crashes due to missing `torchvision`), do NOT try to fix by installing packages. Switch tools (prefer Marker) or report the environment issue. - Slow runtime is normal for Marker (especially with `--ark-code-latest`). Prefer splitting the PDF rather than changing tools or adding dependencies. Recommended approach for long PDFs: - Use `--page-range` (0-based) to extract per page or small page batches. - Merge the resulting markdown files afterward (simple concatenation is fine). Keep the combined file in the same folder as the per-page outputs so image links remain valid. Example (per-page extraction with LLM mode): ```bash D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "paper.pdf" --ark-code-latest --page-range "0" -o "out/page_01.md" ``` --- ## Tool 1: Marker (推荐 - 中英文支持) ### Command Line ```bash # 转换中文论文 (默认支持中英文) D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "论文.pdf" # 指定输出路径 D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "paper.pdf" -o "output.md" # 强制 OCR (用于扫描版 PDF) D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "scanned.pdf" --force-ocr # 使用火山方舟 Coding Plan (OpenAI-compatible) 增强转换质量（表格/公式/跨页结构更稳） # 注意：默认走 ark-code-latest，后台会自动路由到合适的模型 D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "paper.pdf" --ark-code-latest # 只跑第 1 页做快速验证（0-based page index） D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "paper.pdf" --ark-code-latest --page-range "0" -o "out_first_page.md" # 如需自定义（不推荐）：也可以手动指定 --openai-base-url/--openai-api-key/--openai-model # 指定语言 D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2md_marker.py "paper.pdf" --languages Chinese English Japanese ``` ### Python API ```python import sys sys.path.insert(0, r'C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts') from pdf2md_marker import convert_pdf, convert_pdf_cli # 简单用法 output_file = convert_pdf_cli('论文.pdf', 'output.md') # 完整 API markdown_text, metadata = convert_pdf( 'paper.pdf', output_dir='./output', force_ocr=False, batch_multiplier=2, languages=['Chinese', 'English'] ) print(markdown_text) ``` ### Marker Options | Option | Description | |--------|-------------| | `-o, --output` | Output file (.md) or directory | | `--force-ocr` | Force OCR even for text PDFs | | `--batch-multiplier` | Batch size multiplier (default: 2) | | `--languages` | Languages in document (default: Chinese English) | --- ## Tool 2: Nougat (纯英文论文) ### Command Line ```bash # Convert entire PDF D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2latex.py "paper.pdf" # Convert specific pages D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2latex.py "paper.pdf" -p 0-5 # Custom output D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2latex.py "paper.pdf" -o output.mmd # Save each page separately D:\anaconda3\envs\pdf-extractor\python.exe C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts\pdf2latex.py "paper.pdf" --per-page ``` ### Python API ```python import sys sys.path.insert(0, r'C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts') from pdf2latex import load_model, process_pdf, save_results # Load model (uses GPU if available) model, device = load_model() # Process PDF results = process_pdf('paper.pdf', model, device) # Save as single markdown file save_results(results, 'output.mmd') # Or save per page save_results(results, 'output_pages/', format='pages') ``` ### Nougat Options | Option | Description | |--------|-------------| | `-o, --output` | Output file or directory | | `-p, --pages` | Page range (e.g., "0-5" or "1,3,5") | | `-m, --model` | Model tag (default: 0.1.0-base) | | `--dpi` | Render DPI (default: 300) | | `--cpu` | Force CPU mode | | `--per-page` | Save each page separately | --- ## Output Format Both tools output Markdown with LaTeX math: - Text is extracted as regular markdown - Mathematical formulas are in LaTeX format: - Inline: `$formula$` - Display: `$$formula$$` - Tables, figures, and references are preserved - Marker also extracts images to separate folder --- ## Comparison | Feature | Marker | Nougat | |---------|--------|--------| | Chinese Support | ✓ Excellent | ✗ Poor | | English Support | ✓ Excellent | ✓ Excellent | | Math Formulas | ✓ (Texify) | ✓ (Native) | | Table Extraction | ✓ | ✓ | | Image Extraction | ✓ | ✗ | | Speed (RTX 4060) | ~2 min/page | ~10-15 sec/page | | OCR Quality | Excellent | Good | --- ## Troubleshooting ### Import Errors Make sure you're using the correct Python: ```bash D:\anaconda3\envs\pdf-extractor\python.exe your_script.py ``` ### CUDA Out of Memory Try CPU mode (Nougat) or reduce batch size (Marker): ```bash # Nougat: use CPU D:\anaconda3\envs\pdf-extractor\python.exe pdf2latex.py paper.pdf --cpu # Marker: reduce batch multiplier D:\anaconda3\envs\pdf-extractor\python.exe pdf2md_marker.py paper.pdf --batch-multiplier 1 ``` ### Chinese Characters Not Recognized Use Marker instead of Nougat for Chinese documents. ### Slow Processing - Marker is slower but more accurate (uses multiple ML models) - For faster processing on English-only papers, use Nougat - Ensure GPU is being used (check CUDA availability) --- ## Model Information **Marker Models** (downloaded automatically): - Surya OCR: Text detection and recognition - Texify: Math formula recognition - Layout analysis models **Nougat Base Model** (1.31 GB): - Location: `C:\Users\cr\.cache\torch\hub\nougat-0.1.0-base` - Best for: Standard academic papers, arXiv papers --- ## Example Workflow ```python import sys sys.path.insert(0, r'C:\Users\cr\.config\opencode\skills\pdf-extractor\scripts') def extract_paper(pdf_path, is_chinese=True): """ Extract text and formulas from academic paper. Args: pdf_path: Path to PDF file is_chinese: True for Chinese papers, False for English only Returns: Extracted markdown text """ if is_chinese: from pdf2md_marker import convert_pdf text, _ = convert_pdf(pdf_path, languages=['Chinese', 'English']) else: from pdf2latex import load_model, process_pdf model, device = load_model() results = process_pdf(pdf_path, model, device) text = '\n\n'.join([t for _, t in results]) return text # Usage text = extract_paper('中文论文.pdf', is_chinese=True) print(text) ```

path: README.md
content: # PDF Extractor Skill

Extract text and mathematical formulas from academic PDF papers. Supports both English and Chinese content.

## Installation

### 1. Create Conda Environment

```bash
conda create -n pdf-extractor python=3.10 -y
conda activate pdf-extractor
```

### 2. Install Dependencies

```bash
# PyTorch (CUDA 12.8)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128

# Marker (recommended for Chinese + English)
pip install marker-pdf

# Nougat (optional, for English-only papers)
pip install nougat-ocr

# Transformers
pip install transformers
```

### 3. Copy this skill to your device

```bash
# Option A: Clone from git (if you've pushed to a remote)
git clone <your-repo-url> ~/.config/opencode/skills/pdf-extractor

# Option B: Copy the directory manually
# Copy this entire folder to:
# - Windows: C:\Users\<username>\.config\opencode\skills\pdf-extractor
# - Linux/Mac: ~/.config/opencode/skills/pdf-extractor
```

## Usage

### Marker (Recommended - Chinese + English Support)

```bash
# Convert Chinese paper (default supports Chinese + English)
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py "论文.pdf"

# Specify output path
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py "paper.pdf" -o "output.md"

# Force OCR (for scanned PDFs)
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py "scanned.pdf" --force-ocr

# Use LLM enhancement (火山方舟 Coding Plan for better tables/formulas)
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py "paper.pdf" --ark-code-latest

# Extract only page 1 (0-based index)
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py "paper.pdf" --ark-code-latest --page-range "0" -o "out_first_page.md"
```

### Nougat (English-only Papers)

```bash
# Convert entire PDF
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2latex.py "paper.pdf"

# Convert specific pages
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2latex.py "paper.pdf" -p 0-5

# Custom output
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2latex.py "paper.pdf" -o output.mmd
```

## Tool Comparison

| Feature | Marker | Nougat |
|---------|--------|--------|
| Chinese Support | ✓ Excellent | ✗ Poor |
| English Support | ✓ Excellent | ✓ Excellent |
| Math Formulas | ✓ (Texify) | ✓ (Native) |
| Table Extraction | ✓ | ✓ |
| Image Extraction | ✓ | ✗ |
| Speed (RTX 4060) | ~2 min/page | ~10-15 sec/page |

## Output Format

Both tools output Markdown with LaTeX math:
- Inline: `$formula$`
- Display: `$$formula$$`

## Troubleshooting

### CUDA Out of Memory
```bash
# Nougat: use CPU
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2latex.py paper.pdf --cpu

# Marker: reduce batch multiplier
D:\anaconda3\envs\pdf-extractor\python.exe scripts/pdf2md_marker.py paper.pdf --batch-multiplier 1
```

### Chinese Characters Not Recognized
Use Marker instead of Nougat for Chinese documents.

## Model Cache Locations

Models are downloaded automatically on first use:
- **Nougat**: `C:\Users\<username>\.cache\torch\hub\nougat-0.1.0-base` (~1.31 GB)
- **Marker Models**: Downloaded by marker-pdf automatically


path: scripts/marker_llm_antigravity.py
content: """Marker LLM service for Antigravity Tools (OpenAI-compatible proxy).

Why this exists:
- marker.services.openai.OpenAIService uses OpenAI's beta 'parse' API
  (client.beta.chat.completions.parse + response_format schema).
- Antigravity Tools' OpenAI-compatible proxy may not fully support that,
  causing non-JSON responses and schema validation failures.

This service forces JSON-only responses and validates them with the
provided Pydantic schema.

Endpoint expected: http://127.0.0.1:8045/v1
"""

from __future__ import annotations

import json
import time
from typing import Annotated, List

import openai
import PIL
from pydantic import BaseModel

from marker.schema.blocks import Block
from marker.services import BaseService


class AntigravityOpenAIService(BaseService):
    openai_base_url: Annotated[
        str, "The base url to use for OpenAI-like models. No trailing slash."
    ] = "http://127.0.0.1:8045/v1"

    openai_model: Annotated[str, "The model name to use for OpenAI-like model."] = (
        "gemini-3-flash"
    )

    openai_api_key: Annotated[str, "The API key to use for the OpenAI-like service."] = ""

    openai_image_format: Annotated[
        str,
        "The image format to use for the OpenAI-like service. Use 'png' for better compatibility.",
    ] = "png"

    def process_images(self, images: List[PIL.Image.Image]) -> List[dict]:
        if isinstance(images, PIL.Image.Image):
            images = [images]

        img_fmt = self.openai_image_format
        return [
            {
                "type": "image_url",
                "image_url": {
                    "url": "data:image/{};base64,{}".format(
                        img_fmt, self.img_to_base64(img, format=img_fmt)
                    ),
                },
            }
            for img in images
        ]

    def __call__(
        self,
        prompt: str,
        image: PIL.Image.Image | List[PIL.Image.Image] | None,
        block: Block | None,
        response_schema: type[BaseModel],
        max_retries: int | None = None,
        timeout: int | None = None,
    ):
        if max_retries is None:
            max_retries = self.max_retries

        if timeout is None:
            timeout = self.timeout

        client = openai.OpenAI(
            [REDACTED_SECRET],
            base_url=self.openai_base_url,
        )

        image_data = self.format_image_for_llm(image)
        schema_json = response_schema.model_json_schema()

        strict_prompt = (
            prompt
            + "\n\n"
            + "You MUST respond with ONLY valid JSON (no markdown, no code fences, no extra text).\n"
            + "The JSON MUST conform to this JSON Schema:\n"
            + json.dumps(schema_json, ensure_ascii=False)
        )

        messages = [
            {
                "role": "user",
                "content": [
                    *image_data,
                    {"type": "text", "text": strict_prompt},
                ],
            }
        ]

        total_tries = max_retries + 1
        for tries in range(1, total_tries + 1):
            try:
                response = client.chat.completions.create(
                    extra_headers={
                        "X-Title": "Marker",
                        "HTTP-Referer": "https://github.com/datalab-to/marker",
                    },
                    model=self.openai_model,
                    messages=messages,
                    timeout=timeout,
                    response_format={"type": "json_object"},
                    max_tokens=self.max_output_tokens,
                )

                response_text = response.choices[0].message.content or "{}"
                data = json.loads(response_text)

                # Validate with the schema Marker requested.
                validated = response_schema.model_validate(data)

                if block and getattr(response, "usage", None):
                    block.update_metadata(
                        llm_tokens_used=response.usage.total_tokens,
                        llm_request_count=1,
                    )

                return validated.model_dump()

            except Exception:
                if tries == total_tries:
                    break
                time.sleep(tries * self.retry_wait_time)

        return {}


path: scripts/marker_openai_compat_service.py
content: from __future__ import annotations

import base64
import json
import time
from io import BytesIO
from typing import Annotated, List

import PIL
import openai
from openai import APITimeoutError, RateLimitError
from pydantic import BaseModel

from marker.logger import get_logger
from marker.schema.blocks import Block
from marker.services import BaseService

logger = get_logger()


class OpenAICompatJSONService(BaseService):
    """OpenAI-compatible multimodal service for Marker.

    Marker upstream's OpenAIService uses `client.beta.chat.completions.parse(...)` with
    a Pydantic schema. Many OpenAI-compatible proxies (including Gemini gateways)
    don't implement that endpoint/behavior.

    This service instead:
    - calls `client.chat.completions.create(...)`
    - requests JSON-only output via `response_format={"type": "json_object"}`
    - validates against the provided Pydantic schema

    It is intended for OpenAI-compatible servers at `openai_base_url`.
    """

    openai_base_url: Annotated[
        str, "The base url to use for OpenAI-like models. No trailing slash."
    ] = "https://api.openai.com/v1"
    openai_model: Annotated[str, "The model name to use for OpenAI-like model."] = (
        "gpt-4o-mini"
    )
    openai_api_key: Annotated[str, "The API key to use for the OpenAI-like service."] = (
        None
    )
    openai_image_format: Annotated[
        str, "The image format to use for the OpenAI-like service. Use 'png' for better compatibility."
    ] = "png"

    def _image_part(self, img: PIL.Image.Image) -> dict:
        img_fmt = self.openai_image_format
        image_bytes = BytesIO()
        img.save(image_bytes, format=img_fmt)
        b64 = base64.b64encode(image_bytes.getvalue()).decode("utf-8")
        return {
            "type": "image_url",
            "image_url": {"url": f"data:image/{img_fmt.lower()};base64,{b64}"},
        }

    def process_images(self, images: List[PIL.Image.Image]) -> List[dict]:
        if isinstance(images, PIL.Image.Image):
            images = [images]
        return [self._image_part(img) for img in images]

    def get_client(self) -> openai.OpenAI:
        return openai.OpenAI([REDACTED_SECRET], base_url=self.openai_base_url)

    def __call__(
        self,
        prompt: str,
        image: PIL.Image.Image | List[PIL.Image.Image] | None,
        block: Block | None,
        response_schema: type[BaseModel],
        max_retries: int | None = None,
        timeout: int | None = None,
    ):
        if max_retries is None:
            max_retries = self.max_retries
        if timeout is None:
            timeout = self.timeout

        client = self.get_client()
        image_data = self.format_image_for_llm(image)

        schema_json = response_schema.model_json_schema()
        strict_prompt = (
            "You MUST output a single JSON object only (no Markdown, no code fences).\n"
            "The JSON must match this JSON schema exactly.\n\n"
            f"JSON schema: {json.dumps(schema_json, ensure_ascii=True)}\n\n"
            "Task:\n"
            f"{prompt}"
        )

        messages = [
            {
                "role": "user",
                "content": [
                    *image_data,
                    {"type": "text", "text": strict_prompt},
                ],
            }
        ]

        total_tries = max_retries + 1
        for tries in range(1, total_tries + 1):
            try:
                def _create_chat_completion(*, use_response_format: bool):
                    kwargs = {
                        "extra_headers": {
                            "X-Title": "Marker",
                            "HTTP-Referer": "https://github.com/datalab-to/marker",
                        },
                        "model": self.openai_model,
                        "messages": messages,
                        "timeout": timeout,
                    }
                    if use_response_format:
                        # Some OpenAI-compatible providers/models don't support `response_format`.
                        kwargs["response_format"] = {"type": "json_object"}
                    return client.chat.completions.create(**kwargs)

                try:
                    resp = _create_chat_completion(use_response_format=True)
                except Exception as e:
                    # Volcengine `ark-code-latest` (and some other OpenAI-compatible models)
                    # rejects `response_format.type=json_object`.
                    msg = str(e)
                    if (
                        "response_format" in msg
                        and "json_object" in msg
                        and ("not supported" in msg or "InvalidParameter" in msg)
                    ):
                        logger.warning(
                            "Provider/model does not support response_format=json_object; retrying without it"
                        )
                        resp = _create_chat_completion(use_response_format=False)
                    else:
                        raise

                text = resp.choices[0].message.content or "{}"

                def _fix_invalid_json_escapes(s: str) -> str:
                    # Fix common invalid backslash escapes inside JSON strings.
                    # Example: "C:\path\file" is valid, but "C:\path\new\t" might contain "\p" etc.
                    valid_escapes = {"\"", "\\", "/", "b", "f", "n", "r", "t"}

                    out: list[str] = []
                    in_str = False
                    i = 0
                    while i < len(s):
                        ch = s[i]
                        if ch == '"':
                            # toggle string context if not escaped
                            backslashes = 0
                            j = i - 1
                            while j >= 0 and s[j] == "\\":
                                backslashes += 1
                                j -= 1
                            if backslashes % 2 == 0:
                                in_str = not in_str
                            out.append(ch)
                            i += 1
                            continue

                        if in_str and ch == "\\" and i + 1 < len(s):
                            nxt = s[i + 1]
                            if nxt in valid_escapes:
                                out.append(ch)
                                out.append(nxt)
                                i += 2
                                continue
                            if nxt == "u" and i + 5 < len(s):
                                hex_part = s[i + 2 : i + 6]
                                if all(c in "0123456789abcdefABCDEF" for c in hex_part):
                                    out.append(ch)
                                    out.append(nxt)
                                    out.append(hex_part)
                                    i += 6
                                    continue
                            # invalid escape: turn `\X` into `\\X`
                            out.append("\\\\")
                            i += 1
                            continue

                        out.append(ch)
                        i += 1

                    return "".join(out)

                def _json_loads_robust(s: str):
                    s2 = s.strip()
                    decoder = json.JSONDecoder()
                    try:
                        return json.loads(s2)
                    except json.JSONDecodeError as e:
                        # Some providers may append extra text after JSON.
                        try:
                            obj, _idx = decoder.raw_decode(s2.lstrip())
                            return obj
                        except json.JSONDecodeError:
                            pass

                        if "Invalid \\escape" in str(e):
                            fixed = _fix_invalid_json_escapes(s2)
                            try:
                                return json.loads(fixed)
                            except json.JSONDecodeError:
                                obj, _idx = decoder.raw_decode(fixed.lstrip())
                                return obj

                        raise

                # Robust JSON extraction (some servers may add leading whitespace)
                text_stripped = text.strip()
                try:
                    data = _json_loads_robust(text_stripped)
                except Exception:
                    start = text_stripped.find("{")
                    end = text_stripped.rfind("}")
                    if start == -1 or end == -1 or end <= start:
                        raise
                    data = _json_loads_robust(text_stripped[start : end + 1])

                validated = response_schema.model_validate(data)

                if block:
                    usage = getattr(resp, "usage", None)
                    total_tokens = getattr(usage, "total_tokens", None)
                    if total_tokens is not None:
                        block.update_metadata(llm_tokens_used=total_tokens, llm_request_count=1)

                return validated.model_dump()

            except (APITimeoutError, RateLimitError) as e:
                if tries == total_tries:
                    logger.error(
                        f"Rate limit/timeout error: {e}. Max retries reached. Giving up. (Attempt {tries}/{total_tries})"
                    )
                    break
                wait_time = tries * self.retry_wait_time
                logger.warning(
                    f"Rate limit/timeout error: {e}. Retrying in {wait_time} seconds... (Attempt {tries}/{total_tries})"
                )
                time.sleep(wait_time)
            except Exception as e:
                logger.error(f"OpenAI-compatible inference failed: {e}")
                break

        return {}


path: scripts/pdf2latex.py
content: #!/usr/bin/env python3
"""
PDF to LaTeX/Markdown Converter using Nougat OCR

This script extracts text and mathematical formulas from academic PDF papers
and converts them to LaTeX-compatible Markdown format.

Environment: D:\anaconda3\envs\pdf-extractor
"""

import argparse
import torch
from pathlib import Path
import fitz  # PyMuPDF
from PIL import Image
import io
import sys
from tqdm import tqdm


def load_model(model_tag="0.1.0-base", device=None):
    """Load Nougat model with GPU support if available."""
    from nougat import NougatModel
    from nougat.utils.checkpoint import get_checkpoint

    print(f"Loading Nougat model ({model_tag})...")
    checkpoint = get_checkpoint(model_tag=model_tag)
    model = NougatModel.from_pretrained(checkpoint)

    if device is None:
        device = "cuda" if torch.cuda.is_available() else "cpu"

    if device == "cuda":
        model = model.to("cuda")
        print(f"Using GPU: {torch.cuda.get_device_name(0)}")
    else:
        print("Using CPU")

    model.eval()
    return model, device


def render_page(page, dpi=300):
    """Render a PDF page to PIL Image."""
    mat = fitz.Matrix(dpi / 72, dpi / 72)
    pix = page.get_pixmap(matrix=mat)
    img_data = pix.tobytes("png")
    return Image.open(io.BytesIO(img_data)).convert("RGB")


def process_pdf(pdf_path, model, device, pages=None, dpi=300):
    """
    Process a PDF file and extract text/formulas.

    Args:
        pdf_path: Path to PDF file
        model: Loaded Nougat model
        device: 'cuda' or 'cpu'
        pages: List of page numbers (0-indexed) or None for all pages
        dpi: Resolution for rendering (default 300)

    Returns:
        List of (page_num, text) tuples
    """
    from nougat.utils.device import move_to_device

    doc = fitz.open(pdf_path)
    total_pages = len(doc)

    if pages is None:
        pages = list(range(total_pages))
    else:
        pages = [p for p in pages if 0 <= p < total_pages]

    print(f"Processing {len(pages)} pages from {pdf_path}")

    results = []
    for page_num in tqdm(pages, desc="Converting"):
        page = doc[page_num]
        img = render_page(page, dpi)

        # Prepare for model
        img_tensor = model.encoder.prepare_input(img, random_padding=False)
        img_tensor = move_to_device(img_tensor.unsqueeze(0), device)

        # Inference
        with torch.no_grad():
            output = model.inference(image_tensors=img_tensor)

        text = output["predictions"][0]
        results.append((page_num + 1, text))  # 1-indexed for output

    doc.close()
    return results


def save_results(results, output_path, format="mmd"):
    """Save extraction results to file."""
    if format == "mmd":
        # Single markdown file
        content = "\n\n".join([text for _, text in results])
        Path(output_path).write_text(content, encoding="utf-8")
    elif format == "pages":
        # Separate file per page
        output_dir = Path(output_path)
        output_dir.mkdir(parents=True, exist_ok=True)
        for page_num, text in results:
            page_file = output_dir / f"page_{page_num:03d}.mmd"
            page_file.write_text(text, encoding="utf-8")

    print(f"Saved to: {output_path}")


def main():
    parser = argparse.ArgumentParser(
        description="Convert PDF to LaTeX/Markdown using Nougat OCR"
    )
    parser.add_argument("pdf", help="Path to PDF file")
    parser.add_argument("-o", "--output", help="Output file or directory")
    parser.add_argument("-p", "--pages", help='Page range (e.g., "0-5" or "1,3,5")')
    parser.add_argument("-m", "--model", default="0.1.0-base", help="Model tag")
    parser.add_argument("--dpi", type=int, default=300, help="Render DPI")
    parser.add_argument("--cpu", action="store_true", help="Force CPU mode")
    parser.add_argument(
        "--per-page", action="store_true", help="Save each page separately"
    )

    args = parser.parse_args()

    # Parse page range
    pages = None
    if args.pages:
        pages = []
        for part in args.pages.split(","):
            if "-" in part:
                start, end = part.split("-")
                pages.extend(range(int(start), int(end) + 1))
            else:
                pages.append(int(part))

    # Default output
    pdf_path = Path(args.pdf)
    if args.output:
        output_path = args.output
    else:
        if args.per_page:
            output_path = pdf_path.parent / f"{pdf_path.stem}_pages"
        else:
            output_path = pdf_path.with_suffix(".mmd")

    # Load model
    device = "cpu" if args.cpu else None
    model, device = load_model(args.model, device)

    # Process
    results = process_pdf(args.pdf, model, device, pages, args.dpi)

    # Save
    format = "pages" if args.per_page else "mmd"
    save_results(results, output_path, format)

    # Print summary
    total_chars = sum(len(text) for _, text in results)
    print(f"\nSummary:")
    print(f"  Pages processed: {len(results)}")
    print(f"  Total characters: {total_chars}")
    print(f"  Output: {output_path}")


if __name__ == "__main__":
    main()


path: scripts/pdf2md_marker.py
content: #!/usr/bin/env python3
"""PDF to Markdown converter using Marker.

This script extracts text and mathematical formulas from academic PDF papers.

It supports two modes:
- Pure Marker (local models only)
- Marker hybrid mode with an LLM (via Marker LLM services)

The LLM mode is useful for improving table formatting, inline math, and
cross-page structure.

Environment: D:/anaconda3/envs/pdf-extractor
Requirements: marker-pdf, torch>=2.1.0
"""

from __future__ import annotations

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

# Ensure this script folder is importable by Marker config.
sys.path.insert(0, str(Path(__file__).parent))


# Volcengine (Ark / Coding Plan) OpenAI-compatible endpoint
# NOTE: no trailing slash.
DEFAULT_ARK_OPENAI_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3"

# Prefer env var to avoid hardcoding secrets in code.
# Fallback kept for convenience (matches user's provided key).
DEFAULT_ARK_OPENAI_API_KEY = os.environ.get("VOLCENGINE_CODING_PLAN_API_KEY") or os.environ.get(
    "ARK_API_KEY"
) or "991ee1db-32ff-4884-b45a-155fa632ecbb"

# As requested: use ark-code-latest; backend routes to an appropriate model.
DEFAULT_ARK_OPENAI_MODEL = "ark-code-latest"


def convert_pdf(
    pdf_path: str,
    output_dir: Optional[str] = None,
    *,
    force_ocr: bool = False,
    batch_multiplier: int = 2,
    languages: Optional[list[str]] = None,
    page_range: Optional[str] = None,
    use_llm: bool = False,
    llm_service: Optional[str] = None,
    openai_base_url: Optional[str] = None,
    openai_api_key: Optional[str] = None,
    openai_model: Optional[str] = None,
) -> tuple[str, dict]:
    """Convert a PDF file to Markdown using Marker.

    Args:
        pdf_path: Path to PDF file.
        output_dir: Output directory (default: same as PDF).
        force_ocr: Force OCR even for text PDFs.
        batch_multiplier: Batch size multiplier for GPU.
        languages: List of languages (e.g., ['Chinese', 'English']).
        page_range: Marker page range string (e.g., "0", "0,5-10").
        use_llm: Enable Marker hybrid mode (uses an LLM service).
        llm_service: Marker LLM service class path.
        openai_base_url: Base URL for OpenAI-compatible endpoint.
        openai_api_key: API key for OpenAI-compatible endpoint.
        openai_model: Model name for OpenAI-compatible endpoint.

    Returns:
        (markdown_text, metadata_dict)
    """

    # Local imports so the module is importable even if marker isn't installed.
    from marker.config.parser import ConfigParser
    from marker.converters.pdf import PdfConverter
    from marker.models import create_model_dict
    from marker.output import text_from_rendered

    pdf_file = Path(pdf_path).resolve()

    # Defaults
    if languages is None:
        languages = ["Chinese", "English"]

    # Marker config
    config: dict = {
        "output_format": "markdown",
        "force_ocr": force_ocr,
        "languages": languages,
        "batch_multiplier": batch_multiplier,
    }

    if page_range:
        # Marker CLI calls this `--page_range`.
        config["page_range"] = page_range

    if use_llm:
        config["use_llm"] = True
        if llm_service:
            # Marker CLI uses `--llm_service`.
            config["llm_service"] = llm_service

        # If user supplies OpenAI-compatible config, pass through.
        if openai_base_url:
            config["openai_base_url"] = openai_base_url
        if openai_api_key:
            config["openai_api_key"] = openai_api_key
        if openai_model:
            config["openai_model"] = openai_model

    # Marker sometimes expects this to exist even if unused.
    os.environ.setdefault("TESSDATA_PREFIX", "")

    print("Loading Marker models...")
    print(f"  File: {pdf_file}")
    print(f"  Languages: {languages}")
    print(f"  Force OCR: {force_ocr}")
    if page_range:
        print(f"  Page range: {page_range}")
    print(f"  LLM hybrid mode: {use_llm}")
    if use_llm:
        if llm_service:
            print(f"  LLM service: {llm_service}")
        if openai_base_url:
            print(f"  OpenAI base_url: {openai_base_url}")
        if openai_model:
            print(f"  OpenAI model: {openai_model}")

    model_dict = create_model_dict()
    config_parser = ConfigParser(config)

    converter = PdfConverter(
        artifact_dict=model_dict,
        config=config_parser.generate_config_dict(),
        processor_list=config_parser.get_processors(),
        renderer=config_parser.get_renderer(),
        llm_service=config_parser.get_llm_service(),
    )

    rendered = converter(str(pdf_file))
    markdown_text, metadata, images = text_from_rendered(rendered)

    if output_dir:
        out_dir = Path(output_dir)
        out_dir.mkdir(parents=True, exist_ok=True)

        md_file = out_dir / f"{pdf_file.stem}.md"
        md_file.write_text(markdown_text, encoding="utf-8")
        print(f"Saved: {md_file}")

        if images:
            img_dir_name = f"{pdf_file.stem}_images"
            # In segmented runs (page_range specified), avoid collisions by writing
            # images to a page-range-specific folder.
            if page_range:
                safe_range = (
                    page_range.replace(" ", "")
                    .replace(",", "_")
                    .replace("-", "-")
                    .replace(":", "_")
                )
                img_dir_name = f"{pdf_file.stem}_images_p{safe_range}"

            img_dir = out_dir / img_dir_name
            img_dir.mkdir(exist_ok=True)

            saved = 0
            for img_name, img_data in getattr(images, "items", lambda: [])():
                img_path = img_dir / img_name

                # Marker may return raw bytes OR PIL.Image objects.
                if isinstance(img_data, (bytes, bytearray, memoryview)):
                    img_path.write_bytes(img_data)
                    saved += 1
                    continue

                if hasattr(img_data, "save"):
                    img_data.save(img_path)
                    saved += 1
                    continue

            print(f"Saved {saved} images to: {img_dir}")

    return markdown_text, metadata


def convert_pdf_cli(pdf_path: str, output_path: Optional[str] = None, **kwargs) -> str:
    """CLI-friendly wrapper.

    Args:
        pdf_path: Path to PDF file.
        output_path: Output file path (.md) or directory.
        **kwargs: Passed to convert_pdf.

    Returns:
        Path to the output markdown file.
    """

    pdf_file = Path(pdf_path).resolve()
    if not pdf_file.exists():
        raise FileNotFoundError(f"PDF not found: {pdf_file}")

    if output_path:
        out_path = Path(output_path)
        if out_path.suffix.lower() == ".md":
            output_dir = out_path.parent
            output_file = out_path
        else:
            output_dir = out_path
            output_file = output_dir / f"{pdf_file.stem}.md"
    else:
        output_dir = pdf_file.parent
        output_file = output_dir / f"{pdf_file.stem}.md"

    output_dir.mkdir(parents=True, exist_ok=True)

    markdown_text, _metadata = convert_pdf(str(pdf_file), str(output_dir), **kwargs)

    # If user requested a specific .md filename, rename.
    if output_file.name != f"{pdf_file.stem}.md":
        default_md = output_dir / f"{pdf_file.stem}.md"
        if default_md.exists():
            default_md.replace(output_file)
            print(f"Renamed output to: {output_file}")
        else:
            output_file.write_text(markdown_text, encoding="utf-8")

    return str(output_file)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Convert PDF to Markdown using Marker (supports Chinese + Math)"
    )
    parser.add_argument("pdf", help="Path to PDF file")
    parser.add_argument("-o", "--output", help="Output file (.md) or directory")
    parser.add_argument(
        "--force-ocr", action="store_true", help="Force OCR even for text PDFs"
    )
    parser.add_argument(
        "--batch-multiplier",
        type=int,
        default=2,
        help="Batch size multiplier (default: 2)",
    )
    parser.add_argument(
        "--languages",
        nargs="+",
        default=["Chinese", "English"],
        help="Languages in the document (default: Chinese English)",
    )
    parser.add_argument(
        "--page-range",
        default=None,
        help='Page range string, e.g. "0" or "0,5-10" (0-based pages)',
    )

    llm = parser.add_argument_group("LLM hybrid mode")
    llm.add_argument(
        "--use-llm",
        action="store_true",
        help="Enable Marker hybrid mode using an LLM service",
    )
    llm.add_argument(
        "--llm-service",
        default=None,
        help=(
            "Marker LLM service class path, e.g. marker.services.openai.OpenAIService"
        ),
    )
    llm.add_argument("--openai-base-url", default=None)
    llm.add_argument("--openai-api-key", default=None)
    llm.add_argument("--openai-model", default=None)

    llm.add_argument(
        "--ark-code-latest",
        action="store_true",
        help=(
            "Shortcut: use Volcengine Ark (Coding Plan) via OpenAI-compatible endpoint "
            f"({DEFAULT_ARK_OPENAI_BASE_URL}), model={DEFAULT_ARK_OPENAI_MODEL}"
        ),
    )

    args = parser.parse_args()

    use_llm = args.use_llm
    llm_service = args.llm_service
    openai_base_url = args.openai_base_url
    openai_api_key = args.openai_api_key
    openai_model = args.openai_model

    if args.ark_code_latest:
        use_llm = True
        # Use our robust OpenAI-compatible JSON-only service.
        llm_service = "marker_openai_compat_service.OpenAICompatJSONService"
        openai_base_url = DEFAULT_ARK_OPENAI_BASE_URL
        openai_api_key = DEFAULT_ARK_OPENAI_API_KEY
        openai_model = DEFAULT_ARK_OPENAI_MODEL

    try:
        output_file = convert_pdf_cli(
            args.pdf,
            args.output,
            force_ocr=args.force_ocr,
            batch_multiplier=args.batch_multiplier,
            languages=args.languages,
            page_range=args.page_range,
            use_llm=use_llm,
            llm_service=llm_service,
            openai_base_url=openai_base_url,
            openai_api_key=openai_api_key,
            openai_model=openai_model,
        )
        print(f"\nConversion complete: {output_file}")

    except Exception as exc:
        print(f"\nError: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


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