Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/en/api/02-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ OpenViking supports various resource types, categorized by functionality:

| Type | Extensions | Description |
|------|------------|-------------|
| PDF | `.pdf` | Supports local parsing and MinerU API conversion |
| PDF | `.pdf` | Locally extracts text, tables, images, and document structure |
| Markdown | `.md`, `.markdown`, `.mdown`, `.mkd` | Native support, extracts structure and stores in segments |
| HTML | `.html`, `.htm` | Cleans navigation/ads and extracts content, converts to Markdown |
| Word | `.docx` | Extracts text, headings, tables and converts to Markdown |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/api/02-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ OpenViking 支持多种资源类型,按照功能分类如下:
文档类
| 类型 | 扩展名 | 说明 |
|------|--------|------|
| PDF | `.pdf` | 支持本地解析和 MinerU API 转换 |
| PDF | `.pdf` | 本地提取文本、表格、图片和文档结构 |
| Markdown | `.md`, `.markdown`, `.mdown`, `.mkd` | 原生支持,会提取结构并分段存储 |
| HTML | `.html`, `.htm` | 清理导航/广告后提取内容,转换为 Markdown |
| Word | `.docx` | 提取文本、标题、表格并转换为 Markdown |
Expand Down
6 changes: 2 additions & 4 deletions examples/ov.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,12 @@
},
"parsers": {
"pdf": {
"strategy": "auto",
"max_content_length": 100000,
"max_section_size": 4000,
"section_size_flexibility": 0.3,
"max_section_chars": 6000,
"mineru_endpoint": "https://mineru.example.com/api/v1",
"mineru_api_key": "{your-mineru-api-key}",
"mineru_timeout": 300.0,
"heading_detection": "auto",
"image_resolution": 300,
},
"code": {
"github_raw_domain": "raw.githubusercontent.com",
Expand Down
164 changes: 18 additions & 146 deletions openviking/parse/parsers/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@
"""
PDF parser for OpenViking.

Unified parser that converts PDF to Markdown then parses the result.
Supports dual strategy:
- Local: pdfplumber for direct conversion
- Remote: MinerU API for advanced conversion

This design simplifies PDF handling by delegating structure analysis
to the MarkdownParser after conversion.
Converts PDF to Markdown with pdfplumber, then delegates structure analysis
to the MarkdownParser.
"""

import asyncio
Expand Down Expand Up @@ -37,30 +32,15 @@

class PDFParser(BaseParser):
"""
PDF parser with dual conversion strategy.
PDF parser backed by pdfplumber.

Converts PDF → Markdown → ParseResult using MarkdownParser.
When available, extracts PDF bookmarks/outlines and injects them as
markdown headings so MarkdownParser can build a hierarchical directory
structure instead of flat numbered files.

Strategies:
- "local": Use pdfplumber for text and table extraction
- "mineru": Use MinerU API for advanced PDF processing
- "auto": Try local first, fallback to MinerU if configured

Examples:
>>> # Local parsing
>>> parser = PDFParser(PDFConfig(strategy="local"))
>>> result = await parser.parse("document.pdf")

>>> # Remote API parsing
>>> config = PDFConfig(
... strategy="mineru",
... mineru_endpoint="https://api.example.com/convert",
... mineru_api_key="key"
... )
>>> parser = PDFParser(config)
>>> parser = PDFParser(PDFConfig())
>>> result = await parser.parse("document.pdf")
"""

Expand All @@ -69,7 +49,7 @@ def __init__(self, config: Optional[PDFConfig] = None):
Initialize PDF parser.

Args:
config: PDFConfig instance (defaults to auto strategy)
config: PDFConfig instance
"""
self.config = config or PDFConfig()
self.config.validate()
Expand Down Expand Up @@ -103,7 +83,7 @@ async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs)

Raises:
FileNotFoundError: If PDF file doesn't exist
ValueError: If conversion fails with all strategies
ValueError: If PDF conversion fails
"""
start_time = time.time()
pdf_path = Path(source)
Expand Down Expand Up @@ -146,10 +126,9 @@ async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs)
# Step 3: Update metadata for PDF origin
result.source_format = "pdf" # Override markdown format
result.parser_name = "PDFParser"
result.parser_version = "2.0"
result.parser_version = "3.0"
result.parse_time = time.time() - start_time
result.meta.update(conversion_meta)
result.meta["pdf_strategy"] = self.config.strategy
result.meta["intermediate_markdown_length"] = len(markdown_content)
result.meta["intermediate_markdown_preview"] = markdown_content[:500]

Expand All @@ -175,60 +154,31 @@ async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs)
async def _convert_to_markdown(
self,
pdf_path: Path,
storage=None,
resource_name: Optional[str] = None,
) -> tuple[str, Dict[str, Any]]:
"""
Convert PDF to Markdown using configured strategy.
Convert PDF to Markdown with pdfplumber without blocking the event loop.

Args:
pdf_path: Path to PDF file
storage: Optional storage instance for extracted images
resource_name: Optional resource name for organizing saved images

Returns:
Tuple of (markdown_content, metadata_dict)

Raises:
ValueError: If all conversion strategies fail
"""
if self.config.strategy == "local":
return await self._convert_local(pdf_path, resource_name=resource_name)

