pdf_tools.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. # -*- coding: utf-8 -*-
  2. """
  3. PDF 文本提取工具模块
  4. 功能:
  5. - 可选 MinerU 云端解析(高精度,需网络,由 MINERU_ENABLED 环境变量控制)
  6. - 使用 PyPDF2 (pypdf) 提取 PDF 文本内容
  7. - 若 PyPDF2 提取失败或内容为空,回退到 PaddleOCR 进行 OCR 识别
  8. - 支持上传文件保存到临时目录
  9. - 基于文件 MD5 哈希的缓存:同一 PDF 不重复提取
  10. - MinerU 与本地解析使用独立缓存,互不干扰
  11. 解析优先级(当 MINERU_ENABLED=true 时):
  12. MinerU 云端解析 → PyPDF2 本地提取 → PaddleOCR 回退
  13. 工具列表:
  14. - extract_pdf_text: 从 PDF 文件提取文本(含缓存,MinerU → PyPDF2 → PaddleOCR)
  15. - save_upload_file: 保存上传文件到临时目录
  16. - calculate_file_hash: 计算文件 MD5 哈希
  17. - clear_cache: 清除 PDF 提取缓存
  18. """
  19. import hashlib
  20. import os
  21. import re
  22. import time
  23. import tempfile
  24. from datetime import datetime
  25. from pathlib import Path
  26. from typing import Optional
  27. from dotenv import load_dotenv
  28. load_dotenv()
  29. # ============================================================
  30. # 缓存目录
  31. # ============================================================
  32. # 缓存目录:项目根目录下的 data/pdf_cache/
  33. _CACHE_DIR = Path(__file__).parent.parent / "data" / "pdf_cache"
  34. # MinerU 缓存文件前缀(与本地解析缓存隔离)
  35. _MINERU_CACHE_PREFIX = "mineru_"
  36. # ============================================================
  37. # 文件哈希计算
  38. # ============================================================
  39. def calculate_file_hash(file_path: str) -> str:
  40. """计算文件的 MD5 哈希值,作为缓存 key。
  41. 同一文件(相同内容)产生相同哈希,实现"同一 PDF 不重复提取"。
  42. Args:
  43. file_path: 文件路径
  44. Returns:
  45. str: 32位 MD5 十六进制字符串
  46. """
  47. try:
  48. with open(file_path, 'rb') as f:
  49. file_data = f.read()
  50. return hashlib.md5(file_data).hexdigest()
  51. except Exception as e:
  52. print(f"[PDF] 计算文件哈希失败: {e}")
  53. # 失败时用时间戳生成唯一 key,确保不误命中缓存
  54. return hashlib.md5(str(datetime.now()).encode()).hexdigest()
  55. def _get_cache_path(file_hash: str) -> Path:
  56. """获取缓存文件路径。
  57. Args:
  58. file_hash: 文件 MD5 哈希值
  59. Returns:
  60. Path: 缓存文件路径,如 data/pdf_cache/abc123.txt
  61. """
  62. return _CACHE_DIR / f"{file_hash}.txt"
  63. def _read_cache(file_hash: str) -> Optional[str]:
  64. """从缓存中读取已提取的文本。
  65. Args:
  66. file_hash: 文件 MD5 哈希值
  67. Returns:
  68. str | None: 缓存文本,不存在或读取失败返回 None
  69. """
  70. cache_path = _get_cache_path(file_hash)
  71. if not cache_path.exists():
  72. return None
  73. try:
  74. text = cache_path.read_text(encoding='utf-8')
  75. if text.strip():
  76. print(f"[PDF] 命中缓存: {cache_path.name} ({len(text)} 字符)")
  77. return text
  78. return None
  79. except Exception as e:
  80. print(f"[PDF] 缓存读取失败: {e}")
  81. return None
  82. def _write_cache(file_hash: str, text: str):
  83. """将提取的文本写入缓存。
  84. Args:
  85. file_hash: 文件 MD5 哈希值
  86. text: 提取的文本内容
  87. """
  88. try:
  89. _CACHE_DIR.mkdir(parents=True, exist_ok=True)
  90. cache_path = _get_cache_path(file_hash)
  91. cache_path.write_text(text, encoding='utf-8')
  92. print(f"[PDF] 已缓存: {cache_path.name} ({len(text)} 字符)")
  93. except Exception as e:
  94. print(f"[PDF] 缓存写入失败: {e}")
  95. def clear_cache(file_hash: Optional[str] = None):
  96. """清除 PDF 提取缓存(同时清除本地解析缓存和 MinerU 缓存)。
  97. Args:
  98. file_hash: 指定要清除的哈希(可选),不传则清除全部缓存
  99. """
  100. if file_hash:
  101. # 清除本地缓存
  102. local_path = _get_cache_path(file_hash)
  103. if local_path.exists():
  104. local_path.unlink()
  105. print(f"[PDF] 已清除本地缓存: {local_path.name}")
  106. # 清除 MinerU 缓存
  107. mineru_path = _get_mineru_cache_path(file_hash)
  108. if mineru_path.exists():
  109. mineru_path.unlink()
  110. print(f"[PDF] 已清除 MinerU 缓存: {mineru_path.name}")
  111. else:
  112. if _CACHE_DIR.exists():
  113. count = 0
  114. for f in _CACHE_DIR.glob("*.txt"):
  115. f.unlink()
  116. count += 1
  117. print(f"[PDF] 已清除全部缓存 ({count} 个文件)")
  118. # ============================================================
  119. # 上传文件保存
  120. # ============================================================
  121. async def save_upload_file(upload_file, upload_dir: Optional[str] = None) -> str:
  122. """保存 FastAPI UploadFile 到临时目录,返回文件路径。
  123. Args:
  124. upload_file: FastAPI UploadFile 对象
  125. upload_dir: 保存目录,默认为系统临时目录下的 vent_review_pdfs
  126. Returns:
  127. str: 保存后的文件绝对路径
  128. """
  129. if upload_dir is None:
  130. upload_dir = os.path.join(tempfile.gettempdir(), "vent_review_pdfs")
  131. os.makedirs(upload_dir, exist_ok=True)
  132. # 保留原始文件名,避免冲突加时间戳
  133. import time
  134. original_name = upload_file.filename or "upload.pdf"
  135. safe_name = f"{int(time.time() * 1000)}_{original_name}"
  136. file_path = os.path.join(upload_dir, safe_name)
  137. content = await upload_file.read()
  138. with open(file_path, "wb") as f:
  139. f.write(content)
  140. return file_path
  141. # ============================================================
  142. # PDF 文本提取 - PyPDF2 方式
  143. # ============================================================
  144. def _extract_with_pypdf(file_path: str) -> Optional[str]:
  145. """使用 pypdf (PyPDF2) 提取 PDF 文本。
  146. Args:
  147. file_path: PDF 文件路径
  148. Returns:
  149. str | None: 提取的文本内容,失败返回 None
  150. """
  151. try:
  152. from pypdf import PdfReader
  153. reader = PdfReader(file_path)
  154. pages_text = []
  155. for i, page in enumerate(reader.pages):
  156. text = page.extract_text()
  157. if text:
  158. pages_text.append(f"--- 第 {i + 1} 页 ---\n{text.strip()}")
  159. if pages_text:
  160. return "\n\n".join(pages_text)
  161. return None
  162. except ImportError:
  163. print("[PDF] pypdf 未安装,跳过 PyPDF2 提取")
  164. return None
  165. except Exception as e:
  166. print(f"[PDF] PyPDF2 提取失败: {e}")
  167. return None
  168. # ============================================================
  169. # PDF 文本提取 - PaddleOCR 回退方式
  170. # ============================================================
  171. def _pdf_to_images(file_path: str, dpi: int = 150) -> list:
  172. """将 PDF 每页转换为 PIL Image 列表。
  173. Args:
  174. file_path: PDF 文件路径
  175. dpi: 图片分辨率,默认 150(平衡速度与精度)
  176. Returns:
  177. list[PIL.Image]: 图片列表
  178. """
  179. try:
  180. from pdf2image import convert_from_path
  181. return convert_from_path(file_path, dpi=dpi)
  182. except ImportError:
  183. print("[PDF] pdf2image 未安装,无法转换 PDF 为图片")
  184. return []
  185. except Exception as e:
  186. print(f"[PDF] PDF 转图片失败: {e}")
  187. return []
  188. def _extract_with_paddleocr(file_path: str,
  189. progress_callback: callable = None) -> Optional[str]:
  190. """使用 PaddleOCR 对 PDF 进行 OCR 识别。
  191. 兼容 PaddleOCR v2.x (ocr/cls API) 和 v3.x (predict API)。
  192. 先将 PDF 每页转为图片,再逐页 OCR。
  193. Args:
  194. file_path: PDF 文件路径
  195. progress_callback: 进度回调,签名 callback(page_num: int, total_pages: int),每页扫描前调用
  196. Returns:
  197. str | None: OCR 识别结果,失败返回 None
  198. """
  199. try:
  200. from paddleocr import PaddleOCR
  201. # 检测 API 版本:v3.x 主要使用 predict(),ocr() 已废弃(deprecated)
  202. # 判断依据:v3.x 的 predict 方法签名中不含 cls 参数
  203. import inspect
  204. _is_v3 = False
  205. if hasattr(PaddleOCR, 'predict'):
  206. try:
  207. sig = inspect.signature(PaddleOCR.predict)
  208. _is_v3 = 'cls' not in sig.parameters
  209. except Exception:
  210. pass
  211. # 如果 predict 不存在,则为 v2.x(只有 ocr 方法)
  212. if not hasattr(PaddleOCR, 'predict'):
  213. _is_v3 = False
  214. # 初始化(兼容两种版本 + 多级加速策略)
  215. # 加速优先级:onnxruntime → paddle_dynamic+mkldnn → paddle_static+mkldnn → 纯CPU
  216. try:
  217. if _is_v3:
  218. # v3.x 加速参数说明:
  219. # - text_det_limit_side_len=960:检测时缩图边长上限,越小越快
  220. # - enable_mkldnn=True:Intel oneDNN 加速(paddle 引擎时有效)
  221. # - engine="onnxruntime":ONNX Runtime 推理,CPU 上比 paddle 快 2-5x
  222. _v3_base = dict(
  223. lang="ch",
  224. use_doc_orientation_classify=False,
  225. use_doc_unwarping=False,
  226. use_textline_orientation=False,
  227. text_det_limit_side_len=960,
  228. cpu_threads=10,
  229. )
  230. # 策略0: onnxruntime(最快,绕过 PIR/onednn 问题)
  231. try:
  232. ocr = PaddleOCR(**_v3_base, engine="onnxruntime")
  233. print("[PDF] PaddleOCR 初始化: onnxruntime")
  234. except Exception:
  235. # 策略1: paddle_dynamic + mkldnn
  236. try:
  237. ocr = PaddleOCR(**_v3_base, enable_mkldnn=True, engine="paddle_dynamic")
  238. print("[PDF] PaddleOCR 初始化: paddle_dynamic + mkldnn")
  239. except Exception:
  240. # 策略2: paddle_static + mkldnn(可能触发 PIR 错误)
  241. try:
  242. ocr = PaddleOCR(**_v3_base, enable_mkldnn=True)
  243. print("[PDF] PaddleOCR 初始化: paddle_static + mkldnn")
  244. except Exception:
  245. # 策略3: 回退到纯 CPU(稳定但慢)
  246. try:
  247. ocr = PaddleOCR(**_v3_base, enable_mkldnn=False)
  248. print("[PDF] PaddleOCR 初始化: paddle_static (无加速)")
  249. except Exception:
  250. ocr = PaddleOCR(**_v3_base)
  251. else:
  252. # v2.x: 传统参数
  253. try:
  254. ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
  255. except Exception:
  256. ocr = PaddleOCR(use_angle_cls=True, lang="ch")
  257. except Exception:
  258. # 最后兜底:最少参数
  259. ocr = PaddleOCR(lang="ch")
  260. # 将 PDF 转为图片
  261. images = _pdf_to_images(file_path)
  262. if not images:
  263. print("[PDF] PDF 转图片失败,无法进行 OCR")
  264. return None
  265. # 逐页 OCR
  266. total = len(images)
  267. pages_text = []
  268. for i, img in enumerate(images):
  269. page_num = i + 1
  270. # 进度回调
  271. if progress_callback:
  272. try:
  273. progress_callback(page_num, total)
  274. except Exception:
  275. pass # 回调异常不影响主流程
  276. import tempfile
  277. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
  278. img.save(tmp.name)
  279. tmp_path = tmp.name
  280. try:
  281. if _is_v3:
  282. # v3.x: predict() → list of OCRResult,传入加速参数
  283. result = ocr.predict(
  284. tmp_path,
  285. text_det_limit_side_len=960,
  286. text_rec_score_thresh=0.5,
  287. )
  288. page_text = _extract_text_from_v3_result(result)
  289. else:
  290. # v2.x: ocr(img, cls=True) → nested list
  291. result = ocr.ocr(tmp_path, cls=True)
  292. page_text = _extract_text_from_v2_result(result)
  293. if page_text:
  294. pages_text.append(f"--- 第 {i + 1} 页 (OCR) ---\n{page_text}")
  295. finally:
  296. try:
  297. os.unlink(tmp_path)
  298. except OSError:
  299. pass
  300. if pages_text:
  301. return "\n\n".join(pages_text)
  302. return None
  303. except ImportError:
  304. print("[PDF] PaddleOCR 未安装,跳过 OCR 提取")
  305. return None
  306. except Exception as e:
  307. print(f"[PDF] PaddleOCR 提取失败: {e}")
  308. import traceback
  309. traceback.print_exc()
  310. return None
  311. def _extract_text_from_v2_result(result) -> str:
  312. """从 PaddleOCR v2.x 的 ocr() 返回结果中提取文本。
  313. v2 格式: [[[bbox, (text, confidence)], ...], ...]
  314. """
  315. if not result or not result[0]:
  316. return ""
  317. lines = []
  318. for line in result[0]:
  319. if line and len(line) >= 2:
  320. text = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1])
  321. lines.append(text)
  322. return "\n".join(lines)
  323. def _extract_text_from_v3_result(result) -> str:
  324. """从 PaddleOCR v3.x 的 predict() 返回结果中提取文本。
  325. v3 格式: list of OCRResult,每个 OCRResult 可通过 ["rec_texts"] 或 str() 获取文本。
  326. """
  327. if not result:
  328. return ""
  329. lines = []
  330. for res in result:
  331. # 尝试多种方式提取文本
  332. if hasattr(res, '__getitem__'):
  333. try:
  334. rec_texts = res["rec_texts"]
  335. if rec_texts:
  336. lines.extend(rec_texts if isinstance(rec_texts, list) else [str(rec_texts)])
  337. continue
  338. except (KeyError, TypeError):
  339. pass
  340. # 尝试 str()
  341. text = str(res)
  342. if text:
  343. lines.append(text)
  344. return "\n".join(lines)
  345. # ============================================================
  346. # PDF 页数统计
  347. # ============================================================
  348. def _count_pdf_pages(file_path: str) -> int:
  349. """统计 PDF 文件的总页数。
  350. Args:
  351. file_path: PDF 文件路径
  352. Returns:
  353. int: 总页数,失败返回 0
  354. """
  355. try:
  356. from pypdf import PdfReader
  357. reader = PdfReader(file_path)
  358. return len(reader.pages)
  359. except Exception as e:
  360. print(f"[PDF] 无法获取 PDF 页数: {e}")
  361. return 0
  362. # ============================================================
  363. # MinerU 缓存(独立于本地解析缓存)
  364. # ============================================================
  365. def _get_mineru_cache_path(file_hash: str) -> Path:
  366. """获取 MinerU 专用缓存文件路径。
  367. Args:
  368. file_hash: 文件 MD5 哈希值
  369. Returns:
  370. Path: 缓存文件路径,如 data/pdf_cache/mineru_abc123.txt
  371. """
  372. return _CACHE_DIR / f"{_MINERU_CACHE_PREFIX}{file_hash}.txt"
  373. def _read_mineru_cache(file_hash: str) -> Optional[str]:
  374. """从 MinerU 专用缓存中读取已提取的文本。
  375. Args:
  376. file_hash: 文件 MD5 哈希值
  377. Returns:
  378. str | None: 缓存文本,不存在或读取失败返回 None
  379. """
  380. cache_path = _get_mineru_cache_path(file_hash)
  381. if not cache_path.exists():
  382. return None
  383. try:
  384. text = cache_path.read_text(encoding='utf-8')
  385. if text.strip():
  386. print(f"[PDF] MinerU 缓存命中: {cache_path.name} ({len(text)} 字符)")
  387. return text
  388. return None
  389. except Exception as e:
  390. print(f"[PDF] MinerU 缓存读取失败: {e}")
  391. return None
  392. def _write_mineru_cache(file_hash: str, text: str):
  393. """将 MinerU 提取的文本写入专用缓存。
  394. Args:
  395. file_hash: 文件 MD5 哈希值
  396. text: 提取的文本内容
  397. """
  398. try:
  399. _CACHE_DIR.mkdir(parents=True, exist_ok=True)
  400. cache_path = _get_mineru_cache_path(file_hash)
  401. cache_path.write_text(text, encoding='utf-8')
  402. print(f"[PDF] MinerU 已缓存: {cache_path.name} ({len(text)} 字符)")
  403. except Exception as e:
  404. print(f"[PDF] MinerU 缓存写入失败: {e}")
  405. # ============================================================
  406. # MinerU 文本清洗
  407. # ============================================================
  408. def _clean_mineru_text(text: str) -> str:
  409. """清洗 MinerU 输出的文本。
  410. - 去除每行首尾空白
  411. - 合并多余空行(连续 3 个以上空行压缩为 2 个)
  412. Args:
  413. text: MinerU 原始输出文本
  414. Returns:
  415. str: 清洗后的文本
  416. """
  417. if not text:
  418. return ""
  419. text = "\n".join(line.strip() for line in text.splitlines())
  420. text = re.sub(r'\n{3,}', '\n\n', text)
  421. return text.strip()
  422. # ============================================================
  423. # MinerU 云端 PDF 解析
  424. # ============================================================
  425. def _extract_with_mineru(file_path: str,
  426. force_refresh: bool = False,
  427. use_cache: bool = True) -> Optional[str]:
  428. """使用 MinerU 云端服务解析 PDF(高精度,需要网络)。
  429. 解析流程:
  430. 1. 检查 MinerU 专用缓存(mineru_{hash}.txt)
  431. 2. 若缓存命中且非强制刷新,直接返回
  432. 3. 通过 langchain_mineru.MinerULoader 分片加载 PDF
  433. 4. 清洗每页文本并拼接
  434. 5. 写入 MinerU 专用缓存
  435. Args:
  436. file_path: PDF 文件路径
  437. force_refresh: 是否强制重新提取(忽略缓存)
  438. use_cache: 是否使用缓存
  439. Returns:
  440. str | None: 提取的文本内容,失败返回 None
  441. """
  442. mineru_token = os.getenv("MINERU_TOKEN", "").strip()
  443. mineru_max_pages = int(os.getenv("MINERU_MAX_PAGES", "50"))
  444. if not mineru_token:
  445. print("[PDF] MINERU_TOKEN 未配置,跳过 MinerU 解析")
  446. return None
  447. file_hash = calculate_file_hash(file_path)
  448. # ── 检查 MinerU 专用缓存 ──
  449. if use_cache and not force_refresh:
  450. cached = _read_mineru_cache(file_hash)
  451. if cached is not None:
  452. return cached
  453. # ── 统计总页数 ──
  454. total_pages = _count_pdf_pages(file_path)
  455. if total_pages == 0:
  456. print("[PDF] 无法获取 PDF 页数,跳过 MinerU 解析")
  457. return None
  458. print(f"[PDF] PDF总页数:{total_pages}(MinerU 云端解析)")
  459. start_time = time.time()
  460. all_text_parts = []
  461. try:
  462. from langchain_mineru import MinerULoader
  463. for start in range(1, total_pages + 1, mineru_max_pages):
  464. end = min(start + mineru_max_pages - 1, total_pages)
  465. print(f"[PDF] MinerU 正在加载分片:{start} ~ {end} 页")
  466. loader = MinerULoader(
  467. source=file_path,
  468. mode="precision",
  469. token=mineru_token,
  470. pages=f"{start}-{end}",
  471. )
  472. docs = loader.load()
  473. for idx, doc in enumerate(docs):
  474. cleaned = _clean_mineru_text(doc.page_content)
  475. if cleaned:
  476. all_text_parts.append(
  477. f"--- 第 {start + idx} 页 (MinerU) ---\n{cleaned}"
  478. )
  479. if not all_text_parts:
  480. print("[PDF] MinerU 未提取到任何文本内容")
  481. return None
  482. full_text = "\n\n".join(all_text_parts)
  483. # ── 写入 MinerU 专用缓存 ──
  484. if use_cache:
  485. _write_mineru_cache(file_hash, full_text)
  486. elapsed = round(time.time() - start_time, 2)
  487. print(f"[PDF] MinerU 解析完成 | 总页数:{total_pages} | 总耗时:{elapsed} 秒")
  488. return full_text
  489. except ImportError:
  490. print("[PDF] langchain-mineru 未安装,跳过 MinerU 解析")
  491. return None
  492. except Exception as e:
  493. print(f"[PDF] MinerU 解析失败: {e}")
  494. import traceback
  495. traceback.print_exc()
  496. return None
  497. # ============================================================
  498. # 主提取函数
  499. # ============================================================
  500. def extract_pdf_text(file_path: str, min_text_length: int = 50,
  501. use_cache: bool = True, force_refresh: bool = False,
  502. progress_callback: callable = None) -> str:
  503. """从 PDF 文件提取文本。
  504. 提取策略(按优先级):
  505. 0. 若 MINERU_ENABLED=true,优先使用 MinerU 云端解析(高精度,需网络)
  506. - 失败或内容不足则自动回退到本地解析
  507. 1. 计算文件 MD5 哈希,若本地缓存命中且非强制刷新,直接返回
  508. 2. 尝试 PyPDF2 (pypdf) 直接提取文本
  509. 3. 若提取内容为空或长度不足 min_text_length,回退到 PaddleOCR
  510. 4. 提取成功后将结果写入缓存
  511. 5. 若全部失败,返回错误信息
  512. Args:
  513. file_path: PDF 文件路径
  514. min_text_length: 最小有效文本长度,低于此值认为提取失败
  515. use_cache: 是否使用缓存,默认 True
  516. force_refresh: 是否强制重新提取(忽略缓存),默认 False
  517. progress_callback: PaddleOCR 进度回调(MinerU 不使用此回调)
  518. Returns:
  519. str: 提取的文本内容
  520. """
  521. # 验证文件存在
  522. if not os.path.exists(file_path):
  523. return f"[错误] 文件不存在: {file_path}"
  524. # 计算文件哈希
  525. file_hash = calculate_file_hash(file_path)
  526. # ── 步骤0: MinerU 云端解析(优先,如果启用)──
  527. use_mineru = os.getenv("MINERU_ENABLED", "false").strip().lower() == "true"
  528. if use_mineru:
  529. print("[PDF] MinerU 已启用,优先使用云端解析...")
  530. mineru_text = _extract_with_mineru(
  531. file_path,
  532. force_refresh=force_refresh,
  533. use_cache=use_cache,
  534. )
  535. if mineru_text and len(mineru_text.strip()) >= min_text_length:
  536. print(f"[PDF] MinerU 解析成功,共 {len(mineru_text)} 字符")
  537. return mineru_text
  538. print("[PDF] MinerU 解析失败或内容不足,回退到本地解析...")
  539. # ── 步骤1: 检查本地缓存 ──
  540. if use_cache and not force_refresh:
  541. cached = _read_cache(file_hash)
  542. if cached:
  543. return cached
  544. # ── 步骤2: PyPDF2 提取 ──
  545. print(f"[PDF] 正在使用 PyPDF2 提取: {file_path}")
  546. text = _extract_with_pypdf(file_path)
  547. if text and len(text.strip()) >= min_text_length:
  548. print(f"[PDF] PyPDF2 提取成功,共 {len(text)} 字符")
  549. if use_cache:
  550. _write_cache(file_hash, text)
  551. return text
  552. # ── 步骤3: PaddleOCR 回退 ──
  553. print(f"[PDF] PyPDF2 提取不足 ({len(text or '')} 字符),回退到 PaddleOCR...")
  554. ocr_text = _extract_with_paddleocr(file_path, progress_callback=progress_callback)
  555. if ocr_text and len(ocr_text.strip()) >= min_text_length:
  556. print(f"[PDF] PaddleOCR 提取成功,共 {len(ocr_text)} 字符")
  557. if use_cache:
  558. _write_cache(file_hash, ocr_text)
  559. return ocr_text
  560. # ── 步骤4: 全部失败 ──
  561. if text and text.strip():
  562. print(f"[PDF] 警告: 提取内容较短 ({len(text)} 字符),但已是全部可用内容")
  563. if use_cache:
  564. _write_cache(file_hash, text)
  565. return text
  566. return "[错误] 无法从 PDF 中提取文本内容。文件可能为扫描件且 OCR 不可用,或文件已损坏。"
  567. # ============================================================
  568. # 快速提取(仅 PyPDF2,不触发 OCR)
  569. # ============================================================
  570. def extract_pdf_text_fast(file_path: str) -> str:
  571. """仅使用 PyPDF2 快速提取 PDF 文本(不触发 OCR)。
  572. 用于 LLM 预览场景,避免 OCR 耗时。
  573. Args:
  574. file_path: PDF 文件路径
  575. Returns:
  576. str: 提取的文本内容
  577. """
  578. text = _extract_with_pypdf(file_path)
  579. return text or "[提示] 该 PDF 可能为扫描件,PyPDF2 无法提取文本,请使用完整 OCR 模式。"