
Mineru Converter
- 1 installs
- Updated May 19, 2026
- etiwo/mineru-converter
Converts PDF, DOCX, PPTX, XLSX, EPUB, and image files to Markdown using MinerU, with incremental detection, page ranges, OCR, and image extraction.
About
Batch-converts documents to Markdown via MinerU (EPUB via a built-in converter), skipping already-converted files by SHA256 and extracting images into Obsidian-compatible paths. A developer uses it to feed documents into a knowledge pipeline or convert a folder of PDFs.
- Incremental conversion skips already-converted files by hash
- Supports page ranges, OCR method, language, and image path rewriting
Mineru Converter by the numbers
- 1 all-time installs (skills.sh)
- Ranked #565 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/etiwo/mineru-converter --skill mineru-converterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 19, 2026 |
| Repository | etiwo/mineru-converter ↗ |
What it does
Converts PDF, DOCX, PPTX, XLSX, EPUB, and image files to Markdown using MinerU, with incremental detection, page ranges, OCR, and image extraction.
Files
MinerU Document Converter
Convert documents (PDF, DOCX, PPTX, XLSX, EPUB, PNG, JPG, JPEG, BMP, TIFF) to Markdown.
What I do
- Convert single files or batch directories to Markdown
- Incremental conversion — skips already-converted files (SHA256 based)
- Extract and organize images into
raw/attachments/<hash>/ - Rewrite image paths in Markdown to be Obsidian-compatible
- Clean up MinerU temporary files
- Support page range, OCR method, and language selection
- Convert EPUB files to Markdown with image extraction (no MinerU needed)
When to use me
Use when the user wants to:
- Convert a PDF or other document to Markdown
- Convert a specific page range of a PDF
- Batch convert a folder of documents
- Check which files are already converted and which need conversion
- Integrate document conversion into a knowledge pipeline
Source and Output
- Source: Documents in the project's
inbox/directory - Output: Converted files in the project's
raw/directory - Manifest:
raw/manifest.jsontracks conversion state
Prerequisites
- MinerU must be installed for PDF/DOCX/PPTX/XLSX/image conversion (
mineru --version) - EPUB conversion uses a built-in converter and does not require MinerU
- Python packages:
ebooklib,html2text(installed automatically withpip install -r requirements.txt) - The converter automatically detects if MinerU is installed before converting
- If MinerU is not found, the converter will prompt the user to install it
Commands
Always run commands from the project directory, or use the full path. The converter lives at: ~/.config/opencode/skills/mineru-converter/
Convert a single file
cd <project_dir> && python3 ~/.config/opencode/skills/mineru-converter/run.py convert --file <path> [--force] [--pages 3-5] [--method ocr] [--lang en]| Option | Description | Example |
|---|---|---|
--file | Path to file to convert (required) | --file report.pdf |
--force | Force re-conversion even if already done | --force |
--pages | Page range to convert (1-indexed, only for PDF) | --pages 3-5 |
--method | PDF parsing method: auto, txt, or ocr | --method ocr |
--lang | Document language code | --lang en |
Convert all files in a directory
cd <project_dir> && python3 ~/.config/opencode/skills/mineru-converter/run.py convert --dir <path> [--force] [--workers 2] [--method ocr] [--lang en]| Option | Description | Example |
|---|---|---|
--dir | Directory of files to convert (required) | --dir inbox/ |
--force | Force re-conversion even if already done | --force |
--workers | Parallel workers for batch (default: 1) | --workers 2 |
--method | PDF parsing method applied to all files | --method ocr |
--lang | Document language applied to all files | --lang en |
Note:--pagesis only allowed with--file, not with--dir.
View conversion plan (no execution)
python3 ~/.config/opencode/skills/mineru-converter/run.py plan --dir <path> [--json]Check conversion status
python3 ~/.config/opencode/skills/mineru-converter/run.py statusOutput Structure
<output_dir>/
├── document.md # Converted markdown
├── attachments/
│ └── <hash8>/ # Images grouped by file hash
│ ├── image1.jpg
│ └── image2.png
└── manifest.json # Conversion recordsJSON Output
Add --json flag to get structured output for parsing:
{
"scanned": 3,
"processed": 1,
"skipped": 2,
"failed": 0,
"items": [
{
"path": "/abs/path/to/file.pdf",
"status": "success",
"details": {
"md_path": "document.md",
"images": 5
}
}
]
}Supported Formats
PDF, DOCX, PPTX, XLSX, EPUB, PNG, JPG, JPEG, BMP, TIFF
Page Range Syntax
Use --pages to specify a range of pages to convert (PDF only):
# Convert pages 3 to 5
convert --file doc.pdf --pages 3-5
# Convert single page 10
convert --file doc.pdf --pages 10Pages are 1-indexed (human-friendly) and are automatically converted to 0-indexed for MinerU.
OCR Method
Use --method to control PDF parsing strategy:
| Method | Description | Use case |
|---|---|---|
auto | Auto-detect (default) | Most documents |
txt | Text extraction only | Documents with extractable text |
ocr | OCR recognition | Scanned/image PDFs |
# Use OCR for scanned PDFs
convert --file scanned.pdf --method ocr
# Use text extraction for digital PDFs
convert --file digital.pdf --method txtLanguage
Use --lang to specify the document language for better OCR accuracy:
# English document
convert --file doc.pdf --lang en
# Japanese document
convert --file doc.pdf --lang ja
# Chinese (default)
convert --file doc.pdf --lang chConfiguration
Edit config.yaml to change defaults:
mineru:
command: "mineru" # or absolute path to venv/bin/mineru
args:
backend: "pipeline"
model: "auto"
language: "ch" # default language
method: "auto" # default parsing methodError Handling
- Unsupported file formats are skipped (not failed)
- Individual file failures do not stop the batch
- Failed files are logged in manifest.json with error details
- MinerU installation is checked before every conversion
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
# MinerU auto-installed venv
.mineru_venv/
# OS
.DS_Store
Thumbs.db
# Generated output during local testing
raw/
# MinerU Converter configuration
# Copy this file to config.yaml and customize as needed.
# Default output directory for converted files
output_dir: "./raw"
# MinerU CLI settings
mineru:
command: "mineru"
args:
backend: "pipeline"
model: "auto"
language: "ch"
method: "auto"
# Supported file extensions (lowercase)
supported_extensions:
- ".pdf"
- ".docx"
- ".pptx"
- ".xlsx"
- ".png"
- ".jpg"
- ".jpeg"
- ".bmp"
- ".tiff"
- ".tif"
# Manifest settings
manifest:
filename: "manifest.json"
version: "1.0"
# File organization
organizer:
attachments_dir: "attachments"
hash_prefix_length: 8
# MinerU Converter configuration
# Customize defaults by editing this file.
# Output directory for converted files (relative to project root)
output_dir: "./raw"
# MinerU CLI settings
mineru:
command: "mineru"
args:
backend: "pipeline"
model: "auto"
language: "ch"
method: "auto"
# Supported file extensions
supported_extensions:
- ".pdf"
- ".docx"
- ".pptx"
- ".xlsx"
- ".png"
- ".jpg"
- ".jpeg"
- ".bmp"
- ".tiff"
- ".tif"
- ".epub"
# Manifest settings
manifest:
filename: "manifest.json"
version: "1.0"
# File organization
organizer:
attachments_dir: "attachments"
hash_prefix_length: 8
mineru-converter Developer Memo
Technical reference for developers. Contains CLI commands, architecture details, and API reference.
CLI Commands
convert — Convert files
# Convert a single file
python3 run.py convert --file inbox/document.pdf
# Batch convert
python3 run.py convert --dir inbox/ --workers 2
# Convert specific pages (PDF only)
python3 run.py convert --file doc.pdf --pages 3-5
# Use OCR for scanned documents
python3 run.py convert --file scanned.pdf --method ocr
# Convert with language setting
python3 run.py convert --file doc.pdf --lang en
# Force re-conversion
python3 run.py convert --file doc.pdf --forceCLI Options
| Option | Description | Example |
|---|---|---|
--file <path> | Convert a single file | --file doc.pdf |
--dir <path> | Convert all files in directory | --dir inbox/ |
--output-dir <dir> | Custom output directory | --output-dir ./output |
--force | Re-convert even if already done | --force |
--workers <N> | Parallel workers (default: 1) | --workers 2 |
--verbose | Print MinerU output | --verbose |
--json | Output as JSON | --json |
--pages <range> | Page range for PDF (1-indexed) | --pages 18-28 |
| `--method <auto | txt | ocr>` |
--lang <code> | Document language | --lang en |
Note: --pages is only allowed with --file, not with --dir.
plan — View conversion plan
python3 run.py plan --dir inbox/ --jsonstatus — Check conversion status
python3 run.py status
python3 run.py status --jsonConfiguration
Edit config.yaml:
mineru:
command: "mineru" # Or absolute path to venv/bin/mineru
args:
backend: "pipeline"
model: "auto"
language: "ch" # Default language
method: "auto" # Default parsing method (auto/txt/ocr)Architecture
- PDF/DOCX/PPTX/XLSX/images — converted via MinerU (see
mineru_caller.py→file_organizer.py) - EPUB — converted via a built-in converter (
epub_converter.py) that usesebooklib+html2text, completely independent of MinerU - MinerU auto-detection — checks availability before conversion, guides installation
- Atomic manifest writes — single-lock read-modify-save for concurrent safety
- Unique temp directories — each worker gets its own
.tmp_mineru_{uuid}to avoid conflicts - File locking — safe
manifest.jsonaccess withfcntl.flock - Image handling — all formats store images in
attachments/<hash8>/with Obsidian-compatible relative paths; EPUB uses its own path-rewriting logic (epub_converter._rewrite_image_paths)
Testing
python3 -m pytest tests/ -v72+ tests covering MinerU invocation, page range parsing, CLI argument handling, installation guidance, file organization, manifest management, and EPUB conversion.
mineru-converter
使用 MinerU 将文档转换为 Markdown。可用作 opencode 技能,也可作为独立 CLI 工具。
功能
- 将 PDF、DOCX、PPTX、XLSX 和图片转换为 Markdown
- 增量转换 — 跳过已转换的文件
- 提取并整理图片,兼容 Obsidian
- 支持扫描件 OCR 识别
- 自动检测 MinerU 安装状态并提供安装引导
支持的格式
PDF、DOCX、PPTX、XLSX、PNG、JPG、JPEG、BMP、TIFF
安装
1. 安装依赖
pip install -r requirements.txt2. 安装 MinerU
使用前需要安装 MinerU。转换器会自动检测 MinerU 是否可用,如果未安装会提示安装。
检查 MinerU 是否已安装:
mineru --version如果 MinerU 未安装,转换器提供以下选项:
- 自动安装:在技能目录下创建
.mineru_venv/虚拟环境 - 手动安装:提供手动安装命令
3. 配置
cp config.template.yaml config.yaml
# 根据需要编辑 config.yaml如何使用
将文档放入 inbox/ 目录,然后通过自然语言调用转换器。以下是典型场景:
场景 1:批量转换整个文件夹
当有多个文件需要全部转换时:
"把 inbox 文件夹里的所有文档转成 Markdown。"
"批量转换 inbox 目录。"
"转换 inbox/ 下的所有文件。"
转换器会处理目录下所有支持的格式,跳过已转换的文件。
场景 2:转换 PDF 的指定页面
当只需要 PDF 中的部分页面时:
"把这份 PDF 的第 3 到第 5 页转成 Markdown。"
"转换这份文档第 18 页到第 28 页。"
"只提取这个 PDF 的第 10 页。"
仅对 PDF 文件有效,其他格式会完整转换。
场景 3:对扫描件使用 OCR
当处理扫描件或图像型 PDF,需要文字识别时:
"用 OCR 转换这份扫描版 PDF。"
"这份文档是图片型的,请用 OCR 识别。"
"这是扫描件,帮我识别文字。"
OCR 可以提取图片和扫描件中的文字内容。
场景 4:转换不同语言的文档
当文档不是中文,希望提高识别精度时:
"转换这份英文文档。"
"把这份日文 PDF 转成 Markdown。"
"处理这个英文 PDF。"
转换器支持中文(默认)、英文、日文、韩文等多种语言。
场景 5:重新转换文件
当之前的转换结果不理想或文件已更新时:
"重新转换这个文件。"
"这个转错了,帮我再转一次。"
"强制重新转换。"
场景 6:查看转换计划
当想看哪些文件待转换、哪些已完成时:
"看看有哪些文件可以转换?"
"查看转换计划。"
"哪些文件已经转过了?"
场景 7:并行加速转换
当文件很多,想要加快转换速度时:
"用 2 个线程批量转换 inbox 文件夹。"
"并发处理文件。"
场景 8:批量转换并统一应用 OCR
当批量转换一批文件且都需要 OCR 时:
"批量转换 inbox 文件夹,使用 OCR。"
"inbox 下的所有文件都用 OCR 方法转换。"
输出位置
转换后的 Markdown 文件和提取的图片保存在 raw/ 目录:
<项目>/
├── inbox/ # 放置文档的位置
│ ├── paper.pdf
│ └── notes.docx
├── raw/ # 转换输出
│ ├── paper.md
│ ├── notes.md
│ ├── attachments/
│ │ └── <hash8>/ # 按文件哈希分组的图片
│ └── manifest.json # 转换记录配置
编辑 config.yaml 修改默认值:
mineru:
command: "mineru" # 或虚拟环境 bin 目录的绝对路径
args:
backend: "pipeline"
model: "auto"
language: "ch" # 默认语言
method: "auto" # 默认解析方法 (auto/txt/ocr)架构概览
- MinerU 自动检测 — 转换前检测可用性,未安装时引导安装
- 原子写入 — manifest 更新使用单锁 read-modify-save 保证并发安全
- 独立临时目录 — 每个工作线程使用独立的
.tmp_mineru_{uuid}避免竞争 - 文件锁 —
manifest.json使用fcntl.flock安全访问
测试
python3 -m pytest tests/ -v72 个测试覆盖 MinerU 调用、页码解析、CLI 参数处理、安装引导、文件整理和 manifest 管理。
mineru-converter
Convert documents to Markdown using MinerU. Designed as an opencode Skill but works as a standalone CLI tool.
Features
- Convert PDF, DOCX, PPTX, XLSX, and image files to Markdown
- Incremental conversion — skips already-converted files
- Extract and organize images for Obsidian compatibility
- OCR support for scanned documents
- Automatic MinerU installation detection and guidance
Supported Formats
PDF, DOCX, PPTX, XLSX, PNG, JPG, JPEG, BMP, TIFF
Installation
1. Install Dependencies
pip install -r requirements.txt2. Install MinerU
MinerU must be installed before use. The converter will automatically detect if MinerU is available and prompt you to install it if not found.
Check if MinerU is installed:
mineru --versionIf MinerU is not installed, the converter offers:
- Auto-install: Creates a virtual environment in
.mineru_venv/under the skill directory - Manual install: Provides installation instructions to run manually
3. Configure
cp config.template.yaml config.yaml
# Edit config.yaml if neededHow to Use
Place your documents in the inbox/ directory, then use the converter through natural language. Here are typical scenarios:
Scenario 1: Convert all documents in a folder
When you have multiple files and want to convert everything:
"Convert all documents in the inbox folder to Markdown."
"Batch convert the inbox directory."
"Convert everything in inbox/."
The converter processes all supported files in the directory, skipping those already converted.
Scenario 2: Convert a specific page range of a PDF
When you only need certain pages from a PDF:
"Convert pages 3 to 5 of this PDF to Markdown."
"Convert this document from page 18 to page 28."
"Extract only page 10 of this PDF."
Works with PDF files only. Other formats are converted in full.
Scenario 3: Use OCR for scanned documents
When dealing with scanned image-based PDFs that need text recognition:
"Use OCR to convert this scanned PDF."
"Convert this document with OCR — it's image-based."
"This is a scanned document, please recognize the text."
OCR enables text extraction from images and scanned pages.
Scenario 4: Convert documents in different languages
When the document is not in Chinese and you want better recognition accuracy:
"Convert this English document to Markdown."
"Convert this Japanese PDF."
"Process this English PDF with language detection."
The converter supports Chinese (default), English, Japanese, Korean, and many other languages.
Scenario 5: Re-convert a file
When the previous conversion result is unsatisfactory or the file has changed:
"Re-convert this file."
"This conversion was wrong, convert it again."
"Force re-conversion."
Scenario 6: Check what will be converted
When you want to see which files are pending and which are already done:
"What files are ready to be converted?"
"Show me the conversion plan."
"Which files have already been converted?"
Scenario 7: Convert with parallel processing
When you have many files and want to speed up:
"Batch convert the inbox folder with 2 workers."
"Process files in parallel."
Scenario 8: Convert with OCR for all files
When converting a batch of documents and all need OCR:
"Batch convert the inbox folder using OCR."
"Convert all files in inbox with OCR method."
Output Location
Converted Markdown files and extracted images are saved in the raw/ directory:
<project>/
├── inbox/ # Place documents here
│ ├── paper.pdf
│ └── notes.docx
├── raw/ # Converted output
│ ├── paper.md
│ ├── notes.md
│ ├── attachments/
│ │ └── <hash8>/ # Images grouped by file hash
│ └── manifest.json # Conversion recordsConfiguration
Edit config.yaml to change defaults:
mineru:
command: "mineru" # Or absolute path to venv/bin/mineru
args:
backend: "pipeline"
model: "auto"
language: "ch" # Default language
method: "auto" # Default parsing method (auto/txt/ocr)Architecture Overview
- MinerU auto-detection — checks availability before conversion, guides installation
- Atomic manifest writes — single-lock read-modify-save for concurrent safety
- Unique temp directories — each worker gets its own
.tmp_mineru_{uuid}to avoid conflicts - File locking — safe
manifest.jsonaccess withfcntl.flock
Testing
python3 -m pytest tests/ -v72 tests covering MinerU invocation, page range parsing, CLI argument handling, installation guidance, file organization, and manifest management.
pyyaml>=6.0
ebooklib>=1.0
html2text>=2024.0
#!/usr/bin/env python3
"""Entry point for mineru-converter-skill."""
import sys
from pathlib import Path
# Ensure project root is in path
project_root = Path(__file__).resolve().parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from scripts.cli import main
sys.exit(main())
"""Allow running as: python3 -m scripts.cli"""
import sys
from pathlib import Path
# Ensure the project root is in sys.path
_project_root = Path(__file__).resolve().parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from scripts.cli import main
sys.exit(main())
"""CLI entry point for mineru-converter-skill."""
import sys
import json
import argparse
from pathlib import Path
# Support both module and direct execution
try:
from .config_loader import get_output_dir
from .converter import convert_single, convert_batch, build_plan, get_status
from .mineru_caller import check_mineru_available, MineruNotFoundError
from .mineru_setup import check_mineru_available as check_mineru_setup, install_mineru_auto, show_manual_instructions
from .manifest_manager import ManifestLoadError
except ImportError:
_project_root = Path(__file__).resolve().parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from config_loader import get_output_dir
from converter import convert_single, convert_batch, build_plan, get_status
from mineru_caller import check_mineru_available, MineruNotFoundError
from mineru_setup import check_mineru_available as check_mineru_setup, install_mineru_auto, show_manual_instructions
from manifest_manager import ManifestLoadError
def _format_report(report, as_json=False):
"""Format conversion report for display."""
if as_json:
return json.dumps(report, indent=2, ensure_ascii=False)
lines = []
if "items" in report:
lines.append(f"Scanned: {report['scanned']}")
lines.append(f"Processed: {report['processed']}")
lines.append(f"Skipped: {report['skipped']}")
lines.append(f"Failed: {report['failed']}")
lines.append("")
for item in report.get("items", []):
icon = {"success": "[OK]", "skipped": "[SKIP]", "failed": "[ERR]"}.get(item["status"], "[???]")
lines.append(f"{icon} {item['path']}")
if item.get("error"):
lines.append(f" {item['error']}")
if item.get("details"):
d = item["details"]
if d.get("md_path"):
lines.append(f" -> {d['md_path']}")
if d.get("images"):
lines.append(f" images: {d['images']}")
elif "process" in report:
lines.append(f"Source: {report['source_dir']}")
lines.append(f"Output: {report['output_dir']}")
lines.append(f"Scanned: {report['scanned']}")
lines.append(f"To convert: {len(report['process'])}")
lines.append(f"Already done: {len(report['skip'])}")
lines.append("")
if report["process"]:
lines.append("To convert:")
for item in report["process"]:
lines.append(f" [NEW] {item['path']}")
if report["skip"]:
lines.append("")
lines.append("Already converted:")
for item in report["skip"]:
lines.append(f" [SKIP] {item['path']}")
if item.get("reason"):
lines.append(f" {item['reason']}")
if report.get("unsupported"):
lines.append("")
lines.append("Unsupported:")
for item in report["unsupported"]:
lines.append(f" [SKIP] {item['path']} ({item['reason']})")
elif "total" in report:
lines.append(f"Output dir: {report['output_dir']}")
lines.append(f"Total: {report['total']} (success: {report['success']}, failed: {report['failed']})")
return "\n".join(lines)
def _ensure_mineru_installed():
"""Check if MinerU is installed, prompt user to install if not."""
available, version = check_mineru_available()
if available:
return True
print("MinerU is not installed or not in PATH.")
print("The converter needs MinerU to function.")
print()
try:
choice = input("Would you like to install MinerU? (A)uto / (M)anual / (C)ancel [A]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
choice = "c"
if choice in ("b", "m", "manual"):
print()
print(show_manual_instructions())
return False
elif choice in ("c", "cancel", "n", "no"):
print("Aborted. Please install MinerU and try again.")
return False
else:
# Default: auto install
try:
use_all_choice = input("Install full version (all backends) or core only? (F)ull / (C)ore [F]: ").strip().lower()
use_all = use_all_choice not in ("c", "core", "core only")
except (EOFError, KeyboardInterrupt):
use_all = True
print()
if install_mineru_auto(use_all=use_all):
# Re-check
available, version = check_mineru_available()
if available:
print(f"MinerU installed successfully (version {version}).")
return True
else:
print("Installation completed but MinerU still not detected. Check the output above for errors.")
return False
else:
print("Installation failed. Please install manually.")
return False
def main():
parser = argparse.ArgumentParser(
prog="mineru-converter",
description="Convert documents (PDF, DOCX, PPTX, XLSX, images) to Markdown using MinerU.",
)
sub = parser.add_subparsers(dest="command", help="Available commands")
# convert command
conv_parser = sub.add_parser("convert", help="Convert one or more files")
conv_parser.add_argument("--file", type=str, help="Single file to convert")
conv_parser.add_argument("--dir", type=str, help="Directory of files to convert")
conv_parser.add_argument("--output-dir", type=str, default=None, help="Output directory (default: from config)")
conv_parser.add_argument("--force", action="store_true", help="Force re-conversion even if already done")
conv_parser.add_argument("--workers", type=int, default=1, help="Parallel workers for batch (default: 1)")
conv_parser.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
conv_parser.add_argument("--verbose", action="store_true", help="Verbose output")
conv_parser.add_argument("--pages", type=str, default=None, help="Page range to convert (e.g. '3-5'), only for single file")
conv_parser.add_argument("--method", type=str, default=None, choices=["auto", "txt", "ocr"],
help="PDF parsing method (default: from config)")
conv_parser.add_argument("--lang", type=str, default=None, help="Document language (default: from config)")
# plan command
plan_parser = sub.add_parser("plan", help="Show conversion plan without executing")
plan_parser.add_argument("--dir", type=str, required=True, help="Directory to scan")
plan_parser.add_argument("--output-dir", type=str, default=None, help="Output directory (default: from config)")
plan_parser.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
# status command
status_parser = sub.add_parser("status", help="Show conversion statistics")
status_parser.add_argument("--output-dir", type=str, default=None, help="Output directory (default: from config)")
status_parser.add_argument("--list-converted", action="store_true", help="List converted files")
status_parser.add_argument("--list-failed", action="store_true", help="List failed files")
status_parser.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
args = parser.parse_args()
if not args.command:
parser.print_help()
return 1
output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else None
try:
if args.command == "convert":
# Validate: --pages only works with --file
if args.dir and args.pages:
parser.error("--pages can only be used with --file, not with --dir")
# Ensure MinerU is installed before any conversion
if not _ensure_mineru_installed():
return 1
if args.file:
result = convert_single(
args.file, output_dir=output_dir, force=args.force,
verbose=args.verbose, pages=args.pages,
method=args.method, lang=args.lang,
)
# For single file, wrap in report format
report = {"items": [result], "scanned": 1, "processed": 1 if result["status"] == "success" else 0,
"skipped": 1 if result["status"] == "skipped" else 0, "failed": 1 if result["status"] == "failed" else 0}
elif args.dir:
result = convert_batch(
args.dir, output_dir=output_dir, force=args.force,
workers=args.workers, verbose=args.verbose,
method=args.method, lang=args.lang,
)
report = result
else:
parser.error("--file or --dir required for convert")
return 1
elif args.command == "plan":
report = build_plan(args.dir, output_dir=output_dir)
elif args.command == "status":
report = get_status(output_dir=output_dir,
list_converted=args.list_converted,
list_failed=args.list_failed)
else:
parser.print_help()
return 1
print(_format_report(report, as_json=args.as_json))
except ManifestLoadError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
"""Configuration loader for mineru-converter-skill."""
import yaml
from pathlib import Path
from typing import Dict, Any, Optional
# Project root is the parent of the scripts/ directory
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_CONFIG_PATH = _PROJECT_ROOT / "config.yaml"
_DEFAULT_CONFIG: Dict[str, Any] = {
"output_dir": "./raw",
"mineru": {
"command": "mineru",
"args": {
"backend": "pipeline",
"model": "auto",
"language": "ch",
"method": "auto",
},
},
"supported_extensions": [
".pdf", ".docx", ".pptx", ".xlsx",
".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif",
".epub",
],
"manifest": {
"filename": "manifest.json",
"version": "1.0",
},
"organizer": {
"attachments_dir": "attachments",
"hash_prefix_length": 8,
},
}
def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
"""Load configuration from YAML file, falling back to defaults."""
path = config_path or _CONFIG_PATH
if not path.exists():
return _DEFAULT_CONFIG
with open(path, "r", encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
return _merge_defaults(_DEFAULT_CONFIG, user_config)
def _merge_defaults(defaults: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge overrides into defaults."""
merged = {}
for key, default_val in defaults.items():
override_val = overrides.get(key)
if isinstance(default_val, dict) and isinstance(override_val, dict):
merged[key] = _merge_defaults(default_val, override_val)
else:
merged[key] = override_val if override_val is not None else default_val
return merged
def get_output_dir(config: Optional[Dict[str, Any]] = None) -> Path:
"""Get expanded output directory path from config.
Relative paths (e.g., "./raw") are resolved against the current working directory.
Absolute paths or tilde paths are resolved normally.
"""
cfg = config or load_config()
raw_path = Path(cfg["output_dir"])
if raw_path.is_absolute() or str(raw_path).startswith("~"):
return raw_path.expanduser().resolve()
# Relative path — resolve against CWD
return (Path.cwd() / raw_path).resolve()
def get_supported_extensions(config: Optional[Dict[str, Any]] = None) -> set:
"""Get set of supported file extensions from config."""
cfg = config or load_config()
return set(cfg["supported_extensions"])
def get_manifest_path(output_dir: Path) -> Path:
"""Get manifest.json path within output_dir."""
return output_dir / "manifest.json"
"""Core converter — orchestrates single file, batch, and plan operations."""
import json
import shutil
import uuid
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from concurrent.futures import ThreadPoolExecutor, as_completed
from .config_loader import load_config, get_output_dir, get_supported_extensions, get_manifest_path
from .manifest_manager import (
load_manifest,
check_converted,
upsert_file_record,
compute_sha256,
ManifestLoadError,
ManifestSaveError,
)
from .mineru_caller import run_mineru, MineruError
from .file_organizer import move_mineru_output, OrganizerError
class ConvertError(Exception):
"""Base exception for conversion operations."""
def _get_format(file_path: Path) -> str:
"""Return lowercase file extension."""
return file_path.suffix.lower()
def _is_supported(file_path: Path, config: Optional[Dict] = None) -> bool:
"""Check if a file extension is supported."""
return file_path.suffix.lower() in get_supported_extensions(config)
def _collect_files(input_path: Path) -> List[Path]:
"""Recursively collect supported files from a directory, or return the single file."""
input_path = Path(input_path).expanduser().resolve()
config = load_config()
if input_path.is_file():
return [input_path]
files = []
for ext in config.get("supported_extensions", []):
files.extend(input_path.rglob(f"*{ext}"))
return sorted(files)
def _parse_page_range(pages_str: str) -> Tuple[Optional[int], Optional[int]]:
"""
Parse page range string like '3-5' into (start, end) 0-indexed.
Single page '3' returns (2, 2).
Returns:
(start_page, end_page) as 0-indexed ints, or (None, None).
"""
if not pages_str:
return None, None
pages_str = pages_str.strip()
if "-" in pages_str:
parts = pages_str.split("-", 1)
start = int(parts[0]) - 1
end = int(parts[1]) - 1
return start, end
else:
page = int(pages_str) - 1
return page, page
def convert_single(
file_path: Path,
output_dir: Optional[Path] = None,
force: bool = False,
verbose: bool = False,
pages: Optional[str] = None,
method: Optional[str] = None,
lang: Optional[str] = None,
) -> Dict[str, Any]:
"""
Convert a single file.
Args:
pages: Page range string, e.g. '3-5' (1-indexed). Only for PDF.
method: PDF parsing method — 'auto', 'txt', 'ocr'.
lang: Document language code.
Returns:
Dict with keys: path, status (success/skipped/failed), error, details
"""
file_path = Path(file_path).expanduser().resolve()
output_dir = Path(output_dir or get_output_dir()).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
# Parse page range
start_page, end_page = _parse_page_range(pages) if pages else (None, None)
result = {
"path": str(file_path),
"status": "pending",
"error": None,
"details": None,
}
# Check if supported
if not _is_supported(file_path):
result["status"] = "skipped"
result["error"] = f"Unsupported format: {file_path.suffix}"
return result
# Check if already converted
manifest = load_manifest(get_manifest_path(output_dir))
is_converted, new_hash = check_converted(file_path, manifest)
if is_converted and not force:
result["status"] = "skipped"
return result
file_format = _get_format(file_path)
file_hash = new_hash or _get_file_hash(file_path)
# EPUB conversion — does not use MinerU
if file_format == ".epub":
return _convert_epub_workflow(
file_path, output_dir, file_format, result,
)
try:
# Step 1: Run MinerU
tmp_dir = output_dir / f".tmp_mineru_{uuid.uuid4().hex[:8]}"
mineru_results = run_mineru(
file_path, tmp_dir, verbose=verbose,
start_page=start_page, end_page=end_page,
method=method, lang=lang,
)
if not mineru_results:
raise MineruError("MinerU produced no output")
# Use the first (and usually only) output subdirectory from MinerU
mineru_subdir = Path(mineru_results[0]["subdir"])
# Step 2: Move outputs and rewrite paths
final_result = move_mineru_output(mineru_subdir, output_dir)
# Step 3: Cleanup temp MinerU directory
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
# Step 4: Update manifest (atomic load-modify-save)
attachments_rel = f"attachments/{final_result['hash_prefix']}"
md_rel = Path(final_result["md_path"]).relative_to(output_dir)
upsert_file_record(
file_path,
output_md=str(md_rel),
output_attachments=attachments_rel,
file_format=file_format,
status="success",
)
result["status"] = "success"
result["details"] = {
"md_path": str(final_result["md_path"]),
"attachments_path": final_result["attachments_path"],
"images": final_result["image_count"],
}
except MineruError as e:
result["status"] = "failed"
result["error"] = f"MinerU error: {e}"
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
try:
upsert_file_record(
file_path,
output_md="", output_attachments="",
file_format=file_format,
status="failed", error=str(e),
)
except ManifestSaveError:
pass
except OrganizerError as e:
result["status"] = "failed"
result["error"] = f"Organization error: {e}"
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
except Exception as e:
result["status"] = "failed"
result["error"] = f"Unexpected error: {e}"
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
return result
def convert_batch(
dir_path: Path,
output_dir: Optional[Path] = None,
force: bool = False,
workers: int = 1,
verbose: bool = False,
method: Optional[str] = None,
lang: Optional[str] = None,
) -> Dict[str, Any]:
"""
Convert all supported files in a directory.
Args:
method: PDF parsing method applied to all files.
lang: Document language applied to all files.
Returns:
Dict with scanned, processed, skipped, failed counts and per-file results.
"""
dir_path = Path(dir_path).expanduser().resolve()
output_dir = Path(output_dir or get_output_dir()).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
files = _collect_files(dir_path)
if not files:
return {
"scanned": 0, "processed": 0, "skipped": 0, "failed": 0,
"items": [], "message": f"No supported files found in {dir_path}",
}
results = []
stats = {"scanned": len(files), "processed": 0, "skipped": 0, "failed": 0}
def _convert_one(fp: Path) -> Dict[str, Any]:
return convert_single(fp, output_dir, force=force, verbose=verbose, method=method, lang=lang)
# Single-threaded by default for manifest safety; parallel if workers > 1
if workers > 1:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(_convert_one, fp): fp for fp in files}
for future in as_completed(futures):
try:
r = future.result()
except Exception as e:
r = {"path": str(futures[future]), "status": "failed", "error": str(e), "details": None}
results.append(r)
else:
for fp in files:
results.append(_convert_one(fp))
# Aggregate stats
for r in results:
if r["status"] == "skipped":
stats["skipped"] += 1
elif r["status"] == "success":
stats["processed"] += 1
elif r["status"] == "failed":
stats["failed"] += 1
stats["items"] = results
return stats
def build_plan(
dir_path: Path,
output_dir: Optional[Path] = None,
) -> Dict[str, Any]:
"""
Build a conversion plan: which files are skip vs process.
Returns:
Dict with scanned count, skip list, process list, and failed list.
"""
dir_path = Path(dir_path).expanduser().resolve()
output_dir = Path(output_dir or get_output_dir()).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
files = _collect_files(dir_path)
manifest = load_manifest(get_manifest_path(output_dir))
plan = {
"source_dir": str(dir_path),
"output_dir": str(output_dir),
"scanned": len(files),
"skip": [],
"process": [],
"unsupported": [],
}
for fp in files:
if not _is_supported(fp):
plan["unsupported"].append({"path": str(fp), "reason": f"Unsupported format: {fp.suffix}"})
continue
is_converted, _ = check_converted(fp, manifest)
entry = {"path": str(fp)}
if is_converted:
# Check if content has changed
file_hash = _get_file_hash(fp)
# Find existing record by filename
found = False
for hk, record in manifest.get("files", {}).items():
if record.get("source_filename") == fp.name:
if hk == file_hash:
entry["reason"] = "Already converted"
else:
entry["reason"] = "Modified (re-conversion needed)"
break
if not found:
entry["reason"] = "Already converted"
plan["skip"].append(entry)
else:
plan["process"].append(entry)
return plan
def get_status(
output_dir: Optional[Path] = None,
list_converted: bool = False,
list_failed: bool = False,
) -> Dict[str, Any]:
"""Get conversion status statistics."""
output_dir = Path(output_dir or get_output_dir()).expanduser().resolve()
manifest = load_manifest(get_manifest_path(output_dir))
files = manifest.get("files", {})
status = {
"output_dir": str(output_dir),
"total": len(files),
"success": 0,
"failed": 0,
"converted": [],
"failed_list": [],
}
for hash_key, record in files.items():
if record.get("status") == "success":
status["success"] += 1
if list_converted:
status["converted"].append({
"file": record.get("source_filename"),
"format": record.get("format"),
"converted_at": record.get("converted_at"),
})
else:
status["failed"] += 1
if list_failed:
status["failed_list"].append({
"file": record.get("source_filename"),
"error": record.get("error"),
})
return status
def _get_file_hash(file_path: Path) -> str:
"""Helper to compute file hash (wrapper for manifest_manager.compute_sha256)."""
return compute_sha256(file_path)
def _convert_epub_workflow(
file_path: Path,
output_dir: Path,
file_format: str,
result: Dict[str, Any],
) -> Dict[str, Any]:
"""Convert an EPUB file and record the result in the manifest."""
from .epub_converter import convert_epub, EpubConvertError
manifest_path = get_manifest_path(output_dir)
try:
final_result = convert_epub(file_path, output_dir)
attachments_rel = f"attachments/{final_result['hash_prefix']}"
md_rel = Path(final_result["md_path"]).relative_to(output_dir)
upsert_file_record(
file_path,
output_md=str(md_rel),
output_attachments=attachments_rel,
file_format=file_format,
status="success",
manifest_path=manifest_path,
)
result["status"] = "success"
result["details"] = {
"md_path": str(final_result["md_path"]),
"attachments_path": final_result["attachments_path"],
"images": final_result["image_count"],
}
except EpubConvertError as e:
result["status"] = "failed"
result["error"] = f"EPUB conversion error: {e}"
try:
upsert_file_record(
file_path,
output_md="", output_attachments="",
file_format=file_format,
status="failed", error=str(e),
manifest_path=manifest_path,
)
except ManifestSaveError:
pass
except Exception as e:
result["status"] = "failed"
result["error"] = f"Unexpected EPUB error: {e}"
try:
upsert_file_record(
file_path,
output_md="", output_attachments="",
file_format=file_format,
status="failed", error=str(e),
manifest_path=manifest_path,
)
except ManifestSaveError:
pass
return result
"""EPUB to Markdown converter — standalone, does not depend on MinerU.
Extracts images, converts HTML content to Markdown, and rewrites
image paths to the `attachments/<hash>/` convention used by
the mineru-converter skill.
"""
import hashlib
import re
from pathlib import Path
from typing import Dict, Any, Optional
from ebooklib import epub
from html2text import HTML2Text
from .config_loader import load_config
ITEM_IMAGE = 1
ITEM_DOCUMENT = 9
class EpubConvertError(Exception):
"""Base exception for EPUB conversion."""
def convert_epub(
epub_path: Path,
output_dir: Path,
) -> Dict[str, Any]:
"""Convert an EPUB file to Markdown and extract its images.
Args:
epub_path: Path to the .epub file.
output_dir: Root output directory (e.g. ``./raw``).
Returns:
Dict with keys:
- md_path: absolute path to the written .md file
- attachments_path: absolute path to the images subdirectory
- image_count: number of extracted images
- hash_prefix: 8-char hex prefix used for the attachments folder
"""
epub_path = Path(epub_path).expanduser().resolve()
output_dir = Path(output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
file_hash = _compute_file_hash(epub_path)
config = load_config()
org = config.get("organizer", {})
hash_prefix_len = org.get("hash_prefix_length", 8)
hash_prefix = file_hash[:hash_prefix_len]
attachments_dir_name = org.get("attachments_dir", "attachments")
attachments_path = output_dir / attachments_dir_name / hash_prefix
attachments_path.mkdir(parents=True, exist_ok=True)
book = epub.read_epub(str(epub_path))
name_map = _extract_images(book, attachments_path)
md_content = _convert_content(book)
md_content = _rewrite_image_paths(
md_content,
f"{attachments_dir_name}/{hash_prefix}",
name_map,
)
md_path = output_dir / _derive_filename(book, epub_path)
md_path.parent.mkdir(parents=True, exist_ok=True)
md_path.write_text(md_content, encoding="utf-8")
return {
"md_path": str(md_path.resolve()),
"attachments_path": str(attachments_path.resolve()),
"image_count": len(name_map) // 2,
"hash_prefix": hash_prefix,
}
def _compute_file_hash(file_path: Path) -> str:
"""SHA-256 of the entire EPUB file (used for attachment folder naming)."""
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _extract_images(
book: epub.EpubBook,
attachments_dir: Path,
) -> Dict[str, str]:
"""Save every image embedded in the EPUB to *attachments_dir*.
Returns a mapping of *both* the original EPUB path (``images/foo.jpg``)
and bare filename (``foo.jpg``) → the filename used on disk, so that
path rewriting can succeed regardless of how the HTML references the
image.
"""
name_map: Dict[str, str] = {}
for item in book.get_items():
if item.get_type() != ITEM_IMAGE:
continue
orig = item.get_name()
base = Path(orig).name
if not base:
continue
dest = attachments_dir / base
if dest.exists():
stem = Path(base).stem
counter = 1
while dest.exists():
dest = attachments_dir / f"{stem}_{counter}{Path(base).suffix}"
counter += 1
attachments_dir.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
f.write(item.get_content())
name_map[orig] = dest.name
name_map[base] = dest.name
return name_map
def _convert_content(book: epub.EpubBook) -> str:
"""Walk every ``ITEM_DOCUMENT`` in spine order and convert to Markdown."""
h = HTML2Text()
h.body_width = 0
h.ignore_links = False
h.ignore_images = False
h.ignore_emphasis = False
h.unicode_snob = True
h.single_line_break = True
parts: list[str] = []
for item in book.get_items():
if item.get_type() != ITEM_DOCUMENT:
continue
content = item.get_body_content().decode("utf-8", errors="replace")
md = h.handle(content)
parts.append(md)
return "\n\n".join(parts)
def _rewrite_image_paths(
md_content: str,
attachments_rel: str,
name_map: Dict[str, str],
) -> str:
"""Replace every ```` with the correct attachments path.
Uses *name_map* (built by :func:`_extract_images`) to look up the
actual saved filename regardless of the original reference style
(``images/x.jpg``, ``../images/x.jpg``, ``OEBPS/x.jpg``, …).
"""
def _replacer(m):
alt = m.group(1)
ref = m.group(2)
basename = Path(ref).name
saved = name_map.get(ref) or name_map.get(basename) or basename
return f""
return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', _replacer, md_content)
def _derive_filename(book: epub.EpubBook, epub_path: Path) -> str:
"""Produce a human-friendly ``.md`` filename from metadata or the file path."""
title: Optional[str] = None
try:
title = book.get_metadata("DC", "title")
if title:
title = title[0][0]
except Exception:
pass
stem = (title or epub_path.stem).strip()
stem = re.sub(r'[\\/:*?"<>|]', "_", stem)
if len(stem) > 128:
stem = stem[:128]
return f"{stem}.md"
"""File organizer — moves outputs, rewrites image paths, cleans up temp files."""
import hashlib
import re
import shutil
from pathlib import Path
from typing import Dict, Any, Optional, List
from .config_loader import load_config
from .manifest_manager import compute_sha256
class OrganizerError(Exception):
"""Base exception for file organization operations."""
def _hash_dir(dir_path: Path) -> str:
"""Compute a hash based on all files within a directory (for grouping attachments)."""
h = hashlib.sha256()
for f in sorted(dir_path.rglob("*")):
if f.is_file():
h.update(f.name.encode())
h.update(str(f.stat().st_size).encode())
return h.hexdigest()
class ImagePathRewriteError(OrganizerError):
"""Failed to rewrite image paths in markdown."""
def move_mineru_output(
mineru_output_dir: Path,
target_output_dir: Path,
) -> Dict[str, Any]:
"""
Extract markdown and images from MinerU output, move to target directory,
and clean up temporary MinerU subdirectories.
Args:
mineru_output_dir: The top-level directory where MinerU wrote results.
target_output_dir: The target output directory (raw/).
Returns:
Dict with keys:
- md_path: Path to the final markdown file
- attachments_path: Path to the attachments subdirectory
- md_content: Content of the markdown file (for path rewriting)
"""
mineru_output_dir = Path(mineru_output_dir).resolve()
target_output_dir = Path(target_output_dir).expanduser().resolve()
target_output_dir.mkdir(parents=True, exist_ok=True)
config = load_config()
organizer_config = config.get("organizer", {})
attachments_dir_name = organizer_config.get("attachments_dir", "attachments")
hash_prefix_len = organizer_config.get("hash_prefix_length", 8)
# Compute hash for attachments folder (from md files' content)
md_files = list(mineru_output_dir.rglob("*.md"))
if md_files:
file_hash = compute_sha256(md_files[0])
else:
file_hash = _hash_dir(mineru_output_dir)
hash_prefix = file_hash[:hash_prefix_len]
attachments_path = target_output_dir / attachments_dir_name / hash_prefix
attachments_path.mkdir(parents=True, exist_ok=True)
# Find all .md files in the mineru output
md_files = []
for md_file in mineru_output_dir.rglob("*.md"):
if md_file.is_file() and md_file.stat().st_size > 0:
md_files.append(md_file)
if not md_files:
raise OrganizerError(f"No valid markdown files found in {mineru_output_dir}")
# Use the .md file whose name most closely matches the input directory name
input_dir_name = mineru_output_dir.name.lower()
md_file = _select_best_md(md_files, input_dir_name)
# Copy images to attachments — search recursively (MinerU v3.x puts images in subdirs)
images_src = None
for img_dir in mineru_output_dir.rglob("images"):
if img_dir.is_dir() and any(img_dir.iterdir()):
images_src = img_dir
break
image_count = 0
if images_src is not None:
image_count = _copy_images(images_src, attachments_path)
# Determine the target MD filename
md_filename = _get_md_filename(md_file)
md_dest = target_output_dir / md_filename
# Read content, rewrite image paths, then write
md_content = md_file.read_text(encoding="utf-8")
md_content = rewrite_image_paths(md_content, attachments_dir_name, hash_prefix)
md_dest.write_text(md_content, encoding="utf-8")
return {
"md_path": str(md_dest),
"attachments_path": str(attachments_path),
"md_content": md_content,
"image_count": image_count,
"hash_prefix": hash_prefix,
}
def _select_best_md(md_files: List[Path], input_dir_name: str) -> Path:
"""
Select the most appropriate .md file from the MinerU output.
Prefers files whose stem matches the input directory name.
"""
for md_file in md_files:
stem = md_file.stem.lower()
if input_dir_name in stem or stem in input_dir_name:
return md_file
# Fallback: pick the file with the most content
return max(md_files, key=lambda f: f.stat().st_size)
def _get_md_filename(md_file: Path) -> str:
"""
Derive a clean filename for the output markdown.
Uses the md file's stem, replacing characters that are invalid in filenames.
"""
stem = md_file.stem
# Replace characters that could be problematic
stem = re.sub(r'[\\/:*?"<>|]', '_', stem)
# Limit length
if len(stem) > 128:
stem = stem[:128]
return f"{stem}.md"
def _copy_images(images_src: Path, attachments_dest: Path) -> int:
"""
Copy all images from source to destination, handling name conflicts
by appending a numeric suffix.
Returns the number of images copied.
"""
count = 0
for img_file in sorted(images_src.iterdir()):
if not img_file.is_file():
continue
ext = img_file.suffix.lower()
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif"):
continue
dest = attachments_dest / img_file.name
if dest.exists():
# Handle conflicts
counter = 1
base = img_file.stem
while dest.exists():
dest = attachments_dest / f"{base}_{counter}{ext}"
counter += 1
shutil.copy2(str(img_file), str(dest))
count += 1
return count
def rewrite_image_paths(
md_content: str,
attachments_base: str = "attachments",
hash_prefix: str = "",
) -> str:
"""
Rewrite image paths in markdown content.
Converts paths like:
- 
- 
To:
- 
Args:
md_content: The markdown content to rewrite.
attachments_base: Base name for the attachments directory (default: "attachments").
hash_prefix: The hash prefix for the attachments subdirectory.
Returns:
The rewritten markdown content.
"""
# Pattern matches  where path starts with images/ or ./images/
pattern = re.compile(
r'(!\[[^\]]*\])\((?:\./)?images/([^)]+)\)'
)
def replacer(match):
prefix = match.group(1)
img_name = match.group(2)
return f"{prefix}({attachments_base}/{hash_prefix}/{img_name})"
return pattern.sub(replacer, md_content)
def cleanup_mineru_subdirs(output_dir: Path, keep_subdirs: Optional[List[str]] = None) -> int:
"""
Remove temporary subdirectories created by MinerU that are no longer needed.
Args:
output_dir: The top-level output directory.
keep_subdirs: List of subdirectory names to keep (relative to output_dir).
Returns:
Number of directories removed.
"""
output_dir = Path(output_dir).resolve()
keep_subdirs = keep_subdirs or []
removed = 0
for item in output_dir.iterdir():
if not item.is_dir():
continue
if item.name in keep_subdirs:
continue
try:
shutil.rmtree(item)
removed += 1
except OSError:
pass
return removed
"""Manifest manager for tracking converted files with SHA256 hashing and file locking."""
import hashlib
import json
import fcntl
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
from contextlib import contextmanager
from .config_loader import load_config, get_output_dir, get_manifest_path
class ManifestError(Exception):
"""Base exception for manifest operations."""
class ManifestLoadError(ManifestError):
"""Failed to load manifest."""
class ManifestSaveError(ManifestError):
"""Failed to save manifest."""
@contextmanager
def _locked_file(file_path: Path, mode: str = "r+"):
"""Context manager that applies file locking for safe concurrent access."""
if not file_path.exists() and "r" not in mode:
file_path.parent.mkdir(parents=True, exist_ok=True)
fd = open(file_path, mode)
else:
fd = open(file_path, mode)
try:
if "r" in mode:
fcntl.flock(fd, fcntl.LOCK_SH)
else:
fcntl.flock(fd, fcntl.LOCK_EX)
yield fd
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
fd.close()
def compute_sha256(file_path: Path) -> str:
"""Compute SHA256 hash of a file."""
file_path = Path(file_path).expanduser().resolve()
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def load_manifest(manifest_path: Optional[Path] = None) -> Dict[str, Any]:
"""Load manifest.json from disk."""
if manifest_path is None:
output_dir = get_output_dir()
manifest_path = get_manifest_path(output_dir)
if not manifest_path.exists():
return {
"version": load_config().get("manifest", {}).get("version", "1.0"),
"files": {},
}
try:
with _locked_file(manifest_path, "r") as f:
content = f.read()
return json.loads(content)
except (json.JSONDecodeError, IOError) as e:
raise ManifestLoadError(f"Failed to load manifest from {manifest_path}: {e}")
def save_manifest(manifest: Dict[str, Any], manifest_path: Optional[Path] = None) -> None:
"""Save manifest to disk with file locking."""
if manifest_path is None:
output_dir = get_output_dir()
manifest_path = get_manifest_path(output_dir)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
try:
with _locked_file(manifest_path, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
f.seek(0)
f.truncate()
json.dump(manifest, f, indent=2, ensure_ascii=False)
except IOError as e:
raise ManifestSaveError(f"Failed to save manifest to {manifest_path}: {e}")
def upsert_file_record(
file_path: Path,
output_md: str,
output_attachments: str,
file_format: str,
status: str = "success",
error: Optional[str] = None,
manifest_path: Optional[Path] = None,
) -> None:
"""Atomically load manifest, add/update a file record, and save — all under one lock."""
if manifest_path is None:
output_dir = get_output_dir()
manifest_path = get_manifest_path(output_dir)
file_path = Path(file_path).expanduser().resolve()
manifest_path = Path(manifest_path).expanduser().resolve()
file_hash = compute_sha256(file_path)
record = build_record(
source_path=file_path,
output_md=output_md,
output_attachments=output_attachments,
file_format=file_format,
status=status,
error=error,
)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
# Ensure file exists before opening for read+write
if not manifest_path.exists():
json.dump(
{"version": load_config().get("manifest", {}).get("version", "1.0"), "files": {}},
open(manifest_path, "w"),
)
try:
with open(manifest_path, "r+") as f:
fcntl.flock(f, fcntl.LOCK_EX)
content = f.read()
if not content:
content = "{}"
try:
manifest = json.loads(content)
except json.JSONDecodeError:
manifest = {"version": load_config().get("manifest", {}).get("version", "1.0"), "files": {}}
manifest.setdefault("files", {})[file_hash] = record
f.seek(0)
f.truncate()
json.dump(manifest, f, indent=2, ensure_ascii=False)
f.flush()
except IOError as e:
raise ManifestSaveError(f"Failed to save manifest to {manifest_path}: {e}")
def upsert_record(manifest: Dict[str, Any], file_hash: str, record: Dict[str, Any]) -> Dict[str, Any]:
"""Add or update a file record in the manifest."""
manifest.setdefault("files", {})[file_hash] = record
return manifest
def build_record(
source_path: Path,
output_md: str,
output_attachments: str,
file_format: str,
status: str = "success",
error: Optional[str] = None,
) -> Dict[str, Any]:
"""Build a manifest record for a converted file."""
return {
"source_path": str(Path(source_path).expanduser().resolve()),
"source_filename": source_path.name,
"output_md": output_md,
"output_attachments": output_attachments,
"format": file_format,
"converted_at": datetime.now(timezone.utc).isoformat(),
"status": status,
"error": error,
}
def check_converted(
file_path: Path,
manifest: Dict[str, Any],
) -> Tuple[bool, Optional[str]]:
"""
Check if a file has already been converted.
Returns (is_converted, hash_key).
"""
file_path = Path(file_path).expanduser().resolve()
file_hash = compute_sha256(file_path)
files = manifest.get("files", {})
for hash_key, record in files.items():
if record.get("source_path") == str(file_path):
# Source path matches — check if hash changed
if hash_key == file_hash:
return True, hash_key
else:
# File modified — return not converted but with new hash
return False, file_hash
# Also check by source_filename + size as fallback
if record.get("source_filename") == file_path.name:
existing_size = record.get("source_size")
current_size = file_path.stat().st_size
if existing_size == current_size:
if hash_key == file_hash:
return True, hash_key
return False, file_hash
def add_conversion_record(
manifest: Dict[str, Any],
file_path: Path,
output_md: str,
output_attachments: str,
file_format: str,
status: str = "success",
error: Optional[str] = None,
) -> Dict[str, Any]:
"""Add a conversion record to the manifest and return the hash key."""
file_hash = compute_sha256(file_path)
record = build_record(
source_path=file_path,
output_md=output_md,
output_attachments=output_attachments,
file_format=file_format,
status=status,
error=error,
)
manifest = upsert_record(manifest, file_hash, record)
return manifest
"""MinerU CLI wrapper — invokes mineru command and returns output metadata."""
import subprocess
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple
from .config_loader import load_config, get_output_dir, get_manifest_path
class MineruError(Exception):
"""Base exception for MinerU operations."""
class MineruNotFoundError(MineruError):
"""MinerU CLI not found."""
class MineruExecutionError(MineruError):
"""MinerU command failed."""
# Format-specific extra arguments
_FORMAT_EXTRA_ARGS: Dict[str, List[str]] = {
".xlsx": ["--table"],
}
# Timeout in seconds
_TIMEOUT = 300
def run_mineru(
input_path: Path,
output_dir: Path,
verbose: bool = False,
start_page: Optional[int] = None,
end_page: Optional[int] = None,
method: Optional[str] = None,
lang: Optional[str] = None,
) -> List[Dict[str, str]]:
"""
Invoke MinerU to convert a single file.
Args:
input_path: Path to the source file.
output_dir: Directory where MinerU writes results.
verbose: If True, print MinerU stderr output.
start_page: 0-indexed start page (PDF only).
end_page: 0-indexed end page (PDF only).
method: PDF parsing method — 'auto', 'txt', or 'ocr'.
lang: Document language code (e.g. 'ch', 'en', 'ja').
Returns:
List of dicts with keys:
- subdir: relative path of the output subdirectory
- md_files: list of .md file paths found
- images_dir: path to the images directory (or None)
Raises:
MineruNotFoundError: If mineru command is not available.
MineruExecutionError: If MinerU fails.
"""
input_path = Path(input_path).expanduser().resolve()
output_dir = Path(output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
config = load_config()
mineru_config = config.get("mineru", {})
mineru_cmd = mineru_config.get("command", "mineru")
default_args = mineru_config.get("args", {})
cmd = [
mineru_cmd,
"-p", str(input_path),
"-o", str(output_dir),
"-b", default_args.get("backend", "pipeline"),
]
# -m method: CLI arg overrides config
if method is not None:
cmd.extend(["-m", method])
elif default_args.get("method", "auto") != "auto":
cmd.extend(["-m", default_args["method"]])
# -l language: CLI arg overrides config
if lang is not None:
cmd.extend(["-l", lang])
elif default_args.get("language", "ch") != "ch":
cmd.extend(["-l", default_args["language"]])
# -s/-e page range (only for PDF)
if start_page is not None:
cmd.extend(["-s", str(start_page)])
if end_page is not None:
cmd.extend(["-e", str(end_page)])
# Add format-specific arguments
ext = input_path.suffix.lower()
cmd.extend(_FORMAT_EXTRA_ARGS.get(ext, []))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
timeout=_TIMEOUT,
)
except FileNotFoundError:
raise MineruNotFoundError(
f"mineru command not found: {mineru_cmd}. "
"Please ensure MinerU is installed and available in PATH."
)
except subprocess.TimeoutExpired:
raise MineruExecutionError(f"MinerU timed out after {_TIMEOUT}s for {input_path}")
except subprocess.CalledProcessError as e:
stderr = e.stderr or ""
if verbose:
print(f"[mineru stderr] {stderr}")
raise MineruExecutionError(
f"MinerU failed (exit code {e.returncode}) for {input_path}: {stderr}"
)
if verbose and result.stdout:
print(f"[mineru output] {result.stdout.strip()}")
return _scan_mineru_output(output_dir, input_path)
def check_mineru_available() -> Tuple[bool, Optional[str]]:
"""
Check if the MinerU command is available and responds to --version.
Reads the command from config.yaml, falls back to "mineru".
Returns:
Tuple of (available: bool, version_string: str or None).
"""
config = load_config()
mineru_cmd = config.get("mineru", {}).get("command", "mineru")
try:
result = subprocess.run(
[mineru_cmd, "--version"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
version = result.stdout.strip().split("\n")[-1].strip()
return True, version
return False, None
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False, None
def _scan_mineru_output(output_dir: Path, input_path: Path) -> List[Dict[str, str]]:
"""
Scan the output directory for MinerU-generated subdirectories.
Finds all subdirectories, locates .md files and images, returns metadata.
"""
output_dir = Path(output_dir).resolve()
results = []
for item in sorted(output_dir.iterdir()):
if not item.is_dir():
continue
md_files = []
for md_file in item.rglob("*.md"):
if md_file.is_file():
md_files.append(str(md_file))
images_dir = None
images_path = item / "images"
if images_path.is_dir():
images_dir = str(images_path)
results.append({
"subdir": str(item),
"md_files": md_files,
"images_dir": images_dir,
})
if not results:
raise MineruExecutionError(
f"No output subdirectories found in {output_dir}. "
"MinerU may have failed or written to an unexpected location."
)
return results
"""MinerU installation guidance and automatic setup."""
import subprocess
from pathlib import Path
from typing import Tuple
import yaml
from .config_loader import load_config
_SKILL_DIR = Path(__file__).resolve().parent.parent
def check_mineru_available() -> Tuple[bool, str]:
"""
Check if MinerU is available by running mineru --version.
Returns:
(available: bool, version: str)
"""
from .mineru_caller import check_mineru_available as _check
return _check()
def install_mineru_auto(
use_all: bool = True,
) -> bool:
"""
Automatically install MinerU in a local venv under the skill directory.
Creates ~/.config/opencode/skills/mineru-converter/.mineru_venv
and installs mineru[all] or mineru[core] into it.
Updates config.yaml to point to the venv's mineru binary.
Args:
use_all: If True, install mineru[all]; otherwise mineru[core].
Returns:
True if installation succeeded.
"""
venv_dir = _SKILL_DIR / ".mineru_venv"
pip_install = "mineru[all]" if use_all else "mineru[core]"
print(f"\nCreating virtual environment at {venv_dir}...")
result = subprocess.run(
["python3", "-m", "venv", str(venv_dir)],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f"Failed to create venv: {result.stderr}")
return False
pip_cmd = str(venv_dir / "bin" / "pip")
print(f"Installing {pip_install}...")
result = subprocess.run(
[pip_cmd, "install", "-i", "https://mirrors.aliyun.com/pypi/simple", pip_install],
capture_output=True, text=True,
timeout=600,
)
if result.returncode != 0:
print(f"Installation failed: {result.stderr}")
return False
mineru_bin = str(venv_dir / "bin" / "mineru")
print(f"\nMinerU installed successfully at {mineru_bin}")
# Update config.yaml
_update_config_command(_SKILL_DIR / "config.yaml", mineru_bin)
return True
def _update_config_command(config_path: Path, mineru_bin: str) -> None:
"""Update config.yaml with the new mineru command path."""
cfg_path = Path(config_path).expanduser().resolve()
if not cfg_path.exists():
return
with open(cfg_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
config.setdefault("mineru", {})["command"] = mineru_bin
with open(cfg_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def show_manual_instructions() -> str:
"""Return installation instructions as text."""
return (
"\nMinerU is not installed. Install it manually:\n\n"
" 1. Create a virtual environment:\n"
" python3 -m venv ~/Projects/mineru_env\n"
" source ~/Projects/mineru_env/bin/activate\n\n"
" 2. Install MinerU (with all backends):\n"
" pip install -i https://mirrors.aliyun.com/pypi/simple 'mineru[all]'\n\n"
" Or core only (lighter):\n"
" pip install -i https://mirrors.aliyun.com/pypi/simple 'mineru[core]'\n\n"
" 3. Verify:\n"
" mineru --version\n\n"
"After installing, call the converter again.\n"
)
"""Validate converted output — checks MD existence, image paths, and manifest consistency."""
import re
from pathlib import Path
from typing import Dict, Any, List, Optional
from .config_loader import get_output_dir, get_manifest_path
from .manifest_manager import load_manifest
class ValidationResult:
def __init__(self):
self.errors: List[str] = []
self.warnings: List[str] = []
self.files_checked: int = 0
@property
def is_valid(self) -> bool:
return len(self.errors) == 0
def summary(self) -> str:
parts = [f"Checked: {self.files_checked} files"]
if self.errors:
parts.append(f"Errors: {len(self.errors)}")
if self.warnings:
parts.append(f"Warnings: {len(self.warnings)}")
return " | ".join(parts)
def validate_conversion(
output_dir: Optional[Path] = None,
) -> ValidationResult:
"""
Validate all entries in manifest.json against actual files on disk.
Checks:
- Each manifest entry's output_md file exists
- Each manifest entry's attachments directory exists (if specified)
- Image paths in MD files reference valid files
"""
output_dir = Path(output_dir or get_output_dir()).expanduser().resolve()
result = ValidationResult()
manifest = load_manifest(get_manifest_path(output_dir))
files = manifest.get("files", {})
if not files:
result.warnings.append("No records in manifest to validate")
return result
for file_hash, record in files.items():
result.files_checked += 1
# Check MD file exists
md_rel = record.get("output_md", "")
if not md_rel:
result.warnings.append(f"Record {file_hash}: no output_md path")
continue
md_path = output_dir / md_rel
if not md_path.exists():
result.errors.append(f"MD file missing: {md_path}")
continue
# Check attachments exist
attachments_rel = record.get("output_attachments", "")
if attachments_rel:
attachments_path = output_dir / attachments_rel
if not attachments_path.exists():
result.warnings.append(f"Attachments missing: {attachments_path}")
# Check image references in MD
if md_path.exists():
_check_image_refs(md_path, output_dir, result)
return result
def _check_image_refs(md_path: Path, output_dir: Path, result: ValidationResult) -> None:
"""Check that all image references in a markdown file point to existing files."""
content = md_path.read_text(encoding="utf-8")
pattern = re.compile(r'!\[[^\]]*\]\(([^)]+)\)')
refs = pattern.findall(content)
for ref in refs:
# Skip external URLs
if ref.startswith("http://") or ref.startswith("https://"):
continue
img_path = output_dir / ref
if not img_path.exists():
result.warnings.append(f"Image not found: {img_path} (referenced from {md_path})")
if __name__ == "__main__":
import sys
output = Path(sys.argv[1]).expanduser().resolve() if len(sys.argv) > 1 else None
vr = validate_conversion(output_dir=output)
print(vr.summary())
for e in vr.errors:
print(f" ERROR: {e}", file=sys.stderr)
for w in vr.warnings:
print(f" WARN: {w}")
sys.exit(0 if vr.is_valid else 1)
"""Tests for CLI argument parsing."""
import pytest
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
# Ensure scripts/ is in path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.cli import main
def _mock_convert_success():
"""Return a mock result dict for a successful conversion."""
return {
"path": "/fake/file.pdf",
"status": "success",
"error": None,
"details": {"md_path": "/fake/out/file.md", "images": 5},
}
class TestCliPagesParam:
"""Test --pages parameter handling."""
def test_pages_with_file_succeeds(self, tmp_path):
"""--pages with --file should be accepted."""
f = tmp_path / "test.pdf"
f.write_text("fake", encoding="utf-8")
with patch("scripts.cli.convert_single", return_value=_mock_convert_success()):
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--file", str(f), "--pages", "3-5"]):
main()
def test_pages_with_dir_errors(self, tmp_path):
"""--pages with --dir should raise argparse error in main()."""
d = tmp_path / "inbox"
d.mkdir()
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--dir", str(d), "--pages", "3-5"]):
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 2 # parser.error exits with code 2 in argparse
def test_pages_single_page(self, tmp_path):
"""--pages 3 (single page) should be accepted."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--file", type=str)
p.add_argument("--pages", type=str, default=None)
args = parser.parse_args(["convert", "--file", "/fake.pdf", "--pages", "3"])
assert args.pages == "3"
class TestCliMethodParam:
"""Test --method parameter handling."""
def test_method_choices(self):
"""--method should accept auto, txt, ocr."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--method", type=str, choices=["auto", "txt", "ocr"])
for choice in ["auto", "txt", "ocr"]:
args = parser.parse_args(["convert", "--method", choice])
assert args.method == choice
def test_method_invalid(self):
"""--method with invalid value should raise error."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--method", type=str, choices=["auto", "txt", "ocr"])
with pytest.raises(SystemExit):
parser.parse_args(["convert", "--method", "invalid"])
def test_method_with_dir_allowed(self):
"""--method with --dir should be accepted (no error)."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--dir", type=str)
p.add_argument("--method", type=str, choices=["auto", "txt", "ocr"])
args = parser.parse_args(["convert", "--dir", "/some/dir", "--method", "ocr"])
assert args.method == "ocr"
class TestCliLangParam:
"""Test --lang parameter handling."""
def test_lang_accepted(self):
"""--lang should be accepted."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--file", type=str)
p.add_argument("--lang", type=str, default=None)
args = parser.parse_args(["convert", "--file", "/f.pdf", "--lang", "en"])
assert args.lang == "en"
def test_lang_with_dir_allowed(self):
"""--lang with --dir should be accepted."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--dir", type=str)
p.add_argument("--lang", type=str, default=None)
args = parser.parse_args(["convert", "--dir", "/some/dir", "--lang", "ja"])
assert args.lang == "ja"
class TestCliMutualExclusion:
"""Test mutual exclusion rules."""
def test_pages_and_dir_mutually_exclusive(self):
"""--pages and --dir together should error."""
from argparse import ArgumentParser
parser = ArgumentParser()
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("convert")
p.add_argument("--dir", type=str)
p.add_argument("--file", type=str)
p.add_argument("--pages", type=str, default=None)
args = parser.parse_args(["convert", "--dir", "/d", "--pages", "1-3"])
# The mutual exclusion check is done in cli.py logic, not argparse
# So argparse succeeds, but main() should call parser.error
assert args.dir == "/d"
assert args.pages == "1-3"
# The actual error is tested in the integration below
class TestCliEnsureMineru:
"""Test _ensure_mineru_installed function."""
def test_mineru_available_returns_true(self):
"""When MinerU is available, returns True."""
with patch("scripts.cli.check_mineru_available", return_value=(True, "3.1.4")):
result = main.__globals__["_ensure_mineru_installed"]()
assert result is True
def test_mineru_unavailable_prompt_auto(self):
"""When MinerU missing and user chooses auto, returns True."""
with patch("scripts.cli.check_mineru_available", return_value=(False, None)):
with patch("scripts.cli.install_mineru_auto", return_value=True):
with patch("scripts.cli.check_mineru_available", return_value=(True, "3.1.4")):
with patch("builtins.input", return_value="a"):
result = main.__globals__["_ensure_mineru_installed"]()
assert result is True
def test_mineru_unavailable_prompt_manual(self):
"""When MinerU missing and user chooses manual, returns False."""
with patch("scripts.cli.check_mineru_available", return_value=(False, None)):
with patch("builtins.input", return_value="m"):
result = main.__globals__["_ensure_mineru_installed"]()
assert result is False
class TestConvertSubcommandIntegration:
"""Integration test: convert --file with new parameters."""
def test_convert_file_with_pages(self, tmp_path):
"""convert --file with --pages calls convert_single with page args."""
f = tmp_path / "test.pdf"
f.write_text("fake", encoding="utf-8")
with patch("scripts.cli.convert_single", return_value=_mock_convert_success()) as mock_conv:
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--file", str(f), "--pages", "3-5"]):
main()
mock_conv.assert_called_once()
call_kwargs = mock_conv.call_args
assert call_kwargs.kwargs.get("pages") == "3-5"
def test_convert_file_with_method(self, tmp_path):
"""convert --file with --method ocr."""
f = tmp_path / "test.pdf"
f.write_text("fake", encoding="utf-8")
with patch("scripts.cli.convert_single", return_value=_mock_convert_success()) as mock_conv:
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--file", str(f), "--method", "ocr"]):
main()
call_kwargs = mock_conv.call_args
assert call_kwargs.kwargs.get("method") == "ocr"
def test_convert_file_with_lang(self, tmp_path):
"""convert --file with --lang en."""
f = tmp_path / "test.pdf"
f.write_text("fake", encoding="utf-8")
with patch("scripts.cli.convert_single", return_value=_mock_convert_success()) as mock_conv:
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--file", str(f), "--lang", "en"]):
main()
call_kwargs = mock_conv.call_args
assert call_kwargs.kwargs.get("lang") == "en"
def test_convert_dir_with_method(self, tmp_path):
"""convert --dir with --method ocr."""
d = tmp_path / "inbox"
d.mkdir()
with patch("scripts.cli.convert_batch", return_value={
"scanned": 0, "processed": 0, "skipped": 0, "failed": 0, "items": []
}) as mock_conv:
with patch("scripts.cli._ensure_mineru_installed", return_value=True):
with patch("sys.argv", ["mineru-converter", "convert", "--dir", str(d), "--method", "ocr"]):
main()
call_kwargs = mock_conv.call_args
assert call_kwargs.kwargs.get("method") == "ocr"
"""Tests for converter module page range parsing."""
import pytest
from scripts.converter import _parse_page_range
class TestParsePageRange:
"""Test _parse_page_range utility function."""
def test_range_3_to_5(self):
"""'3-5' should return (2, 4) — 0-indexed."""
start, end = _parse_page_range("3-5")
assert start == 2
assert end == 4
def test_single_page(self):
"""'3' (no dash) should return (2, 2) — 0-indexed single page."""
start, end = _parse_page_range("3")
assert start == 2
assert end == 2
def test_full_document_range(self):
"""'1-100' should return (0, 99)."""
start, end = _parse_page_range("1-100")
assert start == 0
assert end == 99
def test_empty_string(self):
"""Empty string should return (None, None)."""
start, end = _parse_page_range("")
assert start is None
assert end is None
def test_none_input(self):
"""None or empty string returns (None, None)."""
start, end = _parse_page_range(None)
assert start is None
assert end is None
def test_range_with_spaces(self):
"""' 3 - 5 ' should handle whitespace."""
start, end = _parse_page_range(" 3 - 5 ")
assert start == 2
assert end == 4
def test_same_start_end(self):
"""'5-5' should return (4, 4)."""
start, end = _parse_page_range("5-5")
assert start == 4
assert end == 4
def test_invalid_format(self):
"""'3x5' should raise ValueError."""
with pytest.raises(ValueError):
_parse_page_range("3x5")
"""Tests for epub_converter module and EPUB integration in converter.py."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from ebooklib import epub
from scripts.epub_converter import (
convert_epub,
_compute_file_hash,
_extract_images,
_convert_content,
_rewrite_image_paths,
_derive_filename,
EpubConvertError,
)
from scripts.converter import convert_single, _convert_epub_workflow
# ---------------------------------------------------------------------------
# Helpers — build minimal EPUB fixtures
# ---------------------------------------------------------------------------
def _make_minimal_epub(tmp_dir: Path) -> Path:
"""Create an EPUB with one section of text and one embedded image."""
book = epub.EpubBook()
book.set_identifier("test-001")
book.set_title("Test Book")
book.set_language("en")
chapter = epub.EpubHtml(
title="Chapter 1",
file_name="chap_01.xhtml",
lang="en",
)
chapter.content = (
"<html><body>"
"<h1>Chapter 1</h1>"
"<p>Hello world.</p>"
'<p><img src="../images/cover.png" alt="Cover"/></p>'
"</body></html>"
).encode("utf-8")
book.add_item(chapter)
cover = epub.EpubImage()
cover.file_name = "images/cover.png"
cover.content = b"\x89PNG\r\n\x1a\n" + b"fake" * 100 # valid PNG header
book.add_item(cover)
book.toc = [epub.Link("chap_01.xhtml", "Chapter 1", "ch1")]
book.spine = ["nav", chapter]
nav = epub.EpubNav()
book.add_item(nav)
tmp_dir.mkdir(parents=True, exist_ok=True)
epub_path = tmp_dir / "test.epub"
epub.write_epub(str(epub_path), book, {})
return epub_path
def _make_noimage_epub(tmp_dir: Path) -> Path:
"""Create an EPUB with no images at all."""
book = epub.EpubBook()
book.set_identifier("test-002")
book.set_title("Text Only")
book.set_language("en")
ch = epub.EpubHtml(title="Only", file_name="only.xhtml", lang="en")
ch.content = "<html><body><p>Just text.</p></body></html>".encode("utf-8")
book.add_item(ch)
book.toc = [epub.Link("only.xhtml", "Only", "only")]
book.spine = ["nav", ch]
book.add_item(epub.EpubNav())
tmp_dir.mkdir(parents=True, exist_ok=True)
epub_path = tmp_dir / "noimage.epub"
epub.write_epub(str(epub_path), book, {})
return epub_path
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_dir():
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def minimal_epub(tmp_dir):
return _make_minimal_epub(tmp_dir)
@pytest.fixture
def noimage_epub(tmp_dir):
return _make_noimage_epub(tmp_dir)
# ---------------------------------------------------------------------------
# Unit tests — epub_converter internals
# ---------------------------------------------------------------------------
class TestComputeFileHash:
def test_returns_hex_string(self, minimal_epub):
h = _compute_file_hash(minimal_epub)
assert isinstance(h, str)
assert len(h) == 64
assert all(c in "0123456789abcdef" for c in h)
def test_deterministic(self, tmp_dir):
p1 = _make_minimal_epub(tmp_dir / "a")
p2 = _make_minimal_epub(tmp_dir / "b")
assert _compute_file_hash(p1) == _compute_file_hash(p2)
class TestExtractImages:
def test_extracts_images(self, tmp_dir, minimal_epub):
book = epub.read_epub(str(minimal_epub))
adir = tmp_dir / "attachments" / "abcd1234"
nm = _extract_images(book, adir)
assert "images/cover.png" in nm
assert "cover.png" in nm
assert (adir / "cover.png").exists()
def test_no_images(self, tmp_dir, noimage_epub):
book = epub.read_epub(str(noimage_epub))
adir = tmp_dir / "attachments" / "none"
nm = _extract_images(book, adir)
assert nm == {}
class TestConvertContent:
def test_returns_markdown(self, minimal_epub):
book = epub.read_epub(str(minimal_epub))
md = _convert_content(book)
assert "Chapter 1" in md
assert "Hello world" in md
def test_text_only(self, noimage_epub):
book = epub.read_epub(str(noimage_epub))
md = _convert_content(book)
assert "Just text" in md
class TestRewriteImagePaths:
def test_rewrites_from_name_map(self):
md = ""
nm = {"images/cover.png": "cover.png", "cover.png": "cover.png"}
result = _rewrite_image_paths(md, "attachments/abc123", nm)
assert result == ""
def test_preserves_non_image_content(self):
md = "# Title\n\n[link](http://x.com)\n\n"
nm = {"images/a.png": "a.png", "a.png": "a.png"}
result = _rewrite_image_paths(md, "attachments/h", nm)
assert "# Title" in result
assert "link](http://x.com)" in result
assert "attachments/h/a.png" in result
def test_no_images(self):
md = "Just plain text."
result = _rewrite_image_paths(md, "attachments/x", {})
assert result == md
def test_fallback_basename(self):
"""When name_map doesn't contain the ref, use the basename."""
md = ""
result = _rewrite_image_paths(md, "attachments/h", {})
assert result == ""
class TestDeriveFilename:
def test_from_metadata(self, minimal_epub):
book = epub.read_epub(str(minimal_epub))
name = _derive_filename(book, minimal_epub)
assert name == "Test Book.md"
def test_fallback_to_stem(self, tmp_dir):
book = epub.EpubBook()
path = tmp_dir / "my_document.epub"
name = _derive_filename(book, path)
assert name == "my_document.md"
def test_sanitizes_invalid_chars(self, tmp_dir):
path = tmp_dir / "bad:name?.epub"
name = _derive_filename(epub.EpubBook(), path)
assert ":" not in name
assert "?" not in name
# ---------------------------------------------------------------------------
# Integration tests — convert_epub
# ---------------------------------------------------------------------------
class TestConvertEpub:
def test_returns_expected_keys(self, tmp_dir, minimal_epub):
result = convert_epub(minimal_epub, tmp_dir)
assert "md_path" in result
assert "attachments_path" in result
assert "image_count" in result
assert "hash_prefix" in result
assert len(result["hash_prefix"]) == 8
def test_writes_markdown_file(self, tmp_dir, minimal_epub):
result = convert_epub(minimal_epub, tmp_dir)
md_path = Path(result["md_path"])
assert md_path.exists()
content = md_path.read_text(encoding="utf-8")
assert "Chapter 1" in content
assert "Hello world" in content
def test_writes_attachments(self, tmp_dir, minimal_epub):
result = convert_epub(minimal_epub, tmp_dir)
ap = Path(result["attachments_path"])
assert ap.exists()
images = list(ap.iterdir())
assert len(images) >= 1
assert result["image_count"] >= 1
def test_image_path_rewritten(self, tmp_dir, minimal_epub):
result = convert_epub(minimal_epub, tmp_dir)
content = Path(result["md_path"]).read_text(encoding="utf-8")
# Should reference attachments/, not images/ or ../images/
assert "attachments/" in content
assert "cover.png" in content
assert "../images/" not in content
def test_no_images(self, tmp_dir, noimage_epub):
result = convert_epub(noimage_epub, tmp_dir)
assert result["image_count"] == 0
md = Path(result["md_path"]).read_text(encoding="utf-8")
assert "Just text" in md
# ---------------------------------------------------------------------------
# Integration tests — converter.py integration
# ---------------------------------------------------------------------------
class TestConverterIntegration:
def test_convert_single_epub(self, tmp_dir, minimal_epub):
"""convert_single should handle EPUB correctly and return success."""
result = convert_single(minimal_epub, output_dir=tmp_dir)
assert result["status"] == "success"
assert result["details"] is not None
assert result["details"]["images"] >= 1
assert Path(result["details"]["md_path"]).exists()
def test_skip_already_converted(self, tmp_dir, minimal_epub):
"""Second call without --force should skip."""
r1 = convert_single(minimal_epub, output_dir=tmp_dir)
assert r1["status"] == "success"
r2 = convert_single(minimal_epub, output_dir=tmp_dir)
assert r2["status"] == "skipped"
def test_force_reconvert(self, tmp_dir, minimal_epub):
"""--force should re-convert even if already done."""
convert_single(minimal_epub, output_dir=tmp_dir)
r2 = convert_single(minimal_epub, output_dir=tmp_dir, force=True)
assert r2["status"] == "success"
def test_epub_without_images(self, tmp_dir, noimage_epub):
result = convert_single(noimage_epub, output_dir=tmp_dir)
assert result["status"] == "success"
assert result["details"]["images"] == 0
def test_manifest_record_created(self, tmp_dir, minimal_epub):
from scripts.manifest_manager import load_manifest, get_manifest_path
convert_single(minimal_epub, output_dir=tmp_dir)
manifest = load_manifest(get_manifest_path(tmp_dir))
assert len(manifest["files"]) >= 1
for rec in manifest["files"].values():
if rec["source_filename"].endswith(".epub"):
assert rec["format"] == ".epub"
assert rec["status"] == "success"
break
else:
pytest.fail("No EPUB record found in manifest")
"""Tests for file_organizer module."""
import tempfile
from pathlib import Path
import pytest
from scripts.file_organizer import (
move_mineru_output,
rewrite_image_paths,
cleanup_mineru_subdirs,
_select_best_md,
_copy_images,
)
@pytest.fixture
def tmp_dir():
with tempfile.TemporaryDirectory() as d:
yield Path(d)
def test_rewrite_image_paths_basic():
"""Test basic image path rewriting."""
content = "\n"
result = rewrite_image_paths(content, "attachments", "abc12345")
assert "attachments/abc12345/test.png" in result
assert "attachments/abc12345/pic.jpg" in result
assert "images/test.png" not in result
assert "./images/pic.jpg" not in result
def test_rewrite_image_paths_no_images():
"""Test content without images is unchanged."""
content = "Just text, no images here."
result = rewrite_image_paths(content, "attachments", "abc12345")
assert result == content
def test_rewrite_image_paths_preserves_text():
"""Test that non-image content is preserved."""
content = "# Title\n\nParagraph with  inline.\n\n---\n\n[link](http://example.com)"
result = rewrite_image_paths(content, "attachments", "hash123")
assert "# Title" in result
assert "Paragraph with" in result
assert "link](http://example.com)" in result
assert "attachments/hash123/foo.png" in result
def test_move_mineru_output_creates_md(tmp_dir):
"""Test that move_mineru_output extracts and creates the MD file."""
# Setup MinerU-like output structure
mineru_dir = tmp_dir / "mineru_output" / "my_document" / "auto"
mineru_dir.mkdir(parents=True)
(mineru_dir / "my_document.md").write_text(
"# My Doc\n\n", encoding="utf-8"
)
images_dir = mineru_dir / "images"
images_dir.mkdir()
(images_dir / "screenshot.png").write_bytes(b"fake_png_data")
target = tmp_dir / "raw"
result = move_mineru_output(mineru_dir, target)
assert result["md_path"] is not None
assert Path(result["md_path"]).exists()
assert result["image_count"] == 1
# Verify image path was rewritten
md_content = Path(result["md_path"]).read_text(encoding="utf-8")
assert "attachments/" in md_content
assert "screenshot.png" in md_content
assert "images/screenshot.png" not in md_content
def test_move_mineru_output_creates_attachments(tmp_dir):
"""Test that images are copied to attachments directory."""
mineru_dir = tmp_dir / "mineru_output" / "doc" / "auto"
mineru_dir.mkdir(parents=True)
(mineru_dir / "doc.md").write_text("content", encoding="utf-8")
images_dir = mineru_dir / "images"
images_dir.mkdir()
(images_dir / "pic.jpg").write_bytes(b"fake_jpg")
target = tmp_dir / "raw"
result = move_mineru_output(mineru_dir, target)
assert Path(result["attachments_path"]).exists()
assert (Path(result["attachments_path"]) / "pic.jpg").exists()
def test_move_mineru_output_no_images(tmp_dir):
"""Test when MinerU output has no images directory."""
mineru_dir = tmp_dir / "mineru_output" / "nodocs" / "auto"
mineru_dir.mkdir(parents=True)
(mineru_dir / "nodocs.md").write_text("# No Images", encoding="utf-8")
target = tmp_dir / "raw"
result = move_mineru_output(mineru_dir, target)
assert result["image_count"] == 0
assert Path(result["md_path"]).exists()
def test_cleanup_mineru_subdirs(tmp_dir):
"""Test cleanup of temporary subdirectories."""
(tmp_dir / "keep_me").mkdir()
(tmp_dir / "remove_me_1").mkdir()
(tmp_dir / "remove_me_2").mkdir()
count = cleanup_mineru_subdirs(tmp_dir, keep_subdirs=["keep_me"])
assert count == 2
assert (tmp_dir / "keep_me").exists()
assert not (tmp_dir / "remove_me_1").exists()
assert not (tmp_dir / "remove_me_2").exists()
def test_select_best_md_by_name():
"""Test MD selection prefers matching name."""
files = [
Path("/tmp/other.md"),
Path("/tmp/my_paper.md"),
Path("/tmp/extra.md"),
]
selected = _select_best_md(files, "my_paper")
assert selected.name == "my_paper.md"
def test_select_best_md_by_content(tmp_dir):
"""Test MD selection falls back to largest file."""
f1 = tmp_dir / "small.md"
f1.write_text("a", encoding="utf-8")
f2 = tmp_dir / "large.md"
f2.write_text("a" * 100, encoding="utf-8")
files = [f1, f2]
selected = _select_best_md(files, "unmatched_name")
assert selected.name == "large.md"
"""Tests for manifest_manager module."""
import json
import hashlib
import tempfile
from pathlib import Path
import pytest
from scripts.manifest_manager import (
compute_sha256,
load_manifest,
save_manifest,
upsert_record,
build_record,
check_converted,
add_conversion_record,
ManifestLoadError,
)
@pytest.fixture
def tmp_dir():
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def sample_file(tmp_dir):
f = tmp_dir / "test.txt"
f.write_text("hello world", encoding="utf-8")
return f
def test_compute_sha256(sample_file):
h = compute_sha256(sample_file)
assert isinstance(h, str)
assert len(h) == 64
expected = hashlib.sha256(b"hello world").hexdigest()
assert h == expected
def test_compute_sha256_different_content(tmp_dir):
f1 = tmp_dir / "a.txt"
f1.write_text("aaa", encoding="utf-8")
f2 = tmp_dir / "b.txt"
f2.write_text("bbb", encoding="utf-8")
assert compute_sha256(f1) != compute_sha256(f2)
def test_load_manifest_empty(tmp_dir):
manifest_path = tmp_dir / "manifest.json"
m = load_manifest(manifest_path)
assert "version" in m
assert m["files"] == {}
def test_load_manifest_from_existing(tmp_dir):
manifest_path = tmp_dir / "manifest.json"
data = {"version": "2.0", "files": {"abc123": {"status": "success"}}}
manifest_path.write_text(json.dumps(data), encoding="utf-8")
m = load_manifest(manifest_path)
assert m["version"] == "2.0"
assert "abc123" in m["files"]
def test_load_manifest_corrupted(tmp_dir):
manifest_path = tmp_dir / "manifest.json"
manifest_path.write_text("not valid json {{{", encoding="utf-8")
with pytest.raises(ManifestLoadError):
load_manifest(manifest_path)
def test_save_and_load_manifest(tmp_dir):
manifest_path = tmp_dir / "manifest.json"
m = {"version": "1.0", "files": {}}
save_manifest(m, manifest_path)
loaded = load_manifest(manifest_path)
assert loaded["version"] == "1.0"
def test_upsert_record():
m = {"files": {}}
m = upsert_record(m, "hash1", {"status": "success"})
assert "hash1" in m["files"]
assert m["files"]["hash1"]["status"] == "success"
def test_build_record():
r = build_record(
source_path=Path("/tmp/doc.pdf"),
output_md="doc.md",
output_attachments="attachments/abc123",
file_format="pdf",
)
assert r["format"] == "pdf"
assert r["status"] == "success"
assert r["output_md"] == "doc.md"
assert "converted_at" in r
def test_check_converted_new_file(tmp_dir):
f = tmp_dir / "new.pdf"
f.write_text("pdf content", encoding="utf-8")
manifest = {"version": "1.0", "files": {}}
is_conv, h = check_converted(f, manifest)
assert is_conv is False
assert isinstance(h, str)
def test_check_converted_existing(tmp_dir):
f = tmp_dir / "existing.pdf"
f.write_text("same content", encoding="utf-8")
h = compute_sha256(f)
manifest = {
"version": "1.0",
"files": {h: {"source_path": str(f.resolve()), "status": "success"}},
}
is_conv, h2 = check_converted(f, manifest)
assert is_conv is True
def test_check_converted_modified(tmp_dir):
f = tmp_dir / "changed.pdf"
f.write_text("original content", encoding="utf-8")
h_original = compute_sha256(f)
# Create manifest with old hash
manifest = {
"version": "1.0",
"files": {h_original: {"source_path": str(f.resolve()), "status": "success"}},
}
# Modify file
f.write_text("modified content", encoding="utf-8")
new_h = compute_sha256(f)
is_conv, h_new = check_converted(f, manifest)
assert is_conv is False
assert h_new == new_h
def test_add_conversion_record(tmp_dir):
f = tmp_dir / "doc.pdf"
f.write_text("some pdf", encoding="utf-8")
manifest = {"version": "1.0", "files": {}}
manifest = add_conversion_record(
manifest, f, "doc.md", "attachments/abc", "pdf", status="success"
)
assert len(manifest["files"]) == 1
file_hash = compute_sha256(f)
assert file_hash in manifest["files"]
assert manifest["files"][file_hash]["status"] == "success"
"""Tests for mineru_caller module."""
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from scripts.mineru_caller import run_mineru, MineruError, MineruNotFoundError, _FORMAT_EXTRA_ARGS, check_mineru_available
def test_format_extra_args():
"""Verify format-specific extra args are configured."""
assert ".xlsx" in _FORMAT_EXTRA_ARGS
assert "--table" in _FORMAT_EXTRA_ARGS[".xlsx"]
assert ".pdf" not in _FORMAT_EXTRA_ARGS
def test_mineru_not_found():
"""Test MineruNotFoundError when command doesn't exist."""
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.side_effect = FileNotFoundError()
with pytest.raises(MineruNotFoundError):
run_mineru(Path("/tmp/test.pdf"), Path("/tmp/out"))
def test_mineru_execution_error():
"""Test MineruExecutionError when command fails."""
import subprocess as sp
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.side_effect = sp.CalledProcessError(1, "mineru", stderr="bad input")
with pytest.raises(MineruError):
run_mineru(Path("/tmp/test.pdf"), Path("/tmp/out"))
def test_run_mineru_success(tmp_path):
"""Test successful mineru invocation (mocked)."""
output_dir = tmp_path / "output"
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test_document" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test_document.md").write_text("# Title\n\nContent", encoding="utf-8")
(sub_dir / "images").mkdir()
(sub_dir / "images/img1.jpg").write_text("fake image", encoding="utf-8")
input_file = tmp_path / "test.pdf"
input_file.write_text("fake pdf", encoding="utf-8")
result = run_mineru(input_file, output_dir)
assert isinstance(result, list)
assert len(result) > 0
assert "md_files" in result[0]
assert "images_dir" in result[0]
def test_run_mineru_no_output():
"""Test error when mineru produces no output."""
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with pytest.raises(MineruError, match="No output"):
run_mineru(Path("/tmp/test.pdf"), Path("/tmp/nonexistent_output"))
class TestRunMineruPageRange:
"""Test --pages parameter handling in run_mineru."""
def test_pages_3_to_5(self, tmp_path):
"""--pages 3-5 should translate to -s 2 -e 4 (0-indexed)."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, start_page=2, end_page=4)
cmd = mock_run.call_args[0][0]
assert "-s" in cmd and "2" in cmd
assert "-e" in cmd and "4" in cmd
def test_pages_single(self, tmp_path):
"""Single page 3 should translate to -s 2 -e 2 (0-indexed)."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, start_page=2, end_page=2)
cmd = mock_run.call_args[0][0]
assert "-s" in cmd and "2" in cmd
assert "-e" in cmd and "2" in cmd
def test_no_pages(self, tmp_path):
"""No page range should not include -s or -e in command."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, start_page=None, end_page=None)
cmd = mock_run.call_args[0][0]
assert "-s" not in cmd
assert "-e" not in cmd
class TestRunMineruMethod:
"""Test --method parameter handling."""
def test_method_ocr(self, tmp_path):
"""--method ocr should add -m ocr to command."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, method="ocr")
cmd = mock_run.call_args[0][0]
idx = cmd.index("-m")
assert cmd[idx + 1] == "ocr"
def test_method_txt(self, tmp_path):
"""--method txt should add -m txt to command."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, method="txt")
cmd = mock_run.call_args[0][0]
idx = cmd.index("-m")
assert cmd[idx + 1] == "txt"
def test_method_none_uses_config_default(self, tmp_path):
"""When method=None, config value is used (auto is default, no -m flag needed)."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, method=None)
cmd = mock_run.call_args[0][0]
assert "-m" not in cmd
def test_method_override_config(self, tmp_path):
"""When method=None but config says 'txt', -m txt should be added."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.load_config") as mock_config:
mock_config.return_value = {
"mineru": {"command": "mineru", "args": {"backend": "pipeline", "method": "txt", "language": "ch"}},
}
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, method=None)
cmd = mock_run.call_args[0][0]
assert "-m" in cmd
idx = cmd.index("-m")
assert cmd[idx + 1] == "txt"
class TestRunMineruLang:
"""Test --lang parameter handling."""
def test_lang_en(self, tmp_path):
"""--lang en should add -l en to command."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, lang="en")
cmd = mock_run.call_args[0][0]
idx = cmd.index("-l")
assert cmd[idx + 1] == "en"
def test_lang_ja(self, tmp_path):
"""--lang ja should add -l ja to command."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, lang="ja")
cmd = mock_run.call_args[0][0]
idx = cmd.index("-l")
assert cmd[idx + 1] == "ja"
def test_lang_ch_default(self, tmp_path):
"""Default language ch matches hardcoded default, so no -l flag added."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, lang=None)
cmd = mock_run.call_args[0][0]
assert "-l" not in cmd
def test_lang_override_config(self, tmp_path):
"""When lang=None but config says 'en', -l en should be added."""
output_dir = tmp_path / "out"
input_file = tmp_path / "test.pdf"
input_file.write_text("fake", encoding="utf-8")
with patch("scripts.mineru_caller.load_config") as mock_config:
mock_config.return_value = {
"mineru": {"command": "mineru", "args": {"backend": "pipeline", "method": "auto", "language": "en"}},
}
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sub_dir = output_dir / "test" / "auto"
sub_dir.mkdir(parents=True)
(sub_dir / "test.md").write_text("# Title", encoding="utf-8")
run_mineru(input_file, output_dir, lang=None)
cmd = mock_run.call_args[0][0]
assert "-l" in cmd
idx = cmd.index("-l")
assert cmd[idx + 1] == "en"
class TestCheckMineruAvailable:
"""Test check_mineru_available function."""
def test_available(self):
"""When mineru --version succeeds, returns (True, version)."""
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="mineru, version 3.1.4")
available, version = check_mineru_available()
assert available is True
assert "3.1.4" in version
def test_not_found(self):
"""When mineru command is missing, returns (False, None)."""
with patch("scripts.mineru_caller.subprocess.run", side_effect=FileNotFoundError()):
available, version = check_mineru_available()
assert available is False
assert version is None
def test_timeout(self):
"""When mineru --version times out, returns (False, None)."""
import subprocess
with patch("scripts.mineru_caller.subprocess.run", side_effect=subprocess.TimeoutExpired("mineru", 30)):
available, version = check_mineru_available()
assert available is False
assert version is None
def test_uses_config_command(self):
"""check_mineru_available uses mineru.command from config."""
with patch("scripts.mineru_caller.load_config") as mock_config:
mock_config.return_value = {
"mineru": {"command": "/custom/path/mineru"},
}
with patch("scripts.mineru_caller.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="mineru, version 3.1.4")
available, version = check_mineru_available()
assert available is True
cmd = mock_run.call_args[0][0]
assert cmd[0] == "/custom/path/mineru"
"""Tests for mineru_setup module."""
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from scripts.mineru_setup import (
install_mineru_auto,
show_manual_instructions,
)
class TestInstallMineruAuto:
"""Test automatic MinerU installation."""
def test_install_creates_venv(self):
"""install_mineru_auto should create a venv directory."""
with patch("scripts.mineru_setup.subprocess.run") as mock_run:
# venv creation succeeds
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.mineru_setup._SKILL_DIR", Path("/tmp/test_skill")):
with patch("scripts.mineru_setup._update_config_command"):
result = install_mineru_auto(use_all=True)
assert result is True
calls = [c[0][0] for c in mock_run.call_args_list]
assert any("venv" in str(c) for c in calls)
def test_install_creates_correct_pip_command(self):
"""install_mineru_auto[all] should install mineru[all], not mineru[core]."""
with patch("scripts.mineru_setup.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.mineru_setup._SKILL_DIR", Path("/tmp/test_skill")):
with patch("scripts.mineru_setup._update_config_command"):
install_mineru_auto(use_all=True)
calls = [c[0][0] for c in mock_run.call_args_list]
pip_call = calls[1] # second call is pip install
assert "mineru[all]" in " ".join(pip_call)
def test_install_core_when_requested(self):
"""install_mineru_auto[core] should install mineru[core]."""
with patch("scripts.mineru_setup.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.mineru_setup._SKILL_DIR", Path("/tmp/test_skill")):
with patch("scripts.mineru_setup._update_config_command"):
install_mineru_auto(use_all=False)
calls = [c[0][0] for c in mock_run.call_args_list]
pip_call = calls[1]
assert "mineru[core]" in " ".join(pip_call)
def test_install_fails_on_venv_error(self):
"""If venv creation fails, returns False."""
with patch("scripts.mineru_setup.subprocess.run") as mock_run:
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr="venv error"),
]
with patch("scripts.mineru_setup._SKILL_DIR", Path("/tmp/test_skill")):
with patch("scripts.mineru_setup._update_config_command"):
result = install_mineru_auto()
assert result is False
class TestUpdateConfigCommand:
"""Test _update_config_command."""
def test_updates_config_yaml(self, tmp_path):
"""_update_config_command should update config.yaml with new mineru path."""
import yaml
cfg = tmp_path / "config.yaml"
existing_config = {
"output_dir": "./raw",
"mineru": {"command": "mineru", "args": {"backend": "pipeline"}},
}
with open(cfg, "w") as f:
yaml.dump(existing_config, f)
from scripts.mineru_setup import _update_config_command
_update_config_command(cfg, "/some/path/.mineru_venv/bin/mineru")
with open(cfg, "r") as f:
updated = yaml.safe_load(f)
assert updated["mineru"]["command"] == "/some/path/.mineru_venv/bin/mineru"
# Other keys should be preserved
assert updated["output_dir"] == "./raw"
class TestShowManualInstructions:
"""Test show_manual_instructions."""
def test_returns_text(self):
"""show_manual_instructions should return a non-empty string."""
result = show_manual_instructions()
assert isinstance(result, str)
assert len(result) > 0
def test_contains_install_commands(self):
"""Output should contain key installation commands."""
result = show_manual_instructions()
assert "venv" in result
assert "pip install" in result
assert "mineru" in result
assert "mineru --version" in result