Skill: speech2srt/ocr2markdown (version 1.0.1)

--- name: ocr2markdown description: "Document OCR and parsing — converts PDF/images to Markdown on remote L4 GPU via Modal. Trigger when user says: OCR, PDF to markdown, parse PDF, extract text from PDF, 文档识别, PDF转Markdown, 扫描件识别. Takes local PDF/image files and returns Markdown with layout, tables, formulas, and OCR preserved." version: v1.0.1 --- # OCR2Markdown MinerU document parsing pipeline on Modal L4 GPU — PDF/image → Markdown with layout, tables, formulas, and OCR preserved. **Pipeline code is bundled** at `./src/ocr2markdown.py`. After `npx skills add`, runs from any directory. ## Workflow ### 1. Prepare slug and identify files **Slug** = task identifier (volume directory name). Use user-provided value, or generate `ocr_YYYYMMDD_HHMMSS` if none given. **Directory input?** Scan for PDF (`.pdf`), list with index, ask user to confirm selection. **Specific files?** Use directly, no listing needed. ### 2. Upload to volume Ensure both volumes exist (idempotent): ```bash modal volume create speech2srt-data 2>/dev/null || true modal volume create speech2srt-models 2>/dev/null || true ``` Upload each file: ```bash modal volume put speech2srt-data <local_file> <slug>/upload/ ``` Modal `put` auto-creates remote directories. ### 3. Run pipeline ```bash modal run ./src/ocr2markdown.py --slug <slug> ``` Pipeline finds all `.pdf` files in `<slug>/upload/` on the volume and processes them one by one. **Ctrl+C?** Stop cleanly, report progress. Re-run with same slug — already-processed PDFs are skipped automatically. ### 4. Download results Output directory structure: ``` <slug>/output/<stem>_ocr/ ├── <stem>.md # Main markdown output ├── images/ # Extracted images referenced by markdown └── *.pdf, *.json # Auxiliary outputs (layout, model data) ``` Download the entire output folder: ```bash modal volume get speech2srt-data <slug>/output/ <local_destination>/ ``` Preserve original directory tree. ### 5. Report ``` Done. Parsed N PDF(s) in Xs Results: - <output_dir>/<stem>_ocr/<stem>.md - <output_dir>/<stem>_ocr/images/ ``` ## Setup Before first run, verify: 1. **Python 3.9+** — `python -V` 2. **Modal CLI** — `modal config show`: - `token_id` null → `modal setup` to authenticate - command not found → `pip install modal` then `modal setup` 3. **Modal free tier** — L4 GPU is $0.80/hr. New accounts get **$30 free credits/month** (~37 hours of L4). No setup needed beyond authentication. ## Performance | PDF Size | Pages | Time/PDF | |----------|-------|----------| | ~40-55 MB | varies | ~110-130s each | Pipeline auto-skips PDFs with existing output.

path: src/config.py
content: """
OCR2Markdown pipeline constants.
"""

# GPU type for OCR workload
GPU_TYPE = "L4"

# Volume names (shared with audio pipelines via same Modal account)
VOLUME_DATA_NAME = "speech2srt-data"
VOLUME_MODELS_NAME = "speech2srt-models"

# Volume mount points
MOUNT_DATA = "/mnt/data"
MOUNT_MODELS = "/mnt/models"

# Directory names under slug
DIR_UPLOAD = "upload"
DIR_OUTPUT = "output"

# OCR timeout (seconds) — 2 hours for large PDFs
TIMEOUT_OCR2MD = 7200

# App name
APP_NAME = "speech2srt.com"


path: src/images.py
content: import os
from pathlib import Path
import modal
import config as ocr2md_config


volume_data = modal.Volume.from_name(
    ocr2md_config.VOLUME_DATA_NAME, create_if_missing=True
)
volume_models = modal.Volume.from_name(
    ocr2md_config.VOLUME_MODELS_NAME, create_if_missing=True
)


