diff --git a/docs/en/api/02-resources.md b/docs/en/api/02-resources.md
index 4319fe6c76..4c2af55b43 100644
--- a/docs/en/api/02-resources.md
+++ b/docs/en/api/02-resources.md
@@ -12,19 +12,19 @@ OpenViking supports various resource types, categorized by functionality:
| Type | Extensions | Description |
|------|------------|-------------|
-| PDF | `.pdf` | Supports local parsing and MinerU API conversion |
+| PDF | `.pdf` | Uses pdf-inspector for page text, headings, tables, and reading order; pdfplumber extracts images after each page's text. Pages that require OCR are kept with explicit warnings |
| 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 |
+| Word | `.doc`, `.docx` | Uses AnyDoc to preserve text, headings, tables, and embedded image positions in Markdown |
| Plain Text | `.txt`, `.text` | Direct import and processing |
-| EPUB | `.epub` | E-book format, supports ebooklib or manual extraction |
+| EPUB | `.epub` | Uses AnyDoc to convert the e-book structure and embedded images to Markdown |
**Spreadsheets & Presentations**
| Type | Extensions | Description |
|------|------------|-------------|
-| Excel | `.xlsx`, `.xls`, `.xlsm` | Supports new and legacy Excel formats, converts to Markdown tables by worksheet |
-| PowerPoint | `.pptx` | Extracts content by slide, supports extracting notes |
+| Excel | `.xlsx`, `.xls`, `.xlsm` | Uses AnyDoc to convert worksheets to Markdown |
+| PowerPoint | `.pptx` | Uses AnyDoc to preserve slide content and embedded image positions; speaker notes stay in the same resource under `Speaker Notes` sections |
**Code**
diff --git a/docs/en/configuration/01-server.md b/docs/en/configuration/01-server.md
index a137b97fa8..75b373107d 100644
--- a/docs/en/configuration/01-server.md
+++ b/docs/en/configuration/01-server.md
@@ -336,7 +336,6 @@ Parsers live under `parsers`:
"audio": {},
"video": {},
"markdown": {},
- "excel": {},
"html": {},
"text": {},
"directory": {},
@@ -357,8 +356,7 @@ Parsers live under `parsers`:
| `code` | Repository file types, ignore rules, and network safety |
| `image` | Image understanding and OCR |
| `audio`, `video` | Audio/video parsing |
-| `markdown`, `html`, `text` | Text document chunking |
-| `excel` | Workbook parsing and chunking |
+| `markdown`, `html`, `text` | Text document chunking; AnyDoc-backed Office and EPUB files reuse the `markdown` sectioning settings |
| `directory` | Directory scanning and ignore rules |
| `feishu` | Feishu/Lark access and parsing |
| `webfeed` | Sitemap, RSS, and Atom ingestion |
diff --git a/docs/zh/api/02-resources.md b/docs/zh/api/02-resources.md
index 6eaf34ce1a..980dedca57 100644
--- a/docs/zh/api/02-resources.md
+++ b/docs/zh/api/02-resources.md
@@ -11,18 +11,18 @@ OpenViking 支持多种资源类型,按照功能分类如下:
文档类
| 类型 | 扩展名 | 说明 |
|------|--------|------|
-| PDF | `.pdf` | 支持本地解析和 MinerU API 转换 |
+| PDF | `.pdf` | 由 pdf-inspector 提取分页文本、标题、表格和阅读顺序,pdfplumber 在每页正文后提取图片;需要 OCR 的页面会保留并给出明确警告 |
| Markdown | `.md`, `.markdown`, `.mdown`, `.mkd` | 原生支持,会提取结构并分段存储 |
| HTML | `.html`, `.htm` | 清理导航/广告后提取内容,转换为 Markdown |
-| Word | `.docx` | 提取文本、标题、表格并转换为 Markdown |
+| Word | `.doc`, `.docx` | 通过 AnyDoc 将文本、标题、表格和嵌入图片的原始位置转换为 Markdown |
| 纯文本 | `.txt`, `.text` | 直接导入处理 |
-| EPUB | `.epub` | 电子书格式,支持 ebooklib 或手动提取 |
+| EPUB | `.epub` | 通过 AnyDoc 将电子书结构和嵌入图片转换为 Markdown |
表格类
| 类型 | 扩展名 | 说明 |
|------|--------|------|
-| Excel | `.xlsx`, `.xls`, `.xlsm` | 支持新版和老版 Excel,按工作表转换为 Markdown 表格 |
-| PowerPoint | `.pptx` | 按幻灯片提取内容,支持提取备注 |
+| Excel | `.xlsx`, `.xls`, `.xlsm` | 通过 AnyDoc 将工作表转换为 Markdown |
+| PowerPoint | `.pptx` | 通过 AnyDoc 保留幻灯片内容和嵌入图片位置;演讲者备注保留在同一资源的 `Speaker Notes` 分节中 |
代码类
| 类型 | 资源名 | 说明 |
diff --git a/docs/zh/configuration/01-server.md b/docs/zh/configuration/01-server.md
index c4a3192e45..62d76cdd7e 100644
--- a/docs/zh/configuration/01-server.md
+++ b/docs/zh/configuration/01-server.md
@@ -336,7 +336,6 @@ Provider 和密钥管理配置见[加密指南](../guides/08-encryption.md)。
"audio": {},
"video": {},
"markdown": {},
- "excel": {},
"html": {},
"text": {},
"directory": {},
@@ -357,8 +356,7 @@ Provider 和密钥管理配置见[加密指南](../guides/08-encryption.md)。
| `code` | 代码仓库文件类型、忽略规则和安全限制 |
| `image` | 图片理解和 OCR |
| `audio`、`video` | 音视频内容解析 |
-| `markdown`、`html`、`text` | 文本文档分段 |
-| `excel` | Excel 工作表解析与分段 |
+| `markdown`、`html`、`text` | 文本文档分段;由 AnyDoc 解析的 Office 和 EPUB 文件复用 `markdown` 分段配置 |
| `directory` | 目录扫描和忽略规则 |
| `feishu` | 飞书文档访问与解析 |
| `webfeed` | Sitemap、RSS 和 Atom 导入 |
diff --git a/examples/ov.conf.example b/examples/ov.conf.example
index 9056a72865..422869a414 100644
--- a/examples/ov.conf.example
+++ b/examples/ov.conf.example
@@ -223,14 +223,10 @@
},
"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,
},
"code": {
"github_raw_domain": "raw.githubusercontent.com",
diff --git a/openviking/parse/image_validation.py b/openviking/parse/image_validation.py
new file mode 100644
index 0000000000..f8ebe4b33c
--- /dev/null
+++ b/openviking/parse/image_validation.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: AGPL-3.0
+"""Shared acceptance policy for images embedded in parsed documents."""
+
+import io
+from pathlib import Path
+
+from openviking_cli.utils.logger import get_logger
+
+logger = get_logger(__name__)
+
+IMAGE_MIN_SIDE = 14
+IMAGE_MIN_PIXELS = 196
+IMAGE_MAX_PIXELS = 36_000_000
+IMAGE_MIN_ASPECT_RATIO = 1 / 150
+IMAGE_MAX_ASPECT_RATIO = 150
+IMAGE_MAX_FILE_BYTES = 10 * 1024 * 1024
+
+
+def is_valid_image(image_bytes: bytes, source_path: Path) -> bool:
+ """Return whether an extracted image is safe and useful to ingest."""
+ if len(image_bytes) > IMAGE_MAX_FILE_BYTES:
+ logger.warning(f"[ImageValidation] Image exceeds 10MB, skipping: {source_path}")
+ return False
+
+ try:
+ from PIL import Image
+
+ with Image.open(io.BytesIO(image_bytes)) as image:
+ width, height = image.size
+ except Exception as exc:
+ logger.warning(
+ f"[ImageValidation] Cannot read image dimensions, skipping {source_path}: {exc}"
+ )
+ return False
+
+ if width <= IMAGE_MIN_SIDE or height <= IMAGE_MIN_SIDE:
+ logger.warning(
+ f"[ImageValidation] Image side too small ({width}x{height}), skipping: {source_path}"
+ )
+ return False
+
+ pixels = width * height
+ if pixels < IMAGE_MIN_PIXELS or pixels > IMAGE_MAX_PIXELS:
+ logger.warning(
+ f"[ImageValidation] Image pixel count out of range ({pixels}), skipping: {source_path}"
+ )
+ return False
+
+ aspect_ratio = width / height
+ if aspect_ratio < IMAGE_MIN_ASPECT_RATIO or aspect_ratio > IMAGE_MAX_ASPECT_RATIO:
+ logger.warning(
+ f"[ImageValidation] Image aspect ratio out of range ({aspect_ratio:.4f}), "
+ f"skipping: {source_path}"
+ )
+ return False
+
+ return True
diff --git a/openviking/parse/parsers/__init__.py b/openviking/parse/parsers/__init__.py
index 5f17740d03..2bec8f7259 100644
--- a/openviking/parse/parsers/__init__.py
+++ b/openviking/parse/parsers/__init__.py
@@ -1,26 +1,20 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
+from .anydoc import AnyDocParser
from .base_parser import BaseParser
-from .epub import EPubParser
-from .excel import ExcelParser
from .html import HTMLParser
from .markdown import MarkdownParser
from .pdf import PDFParser
-from .powerpoint import PowerPointParser
from .text import TextParser
-from .word import WordParser
from .zip_parser import ZipParser
__all__ = [
+ "AnyDocParser",
"BaseParser",
- "EPubParser",
- "ExcelParser",
"HTMLParser",
"MarkdownParser",
"PDFParser",
- "PowerPointParser",
"TextParser",
- "WordParser",
"ZipParser",
]
diff --git a/openviking/parse/parsers/anydoc.py b/openviking/parse/parsers/anydoc.py
new file mode 100644
index 0000000000..9542a44254
--- /dev/null
+++ b/openviking/parse/parsers/anydoc.py
@@ -0,0 +1,808 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: AGPL-3.0
+"""Unified AnyDoc parser for Office documents and EPUB files."""
+
+import asyncio
+import html
+import re
+import time
+from dataclasses import dataclass
+from importlib.metadata import version
+from pathlib import Path
+from typing import Any, Iterable, List, Optional, Union
+
+from openviking.parse.accessors.mime_types import get_preferred_extension
+from openviking.parse.base import ParseResult
+from openviking.parse.image_validation import is_valid_image
+from openviking.parse.parsers.base_parser import BaseParser
+from openviking_cli.utils.config.parser_config import ParserConfig
+from openviking_cli.utils.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass(frozen=True)
+class _RenderedDocument:
+ markdown: str
+ detected_format: str
+ warnings: list[str]
+ assets_referenced: int
+ images_extracted: int
+
+
+@dataclass(frozen=True)
+class _ResolvedAnchor:
+ fragment: str
+ emit_html: bool
+
+
+@dataclass
+class _TextRun:
+ text: str
+ style: Any
+
+
+class _AnyDocMarkdownRenderer:
+ """Render AnyDoc's public document model while materializing image assets."""
+
+ def __init__(self, document: Any, *, source_format: str, resource_name: str, storage: Any):
+ self.document = document
+ self.source_format = source_format
+ self.resource_name = resource_name
+ self.storage = storage
+ self.assets = {asset.id: asset for asset in document.assets}
+ self.asset_paths: dict[int, Optional[str]] = {}
+ self.warnings: list[str] = []
+ self.assets_referenced: set[int] = set()
+ self.images_extracted = 0
+ self.note_numbers = self._number_notes()
+ self.anchors = self._resolve_anchors()
+
+ def render(self) -> str:
+ parts = [part for block in self.document.blocks if (part := self._render_block(block))]
+
+ rendered_note_numbers: set[int] = set()
+ ordered_notes = sorted(
+ (
+ (self.note_numbers[note.id], note)
+ for note in self.document.notes
+ if note.id in self.note_numbers
+ ),
+ key=lambda item: item[0],
+ )
+ for number, note in ordered_notes:
+ if number in rendered_note_numbers:
+ continue
+ body = self._render_blocks(note.blocks)
+ if not body:
+ continue
+ rendered_note_numbers.add(number)
+ lines = body.splitlines()
+ definition = f"[^{number}]: {lines[0]}"
+ if len(lines) > 1:
+ definition += "\n" + "\n".join(f" {line}" if line else "" for line in lines[1:])
+ parts.append(definition)
+
+ markdown = "\n\n".join(parts)
+ return f"{markdown}\n" if markdown else ""
+
+ def _render_blocks(self, blocks: Iterable[Any]) -> str:
+ return "\n\n".join(rendered for block in blocks if (rendered := self._render_block(block)))
+
+ def _render_block(self, block: Any) -> str:
+ kind = block.kind
+ if kind == "heading":
+ text = self._render_inlines(block.content or [], context="heading").strip()
+ if not text:
+ return ""
+ level = min(max(int(block.level or 1), 1), 6)
+ return f"{'#' * level} {text}"
+ if kind == "paragraph":
+ return self._render_inlines(block.content or [], context="block").strip()
+ if kind == "list":
+ return self._render_list(block.list)
+ if kind == "table":
+ table = block.table
+ if table.kind == "layout" and self._table_is_single_cell(table):
+ return self._render_blocks(table.grid[0][0].cell.blocks)
+ return self._render_table(table)
+ if kind == "block_quote":
+ inner = self._render_blocks(block.blocks or [])
+ if not inner:
+ return ""
+ if self.source_format == "pptx":
+ return f"### Speaker Notes\n\n{inner}"
+ return "\n".join(">" if not line else f"> {line}" for line in inner.splitlines())
+ if kind == "code_block":
+ body = (block.text or "").rstrip("\n")
+ fence = self._backtick_fence(body, 3)
+ return f"{fence}{block.lang or ''}\n{body}\n{fence}"
+ if kind == "rule":
+ return "---"
+ raise RuntimeError(f"Unsupported AnyDoc block kind: {kind}")
+
+ def _render_inlines(
+ self, inlines: Iterable[Any], *, context: str, in_label: bool = False
+ ) -> str:
+ normalized = self._normalize_inlines(inlines)
+ parts: list[str] = []
+ for index, inline in enumerate(normalized):
+ if isinstance(inline, _TextRun):
+ next_inline = normalized[index + 1] if index + 1 < len(normalized) else None
+ trailing_active = (
+ isinstance(next_inline, _TextRun)
+ and self._style_key(next_inline.style) != self._style_key(None)
+ ) or (
+ next_inline is not None
+ and not isinstance(next_inline, _TextRun)
+ and next_inline.kind in {"link", "image", "note_ref"}
+ )
+ rendered_so_far = "".join(parts)
+ parts.append(
+ self._render_text(
+ inline.text or "",
+ inline.style,
+ context=context,
+ in_label=in_label,
+ trailing_active=trailing_active,
+ at_line_start=not rendered_so_far or rendered_so_far.endswith("\n"),
+ )
+ )
+ continue
+
+ kind = inline.kind
+ if kind == "link":
+ target = inline.target
+ if not target.value:
+ parts.append(self._render_inlines(inline.content or [], context=context))
+ continue
+ label = self._render_inlines(inline.content or [], context=context, in_label=True)
+ if target.kind == "anchor":
+ resolved = self.anchors.get(target.value)
+ if resolved is None:
+ parts.append(self._render_inlines(inline.content or [], context=context))
+ continue
+ url = f"#{resolved.fragment}"
+ elif target.kind in {"external", "relative"}:
+ url = target.value
+ else:
+ raise RuntimeError(f"Unsupported AnyDoc link target kind: {target.kind}")
+ if label.strip():
+ parts.append(f"[{label}]({self._format_url(url)})")
+ elif target.kind != "anchor":
+ escaped = self._escape_text(
+ url, context=context, in_label=True, trailing_active=True
+ )
+ parts.append(f"[{escaped}]({self._format_url(url)})")
+ elif kind == "image":
+ parts.append(self._render_image(inline, context=context, in_label=in_label))
+ elif kind == "anchor":
+ resolved = self.anchors.get(inline.anchor)
+ if resolved and resolved.emit_html:
+ parts.append(f'')
+ elif kind == "note_ref":
+ number = self.note_numbers.get(inline.note_id)
+ if number is not None:
+ parts.append(f"[^{number}]")
+ elif kind == "line_break":
+ parts.append(
+ "\\\n" if context == "block" else "\n" if context == "table_cell" else " "
+ )
+ else:
+ raise RuntimeError(f"Unsupported AnyDoc inline kind: {kind}")
+ return "".join(parts)
+
+ def _normalize_inlines(self, inlines: Iterable[Any]) -> list[Any]:
+ """Mirror AnyDoc's run normalization before replacing embedded images."""
+ normalized: list[Any] = []
+ plain_style = self._style_key(None)
+ for inline in inlines:
+ if inline.kind == "anchor":
+ resolved = self.anchors.get(inline.anchor)
+ if resolved is None or not resolved.emit_html:
+ continue
+ if inline.kind != "text":
+ normalized.append(inline)
+ continue
+ if not inline.text:
+ continue
+
+ style = None if inline.text.isspace() else inline.style
+ style_key = self._style_key(style)
+ if isinstance(normalized[-1] if normalized else None, _TextRun):
+ previous = normalized[-1]
+ if self._style_key(previous.style) == style_key:
+ previous.text += inline.text
+ continue
+ if (
+ style_key != plain_style
+ and not style_key[3]
+ and len(normalized) >= 2
+ and isinstance(normalized[-1], _TextRun)
+ and normalized[-1].text.isspace()
+ and self._style_key(normalized[-1].style) == plain_style
+ and isinstance(normalized[-2], _TextRun)
+ and self._style_key(normalized[-2].style) == style_key
+ ):
+ whitespace = normalized.pop().text
+ normalized[-1].text += whitespace + inline.text
+ continue
+ normalized.append(_TextRun(text=inline.text, style=style))
+ return normalized
+
+ @staticmethod
+ def _style_key(style: Any) -> tuple[bool, bool, bool, bool]:
+ if style is None:
+ return False, False, False, False
+ return style.bold, style.italic, style.strike, style.code
+
+ def _render_text(
+ self,
+ text: str,
+ style: Any,
+ *,
+ context: str,
+ in_label: bool,
+ trailing_active: bool,
+ at_line_start: bool,
+ ) -> str:
+ if style is None or not any((style.bold, style.italic, style.strike, style.code)):
+ return self._escape_text(
+ text,
+ context=context,
+ in_label=in_label,
+ trailing_active=trailing_active,
+ at_line_start=at_line_start,
+ )
+
+ leading = text[: len(text) - len(text.lstrip())]
+ trailing = text[len(text.rstrip()) :]
+ core_end = len(text) - len(trailing) if trailing else len(text)
+ core = text[len(leading) : core_end]
+ if not core:
+ return text
+ if style.code:
+ flattened = core.replace("\n", " ")
+ fence = self._backtick_fence(flattened, 1)
+ pad = " " if flattened.startswith("`") or flattened.endswith("`") else ""
+ rendered = f"{fence}{pad}{flattened}{pad}{fence}"
+ else:
+ opening = ""
+ if style.strike:
+ opening += "~~"
+ if style.bold:
+ opening += "**"
+ if style.italic:
+ opening += "*"
+ escaped = self._escape_text(
+ core,
+ context=context,
+ styled=True,
+ in_label=in_label,
+ )
+ rendered = f"{opening}{escaped}{opening[::-1]}"
+ return f"{leading}{rendered}{trailing}"
+
+ def _render_image(self, inline: Any, *, context: str, in_label: bool) -> str:
+ alt = (inline.alt or "").strip()
+ source = inline.source
+ escaped_alt = self._escape_text(alt, context=context, in_label=True)
+ if source.kind == "external":
+ return f"})"
+ if source.kind == "unavailable":
+ self.warnings.append(f"Embedded image is unavailable: {alt or ''}")
+ return self._escape_text(alt, context=context, in_label=in_label)
+ if source.kind != "asset":
+ raise RuntimeError(f"Unsupported AnyDoc image source kind: {source.kind}")
+
+ asset_id = source.asset_id
+ self.assets_referenced.add(asset_id)
+ image_ref = self._materialize_asset(asset_id)
+ if image_ref is None:
+ return self._escape_text(alt, context=context, in_label=in_label)
+ return f"})"
+
+ def _materialize_asset(self, asset_id: int) -> Optional[str]:
+ if asset_id in self.asset_paths:
+ return self.asset_paths[asset_id]
+ asset = self.assets.get(asset_id)
+ if asset is None:
+ self.warnings.append(f"AnyDoc image references missing asset {asset_id}")
+ self.asset_paths[asset_id] = None
+ return None
+ if not asset.media_type.lower().startswith("image/"):
+ self.warnings.append(
+ f"AnyDoc asset {asset_id} is not an image ({asset.media_type}); kept as alt text"
+ )
+ self.asset_paths[asset_id] = None
+ return None
+
+ origin_suffix = Path(asset.origin_part).suffix.lower()
+ extension = origin_suffix or get_preferred_extension(asset.media_type) or ".png"
+ if not re.fullmatch(r"\.[a-z0-9]{1,10}", extension):
+ extension = get_preferred_extension(asset.media_type) or ".png"
+ display_path = Path(f"anydoc_asset_{asset_id}{extension}")
+ if not is_valid_image(asset.data, display_path):
+ self.warnings.append(
+ f"AnyDoc asset {asset_id} is not an ingestable image ({asset.media_type})"
+ )
+ self.asset_paths[asset_id] = None
+ return None
+
+ try:
+ path = self.storage.save_image(
+ self.resource_name,
+ asset.data,
+ filename=f"anydoc_asset_{asset_id}",
+ extension=extension,
+ )
+ relative = path.relative_to(self.storage.media_dir).as_posix()
+ except Exception as exc:
+ self.warnings.append(f"Failed to save AnyDoc image asset {asset_id}: {exc}")
+ self.asset_paths[asset_id] = None
+ return None
+
+ self.asset_paths[asset_id] = relative
+ self.images_extracted += 1
+ return relative
+
+ def _render_list(self, list_model: Any) -> str:
+ if list_model is None or not list_model.items:
+ return ""
+ rendered_items: list[str] = []
+ loose = False
+ for index, item in enumerate(list_model.items):
+ ordinal = int(list_model.start) + index
+ if item.marker_label:
+ marker = (
+ "- "
+ f"{self._escape_text(item.marker_label, context='block', at_line_start=True)} "
+ )
+ elif list_model.marker == "bullet":
+ marker = "- "
+ elif list_model.marker == "decimal":
+ marker = f"{ordinal}. "
+ else:
+ marker = f"- {self._marker_label(list_model.marker, ordinal)} "
+ checkbox = "[x] " if item.checked is True else "[ ] " if item.checked is False else ""
+ body = self._render_blocks(item.blocks)
+ if len(item.blocks) > 1:
+ loose = True
+ lines = body.splitlines() or [""]
+ indent = " " * len(marker)
+ rendered = f"{marker}{checkbox}{lines[0]}"
+ for line in lines[1:]:
+ if not line:
+ loose = True
+ rendered += "\n"
+ else:
+ rendered += f"\n{indent}{line}"
+ rendered_items.append(rendered)
+ return ("\n\n" if loose else "\n").join(rendered_items)
+
+ def _render_table(self, table: Any) -> str:
+ if table is None or not table.grid:
+ return ""
+ width = max((len(row) for row in table.grid), default=0)
+ rows: list[list[tuple[str, bool]]] = []
+ for row in table.grid:
+ rendered_row: list[tuple[str, bool]] = []
+ for slot in row:
+ if slot.kind == "origin":
+ rendered_row.append((self._render_cell(slot.cell), False))
+ elif slot.kind == "covered":
+ rendered_row.append(("", True))
+ else:
+ raise RuntimeError(f"Unsupported AnyDoc table slot kind: {slot.kind}")
+ rendered_row.extend(("", False) for _ in range(width - len(rendered_row)))
+ rows.append(rendered_row)
+ while len(rows) > 1 and all(not text and not covered for text, covered in rows[-1]):
+ rows.pop()
+ width = max(
+ (
+ max((index + 1 for index, cell in enumerate(row) if cell[0] or cell[1]), default=0)
+ for row in rows
+ ),
+ default=0,
+ )
+ if width == 0:
+ return ""
+ rows = [row[:width] for row in rows]
+ if table.header_rows >= 1:
+ header = [text for text, _ in rows.pop(0)]
+ else:
+ header = [""] * width
+ lines = [self._format_table_row(header), self._format_table_row(["---"] * width)]
+ lines.extend(self._format_table_row([text for text, _ in row]) for row in rows)
+ return "\n".join(lines)
+
+ def _render_cell(self, cell: Any) -> str:
+ if cell is None:
+ return ""
+ parts: list[str] = []
+ for block in cell.blocks:
+ kind = block.kind
+ if kind == "heading":
+ text = self._render_inlines(block.content or [], context="table_cell").strip()
+ if text:
+ parts.append(f"**{text}**")
+ elif kind == "paragraph":
+ text = self._render_inlines(block.content or [], context="table_cell")
+ if text.strip():
+ parts.append(text)
+ elif kind == "list":
+ flattened = self._render_list(block.list).replace("\n", " ").strip()
+ if flattened:
+ parts.append(flattened)
+ elif kind == "table":
+ for row in block.table.grid:
+ values = [
+ self._render_cell(slot.cell) if slot.kind == "origin" else ""
+ for slot in row
+ ]
+ if any(values):
+ parts.append(" / ".join(values))
+ elif kind == "block_quote":
+ text = self._render_blocks(block.blocks or []).replace("\n", " ").strip()
+ if text:
+ parts.append(text)
+ elif kind == "code_block":
+ text = (block.text or "").strip()
+ if text:
+ fence = self._backtick_fence(text, 1)
+ parts.append(f"{fence}{text}{fence}")
+ elif kind != "rule":
+ raise RuntimeError(f"Unsupported AnyDoc table-cell block kind: {kind}")
+ return "
".join(line.strip() for line in "
".join(parts).splitlines() if line.strip())
+
+ @staticmethod
+ def _format_table_row(cells: Iterable[str]) -> str:
+ return "|" + "".join(f" {cell} |" for cell in cells)
+
+ @staticmethod
+ def _table_is_single_cell(table: Any) -> bool:
+ return (
+ len(table.grid) == 1 and len(table.grid[0]) == 1 and table.grid[0][0].kind == "origin"
+ )
+
+ def _number_notes(self) -> dict[str, int]:
+ notes = {note.id: note for note in self.document.notes if note.blocks}
+ order: list[str] = []
+ seen: set[str] = set()
+
+ def visit_inlines(inlines: Iterable[Any]) -> None:
+ for inline in inlines:
+ if inline.kind == "note_ref" and inline.note_id in notes:
+ if inline.note_id not in seen:
+ seen.add(inline.note_id)
+ order.append(inline.note_id)
+ visit_blocks(notes[inline.note_id].blocks)
+ elif inline.kind == "link":
+ visit_inlines(inline.content or [])
+
+ def visit_blocks(blocks: Iterable[Any]) -> None:
+ for block in blocks:
+ if block.kind in {"heading", "paragraph"}:
+ visit_inlines(block.content or [])
+ elif block.kind == "list":
+ for item in block.list.items:
+ visit_blocks(item.blocks)
+ elif block.kind == "table":
+ for row in block.table.grid:
+ for slot in row:
+ if slot.kind == "origin":
+ visit_blocks(slot.cell.blocks)
+ elif block.kind == "block_quote":
+ visit_blocks(block.blocks or [])
+
+ visit_blocks(self.document.blocks)
+ for note in self.document.notes:
+ if note.id in notes and note.id not in seen:
+ seen.add(note.id)
+ order.append(note.id)
+ return {note_id: index for index, note_id in enumerate(order, 1)}
+
+ def _resolve_anchors(self) -> dict[str, _ResolvedAnchor]:
+ linked: set[str] = set()
+ headings: list[Any] = []
+ anchors: list[str] = []
+
+ def visit_inlines(inlines: Iterable[Any]) -> None:
+ for inline in inlines:
+ if inline.kind == "link":
+ if inline.target.kind == "anchor":
+ linked.add(inline.target.value)
+ visit_inlines(inline.content or [])
+ elif inline.kind == "anchor" and inline.anchor:
+ anchors.append(inline.anchor)
+
+ def visit_blocks(blocks: Iterable[Any]) -> None:
+ for block in blocks:
+ if block.kind == "heading":
+ headings.append(block)
+ visit_inlines(block.content or [])
+ elif block.kind == "paragraph":
+ visit_inlines(block.content or [])
+ elif block.kind == "list":
+ for item in block.list.items:
+ visit_blocks(item.blocks)
+ elif block.kind == "table":
+ for row in block.table.grid:
+ for slot in row:
+ if slot.kind == "origin":
+ visit_blocks(slot.cell.blocks)
+ elif block.kind == "block_quote":
+ visit_blocks(block.blocks or [])
+
+ visit_blocks(self.document.blocks)
+ for note in self.document.notes:
+ visit_blocks(note.blocks)
+
+ resolved: dict[str, _ResolvedAnchor] = {}
+ used: set[str] = set()
+ for heading in headings:
+ plain = self._plain_text(heading.content or [])
+ slug = self._claim_anchor(self._gfm_slug(plain), used)
+ heading_ids = [heading.anchor] if heading.anchor else []
+ heading_ids.extend(self._inline_anchor_ids(heading.content or []))
+ for anchor_id in heading_ids:
+ resolved.setdefault(anchor_id, _ResolvedAnchor(slug, False))
+ for anchor_id in anchors:
+ if anchor_id in linked and anchor_id not in resolved:
+ fragment = self._claim_anchor(self._sanitize_anchor(anchor_id), used)
+ resolved[anchor_id] = _ResolvedAnchor(fragment, True)
+ return resolved
+
+ def _plain_text(self, inlines: Iterable[Any]) -> str:
+ parts: list[str] = []
+ for inline in inlines:
+ if inline.kind == "text":
+ parts.append(inline.text or "")
+ elif inline.kind == "link":
+ parts.append(self._plain_text(inline.content or []))
+ elif inline.kind == "image":
+ parts.append(inline.alt or "")
+ elif inline.kind == "line_break":
+ parts.append(" ")
+ return "".join(parts)
+
+ def _inline_anchor_ids(self, inlines: Iterable[Any]) -> list[str]:
+ ids: list[str] = []
+ for inline in inlines:
+ if inline.kind == "anchor" and inline.anchor:
+ ids.append(inline.anchor)
+ elif inline.kind == "link":
+ ids.extend(self._inline_anchor_ids(inline.content or []))
+ return ids
+
+ @staticmethod
+ def _claim_anchor(base: str, used: set[str]) -> str:
+ if base not in used:
+ used.add(base)
+ return base
+ suffix = 1
+ while f"{base}-{suffix}" in used:
+ suffix += 1
+ claimed = f"{base}-{suffix}"
+ used.add(claimed)
+ return claimed
+
+ @staticmethod
+ def _gfm_slug(text: str) -> str:
+ slug = "".join(
+ "-" if char == " " else char.lower()
+ for char in text.strip()
+ if char == " " or char == "-" or char == "_" or char.isalnum()
+ )
+ return slug or "section"
+
+ @staticmethod
+ def _sanitize_anchor(anchor: str) -> str:
+ sanitized = re.sub(r"[^a-z0-9_-]+", "-", anchor.lower()).strip("-")
+ return sanitized or "anchor"
+
+ @staticmethod
+ def _marker_label(marker: str, number: int) -> str:
+ if marker in {"lower_alpha", "upper_alpha"}:
+ alpha = ""
+ remaining = max(number, 1)
+ while remaining:
+ remaining, offset = divmod(remaining - 1, 26)
+ alpha = chr(ord("a") + offset) + alpha
+ return f"{alpha.upper() if marker == 'upper_alpha' else alpha}."
+ if marker in {"lower_roman", "upper_roman"}:
+ remaining = max(number, 1)
+ result = ""
+ for unit, symbol in (
+ (1000, "M"),
+ (900, "CM"),
+ (500, "D"),
+ (400, "CD"),
+ (100, "C"),
+ (90, "XC"),
+ (50, "L"),
+ (40, "XL"),
+ (10, "X"),
+ (9, "IX"),
+ (5, "V"),
+ (4, "IV"),
+ (1, "I"),
+ ):
+ while remaining >= unit:
+ result += symbol
+ remaining -= unit
+ return f"{result.lower() if marker == 'lower_roman' else result}."
+ raise RuntimeError(f"Unsupported AnyDoc list marker: {marker}")
+
+ @staticmethod
+ def _format_url(url: str) -> str:
+ escaped = "".join(
+ "%7C" if char == "|" else "%3C" if char == "<" else "%3E" if char == ">" else char
+ for char in url
+ if ord(char) >= 32 and ord(char) != 127
+ )
+ return (
+ f"<{escaped}>" if any(char.isspace() or char in "()" for char in escaped) else escaped
+ )
+
+ @staticmethod
+ def _backtick_fence(text: str, minimum: int) -> str:
+ longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
+ return "`" * max(longest + 1, minimum)
+
+ @staticmethod
+ def _escape_text(
+ text: str,
+ *,
+ context: str,
+ styled: bool = False,
+ trailing_active: bool = False,
+ in_label: bool = False,
+ at_line_start: bool = False,
+ ) -> str:
+ characters = list(text)
+ output: list[str] = []
+ line_has_content = not at_line_start
+ for index, char in enumerate(characters):
+ if char == "\n":
+ output.append(char)
+ if context == "block":
+ line_has_content = False
+ continue
+ start_of_line = not line_has_content
+ if not char.isspace():
+ line_has_content = True
+ next_char = characters[index + 1] if index + 1 < len(characters) else None
+ next_nonspace = trailing_active if next_char is None else not next_char.isspace()
+ later = characters[index + 1 :]
+ escape = False
+ if char == "\\":
+ escape = True
+ elif char == "]" and in_label:
+ escape = True
+ elif char == "`":
+ escape = styled or "`" in later
+ elif char == "*":
+ escape = styled or start_of_line or (next_nonspace and "*" in later)
+ elif char == "_":
+ previous_alnum = index > 0 and characters[index - 1].isalnum()
+ next_alnum = bool(next_char and next_char.isalnum())
+ escape = styled or (
+ next_nonspace and not (previous_alnum and next_alnum) and "_" in later
+ )
+ elif char == "~":
+ escape = styled or (next_nonspace and "~" in later)
+ elif char == "[":
+ escape = in_label or "]" in later
+ elif char == "<":
+ escape = bool(next_char and (next_char.isalpha() or next_char in "/!?"))
+ elif char == "!":
+ escape = next_char is None and trailing_active
+ elif char == "|" and context == "table_cell":
+ escape = True
+ elif char in "#>" and start_of_line:
+ escape = True
+ elif char in "-+" and start_of_line:
+ escape = next_char is None or next_char.isspace()
+ if escape:
+ output.append("\\")
+ output.append(char)
+ return "".join(output)
+
+
+class AnyDocParser(BaseParser):
+ """Parse the existing Office/EPUB formats through AnyDoc's document model."""
+
+ _SUPPORTED_EXTENSIONS = [".doc", ".docx", ".pptx", ".xls", ".xlsx", ".xlsm", ".epub"]
+
+ def __init__(self, config: Optional[ParserConfig] = None):
+ from openviking.parse.parsers.markdown import MarkdownParser
+
+ self.config = config or ParserConfig()
+ self._markdown_parser = MarkdownParser(config=self.config)
+
+ @property
+ def supported_extensions(self) -> List[str]:
+ return list(self._SUPPORTED_EXTENSIONS)
+
+ async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
+ started = time.time()
+ path = Path(source)
+ if not path.is_file():
+ raise FileNotFoundError(f"Document file not found: {path}")
+
+ from openviking_cli.utils.storage import get_storage
+
+ storage = get_storage()
+ resource_name = kwargs.get("resource_name") or kwargs.get("source_name") or path.stem
+ rendered = await asyncio.to_thread(
+ self._convert,
+ path,
+ resource_name=resource_name,
+ storage=storage,
+ )
+
+ markdown_kwargs = dict(kwargs)
+ caller_media_dirs = list(markdown_kwargs.pop("allowed_media_dirs", None) or [])
+ caller_media_dirs.append(storage.media_dir)
+ result = await self._markdown_parser.parse_content(
+ rendered.markdown,
+ source_path=str(path),
+ instruction=instruction,
+ base_dir=path.parent,
+ allowed_media_dirs=caller_media_dirs,
+ **markdown_kwargs,
+ )
+ result.source_format = path.suffix.lower().lstrip(".")
+ result.parser_name = "AnyDocParser"
+ result.parser_version = "1.0"
+ result.parse_time = time.time() - started
+ result.warnings.extend(rendered.warnings)
+ result.meta.update(
+ {
+ "library": "firecrawl-anydoc",
+ "library_version": version("firecrawl-anydoc"),
+ "detected_format": rendered.detected_format,
+ "assets_referenced": rendered.assets_referenced,
+ "images_extracted": rendered.images_extracted,
+ "intermediate_markdown_length": len(rendered.markdown),
+ }
+ )
+ return result
+
+ def _convert(self, path: Path, *, resource_name: str, storage: Any) -> _RenderedDocument:
+ import anydoc
+
+ data = path.read_bytes()
+ detected_format = anydoc.format_from_bytes(data)
+ selected_format = detected_format or anydoc.format_from_extension(path.suffix)
+ if selected_format is None:
+ raise anydoc.UnsupportedError(f"Unrecognized document format: {path.name}")
+ document = anydoc.to_document(data, selected_format)
+ renderer = _AnyDocMarkdownRenderer(
+ document,
+ source_format=path.suffix.lower().lstrip("."),
+ resource_name=resource_name,
+ storage=storage,
+ )
+ markdown = renderer.render()
+ if not markdown.strip():
+ raise anydoc.MalformedError(f"No meaningful content extracted from {path.name}")
+ return _RenderedDocument(
+ markdown=markdown,
+ detected_format=detected_format or selected_format,
+ warnings=renderer.warnings,
+ assets_referenced=len(renderer.assets_referenced),
+ images_extracted=renderer.images_extracted,
+ )
+
+ async def parse_content(
+ self,
+ content: str,
+ source_path: Optional[str] = None,
+ instruction: str = "",
+ **kwargs,
+ ) -> ParseResult:
+ raise NotImplementedError(
+ "AnyDocParser requires a document file path; string content is not supported"
+ )
diff --git a/openviking/parse/parsers/epub.py b/openviking/parse/parsers/epub.py
deleted file mode 100644
index 6ecc76bbc0..0000000000
--- a/openviking/parse/parsers/epub.py
+++ /dev/null
@@ -1,215 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""
-EPub (.epub) parser for OpenViking.
-
-Converts EPub e-books to Markdown then parses using MarkdownParser.
-Inspired by microsoft/markitdown approach.
-"""
-
-import asyncio
-import html
-import re
-import zipfile
-from html.parser import HTMLParser
-from pathlib import Path
-from typing import List, Optional, Union
-
-from openviking.parse.base import ParseResult
-from openviking.parse.parsers.base_parser import BaseParser
-from openviking_cli.utils.config.parser_config import ParserConfig
-from openviking_cli.utils.logger import get_logger
-
-logger = get_logger(__name__)
-
-
-class _EPubMarkdownParser(HTMLParser):
- """Convert HTML fragments to simple markdown without regex tag stripping."""
-
- _HEADER_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
-
- def __init__(self):
- super().__init__(convert_charrefs=True)
- self._parts: list[str] = []
- self._ignored_tag_stack = 0
-
- def handle_starttag(self, tag: str, attrs):
- normalized_tag = tag.lower()
- if normalized_tag in {"script", "style"}:
- self._ignored_tag_stack += 1
- return
- if self._ignored_tag_stack:
- return
- if normalized_tag in self._HEADER_PREFIX:
- self._ensure_block_break()
- self._parts.append(self._HEADER_PREFIX[normalized_tag])
- elif normalized_tag in {"strong", "b"}:
- self._parts.append("**")
- elif normalized_tag in {"em", "i"}:
- self._parts.append("*")
- elif normalized_tag == "li":
- self._parts.append("\n- ")
- elif normalized_tag == "br":
- self._parts.append("\n")
- elif normalized_tag in {"p", "div", "section", "article"}:
- self._ensure_block_break()
-
- def handle_endtag(self, tag: str):
- normalized_tag = tag.lower()
- if normalized_tag in {"script", "style"}:
- if self._ignored_tag_stack:
- self._ignored_tag_stack -= 1
- return
- if self._ignored_tag_stack:
- return
- if normalized_tag in self._HEADER_PREFIX or normalized_tag in {
- "p",
- "div",
- "section",
- "article",
- }:
- self._parts.append("\n\n")
- elif normalized_tag in {"strong", "b"}:
- self._parts.append("**")
- elif normalized_tag in {"em", "i"}:
- self._parts.append("*")
-
- def handle_data(self, data: str):
- if not self._ignored_tag_stack:
- self._parts.append(data)
-
- def get_markdown(self) -> str:
- return "".join(self._parts)
-
- def _ensure_block_break(self):
- if self._parts and not self._parts[-1].endswith("\n"):
- self._parts.append("\n\n")
-
-
-class EPubParser(BaseParser):
- """
- EPub e-book parser for OpenViking.
-
- Supports: .epub
-
- Converts EPub e-books to Markdown using ebooklib (if available)
- or falls back to manual extraction, then delegates to MarkdownParser.
- """
-
- def __init__(self, config: Optional[ParserConfig] = None):
- """Initialize EPub parser."""
- from openviking.parse.parsers.markdown import MarkdownParser
-
- self._md_parser = MarkdownParser(config=config)
- self.config = config or ParserConfig()
-
- @property
- def supported_extensions(self) -> List[str]:
- return [".epub"]
-
- async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """Parse EPub e-book from file path."""
- path = Path(source)
-
- if path.exists():
- markdown_content = await asyncio.to_thread(self._convert_to_markdown, path)
- result = await self._md_parser.parse_content(
- markdown_content, source_path=str(path), instruction=instruction, **kwargs
- )
- else:
- result = await self._md_parser.parse_content(
- str(source), instruction=instruction, **kwargs
- )
- result.source_format = "epub"
- result.parser_name = "EPubParser"
- return result
-
- async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
- ) -> ParseResult:
- """Parse content - delegates to MarkdownParser."""
- result = await self._md_parser.parse_content(content, source_path, **kwargs)
- result.source_format = "epub"
- result.parser_name = "EPubParser"
- return result
-
- def _convert_to_markdown(self, path: Path) -> str:
- """Convert EPub e-book to Markdown string."""
- # Try using ebooklib first
- try:
- import ebooklib
- from ebooklib import epub
-
- return self._convert_with_ebooklib(path, ebooklib, epub)
- except ImportError:
- pass
-
- # Fall back to manual extraction
- return self._convert_manual(path)
-
- def _convert_with_ebooklib(self, path: Path, ebooklib, epub) -> str:
- """Convert EPub using ebooklib."""
- book = epub.read_epub(path)
- markdown_parts = []
-
- title = self._get_metadata(book, "title")
- author = self._get_metadata(book, "creator")
-
- if title:
- markdown_parts.append(f"# {title}")
- if author:
- markdown_parts.append(f"**Author:** {author}")
-
- for item in book.get_items():
- if item.get_type() == ebooklib.ITEM_DOCUMENT:
- content = item.get_content().decode("utf-8", errors="ignore")
- md_content = self._html_to_markdown(content)
- if md_content.strip():
- markdown_parts.append(md_content)
-
- return "\n\n".join(markdown_parts)
-
- def _get_metadata(self, book, key: str) -> str:
- """Get metadata from EPub book."""
- try:
- metadata = book.get_metadata("DC", key)
- if metadata:
- return metadata[0][0]
- except Exception:
- pass
- return ""
-
- def _convert_manual(self, path: Path) -> str:
- """Convert EPub manually using zipfile and HTML parsing."""
- markdown_parts = []
-
- with zipfile.ZipFile(path, "r") as zf:
- html_files = [f for f in zf.namelist() if f.endswith((".html", ".xhtml", ".htm"))]
-
- for html_file in sorted(html_files):
- try:
- content = zf.read(html_file).decode("utf-8", errors="ignore")
- md_content = self._html_to_markdown(content)
- if md_content.strip():
- markdown_parts.append(md_content)
- except Exception as e:
- logger.warning(f"Failed to process {html_file}: {e}")
-
- return (
- "\n\n".join(markdown_parts)
- if markdown_parts
- else "# EPub Content\n\nUnable to extract content."
- )
-
- def _html_to_markdown(self, html_content: str) -> str:
- """Simple HTML to markdown conversion."""
- parser = _EPubMarkdownParser()
- parser.feed(html_content)
- parser.close()
- markdown = html.unescape(parser.get_markdown())
-
- # Normalize whitespace
- markdown = re.sub(r"\n\s*\n", "\n\n", markdown)
- markdown = re.sub(r"[ \t]+", " ", markdown)
-
- return markdown.strip()
diff --git a/openviking/parse/parsers/excel.py b/openviking/parse/parsers/excel.py
deleted file mode 100644
index d8c610c77a..0000000000
--- a/openviking/parse/parsers/excel.py
+++ /dev/null
@@ -1,440 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""
-Excel (.xlsx/.xls/.xlsm) parser for OpenViking.
-
-Converts Excel spreadsheets to Markdown then parses using MarkdownParser.
-Inspired by microsoft/markitdown approach.
-"""
-
-import asyncio
-import concurrent.futures
-import time
-from dataclasses import asdict, fields
-from functools import partial
-from multiprocessing import get_context
-from pathlib import Path
-from typing import Any, Dict, List, Optional, Union
-
-from openviking.parse.base import NodeType, ParseResult, ResourceNode, create_parse_result
-from openviking.parse.parsers.base_parser import BaseParser
-from openviking_cli.utils.config.parser_config import ExcelConfig, ParserConfig
-from openviking_cli.utils.logger import get_logger
-
-logger = get_logger(__name__)
-
-_EXCEL_LAYOUT_EXECUTOR: Optional[concurrent.futures.ProcessPoolExecutor] = None
-_EXCEL_LAYOUT_EXECUTOR_WORKERS: Optional[int] = None
-
-# Non-configurable process-pool internals.
-_EXCEL_PROCESS_POOL_START_METHOD = "spawn"
-_EXCEL_PROCESS_POOL_MIN_BYTES = 200_000
-_EXCEL_PROCESS_POOL_TIMEOUT_S = 120.0
-
-
-def _get_excel_layout_executor(workers: int) -> concurrent.futures.ProcessPoolExecutor:
- global _EXCEL_LAYOUT_EXECUTOR, _EXCEL_LAYOUT_EXECUTOR_WORKERS
- workers = max(1, int(workers))
- if _EXCEL_LAYOUT_EXECUTOR is None:
- _EXCEL_LAYOUT_EXECUTOR = concurrent.futures.ProcessPoolExecutor(
- max_workers=workers,
- mp_context=get_context(_EXCEL_PROCESS_POOL_START_METHOD),
- )
- _EXCEL_LAYOUT_EXECUTOR_WORKERS = workers
- logger.info(
- f"[ExcelParserProcessPool] Started process pool "
- f"workers={workers} start_method={_EXCEL_PROCESS_POOL_START_METHOD}"
- )
- elif _EXCEL_LAYOUT_EXECUTOR_WORKERS != workers:
- logger.warning(
- "[ExcelParserProcessPool] Ignoring process_pool_workers=%s; "
- "pool already started with workers=%s",
- workers,
- _EXCEL_LAYOUT_EXECUTOR_WORKERS,
- )
- return _EXCEL_LAYOUT_EXECUTOR
-
-
-def _build_excel_layout_in_process(
- *,
- path_str: str,
- temp_uri: str,
- instruction: str,
- layout_kwargs: Dict[str, Any],
- config_dict: Dict[str, Any],
- max_rows_per_sheet: int,
-) -> Dict[str, Any]:
- """CPU-only child process worker.
-
- The worker must not touch VikingFS, DB, queues, or RequestContext. It only
- converts Excel to markdown and computes MarkdownParser layout ops.
- """
- import asyncio
-
- from openviking.parse.parsers.excel import ExcelParser
- from openviking_cli.utils.config.parser_config import ParserConfig
-
- started = time.perf_counter()
- path = Path(path_str)
- allowed_config_fields = {field.name for field in fields(ParserConfig)}
- filtered_config = {
- key: value for key, value in config_dict.items() if key in allowed_config_fields
- }
- parser = ExcelParser(
- config=ParserConfig.from_dict(filtered_config),
- max_rows_per_sheet=max_rows_per_sheet,
- )
-
- convert_started = time.perf_counter()
- if path.suffix.lower() == ".xls":
- markdown_content = parser._convert_xls_to_markdown(path)
- else:
- import openpyxl
-
- markdown_content = parser._convert_to_markdown(path, openpyxl)
- convert_s = time.perf_counter() - convert_started
-
- layout_started = time.perf_counter()
- layout = asyncio.run(
- parser._md_parser._compute_layout(
- markdown_content,
- temp_uri,
- source_path=str(path),
- instruction=instruction,
- **layout_kwargs,
- )
- )
- layout_s = time.perf_counter() - layout_started
-
- return {
- "layout": layout,
- "convert_s": convert_s,
- "layout_s": layout_s,
- "total_s": time.perf_counter() - started,
- "markdown_chars": len(markdown_content),
- "layout_ops": len(layout.ops),
- "layout_write_ops": sum(1 for op in layout.ops if op.kind == "write"),
- "layout_mkdir_ops": sum(1 for op in layout.ops if op.kind == "mkdir"),
- }
-
-
-class ExcelParser(BaseParser):
- """
- Excel spreadsheet parser for OpenViking.
-
- Supports: .xlsx, .xls, .xlsm
-
- Converts Excel spreadsheets to Markdown using openpyxl,
- then delegates to MarkdownParser for tree structure creation.
- """
-
- def __init__(
- self, config: Optional[ParserConfig] = None, max_rows_per_sheet: int = 1000
- ):
- """
- Initialize Excel parser.
-
- Args:
- config: Parser configuration (prefer ExcelConfig for process-pool knobs)
- max_rows_per_sheet: Maximum rows to process per sheet (0 = unlimited)
- """
- from openviking.parse.parsers.markdown import MarkdownParser
-
- self._md_parser = MarkdownParser(config=config)
- self.config = config or ExcelConfig()
- self.max_rows_per_sheet = max_rows_per_sheet
-
- def _process_pool_enabled(self) -> bool:
- return bool(getattr(self.config, "enable_process_pool", False))
-
- def _process_pool_workers(self) -> int:
- return max(1, int(getattr(self.config, "process_pool_workers", 2) or 2))
-
- @property
- def supported_extensions(self) -> List[str]:
- return [".xlsx", ".xls", ".xlsm"]
-
- async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """Parse Excel spreadsheet from file path."""
- path = Path(source)
-
- if path.exists():
- result = await self._parse_existing_path(path, instruction=instruction, **kwargs)
- else:
- result = await self._md_parser.parse_content(
- str(source), instruction=instruction, **kwargs
- )
- result.source_format = path.suffix.lstrip(".") if path.exists() else "xlsx"
- result.parser_name = "ExcelParser"
- return result
-
- async def _parse_existing_path(
- self, path: Path, instruction: str = "", **kwargs
- ) -> ParseResult:
- if self._should_use_process_pool(path, kwargs):
- try:
- return await self._parse_existing_path_process_pool(
- path, instruction=instruction, **kwargs
- )
- except Exception as exc:
- logger.warning(
- f"[ExcelParserProcessPool] Falling back to in-process parse: {exc}",
- exc_info=True,
- )
-
- # Use xlrd for legacy .xls, openpyxl for .xlsx/.xlsm
- if path.suffix.lower() == ".xls":
- markdown_content = await asyncio.to_thread(self._convert_xls_to_markdown, path)
- else:
- import openpyxl
-
- markdown_content = await asyncio.to_thread(self._convert_to_markdown, path, openpyxl)
- return await self._md_parser.parse_content(
- markdown_content, source_path=str(path), instruction=instruction, **kwargs
- )
-
- def _should_use_process_pool(self, path: Path, kwargs: Dict[str, Any]) -> bool:
- if not self._process_pool_enabled():
- return False
- if path.suffix.lower() == ".xls":
- return False
- if kwargs.get("enable_link_rewrite") or kwargs.get("base_dir") or kwargs.get("allowed_media_dirs"):
- logger.debug("[ExcelParserProcessPool] Disabled for link/media rewrite parse")
- return False
- try:
- return path.stat().st_size >= _EXCEL_PROCESS_POOL_MIN_BYTES
- except OSError:
- return False
-
- async def _parse_existing_path_process_pool(
- self, path: Path, instruction: str = "", **kwargs
- ) -> ParseResult:
- parse_started = time.perf_counter()
- loop = asyncio.get_running_loop()
- temp_uri = self._md_parser._create_temp_uri()
- layout_kwargs: Dict[str, Any] = {
- key: value
- for key, value in kwargs.items()
- if key in {"resource_name", "source_name"} and isinstance(value, str)
- }
- if isinstance(kwargs.get("split_content"), bool):
- layout_kwargs["split_content"] = kwargs["split_content"]
- future = loop.run_in_executor(
- _get_excel_layout_executor(self._process_pool_workers()),
- partial(
- _build_excel_layout_in_process,
- path_str=str(path),
- temp_uri=temp_uri,
- instruction=instruction,
- layout_kwargs=layout_kwargs,
- config_dict=asdict(self.config),
- max_rows_per_sheet=self.max_rows_per_sheet,
- ),
- )
-
- worker_started = time.perf_counter()
- worker_result = await asyncio.wait_for(
- future, timeout=_EXCEL_PROCESS_POOL_TIMEOUT_S
- )
- worker_s = time.perf_counter() - worker_started
- layout = worker_result["layout"]
-
- self._md_parser._rewrite_ctx = {
- "enabled": False,
- "source_path": str(path),
- "doc_name": layout.doc_name,
- "root_dir": layout.root_dir,
- "import_root": None,
- "base_dir": None,
- "allowed_media_dirs": None,
- }
- try:
- apply_started = time.perf_counter()
- await self._md_parser._apply_layout(layout)
- apply_s = time.perf_counter() - apply_started
- finally:
- self._md_parser._rewrite_ctx = None
-
- parse_time = time.perf_counter() - parse_started
- logger.info(
- f"[ExcelParserProcessPool] path={path} total={parse_time:.3f}s "
- f"worker_wall={worker_s:.3f}s convert={worker_result.get('convert_s', -1.0):.3f}s "
- f"layout={worker_result.get('layout_s', -1.0):.3f}s apply={apply_s:.3f}s "
- f"chars={worker_result.get('markdown_chars')} ops={worker_result.get('layout_ops')} "
- f"writes={worker_result.get('layout_write_ops')} mkdirs={worker_result.get('layout_mkdir_ops')}"
- )
-
- root = ResourceNode(
- type=NodeType.ROOT,
- title=layout.doc_title,
- level=0,
- meta=layout.meta.get("frontmatter", {}),
- )
- result = create_parse_result(
- root=root,
- source_path=str(path),
- source_format="markdown",
- parser_name="MarkdownParser",
- parse_time=parse_time,
- meta=layout.meta,
- warnings=layout.warnings,
- )
- result.temp_dir_path = layout.temp_uri
- return result
-
- async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
- ) -> ParseResult:
- """Parse content - delegates to MarkdownParser."""
- result = await self._md_parser.parse_content(content, source_path, **kwargs)
- result.source_format = "xlsx"
- result.parser_name = "ExcelParser"
- return result
-
- def _convert_xls_to_markdown(self, path: Path) -> str:
- """Convert legacy .xls spreadsheet to Markdown using xlrd."""
- import xlrd
-
- # formatting_info=True enables xlrd to detect date cells via XL_CELL_DATE
- # instead of reporting them as XL_CELL_NUMBER with raw float serials
- wb = xlrd.open_workbook(str(path), formatting_info=True, on_demand=True)
- try:
- return self._build_xls_markdown(wb, path, xlrd)
- finally:
- wb.release_resources()
-
- def _build_xls_markdown(self, wb, path: Path, xlrd) -> str:
- """Build markdown from xlrd workbook."""
- markdown_parts = []
- markdown_parts.append(f"# {path.stem}")
- markdown_parts.append(f"**Sheets:** {wb.nsheets}")
-
- for sheet_idx in range(wb.nsheets):
- sheet = wb.sheet_by_index(sheet_idx)
- parts = [f"## Sheet: {sheet.name}"]
-
- if sheet.nrows == 0 or sheet.ncols == 0:
- parts.append("*Empty sheet*")
- markdown_parts.append("\n\n".join(parts))
- continue
-
- parts.append(f"**Dimensions:** {sheet.nrows} rows × {sheet.ncols} columns")
-
- rows_to_process = sheet.nrows
- if self.max_rows_per_sheet > 0:
- rows_to_process = min(sheet.nrows, self.max_rows_per_sheet)
-
- rows = []
- for row_idx in range(rows_to_process):
- row_data = []
- for col_idx in range(sheet.ncols):
- row_data.append(self._format_xls_cell(sheet.cell(row_idx, col_idx), wb, xlrd))
- rows.append(row_data)
-
- if rows:
- from openviking.parse.base import format_table_to_markdown
-
- parts.append(format_table_to_markdown(rows, has_header=True))
-
- if self.max_rows_per_sheet > 0 and sheet.nrows > self.max_rows_per_sheet:
- parts.append(
- f"\n*... {sheet.nrows - self.max_rows_per_sheet} more rows truncated ...*"
- )
-
- markdown_parts.append("\n\n".join(parts))
-
- return "\n\n".join(markdown_parts)
-
- @staticmethod
- def _format_xls_cell(cell, wb, xlrd) -> str:
- """Format a single xlrd cell value with proper type handling."""
- if cell.ctype == xlrd.XL_CELL_EMPTY or cell.ctype == xlrd.XL_CELL_BLANK:
- return ""
- if cell.ctype == xlrd.XL_CELL_DATE:
- try:
- dt = xlrd.xldate_as_tuple(cell.value, wb.datemode)
- # Include time component if non-zero
- if dt[3] or dt[4] or dt[5]:
- return (
- f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[3]:02d}:{dt[4]:02d}:{dt[5]:02d}"
- )
- return f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d}"
- except Exception:
- return str(cell.value)
- if cell.ctype == xlrd.XL_CELL_BOOLEAN:
- return "TRUE" if cell.value else "FALSE"
- if cell.ctype == xlrd.XL_CELL_ERROR:
- # xlrd error code map
- error_map = {
- 0x00: "#NULL!",
- 0x07: "#DIV/0!",
- 0x0F: "#VALUE!",
- 0x17: "#REF!",
- 0x1D: "#NAME?",
- 0x24: "#NUM!",
- 0x2A: "#N/A",
- }
- return error_map.get(cell.value, f"#ERR({cell.value})")
- if cell.ctype == xlrd.XL_CELL_NUMBER:
- # Display integers without trailing .0
- if cell.value == int(cell.value):
- return str(int(cell.value))
- return str(cell.value)
- # XL_CELL_TEXT or fallback
- return str(cell.value) if cell.value is not None else ""
-
- def _convert_to_markdown(self, path: Path, openpyxl) -> str:
- """Convert Excel spreadsheet to Markdown string."""
- wb = openpyxl.load_workbook(path, data_only=True)
-
- markdown_parts = []
- markdown_parts.append(f"# {path.stem}")
- markdown_parts.append(f"**Sheets:** {len(wb.sheetnames)}")
-
- for sheet_name in wb.sheetnames:
- sheet = wb[sheet_name]
- sheet_content = self._convert_sheet(sheet, sheet_name)
- markdown_parts.append(sheet_content)
-
- return "\n\n".join(markdown_parts)
-
- def _convert_sheet(self, sheet, sheet_name: str) -> str:
- """Convert a single sheet to markdown."""
- parts = []
- parts.append(f"## Sheet: {sheet_name}")
-
- max_row = sheet.max_row
- max_col = sheet.max_column
-
- if max_row == 0 or max_col == 0:
- parts.append("*Empty sheet*")
- return "\n\n".join(parts)
-
- parts.append(f"**Dimensions:** {max_row} rows × {max_col} columns")
-
- rows_to_process = max_row
- if self.max_rows_per_sheet > 0:
- rows_to_process = min(max_row, self.max_rows_per_sheet)
-
- rows = []
- for _row_idx, row in enumerate(
- sheet.iter_rows(min_row=1, max_row=rows_to_process, values_only=True), 1
- ):
- row_data = []
- for cell in row:
- if cell is None:
- row_data.append("")
- else:
- row_data.append(str(cell))
- rows.append(row_data)
-
- if rows:
- from openviking.parse.base import format_table_to_markdown
-
- table_md = format_table_to_markdown(rows, has_header=True)
- parts.append(table_md)
-
- if self.max_rows_per_sheet > 0 and max_row > self.max_rows_per_sheet:
- parts.append(f"\n*... {max_row - self.max_rows_per_sheet} more rows truncated ...*")
-
- return "\n\n".join(parts)
diff --git a/openviking/parse/parsers/legacy_doc.py b/openviking/parse/parsers/legacy_doc.py
deleted file mode 100644
index 11b48a5194..0000000000
--- a/openviking/parse/parsers/legacy_doc.py
+++ /dev/null
@@ -1,408 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""
-Legacy Word document (.doc) parser for OpenViking.
-
-Extracts text from OLE2 compound binary .doc files using olefile,
-then delegates to MarkdownParser for tree structure creation.
-"""
-
-import asyncio
-import struct
-import zipfile
-from pathlib import Path
-from typing import List, Optional, Union
-
-from openviking.parse.base import ParseResult
-from openviking.parse.parsers.base_parser import BaseParser
-from openviking_cli.utils.config.parser_config import ParserConfig
-from openviking_cli.utils.logger import get_logger
-
-logger = get_logger(__name__)
-
-
-# Max stream size to read (50MB) — prevents DoS from crafted files
-_MAX_STREAM_SIZE = 50 * 1024 * 1024
-# Max character count sanity cap for ccpText
-_MAX_CCP_TEXT = 10_000_000
-_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
-
-
-class LegacyDocParser(BaseParser):
- """
- Legacy .doc (OLE2 binary) parser.
-
- Extracts text content from Word 97-2003 (.doc) files using olefile
- to read the WordDocument and table streams, then delegates to
- MarkdownParser for tree structure.
- """
-
- def __init__(self, config: Optional[ParserConfig] = None):
- from openviking.parse.parsers.markdown import MarkdownParser
-
- self._md_parser = MarkdownParser(config=config)
- self.config = config or ParserConfig()
-
- @property
- def supported_extensions(self) -> List[str]:
- return [".doc"]
-
- async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """Parse legacy .doc file."""
- path = Path(source)
-
- if path.exists():
- if self._has_zip_signature(path):
- package_type = await asyncio.to_thread(self._classify_zip_package, path)
- if package_type == "docx":
- # python-docx writes OOXML regardless of the filename suffix. This
- # also occurs in real uploads whose extension says .doc while the
- # payload is a modern Word ZIP package. Never pass those bytes to
- # the legacy UTF-16 fallback: it turns ZIP data into plausible but
- # meaningless Unicode and silently persists corrupted Markdown.
- from openviking.parse.parsers.word import WordParser
-
- logger.info(
- "Detected OOXML Word content in %s; routing to WordParser",
- path.name,
- )
- return await WordParser(config=self.config).parse(
- path,
- instruction=instruction,
- **kwargs,
- )
-
- raise ValueError(f"{path.name} is a ZIP package, not a legacy Word .doc file")
-
- text = await asyncio.to_thread(self._extract_text, path)
- result = await self._md_parser.parse_content(
- text, source_path=str(path), instruction=instruction, **kwargs
- )
- else:
- result = await self._md_parser.parse_content(
- str(source), instruction=instruction, **kwargs
- )
- result.source_format = "doc"
- result.parser_name = "LegacyDocParser"
- return result
-
- @staticmethod
- def _has_zip_signature(path: Path) -> bool:
- """Return whether the payload starts with a recognized ZIP signature."""
- with path.open("rb") as file_obj:
- return file_obj.read(4) in _ZIP_SIGNATURES
-
- @staticmethod
- def _classify_zip_package(path: Path) -> str:
- """Identify a ZIP-backed Word package without extracting archive members."""
- try:
- with zipfile.ZipFile(path) as archive:
- try:
- archive.getinfo("[Content_Types].xml")
- archive.getinfo("word/document.xml")
- except KeyError:
- return "zip"
- return "docx"
- except (zipfile.BadZipFile, zipfile.LargeZipFile):
- # A payload with ZIP magic is still not a legacy OLE .doc. Classify
- # malformed archives as generic ZIP so callers fail closed instead of
- # feeding binary bytes into the permissive legacy text fallback.
- return "zip"
-
- async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
- ) -> ParseResult:
- """Parse content string — delegates to MarkdownParser."""
- result = await self._md_parser.parse_content(
- content, source_path, instruction=instruction, **kwargs
- )
- result.source_format = "doc"
- result.parser_name = "LegacyDocParser"
- return result
-
- def _extract_text(self, path: Path) -> str:
- """
- Extract text from a legacy .doc OLE2 file.
-
- Reads the WordDocument stream and uses the FIB (File Information Block)
- to locate text in the document body. Falls back to raw byte scanning
- if structured extraction fails.
- """
- import olefile
-
- try:
- ole = olefile.OleFileIO(str(path))
- except Exception as e:
- logger.warning(f"Failed to open .doc as OLE file: {e}")
- return self._fallback_extract(path)
-
- try:
- return self._extract_from_ole(ole)
- except Exception as e:
- logger.warning(f"Structured OLE extraction failed, using fallback: {e}")
- return self._fallback_extract(path)
- finally:
- ole.close()
-
- @staticmethod
- def _read_ole_stream(ole, stream_name: str) -> bytes:
- """Read an OLE stream with size cap to prevent DoS."""
- stream = ole.openstream(stream_name)
- data = stream.read(_MAX_STREAM_SIZE + 1)
- if len(data) > _MAX_STREAM_SIZE:
- raise ValueError(f"OLE stream '{stream_name}' exceeds {_MAX_STREAM_SIZE} bytes")
- return data
-
- def _extract_from_ole(self, ole) -> str:
- """
- Extract text from OLE streams using the Word Binary File Format.
-
- Reads the FIB to determine if text is stored as UTF-16 or compressed
- (CP1252), then extracts the document body text from the appropriate
- stream (WordDocument or table stream).
- """
- if not ole.exists("WordDocument"):
- raise ValueError("No WordDocument stream found")
-
- word_doc = self._read_ole_stream(ole, "WordDocument")
-
- # Minimum FIB size: need at least 0x01A8 bytes for Word 97+ FIB fields
- if len(word_doc) < 0x01A8:
- raise ValueError(f"WordDocument stream too small ({len(word_doc)} bytes)")
-
- # Check FIB version (nFib at offset 0x0002) — require Word 97+ (0x00C1+)
- nfib = struct.unpack_from(" len(table_data):
- return self._simple_text_extract(word_doc, ccp_text)
-
- return self._extract_via_clx(word_doc, table_data, fc_clx, lcb_clx, ccp_text)
-
- def _simple_text_extract(self, word_doc: bytes, ccp_text: int) -> str:
- """
- Simple text extraction using FIB text offset.
-
- The main document text starts at offset 0x0800 in the WordDocument stream
- for most Word 97+ files. Tries UTF-16LE first; falls back to CP1252 if
- the stream is too small for UTF-16.
- """
- text_start = 0x0800 # Standard text start offset
-
- if text_start >= len(word_doc):
- raise ValueError("WordDocument stream too small for text extraction")
-
- # Try UTF-16LE first (2 bytes per char)
- if ccp_text * 2 + text_start <= len(word_doc):
- end = text_start + ccp_text * 2
- raw = word_doc[text_start:end]
- text = raw.decode("utf-16-le", errors="replace")
- # Sanity: if mostly printable, it's likely correct
- if (
- sum(1 for c in text[:200] if c.isprintable() or c in "\n\r\t")
- > len(text[:200]) * 0.5
- ):
- return self._clean_word_text(text)
-
- # Fall back to CP1252 single-byte
- end = min(text_start + ccp_text, len(word_doc))
- raw = word_doc[text_start:end]
- return self._clean_word_text(self._decode_cp1252(raw))
-
- def _extract_via_clx(
- self,
- word_doc: bytes,
- table_data: bytes,
- fc_clx: int,
- lcb_clx: int,
- ccp_text: int,
- ) -> str:
- """
- Extract text using the Clx (piece table) structure.
-
- The Clx contains a PiecePLC that maps character positions to file offsets,
- allowing reconstruction of the document text even when pieces are scattered.
- """
- clx = table_data[fc_clx : fc_clx + lcb_clx]
- pos = 0
- text_parts = []
- chars_extracted = 0
-
- # Skip any Grpprl (type 0x01) entries in the Clx
- while pos < len(clx) and clx[pos] == 0x01:
- if pos + 3 > len(clx):
- break
- cb = struct.unpack_from("= len(clx) or clx[pos] != 0x02:
- return self._simple_text_extract(word_doc, ccp_text)
-
- pos += 1 # skip type byte
- if pos + 4 > len(clx):
- return self._simple_text_extract(word_doc, ccp_text)
-
- lcb_pcd = struct.unpack_from(" len(clx):
- return self._simple_text_extract(word_doc, ccp_text)
-
- # Calculate number of pieces: (lcb_pcd - 4) / (4 + 8) per piece,
- # but CPs are (n+1)*4 bytes + n*8 bytes = lcb_pcd
- # So: 4*(n+1) + 8*n = lcb_pcd → 12n + 4 = lcb_pcd → n = (lcb_pcd - 4) / 12
- n_pieces = (lcb_pcd - 4) // 12
- if n_pieces <= 0:
- return self._simple_text_extract(word_doc, ccp_text)
-
- # Read character positions (n+1 values)
- cps = []
- for i in range(n_pieces + 1):
- offset = pcd_start + i * 4
- if offset + 4 > len(clx):
- break
- cps.append(struct.unpack_from("= ccp_text:
- break
-
- pcd_offset = pcd_array_start + i * 8
- if pcd_offset + 8 > len(clx):
- break
-
- # PCD: 2 bytes flags, 4 bytes fc, 2 bytes prm
- fc_value = struct.unpack_from(" {len(word_doc)})"
- )
- else:
- # UTF-16LE
- byte_offset = fc_real
- byte_end = byte_offset + piece_char_count * 2
- if byte_end <= len(word_doc):
- raw = word_doc[byte_offset:byte_end]
- text_parts.append(raw.decode("utf-16-le", errors="replace"))
- else:
- logger.warning(
- f"Piece {i} extends beyond stream ({byte_end} > {len(word_doc)})"
- )
-
- chars_extracted += piece_char_count
-
- result = self._clean_word_text("".join(text_parts))
- if not result.strip():
- return self._simple_text_extract(word_doc, ccp_text)
- return result
-
- @staticmethod
- def _decode_cp1252(data: bytes) -> str:
- """Decode CP1252 bytes to string."""
- return data.decode("cp1252", errors="replace")
-
- @staticmethod
- def _clean_word_text(text: str) -> str:
- """Normalize Word control characters to readable equivalents."""
- text = text.replace("\r\n", "\n").replace("\r", "\n")
- # \x07 = cell/row end, \x0B = soft line break, \x0C = section break
- text = text.replace("\x07", "\t").replace("\x0b", "\n").replace("\x0c", "\n\n")
- return text
-
- def _fallback_extract(self, path: Path) -> str:
- """
- Last-resort text extraction by scanning raw bytes for readable text runs.
-
- Tries UTF-16LE decoding first (common in .doc), then falls back to CP1252.
- """
- # Cap read size to prevent DoS from large files
- with open(path, "rb") as f:
- raw = f.read(_MAX_STREAM_SIZE)
-
- # Try to find UTF-16LE text (every other byte is often 0x00 for ASCII)
- try:
- decoded = raw.decode("utf-16-le", errors="ignore")
- # Filter to printable text runs
- lines = []
- current = []
- for ch in decoded:
- if ch.isprintable() or ch in "\n\t":
- current.append(ch)
- else:
- if len(current) > 3:
- lines.append("".join(current))
- current = []
- if current and len(current) > 3:
- lines.append("".join(current))
- text = "\n".join(lines)
- if len(text) > 50:
- return text
- except Exception:
- pass
-
- # Fall back to CP1252
- text = raw.decode("cp1252", errors="replace")
- lines = []
- current = []
- for ch in text:
- if ch.isprintable() or ch in "\n\t":
- current.append(ch)
- else:
- if len(current) > 3:
- lines.append("".join(current))
- current = []
- if current and len(current) > 3:
- lines.append("".join(current))
- return "\n".join(lines)
diff --git a/openviking/parse/parsers/markdown.py b/openviking/parse/parsers/markdown.py
index 4d267aa54a..7400cc15dd 100644
--- a/openviking/parse/parsers/markdown.py
+++ b/openviking/parse/parsers/markdown.py
@@ -19,7 +19,6 @@
import asyncio
import hashlib
-import io
import os
import re
import time
@@ -29,6 +28,7 @@
from openviking.parse.accessors.mime_types import IANA_MEDIA_TYPE_TO_EXTENSION
from openviking.parse.base import NodeType, ParseResult, ResourceNode, create_parse_result
+from openviking.parse.image_validation import is_valid_image
from openviking.parse.parsers.base_parser import BaseParser
from openviking.parse.parsers.code.ast.providers import supports_code_skeleton
from openviking.parse.parsers.constants import (
@@ -160,14 +160,6 @@ class MarkdownParser(BaseParser):
MAX_TOKENS_PER_CHAR = 0.7
MAX_MERGED_FILENAME_LENGTH = 32 # Maximum length for merged section filenames
- # Image validation constants
- IMAGE_MIN_SIDE = 14 # Minimum width/height in pixels (exclusive)
- IMAGE_MIN_PIXELS = 196 # Minimum width * height
- IMAGE_MAX_PIXELS = 36000000 # Maximum width * height
- IMAGE_MIN_ASPECT_RATIO = 1 / 150 # Minimum width/height ratio
- IMAGE_MAX_ASPECT_RATIO = 150 # Maximum width/height ratio
- IMAGE_MAX_FILE_BYTES = 10 * 1024 * 1024 # Local file path limit: 10 MB
-
def __init__(
self,
extract_frontmatter: Optional[bool] = None,
@@ -540,7 +532,7 @@ async def _has_ingestable_local_image(
seen.add(resolved)
try:
image_bytes = await asyncio.to_thread(resolved.read_bytes)
- if await asyncio.to_thread(self._is_valid_image, image_bytes, resolved):
+ if await asyncio.to_thread(is_valid_image, image_bytes, resolved):
return True
except Exception:
continue
@@ -779,7 +771,7 @@ async def _ingest_local_images(
# Validate pixel size and file size; skip non-compliant images
if not await asyncio.to_thread(
- self._is_valid_image, image_bytes, resolved_path
+ is_valid_image, image_bytes, resolved_path
):
continue
@@ -891,63 +883,6 @@ def _resolve_image_path(
logger.warning(f"[MarkdownParser] Cannot resolve image path: {path_str}")
return None
- def _is_valid_image(self, image_bytes: bytes, source_path: Path) -> bool:
- """
- Validate an image's pixel dimensions and file size.
-
- Requirements:
- - Width > 14px and height > 14px
- - Width * height within [196, 36000000]
- - Aspect ratio (width/height) within [1/150, 150]
- - File size (local path) <= 10 MB
-
- Args:
- image_bytes: Raw image bytes
- source_path: Original image path (for logging)
-
- Returns:
- True if the image satisfies all requirements, otherwise False
- """
- # File size check (local file path limit: 10 MB)
- if len(image_bytes) > self.IMAGE_MAX_FILE_BYTES:
- logger.warning(f"[MarkdownParser] Image exceeds 10MB, skipping: {source_path}")
- return False
-
- # Pixel size check
- try:
- from PIL import Image
-
- with Image.open(io.BytesIO(image_bytes)) as img:
- width, height = img.size
- except Exception as e:
- logger.warning(
- f"[MarkdownParser] Cannot read image dimensions, skipping {source_path}: {e}"
- )
- return False
-
- if width <= self.IMAGE_MIN_SIDE or height <= self.IMAGE_MIN_SIDE:
- logger.warning(
- f"[MarkdownParser] Image side too small ({width}x{height}), skipping: {source_path}"
- )
- return False
-
- pixels = width * height
- if pixels < self.IMAGE_MIN_PIXELS or pixels > self.IMAGE_MAX_PIXELS:
- logger.warning(
- f"[MarkdownParser] Image pixel count out of range ({pixels}), skipping: {source_path}"
- )
- return False
-
- aspect_ratio = width / height
- if aspect_ratio < self.IMAGE_MIN_ASPECT_RATIO or aspect_ratio > self.IMAGE_MAX_ASPECT_RATIO:
- logger.warning(
- f"[MarkdownParser] Image aspect ratio out of range ({aspect_ratio:.4f}), "
- f"skipping: {source_path}"
- )
- return False
-
- return True
-
@staticmethod
def _is_remote_uri(path: str) -> bool:
"""
@@ -1114,7 +1049,7 @@ async def _ingest_will_handle_image(self, link: str) -> bool:
if resolved is not None:
try:
image_bytes = await asyncio.to_thread(resolved.read_bytes)
- handled = await asyncio.to_thread(self._is_valid_image, image_bytes, resolved)
+ handled = await asyncio.to_thread(is_valid_image, image_bytes, resolved)
except Exception:
handled = False
cache[link] = handled
diff --git a/openviking/parse/parsers/pdf.py b/openviking/parse/parsers/pdf.py
index 4d7acca204..45ac66854c 100644
--- a/openviking/parse/parsers/pdf.py
+++ b/openviking/parse/parsers/pdf.py
@@ -1,33 +1,17 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
-"""
-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.
-"""
+"""PDF parser using pdf-inspector for structure and pdfplumber for images."""
import asyncio
import hashlib
import io
-import re
import time
-from collections import Counter, defaultdict
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
-from openviking.parse.base import (
- NodeType,
- ParseResult,
- ResourceNode,
- create_parse_result,
- lazy_import,
-)
+from openviking.parse.base import NodeType, ParseResult, ResourceNode, create_parse_result
+from openviking.parse.image_validation import is_valid_image
from openviking.parse.parsers.base_parser import BaseParser
from openviking_cli.utils import get_logger
from openviking_cli.utils.config.parser_config import PDFConfig
@@ -35,50 +19,30 @@
logger = get_logger(__name__)
-class PDFParser(BaseParser):
- """
- PDF parser with dual conversion strategy.
+@dataclass
+class _PdfTextExtraction:
+ pages: dict[int, str]
+ warnings: list[str]
+ meta: dict[str, Any]
- 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
+@dataclass
+class _PdfImageExtraction:
+ pages: dict[int, list[str]] = field(default_factory=dict)
+ warnings: list[str] = field(default_factory=list)
+ images_extracted: int = 0
+ images_deduplicated: int = 0
- 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)
- >>> result = await parser.parse("document.pdf")
- """
+class PDFParser(BaseParser):
+ """Convert a PDF to page-ordered Markdown and then build its context tree."""
def __init__(self, config: Optional[PDFConfig] = None):
- """
- Initialize PDF parser.
-
- Args:
- config: PDFConfig instance (defaults to auto strategy)
- """
self.config = config or PDFConfig()
self.config.validate()
-
- # Lazy import MarkdownParser to avoid circular imports
self._markdown_parser = None
def _get_markdown_parser(self):
- """Lazy import and create MarkdownParser."""
if self._markdown_parser is None:
from openviking.parse.parsers.markdown import MarkdownParser
@@ -87,740 +51,228 @@ def _get_markdown_parser(self):
@property
def supported_extensions(self) -> List[str]:
- """List of supported file extensions."""
return [".pdf"]
async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """
- Parse PDF file.
-
- Args:
- source: Path to PDF file
- **kwargs: Additional options (resource_name/source_name for original filename)
-
- Returns:
- ParseResult with document tree
-
- Raises:
- FileNotFoundError: If PDF file doesn't exist
- ValueError: If conversion fails with all strategies
- """
- start_time = time.time()
- pdf_path = Path(source)
-
- # Get resource name from kwargs, prefer original filename from upload
+ started = time.time()
+ path = Path(source)
resource_name = kwargs.get("resource_name") or kwargs.get("source_name")
+ if not path.is_file():
+ return self._failed_result(path, started, f"File not found: {path}")
- if not pdf_path.exists():
- return create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(pdf_path),
- source_format="pdf",
- parser_name="PDFParser",
- parse_time=time.time() - start_time,
- warnings=[f"File not found: {pdf_path}"],
- )
+ from openviking_cli.utils.storage import get_storage
+ storage = get_storage()
try:
- # Step 1: Convert PDF to Markdown
- markdown_content, conversion_meta = await self._convert_to_markdown(
- pdf_path,
- resource_name=resource_name,
+ markdown, conversion_meta, conversion_warnings = await self._convert_to_markdown(
+ path,
+ storage=storage,
+ resource_name=resource_name or path.stem,
)
-
- # Step 2: Parse Markdown using MarkdownParser, pass through resource name
- md_parser = self._get_markdown_parser()
- from openviking_cli.utils.storage import get_storage
-
- storage = get_storage()
- result = await md_parser.parse_content(
- markdown_content,
- source_path=str(pdf_path),
- resource_name=resource_name,
- source_name=resource_name,
- base_dir=pdf_path.parent,
- allowed_media_dirs=[storage.media_dir],
- split_content=kwargs.get("split_content", True),
+ markdown_kwargs = dict(kwargs)
+ caller_media_dirs = list(markdown_kwargs.pop("allowed_media_dirs", None) or [])
+ caller_media_dirs.append(storage.media_dir)
+ result = await self._get_markdown_parser().parse_content(
+ markdown,
+ source_path=str(path),
+ instruction=instruction,
+ base_dir=path.parent,
+ allowed_media_dirs=caller_media_dirs,
+ **markdown_kwargs,
)
-
- # Step 3: Update metadata for PDF origin
- result.source_format = "pdf" # Override markdown format
+ result.source_format = "pdf"
result.parser_name = "PDFParser"
- result.parser_version = "2.0"
- result.parse_time = time.time() - start_time
+ result.parser_version = "3.0"
+ result.parse_time = time.time() - started
+ result.warnings.extend(conversion_warnings)
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]
-
+ result.meta["intermediate_markdown_length"] = len(markdown)
logger.info(
- f"PDF parsed successfully: {pdf_path.name} "
- f"({len(markdown_content)} chars markdown, "
- f"{result.parse_time:.2f}s)"
+ f"PDF parsed successfully: {path.name} "
+ f"({len(markdown)} chars markdown, {result.parse_time:.2f}s)"
)
-
return result
-
- except Exception as e:
- logger.error(f"Failed to parse PDF {pdf_path}: {e}")
- return create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(pdf_path),
- source_format="pdf",
- parser_name="PDFParser",
- parse_time=time.time() - start_time,
- warnings=[f"Failed to parse PDF: {e}"],
- )
+ except Exception as exc:
+ logger.error(f"Failed to parse PDF {path}: {exc}", exc_info=True)
+ return self._failed_result(path, started, f"Failed to parse PDF: {exc}")
+
+ def _failed_result(self, path: Path, started: float, warning: str) -> ParseResult:
+ return create_parse_result(
+ root=ResourceNode(type=NodeType.ROOT),
+ source_path=str(path),
+ source_format="pdf",
+ parser_name="PDFParser",
+ parse_time=time.time() - started,
+ warnings=[warning],
+ )
async def _convert_to_markdown(
self,
- pdf_path: Path,
- resource_name: Optional[str] = None,
- ) -> tuple[str, Dict[str, Any]]:
- """
- Convert PDF to Markdown using configured strategy.
-
- Args:
- pdf_path: Path to PDF file
- 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)
-
- def _convert_local_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 调用。
- """
- pdfplumber = lazy_import("pdfplumber")
-
- # Import storage utilities
- if storage is None:
- from openviking_cli.utils.storage import get_storage
-
- storage = get_storage()
-
- if resource_name is None:
- resource_name = pdf_path.stem
-
- parts = []
- meta = {
- "strategy": "local",
- "library": "pdfplumber",
- "pages_processed": 0,
- "images_extracted": 0,
- "images_deduplicated": 0,
- "tables_extracted": 0,
- "bookmarks_found": 0,
- "bookmarks_resolved": 0,
- "bookmarks_unresolved": 0,
- "headings_found": 0,
- "heading_source": "none",
- }
-
- try:
- with pdfplumber.open(str(pdf_path)) as pdf:
- meta["total_pages"] = len(pdf.pages)
-
- # Extract structure (bookmarks → font fallback)
- detection_mode = self.config.heading_detection
- bookmarks = []
- raw_bookmarks = []
- heading_source = "none"
-
- if detection_mode in ("bookmarks", "auto"):
- raw_bookmarks = self._extract_bookmarks(pdf)
- meta["bookmarks_found"] = len(raw_bookmarks)
- bookmarks = [bm for bm in raw_bookmarks if bm["page_num"] is not None]
- meta["bookmarks_resolved"] = len(bookmarks)
- meta["bookmarks_unresolved"] = len(raw_bookmarks) - len(bookmarks)
-
- if bookmarks:
- heading_source = "bookmarks"
- elif raw_bookmarks:
- logger.info(
- "Bookmark detection found %d entries but none resolved to pages; "
- "ignoring bookmark headings",
- len(raw_bookmarks),
- )
-
- if not bookmarks and detection_mode in ("font", "auto"):
- bookmarks = self._detect_headings_by_font(pdf)
- if bookmarks:
- heading_source = "font_analysis"
-
- meta["headings_found"] = len(bookmarks)
- meta["heading_source"] = heading_source
- logger.info(
- "Heading detection: source=%s, headings=%d, bookmarks=%d, resolved=%d, "
- "unresolved=%d",
- heading_source,
- len(bookmarks),
- meta["bookmarks_found"],
- meta["bookmarks_resolved"],
- meta["bookmarks_unresolved"],
- )
-
- # Group bookmarks by page_num
- bookmarks_by_page = defaultdict(list)
- for bm in bookmarks:
- page = bm["page_num"]
- if page is None:
- continue
- bookmarks_by_page[page].append(bm)
-
- for page_num, page in enumerate(pdf.pages, 1):
- try:
- # Inject headings before page text
- page_bookmarks = bookmarks_by_page.get(page_num, [])
- for bm in page_bookmarks:
- heading_prefix = "#" * bm["level"]
- parts.append(f"\n{heading_prefix} {bm['title']}\n")
-
- # Extract text
- text = page.extract_text()
- if text and text.strip():
- # Add page marker as HTML comment
- parts.append(f"\n{text.strip()}")
- meta["pages_processed"] += 1
-
- # Extract tables
- tables = page.extract_tables()
- for table_idx, table in enumerate(tables or []):
- if table and len(table) > 0:
- md_table = self._format_table_markdown(table)
- if md_table:
- parts.append(
- f"\n{md_table}"
- )
- meta["tables_extracted"] += 1
-
- # Extract images.
- #
- # A page can stack several image XObjects on the exact same
- # spot — print-to-PDF producers routinely emit a background
- # layer plus a content layer. Since extraction rasterises the
- # page *region* rather than the XObject itself, every one of
- # them renders to identical bytes. Skip the repeats: bbox
- # first, which avoids the (expensive) render entirely, then a
- # content hash as a backstop. Both sets are per-page, so a
- # header logo repeated across pages is still kept once per
- # page.
- images = page.images
- seen_boxes = set()
- seen_digests = set()
- for img_idx, img in enumerate(images or []):
- try:
- bbox_key = (
- round(img["x0"], 1),
- round(img["top"], 1),
- round(img["x1"], 1),
- round(img["bottom"], 1),
- )
- if bbox_key in seen_boxes:
- meta["images_deduplicated"] += 1
- continue
- seen_boxes.add(bbox_key)
-
- # Extract image using underlying PDF object
- image_obj = self._extract_image_from_page(page, img)
- if image_obj:
- # Dedup only — md5 keeps this cheap, and the
- # flag keeps it working on FIPS-locked hosts.
- digest = hashlib.md5(image_obj, usedforsecurity=False).digest()
- if digest in seen_digests:
- meta["images_deduplicated"] += 1
- continue
- seen_digests.add(digest)
-
- # Save image
- filename = f"page{page_num}_img{img_idx + 1}"
- image_path = storage.save_image(
- resource_name, image_obj, filename=filename
- )
-
- # Generate path relative to the media root.
- rel_path = image_path.relative_to(storage.media_dir)
- parts.append(
- f"\n"
- f""
- )
- meta["images_extracted"] += 1
- except Exception as img_err:
- logger.warning(
- f"Failed to extract image {img_idx + 1} on page {page_num}: {img_err}"
- )
- finally:
- self._release_page_cache(page)
-
- if not parts:
- logger.warning(f"No content extracted from {pdf_path}")
- return "", meta
-
- markdown_content = "\n\n".join(parts)
- logger.info(
- f"Local conversion: {meta['pages_processed']}/{meta['total_pages']} pages, "
- f"{meta['headings_found']} headings ({meta['heading_source']}, "
- f"bookmarks={meta['bookmarks_found']}, "
- f"resolved={meta['bookmarks_resolved']}), "
- f"{meta['images_extracted']} images "
- f"({meta['images_deduplicated']} duplicates skipped), "
- f"{meta['tables_extracted']} tables → "
- f"{len(markdown_content)} chars"
+ path: Path,
+ *,
+ storage: Any,
+ resource_name: str,
+ ) -> tuple[str, Dict[str, Any], list[str]]:
+ text_result, image_result = await asyncio.gather(
+ asyncio.to_thread(self._extract_text_sync, path),
+ asyncio.to_thread(self._extract_images_sync, path, storage, resource_name),
+ return_exceptions=True,
+ )
+ if isinstance(text_result, BaseException):
+ raise text_result
+ if isinstance(image_result, BaseException):
+ image_result = _PdfImageExtraction(
+ warnings=[f"PDF image extraction failed: {image_result}"]
)
- return markdown_content, meta
-
- except Exception as e:
- logger.error(f"pdfplumber conversion failed: {e}")
- raise
+ page_numbers = sorted(set(text_result.pages) | set(image_result.pages))
+ parts: list[str] = []
+ for page_number in page_numbers:
+ page_parts: list[str] = []
+ page_markdown = text_result.pages.get(page_number, "").strip()
+ if page_markdown:
+ page_parts.append(page_markdown)
+ page_parts.extend(image_result.pages.get(page_number, []))
+ if page_parts:
+ parts.append(f"\n\n" + "\n\n".join(page_parts))
+ if not parts:
+ raise ValueError("No meaningful text or images extracted from PDF")
+
+ meta = dict(text_result.meta)
+ meta.update(
+ {
+ "library": "pdf-inspector",
+ "image_library": "pdfplumber",
+ "images_extracted": image_result.images_extracted,
+ "images_deduplicated": image_result.images_deduplicated,
+ }
+ )
+ return "\n\n".join(parts), meta, text_result.warnings + image_result.warnings
@staticmethod
- def _release_page_cache(page: Any) -> None:
- """Release pdfplumber/pdfminer per-page caches when available."""
- close = getattr(page, "close", None)
- if callable(close):
- try:
- close()
- return
- except Exception:
- pass
-
- flush_cache = getattr(page, "flush_cache", None)
- if callable(flush_cache):
- try:
- flush_cache()
- except Exception:
- pass
-
- def _extract_bookmarks(self, pdf) -> List[Dict[str, Any]]:
- """Extract bookmark structure from PDF outlines.
-
- Returns: [{level: int, title: str, page_num: int(1-based)}]
- """
- try:
- if not hasattr(pdf, "doc") or not hasattr(pdf.doc, "get_outlines"):
- return []
-
- outlines = list(pdf.doc.get_outlines())
- if not outlines:
- return []
-
- page_ref_to_num = self._build_page_number_map(pdf)
-
- bookmarks = []
- for level, title, dest, _action, _se in outlines:
- if not title or not title.strip():
- continue
-
- page_num = None
- try:
- if dest and len(dest) > 0:
- page_num = self._resolve_bookmark_page(
- dest[0], page_ref_to_num, len(pdf.pages)
- )
- except Exception:
- pass
-
- bookmarks.append(
- {
- "level": min(max(level, 1), 6),
- "title": title.strip(),
- "page_num": page_num,
- }
- )
-
- return bookmarks
-
- except Exception as e:
- logger.warning(f"Failed to extract bookmarks: {e}")
- return []
-
- def _build_page_number_map(self, pdf) -> Dict[int, int]:
- """Build a lookup from PDF page object ids to 1-based page numbers.
-
- pdfminer outlines and link annotations reference page objects by object id.
- In pdfplumber these ids are exposed as ``page.page_obj.pageid``; some mocks
- or alternate inputs may still expose ``objid``, so we keep both.
- """
- page_ref_to_num: Dict[int, int] = {}
- for page_num, page in enumerate(pdf.pages, 1):
- page_obj = getattr(page, "page_obj", None)
- if page_obj is None:
- continue
-
- for attr_name in ("pageid", "objid"):
- ref_id = getattr(page_obj, attr_name, None)
- if isinstance(ref_id, int):
- page_ref_to_num.setdefault(ref_id, page_num)
-
- return page_ref_to_num
-
- def _resolve_bookmark_page(
- self, page_ref: Any, page_ref_to_num: Dict[int, int], total_pages: int
- ) -> Optional[int]:
- """Resolve a bookmark destination to a 1-based page number."""
- ref_id = getattr(page_ref, "objid", None)
- if isinstance(ref_id, int):
- return page_ref_to_num.get(ref_id)
-
- if isinstance(page_ref, int):
- # 0-based integer page index (common in many PDF producers)
- candidate = page_ref + 1
- if 1 <= candidate <= total_pages:
- return candidate
- return None
-
- if hasattr(page_ref, "resolve"):
- resolved = page_ref.resolve()
- for attr_name in ("pageid", "objid"):
- resolved_id = getattr(resolved, attr_name, None)
- if isinstance(resolved_id, int):
- return page_ref_to_num.get(resolved_id)
+ def _extract_text_sync(path: Path) -> _PdfTextExtraction:
+ import pdf_inspector
- return None
-
- def _detect_headings_by_font(self, pdf) -> List[Dict[str, Any]]:
- """Detect headings by font size analysis.
-
- Returns: [{level: int, title: str, page_num: int(1-based)}]
- """
- try:
- # Step 1: Sample font size distribution (every 5th page)
- size_counter: Counter = Counter()
- sample_pages = pdf.pages[::5]
- for page in sample_pages:
- try:
- for char in page.chars:
- if char["text"].strip():
- rounded = round(char["size"] * 2) / 2
- size_counter[rounded] += 1
- finally:
- self._release_page_cache(page)
-
- if not size_counter:
- return []
-
- # Step 2: Determine body font size and heading font sizes
- body_size = size_counter.most_common(1)[0][0]
- min_delta = self.config.font_heading_min_delta
-
- heading_sizes = sorted(
- [
- s
- for s, count in size_counter.items()
- if s >= body_size + min_delta and count < size_counter[body_size] * 0.5
- ],
- reverse=True,
- )
-
- max_levels = self.config.max_heading_levels
- heading_sizes = heading_sizes[:max_levels]
-
- if not heading_sizes:
- logger.debug(f"Font analysis: body_size={body_size}pt, no heading sizes found")
- return []
-
- size_to_level = {s: i + 1 for i, s in enumerate(heading_sizes)}
- logger.debug(
- f"Font analysis: body_size={body_size}pt, "
- f"heading_sizes={heading_sizes}, size_to_level={size_to_level}"
- )
-
- # Step 3: Extract heading text page by page
- headings: List[Dict[str, Any]] = []
-
- def flush_line(chars_to_flush: list, page_num: int) -> None:
- if not chars_to_flush:
- return
- title = "".join(c["text"] for c in chars_to_flush).strip()
- size = round(chars_to_flush[0]["size"] * 2) / 2
-
- if len(title) < 2:
- return
- if len(title) > 100:
- return
- if title.isdigit():
- return
- if re.match(r"^[\d\s.·…]+$", title):
- return
-
- headings.append(
- {
- "level": size_to_level[size],
- "title": title,
- "page_num": page_num,
- }
- )
+ extraction = pdf_inspector.extract_pages_markdown(str(path))
+ pages = {page.page + 1: page.markdown or "" for page in extraction.pages}
+ reason_by_page = {
+ reason.page: list(reason.reasons) for reason in extraction.ocr_reasons_by_page
+ }
+ reasons = [
+ {"page": page, "reasons": page_reasons} for page, page_reasons in reason_by_page.items()
+ ]
+ warnings = [
+ f"PDF page {page} requires OCR: {', '.join(reason_by_page.get(page, ['unknown']))}"
+ for page in extraction.pages_needing_ocr
+ ]
+ meta = {
+ "total_pages": len(extraction.pages),
+ "pages_processed": sum(bool(markdown.strip()) for markdown in pages.values()),
+ "pages_needing_ocr": list(extraction.pages_needing_ocr),
+ "ocr_reasons_by_page": reasons,
+ "pages_with_tables": list(extraction.pages_with_tables),
+ "pages_with_columns": list(extraction.pages_with_columns),
+ "is_complex_layout": extraction.is_complex,
+ }
+ return _PdfTextExtraction(pages=pages, warnings=warnings, meta=meta)
- for page in pdf.pages:
+ def _extract_images_sync(
+ self,
+ path: Path,
+ storage: Any,
+ resource_name: str,
+ ) -> _PdfImageExtraction:
+ import pdfplumber
+
+ result = _PdfImageExtraction()
+ with pdfplumber.open(str(path)) as pdf:
+ for page_number, page in enumerate(pdf.pages, 1):
try:
- page_num = page.page_number + 1
- chars = sorted(page.chars, key=lambda c: (c["top"], c["x0"]))
-
- current_line_chars: list = []
- current_top = None
-
- for char in chars:
- # Performance: headings won't appear in bottom 70% of page
- if char["top"] > page.height * 0.3:
- flush_line(current_line_chars, page_num)
- current_line_chars = []
- break
-
- rounded_size = round(char["size"] * 2) / 2
- if rounded_size not in size_to_level:
- flush_line(current_line_chars, page_num)
- current_line_chars = []
- current_top = None
- continue
-
- # Same line check (top offset < 2pt)
- if current_top is not None and abs(char["top"] - current_top) > 2:
- flush_line(current_line_chars, page_num)
- current_line_chars = []
-
- current_line_chars.append(char)
- current_top = char["top"]
-
- flush_line(current_line_chars, page_num)
+ seen_boxes: set[tuple[float, float, float, float]] = set()
+ seen_digests: set[bytes] = set()
+ page_images: list[str] = []
+ for image_index, image in enumerate(page.images or [], 1):
+ try:
+ box = (
+ round(image["x0"], 1),
+ round(image["top"], 1),
+ round(image["x1"], 1),
+ round(image["bottom"], 1),
+ )
+ if box in seen_boxes:
+ result.images_deduplicated += 1
+ continue
+ seen_boxes.add(box)
+ image_bytes = self._extract_image_from_page(page, image)
+ if image_bytes is None:
+ continue
+ filename = f"page{page_number}_img{image_index}"
+ if not is_valid_image(image_bytes, Path(f"{filename}.png")):
+ continue
+ digest = hashlib.md5(image_bytes, usedforsecurity=False).digest()
+ if digest in seen_digests:
+ result.images_deduplicated += 1
+ continue
+ seen_digests.add(digest)
+ image_path = storage.save_image(
+ resource_name,
+ image_bytes,
+ filename=filename,
+ extension=".png",
+ )
+ relative = image_path.relative_to(storage.media_dir).as_posix()
+ page_images.append(
+ f""
+ )
+ result.images_extracted += 1
+ except Exception as exc:
+ warning = (
+ f"Failed to extract image {image_index} on page "
+ f"{page_number}: {exc}"
+ )
+ logger.warning(warning)
+ result.warnings.append(warning)
+ if page_images:
+ result.pages[page_number] = page_images
finally:
self._release_page_cache(page)
-
- # Step 4: Deduplicate - filter headers appearing on >30% of pages
- title_page_count: Counter = Counter(h["title"] for h in headings)
- total_pages = len(pdf.pages)
- header_titles = {t for t, c in title_page_count.items() if c > total_pages * 0.3}
- headings = [h for h in headings if h["title"] not in header_titles]
-
- logger.debug(
- f"Font heading detection: {len(headings)} headings found "
- f"(filtered {len(header_titles)} header titles)"
- )
- return headings
-
- except Exception as e:
- logger.warning(f"Failed to detect headings by font: {e}")
- return []
-
- def _extract_image_from_page(self, page, img_info: dict) -> Optional[bytes]:
- """
- Extract a PDF image as valid PNG bytes.
-
- Renders the image's bounding box on the page to a raster PNG via
- pdfplumber's ``crop().to_image()`` instead of returning the raw decoded
- XObject stream (which is not a valid image file and cannot be opened).
-
- Args:
- page: pdfplumber page object
- img_info: Image metadata from page.images
-
- Returns:
- PNG-encoded image bytes or None if extraction fails
- """
- try:
- # pdfplumber coordinates: ``top`` is measured from the top of the page.
- bbox = (
- max(0, img_info["x0"]),
- max(0, img_info["top"]),
- min(page.width, img_info["x1"]),
- min(page.height, img_info["bottom"]),
- )
-
- # Skip degenerate / zero-area boxes that cannot be cropped.
- if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
- return None
-
- cropped = page.crop(bbox)
- page_image = cropped.to_image(resolution=self.config.image_resolution)
-
- buffer = io.BytesIO()
- page_image.save(buffer, format="PNG")
- return buffer.getvalue()
-
- except Exception as e:
- logger.debug(f"Image extraction error: {e}")
+ return result
+
+ def _extract_image_from_page(self, page: Any, image: dict[str, Any]) -> Optional[bytes]:
+ bbox = (
+ max(0, image["x0"]),
+ max(0, image["top"]),
+ min(page.width, image["x1"]),
+ min(page.height, image["bottom"]),
+ )
+ if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
return None
+ cropped = page.crop(bbox)
+ rendered = cropped.to_image(resolution=self.config.image_resolution)
+ buffer = io.BytesIO()
+ rendered.save(buffer, format="PNG")
+ return buffer.getvalue()
- 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,
- }
-
+ @staticmethod
+ def _release_page_cache(page: Any) -> 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.
-
- Args:
- table: 2D array of table cells
-
- Returns:
- Markdown table string
-
- Examples:
- >>> table = [["Name", "Age"], ["Alice", "30"], ["Bob", "25"]]
- >>> print(parser._format_table_markdown(table))
- | Name | Age |
- | --- | --- |
- | Alice | 30 |
- | Bob | 25 |
- """
- if not table or not table[0]:
- return ""
-
- # Clean cells and handle None values
- def clean_cell(cell):
- if cell is None:
- return ""
- return str(cell).strip().replace("|", "\\|") # Escape pipe characters
-
- lines = []
-
- # Header row
- header = table[0]
- header_cells = [clean_cell(cell) for cell in header]
- lines.append("| " + " | ".join(header_cells) + " |")
-
- # Separator row
- separator = ["---"] * len(header)
- lines.append("| " + " | ".join(separator) + " |")
-
- # Data rows
- for row in table[1:]:
- # Pad row to match header length
- padded_row = row + [None] * (len(header) - len(row))
- cells = [clean_cell(cell) for cell in padded_row[: len(header)]]
- lines.append("| " + " | ".join(cells) + " |")
-
- return "\n".join(lines)
+ close = getattr(page, "close", None)
+ if callable(close):
+ close()
+ return
+ flush_cache = getattr(page, "flush_cache", None)
+ if callable(flush_cache):
+ flush_cache()
+ except Exception as exc:
+ logger.debug(f"Failed to release pdfplumber page cache: {exc}")
async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
+ self,
+ content: str,
+ source_path: Optional[str] = None,
+ instruction: str = "",
+ **kwargs,
) -> ParseResult:
- """
- Parse PDF content string.
-
- Note: This method is not recommended for PDFParser as it requires
- file path for conversion tools. Use parse() with file path instead.
-
- Args:
- content: PDF content (not supported)
- source_path: Optional source path
- **kwargs: Additional options
-
- Raises:
- NotImplementedError: PDFParser requires file path
- """
raise NotImplementedError(
- "PDFParser does not support parsing content strings. "
- "Use parse() with a file path instead."
+ "PDFParser requires a PDF file path; string content is not supported"
)
diff --git a/openviking/parse/parsers/powerpoint.py b/openviking/parse/parsers/powerpoint.py
deleted file mode 100644
index 9ef1839969..0000000000
--- a/openviking/parse/parsers/powerpoint.py
+++ /dev/null
@@ -1,150 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""
-PowerPoint (.pptx) parser for OpenViking.
-
-Converts PowerPoint presentations to Markdown then parses using MarkdownParser.
-Inspired by microsoft/markitdown approach.
-"""
-
-import asyncio
-from pathlib import Path
-from typing import List, Optional, Union
-
-from openviking.parse.base import ParseResult
-from openviking.parse.parsers.base_parser import BaseParser
-from openviking_cli.utils.config.parser_config import ParserConfig
-from openviking_cli.utils.logger import get_logger
-
-logger = get_logger(__name__)
-
-
-class PowerPointParser(BaseParser):
- """
- PowerPoint presentation parser for OpenViking.
-
- Supports: .pptx
-
- Converts PowerPoint presentations to Markdown using python-pptx,
- then delegates to MarkdownParser for tree structure creation.
- """
-
- def __init__(self, config: Optional[ParserConfig] = None, extract_notes: bool = False):
- """
- Initialize PowerPoint parser.
-
- Args:
- config: Parser configuration
- extract_notes: Whether to extract speaker notes
- """
- from openviking.parse.parsers.markdown import MarkdownParser
-
- self._md_parser = MarkdownParser(config=config)
- self.config = config or ParserConfig()
- self.extract_notes = extract_notes
-
- @property
- def supported_extensions(self) -> List[str]:
- return [".pptx"]
-
- async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """Parse PowerPoint presentation from file path."""
- path = Path(source)
-
- if path.exists():
- import pptx
-
- markdown_content = await asyncio.to_thread(self._convert_to_markdown, path, pptx)
- result = await self._md_parser.parse_content(
- markdown_content, source_path=str(path), instruction=instruction, **kwargs
- )
- else:
- result = await self._md_parser.parse_content(
- str(source), instruction=instruction, **kwargs
- )
- result.source_format = "pptx"
- result.parser_name = "PowerPointParser"
- return result
-
- async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
- ) -> ParseResult:
- """Parse content - delegates to MarkdownParser."""
- result = await self._md_parser.parse_content(content, source_path, **kwargs)
- result.source_format = "pptx"
- result.parser_name = "PowerPointParser"
- return result
-
- def _convert_to_markdown(self, path: Path, pptx) -> str:
- """Convert PowerPoint presentation to Markdown string."""
- prs = pptx.Presentation(path)
- markdown_parts = []
- slide_count = len(prs.slides)
-
- for idx, slide in enumerate(prs.slides, 1):
- slide_parts = []
- slide_parts.append(f"## Slide {idx}/{slide_count}")
-
- title = self._extract_slide_title(slide)
- if title:
- slide_parts.append(f"### {title}")
-
- content = self._extract_slide_content(slide)
- if content:
- slide_parts.append(content)
-
- if self.extract_notes and slide.has_notes_slide:
- notes = slide.notes_slide.notes_text_frame.text.strip()
- if notes:
- slide_parts.append(f"**Notes:** {notes}")
-
- markdown_parts.append("\n\n".join(slide_parts))
-
- return "\n\n---\n\n".join(markdown_parts)
-
- def _extract_slide_title(self, slide) -> str:
- """Extract title from a slide."""
- from pptx.enum.shapes import PP_PLACEHOLDER
-
- for shape in slide.shapes:
- if shape.is_placeholder:
- ph_type = shape.placeholder_format.type
- if ph_type in (PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE):
- return shape.text.strip()
- return ""
-
- def _extract_slide_content(self, slide) -> str:
- """Extract content from slide shapes."""
- from pptx.enum.shapes import PP_PLACEHOLDER
-
- content_parts = []
-
- for shape in slide.shapes:
- if shape.is_placeholder:
- ph_type = shape.placeholder_format.type
- if ph_type in (PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE):
- continue
-
- if hasattr(shape, "text") and shape.text.strip():
- if shape.has_table:
- content_parts.append(self._convert_table(shape.table))
- else:
- text = shape.text.strip()
- if text:
- content_parts.append(text)
-
- return "\n\n".join(content_parts)
-
- def _convert_table(self, table) -> str:
- """Convert PowerPoint table to markdown format."""
- if not table.rows:
- return ""
-
- rows = []
- for row in table.rows:
- row_data = [cell.text.strip() for cell in row.cells]
- rows.append(row_data)
-
- from openviking.parse.base import format_table_to_markdown
-
- return format_table_to_markdown(rows, has_header=True)
diff --git a/openviking/parse/parsers/word.py b/openviking/parse/parsers/word.py
deleted file mode 100644
index 0a76f110ae..0000000000
--- a/openviking/parse/parsers/word.py
+++ /dev/null
@@ -1,231 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""
-Word document (.docx) parser for OpenViking.
-
-Converts Word documents to Markdown then parses using MarkdownParser.
-Inspired by microsoft/markitdown approach.
-"""
-
-import asyncio
-from pathlib import Path
-from typing import List, Optional, Union
-
-from openviking.parse.base import ParseResult
-from openviking.parse.parsers.base_parser import BaseParser
-from openviking_cli.utils.config.parser_config import ParserConfig
-from openviking_cli.utils.logger import get_logger
-
-logger = get_logger(__name__)
-
-
-class WordParser(BaseParser):
- """
- Word document parser for OpenViking.
-
- Supports: .docx
-
- Converts Word documents to Markdown using python-docx,
- then delegates to MarkdownParser for tree structure creation.
- """
-
- def __init__(self, config: Optional[ParserConfig] = None):
- """Initialize Word parser."""
- from openviking.parse.parsers.markdown import MarkdownParser
-
- self._md_parser = MarkdownParser(config=config)
- self.config = config or ParserConfig()
-
- @property
- def supported_extensions(self) -> List[str]:
- return [".docx"]
-
- async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult:
- """Parse Word document from file path."""
- path = Path(source)
-
- if path.exists():
- import docx
-
- from openviking_cli.utils.storage import get_storage
-
- storage = get_storage()
- resource_name = kwargs.get("resource_name") or kwargs.get("source_name") or path.stem
-
- markdown_content = await asyncio.to_thread(
- self._convert_to_markdown, path, docx, resource_name, storage
- )
- result = await self._md_parser.parse_content(
- markdown_content,
- source_path=str(path),
- # Forward the original upload name explicitly (mirrors pdf.py) so
- # the resource is named after it, not the temp upload path. Pass
- # the raw kwargs values rather than the path.stem-backed local
- # resource_name, leaving MarkdownParser's naming logic unchanged.
- resource_name=kwargs.get("resource_name"),
- source_name=kwargs.get("source_name"),
- instruction=instruction,
- base_dir=path.parent,
- # docx images are extracted into storage.media_dir, so (like pdf)
- # that is the only derived-media root needed.
- allowed_media_dirs=[storage.media_dir],
- split_content=kwargs.get("split_content", True),
- )
- else:
- result = await self._md_parser.parse_content(
- str(source),
- instruction=instruction,
- resource_name=kwargs.get("resource_name"),
- source_name=kwargs.get("source_name"),
- split_content=kwargs.get("split_content", True),
- )
- result.source_format = "docx"
- result.parser_name = "WordParser"
- return result
-
- async def parse_content(
- self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs
- ) -> ParseResult:
- """Parse content - delegates to MarkdownParser."""
- result = await self._md_parser.parse_content(content, source_path, **kwargs)
- result.source_format = "docx"
- result.parser_name = "WordParser"
- return result
-
- def _convert_to_markdown(self, path: Path, docx, resource_name=None, storage=None) -> str:
- """Convert Word document to Markdown string.
-
- Iterates the document body in order so that tables appear in their
- original position rather than being appended at the end. Embedded
- images are extracted to local storage and referenced inline so the
- MarkdownParser can ingest them into VikingFS.
- """
- doc = docx.Document(path)
- markdown_parts = []
-
- # Map XML table elements to python-docx Table objects for O(1) lookup
- table_by_element = {table._tbl: table for table in doc.tables}
-
- # Track extracted images to deduplicate and number sequentially
- image_counter = [0]
-
- # Walk the document body in order to preserve table positions
- from docx.oxml.ns import qn
-
- for child in doc.element.body:
- if child.tag == qn("w:p"):
- # It's a paragraph
- from docx.text.paragraph import Paragraph
-
- paragraph = Paragraph(child, doc)
-
- # Extract any embedded images in this paragraph first so they
- # keep their original document position.
- image_md = self._convert_paragraph_images(
- paragraph, doc, qn, resource_name, storage, image_counter
- )
- if image_md:
- markdown_parts.append(image_md)
-
- if not paragraph.text.strip():
- continue
-
- style_name = paragraph.style.name if paragraph.style else "Normal"
-
- if style_name.startswith("Heading"):
- level = self._extract_heading_level(style_name)
- markdown_parts.append(f"{'#' * level} {paragraph.text}")
- else:
- text = self._convert_formatted_text(paragraph)
- markdown_parts.append(text)
-
- elif child.tag == qn("w:tbl"):
- # It's a table
- if child in table_by_element:
- markdown_parts.append(self._convert_table(table_by_element[child]))
-
- return "\n\n".join(markdown_parts)
-
- def _convert_paragraph_images(
- self, paragraph, doc, qn, resource_name, storage, image_counter
- ) -> str:
- """Extract embedded images from a paragraph and return markdown references.
-
- Images in .docx are stored as relationship parts (``word/media/*``)
- referenced by ``r:embed`` ids inside ``w:drawing`` elements. We pull the
- binary blob for each and persist it via the shared storage helper so the
- MarkdownParser's image ingestion can pick it up.
- """
- if storage is None:
- return ""
-
- parts = []
- # Each inside the paragraph references an image part
- for blip in paragraph._p.findall(".//" + qn("a:blip")):
- rid = blip.get(qn("r:embed"))
- if not rid:
- continue
- try:
- image_part = doc.part.related_parts[rid]
- image_bytes = image_part.blob
- except Exception as e:
- logger.warning(f"[WordParser] Failed to read embedded image {rid}: {e}")
- continue
-
- try:
- extension = Path(image_part.partname).suffix or ".png"
- image_counter[0] += 1
- filename = f"image{image_counter[0]}"
- image_path = storage.save_image(
- resource_name, image_bytes, filename=filename, extension=extension
- )
- # Reference relative to the media root so the MarkdownParser can
- # resolve it within the resource's media lifecycle (never cwd).
- rel_path = image_path.relative_to(storage.media_dir)
- parts.append(f"")
- except Exception as e:
- logger.warning(f"[WordParser] Failed to save embedded image {rid}: {e}")
-
- return "\n\n".join(parts)
-
- def _extract_heading_level(self, style_name: str) -> int:
- """Extract heading level from style name."""
- try:
- if "Heading" in style_name:
- parts = style_name.split()
- for part in parts:
- if part.isdigit():
- return min(int(part), 6)
- except Exception:
- pass
- return 1
-
- def _convert_formatted_text(self, paragraph) -> str:
- """Convert paragraph with formatting to markdown."""
- text_parts = []
- for run in paragraph.runs:
- text = run.text
- if not text:
- continue
- if run.bold:
- text = f"**{text}**"
- if run.italic:
- text = f"*{text}*"
- if run.underline:
- text = f"{text}"
- text_parts.append(text)
- return "".join(text_parts)
-
- def _convert_table(self, table) -> str:
- """Convert Word table to markdown format."""
- if not table.rows:
- return ""
-
- rows = []
- for row in table.rows:
- row_data = [cell.text.strip() for cell in row.cells]
- rows.append(row_data)
-
- from openviking.parse.base import format_table_to_markdown
-
- return format_table_to_markdown(rows, has_header=True)
diff --git a/openviking/parse/registry.py b/openviking/parse/registry.py
index dc0140562d..5db8868c86 100644
--- a/openviking/parse/registry.py
+++ b/openviking/parse/registry.py
@@ -10,24 +10,18 @@
from typing import Dict, List, Optional, Union
from openviking.parse.base import ParseResult
+from openviking.parse.parsers.anydoc import AnyDocParser
from openviking.parse.parsers.base_parser import BaseParser
from openviking.parse.parsers.constants import TYPESCRIPT_MPEG_TS_EXTENSION
from openviking.parse.parsers.directory import DirectoryParser
-from openviking.parse.parsers.epub import EPubParser
-from openviking.parse.parsers.excel import ExcelParser
# Import will be handled dynamically to avoid dependency issues
from openviking.parse.parsers.html import HTMLParser
-
-# Import markitdown-inspired parsers
-from openviking.parse.parsers.legacy_doc import LegacyDocParser
from openviking.parse.parsers.markdown import MarkdownParser
from openviking.parse.parsers.media import AudioParser, ImageParser, VideoParser
from openviking.parse.parsers.media.utils import is_mpeg_ts, read_mpeg_ts_probe
from openviking.parse.parsers.pdf import PDFParser
-from openviking.parse.parsers.powerpoint import PowerPointParser
from openviking.parse.parsers.text import TextParser
-from openviking.parse.parsers.word import WordParser
from openviking.parse.parsers.zip_parser import ZipParser
from openviking_cli.utils.config.parser_config import ParserConfig
@@ -59,14 +53,7 @@ def __init__(
self._register("pdf", PDFParser(config=self._parser_configs.get("pdf")))
self._register("html", HTMLParser(config=self._parser_configs.get("html")))
- # Register markitdown-inspired parsers (built-in)
- self._register("word", WordParser(config=self._parser_configs.get("word")))
- self._register("legacy_doc", LegacyDocParser(config=self._parser_configs.get("legacy_doc")))
- self._register(
- "powerpoint", PowerPointParser(config=self._parser_configs.get("powerpoint"))
- )
- self._register("excel", ExcelParser(config=self._parser_configs.get("excel")))
- self._register("epub", EPubParser(config=self._parser_configs.get("epub")))
+ self._register("anydoc", AnyDocParser(config=self._parser_configs.get("anydoc")))
self._register("zip", ZipParser())
self._register("directory", DirectoryParser())
@@ -176,15 +163,7 @@ def get_registry() -> ParserRegistry:
"markdown": config.markdown,
"pdf": config.pdf,
"html": config.html,
- "word": config.markdown,
- "legacy_doc": config.markdown,
- "powerpoint": config.markdown,
- # Excel had no dedicated config section and reused
- # ``config.markdown``. Keep unset sectioning fields following
- # Markdown so existing deployments keep their node structure and
- # stable URIs after this upgrade.
- "excel": config.excel.with_sectioning_defaults_from(config.markdown),
- "epub": config.markdown,
+ "anydoc": config.markdown,
"image": config.image,
}
except Exception:
diff --git a/openviking_cli/utils/config/__init__.py b/openviking_cli/utils/config/__init__.py
index b84173610a..3284689ee3 100644
--- a/openviking_cli/utils/config/__init__.py
+++ b/openviking_cli/utils/config/__init__.py
@@ -67,7 +67,6 @@
PARSER_CONFIG_REGISTRY,
AudioConfig,
CodeConfig,
- ExcelConfig,
HTMLConfig,
ImageConfig,
MarkdownConfig,
@@ -147,7 +146,6 @@
"AudioConfig",
"VideoConfig",
"MarkdownConfig",
- "ExcelConfig",
"HTMLConfig",
"TextConfig",
"get_parser_config",
diff --git a/openviking_cli/utils/config/open_viking_config.py b/openviking_cli/utils/config/open_viking_config.py
index c7fbb24519..e7e467f573 100644
--- a/openviking_cli/utils/config/open_viking_config.py
+++ b/openviking_cli/utils/config/open_viking_config.py
@@ -31,7 +31,6 @@
AudioConfig,
CodeConfig,
DirectoryConfig,
- ExcelConfig,
FeishuConfig,
HTMLConfig,
ImageConfig,
@@ -205,14 +204,6 @@ class OpenVikingConfig(BaseModel):
default_factory=MarkdownConfig, description="Markdown parsing configuration"
)
- excel: ExcelConfig = Field(
- # from_dict on an empty mapping, not the bare constructor: an absent
- # parsers.excel section must record that no key was set, so sectioning
- # still follows parsers.markdown for deployments predating this section.
- default_factory=lambda: ExcelConfig.from_dict({}),
- description="Excel parsing configuration",
- )
-
html: HTMLConfig = Field(default_factory=HTMLConfig, description="HTML parsing configuration")
text: TextConfig = Field(default_factory=TextConfig, description="Text parsing configuration")
@@ -401,7 +392,6 @@ def from_dict(cls, config: Dict[str, Any]) -> "OpenVikingConfig":
"audio",
"video",
"markdown",
- "excel",
"html",
"text",
"directory",
diff --git a/openviking_cli/utils/config/parser_config.py b/openviking_cli/utils/config/parser_config.py
index 83fd271c20..74f2abf41d 100644
--- a/openviking_cli/utils/config/parser_config.py
+++ b/openviking_cli/utils/config/parser_config.py
@@ -8,9 +8,9 @@
and can be loaded from ov.conf files.
"""
-from dataclasses import dataclass, replace
+from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, Iterable, Optional, Union
+from typing import Any, Dict, Optional, Union
from openviking_cli.utils.logger import get_logger
@@ -137,34 +137,7 @@ def to_dict(self) -> Dict[str, Any]:
@dataclass
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
- """
-
- 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)
- max_heading_levels: int = 4 # Maximum heading levels for font analysis
+ """Configuration for local PDF parsing."""
# Image extraction configuration
image_resolution: int = 300 # Rendering DPI for extracted image regions
@@ -179,24 +152,8 @@ def validate(self) -> None:
# Validate base class fields
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}")
-
- if self.font_heading_min_delta <= 0:
- raise ValueError("font_heading_min_delta must be positive")
+ if self.image_resolution <= 0:
+ raise ValueError("image_resolution must be positive")
@dataclass
@@ -467,95 +424,6 @@ def validate(self) -> None:
raise ValueError("max_heading_depth must be at least 1")
-@dataclass
-class ExcelConfig(ParserConfig):
- """
- Configuration for Excel parsing.
-
- Attributes:
- enable_process_pool: Offload Excel→Markdown conversion and layout
- planning to a ProcessPoolExecutor (default off).
- process_pool_workers: Max worker processes when the pool is enabled.
- """
-
- enable_process_pool: bool = False
- process_pool_workers: int = 2
-
- # Excel is converted to Markdown and then sectioned by MarkdownParser, so
- # these fields decide the resulting node structure and stable URIs.
- _SECTIONING_FIELDS = (
- "max_content_length",
- "encoding",
- "max_section_size",
- "section_size_flexibility",
- "max_section_chars",
- )
-
- # Names of keys a config source actually provided. Tracked as a plain
- # instance attribute rather than a dataclass field so it never appears in
- # asdict/model_dump output, cannot be injected from a config file, and does
- # not affect equality. Absent means "provenance unknown".
- _EXPLICIT_ATTR = "_openviking_explicit_keys"
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "ExcelConfig":
- """Build the config while remembering which keys were actually present.
-
- ``with_sectioning_defaults_from`` needs to tell "the user wrote this
- value" from "the key was absent". Comparing against class defaults
- cannot do that, so record the provided keys here instead.
- """
- config = super().from_dict(data)
- return config.with_explicit_keys(data)
-
- def with_explicit_keys(self, names: Iterable[str]) -> "ExcelConfig":
- """Return this config marked as having ``names`` explicitly configured."""
- object.__setattr__(self, self._EXPLICIT_ATTR, frozenset(names))
- return self
-
- @property
- def explicit_keys(self) -> Optional[frozenset]:
- """Keys a config source provided, or ``None`` when unknown."""
- return getattr(self, self._EXPLICIT_ATTR, None)
-
- def with_sectioning_defaults_from(self, markdown: "ParserConfig") -> "ExcelConfig":
- """Inherit sectioning fields that ``parsers.excel`` did not set.
-
- Excel used to be registered with ``config.markdown`` directly, so a
- deployment that tuned ``parsers.markdown`` also tuned Excel imports.
- Introducing a dedicated ``parsers.excel`` section must not silently
- change that node structure, so a sectioning field absent from
- ``parsers.excel`` keeps following Markdown. Explicit ``parsers.excel``
- values always win, including one that happens to equal the class
- default.
-
- Configs built without ``from_dict`` carry no key information; those are
- treated as fully explicit so a hand-constructed ``ExcelConfig`` is never
- silently rewritten.
- """
- if markdown is None:
- return self
-
- explicit = self.explicit_keys
- if explicit is None:
- return self
-
- overrides = {
- name: getattr(markdown, name)
- for name in self._SECTIONING_FIELDS
- if hasattr(markdown, name) and name not in explicit
- }
- if not overrides:
- return self
- return replace(self, **overrides).with_explicit_keys(explicit)
-
- def validate(self) -> None:
- """Validate Excel-specific configuration."""
- super().validate()
- if self.process_pool_workers < 1:
- raise ValueError("process_pool_workers must be at least 1")
-
-
@dataclass
class HTMLConfig(ParserConfig):
"""
@@ -782,7 +650,6 @@ def __post_init__(self):
"audio": AudioConfig,
"video": VideoConfig,
"markdown": MarkdownConfig,
- "excel": ExcelConfig,
"html": HTMLConfig,
"text": TextConfig,
"directory": DirectoryConfig,
@@ -840,7 +707,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": {"image_resolution": 300},
... "code": {"github_raw_domain": "raw.githubusercontent.com"}
... })
>>> pdf_config = configs["pdf"]
diff --git a/pyproject.toml b/pyproject.toml
index defbab250d..c8a9a35bc0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,8 @@ dependencies = [
"typing-extensions>=4.5.0",
"pyyaml>=6.0",
"httpx>=0.25.0",
+ "firecrawl-anydoc>=0.1.8,<0.2",
+ "pdf-inspector>=1.14.1,<2",
"pdfplumber>=0.10.0",
"scrapy>=2.11.0",
"trafilatura>=1.12.0",
@@ -43,12 +45,6 @@ dependencies = [
"openai>=1.0.0",
"requests>=2.33.0",
"charset-normalizer>=3.4,<4",
- "python-docx>=1.0.0",
- "olefile>=0.47",
- "xlrd>=2.0.1",
- "python-pptx>=1.0.0",
- "openpyxl>=3.0.0",
- "ebooklib>=0.18.0",
"json-repair>=0.25.0",
"apscheduler>=3.11.0",
"volcengine>=1.0.216",
@@ -60,7 +56,6 @@ dependencies = [
"tabulate>=0.9.0",
"urllib3>=2.7.0",
"protobuf>=6.33.5",
- "pdfminer-six>=20251230",
"typer>=0.12.0",
"litellm>=1.83.7,<1.91.2",
"python-multipart>=0.0.31",
@@ -104,6 +99,10 @@ test = [
"pandas>=2.0.0",
"diff-match-patch>=20200713",
"hvac>=2.0.0",
+ "python-docx>=1.0.0",
+ "python-pptx>=1.0.0",
+ "openpyxl>=3.0.0",
+ "ebooklib>=0.18.0",
]
auth = [
"python-jose[cryptography]>=3.3.0",
diff --git a/tests/parse/test_add_directory.py b/tests/parse/test_add_directory.py
index 70269b62d3..7482baccc5 100644
--- a/tests/parse/test_add_directory.py
+++ b/tests/parse/test_add_directory.py
@@ -430,143 +430,39 @@ async def test_txt_file_goes_through_parser(self, tmp_path: Path, parser, fake_f
assert len(fake_fs.files) > 0
@pytest.mark.asyncio
- async def test_docx_file_goes_through_parser(self, tmp_path: Path, parser, fake_fs) -> None:
- """Word (.docx) files should be processed by WordParser.parse()."""
- (tmp_path / "report.docx").write_bytes(b"PK\x03\x04")
-
- mock_temp = fake_fs.create_temp_uri()
- doc_dir = f"{mock_temp}/report"
- await fake_fs.mkdir(mock_temp)
- await fake_fs.mkdir(doc_dir)
- await fake_fs.write_file(f"{doc_dir}/report.md", "# Converted Word")
-
- fake_result = create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(tmp_path / "report.docx"),
- source_format="docx",
- parser_name="WordParser",
- parse_time=0.1,
- )
- fake_result.temp_dir_path = mock_temp
-
- with patch(
- "openviking.parse.parsers.directory.DirectoryParser._assign_parser",
- ) as mock_assign:
- from openviking.parse.parsers.word import WordParser as _Word
-
- mock_word = AsyncMock(spec=_Word)
- mock_word.parse = AsyncMock(return_value=fake_result)
-
- def assign_side_effect(cf, registry):
- if cf.path.suffix == ".docx":
- return mock_word
- return registry.get_parser_for_file(cf.path)
-
- mock_assign.side_effect = assign_side_effect
- await parser.parse(str(tmp_path))
-
- dir_name = tmp_path.name
- found_md = any(
- uri.endswith("report.md") and f"/{dir_name}/" in uri for uri in fake_fs.files
- )
- assert found_md, f"report.md not found. Files: {list(fake_fs.files.keys())}"
-
- @pytest.mark.asyncio
- async def test_xlsx_file_goes_through_parser(self, tmp_path: Path, parser, fake_fs) -> None:
- """Excel (.xlsx) files should be processed by ExcelParser.parse()."""
- (tmp_path / "data.xlsx").write_bytes(b"PK\x03\x04")
-
- mock_temp = fake_fs.create_temp_uri()
- doc_dir = f"{mock_temp}/data"
- await fake_fs.mkdir(mock_temp)
- await fake_fs.mkdir(doc_dir)
- await fake_fs.write_file(f"{doc_dir}/data.md", "# Converted Excel")
-
- fake_result = create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(tmp_path / "data.xlsx"),
- source_format="xlsx",
- parser_name="ExcelParser",
- parse_time=0.1,
- )
- fake_result.temp_dir_path = mock_temp
-
- with patch(
- "openviking.parse.parsers.directory.DirectoryParser._assign_parser",
- ) as mock_assign:
- from openviking.parse.parsers.excel import ExcelParser as _Excel
-
- mock_excel = AsyncMock(spec=_Excel)
- mock_excel.parse = AsyncMock(return_value=fake_result)
-
- def assign_side_effect(cf, registry):
- if cf.path.suffix in {".xlsx", ".xls", ".xlsm"}:
- return mock_excel
- return registry.get_parser_for_file(cf.path)
-
- mock_assign.side_effect = assign_side_effect
- await parser.parse(str(tmp_path))
-
- dir_name = tmp_path.name
- found_md = any(uri.endswith("data.md") and f"/{dir_name}/" in uri for uri in fake_fs.files)
- assert found_md, f"data.md not found. Files: {list(fake_fs.files.keys())}"
-
- @pytest.mark.asyncio
- async def test_epub_file_goes_through_parser(self, tmp_path: Path, parser, fake_fs) -> None:
- """EPub (.epub) files should be processed by EPubParser.parse()."""
- (tmp_path / "book.epub").write_bytes(b"PK\x03\x04")
-
- mock_temp = fake_fs.create_temp_uri()
- doc_dir = f"{mock_temp}/book"
- await fake_fs.mkdir(mock_temp)
- await fake_fs.mkdir(doc_dir)
- await fake_fs.write_file(f"{doc_dir}/book.md", "# Converted EPub")
-
- fake_result = create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(tmp_path / "book.epub"),
- source_format="epub",
- parser_name="EPubParser",
- parse_time=0.1,
- )
- fake_result.temp_dir_path = mock_temp
-
- with patch(
- "openviking.parse.parsers.directory.DirectoryParser._assign_parser",
- ) as mock_assign:
- from openviking.parse.parsers.epub import EPubParser as _EPub
-
- mock_epub = AsyncMock(spec=_EPub)
- mock_epub.parse = AsyncMock(return_value=fake_result)
-
- def assign_side_effect(cf, registry):
- if cf.path.suffix == ".epub":
- return mock_epub
- return registry.get_parser_for_file(cf.path)
-
- mock_assign.side_effect = assign_side_effect
- await parser.parse(str(tmp_path))
-
- dir_name = tmp_path.name
- found_md = any(uri.endswith("book.md") and f"/{dir_name}/" in uri for uri in fake_fs.files)
- assert found_md, f"book.md not found. Files: {list(fake_fs.files.keys())}"
-
- @pytest.mark.asyncio
- async def test_pptx_file_goes_through_parser(self, tmp_path: Path, parser, fake_fs) -> None:
- """PowerPoint (.pptx) files should be processed by PowerPointParser.parse()."""
- (tmp_path / "slides.pptx").write_bytes(b"PK\x03\x04")
+ @pytest.mark.parametrize(
+ ("filename", "converted_content"),
+ [
+ ("report.docx", "# Converted Word"),
+ ("data.xlsx", "# Converted Excel"),
+ ("book.epub", "# Converted EPUB"),
+ ("slides.pptx", "# Converted PowerPoint"),
+ ],
+ )
+ async def test_anydoc_file_goes_through_parser(
+ self,
+ tmp_path: Path,
+ parser,
+ fake_fs,
+ filename: str,
+ converted_content: str,
+ ) -> None:
+ """Every AnyDoc-backed format uses the same directory import path."""
+ source = tmp_path / filename
+ source.write_bytes(b"PK\x03\x04")
+ stem = source.stem
mock_temp = fake_fs.create_temp_uri()
- doc_dir = f"{mock_temp}/slides"
+ doc_dir = f"{mock_temp}/{stem}"
await fake_fs.mkdir(mock_temp)
await fake_fs.mkdir(doc_dir)
- await fake_fs.write_file(f"{doc_dir}/slides.md", "# Converted PowerPoint")
+ await fake_fs.write_file(f"{doc_dir}/{stem}.md", converted_content)
fake_result = create_parse_result(
root=ResourceNode(type=NodeType.ROOT),
- source_path=str(tmp_path / "slides.pptx"),
- source_format="pptx",
- parser_name="PowerPointParser",
+ source_path=str(source),
+ source_format=source.suffix.lstrip("."),
+ parser_name="AnyDocParser",
parse_time=0.1,
)
fake_result.temp_dir_path = mock_temp
@@ -574,24 +470,24 @@ async def test_pptx_file_goes_through_parser(self, tmp_path: Path, parser, fake_
with patch(
"openviking.parse.parsers.directory.DirectoryParser._assign_parser",
) as mock_assign:
- from openviking.parse.parsers.powerpoint import PowerPointParser as _PPT
+ from openviking.parse.parsers.anydoc import AnyDocParser
- mock_ppt = AsyncMock(spec=_PPT)
- mock_ppt.parse = AsyncMock(return_value=fake_result)
+ mock_anydoc = AsyncMock(spec=AnyDocParser)
+ mock_anydoc.parse = AsyncMock(return_value=fake_result)
- def assign_side_effect(cf, registry):
- if cf.path.suffix == ".pptx":
- return mock_ppt
- return registry.get_parser_for_file(cf.path)
+ def assign_side_effect(candidate, registry):
+ if candidate.path == source:
+ return mock_anydoc
+ return registry.get_parser_for_file(candidate.path)
mock_assign.side_effect = assign_side_effect
await parser.parse(str(tmp_path))
- dir_name = tmp_path.name
found_md = any(
- uri.endswith("slides.md") and f"/{dir_name}/" in uri for uri in fake_fs.files
+ uri.endswith(f"/{stem}.md") and f"/{tmp_path.name}/" in uri
+ for uri in fake_fs.files
)
- assert found_md, f"slides.md not found. Files: {list(fake_fs.files.keys())}"
+ assert found_md, f"{stem}.md not found. Files: {list(fake_fs.files)}"
@pytest.mark.asyncio
async def test_zip_file_goes_through_parser(self, tmp_path: Path, parser, fake_fs) -> None:
diff --git a/tests/parse/test_directory_parser_routing.py b/tests/parse/test_directory_parser_routing.py
index ca75798cf6..262c546187 100644
--- a/tests/parse/test_directory_parser_routing.py
+++ b/tests/parse/test_directory_parser_routing.py
@@ -27,14 +27,11 @@
DirectoryScanResult,
scan_directory,
)
-from openviking.parse.parsers.epub import EPubParser
-from openviking.parse.parsers.excel import ExcelParser
+from openviking.parse.parsers.anydoc import AnyDocParser
from openviking.parse.parsers.html import HTMLParser
from openviking.parse.parsers.markdown import MarkdownParser
from openviking.parse.parsers.pdf import PDFParser
-from openviking.parse.parsers.powerpoint import PowerPointParser
from openviking.parse.parsers.text import TextParser
-from openviking.parse.parsers.word import WordParser
from openviking.parse.parsers.zip_parser import ZipParser
from openviking.parse.registry import ParserRegistry
@@ -72,13 +69,14 @@ def tmp_all_parsers(tmp_path: Path) -> Path:
notes.txt -> TextParser
log.text -> TextParser
office/
- report.docx -> WordParser
- data.xlsx -> ExcelParser
- legacy.xls -> ExcelParser
- macro.xlsm -> ExcelParser
- slides.pptx -> PowerPointParser
+ legacy.doc -> AnyDocParser
+ report.docx -> AnyDocParser
+ data.xlsx -> AnyDocParser
+ legacy.xls -> AnyDocParser
+ macro.xlsm -> AnyDocParser
+ slides.pptx -> AnyDocParser
books/
- book.epub -> EPubParser
+ book.epub -> AnyDocParser
archives/
bundle.zip -> ZipParser
code/
@@ -123,6 +121,7 @@ def tmp_all_parsers(tmp_path: Path) -> Path:
(tmp_path / "config" / "rules.toml").write_text("[section]", encoding="utf-8")
(tmp_path / "office").mkdir()
+ (tmp_path / "office" / "legacy.doc").write_bytes(b"\xd0\xcf\x11\xe0")
(tmp_path / "office" / "report.docx").write_bytes(b"PK\x03\x04")
(tmp_path / "office" / "data.xlsx").write_bytes(b"PK\x03\x04")
(tmp_path / "office" / "legacy.xls").write_bytes(b"\xd0\xcf\x11\xe0")
@@ -156,12 +155,13 @@ class TestParserSelection:
".pdf": PDFParser,
".txt": TextParser,
".text": TextParser,
- ".docx": WordParser,
- ".xlsx": ExcelParser,
- ".xls": ExcelParser,
- ".xlsm": ExcelParser,
- ".epub": EPubParser,
- ".pptx": PowerPointParser,
+ ".doc": AnyDocParser,
+ ".docx": AnyDocParser,
+ ".xlsx": AnyDocParser,
+ ".xls": AnyDocParser,
+ ".xlsm": AnyDocParser,
+ ".epub": AnyDocParser,
+ ".pptx": AnyDocParser,
".zip": ZipParser,
}
@@ -240,10 +240,18 @@ class TestParserCanParse:
(HTMLParser, ["page.html", "site.htm"]),
(PDFParser, ["paper.pdf"]),
(TextParser, ["notes.txt", "log.text"]),
- (WordParser, ["report.docx"]),
- (ExcelParser, ["data.xlsx", "legacy.xls", "book.xlsm"]),
- (EPubParser, ["book.epub"]),
- (PowerPointParser, ["slides.pptx"]),
+ (
+ AnyDocParser,
+ [
+ "legacy.doc",
+ "report.docx",
+ "data.xlsx",
+ "legacy.xls",
+ "book.xlsm",
+ "book.epub",
+ "slides.pptx",
+ ],
+ ),
(ZipParser, ["archive.zip"]),
],
)
@@ -261,10 +269,7 @@ def test_can_parse_returns_true(self, parser_cls: type, filenames: List[str]) ->
(HTMLParser, ["file.md", "file.pdf", "file.txt"]),
(PDFParser, ["file.md", "file.txt", "file.html"]),
(TextParser, ["file.md", "file.html", "file.pdf"]),
- (WordParser, ["file.pdf", "file.xlsx", "file.txt"]),
- (ExcelParser, ["file.docx", "file.pdf", "file.txt"]),
- (EPubParser, ["file.pdf", "file.docx", "file.zip"]),
- (PowerPointParser, ["file.pdf", "file.docx", "file.txt"]),
+ (AnyDocParser, ["file.pdf", "file.md", "file.zip"]),
(ZipParser, ["file.rar", "file.pdf", "file.docx"]),
],
)
diff --git a/tests/parse/test_document_parser_threading.py b/tests/parse/test_document_parser_threading.py
index 35e9165a05..825fbf2c6e 100644
--- a/tests/parse/test_document_parser_threading.py
+++ b/tests/parse/test_document_parser_threading.py
@@ -1,9 +1,7 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
-"""Regression tests for offloading synchronous document conversions."""
+"""Contract tests for the unified AnyDoc document parser."""
-import sys
-import zipfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
@@ -11,10 +9,43 @@
import pytest
from openviking.parse.base import NodeType, ResourceNode, create_parse_result
-from openviking.parse.parsers import epub, excel, legacy_doc, powerpoint, word
+from openviking.parse.parsers import anydoc
-def _stub_markdown_parse(parser) -> dict[str, Any]:
+class _FakeStorage:
+ def __init__(self, media_dir: Path) -> None:
+ self.media_dir = media_dir
+ self.saved: list[tuple[str, bytes, str, str]] = []
+
+ def save_image(
+ self,
+ resource_name: str,
+ data: bytes,
+ *,
+ filename: str,
+ extension: str,
+ ) -> Path:
+ self.saved.append((resource_name, data, filename, extension))
+ return self.media_dir / resource_name / "images" / f"{filename}{extension}"
+
+
+def _model(**values: Any) -> SimpleNamespace:
+ return SimpleNamespace(**values)
+
+
+def _text(value: str) -> SimpleNamespace:
+ return _model(kind="text", text=value, style=None)
+
+
+def _paragraph(*content: SimpleNamespace) -> SimpleNamespace:
+ return _model(kind="paragraph", content=list(content))
+
+
+def _document(*blocks: SimpleNamespace, assets=None, notes=None) -> SimpleNamespace:
+ return _model(blocks=list(blocks), assets=assets or [], notes=notes or [])
+
+
+def _stub_markdown_parse(parser: anydoc.AnyDocParser) -> dict[str, Any]:
seen: dict[str, Any] = {}
async def parse_content(
@@ -23,10 +54,12 @@ async def parse_content(
instruction: str = "",
**kwargs,
):
- seen["content"] = content
- seen["source_path"] = source_path
- seen["instruction"] = instruction
- seen["kwargs"] = kwargs
+ seen.update(
+ content=content,
+ source_path=source_path,
+ instruction=instruction,
+ kwargs=kwargs,
+ )
return create_parse_result(
root=ResourceNode(type=NodeType.ROOT),
source_path=source_path,
@@ -34,244 +67,164 @@ async def parse_content(
parser_name="MarkdownParser",
)
- parser._md_parser.parse_content = parse_content
+ parser._markdown_parser.parse_content = parse_content
return seen
-def _patch_to_thread(monkeypatch, module) -> list[tuple[Callable[..., Any], tuple, dict]]:
- calls: list[tuple[Callable[..., Any], tuple, dict]] = []
+@pytest.mark.asyncio
+async def test_anydoc_parser_offloads_conversion_and_forwards_markdown_options(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ parser = anydoc.AnyDocParser()
+ seen = _stub_markdown_parse(parser)
+ calls: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = []
async def fake_to_thread(func, /, *args, **kwargs):
calls.append((func, args, kwargs))
return func(*args, **kwargs)
- monkeypatch.setattr(module.asyncio, "to_thread", fake_to_thread)
- return calls
-
-
-@pytest.mark.asyncio
-async def test_word_parser_offloads_docx_conversion(monkeypatch, tmp_path: Path):
- parser = word.WordParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, word)
- fake_docx = SimpleNamespace()
- monkeypatch.setitem(sys.modules, "docx", fake_docx)
-
- def convert(path: Path, docx_module, resource_name=None, storage=None) -> str:
- assert docx_module is fake_docx
- return "# converted docx"
-
- monkeypatch.setattr(parser, "_convert_to_markdown", convert)
- source = tmp_path / "sample.docx"
- source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- # #2429 added resource_name/storage to the offloaded conversion call; assert
- # the conversion was offloaded with the doc + docx module, without pinning the
- # identity of the storage singleton.
- assert len(calls) == 1
- func, args, _ = calls[0]
- assert func is convert
- assert args[0] == source
- assert args[1] is fake_docx
- assert seen["content"] == "# converted docx"
- assert result.source_format == "docx"
- assert result.parser_name == "WordParser"
-
-
-@pytest.mark.asyncio
-async def test_word_parser_forwards_original_name_to_markdown(monkeypatch, tmp_path: Path):
- # On single-file upload the on-disk path is a temp name (upload_.docx)
- # and the user's original filename arrives via source_name. WordParser must
- # forward that name to MarkdownParser explicitly; otherwise parse_content can
- # only fall back to the temp path and the resource ends up named upload_.
- parser = word.WordParser()
- seen = _stub_markdown_parse(parser)
- _patch_to_thread(monkeypatch, word)
- monkeypatch.setitem(sys.modules, "docx", SimpleNamespace())
- monkeypatch.setattr(parser, "_convert_to_markdown", lambda *args, **kwargs: "# converted docx")
-
- upload = tmp_path / "upload_abc123.docx"
- upload.write_bytes(b"placeholder")
-
- await parser.parse(upload, source_name="季度报告.docx")
-
- forwarded = seen["kwargs"].get("source_name") or seen["kwargs"].get("resource_name")
- assert forwarded == "季度报告.docx", seen["kwargs"]
-
-
-@pytest.mark.asyncio
-async def test_word_parser_forwards_no_split_to_markdown(monkeypatch, tmp_path: Path):
- parser = word.WordParser()
- seen = _stub_markdown_parse(parser)
- _patch_to_thread(monkeypatch, word)
- monkeypatch.setitem(sys.modules, "docx", SimpleNamespace())
- monkeypatch.setattr(parser, "_convert_to_markdown", lambda *args, **kwargs: "# converted docx")
-
- upload = tmp_path / "screenplay.docx"
- upload.write_bytes(b"placeholder")
-
- await parser.parse(upload, split_content=False)
-
- assert seen["kwargs"]["split_content"] is False
-
-
-@pytest.mark.asyncio
-async def test_excel_parser_offloads_xlsx_conversion(monkeypatch, tmp_path: Path):
- parser = excel.ExcelParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, excel)
- fake_openpyxl = SimpleNamespace()
- monkeypatch.setitem(sys.modules, "openpyxl", fake_openpyxl)
-
- def convert(path: Path, openpyxl_module) -> str:
- assert openpyxl_module is fake_openpyxl
- return "# converted xlsx"
-
- monkeypatch.setattr(parser, "_convert_to_markdown", convert)
- source = tmp_path / "sample.xlsx"
- source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- assert calls == [(convert, (source, fake_openpyxl), {})]
- assert seen["content"] == "# converted xlsx"
- assert result.source_format == "xlsx"
- assert result.parser_name == "ExcelParser"
-
-
-@pytest.mark.asyncio
-async def test_excel_parser_offloads_xls_conversion(monkeypatch, tmp_path: Path):
- parser = excel.ExcelParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, excel)
-
- def convert(path: Path) -> str:
- return "# converted xls"
-
- monkeypatch.setattr(parser, "_convert_xls_to_markdown", convert)
- source = tmp_path / "sample.xls"
- source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- assert calls == [(convert, (source,), {})]
- assert seen["content"] == "# converted xls"
- assert result.source_format == "xls"
- assert result.parser_name == "ExcelParser"
-
-
-@pytest.mark.asyncio
-async def test_powerpoint_parser_offloads_pptx_conversion(monkeypatch, tmp_path: Path):
- parser = powerpoint.PowerPointParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, powerpoint)
- fake_pptx = SimpleNamespace()
- monkeypatch.setitem(sys.modules, "pptx", fake_pptx)
-
- def convert(path: Path, pptx_module) -> str:
- assert pptx_module is fake_pptx
- return "# converted pptx"
-
- monkeypatch.setattr(parser, "_convert_to_markdown", convert)
- source = tmp_path / "sample.pptx"
- source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- assert calls == [(convert, (source, fake_pptx), {})]
- assert seen["content"] == "# converted pptx"
- assert result.source_format == "pptx"
- assert result.parser_name == "PowerPointParser"
-
-
-@pytest.mark.asyncio
-async def test_epub_parser_offloads_epub_conversion(monkeypatch, tmp_path: Path):
- parser = epub.EPubParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, epub)
-
- def convert(path: Path) -> str:
- return "# converted epub"
-
- monkeypatch.setattr(parser, "_convert_to_markdown", convert)
- source = tmp_path / "sample.epub"
- source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- assert calls == [(convert, (source,), {})]
- assert seen["content"] == "# converted epub"
- assert result.source_format == "epub"
- assert result.parser_name == "EPubParser"
-
-
-@pytest.mark.asyncio
-async def test_legacy_doc_parser_offloads_doc_extraction(monkeypatch, tmp_path: Path):
- parser = legacy_doc.LegacyDocParser()
- seen = _stub_markdown_parse(parser)
- calls = _patch_to_thread(monkeypatch, legacy_doc)
-
- def extract(path: Path) -> str:
- return "# converted doc"
+ converted = anydoc._RenderedDocument(
+ markdown="# Converted\n",
+ detected_format="docx",
+ warnings=["conversion warning"],
+ assets_referenced=2,
+ images_extracted=1,
+ )
+ monkeypatch.setattr(anydoc.asyncio, "to_thread", fake_to_thread)
+ monkeypatch.setattr(parser, "_convert", lambda *args, **kwargs: converted)
+ monkeypatch.setattr(anydoc, "version", lambda _package: "0.1.8")
+ storage = _FakeStorage(tmp_path / "media")
+ monkeypatch.setattr("openviking_cli.utils.storage.get_storage", lambda: storage)
- monkeypatch.setattr(parser, "_extract_text", extract)
- source = tmp_path / "sample.doc"
+ source = tmp_path / "upload.docx"
source.write_bytes(b"placeholder")
-
- result = await parser.parse(source)
-
- assert calls == [(extract, (source,), {})]
- assert seen["content"] == "# converted doc"
- assert result.source_format == "doc"
- assert result.parser_name == "LegacyDocParser"
-
-
-@pytest.mark.asyncio
-async def test_legacy_doc_parser_routes_ooxml_payload_to_word_parser(monkeypatch, tmp_path: Path):
- parser = legacy_doc.LegacyDocParser()
- _stub_markdown_parse(parser)
- source = tmp_path / "mislabeled.doc"
- with zipfile.ZipFile(source, "w") as archive:
- archive.writestr("[Content_Types].xml", "")
- archive.writestr("word/document.xml", "")
-
- seen: dict[str, Any] = {}
-
- async def parse_word(self, source_path, instruction="", **kwargs):
- seen["source"] = source_path
- seen["instruction"] = instruction
- seen["kwargs"] = kwargs
- return create_parse_result(
- root=ResourceNode(type=NodeType.ROOT),
- source_path=str(source_path),
- source_format="docx",
- parser_name="WordParser",
- )
-
- monkeypatch.setattr(word.WordParser, "parse", parse_word)
-
+ caller_media = tmp_path / "caller-media"
result = await parser.parse(
source,
instruction="preserve tables",
- source_name="mislabeled.doc",
+ source_name="季度报告.docx",
+ allowed_media_dirs=[caller_media],
+ split_content=False,
)
+ assert len(calls) == 1
+ assert calls[0][0] is parser._convert
+ assert calls[0][1] == (source,)
+ assert calls[0][2] == {
+ "resource_name": "季度报告.docx",
+ "storage": storage,
+ }
assert seen == {
- "source": source,
+ "content": "# Converted\n",
+ "source_path": str(source),
"instruction": "preserve tables",
- "kwargs": {"source_name": "mislabeled.doc"},
+ "kwargs": {
+ "base_dir": tmp_path,
+ "allowed_media_dirs": [caller_media, storage.media_dir],
+ "source_name": "季度报告.docx",
+ "split_content": False,
+ },
}
assert result.source_format == "docx"
- assert result.parser_name == "WordParser"
+ assert result.parser_name == "AnyDocParser"
+ assert result.warnings == ["conversion warning"]
+ assert result.meta["images_extracted"] == 1
+
+
+def test_anydoc_renderer_preserves_image_position_and_saves_repeated_asset_once(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ storage = _FakeStorage(tmp_path / "media")
+ asset = _model(
+ id=7,
+ media_type="image/png",
+ origin_part="word/media/image.png",
+ data=b"valid-image",
+ )
+ embedded = _model(
+ kind="image",
+ alt="diagram",
+ source=_model(kind="asset", asset_id=7),
+ )
+ external = _model(
+ kind="image",
+ alt="remote",
+ source=_model(kind="external", url="https://example.com/image.png"),
+ )
+ document = _document(
+ _paragraph(_text("before "), embedded, _text(" middle "), embedded, external),
+ assets=[asset],
+ )
+ monkeypatch.setattr(anydoc, "is_valid_image", lambda *_args: True)
+ renderer = anydoc._AnyDocMarkdownRenderer(
+ document,
+ source_format="docx",
+ resource_name="report",
+ storage=storage,
+ )
+ markdown = renderer.render()
-@pytest.mark.asyncio
-async def test_legacy_doc_parser_rejects_non_word_zip_payload(tmp_path: Path):
- source = tmp_path / "not-a-word-document.doc"
- with zipfile.ZipFile(source, "w") as archive:
- archive.writestr("payload.bin", b"binary")
+ image_ref = "report/images/anydoc_asset_7.png"
+ assert markdown == (
+ f"before  middle "
+ "\n"
+ )
+ assert storage.saved == [("report", b"valid-image", "anydoc_asset_7", ".png")]
+ assert renderer.images_extracted == 1
+ assert renderer.assets_referenced == {7}
+
+
+def test_anydoc_renderer_keeps_ppt_speaker_notes_in_document_order(tmp_path: Path) -> None:
+ heading = _model(kind="heading", level=1, content=[_text("Slide One")], anchor=None)
+ notes = _model(kind="block_quote", blocks=[_paragraph(_text("Presenter detail"))])
+ document = _document(heading, _paragraph(_text("Slide body")), notes)
+
+ markdown = anydoc._AnyDocMarkdownRenderer(
+ document,
+ source_format="pptx",
+ resource_name="slides",
+ storage=_FakeStorage(tmp_path / "media"),
+ ).render()
+
+ assert markdown == ("# Slide One\n\nSlide body\n\n### Speaker Notes\n\nPresenter detail\n")
+
+
+def test_anydoc_renderer_warns_and_keeps_alt_for_unusable_assets(tmp_path: Path) -> None:
+ document = _document(
+ _paragraph(
+ _model(
+ kind="image",
+ alt="attachment",
+ source=_model(kind="asset", asset_id=3),
+ ),
+ _text(" "),
+ _model(
+ kind="image",
+ alt="missing preview",
+ source=_model(kind="unavailable"),
+ ),
+ ),
+ assets=[
+ _model(
+ id=3,
+ media_type="application/octet-stream",
+ origin_part="embedding.bin",
+ data=b"not-an-image",
+ )
+ ],
+ )
+ renderer = anydoc._AnyDocMarkdownRenderer(
+ document,
+ source_format="xlsx",
+ resource_name="book",
+ storage=_FakeStorage(tmp_path / "media"),
+ )
- with pytest.raises(ValueError, match="ZIP package"):
- await legacy_doc.LegacyDocParser().parse(source)
+ assert renderer.render() == "attachment missing preview\n"
+ assert renderer.warnings == [
+ "AnyDoc asset 3 is not an image (application/octet-stream); kept as alt text",
+ "Embedded image is unavailable: missing preview",
+ ]
diff --git a/tests/parse/test_excel_process_pool.py b/tests/parse/test_excel_process_pool.py
deleted file mode 100644
index 2f376a7d54..0000000000
--- a/tests/parse/test_excel_process_pool.py
+++ /dev/null
@@ -1,290 +0,0 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
-# SPDX-License-Identifier: AGPL-3.0
-"""Tests for ExcelParser._should_use_process_pool, the config-gated routing decision
-that decides whether Excel→Markdown conversion + layout planning run in a
-ProcessPoolExecutor child process instead of the main process's event loop.
-
-A real ProcessPoolExecutor is not exercised here: spawning a child process per
-test is slow and orthogonal to this decision, and the worker itself
-(_build_excel_layout_in_process) is a thin, side-effect-free function that only
-touches its own arguments. This covers the routing decision that gates it.
-"""
-
-from pathlib import Path
-
-import pytest
-
-from openviking.parse.parsers import excel as excel_module
-from openviking.parse.parsers.excel import (
- ExcelParser,
- _EXCEL_PROCESS_POOL_MIN_BYTES,
-)
-from openviking_cli.utils.config.parser_config import (
- ExcelConfig,
- MarkdownConfig,
- ParserConfig,
-)
-
-
-class TestShouldUseProcessPool:
- def _parser(self, **excel_kwargs) -> ExcelParser:
- return ExcelParser(config=ExcelConfig(**excel_kwargs))
-
- def _make_file(self, tmp_path: Path, suffix: str = ".xlsx", size: int = 10) -> Path:
- path = tmp_path / f"sheet{suffix}"
- path.write_bytes(b"x" * size)
- return path
-
- def test_disabled_by_default(self, tmp_path: Path):
- path = self._make_file(tmp_path, size=_EXCEL_PROCESS_POOL_MIN_BYTES)
- assert self._parser()._should_use_process_pool(path, {}) is False
-
- def test_xls_never_uses_process_pool(self, tmp_path: Path):
- # Legacy .xls goes through xlrd, not the openpyxl path the worker assumes.
- path = self._make_file(tmp_path, suffix=".xls", size=_EXCEL_PROCESS_POOL_MIN_BYTES)
- assert (
- self._parser(enable_process_pool=True)._should_use_process_pool(path, {})
- is False
- )
-
- @pytest.mark.parametrize(
- "kwargs",
- [
- {"enable_link_rewrite": True},
- {"base_dir": Path(".")},
- {"allowed_media_dirs": [Path(".")]},
- ],
- )
- def test_link_or_media_rewrite_kwargs_disable_process_pool(self, tmp_path, kwargs):
- # The worker never touches VikingFS/base_dir-relative media, so any parse
- # that needs link/media rewriting must stay in-process.
- path = self._make_file(tmp_path, size=_EXCEL_PROCESS_POOL_MIN_BYTES)
- assert (
- self._parser(enable_process_pool=True)._should_use_process_pool(path, kwargs)
- is False
- )
-
- def test_below_min_bytes_returns_false(self, tmp_path):
- path = self._make_file(tmp_path, size=_EXCEL_PROCESS_POOL_MIN_BYTES - 1)
- assert (
- self._parser(enable_process_pool=True)._should_use_process_pool(path, {})
- is False
- )
-
- def test_at_or_above_min_bytes_returns_true(self, tmp_path):
- path = self._make_file(tmp_path, size=_EXCEL_PROCESS_POOL_MIN_BYTES)
- assert (
- self._parser(enable_process_pool=True)._should_use_process_pool(path, {})
- is True
- )
-
-
-@pytest.mark.asyncio
-async def test_process_pool_forwards_no_split_layout_flag(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-):
- path = tmp_path / "sheet.xlsx"
- path.write_bytes(b"xlsx")
- parser = ExcelParser(config=ExcelConfig(enable_process_pool=True))
- monkeypatch.setattr(
- parser._md_parser,
- "_create_temp_uri",
- lambda: "viking://temp/test/excel",
- )
- captured = {}
-
- class _CapturingLoop:
- def run_in_executor(self, _executor, callback):
- captured["callback"] = callback
- raise RuntimeError("layout callback captured")
-
- monkeypatch.setattr(excel_module.asyncio, "get_running_loop", lambda: _CapturingLoop())
- monkeypatch.setattr(excel_module, "_get_excel_layout_executor", lambda _workers: object())
-
- with pytest.raises(RuntimeError, match="layout callback captured"):
- await parser._parse_existing_path_process_pool(path, split_content=False)
-
- callback = captured["callback"]
- assert callback.keywords["layout_kwargs"]["split_content"] is False
-
-
-class TestExcelConfig:
- def test_defaults(self):
- cfg = ExcelConfig()
- assert cfg.enable_process_pool is False
- assert cfg.process_pool_workers == 2
-
- def test_validate_rejects_zero_workers(self):
- cfg = ExcelConfig(process_pool_workers=0)
- with pytest.raises(ValueError, match="process_pool_workers"):
- cfg.validate()
-
- def test_openviking_config_accepts_excel_section(self):
- from openviking_cli.utils.config.open_viking_config import OpenVikingConfig
-
- cfg = OpenVikingConfig.from_dict(
- {
- "excel": {
- "enable_process_pool": True,
- "process_pool_workers": 8,
- }
- }
- )
- assert cfg.excel.enable_process_pool is True
- assert cfg.excel.process_pool_workers == 8
-
-
-class TestExcelSectioningInheritance:
- """Excel used to be registered with ``config.markdown``.
-
- A dedicated ``parsers.excel`` section must not silently change sectioning,
- because section boundaries decide node structure and stable Viking URIs.
- """
-
- def test_unset_sectioning_follows_markdown(self):
- markdown = MarkdownConfig(max_section_size=512, max_section_chars=2000)
- resolved = ExcelConfig.from_dict({}).with_sectioning_defaults_from(markdown)
- assert resolved.max_section_size == 512
- assert resolved.max_section_chars == 2000
-
- def test_explicit_excel_values_win(self):
- markdown = MarkdownConfig(max_section_size=512, max_section_chars=2000)
- resolved = ExcelConfig.from_dict(
- {"max_section_size": 1024}
- ).with_sectioning_defaults_from(markdown)
- assert resolved.max_section_size == 1024
- # Absent from parsers.excel, so this one keeps following Markdown.
- assert resolved.max_section_chars == 2000
-
- def test_explicit_value_equal_to_default_is_not_overwritten(self):
- """"Excel keeps 2048" is a legitimate config even though 2048 is the default.
-
- Comparing against class defaults cannot distinguish it from an absent
- key, which would make the documented "explicit values win" contract
- false for exactly this case.
- """
- markdown = MarkdownConfig(max_section_size=512)
- resolved = ExcelConfig.from_dict(
- {"max_section_size": 2048}
- ).with_sectioning_defaults_from(markdown)
- assert resolved.max_section_size == 2048
-
- def test_process_pool_knobs_are_never_inherited(self):
- markdown = MarkdownConfig(max_section_size=512)
- resolved = ExcelConfig.from_dict(
- {"enable_process_pool": True, "process_pool_workers": 8}
- ).with_sectioning_defaults_from(markdown)
- assert resolved.enable_process_pool is True
- assert resolved.process_pool_workers == 8
- assert resolved.max_section_size == 512
-
- def test_default_markdown_leaves_excel_unchanged(self):
- resolved = ExcelConfig.from_dict({}).with_sectioning_defaults_from(MarkdownConfig())
- assert resolved == ExcelConfig()
-
- def test_missing_markdown_config_is_tolerated(self):
- assert ExcelConfig.from_dict({}).with_sectioning_defaults_from(None) == ExcelConfig()
-
- def test_hand_built_config_is_treated_as_fully_explicit(self):
- """A config built without from_dict carries no key provenance."""
- markdown = MarkdownConfig(max_section_size=512)
- resolved = ExcelConfig(max_section_size=2048).with_sectioning_defaults_from(markdown)
- assert resolved.max_section_size == 2048
-
- def test_provenance_does_not_affect_equality(self):
- assert ExcelConfig.from_dict({}) == ExcelConfig()
- assert ExcelConfig.from_dict({"max_section_size": 2048}) == ExcelConfig()
-
- def test_absent_excel_section_still_inherits_through_full_config(self):
- """Deployments predating parsers.excel must keep their Excel sectioning."""
- from openviking_cli.utils.config.open_viking_config import OpenVikingConfig
-
- cfg = OpenVikingConfig.from_dict({"markdown": {"max_section_size": 512}})
- resolved = cfg.excel.with_sectioning_defaults_from(cfg.markdown)
- assert resolved.max_section_size == 512
-
- def test_key_provenance_does_not_leak_into_serialization(self):
- """Provenance is bookkeeping, not config: it must not reach any output.
-
- A dataclass field would appear in asdict/model_dump and, being a
- frozenset, would break JSON serialization for callers.
- """
- import dataclasses
- import json
-
- cfg = ExcelConfig.from_dict({"max_section_size": 2048})
- dumped = dataclasses.asdict(cfg)
- assert "_explicit_fields" not in dumped
- assert not any(key.startswith("_") for key in dumped), sorted(dumped)
- json.dumps(dumped)
-
- def test_config_data_cannot_forge_key_provenance(self):
- """Provenance must not be settable from a config file."""
- with pytest.raises(ValueError):
- ExcelConfig.from_dict({"_explicit_fields": ["max_section_size"]})
-
- def test_replace_and_copy_preserve_key_provenance(self):
- import copy
- import dataclasses
-
- markdown = MarkdownConfig(max_section_size=512)
- cfg = ExcelConfig.from_dict({"max_section_size": 2048})
-
- for variant in (
- dataclasses.replace(cfg, enable_process_pool=True),
- copy.copy(cfg),
- copy.deepcopy(cfg),
- ):
- resolved = variant.with_sectioning_defaults_from(markdown)
- assert resolved.max_section_size == 2048
-
- def test_absent_excel_section_inherits_through_parser_loader(self):
- """load_parser_configs_from_dict is a second config entry point."""
- from openviking_cli.utils.config.parser_config import (
- load_parser_configs_from_dict,
- )
-
- configs = load_parser_configs_from_dict({"markdown": {"max_section_size": 512}})
- resolved = configs["excel"].with_sectioning_defaults_from(configs["markdown"])
- assert resolved.max_section_size == 512
-
- def test_explicit_excel_section_wins_through_full_config(self):
- from openviking_cli.utils.config.open_viking_config import OpenVikingConfig
-
- cfg = OpenVikingConfig.from_dict(
- {
- "markdown": {"max_section_size": 512},
- "excel": {"max_section_size": 2048, "enable_process_pool": True},
- }
- )
- resolved = cfg.excel.with_sectioning_defaults_from(cfg.markdown)
- assert resolved.max_section_size == 2048
- assert resolved.enable_process_pool is True
-
- def test_registry_resolves_excel_against_markdown(self, monkeypatch):
- from types import SimpleNamespace
-
- from openviking.parse import registry as registry_module
-
- config = SimpleNamespace(
- text=ParserConfig(),
- markdown=MarkdownConfig(max_section_size=512, max_section_chars=2000),
- pdf=ParserConfig(),
- html=ParserConfig(),
- excel=ExcelConfig.from_dict({"enable_process_pool": True}),
- image=ParserConfig(),
- )
- monkeypatch.setattr(
- "openviking_cli.utils.config.get_openviking_config",
- lambda: config,
- )
- monkeypatch.setattr(registry_module, "_default_registry", None)
-
- excel_parser = registry_module.get_registry().get_parser_for_file("book.xlsx")
-
- assert excel_parser.config.enable_process_pool is True
- assert excel_parser.config.max_section_size == 512
- # The inner MarkdownParser is what actually sections the converted sheet.
- assert excel_parser._md_parser.config.max_section_size == 512
- assert excel_parser._md_parser.config.max_section_chars == 2000
diff --git a/tests/parse/test_markdown_no_split.py b/tests/parse/test_markdown_no_split.py
index 62f5d18a6f..287dd295a3 100644
--- a/tests/parse/test_markdown_no_split.py
+++ b/tests/parse/test_markdown_no_split.py
@@ -103,14 +103,14 @@ async def test_pdf_no_split_converts_to_one_complete_markdown(
content = _long_markdown(with_headings=True)
fake_fs = _FakeVikingFS()
parser = PDFParser(
- PDFConfig(strategy="local", max_section_size=32, max_section_chars=128)
+ 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(
parser,
"_convert_to_markdown",
- AsyncMock(return_value=(content, {})),
+ AsyncMock(return_value=(content, {}, [])),
)
monkeypatch.setattr(
"openviking_cli.utils.storage.get_storage",
diff --git a/tests/parse/test_parser_config_wiring.py b/tests/parse/test_parser_config_wiring.py
index aaae5cc4c5..fe199a89a2 100644
--- a/tests/parse/test_parser_config_wiring.py
+++ b/tests/parse/test_parser_config_wiring.py
@@ -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()
diff --git a/tests/parse/test_pdf_bookmark_extraction.py b/tests/parse/test_pdf_bookmark_extraction.py
index 441540e516..da05f50961 100644
--- a/tests/parse/test_pdf_bookmark_extraction.py
+++ b/tests/parse/test_pdf_bookmark_extraction.py
@@ -1,535 +1,193 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
-"""
-Tests for PDF bookmark/outline extraction in PDFParser.
+"""Contract tests for pdf-inspector text and pdfplumber image extraction."""
-Verifies that _extract_bookmarks correctly extracts bookmark entries
-and that _convert_local injects them as markdown headings.
-"""
-
-from contextlib import nullcontext
+import sys
from pathlib import Path
from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
+from typing import Any
import pytest
-from openviking.parse.parsers.pdf import PDFParser
-
-
-def _make_page(*, pageid=None, objid=None):
- """Create a minimal page stub for bookmark extraction tests."""
- return SimpleNamespace(page_obj=SimpleNamespace(pageid=pageid, objid=objid))
-
-
-def _make_ref(objid):
- """Create a minimal PDF object reference stub."""
- return SimpleNamespace(objid=objid)
-
-
-class _FakePDFStream(dict):
- """Minimal pdfminer PDFStream-like object."""
-
- def __init__(self, data: bytes, subtype: str = "Image"):
- super().__init__({"Subtype": SimpleNamespace(name=subtype)})
- self._data = data
-
- def get_data(self):
- return self._data
-
-
-class _FakePDFObjectRef:
- """Minimal PDF object reference with a resolve() method."""
-
- def __init__(self, stream):
- self._stream = stream
-
- def resolve(self):
- return self._stream
-
-
-def _make_image_page(xobjects):
- return SimpleNamespace(page_obj=SimpleNamespace(resources={"XObject": xobjects}))
-
-
-class _FakeRenderedImage:
- """Minimal rendered image stub that writes deterministic PNG bytes."""
-
- def __init__(self, data: bytes):
- self._data = data
-
- def save(self, buffer, format: str):
- assert format == "PNG"
- buffer.write(self._data)
-
-
-class _FakeCroppedPage:
- """Minimal cropped page stub for image extraction tests."""
-
- def __init__(self, rendered_image: _FakeRenderedImage):
- self._rendered_image = rendered_image
- self.resolution_calls = []
-
- def to_image(self, resolution: int):
- self.resolution_calls.append(resolution)
- return self._rendered_image
-
-
-class _FakeImagePage:
- """Minimal pdfplumber page stub for _extract_image_from_page tests."""
-
- def __init__(self, *, width: int = 100, height: int = 200, image_bytes: bytes = b"png-bytes"):
- self.width = width
- self.height = height
- self.crop_calls = []
- self.cropped_page = _FakeCroppedPage(_FakeRenderedImage(image_bytes))
-
- def crop(self, bbox):
- self.crop_calls.append(bbox)
- return self.cropped_page
-
-
-class _FakePage:
- """Minimal pdfplumber page stub for _convert_local tests."""
-
- def __init__(self, text: str):
- self._text = text
- self.images = []
- self.close_count = 0
- self.flush_count = 0
-
- def extract_text(self):
- return self._text
-
- def extract_tables(self):
- return []
-
- def flush_cache(self):
- self.flush_count += 1
-
- def close(self):
- self.close_count += 1
- self.flush_cache()
-
-
-class _FakeFontPage(_FakePage):
- """Minimal page stub with character layout data for font heading detection."""
-
- def __init__(self, *, page_number: int, chars: list[dict], height: int = 1000):
- super().__init__("")
- self.page_number = page_number
- self.chars = chars
- self.height = height
-
-
-class TestExtractBookmarks:
- """Test PDF bookmark extraction logic."""
-
- def setup_method(self):
- self.parser = PDFParser()
-
- def test_extract_bookmarks_with_outlines(self):
- """Bookmarks are extracted from PDF outlines with correct levels and page mapping."""
- # Mock pdfplumber PDF object
- mock_pdf = MagicMock()
-
- # Real pdfminer outlines point at page.page_obj.pageid
- mock_pdf.pages = [_make_page(pageid=100), _make_page(pageid=200)]
-
- # Mock page reference objects for bookmark destinations
- mock_ref1 = _make_ref(100) # Points to page 1
- mock_ref2 = _make_ref(200) # Points to page 2
-
- # Mock document outlines: (level, title, dest, action, structelem)
- mock_pdf.doc.get_outlines.return_value = [
- (1, "Chapter 1", [mock_ref1, "/Fit"], None, None),
- (2, "Section 1.1", [mock_ref1, "/Fit"], None, None),
- (1, "Chapter 2", [mock_ref2, "/Fit"], None, None),
+from openviking.parse.parsers.pdf import (
+ PDFParser,
+ _PdfImageExtraction,
+ _PdfTextExtraction,
+)
+
+
+class _FakeStorage:
+ def __init__(self, media_dir: Path) -> None:
+ self.media_dir = media_dir
+ self.saved: list[tuple[str, bytes, str, str]] = []
+
+ def save_image(
+ self,
+ resource_name: str,
+ data: bytes,
+ *,
+ filename: str,
+ extension: str,
+ ) -> Path:
+ self.saved.append((resource_name, data, filename, extension))
+ return self.media_dir / resource_name / "images" / f"{filename}{extension}"
+
+
+def test_pdf_inspector_owns_page_markdown_and_reports_ocr_pages(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ extraction = SimpleNamespace(
+ pages=[
+ SimpleNamespace(page=0, markdown="# Introduction\n\nBody"),
+ SimpleNamespace(page=1, markdown=""),
+ ],
+ pages_needing_ocr=[2],
+ ocr_reasons_by_page=[SimpleNamespace(page=2, reasons=["no_text", "image_coverage"])],
+ pages_with_tables=[1],
+ pages_with_columns=[1],
+ is_complex=True,
+ )
+ fake_module = SimpleNamespace(
+ extract_pages_markdown=lambda path: extraction,
+ )
+ monkeypatch.setitem(sys.modules, "pdf_inspector", fake_module)
+
+ result = PDFParser._extract_text_sync(tmp_path / "paper.pdf")
+
+ assert result.pages == {1: "# Introduction\n\nBody", 2: ""}
+ assert result.warnings == ["PDF page 2 requires OCR: no_text, image_coverage"]
+ assert result.meta == {
+ "total_pages": 2,
+ "pages_processed": 1,
+ "pages_needing_ocr": [2],
+ "ocr_reasons_by_page": [{"page": 2, "reasons": ["no_text", "image_coverage"]}],
+ "pages_with_tables": [1],
+ "pages_with_columns": [1],
+ "is_complex_layout": True,
+ }
+
+
+@pytest.mark.asyncio
+async def test_pdf_markdown_keeps_page_order_and_places_images_after_text(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ parser = PDFParser()
+ text = _PdfTextExtraction(
+ pages={1: "# Page One", 2: ""},
+ warnings=["PDF page 2 requires OCR: no_text"],
+ meta={"total_pages": 2, "pages_needing_ocr": [2]},
+ )
+ images = _PdfImageExtraction(
+ pages={
+ 1: [""],
+ 2: [""],
+ },
+ warnings=[],
+ images_extracted=2,
+ images_deduplicated=0,
+ )
+ monkeypatch.setattr(parser, "_extract_text_sync", lambda _path: text)
+ monkeypatch.setattr(
+ parser,
+ "_extract_images_sync",
+ lambda _path, _storage, _name: images,
+ )
+
+ markdown, meta, warnings = await parser._convert_to_markdown(
+ tmp_path / "paper.pdf",
+ storage=_FakeStorage(tmp_path / "media"),
+ resource_name="doc",
+ )
+
+ assert markdown == (
+ "\n\n# Page One\n\n\n\n"
+ "\n\n"
+ )
+ assert warnings == ["PDF page 2 requires OCR: no_text"]
+ assert meta == {
+ "total_pages": 2,
+ "pages_needing_ocr": [2],
+ "library": "pdf-inspector",
+ "image_library": "pdfplumber",
+ "images_extracted": 2,
+ "images_deduplicated": 0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_pdf_image_failure_does_not_replace_text_result(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ parser = PDFParser()
+ monkeypatch.setattr(
+ parser,
+ "_extract_text_sync",
+ lambda _path: _PdfTextExtraction(
+ pages={1: "Text survives"}, warnings=[], meta={"total_pages": 1}
+ ),
+ )
+
+ def fail_images(*_args: Any) -> _PdfImageExtraction:
+ raise RuntimeError("renderer unavailable")
+
+ monkeypatch.setattr(parser, "_extract_images_sync", fail_images)
+
+ markdown, meta, warnings = await parser._convert_to_markdown(
+ tmp_path / "paper.pdf",
+ storage=_FakeStorage(tmp_path / "media"),
+ resource_name="doc",
+ )
+
+ assert markdown == "\n\nText survives"
+ assert meta["images_extracted"] == 0
+ assert warnings == ["PDF image extraction failed: renderer unavailable"]
+
+
+def test_pdfplumber_images_are_deduplicated_per_page_and_cache_is_released(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ class FakePage:
+ width = 100
+ height = 100
+ images = [
+ {"x0": 1, "top": 2, "x1": 30, "bottom": 40},
+ {"x0": 1, "top": 2, "x1": 30, "bottom": 40},
]
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
-
- assert len(bookmarks) == 3
- assert bookmarks[0] == {"title": "Chapter 1", "level": 1, "page_num": 1}
- assert bookmarks[1] == {"title": "Section 1.1", "level": 2, "page_num": 1}
- assert bookmarks[2] == {"title": "Chapter 2", "level": 1, "page_num": 2}
-
- def test_extract_bookmarks_falls_back_to_objid_mapping(self):
- """Objid-based mapping remains supported for tests and alternate backends."""
- mock_pdf = MagicMock()
- mock_pdf.pages = [_make_page(objid=100), _make_page(objid=200)]
-
- mock_pdf.doc.get_outlines.return_value = [
- (1, "Chapter 1", [_make_ref(100), "/Fit"], None, None),
- (1, "Chapter 2", [_make_ref(200), "/Fit"], None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert [b["page_num"] for b in bookmarks] == [1, 2]
-
- def test_extract_bookmarks_no_outlines(self):
- """Returns empty list when PDF has no outlines."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- mock_pdf.doc.get_outlines.return_value = []
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert bookmarks == []
-
- def test_extract_bookmarks_no_get_outlines(self):
- """Returns empty list when document has no get_outlines method."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- del mock_pdf.doc.get_outlines # Remove the method
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert bookmarks == []
-
- def test_extract_bookmarks_skips_empty_titles(self):
- """Bookmarks with empty or whitespace-only titles are skipped."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- mock_pdf.doc.get_outlines.return_value = [
- (1, "", None, None, None),
- (1, " ", None, None, None),
- (1, "Valid Title", None, None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert len(bookmarks) == 1
- assert bookmarks[0]["title"] == "Valid Title"
-
- def test_extract_bookmarks_caps_level_at_6(self):
- """Heading levels are capped at 6 for markdown compatibility."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- mock_pdf.doc.get_outlines.return_value = [
- (10, "Deep Heading", None, None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert bookmarks[0]["level"] == 6
-
- def test_extract_bookmarks_unresolved_pages(self):
- """Bookmarks with unresolvable destinations get page_num=None."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- mock_pdf.doc.get_outlines.return_value = [
- (1, "No Destination", None, None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert len(bookmarks) == 1
- assert bookmarks[0]["page_num"] is None
-
- def test_extract_bookmarks_integer_page_index(self):
- """Bookmarks with integer destination (0-based) are resolved correctly."""
- mock_pdf = MagicMock()
- mock_pdf.pages = [_make_page(pageid=100), _make_page(pageid=200)]
-
- # Integer page indices instead of object references
- mock_pdf.doc.get_outlines.return_value = [
- (1, "Chapter 1", [0, "/Fit"], None, None),
- (1, "Chapter 2", [1, "/Fit"], None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert len(bookmarks) == 2
- assert bookmarks[0]["page_num"] == 1
- assert bookmarks[0]["title"] == "Chapter 1"
- assert bookmarks[1]["page_num"] == 2
- assert bookmarks[1]["title"] == "Chapter 2"
-
- def test_extract_bookmarks_integer_page_index_out_of_range(self):
- """Out-of-range integer page indices are treated as unresolved."""
- mock_pdf = MagicMock()
- mock_pdf.pages = [_make_page(pageid=100)] # Only 1 page
-
- mock_pdf.doc.get_outlines.return_value = [
- (1, "Valid", [0, "/Fit"], None, None),
- (1, "Too High", [5, "/Fit"], None, None),
- (1, "Negative", [-1, "/Fit"], None, None),
- ]
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert len(bookmarks) == 3
- assert bookmarks[0]["page_num"] == 1
- assert bookmarks[1]["page_num"] is None
- assert bookmarks[2]["page_num"] is None
-
- def test_extract_bookmarks_exception_returns_empty(self):
- """Returns empty list on unexpected exceptions (best-effort)."""
- mock_pdf = MagicMock()
- mock_pdf.pages = []
- mock_pdf.doc.get_outlines.side_effect = RuntimeError("Corrupt PDF")
-
- bookmarks = self.parser._extract_bookmarks(mock_pdf)
- assert bookmarks == []
-
-
-class TestConvertLocalBookmarks:
- """Test bookmark injection behavior in local PDF conversion."""
-
- @pytest.mark.asyncio
- async def test_convert_local_skips_unresolved_bookmarks(self):
- parser = PDFParser()
- fake_pdf = SimpleNamespace(pages=[_FakePage("Page one"), _FakePage("Page two")])
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(
- parser,
- "_extract_bookmarks",
- return_value=[
- {"level": 1, "title": "Broken Bookmark", "page_num": None},
- {"level": 1, "title": "Chapter 2", "page_num": 2},
- ],
- ),
- ):
- markdown, meta = await parser._convert_local(
- "dummy.pdf", storage=MagicMock(), resource_name="dummy"
- )
-
- assert "Broken Bookmark" not in markdown
- assert "\n# Chapter 2\n" in markdown
- assert meta["bookmarks_found"] == 2
- assert meta["bookmarks_resolved"] == 1
- assert meta["bookmarks_unresolved"] == 1
- assert meta["headings_found"] == 1
- assert meta["heading_source"] == "bookmarks"
-
- @pytest.mark.asyncio
- async def test_convert_local_falls_back_to_font_when_bookmarks_unresolved(self):
- parser = PDFParser()
- fake_pdf = SimpleNamespace(pages=[_FakePage("Page one"), _FakePage("Page two")])
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(
- parser,
- "_extract_bookmarks",
- return_value=[{"level": 1, "title": "Broken Bookmark", "page_num": None}],
- ),
- patch.object(
- parser,
- "_detect_headings_by_font",
- return_value=[{"level": 1, "title": "Font Heading", "page_num": 2}],
- ),
- ):
- markdown, meta = await parser._convert_local(
- "dummy.pdf", storage=MagicMock(), resource_name="dummy"
- )
-
- assert "Broken Bookmark" not in markdown
- assert "\n# Font Heading\n" in markdown
- assert meta["bookmarks_found"] == 1
- assert meta["bookmarks_resolved"] == 0
- assert meta["bookmarks_unresolved"] == 1
- assert meta["headings_found"] == 1
- assert meta["heading_source"] == "font_analysis"
-
- @pytest.mark.asyncio
- async def test_convert_local_skips_images_stacked_on_same_bbox(self):
- """Two image XObjects at the same spot must render (and save) only once."""
- parser = PDFParser()
- stacked = {"x0": 0.0, "top": 0.1, "x1": 595.0, "bottom": 841.9}
- page = _FakePage("Page one")
- page.images = [dict(stacked), dict(stacked)]
- fake_pdf = SimpleNamespace(pages=[page])
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
- storage = MagicMock()
- storage.save_image.return_value = Path("/media/dummy/images/page1_img1.png")
- storage.media_dir = Path("/media")
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(parser, "_extract_bookmarks", return_value=[]),
- patch.object(parser, "_detect_headings_by_font", return_value=[]),
- patch.object(parser, "_extract_image_from_page", return_value=b"png") as extract,
- ):
- markdown, meta = await parser._convert_local(
- "dummy.pdf", storage=storage, resource_name="dummy"
- )
-
- # The duplicate is dropped before rendering, so it never costs a render.
- assert extract.call_count == 1
- assert storage.save_image.call_count == 1
- assert meta["images_extracted"] == 1
- assert meta["images_deduplicated"] == 1
- assert markdown.count("![Page 1 Image") == 1
-
- @pytest.mark.asyncio
- async def test_convert_local_skips_images_rendering_to_same_bytes(self):
- """Distinct bboxes that still render identically are caught by the hash."""
- parser = PDFParser()
- page = _FakePage("Page one")
- page.images = [
- {"x0": 0.0, "top": 0.0, "x1": 100.0, "bottom": 100.0},
- {"x0": 5.0, "top": 5.0, "x1": 105.0, "bottom": 105.0},
- ]
- fake_pdf = SimpleNamespace(pages=[page])
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
- storage = MagicMock()
- storage.save_image.return_value = Path("/media/dummy/images/page1_img1.png")
- storage.media_dir = Path("/media")
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(parser, "_extract_bookmarks", return_value=[]),
- patch.object(parser, "_detect_headings_by_font", return_value=[]),
- patch.object(parser, "_extract_image_from_page", return_value=b"png") as extract,
- ):
- _markdown, meta = await parser._convert_local(
- "dummy.pdf", storage=storage, resource_name="dummy"
- )
-
- # Both are rendered (bboxes differ), but only one is saved.
- assert extract.call_count == 2
- assert storage.save_image.call_count == 1
- assert meta["images_extracted"] == 1
- assert meta["images_deduplicated"] == 1
-
- @pytest.mark.asyncio
- async def test_convert_local_keeps_distinct_images_and_repeats_across_pages(self):
- """Dedup is per-page: a logo on every page survives on every page."""
- parser = PDFParser()
- logo = {"x0": 0.0, "top": 0.0, "x1": 50.0, "bottom": 50.0}
- figure = {"x0": 0.0, "top": 200.0, "x1": 300.0, "bottom": 400.0}
- page1, page2 = _FakePage("Page one"), _FakePage("Page two")
- page1.images = [dict(logo), dict(figure)]
- page2.images = [dict(logo)]
- fake_pdf = SimpleNamespace(pages=[page1, page2])
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
- storage = MagicMock()
- storage.save_image.return_value = Path("/media/dummy/images/img.png")
- storage.media_dir = Path("/media")
-
- renders = {
- (0.0, 0.0, 50.0, 50.0): b"logo-png",
- (0.0, 200.0, 300.0, 400.0): b"figure-png",
- }
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(parser, "_extract_bookmarks", return_value=[]),
- patch.object(parser, "_detect_headings_by_font", return_value=[]),
- patch.object(
- parser,
- "_extract_image_from_page",
- side_effect=lambda _page, img: renders[
- (img["x0"], img["top"], img["x1"], img["bottom"])
- ],
- ),
- ):
- _markdown, meta = await parser._convert_local(
- "dummy.pdf", storage=storage, resource_name="dummy"
- )
-
- assert meta["images_extracted"] == 3
- assert meta["images_deduplicated"] == 0
-
- @pytest.mark.asyncio
- async def test_convert_local_closes_page_after_each_page(self):
- parser = PDFParser()
- pages = [_FakePage("Page one"), _FakePage("Page two")]
- fake_pdf = SimpleNamespace(pages=pages)
- fake_pdfplumber = SimpleNamespace(open=lambda _path: nullcontext(fake_pdf))
-
- with (
- patch("openviking.parse.parsers.pdf.lazy_import", return_value=fake_pdfplumber),
- patch.object(parser, "_extract_bookmarks", return_value=[]),
- patch.object(parser, "_detect_headings_by_font", return_value=[]),
- ):
- await parser._convert_local("dummy.pdf", storage=MagicMock(), resource_name="dummy")
-
- assert [page.close_count for page in pages] == [1, 1]
- assert [page.flush_count for page in pages] == [1, 1]
-
- def test_detect_headings_by_font_closes_pages(self):
- parser = PDFParser()
-
- def chars(text: str, size: float, top: float) -> list[dict]:
- return [
- {"text": char, "size": size, "top": top, "x0": idx} for idx, char in enumerate(text)
- ]
-
- pages = [
- _FakeFontPage(
- page_number=0,
- chars=chars("Body text repeated", 10, 500) + chars("Heading", 14, 100),
- ),
- _FakeFontPage(
- page_number=1,
- chars=chars("Another heading", 14, 120),
- ),
- _FakeFontPage(
- page_number=2,
- chars=chars("Plain body", 10, 500),
- ),
- _FakeFontPage(
- page_number=3,
- chars=chars("More body", 10, 500),
- ),
- ]
- fake_pdf = SimpleNamespace(pages=pages)
-
- headings = parser._detect_headings_by_font(fake_pdf)
-
- assert [heading["title"] for heading in headings] == ["Heading", "Another heading"]
- assert [page.close_count for page in pages] == [2, 1, 1, 1]
- assert [page.flush_count for page in pages] == [2, 1, 1, 1]
-
-
-class TestExtractImages:
- """Test PDF XObject image extraction."""
-
- def setup_method(self):
- self.parser = PDFParser()
-
- def test_extract_image_renders_cropped_bbox_as_png(self):
- page = _FakeImagePage(image_bytes=b"rendered-png")
-
- image_data = self.parser._extract_image_from_page(
- page,
- {"x0": 10, "top": 20, "x1": 40, "bottom": 60},
- )
+ def __init__(self) -> None:
+ self.closed = False
- assert image_data == b"rendered-png"
- assert page.crop_calls == [(10, 20, 40, 60)]
- assert page.cropped_page.resolution_calls == [self.parser.config.image_resolution]
+ def close(self) -> None:
+ self.closed = True
- def test_extract_image_clamps_bbox_to_page_bounds(self):
- page = _FakeImagePage()
+ page = FakePage()
- self.parser._extract_image_from_page(
- page,
- {"x0": -5, "top": -10, "x1": 150, "bottom": 250},
- )
+ class FakePDF:
+ pages = [page]
- assert page.crop_calls == [(0, 0, page.width, page.height)]
+ def __enter__(self):
+ return self
- def test_extract_image_returns_none_for_zero_area_bbox(self):
- page = _FakeImagePage()
+ def __exit__(self, *_args):
+ return None
- assert (
- self.parser._extract_image_from_page(
- page,
- {"x0": 30, "top": 40, "x1": 30, "bottom": 60},
- )
- is None
- )
- assert page.crop_calls == []
+ monkeypatch.setitem(
+ sys.modules,
+ "pdfplumber",
+ SimpleNamespace(open=lambda _path: FakePDF()),
+ )
+ parser = PDFParser()
+ monkeypatch.setattr(parser, "_extract_image_from_page", lambda *_args: b"png")
+ monkeypatch.setattr("openviking.parse.parsers.pdf.is_valid_image", lambda *_args: True)
+ storage = _FakeStorage(tmp_path / "media")
- def test_extract_image_returns_none_when_crop_fails(self):
- page = MagicMock(width=100, height=200)
- page.crop.side_effect = RuntimeError("crop failed")
+ result = parser._extract_images_sync(tmp_path / "paper.pdf", storage, "paper")
- assert (
- self.parser._extract_image_from_page(
- page,
- {"x0": 10, "top": 20, "x1": 40, "bottom": 60},
- )
- is None
- )
+ assert result.pages == {1: [""]}
+ assert result.images_extracted == 1
+ assert result.images_deduplicated == 1
+ assert page.closed is True
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index 9cf26591c0..bae51b50d3 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -198,7 +198,7 @@ def test_generic_code_hosting_domains_load_from_config():
assert config.code_hosting_domains == ["git.generic.example.com"]
-def test_openviking_config_rejects_unknown_nested_parser_section(monkeypatch):
+def test_openviking_config_rejects_removed_excel_parser_section(monkeypatch):
monkeypatch.setenv(OPENVIKING_CONFIG_ENV, "/tmp/codex-no-config.json")
from openviking_cli.utils.config.open_viking_config import (
@@ -206,7 +206,7 @@ def test_openviking_config_rejects_unknown_nested_parser_section(monkeypatch):
OpenVikingConfigSingleton,
)
- with pytest.raises(ValueError, match="markdown"):
+ with pytest.raises(ValueError, match="excel"):
OpenVikingConfig.from_dict(
{
"embedding": {
@@ -216,7 +216,7 @@ def test_openviking_config_rejects_unknown_nested_parser_section(monkeypatch):
"model": "text-embedding-3-small",
}
},
- "parsers": {"markdwon": {}},
+ "parsers": {"excel": {}},
}
)
diff --git a/uv.lock b/uv.lock
index 3cfb2dba97..c7a0e1ae32 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1365,6 +1365,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" },
]
+[[package]]
+name = "firecrawl-anydoc"
+version = "0.1.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e9/58/94bdc78509da5d2bec4761cfc6e79061d35562d4397b504aec59e29b548b/firecrawl_anydoc-0.1.8.tar.gz", hash = "sha256:342289a9e7fcc46a312919547b475827f202f6824c2acf05e334fc1ed386e77e", size = 197922, upload-time = "2026-08-10T23:39:54.336Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/61/abd70c4215e2804c8dad88c2abe8685fa84f4d2496ce7fc17d7f7a1cc21d/firecrawl_anydoc-0.1.8-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8a7218553f691f112a2d71f2c08c3ef87de3ce4d15fa82ffa782f493a307a284", size = 3447457, upload-time = "2026-08-10T23:39:39.832Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c1/ab54eb4ca0a131017941964219fbf80873059b4261b046f8dda1bdeb858c/firecrawl_anydoc-0.1.8-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8920b0d303ae5ad34bc5e580b25043eaf107df73e4f8f2f02282bfad7f19845a", size = 3273654, upload-time = "2026-08-10T23:39:42.596Z" },
+ { url = "https://files.pythonhosted.org/packages/af/10/9216bb6e65d027aac7c4c856ad921684337c105f69db77703059afd1ec1e/firecrawl_anydoc-0.1.8-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfe87061151cb42dd52960eb84f4ec6249a220131e61b498bba1248b86ef51cc", size = 3313860, upload-time = "2026-08-10T23:39:44.664Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/23/0d4fb73798b6554a63f38468e62ce13f1d411ef3dd879efc4c89f535ee44/firecrawl_anydoc-0.1.8-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3ba80a81f1831b5cdfcc14678e3ed13078587710fb3f5556f193dbcaa901070", size = 3540091, upload-time = "2026-08-10T23:39:46.642Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f6/438c158410c0f696dbb29e4f0b83cbb6cd89511792fd97917aaa4f2cc76e/firecrawl_anydoc-0.1.8-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:07e4b9444cc979f2eb35289c8d28d738474209824a29d07081af25cd6a65bf9b", size = 3492643, upload-time = "2026-08-10T23:39:48.579Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ba/bd6b8502093ddcc8fe170fdc20a37009dc5e7a6ae55551ede69a9639ecf8/firecrawl_anydoc-0.1.8-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b7d79b8f7c158a67232982dc1883c07114d95f69d7aedc902d0b113a5546b16", size = 3780576, upload-time = "2026-08-10T23:39:50.458Z" },
+ { url = "https://files.pythonhosted.org/packages/df/91/743986265ae347f0380a21d60378155575f4d724465c77c7e4781923df57/firecrawl_anydoc-0.1.8-cp310-abi3-win_amd64.whl", hash = "sha256:bf755eb80439f0a1540b298648919f19751c16f942a7376ee68dea5318e3f2a3", size = 3632712, upload-time = "2026-08-10T23:39:52.561Z" },
+]
+
[[package]]
name = "frozenlist"
version = "1.8.0"
@@ -3415,15 +3430,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
-[[package]]
-name = "olefile"
-version = "0.47"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" },
-]
-
[[package]]
name = "openai"
version = "2.24.0"
@@ -3646,9 +3652,9 @@ dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
{ name = "defusedxml" },
- { name = "ebooklib" },
{ name = "fastapi" },
{ name = "feedparser" },
+ { name = "firecrawl-anydoc" },
{ name = "grep-ast" },
{ name = "httpx" },
{ name = "jinja2" },
@@ -3657,9 +3663,7 @@ dependencies = [
{ name = "litellm" },
{ name = "loguru" },
{ name = "mcp" },
- { name = "olefile" },
{ name = "openai" },
- { name = "openpyxl" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
@@ -3667,13 +3671,11 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "openviking-sdk" },
{ name = "pathspec" },
- { name = "pdfminer-six" },
+ { name = "pdf-inspector" },
{ name = "pdfplumber" },
{ name = "protobuf" },
{ name = "pydantic" },
- { name = "python-docx" },
{ name = "python-multipart" },
- { name = "python-pptx" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scrapy" },
@@ -3697,7 +3699,6 @@ dependencies = [
{ name = "uvicorn" },
{ name = "volcengine" },
{ name = "volcengine-python-sdk", extra = ["ark"] },
- { name = "xlrd" },
{ name = "xxhash" },
]
@@ -3794,13 +3795,17 @@ test = [
{ name = "boto3" },
{ name = "datasets" },
{ name = "diff-match-patch" },
+ { name = "ebooklib" },
{ name = "hvac" },
+ { name = "openpyxl" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-xdist" },
+ { name = "python-docx" },
+ { name = "python-pptx" },
{ name = "ragas" },
]
@@ -3829,9 +3834,10 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "diff-match-patch", marker = "extra == 'test'", specifier = ">=20200713" },
{ name = "dingtalk-stream", marker = "extra == 'bot'", specifier = ">=0.4.0" },
- { name = "ebooklib", specifier = ">=0.18.0" },
+ { name = "ebooklib", marker = "extra == 'test'", specifier = ">=0.18.0" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "feedparser", specifier = ">=6.0.0" },
+ { name = "firecrawl-anydoc", specifier = ">=0.1.8,<0.2" },
{ name = "fusepy", marker = "extra == 'bot'", specifier = ">=3.0.1" },
{ name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.0.0" },
{ name = "google-genai", marker = "extra == 'gemini-async'", specifier = ">=1.0.0" },
@@ -3858,10 +3864,9 @@ requires-dist = [
{ name = "msgpack", marker = "extra == 'bot'", specifier = ">=1.0.8" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
{ name = "myst-parser", marker = "extra == 'doc'", specifier = ">=2.0.0" },
- { name = "olefile", specifier = ">=0.47" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "opencode-ai", marker = "extra == 'bot'", specifier = ">=0.1.0a0" },
- { name = "openpyxl", specifier = ">=3.0.0" },
+ { name = "openpyxl", marker = "extra == 'test'", specifier = ">=3.0.0" },
{ name = "opensandbox", marker = "extra == 'bot'", specifier = ">=0.1.0" },
{ name = "opensandbox-server", marker = "extra == 'bot'", specifier = ">=0.1.0" },
{ name = "opentelemetry-api", specifier = ">=1.14" },
@@ -3874,7 +3879,7 @@ requires-dist = [
{ name = "pandas", marker = "extra == 'eval'", specifier = ">=2.0.0" },
{ name = "pandas", marker = "extra == 'test'", specifier = ">=2.0.0" },
{ name = "pathspec", specifier = ">=1.1.1" },
- { name = "pdfminer-six", specifier = ">=20251230" },
+ { name = "pdf-inspector", specifier = ">=1.14.1,<2" },
{ name = "pdfplumber", specifier = ">=0.10.0" },
{ name = "prompt-toolkit", marker = "extra == 'bot'", specifier = ">=3.0.0" },
{ name = "protobuf", specifier = ">=6.33.5" },
@@ -3887,13 +3892,13 @@ requires-dist = [
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.21.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" },
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.5.0" },
- { name = "python-docx", specifier = ">=1.0.0" },
+ { name = "python-docx", marker = "extra == 'test'", specifier = ">=1.0.0" },
{ name = "python-engineio", marker = "extra == 'bot'", specifier = ">=4.13.2" },
{ name = "python-jose", extras = ["cryptography"], marker = "extra == 'auth'", specifier = ">=3.3.0" },
{ name = "python-jose", extras = ["cryptography"], marker = "extra == 'bot'", specifier = ">=3.3.0" },
{ name = "python-ldap", marker = "extra == 'auth'", specifier = ">=3.4.0" },
{ name = "python-multipart", specifier = ">=0.0.31" },
- { name = "python-pptx", specifier = ">=1.0.0" },
+ { name = "python-pptx", marker = "extra == 'test'", specifier = ">=1.0.0" },
{ name = "python-socketio", marker = "extra == 'bot'", specifier = ">=5.16.2" },
{ name = "python-socks", extras = ["asyncio"], marker = "extra == 'bot'", specifier = ">=2.4.0" },
{ name = "python-telegram-bot", extras = ["socks"], marker = "extra == 'bot'", specifier = ">=21.0" },
@@ -3938,10 +3943,9 @@ requires-dist = [
{ name = "websocket-client", marker = "extra == 'bot'", specifier = ">=1.6.0" },
{ name = "websockets", marker = "extra == 'bot'", specifier = ">=12.0" },
{ name = "wheel", marker = "extra == 'build'" },
- { name = "xlrd", specifier = ">=2.0.1" },
{ name = "xxhash", specifier = ">=3.0.0" },
]
-provides-extras = ["test", "auth", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "benchmark", "langchain", "langgraph", "local-embed"]
+provides-extras = ["test", "auth", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "benchmark", "local-embed"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -4267,6 +4271,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
+[[package]]
+name = "pdf-inspector"
+version = "1.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/7d/29ac7865f4c7332d28638d08b73aa6b7bf2f1325c383094bb6717a1a816d/pdf_inspector-1.14.1.tar.gz", hash = "sha256:e9d0fc1669ca77950d28488c8450d1aa28018e839ea260308d87253dfb8bec03", size = 1559937, upload-time = "2026-08-11T21:22:53.948Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/47/7ddacbcac7a04ec60014e2a03b943c4643a8bd794ed67588f5ebadfbeb15/pdf_inspector-1.14.1-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b2159efa28f132df0442129a9b3eaf70c3f9915eb8202efcaaf759d85a01993f", size = 2855526, upload-time = "2026-08-11T21:22:44.66Z" },
+ { url = "https://files.pythonhosted.org/packages/98/24/00594b8ae250c0b8a87631a86067896416920879c43c8cdcdfeb50586d49/pdf_inspector-1.14.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:54cb32691640e5684832d21fbb58d2b30c068cbc055e0157da37774c1e8b9218", size = 2767918, upload-time = "2026-08-11T21:22:46.768Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/36/5b332c80858407fb19b34e6f18f6c99022d04324caf98b8aaf4547044871/pdf_inspector-1.14.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:501b8a6ffcd2c16ea74d71b9d7d70503c945c7d38c81151121790fd278021f0b", size = 2942959, upload-time = "2026-08-11T21:22:48.771Z" },
+ { url = "https://files.pythonhosted.org/packages/37/cc/589c355fed7b50098ddb9d09116c81d966c7b4ff78741183e600c1b4de6f/pdf_inspector-1.14.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62e761a32ffd9c2b0c803dbefa2b0ad6f98bc26a75847571d99c3f7a65a7cea3", size = 3044612, upload-time = "2026-08-11T21:22:50.571Z" },
+ { url = "https://files.pythonhosted.org/packages/24/7c/4c84916a97cfd85d2e72dc3aaf6988cd19aff6e050d6a1b79d20f97e4566/pdf_inspector-1.14.1-cp38-abi3-win_amd64.whl", hash = "sha256:d4a2455c316e15eac745c3836f72b641e1c33207afb84c250abff2843209e527", size = 2690743, upload-time = "2026-08-11T21:22:52.407Z" },
+]
+
[[package]]
name = "pdfminer-six"
version = "20251230"
@@ -7053,15 +7070,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]
-[[package]]
-name = "xlrd"
-version = "2.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
-]
-
[[package]]
name = "xlsxwriter"
version = "3.2.9"