Spaces:
Sleeping
Sleeping
File size: 6,696 Bytes
8420e1e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | """Docling parser - 主力.
特性:
- 结构感知 (reading order / headings)
- 跨页表合并 (TableFormer)
- 内置 OCR (PaddleOCR 支持中英)
- DOCX / PPTX / 图片 / HTML 全支持
Docling API 在 2.x 期间变动较多, 本实现基于 2.30+ 兼容.
"""
from __future__ import annotations
import asyncio
import logging
import time
from pathlib import Path
from app.config import settings
from app.services.parsers.base_parser import BaseParser, PageContent, ParsedDocument
logger = logging.getLogger(__name__)
class DoclingParser(BaseParser):
name = "docling"
def supported_extensions(self) -> set[str]:
return {".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".tiff", ".html", ".xlsx"}
async def parse(self, file_path: Path) -> ParsedDocument:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._parse_sync, file_path)
def _parse_sync(self, file_path: Path) -> ParsedDocument:
"""实际解析. CPU 密集, 在线程池跑."""
# Force CPU device for Docling to prevent MPS NotImplementedError on Intel Mac
try:
import docling.utils.accelerator_utils
docling.utils.accelerator_utils.decide_device = lambda *args, **kwargs: "cpu"
except ImportError:
pass
# 延迟 import, 避免启动期未装 docling 时崩溃
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
started = time.time()
logger.info("Docling parsing: %s", file_path.name)
from docling.datamodel.accelerator_options import AcceleratorOptions, AcceleratorDevice
opts = PdfPipelineOptions()
opts.do_ocr = settings.parser_enable_ocr
opts.do_table_structure = settings.parser_table_structure
opts.images_scale = 2.0
# ⚠️ 不要设 artifacts_path: 设了但目录为空会被 Docling 拒绝 (报 "is not valid")
# 不设时, Docling 通过 huggingface_hub 走 HF_HOME 自动下载 + 缓存
# 我们的 Dockerfile 设了 HF_HOME=/data/.cache/huggingface (持久卷), 所以重启后还在
opts.accelerator_options = AcceleratorOptions(device=AcceleratorDevice.CPU)
converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=opts),
}
)
try:
result = converter.convert(str(file_path))
except Exception as e: # noqa: BLE001
logger.exception("Docling parse failed: %s", e)
raise
doc = result.document
# 全文 markdown
markdown = doc.export_to_markdown()
# 页面级 (Docling 用 iterate_items / pages 属性, 视版本略有差异)
pages: list[PageContent] = []
try:
page_count = len(doc.pages) if hasattr(doc, "pages") else 0
for idx in range(page_count):
page = doc.pages[idx]
# 提取该页文本 (Docling 2.x 没有现成 API, 用 page-level export 近似)
page_md = ""
if hasattr(page, "export_to_markdown"):
try:
page_md = page.export_to_markdown()
except Exception: # noqa: BLE001
page_md = ""
pages.append(PageContent(
page_no=idx + 1,
text=page_md,
headings=[], # Docling 不直接给页级 headings, 留空
))
except Exception as e: # noqa: BLE001
logger.warning("Docling page extraction partial: %s", e)
# 表格 (简化提取)
tables: list[dict] = []
try:
for t in (doc.tables or []):
tables.append({
"html": t.export_to_html() if hasattr(t, "export_to_html") else "",
"caption": getattr(t, "caption", None),
})
except Exception: # noqa: BLE001
pass
elapsed_ms = int((time.time() - started) * 1000)
logger.info("Docling done: %s pages, %s tables, %dms", len(pages), len(tables), elapsed_ms)
return ParsedDocument(
markdown=markdown,
pages=pages,
tables=tables,
images=[],
meta={
"parser": self.name,
"page_count": len(pages),
"elapsed_ms": elapsed_ms,
},
)
def _prewarm_docling_models() -> None:
"""预下载 Docling 需要的模型 (layout/heron, tableformer, paddleocr 等, 共 ~2GB).
在 Space 启动 lifespan 阶段跑一次, 避免首次上传时下载超时或下载失败.
模型会缓存到 settings.hf_cache_dir, 后续启动跳过.
"""
import logging
from pathlib import Path
from app.config import settings
logger_local = logging.getLogger(__name__)
logger_local.info("Docling model prewarm: pulling layout/table/ocr models...")
from huggingface_hub import snapshot_download
# Docling 模型都在 ds4sd 命名空间下
repos = [
"ds4sd/docling-models", # 主模型集 (layout, tableformer)
]
cache_dir = Path(settings.hf_cache_dir) if hasattr(settings, "hf_cache_dir") else None
if cache_dir is None:
from app.core.paths import data_dir
cache_dir = data_dir() / ".cache" / "huggingface"
for repo in repos:
try:
p = snapshot_download(
repo_id=repo,
cache_dir=str(cache_dir),
# 避免下载所有 variants, 只下必需的
allow_patterns=[
"*.json",
"*.txt",
"*.safetensors",
"tokenizer*",
],
)
logger_local.info("Docling model %s cached at %s", repo, p)
except Exception as e: # noqa: BLE001
logger_local.warning("Docling model prewarm %s failed: %s", repo, e)
# PaddleOCR 模型 (Docling 内置 OCR 用). 单独下载.
try:
from paddleocr import PaddleOCR # type: ignore
# 实例化会触发模型下载到 ~/.paddleocr
PaddleOCR(use_angle_cls=False, lang="ch", show_log=False)
logger_local.info("PaddleOCR (ch) model cached")
except Exception as e: # noqa: BLE001
# PaddleOCR 可能没装 (e.g. arm64 平台), 不阻塞
logger_local.warning("PaddleOCR prewarm skipped: %s", e)
logger_local.info("Docling model prewarm done")
|