_image = (
    modal.Image.from_registry("vllm/vllm-openai:v0.11.2", add_python="3.12")
    .entrypoint([])
    .run_commands(
        "python3 -m pip install --no-cache-dir 'mineru[core]>=3.0.0,<4.0.0' opencv-python-headless --break-system-packages -q"
    )
    .env(
        {
            "MINERU_MODEL_SOURCE": "huggingface",
            "HF_HUB_DISABLE_PROGRESS": "1",
            "TQDM_DISABLE": "1",
        }
    )
    .add_local_file(
        os.path.dirname(__file__) + "/config.py",
        remote_path="/root/src/config.py",
    )
    .add_local_file(
        os.path.dirname(__file__) + "/images.py",
        remote_path="/root/src/images.py",
    )
)


image_ocr2markdown = _image


app = modal.App(ocr2md_config.APP_NAME)


path: src/ocr2markdown.py
content: import modal
import shutil
import subprocess
import sys
import time
from pathlib import Path

sys.path.insert(0, "/root/src")
import config
import images


def _bootstrap_cache():
    models_dir = Path(config.MOUNT_MODELS)
    cache_dir = Path.home() / ".cache"
    if not cache_dir.is_symlink():
        if cache_dir.exists():
            shutil.rmtree(cache_dir)
        cache_dir.parent.mkdir(parents=True, exist_ok=True)
        cache_dir.symlink_to(models_dir, target_is_directory=True)
    models_dir.mkdir(parents=True, exist_ok=True)


def _move_into(src: Path, dst_dir: Path):
    if src.name == "images" and (dst_dir / "images").exists():
        return
    dst = dst_dir / src.name
    if dst.exists():
        return
    shutil.move(str(src), str(dst))


def _flatten_and_save(work_dir: Path, stem: str):
    nested_pdf_dir = work_dir / stem
    if nested_pdf_dir.exists():
        for f in nested_pdf_dir.iterdir():
            if f.is_dir():
                if f.name == "auto":
                    for f2 in f.iterdir():
                        _move_into(f2, work_dir)
                else:
                    _move_into(f, work_dir)
            else:
                _move_into(f, work_dir)
        shutil.rmtree(nested_pdf_dir)
    nested_auto_dir = work_dir / "auto"
    if nested_auto_dir.exists():
        for f in nested_auto_dir.iterdir():
            _move_into(f, work_dir)
        shutil.rmtree(nested_auto_dir)


@images.app.function(
    image=images.image_ocr2markdown,
    gpu=config.GPU_TYPE,
    volumes={
        config.MOUNT_DATA: images.volume_data,
        config.MOUNT_MODELS: images.volume_models,
    },
    timeout=config.TIMEOUT_OCR2MD,
    retries=0,
)
def ocr2markdown(slug: str) -> list[dict]:
    _bootstrap_cache()
    upload_dir = Path(config.MOUNT_DATA) / slug / config.DIR_UPLOAD
    output_base = Path(config.MOUNT_DATA) / slug / config.DIR_OUTPUT
    pdf_files = sorted(upload_dir.glob("*.pdf"))
    if not pdf_files:
        return []
    results = []
    for pdf_path in pdf_files:
        stem = pdf_path.stem
        work_dir = output_base / f"{stem}_ocr"
        md_file = work_dir / f"{stem}.md"
        if md_file.exists():
            print(f"  [skip] {stem}.pdf")
            continue
        work_dir.mkdir(parents=True, exist_ok=True)
        print(f"  [proc] {stem}.pdf")
        t1 = time.monotonic()
        res = subprocess.run(
            ["mineru", "-p", str(pdf_path), "-o", str(work_dir), "-b", "pipeline"],
            capture_output=True,
            text=True,
        )
        elapsed = time.monotonic() - t1
        if res.returncode != 0:
            print(f"    ERROR: {res.stderr[-500:]}")
            continue
        _flatten_and_save(work_dir, stem)
        if md_file.exists():
            print(f"    done in {elapsed:.1f}s")
            results.append({"pdf": pdf_path.name, "elapsed": elapsed})
    return results


@images.app.local_entrypoint()
def main(slug: str) -> None:
    print(f"[ocr2markdown] processing: {slug}")
    results = ocr2markdown.remote(slug)
    print(f"\n[ocr2markdown] done. {len(results)} PDF(s) processed")


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