elif self.config.strategy == "mineru":
return await self._convert_mineru(pdf_path, resource_name=resource_name)

elif self.config.strategy == "auto":
# Try local first
try:
return await self._convert_local(pdf_path, resource_name=resource_name)
except Exception as e:
logger.warning(f"Local conversion failed: {e}")

# Fallback to MinerU if configured
if self.config.mineru_endpoint:
logger.info("Falling back to MinerU API")
return await self._convert_mineru(pdf_path, resource_name=resource_name)
else:
raise ValueError(
f"Local conversion failed and no MinerU endpoint configured: {e}"
)

else:
raise ValueError(f"Unknown strategy: {self.config.strategy}")

async def _convert_local(
self, pdf_path: Path, storage=None, resource_name: Optional[str] = None
) -> tuple[str, Dict[str, Any]]:
# pdfplumber / pdfminer 的解析与图片/表格提取通常是 CPU/IO 密集且为同步实现,
# 放到线程池中执行,避免阻塞事件循环。
return await asyncio.to_thread(self._convert_local_sync, pdf_path, storage, resource_name)
return await asyncio.to_thread(
self._convert_to_markdown_sync,
pdf_path,
storage,
resource_name,
)

def _convert_local_sync(
def _convert_to_markdown_sync(
self, pdf_path: Path, storage=None, resource_name: Optional[str] = None
) -> tuple[str, Dict[str, Any]]:
"""同步版:用 pdfplumber 将 PDF 转 Markdown。

该方法会在 :meth:`_convert_local` 中通过 asyncio.to_thread 调用。
"""
"""Convert PDF to Markdown synchronously with pdfplumber."""
pdfplumber = lazy_import("pdfplumber")

# Import storage utilities
Expand All @@ -242,7 +192,6 @@ def _convert_local_sync(

parts = []
meta = {
"strategy": "local",
"library": "pdfplumber",
"pages_processed": 0,
"images_extracted": 0,
Expand Down Expand Up @@ -679,83 +628,6 @@ def _extract_image_from_page(self, page, img_info: dict) -> Optional[bytes]:
logger.debug(f"Image extraction error: {e}")
return None

async def _convert_mineru(
self,
pdf_path: Path,
resource_name: Optional[str] = None,
) -> tuple[str, Dict[str, Any]]:
"""
Convert PDF to Markdown using MinerU API.

Args:
pdf_path: Path to PDF file
resource_name: Optional resource name (unused in MinerU conversion)

Returns:
Tuple of (markdown_content, metadata)

Raises:
ImportError: If httpx not installed
Exception: If API call fails
"""
httpx = lazy_import("httpx")

if not self.config.mineru_endpoint:
raise ValueError("MinerU endpoint not configured")

meta = {
"strategy": "mineru",
"endpoint": self.config.mineru_endpoint,
"api_version": None,
}

try:
async with httpx.AsyncClient(timeout=self.config.mineru_timeout) as client:
# Prepare file upload
with open(pdf_path, "rb") as f:
files = {"file": (pdf_path.name, f, "application/pdf")}

# Prepare headers
headers = {}
if self.config.mineru_api_key:
headers["Authorization"] = f"Bearer {self.config.mineru_api_key}"

# Prepare request params
params = self.config.mineru_params or {}

# Make API request
logger.info(f"Calling MinerU API: {self.config.mineru_endpoint}")
response = await client.post(
self.config.mineru_endpoint,
files=files,
headers=headers,
params=params,
)
response.raise_for_status()

# Parse response
result = response.json()
markdown_content = result.get("markdown", "")

# Extract metadata from response
meta["api_version"] = result.get("version")
meta["processing_time"] = result.get("processing_time")
meta["total_pages"] = result.get("total_pages")

if not markdown_content:
logger.warning(f"MinerU returned empty content for {pdf_path}")

logger.info(
f"MinerU conversion: {meta.get('total_pages', '?')} pages → "
f"{len(markdown_content)} chars"
)

return markdown_content, meta

except Exception as e:
logger.error(f"MinerU API call failed: {e}")
raise

def _format_table_markdown(self, table: List[List[Optional[str]]]) -> str:
"""
Convert table data to Markdown table format.
Expand Down
36 changes: 5 additions & 31 deletions openviking_cli/utils/config/parser_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,13 @@ class PDFConfig(ParserConfig):
"""
Configuration for PDF parsing.

Supports three strategies:
- "local": Use pdfplumber for local PDF→Markdown conversion
- "mineru": Use MinerU API for remote PDF→Markdown conversion
- "auto": Try local first, fallback to MinerU if available

Attributes:
strategy: Parsing strategy ("local" | "mineru" | "auto")
mineru_endpoint: MinerU API endpoint URL
mineru_api_key: MinerU API authentication key
mineru_timeout: MinerU request timeout in seconds
mineru_params: Additional MinerU API parameters
heading_detection: Heading detection mode
font_heading_min_delta: Minimum font size delta from body text
max_heading_levels: Maximum heading levels for font analysis
image_resolution: Rendering DPI for extracted image regions
"""

strategy: str = "auto" # "local" | "mineru" | "auto"

# MinerU API configuration
mineru_endpoint: Optional[str] = None # API endpoint URL
mineru_api_key: Optional[str] = None # API authentication key
mineru_timeout: float = 300.0 # Request timeout in seconds (5 minutes)
mineru_params: Optional[dict] = None # Additional API parameters

# Heading detection configuration
heading_detection: str = "auto" # "bookmarks" | "font" | "auto" | "none"
font_heading_min_delta: float = 1.5 # Minimum font size delta from body text (pt)
Expand All @@ -180,18 +166,6 @@ def validate(self) -> None:
super().validate()

# Validate PDF-specific fields
if self.strategy not in ("local", "mineru", "auto"):
raise ValueError(
f"Invalid strategy '{self.strategy}'. Must be 'local', 'mineru', or 'auto'"
)

if self.strategy == "mineru":
if not self.mineru_endpoint:
raise ValueError("mineru_endpoint is required when strategy='mineru'")

if self.mineru_timeout <= 0:
raise ValueError("mineru_timeout must be positive")

if self.heading_detection not in ("bookmarks", "font", "auto", "none"):
raise ValueError(f"Invalid heading_detection: {self.heading_detection}")

Expand Down Expand Up @@ -840,7 +814,7 @@ def load_parser_configs_from_dict(config_dict: Dict[str, Any]) -> Dict[str, Pars

Examples:
>>> configs = load_parser_configs_from_dict({
... "pdf": {"strategy": "auto"},
... "pdf": {"heading_detection": "auto"},
... "code": {"github_raw_domain": "raw.githubusercontent.com"}
... })
>>> pdf_config = configs["pdf"]
Expand Down
4 changes: 1 addition & 3 deletions tests/parse/test_markdown_no_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,7 @@ async def test_pdf_no_split_converts_to_one_complete_markdown(
source.write_bytes(b"%PDF-fixture")
content = _long_markdown(with_headings=True)
fake_fs = _FakeVikingFS()
parser = PDFParser(
PDFConfig(strategy="local", max_section_size=32, max_section_chars=128)
)
parser = PDFParser(PDFConfig(max_section_size=32, max_section_chars=128))
markdown_parser = parser._get_markdown_parser()
monkeypatch.setattr(markdown_parser, "_get_viking_fs", lambda: fake_fs)
monkeypatch.setattr(
Expand Down
2 changes: 1 addition & 1 deletion tests/parse/test_parser_config_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


def test_pdf_parser_passes_its_config_to_nested_markdown_parser():
parser = PDFParser(PDFConfig(strategy="local", max_section_size=2222, max_section_chars=5555))
parser = PDFParser(PDFConfig(max_section_size=2222, max_section_chars=5555))

markdown_parser = parser._get_markdown_parser()

Expand Down
Loading