Files
Haocode/tools/builtin_tools/pdf_reader.py
T
sorrow404null a7412824e0 chore: import original project baseline
Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
2026-09-17 16:40:01 +08:00

162 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
PDF 读取工具 (pdf_reader)
=========================
项目内第一个正式内置工具(builtin tool)。对外提供两项能力:
1. ``extract_pdf_text`` —— 提取 PDF 全部文本,按页结构化为提示词友好格式
2. ``extract_pdf_images`` —— 提取 PDF 内嵌的所有图片,逐张落地为 PNG
代码规范与调用模式(约定)
--------------------------
* 每个对外函数都带完整类型注解(参数 + 返回值)。
* docstring 采用 ``Args / Returns / Raises`` 分段,逐参数说明含义与单位。
* 将来的工具注册器(tools/registry.py,尚未实现)可通过
``inspect.signature`` + ``__doc__`` 自动反射出 OpenAI 格式的 Tool JSON Schema
因此本文件的函数签名与文档即"接口契约",请保持稳定、描述清晰。
当前阶段说明
------------
* 本工具暂**不接入** agent 的自动工具调用链路(registry 未实现),
由 UI 附件流程(``ui/views/main_window.py`` 的 PDF 处理)直接 import 调用。
* 参数与返回值已按"可被自动调用"的标准描述清楚,后续接线无需改动函数本身。
依赖与协议
----------
* 依赖 PyMuPDF(导入名 ``pymupdf``)。
* PyMuPDF 为 **AGPL-3.0 / Artifex 商业** 双许可:分发或通过网络提供服务时,
需按 AGPL 开源本项目或购买商业许可。纯自用/内部不分发则无约束。
"""
import os
import uuid
try:
import pymupdf # PyMuPDF >= 1.24 的官方导入名
except ImportError: # 兼容旧版导入名 fitz
import fitz as pymupdf # type: ignore
# 单个 PDF 的大小上限(字节):超过则拒绝解析,避免内存与上下文爆炸
PDF_MAX_BYTES = 20 * 1024 * 1024 # 20 MB
def _open_doc(path: str, max_bytes: int) -> "pymupdf.Document":
"""内部辅助:做存在性/大小校验后打开 PDF,统一异常为 ValueError。"""
if not os.path.isfile(path):
raise ValueError("文件不存在")
size = os.path.getsize(path)
if size > max_bytes:
raise ValueError(
f"PDF 过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB"
)
try:
doc = pymupdf.open(path)
except Exception as e: # pymupdf 抛出的异常类型不固定,统一兜底
raise ValueError(f"PDF 打开失败(可能损坏): {e}")
if doc.needs_pass:
doc.close()
raise ValueError("PDF 已加密,需要密码,暂不支持")
return doc
def extract_pdf_text(path: str, max_bytes: int = PDF_MAX_BYTES) -> tuple:
"""提取 PDF 的全部文本,按页结构化。
输出格式(无文本的空页会被跳过)::
[第 1 页]
<该页文本>
[第 2 页]
<该页文本>
Args:
path: PDF 文件路径(绝对路径,或相对当前工作目录的路径)。
max_bytes: 允许解析的最大文件字节数,默认 ``PDF_MAX_BYTES``20MB)。
Returns:
``(structured_text, page_count)`` 二元组:
* ``structured_text`` (str): 按页拼接的结构化文本,各页之间以空行分隔;
若整份 PDF 无文本则为空字符串 ``""``。
* ``page_count`` (int): PDF 的总页数(含空页)。
Raises:
ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密。
"""
doc = _open_doc(path, max_bytes)
try:
page_count = doc.page_count
parts = []
for i, page in enumerate(doc, 1):
text = page.get_text("text").strip()
if text:
parts.append(f"[第 {i} 页]\n{text}")
return "\n\n".join(parts), page_count
finally:
doc.close()
def extract_pdf_images(path: str, out_dir: str, max_bytes: int = PDF_MAX_BYTES) -> list:
"""提取 PDF 内嵌的所有图片,逐张保存为 PNG。
遍历每一页的内嵌图片(``page.get_images``),用 ``Pixmap`` 解码后写出;
CMYK 色彩空间会自动转换为 RGB(否则无法存为 PNG)。单张图片解码失败
会被跳过,不影响其余图片。
Args:
path: PDF 文件路径。
out_dir: 图片输出目录(建议使用绝对路径)。不存在时自动创建。
max_bytes: 允许解析的最大文件字节数,默认 ``PDF_MAX_BYTES``20MB)。
Returns:
图片元数据列表(按 ``(page, index)`` 升序),每项为 dict::
{
"page": int, # 所在页码(从 1 开始)
"index": int, # 该页内的第几张(从 1 开始)
"abs_path": str, # 落地 PNG 的绝对路径
"mime": str, # 固定为 "image/png"
"size_kb": float, # 文件大小(KB,保留两位小数)
"width": int, # 图片像素宽
"height": int, # 图片像素高
}
PDF 中无内嵌图片时返回空列表 ``[]``。
Raises:
ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密。
"""
doc = _open_doc(path, max_bytes)
os.makedirs(out_dir, exist_ok=True)
tag = uuid.uuid4().hex[:8] # 本批次文件名前缀,避免跨 PDF 撞名
results = []
try:
for page_num, page in enumerate(doc, 1):
for img_index, img_info in enumerate(page.get_images(full=True), 1):
xref = img_info[0]
try:
pix = pymupdf.Pixmap(doc, xref)
if pix.n - pix.alpha >= 4: # CMYK → RGB
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
fname = f"pdfimg_{tag}_p{page_num}_{img_index}.png"
abs_path = os.path.join(out_dir, fname)
pix.save(abs_path)
width, height = pix.width, pix.height
pix = None # 及时释放位图内存
except Exception:
continue # 单张失败不影响整体
results.append({
"page": page_num,
"index": img_index,
"abs_path": abs_path,
"mime": "image/png",
"size_kb": round(os.path.getsize(abs_path) / 1024, 2),
"width": width,
"height": height,
})
return results
finally:
doc.close()