# -*- coding: utf-8 -*- """ PDF 文本提取工具模块 功能: - 可选 MinerU 云端解析(高精度,需网络,由 MINERU_ENABLED 环境变量控制) - 使用 PyPDF2 (pypdf) 提取 PDF 文本内容 - 若 PyPDF2 提取失败或内容为空,回退到 PaddleOCR 进行 OCR 识别 - 支持上传文件保存到临时目录 - 基于文件 MD5 哈希的缓存:同一 PDF 不重复提取 - MinerU 与本地解析使用独立缓存,互不干扰 解析优先级(当 MINERU_ENABLED=true 时): MinerU 云端解析 → PyPDF2 本地提取 → PaddleOCR 回退 工具列表: - extract_pdf_text: 从 PDF 文件提取文本(含缓存,MinerU → PyPDF2 → PaddleOCR) - save_upload_file: 保存上传文件到临时目录 - calculate_file_hash: 计算文件 MD5 哈希 - clear_cache: 清除 PDF 提取缓存 """ import hashlib import os import re import time import tempfile from datetime import datetime from pathlib import Path from typing import Optional from dotenv import load_dotenv load_dotenv() # ============================================================ # 缓存目录 # ============================================================ # 缓存目录:项目根目录下的 data/pdf_cache/ _CACHE_DIR = Path(__file__).parent.parent / "data" / "pdf_cache" # MinerU 缓存文件前缀(与本地解析缓存隔离) _MINERU_CACHE_PREFIX = "mineru_" # ============================================================ # 文件哈希计算 # ============================================================ def calculate_file_hash(file_path: str) -> str: """计算文件的 MD5 哈希值,作为缓存 key。 同一文件(相同内容)产生相同哈希,实现"同一 PDF 不重复提取"。 Args: file_path: 文件路径 Returns: str: 32位 MD5 十六进制字符串 """ try: with open(file_path, 'rb') as f: file_data = f.read() return hashlib.md5(file_data).hexdigest() except Exception as e: print(f"[PDF] 计算文件哈希失败: {e}") # 失败时用时间戳生成唯一 key,确保不误命中缓存 return hashlib.md5(str(datetime.now()).encode()).hexdigest() def _get_cache_path(file_hash: str) -> Path: """获取缓存文件路径。 Args: file_hash: 文件 MD5 哈希值 Returns: Path: 缓存文件路径,如 data/pdf_cache/abc123.txt """ return _CACHE_DIR / f"{file_hash}.txt" def _read_cache(file_hash: str) -> Optional[str]: """从缓存中读取已提取的文本。 Args: file_hash: 文件 MD5 哈希值 Returns: str | None: 缓存文本,不存在或读取失败返回 None """ cache_path = _get_cache_path(file_hash) if not cache_path.exists(): return None try: text = cache_path.read_text(encoding='utf-8') if text.strip(): print(f"[PDF] 命中缓存: {cache_path.name} ({len(text)} 字符)") return text return None except Exception as e: print(f"[PDF] 缓存读取失败: {e}") return None def _write_cache(file_hash: str, text: str): """将提取的文本写入缓存。 Args: file_hash: 文件 MD5 哈希值 text: 提取的文本内容 """ try: _CACHE_DIR.mkdir(parents=True, exist_ok=True) cache_path = _get_cache_path(file_hash) cache_path.write_text(text, encoding='utf-8') print(f"[PDF] 已缓存: {cache_path.name} ({len(text)} 字符)") except Exception as e: print(f"[PDF] 缓存写入失败: {e}") def clear_cache(file_hash: Optional[str] = None): """清除 PDF 提取缓存(同时清除本地解析缓存和 MinerU 缓存)。 Args: file_hash: 指定要清除的哈希(可选),不传则清除全部缓存 """ if file_hash: # 清除本地缓存 local_path = _get_cache_path(file_hash) if local_path.exists(): local_path.unlink() print(f"[PDF] 已清除本地缓存: {local_path.name}") # 清除 MinerU 缓存 mineru_path = _get_mineru_cache_path(file_hash) if mineru_path.exists(): mineru_path.unlink() print(f"[PDF] 已清除 MinerU 缓存: {mineru_path.name}") else: if _CACHE_DIR.exists(): count = 0 for f in _CACHE_DIR.glob("*.txt"): f.unlink() count += 1 print(f"[PDF] 已清除全部缓存 ({count} 个文件)") # ============================================================ # 上传文件保存 # ============================================================ async def save_upload_file(upload_file, upload_dir: Optional[str] = None) -> str: """保存 FastAPI UploadFile 到临时目录,返回文件路径。 Args: upload_file: FastAPI UploadFile 对象 upload_dir: 保存目录,默认为系统临时目录下的 vent_review_pdfs Returns: str: 保存后的文件绝对路径 """ if upload_dir is None: upload_dir = os.path.join(tempfile.gettempdir(), "vent_review_pdfs") os.makedirs(upload_dir, exist_ok=True) # 保留原始文件名,避免冲突加时间戳 import time original_name = upload_file.filename or "upload.pdf" safe_name = f"{int(time.time() * 1000)}_{original_name}" file_path = os.path.join(upload_dir, safe_name) content = await upload_file.read() with open(file_path, "wb") as f: f.write(content) return file_path # ============================================================ # PDF 文本提取 - PyPDF2 方式 # ============================================================ def _extract_with_pypdf(file_path: str) -> Optional[str]: """使用 pypdf (PyPDF2) 提取 PDF 文本。 Args: file_path: PDF 文件路径 Returns: str | None: 提取的文本内容,失败返回 None """ try: from pypdf import PdfReader reader = PdfReader(file_path) pages_text = [] for i, page in enumerate(reader.pages): text = page.extract_text() if text: pages_text.append(f"--- 第 {i + 1} 页 ---\n{text.strip()}") if pages_text: return "\n\n".join(pages_text) return None except ImportError: print("[PDF] pypdf 未安装,跳过 PyPDF2 提取") return None except Exception as e: print(f"[PDF] PyPDF2 提取失败: {e}") return None # ============================================================ # PDF 文本提取 - PaddleOCR 回退方式 # ============================================================ def _pdf_to_images(file_path: str, dpi: int = 150) -> list: """将 PDF 每页转换为 PIL Image 列表。 Args: file_path: PDF 文件路径 dpi: 图片分辨率,默认 150(平衡速度与精度) Returns: list[PIL.Image]: 图片列表 """ try: from pdf2image import convert_from_path return convert_from_path(file_path, dpi=dpi) except ImportError: print("[PDF] pdf2image 未安装,无法转换 PDF 为图片") return [] except Exception as e: print(f"[PDF] PDF 转图片失败: {e}") return [] def _extract_with_paddleocr(file_path: str, progress_callback: callable = None) -> Optional[str]: """使用 PaddleOCR 对 PDF 进行 OCR 识别。 兼容 PaddleOCR v2.x (ocr/cls API) 和 v3.x (predict API)。 先将 PDF 每页转为图片,再逐页 OCR。 Args: file_path: PDF 文件路径 progress_callback: 进度回调,签名 callback(page_num: int, total_pages: int),每页扫描前调用 Returns: str | None: OCR 识别结果,失败返回 None """ try: from paddleocr import PaddleOCR # 检测 API 版本:v3.x 主要使用 predict(),ocr() 已废弃(deprecated) # 判断依据:v3.x 的 predict 方法签名中不含 cls 参数 import inspect _is_v3 = False if hasattr(PaddleOCR, 'predict'): try: sig = inspect.signature(PaddleOCR.predict) _is_v3 = 'cls' not in sig.parameters except Exception: pass # 如果 predict 不存在,则为 v2.x(只有 ocr 方法) if not hasattr(PaddleOCR, 'predict'): _is_v3 = False # 初始化(兼容两种版本 + 多级加速策略) # 加速优先级:onnxruntime → paddle_dynamic+mkldnn → paddle_static+mkldnn → 纯CPU try: if _is_v3: # v3.x 加速参数说明: # - text_det_limit_side_len=960:检测时缩图边长上限,越小越快 # - enable_mkldnn=True:Intel oneDNN 加速(paddle 引擎时有效) # - engine="onnxruntime":ONNX Runtime 推理,CPU 上比 paddle 快 2-5x _v3_base = dict( lang="ch", use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False, text_det_limit_side_len=960, cpu_threads=10, ) # 策略0: onnxruntime(最快,绕过 PIR/onednn 问题) try: ocr = PaddleOCR(**_v3_base, engine="onnxruntime") print("[PDF] PaddleOCR 初始化: onnxruntime") except Exception: # 策略1: paddle_dynamic + mkldnn try: ocr = PaddleOCR(**_v3_base, enable_mkldnn=True, engine="paddle_dynamic") print("[PDF] PaddleOCR 初始化: paddle_dynamic + mkldnn") except Exception: # 策略2: paddle_static + mkldnn(可能触发 PIR 错误) try: ocr = PaddleOCR(**_v3_base, enable_mkldnn=True) print("[PDF] PaddleOCR 初始化: paddle_static + mkldnn") except Exception: # 策略3: 回退到纯 CPU(稳定但慢) try: ocr = PaddleOCR(**_v3_base, enable_mkldnn=False) print("[PDF] PaddleOCR 初始化: paddle_static (无加速)") except Exception: ocr = PaddleOCR(**_v3_base) else: # v2.x: 传统参数 try: ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) except Exception: ocr = PaddleOCR(use_angle_cls=True, lang="ch") except Exception: # 最后兜底:最少参数 ocr = PaddleOCR(lang="ch") # 将 PDF 转为图片 images = _pdf_to_images(file_path) if not images: print("[PDF] PDF 转图片失败,无法进行 OCR") return None # 逐页 OCR total = len(images) pages_text = [] for i, img in enumerate(images): page_num = i + 1 # 进度回调 if progress_callback: try: progress_callback(page_num, total) except Exception: pass # 回调异常不影响主流程 import tempfile with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: img.save(tmp.name) tmp_path = tmp.name try: if _is_v3: # v3.x: predict() → list of OCRResult,传入加速参数 result = ocr.predict( tmp_path, text_det_limit_side_len=960, text_rec_score_thresh=0.5, ) page_text = _extract_text_from_v3_result(result) else: # v2.x: ocr(img, cls=True) → nested list result = ocr.ocr(tmp_path, cls=True) page_text = _extract_text_from_v2_result(result) if page_text: pages_text.append(f"--- 第 {i + 1} 页 (OCR) ---\n{page_text}") finally: try: os.unlink(tmp_path) except OSError: pass if pages_text: return "\n\n".join(pages_text) return None except ImportError: print("[PDF] PaddleOCR 未安装,跳过 OCR 提取") return None except Exception as e: print(f"[PDF] PaddleOCR 提取失败: {e}") import traceback traceback.print_exc() return None def _extract_text_from_v2_result(result) -> str: """从 PaddleOCR v2.x 的 ocr() 返回结果中提取文本。 v2 格式: [[[bbox, (text, confidence)], ...], ...] """ if not result or not result[0]: return "" lines = [] for line in result[0]: if line and len(line) >= 2: text = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1]) lines.append(text) return "\n".join(lines) def _extract_text_from_v3_result(result) -> str: """从 PaddleOCR v3.x 的 predict() 返回结果中提取文本。 v3 格式: list of OCRResult,每个 OCRResult 可通过 ["rec_texts"] 或 str() 获取文本。 """ if not result: return "" lines = [] for res in result: # 尝试多种方式提取文本 if hasattr(res, '__getitem__'): try: rec_texts = res["rec_texts"] if rec_texts: lines.extend(rec_texts if isinstance(rec_texts, list) else [str(rec_texts)]) continue except (KeyError, TypeError): pass # 尝试 str() text = str(res) if text: lines.append(text) return "\n".join(lines) # ============================================================ # PDF 页数统计 # ============================================================ def _count_pdf_pages(file_path: str) -> int: """统计 PDF 文件的总页数。 Args: file_path: PDF 文件路径 Returns: int: 总页数,失败返回 0 """ try: from pypdf import PdfReader reader = PdfReader(file_path) return len(reader.pages) except Exception as e: print(f"[PDF] 无法获取 PDF 页数: {e}") return 0 # ============================================================ # MinerU 缓存(独立于本地解析缓存) # ============================================================ def _get_mineru_cache_path(file_hash: str) -> Path: """获取 MinerU 专用缓存文件路径。 Args: file_hash: 文件 MD5 哈希值 Returns: Path: 缓存文件路径,如 data/pdf_cache/mineru_abc123.txt """ return _CACHE_DIR / f"{_MINERU_CACHE_PREFIX}{file_hash}.txt" def _read_mineru_cache(file_hash: str) -> Optional[str]: """从 MinerU 专用缓存中读取已提取的文本。 Args: file_hash: 文件 MD5 哈希值 Returns: str | None: 缓存文本,不存在或读取失败返回 None """ cache_path = _get_mineru_cache_path(file_hash) if not cache_path.exists(): return None try: text = cache_path.read_text(encoding='utf-8') if text.strip(): print(f"[PDF] MinerU 缓存命中: {cache_path.name} ({len(text)} 字符)") return text return None except Exception as e: print(f"[PDF] MinerU 缓存读取失败: {e}") return None def _write_mineru_cache(file_hash: str, text: str): """将 MinerU 提取的文本写入专用缓存。 Args: file_hash: 文件 MD5 哈希值 text: 提取的文本内容 """ try: _CACHE_DIR.mkdir(parents=True, exist_ok=True) cache_path = _get_mineru_cache_path(file_hash) cache_path.write_text(text, encoding='utf-8') print(f"[PDF] MinerU 已缓存: {cache_path.name} ({len(text)} 字符)") except Exception as e: print(f"[PDF] MinerU 缓存写入失败: {e}") # ============================================================ # MinerU 文本清洗 # ============================================================ def _clean_mineru_text(text: str) -> str: """清洗 MinerU 输出的文本。 - 去除每行首尾空白 - 合并多余空行(连续 3 个以上空行压缩为 2 个) Args: text: MinerU 原始输出文本 Returns: str: 清洗后的文本 """ if not text: return "" text = "\n".join(line.strip() for line in text.splitlines()) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip() # ============================================================ # MinerU 云端 PDF 解析 # ============================================================ def _extract_with_mineru(file_path: str, force_refresh: bool = False, use_cache: bool = True) -> Optional[str]: """使用 MinerU 云端服务解析 PDF(高精度,需要网络)。 解析流程: 1. 检查 MinerU 专用缓存(mineru_{hash}.txt) 2. 若缓存命中且非强制刷新,直接返回 3. 通过 langchain_mineru.MinerULoader 分片加载 PDF 4. 清洗每页文本并拼接 5. 写入 MinerU 专用缓存 Args: file_path: PDF 文件路径 force_refresh: 是否强制重新提取(忽略缓存) use_cache: 是否使用缓存 Returns: str | None: 提取的文本内容,失败返回 None """ mineru_token = os.getenv("MINERU_TOKEN", "").strip() mineru_max_pages = int(os.getenv("MINERU_MAX_PAGES", "50")) if not mineru_token: print("[PDF] MINERU_TOKEN 未配置,跳过 MinerU 解析") return None file_hash = calculate_file_hash(file_path) # ── 检查 MinerU 专用缓存 ── if use_cache and not force_refresh: cached = _read_mineru_cache(file_hash) if cached is not None: return cached # ── 统计总页数 ── total_pages = _count_pdf_pages(file_path) if total_pages == 0: print("[PDF] 无法获取 PDF 页数,跳过 MinerU 解析") return None print(f"[PDF] PDF总页数:{total_pages}(MinerU 云端解析)") start_time = time.time() all_text_parts = [] try: from langchain_mineru import MinerULoader for start in range(1, total_pages + 1, mineru_max_pages): end = min(start + mineru_max_pages - 1, total_pages) print(f"[PDF] MinerU 正在加载分片:{start} ~ {end} 页") loader = MinerULoader( source=file_path, mode="precision", token=mineru_token, pages=f"{start}-{end}", ) docs = loader.load() for idx, doc in enumerate(docs): cleaned = _clean_mineru_text(doc.page_content) if cleaned: all_text_parts.append( f"--- 第 {start + idx} 页 (MinerU) ---\n{cleaned}" ) if not all_text_parts: print("[PDF] MinerU 未提取到任何文本内容") return None full_text = "\n\n".join(all_text_parts) # ── 写入 MinerU 专用缓存 ── if use_cache: _write_mineru_cache(file_hash, full_text) elapsed = round(time.time() - start_time, 2) print(f"[PDF] MinerU 解析完成 | 总页数:{total_pages} | 总耗时:{elapsed} 秒") return full_text except ImportError: print("[PDF] langchain-mineru 未安装,跳过 MinerU 解析") return None except Exception as e: print(f"[PDF] MinerU 解析失败: {e}") import traceback traceback.print_exc() return None # ============================================================ # 主提取函数 # ============================================================ def extract_pdf_text(file_path: str, min_text_length: int = 50, use_cache: bool = True, force_refresh: bool = False, progress_callback: callable = None) -> str: """从 PDF 文件提取文本。 提取策略(按优先级): 0. 若 MINERU_ENABLED=true,优先使用 MinerU 云端解析(高精度,需网络) - 失败或内容不足则自动回退到本地解析 1. 计算文件 MD5 哈希,若本地缓存命中且非强制刷新,直接返回 2. 尝试 PyPDF2 (pypdf) 直接提取文本 3. 若提取内容为空或长度不足 min_text_length,回退到 PaddleOCR 4. 提取成功后将结果写入缓存 5. 若全部失败,返回错误信息 Args: file_path: PDF 文件路径 min_text_length: 最小有效文本长度,低于此值认为提取失败 use_cache: 是否使用缓存,默认 True force_refresh: 是否强制重新提取(忽略缓存),默认 False progress_callback: PaddleOCR 进度回调(MinerU 不使用此回调) Returns: str: 提取的文本内容 """ # 验证文件存在 if not os.path.exists(file_path): return f"[错误] 文件不存在: {file_path}" # 计算文件哈希 file_hash = calculate_file_hash(file_path) # ── 步骤0: MinerU 云端解析(优先,如果启用)── use_mineru = os.getenv("MINERU_ENABLED", "false").strip().lower() == "true" if use_mineru: print("[PDF] MinerU 已启用,优先使用云端解析...") mineru_text = _extract_with_mineru( file_path, force_refresh=force_refresh, use_cache=use_cache, ) if mineru_text and len(mineru_text.strip()) >= min_text_length: print(f"[PDF] MinerU 解析成功,共 {len(mineru_text)} 字符") return mineru_text print("[PDF] MinerU 解析失败或内容不足,回退到本地解析...") # ── 步骤1: 检查本地缓存 ── if use_cache and not force_refresh: cached = _read_cache(file_hash) if cached: return cached # ── 步骤2: PyPDF2 提取 ── print(f"[PDF] 正在使用 PyPDF2 提取: {file_path}") text = _extract_with_pypdf(file_path) if text and len(text.strip()) >= min_text_length: print(f"[PDF] PyPDF2 提取成功,共 {len(text)} 字符") if use_cache: _write_cache(file_hash, text) return text # ── 步骤3: PaddleOCR 回退 ── print(f"[PDF] PyPDF2 提取不足 ({len(text or '')} 字符),回退到 PaddleOCR...") ocr_text = _extract_with_paddleocr(file_path, progress_callback=progress_callback) if ocr_text and len(ocr_text.strip()) >= min_text_length: print(f"[PDF] PaddleOCR 提取成功,共 {len(ocr_text)} 字符") if use_cache: _write_cache(file_hash, ocr_text) return ocr_text # ── 步骤4: 全部失败 ── if text and text.strip(): print(f"[PDF] 警告: 提取内容较短 ({len(text)} 字符),但已是全部可用内容") if use_cache: _write_cache(file_hash, text) return text return "[错误] 无法从 PDF 中提取文本内容。文件可能为扫描件且 OCR 不可用,或文件已损坏。" # ============================================================ # 快速提取(仅 PyPDF2,不触发 OCR) # ============================================================ def extract_pdf_text_fast(file_path: str) -> str: """仅使用 PyPDF2 快速提取 PDF 文本(不触发 OCR)。 用于 LLM 预览场景,避免 OCR 耗时。 Args: file_path: PDF 文件路径 Returns: str: 提取的文本内容 """ text = _extract_with_pypdf(file_path) return text or "[提示] 该 PDF 可能为扫描件,PyPDF2 无法提取文本,请使用完整 OCR 模式。"