Browse Source

pdf_converter_v2: 同步 GitLab/Clerk2.5 修改

- API: pdf_to_markdown 异步/task_id、去水印/裁剪、return_images、zip 下载、OpenAPI 说明
- 转换: PDF 按 50 页切割、重试时重新打开文件、convert_pdf_to_markdown_only return_images
- 工具: pdf_splitter(按页切割)、pdf_watermark_remover 保持文件打开
- requirements 完善;scripts: mineru-api.service、start_mineru_api.sh
何文松 3 weeks ago
parent
commit
d04debc556

+ 454 - 0
pdf_converter_v2/README.md

@@ -0,0 +1,454 @@
+# PDF Converter v2
+
+PDF转换工具 v2版本 - 使用新的API接口进行PDF转换
+
+## 主要特性
+
+v2版本通过调用新的API接口(`http://192.168.2.3:8000/file_parse`)进行PDF转换,API返回zip文件,然后从zip中提取md文件进行原有的json解析逻辑。
+
+## 主要改进
+
+1. **API简化**: 大幅简化API参数,只需指定文件类型即可
+2. **格式支持**: 支持PDF和图片格式(PNG、JPG、JPEG、BMP、TIFF、WEBP等)
+3. **智能限制**: 自动检测页数,超过300页自动拒绝处理
+4. **类型指定**: 支持指定文档类型(噪声记录、电磁记录、工况信息)
+5. **独立解析**: 工况信息支持单独解析返回
+6. **部署优化**: 支持命令行参数和systemd服务部署
+7. **性能优化**: 使用外部API接口,转换速度更快
+8. **保持兼容**: 复用v1的json解析逻辑,保持输出格式一致
+
+## 使用方法
+
+### 命令行使用
+
+```bash
+# 基本使用
+python -m pdf_converter_v2 input.pdf
+
+# 指定输出目录
+python -m pdf_converter_v2 input.pdf -o ./output
+
+# 同时输出JSON格式
+python -m pdf_converter_v2 input.pdf --output-json
+
+# 自定义API服务器地址
+python -m pdf_converter_v2 input.pdf --url http://192.168.2.3:8000
+
+# 更多选项
+python -m pdf_converter_v2 input.pdf --help
+```
+
+### Python代码使用
+
+```python
+import asyncio
+from pdf_converter_v2.processor.converter import convert_to_markdown
+
+async def main():
+    result = await convert_to_markdown(
+        input_file="input.pdf",
+        output_dir="./output",
+        output_json=True,
+        url="http://192.168.2.3:8000"
+    )
+    print(f"Markdown文件: {result['markdown_file']}")
+    if result.get('json_file'):
+        print(f"JSON文件: {result['json_file']}")
+
+asyncio.run(main())
+```
+
+## API接口说明
+
+### FastAPI服务接口
+
+**启动服务:**
+```bash
+# 使用默认配置
+python pdf_converter_v2/api_server.py
+
+# 指定端口和主机
+python pdf_converter_v2/api_server.py --host 0.0.0.0 --port 4214
+
+# 查看帮助
+python pdf_converter_v2/api_server.py --help
+```
+
+**主要端点:**
+- `POST /convert`: 转换文件(异步处理)
+  - 参数:
+    - `file` (required): PDF或图片文件
+    - `type` (optional): 文档类型 (`noiseRec` | `emRec` | `opStatus`)
+- `GET /task/{task_id}`: 查询任务状态
+- `GET /task/{task_id}/json`: 直接获取JSON数据
+- `GET /download/{task_id}/markdown`: 下载Markdown文件
+- `GET /download/{task_id}/json`: 下载JSON文件
+- `DELETE /task/{task_id}`: 删除任务
+
+**示例调用:**
+```bash
+# 上传文件并指定类型
+curl -X POST "http://localhost:4214/convert" \
+  -F "file=@example.pdf" \
+  -F "type=noiseRec"
+
+# 查询任务状态
+curl "http://localhost:4214/task/{task_id}"
+
+# 获取JSON数据
+curl "http://localhost:4214/task/{task_id}/json"
+```
+
+### 外部API接口
+
+v2版本内部调用的外部API接口:
+
+- **URL**: `http://192.168.2.3:8000/file_parse`
+- **方法**: POST
+- **Content-Type**: multipart/form-data
+- **返回格式**: zip文件
+
+### 文档类型说明
+
+| 参数值 | 中文名称 | 正式全称(代码内) |
+|--------|---------|------------------|
+| `noiseRec` | 噪声原始记录 | `noiseMonitoringRecord` |
+| `emRec` | 电磁原始记录 | `electromagneticTestRecord` |
+| `opStatus` | 工况信息 | `operatingConditionInfo` |
+
+## 文件结构
+
+```
+pdf_converter_v2/
+├── __init__.py
+├── __main__.py
+├── main.py                 # 命令行入口
+├── processor/
+│   ├── __init__.py
+│   └── converter.py       # 核心转换逻辑
+├── parser/
+│   ├── __init__.py
+│   └── json_converter.py  # JSON解析(复用v1逻辑)
+└── utils/
+    ├── __init__.py
+    ├── file_utils.py      # 文件工具函数
+    └── logging_config.py # 日志配置
+```
+
+## 安装依赖
+
+### 确定使用的Python环境
+
+在安装依赖之前,需要确定服务使用的Python环境:
+
+**方法1:使用检查脚本(推荐)**
+```bash
+cd /home/hws/workspace/GitLab/Clerk2.5/pdf_converter_v2
+bash check_python_env.sh
+```
+
+**方法2:手动检查**
+```bash
+# 检查systemd服务使用的Python
+cat /etc/systemd/system/pdf-converter-v2.service | grep ExecStart
+
+# 检查运行中的进程
+ps aux | grep pdf-converter-v2 | grep python
+
+# 检查项目使用的Python(查看api_server.py第一行)
+head -1 api_server.py
+
+# 检查默认Python
+which python3
+python3 --version
+```
+
+**方法3:通过Python代码检查**
+```bash
+# 在Python中检查
+python3 -c "import sys; print('Python路径:', sys.executable); print('Python版本:', sys.version)"
+```
+
+### 快速安装(推荐)
+
+确定Python环境后,使用对应的pip安装:
+
+```bash
+# 如果使用 python3,使用 pip3
+pip3 install -r requirements.txt
+
+# 如果使用 python,使用 pip
+pip install -r requirements.txt
+
+# 如果不确定,使用 python -m pip(推荐)
+python3 -m pip install -r requirements.txt
+```
+
+### 手动安装
+
+**必需依赖:**
+```bash
+# 根据你的Python环境选择对应的pip命令
+pip3 install aiohttp aiofiles Pillow
+# 或
+python3 -m pip install aiohttp aiofiles Pillow
+```
+
+**PDF处理库(至少安装一个):**
+```bash
+# 方案1:安装 pypdfium2(推荐,文件更小)
+pip3 install pypdfium2
+# 或
+python3 -m pip install pypdfium2
+
+# 方案2:安装 pdf2image(备用方案,需要系统安装 poppler)
+# Ubuntu/Debian: sudo apt-get install poppler-utils
+# CentOS/RHEL: sudo yum install poppler-utils
+# macOS: brew install poppler
+pip3 install pdf2image
+# 或
+python3 -m pip install pdf2image
+```
+
+**如果使用API服务:**
+```bash
+pip3 install fastapi uvicorn[standard] pydantic typing-extensions
+# 或
+python3 -m pip install fastapi uvicorn[standard] pydantic typing-extensions
+```
+
+**日志库(至少安装一个):**
+```bash
+pip3 install loguru
+# 或
+python3 -m pip install loguru
+# 或使用 happy-python
+pip3 install happy-python
+```
+
+### 系统依赖
+
+如果使用 `pdf2image`,需要安装系统级的 `poppler` 工具:
+
+- **Ubuntu/Debian:**
+  ```bash
+  sudo apt-get update
+  sudo apt-get install poppler-utils
+  ```
+
+- **CentOS/RHEL:**
+  ```bash
+  sudo yum install poppler-utils
+  ```
+
+- **macOS:**
+  ```bash
+  brew install poppler
+  ```
+
+## 依赖要求
+
+- **aiohttp**: 异步HTTP客户端
+- **aiofiles**: 异步文件操作
+- **Pillow**: 图片处理(必需)
+- **pypdfium2** 或 **pdf2image**: PDF转图片(至少安装一个,推荐 pypdfium2)
+- **loguru** 或 **happy-python**: 日志记录(至少安装一个)
+- **fastapi, uvicorn, pydantic**: Web框架(仅在使用API服务时需要)
+
+## 与v1版本的区别
+
+| 特性 | v1版本 | v2版本 |
+|------|--------|--------|
+| PDF处理方式 | 本地MinerU处理 | API接口处理 |
+| 返回格式 | 直接markdown | zip文件(包含md) |
+| 性能 | 本地处理 | 服务器端处理(更快) |
+| JSON解析 | 直接解析 | 复用v1逻辑 |
+
+## 服务部署
+
+### 使用 systemd 服务
+
+1. **安装服务文件:**
+```bash
+sudo cp pdf-converter-v2.service /etc/systemd/system/
+sudo systemctl daemon-reload
+```
+
+2. **修改配置:**
+编辑 `/etc/systemd/system/pdf-converter-v2.service`,根据实际情况修改:
+- `WorkingDirectory`: 工作目录路径
+- `ExecStart`: Python路径和脚本路径
+- 环境变量配置
+
+3. **启动服务:**
+```bash
+sudo systemctl start pdf-converter-v2
+sudo systemctl enable pdf-converter-v2  # 开机自启
+sudo systemctl status pdf-converter-v2  # 查看状态
+```
+
+4. **查看日志:**
+```bash
+sudo journalctl -u pdf-converter-v2 -f
+```
+
+### 环境变量配置
+
+主要环境变量:
+- `API_URL`: 外部API地址(默认: http://192.168.2.3:8000)
+- `API_HOST`: 服务监听地址(默认: 0.0.0.0)
+- `API_PORT`: 服务监听端口(默认: 4214)
+- `LOG_LEVEL`: 日志级别(默认: info)
+- `PDF_CONVERTER_LOG_DIR`: 日志目录(默认: ./logs)
+
+## 注意事项
+
+1. **API服务器**: 确保外部API服务器(`http://192.168.2.3:8000`)正常运行
+2. **网络连接**: v2版本需要网络连接以访问外部API
+3. **页数限制**: 文件页数不能超过300页,超过会自动拒绝
+4. **文件格式**: 支持PDF和常见图片格式(PNG、JPG、JPEG、BMP、TIFF、WEBP)
+5. **输出格式**: JSON输出格式与v1版本保持一致
+6. **工况信息**: 工况信息可以单独解析(`type=opStatus`),也可以包含在噪声记录中
+
+## 容器/NPU 环境额外依赖与常见错误
+
+在 **Docker 或 NPU 容器** 内运行 pdf_converter_v2 API 时,若出现以下错误,按下面步骤处理。
+
+### 1. 去水印失败:`pdfinfo` 未找到(poppler)
+
+**现象**:`PDFInfoNotInstalledError: Unable to get page count. Is poppler installed and in PATH?`
+
+**原因**:`pdf2image` 依赖系统提供的 `pdfinfo`(poppler-utils),容器内未安装。
+
+**解决**:在运行 **pdf_converter_v2 API** 的容器内安装 poppler:
+
+```bash
+# Debian/Ubuntu
+apt-get update && apt-get install -y poppler-utils
+
+# CentOS/RHEL
+yum install -y poppler-utils
+```
+
+### 2. 附件页切割失败:缺少 `pdfplumber`
+
+**现象**:`No module named 'pdfplumber'`
+
+**原因**:API 进程所在 Python 环境未安装 `pdfplumber`。
+
+**解决**:在 **pdf_converter_v2 API** 所在环境安装依赖:
+
+```bash
+pip install pdfplumber
+# 或安装 NPU 环境完整依赖
+pip install -r pdf_converter_v2/requirements-paddle-npu.txt
+```
+
+### 3. MinerU 报错:`operator torchvision::nms does not exist`
+
+**现象**:调用 MinerU API(`/file_parse`)返回 500,日志中 `RuntimeError: operator torchvision::nms does not exist`。
+
+**原因**:MinerU 使用的 `torch` 与 `torchvision` 版本不匹配(常见于 ARM/aarch64 或 NPU 自定义 PyTorch 构建)。
+
+**解决**:在 **运行 MinerU API** 的容器/环境中,安装版本匹配的 PyTorch 与 torchvision(参见项目根目录 [MINERU_DEPLOYMENT.md](../MINERU_DEPLOYMENT.md) 中「常见问题:torchvision::nms」)。简要步骤:
+
+- 使用同一来源、同一版本的 `torch` 和 `torchvision`(如官方 wheel 或 NPU 厂商提供的配对版本)。
+- 若曾单独升级/降级过 PyTorch,需同时重装匹配的 torchvision,或先卸载两者再一起安装。
+
+### 4. MinerU 报错:No module named 'tbe' / ACL 500001(NPU)
+
+**现象**:调用 MinerU API 返回 500,日志中 `ModuleNotFoundError: No module named 'tbe'` 或 `SetPrecisionMode ... error code is 500001`、`GEInitialize failed`。
+
+**原因**:启动 MinerU 前未加载华为昇腾 CANN 环境,NPU 运行时无法找到 `tbe` 等模块。
+
+**解决**:在 **启动 MinerU API** 前加载 CANN 的 `set_env.sh`,或改用 CPU:
+
+- **加载 CANN**:`source /usr/local/Ascend/ascend-toolkit/set_env.sh`(路径以实际安装为准),再启动 MinerU。
+- **使用启动脚本**:设置 `export ASCEND_ENV=/usr/local/Ascend/ascend-toolkit/set_env.sh` 后执行 `start_mineru_in_container.sh`,脚本会自动 source。
+- **临时用 CPU**:`export MINERU_DEVICE_MODE=cpu` 后再启动 MinerU,可先跑通流程(速度较慢)。
+
+详见项目根目录 [MINERU_DEPLOYMENT.md](../MINERU_DEPLOYMENT.md) 中「常见问题:No module named 'tbe' / ACL 500001」。
+
+### 5. MinerU 报错:Hugging Face 无法连接 / 模型下载失败
+
+**现象**:调用 MinerU API 返回 500,日志中 `Network is unreachable`、`LocalEntryNotFoundError`、`opendatalab/PDF-Extract-Kit-1.0` 等,无法从 Hugging Face 下载模型。
+
+**原因**:MinerU 默认从 `huggingface.co` 拉取模型,内网或无法访问外网时会失败。
+
+**解决**:使用 **ModelScope** 作为模型来源(国内可访问):
+
+- **启动前设置**:`export MINERU_MODEL_SOURCE=modelscope`,再启动 MinerU。
+- **使用启动脚本**:`start_mineru_in_container.sh` 已默认使用 `MINERU_MODEL_SOURCE=modelscope`;若需用 Hugging Face,可设置 `export MINERU_MODEL_SOURCE=huggingface` 后执行脚本。
+- **首次使用 ModelScope**:需安装 `pip install modelscope`,模型会下载到 ModelScope 默认缓存目录。
+
+## 多 NPU 配置(MinerU 与 PaddleOCR)
+
+多张昇腾 NPU 时,可按「单进程指定卡」或「多进程多卡」方式配置。
+
+### 1. 指定使用某一张 NPU(单进程)
+
+- **MinerU**:通过环境变量指定设备号(逻辑编号从 0 起):
+  ```bash
+  export MINERU_DEVICE_MODE=npu:0   # 使用第 0 号 NPU
+  export MINERU_DEVICE_MODE=npu:1   # 使用第 1 号 NPU
+  ```
+  再启动 MinerU API(如 `start_mineru_in_container.sh`)。
+
+- **PaddleOCR**(pdf_converter_v2 内调用):通过环境变量指定设备号:
+  ```bash
+  export PADDLE_OCR_DEVICE=npu:0    # 使用第 0 号 NPU
+  export PADDLE_OCR_DEVICE=npu:1    # 使用第 1 号 NPU
+  ```
+  再启动 pdf_converter_v2 API(如 `start_api_in_container.sh`)。
+
+### 2. 限制进程可见的 NPU(物理卡映射)
+
+若希望某进程只看到部分物理卡(再在进程内用 `npu:0`、`npu:1` 指逻辑卡),可在**启动该进程前**设置昇腾可见设备(与 CUDA 的 `CUDA_VISIBLE_DEVICES` 类似):
+
+```bash
+# 仅让当前进程看到物理卡 1(在进程内为 npu:0)
+export ASCEND_RT_VISIBLE_DEVICES=1
+
+# 让当前进程看到物理卡 2、3(在进程内为 npu:0、npu:1)
+export ASCEND_RT_VISIBLE_DEVICES=2,3
+```
+
+再设置 `MINERU_DEVICE_MODE=npu:0` 或 `PADDLE_OCR_DEVICE=npu:0` 等,即使用上述「可见」卡中的逻辑编号。
+
+### 3. 多进程多卡(多个 MinerU API 实例)
+
+多张 NPU 时,可起多个 MinerU API 进程,每个进程绑定一张卡、不同端口,再由负载均衡或 pdf_converter_v2 配置多后端:
+
+| 实例 | 环境变量 | 端口 |
+|------|----------|------|
+| MinerU 实例 1 | `MINERU_DEVICE_MODE=npu:0` | 5282 |
+| MinerU 实例 2 | `MINERU_DEVICE_MODE=npu:1` | 5283 |
+
+示例(在同一台机起两个 MinerU,不同卡、不同端口):
+
+```bash
+# 终端 1:使用 npu:0,端口 5282
+export MINERU_DEVICE_MODE=npu:0
+export MINERU_PORT=5282
+sh pdf_converter_v2/scripts/start_mineru_in_container.sh
+
+# 终端 2:使用 npu:1,端口 5283
+export MINERU_DEVICE_MODE=npu:1
+export MINERU_PORT=5283
+sh pdf_converter_v2/scripts/start_mineru_in_container.sh
+```
+
+pdf_converter_v2 API 默认只连一个 MinerU 地址(如 `API_URL=http://127.0.0.1:5282`)。若要轮询多实例,需在应用层或反向代理(如 Nginx)做负载均衡,或后续在 pdf_converter_v2 中支持多 MinerU 地址配置。
+
+### 4. 小结
+
+| 组件 | 环境变量 | 示例 |
+|------|----------|------|
+| MinerU | `MINERU_DEVICE_MODE` | `npu`、`npu:0`、`npu:1` |
+| PaddleOCR | `PADDLE_OCR_DEVICE` | `npu:0`、`npu:1` |
+| 昇腾可见卡 | `ASCEND_RT_VISIBLE_DEVICES` | `0`、`1,2`(物理卡号) |
+
+## 更新说明
+
+详细更新内容请参考项目根目录的 [CHANGELOG.md](../CHANGELOG.md)
+

+ 292 - 63
pdf_converter_v2/api/main.py

@@ -11,6 +11,7 @@ import tempfile
 import uuid
 import base64
 import json
+import zipfile
 from pathlib import Path
 from typing import Optional, List
 from urllib.parse import quote
@@ -23,6 +24,7 @@ from typing_extensions import Annotated, Literal
 
 from ..processor.converter import convert_to_markdown, convert_pdf_to_markdown_only
 from ..utils.logging_config import get_logger
+from ..utils.pdf_watermark_remover import remove_watermark_from_pdf, crop_header_footer_from_pdf
 
 # 尝试导入配置,如果不存在则使用默认值
 try:
@@ -239,10 +241,11 @@ async def root():
         },
         "endpoints": {
             "POST /convert": "转换PDF/图片文件(异步,立即返回task_id)",
-            "POST /pdf_to_markdown": "PDF/图片转 Markdown(同步,默认返回 .md 文件下载,format=json 可返回 JSON)",
+            "POST /pdf_to_markdown": "PDF/图片转 Markdown(异步,立即返回task_id,通过 task_id 查询状态并下载 .md)",
             "GET /task/{task_id}": "查询任务状态(轮询接口)",
             "GET /task/{task_id}/json": "直接获取JSON数据(返回JSON对象,不下载文件)",
             "GET /download/{task_id}/markdown": "下载Markdown文件",
+            "GET /download/{task_id}/zip": "下载 md+图片 压缩包(需 POST /pdf_to_markdown 时 return_images=true)",
             "GET /download/{task_id}/json": "下载JSON文件",
             "DELETE /task/{task_id}": "删除任务及其临时文件",
             "GET /health": "健康检查"
@@ -543,6 +546,109 @@ async def process_conversion_task(
     # 这样可以方便用户查看上传的文件内容
 
 
+async def process_pdf_to_markdown_task(
+    task_id: str,
+    file_path: str,
+    output_dir: str,
+    backend: str,
+    remove_watermark: bool,
+    watermark_light_threshold: int,
+    watermark_saturation_threshold: int,
+    crop_header_footer: bool,
+    header_ratio: float,
+    footer_ratio: float,
+    return_images: bool = False,
+):
+    """后台执行 PDF/图片转 Markdown(仅转 MD,无 doc_type 等)。"""
+    try:
+        logger.info(f"[任务 {task_id}] PDF转Markdown 后台任务开始...")
+        task_status[task_id]["status"] = "processing"
+        task_status[task_id]["message"] = "正在转换 PDF/图片为 Markdown..."
+
+        ext = (Path(file_path).suffix or "").lower()
+        is_pdf = ext == ".pdf"
+        current_path = file_path
+
+        if is_pdf and remove_watermark:
+            next_path = os.path.join(os.path.dirname(output_dir), "no_watermark.pdf")
+            ok = await asyncio.to_thread(
+                remove_watermark_from_pdf,
+                current_path,
+                next_path,
+                light_threshold=watermark_light_threshold,
+                saturation_threshold=watermark_saturation_threshold,
+            )
+            if ok:
+                current_path = next_path
+            else:
+                logger.warning(f"[任务 {task_id}] 去水印失败,使用原文件继续")
+        if is_pdf and crop_header_footer:
+            next_path = os.path.join(os.path.dirname(output_dir), "cropped.pdf")
+            ok = await asyncio.to_thread(
+                crop_header_footer_from_pdf,
+                current_path,
+                next_path,
+                header_ratio=header_ratio,
+                footer_ratio=footer_ratio,
+            )
+            if ok:
+                current_path = next_path
+            else:
+                logger.warning(f"[任务 {task_id}] 页眉页脚裁剪失败,使用原文件继续")
+
+        api_url = os.getenv("API_URL", "http://127.0.0.1:5282")
+        result = await convert_pdf_to_markdown_only(
+            input_file=current_path,
+            output_dir=output_dir,
+            backend=backend,
+            url=api_url,
+            return_images=return_images,
+        )
+        if not result:
+            task_status[task_id]["status"] = "failed"
+            task_status[task_id]["message"] = "转换失败"
+            task_status[task_id]["error"] = "PDF 转 Markdown 返回空"
+            logger.error(f"[任务 {task_id}] PDF 转 Markdown 返回空")
+            return
+
+        md_content = result.get("markdown", "")
+        filename = result.get("filename", "output.md")
+        if not filename.endswith(".md"):
+            filename = filename + ".md"
+        markdown_file_path = os.path.join(output_dir, filename)
+        with open(markdown_file_path, "w", encoding="utf-8") as f:
+            f.write(md_content)
+
+        task_status[task_id]["status"] = "completed"
+        task_status[task_id]["message"] = "转换成功"
+        task_status[task_id]["markdown_file"] = markdown_file_path
+        task_status[task_id]["json_data"] = {"markdown": md_content, "filename": filename}
+        task_status[task_id]["document_type"] = None
+
+        if return_images:
+            zip_path = os.path.join(output_dir, "markdown_with_images.zip")
+            try:
+                with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+                    for root, _, files in os.walk(output_dir):
+                        for f in files:
+                            if f == "markdown_with_images.zip":
+                                continue
+                            abs_path = os.path.join(root, f)
+                            arcname = os.path.relpath(abs_path, output_dir)
+                            zf.write(abs_path, arcname)
+                task_status[task_id]["zip_file"] = zip_path
+                logger.info(f"[任务 {task_id}] 已打包 md+图片: {zip_path}")
+            except Exception as e:
+                logger.warning(f"[任务 {task_id}] 打包 zip 失败: {e}")
+
+        logger.info(f"[任务 {task_id}] PDF 转 Markdown 完成: {markdown_file_path}")
+    except Exception as e:
+        task_status[task_id]["status"] = "failed"
+        task_status[task_id]["message"] = f"处理出错: {str(e)}"
+        task_status[task_id]["error"] = str(e)
+        logger.exception(f"[任务 {task_id}] PDF 转 Markdown 失败: {e}")
+
+
 @app.post("/convert", response_model=ConversionResponse)
 async def convert_file(
     file: Annotated[UploadFile, File(description="上传的PDF或图片文件")],
@@ -631,7 +737,7 @@ async def convert_file(
     except Exception as e:
         raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}")
     
-    # 计算页数并限制:>20页直接报错;图片按1页处理
+    # 计算页数并限制:>300页直接报错;图片按1页处理
     try:
         suffix = (Path(file_path).suffix or "").lower()
         pages = 1
@@ -648,13 +754,13 @@ async def convert_file(
         else:
             # 常见图片格式视为单页
             pages = 1
-        if pages > 20:
+        if pages > 300:
             # 清理临时目录后报错
             try:
                 shutil.rmtree(temp_dir)
             except Exception:
                 pass
-            raise HTTPException(status_code=400, detail="文件页数超过20页,拒绝处理")
+            raise HTTPException(status_code=400, detail="文件页数超过300页,拒绝处理")
         logger.info(f"[任务 {task_id}] 页数评估: {pages}")
     except HTTPException:
         raise
@@ -730,83 +836,181 @@ async def convert_file(
     )
 
 
+PDF_TO_MARKDOWN_DESCRIPTION = """
+将 **PDF 或图片** 转为纯 Markdown 文本的异步接口。提交后立即返回 `task_id`,不等待转换完成。
+
+## 调用流程
+
+1. **POST 本接口**:上传文件(`multipart/form-data`),请求体需包含 `file` 及可选表单项;响应返回 `task_id`、`status: "pending"`。
+2. **轮询状态**:**GET /task/{task_id}**,直到 `status` 为 `completed` 或 `failed`。
+3. **获取结果**(仅当 `status == "completed"` 时):
+   - **GET /download/{task_id}/markdown**:下载生成的 `.md` 文件;
+   - **GET /task/{task_id}/json**:获取 JSON `{ "markdown": "全文", "filename": "xxx.md" }`;
+   - 若提交时传了 **return_images=true**:**GET /download/{task_id}/zip** 下载 Markdown + 图片压缩包。
+
+## 参数说明
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| file | file | 是 | PDF 或图片文件(如 PNG、JPG)。 |
+| backend | string | 否 | 识别引擎:`mineru`(默认,MinerU file_parse)或 `paddle`(PaddleOCR doc_parser)。 |
+| remove_watermark | boolean | 否 | 是否先对 PDF 去水印,默认 `false`,仅对 PDF 生效。 |
+| watermark_light_threshold | integer | 否 | 去水印亮度阈值 0–255,默认 200。 |
+| watermark_saturation_threshold | integer | 否 | 去水印饱和度阈值 0–255,默认 30。 |
+| crop_header_footer | boolean | 否 | 是否裁剪页眉页脚,默认 `false`,仅对 PDF 生效。 |
+| header_ratio | number | 否 | 页眉裁剪比例 0–1,如 0.05 表示裁掉顶部 5%,默认 0.05。 |
+| footer_ratio | number | 否 | 页脚裁剪比例 0–1,默认 0.05。 |
+| return_images | boolean | 否 | 是否同时拉取并保存图片;为 `true` 时完成后可下载 zip(md+图片),默认 `false`。 |
+
+## 限制与说明
+
+- **页数**:单文件不超过 300 页,超过将返回 400。
+- **大 PDF**:超过 50 页会按 50 页一段切割后分别转换再合并 MD,以降低 MinerU 端内存占用。
+- 去水印、裁剪页眉页脚仅对 **PDF** 生效,图片类型会忽略这些参数。
+"""
+
+
 @app.post(
     "/pdf_to_markdown",
     tags=["PDF转Markdown"],
-    summary="PDF/图片转 Markdown(同步)",
+    summary="PDF/图片转 Markdown(异步)",
+    description=PDF_TO_MARKDOWN_DESCRIPTION,
+    response_model=ConversionResponse,
     responses={
-        200: {"description": "format=file 时返回 .md 文件;format=json 时返回 JSON { markdown, filename }"},
-        400: {"description": "文件页数超过 20 页"},
-        500: {"description": "转换失败"},
+        200: {
+            "description": "成功创建任务,返回 task_id。需用 GET /task/{task_id} 轮询,完成后通过 GET /download/{task_id}/markdown 或 GET /task/{task_id}/json 获取结果;若传了 return_images=true 还可通过 GET /download/{task_id}/zip 下载 md+图片包。",
+            "content": {
+                "application/json": {
+                    "example": {
+                        "task_id": "550e8400-e29b-41d4-a716-446655440000",
+                        "status": "pending",
+                        "message": "任务已创建,请使用 GET /task/{task_id} 查询状态,完成后通过 GET /download/{task_id}/markdown 或 GET /task/{task_id}/json 获取结果",
+                        "markdown_file": None,
+                        "json_file": None,
+                        "document_type": None,
+                    }
+                }
+            },
+        },
+        400: {
+            "description": "请求非法:例如文件页数超过 300 页。",
+            "content": {
+                "application/json": {"example": {"detail": "文件页数超过 300 页,拒绝处理"}}
+            },
+        },
+        500: {
+            "description": "服务端错误:如保存上传文件失败、转换过程异常等。",
+            "content": {
+                "application/json": {"example": {"detail": "保存文件失败: ..."}}
+            },
+        },
     },
 )
 async def pdf_to_markdown(
-    file: Annotated[UploadFile, File(description="上传的 PDF 或图片文件")],
+    file: Annotated[UploadFile, File(description="上传的 PDF 或图片文件(必填)")],
     backend: Annotated[
         Optional[Literal["mineru", "paddle"]],
-        Form(description="识别后端:mineru 调用 MinerU file_parse,paddle 调用 PaddleOCR doc_parser")
+        Form(description="识别后端:mineru = MinerU file_parse(默认);paddle = PaddleOCR doc_parser"),
     ] = "mineru",
-    format: Annotated[
-        Literal["file", "json"],
-        Form(description="返回格式:file 直接返回 .md 文件下载(适合多页),json 返回 JSON 内嵌 markdown 字段(适合少页)")
-    ] = "file",
+    remove_watermark: Annotated[
+        bool,
+        Form(description="是否先对 PDF 去水印,仅对 PDF 生效,默认 false"),
+    ] = False,
+    watermark_light_threshold: Annotated[
+        int,
+        Form(description="去水印亮度阈值 0–255,高于此值的浅色像素视为水印,默认 200"),
+    ] = 200,
+    watermark_saturation_threshold: Annotated[
+        int,
+        Form(description="去水印饱和度阈值 0–255,低于此值的低饱和度像素视为水印,默认 30"),
+    ] = 30,
+    crop_header_footer: Annotated[
+        bool,
+        Form(description="是否裁剪 PDF 页眉页脚,仅对 PDF 生效,默认 false"),
+    ] = False,
+    header_ratio: Annotated[
+        float,
+        Form(description="页眉裁剪比例 0–1,如 0.05 表示裁掉顶部 5%,默认 0.05"),
+    ] = 0.05,
+    footer_ratio: Annotated[
+        float,
+        Form(description="页脚裁剪比例 0–1,如 0.05 表示裁掉底部 5%,默认 0.05"),
+    ] = 0.05,
+    return_images: Annotated[
+        bool,
+        Form(description="是否同时拉取并保存图片;为 true 时完成后可通过 GET /download/{task_id}/zip 下载 md+图片 压缩包,默认 false"),
+    ] = False,
 ):
-    """
-    PDF/图片转 Markdown(同步接口)
-    直接调用 MinerU 或 PaddleOCR 进行识别,生成完整 MD 后返回。
-    - **file**: 上传的 PDF 或图片
-    - **backend**: mineru(默认)/ paddle
-    - **format**: file(默认)— 直接返回 .md 文件下载,适合多页、大文本;json — 返回 JSON { "markdown", "filename" },适合少页
-    注意:大文件或页数多时可能较慢,建议页数不超过 20。
-    """
-    temp_dir = None
-    file_path = None
+    """PDF/图片转 Markdown(异步):提交后立即返回 task_id,轮询 GET /task/{task_id} 后通过 GET /download/{task_id}/markdown 或 /zip 获取结果。"""
+    task_id = str(uuid.uuid4())
+    content_type = file.content_type or ""
+    ext_map = {"application/pdf": ".pdf", "image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg"}
+    ext = ext_map.get(content_type, "") or (Path(file.filename or "").suffix if file.filename else "") or ".pdf"
+    temp_dir = tempfile.mkdtemp(prefix=f"pdf_converter_v2_{task_id}_")
+    file_path = os.path.join(temp_dir, f"file{ext}")
     try:
-        content_type = file.content_type or ""
-        ext_map = {"application/pdf": ".pdf", "image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg"}
-        ext = ext_map.get(content_type, "") or (Path(file.filename or "").suffix if file.filename else "") or ".pdf"
-        temp_dir = tempfile.mkdtemp(prefix="pdf_converter_v2_pdf_to_md_")
-        file_path = os.path.join(temp_dir, f"file{ext}")
         content = await file.read()
         with open(file_path, "wb") as f:
             f.write(content)
-        # 页数限制(与 /convert 一致)
-        pages = 1
-        if ext.lower() == ".pdf" and content:
-            pages = max(1, content.count(b"/Type /Page"))
-        if pages > 20:
-            raise HTTPException(status_code=400, detail="文件页数超过 20 页,拒绝处理")
-        output_dir = os.path.join(temp_dir, "output")
-        os.makedirs(output_dir, exist_ok=True)
-        api_url = os.getenv("API_URL", "http://127.0.0.1:5282")
-        result = await convert_pdf_to_markdown_only(
-            input_file=file_path,
+    except Exception as e:
+        try:
+            shutil.rmtree(temp_dir)
+        except Exception:
+            pass
+        raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}")
+
+    pages = 1
+    if ext.lower() == ".pdf" and content:
+        pages = max(1, content.count(b"/Type /Page"))
+    if pages > 300:
+        try:
+            shutil.rmtree(temp_dir)
+        except Exception:
+            pass
+        raise HTTPException(status_code=400, detail="文件页数超过 300 页,拒绝处理")
+
+    output_dir = os.path.join(temp_dir, "output")
+    os.makedirs(output_dir, exist_ok=True)
+
+    task_status[task_id] = {
+        "status": "pending",
+        "message": "任务已创建",
+        "progress": 0.0,
+        "markdown_file": None,
+        "json_file": None,
+        "json_data": None,
+        "document_type": None,
+        "error": None,
+        "temp_dir": temp_dir,
+        "output_dir": output_dir,
+        "file_path": file_path,
+        "zip_file": None,
+    }
+
+    asyncio.create_task(
+        process_pdf_to_markdown_task(
+            task_id=task_id,
+            file_path=file_path,
             output_dir=output_dir,
             backend=backend or "mineru",
-            url=api_url,
+            remove_watermark=remove_watermark,
+            watermark_light_threshold=watermark_light_threshold,
+            watermark_saturation_threshold=watermark_saturation_threshold,
+            crop_header_footer=crop_header_footer,
+            header_ratio=header_ratio,
+            footer_ratio=footer_ratio,
+            return_images=return_images,
         )
-        if not result:
-            raise HTTPException(status_code=500, detail="PDF 转 Markdown 失败,请查看服务端日志")
-        if format == "file":
-            # 直接返回 .md 文件下载,避免大文本放在 JSON 里
-            safe_filename = quote(result["filename"])
-            return Response(
-                content=result["markdown"],
-                media_type="text/markdown; charset=utf-8",
-                headers={"Content-Disposition": f'attachment; filename="{result["filename"]}"; filename*=UTF-8\'\'{safe_filename}'},
-            )
-        return PdfToMarkdownResponse(markdown=result["markdown"], filename=result["filename"])
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.exception(f"[pdf_to_markdown] 转换失败: {e}")
-        raise HTTPException(status_code=500, detail=str(e))
-    finally:
-        if temp_dir and os.path.isdir(temp_dir):
-            try:
-                shutil.rmtree(temp_dir)
-            except Exception as exc:
-                logger.debug(f"[pdf_to_markdown] 清理临时目录失败: {exc}")
+    )
+    logger.info(f"[任务 {task_id}] PDF 转 Markdown 任务已创建,立即返回 task_id")
+    return ConversionResponse(
+        task_id=task_id,
+        status="pending",
+        message="任务已创建,请使用 GET /task/{task_id} 查询状态,完成后通过 GET /download/{task_id}/markdown 或 GET /task/{task_id}/json 获取结果",
+        markdown_file=None,
+        json_file=None,
+        document_type=None,
+    )
 
 
 @app.get("/task/{task_id}", response_model=TaskStatus)
@@ -872,6 +1076,31 @@ async def download_markdown(task_id: str):
     )
 
 
+@app.get("/download/{task_id}/zip")
+async def download_zip(task_id: str):
+    """
+    下载 Markdown + 图片 压缩包(仅当提交任务时传了 return_images=true 时存在)
+
+    - **task_id**: 任务ID
+    """
+    if task_id not in task_status:
+        raise HTTPException(status_code=404, detail="任务不存在")
+    status_info = task_status[task_id]
+    if status_info["status"] != "completed":
+        raise HTTPException(status_code=400, detail="任务尚未完成")
+    zip_file = status_info.get("zip_file")
+    if not zip_file or not os.path.exists(zip_file):
+        raise HTTPException(
+            status_code=404,
+            detail="未生成 zip(请在 POST /pdf_to_markdown 时传 return_images=true)",
+        )
+    return FileResponse(
+        zip_file,
+        media_type="application/zip",
+        filename="markdown_with_images.zip",
+    )
+
+
 @app.get("/task/{task_id}/json")
 async def get_json(task_id: str):
     """

+ 72 - 9
pdf_converter_v2/processor/converter.py

@@ -18,6 +18,7 @@ from PIL import Image
 
 from ..utils.logging_config import get_logger
 from ..utils.file_utils import safe_stem
+from ..utils.pdf_splitter import get_pdf_page_count, split_pdf_by_pages
 
 logger = get_logger("pdf_converter_v2.processor")
 PADDLE_CMD = os.getenv("PADDLE_DOC_PARSER_CMD", "paddleocr")
@@ -293,8 +294,12 @@ async def convert_to_markdown(
                             logger.info(f"等待 {wait_time} 秒后重试...")
                             await asyncio.sleep(wait_time)
                             
-                            # 重新创建 form_data(因为已经被消费了)
-                            file_obj.seek(0)  # 重置文件指针
+                            # 重试时重新打开文件(aiohttp 可能已关闭原 handle,seek(0) 会报 seek of closed file)
+                            try:
+                                file_obj.close()
+                            except Exception:
+                                pass
+                            file_obj = open(input_file, 'rb')
                             form_data = aiohttp.FormData()
                             form_data.add_field('return_middle_json', str(return_middle_json).lower())
                             form_data.add_field('return_model_output', str(return_model_output).lower())
@@ -427,6 +432,10 @@ async def convert_to_markdown(
         return None
 
 
+# PDF 超过此页数时按段切割后分别转换再合并,降低单次请求内存(避免 MinerU OOM)
+PDF_CHUNK_PAGES = 50
+
+
 async def convert_pdf_to_markdown_only(
     input_file: str,
     output_dir: str,
@@ -436,26 +445,80 @@ async def convert_pdf_to_markdown_only(
     formula_enable: bool = True,
     table_enable: bool = True,
     language: str = "ch",
+    return_images: bool = False,
 ) -> Optional[dict]:
     """
     仅将 PDF/图片 转为 Markdown 文本,不解析 JSON。
-    用于 API 同步返回 MD 内容。
-    :param input_file: 输入文件路径
-    :param output_dir: 输出目录(临时使用)
-    :param backend: "mineru" 调用 MinerU file_parse,"paddle" 调用 PaddleOCR doc_parser
-    :param url: MinerU API 地址(backend=mineru 时使用)
+    大 PDF(>50 页)会先按 50 页切割,分段转换后合并 MD,降低单次请求内存。
+    :param return_images: 是否同时拉取并保存图片(MinerU 的 return_images + 本地 embed_images)
     :return: {"markdown": str, "filename": str} 或 None
     """
     if not os.path.exists(input_file):
         logger.error(f"输入文件不存在: {input_file}")
         return None
+
+    ext = (Path(input_file).suffix or "").lower()
     url = url or os.getenv("API_URL", "http://127.0.0.1:5282")
+
+    # 仅对 PDF 做按页切割;图片或页数不足则单次转换
+    if ext == ".pdf":
+        page_count = get_pdf_page_count(input_file)
+        if page_count <= 0:
+            logger.error(f"无法获取 PDF 页数: {input_file}")
+            return None
+        if page_count > PDF_CHUNK_PAGES:
+            chunks_dir = tempfile.mkdtemp(prefix="pdf_chunks_", dir=output_dir)
+            try:
+                chunk_paths = split_pdf_by_pages(input_file, chunks_dir, chunk_size=PDF_CHUNK_PAGES)
+                if not chunk_paths:
+                    return None
+                logger.info(f"PDF 共 {page_count} 页,按 {PDF_CHUNK_PAGES} 页切割为 {len(chunk_paths)} 段,分段转换后合并")
+                parts = []
+                for i, chunk_path in enumerate(chunk_paths):
+                    chunk_out = os.path.join(output_dir, f"chunk_{i}")
+                    os.makedirs(chunk_out, exist_ok=True)
+                    if backend == "paddle":
+                        r = await _convert_with_paddle(
+                            input_file=chunk_path,
+                            output_dir=chunk_out,
+                            embed_images=return_images,
+                            output_json=False,
+                            forced_document_type=None,
+                        )
+                    else:
+                        r = await convert_to_markdown(
+                            input_file=chunk_path,
+                            output_dir=chunk_out,
+                            max_pages=max_pages,
+                            output_json=False,
+                            formula_enable=formula_enable,
+                            table_enable=table_enable,
+                            language=language,
+                            url=url,
+                            embed_images=return_images,
+                        )
+                    if r and r.get("content"):
+                        parts.append(r["content"])
+                    else:
+                        logger.warning(f"第 {i + 1} 段转换无内容,跳过")
+                if not parts:
+                    return None
+                merged = "\n\n".join(parts)
+                filename = Path(input_file).stem + ".md"
+                return {"markdown": merged, "filename": filename}
+            finally:
+                try:
+                    shutil.rmtree(chunks_dir, ignore_errors=True)
+                except Exception as e:
+                    logger.debug(f"清理切割临时目录失败: {e}")
+
+    # 单次转换(非 PDF、或 PDF 页数 <= PDF_CHUNK_PAGES)
     result = None
     if backend == "paddle":
         result = await _convert_with_paddle(
             input_file=input_file,
             output_dir=output_dir,
-            embed_images=False,
+            embed_images=return_images,
             output_json=False,
             forced_document_type=None,
         )
@@ -469,7 +532,7 @@ async def convert_pdf_to_markdown_only(
             table_enable=table_enable,
             language=language,
             url=url,
-            embed_images=False,
+            embed_images=return_images,
         )
     if not result or not result.get("content"):
         return None

+ 27 - 17
pdf_converter_v2/requirements.txt

@@ -1,21 +1,31 @@
-# PDF Converter v2 依赖包
+# PDF Converter v2 - 依赖(按代码实际使用整理)
 
-# 核心依赖
-aiohttp>=3.8.0          # 异步HTTP客户端
-aiofiles>=23.0.0        # 异步文件操作
-Pillow>=9.0.0           # 图片处理(PIL)
+# ========== Web API(运行 API 服务必装) ==========
+fastapi>=0.100.0
+uvicorn[standard]>=0.23.0
+pydantic>=2.0.0
+typing-extensions>=4.0.0
 
-# PDF处理(至少安装一个)
-pypdfium2>=4.0.0        # PDF处理库(推荐,文件更小)
-pdf2image>=1.16.0       # PDF转图片(备用方案,需要poppler)
-pdfplumber>=0.11.0      # PDF表格提取库(用于settlementReport和designReview类型)
+# ========== 异步与 HTTP ==========
+aiohttp>=3.8.0          # 调用 MinerU file_parse、重试上传
+aiofiles>=23.0.0        # 异步读写文件(converter 解压/写 md)
 
-# Web框架(如果使用API服务)
-fastapi>=0.100.0        # FastAPI框架
-uvicorn[standard]>=0.23.0  # ASGI服务器
-pydantic>=2.0.0         # 数据验证
-typing-extensions>=4.0.0  # 类型扩展
+# ========== 图片处理 ==========
+Pillow>=9.0.0           # 图片处理(converter、parser、test_no、pdf_watermark_remover)
+numpy>=1.20.0           # image_preprocessor 去水印/裁剪页眉页脚
+opencv-python>=4.5.0    # image_preprocessor(去水印、裁剪)、pdf_watermark_remover 依赖
 
-# 日志(至少安装一个)
-loguru>=0.7.0           # 日志库(推荐)
-# happy-python>=1.0.0   # 或使用happy-python
+# ========== PDF 处理 ==========
+PyPDF2>=3.0.0           # 必装:pdf_splitter 按页切割、pdf_watermark_remover、test_no 附件切割
+pypdfium2>=4.0.0        # paddleocr_fallback 从 PDF 提图(优先);可选,无则用 pdf2image
+pdf2image>=1.16.0       # pdf_watermark_remover PDF→图→PDF;paddleocr_fallback 备用提图(需系统 poppler)
+pdfplumber>=0.11.0      # table_extractor 表格提取、file_utils 检测 PDF 文本层、test_no
+
+# ========== 表格提取(/convert 结算报告/设计评审等类型) ==========
+pandas>=1.3.0           # table_extractor 表格数据处理
+
+# ========== 日志 ==========
+loguru>=0.7.0
+
+# ========== 可选 / 测试 ==========
+# requests>=2.28.0      # 仅 test_api.py 调用接口时需要,按需安装

+ 30 - 0
pdf_converter_v2/scripts/mineru-api.service

@@ -0,0 +1,30 @@
+# MinerU file_parse API - systemd 服务
+# 仅适用于宿主机(已用 systemd 作为 init)。Docker 容器内无 systemd,请改用 scripts/start_mineru_in_container.sh
+# 工作目录:/root/work/Clerk2.5
+#
+# 安装步骤(在宿主机上,且以 systemd 启动):
+#   sudo cp mineru-api.service /etc/systemd/system/
+#   sudo systemctl daemon-reload
+#   sudo systemctl enable mineru-api
+#   sudo systemctl start mineru-api
+#   sudo systemctl status mineru-api
+#
+# 若本机 simsimd/scikit_learn 的 libgomp 路径不同,请修改 Environment=LD_PRELOAD
+
+[Unit]
+Description=MinerU file_parse API (uvicorn)
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/root/work/Clerk2.5
+# NPU/容器内需预加载 libgomp,避免 static TLS 报错
+Environment=LD_PRELOAD=/usr/local/lib/python3.10/dist-packages/simsimd.libs/libgomp-a49a47f9.so.1.0.0:/usr/local/lib/python3.10/dist-packages/scikit_learn.libs/libgomp-d22c30c5.so.1.0.0
+Environment=PYTHONPATH=/root/work/Clerk2.5
+ExecStart=/usr/bin/python3 -m uvicorn mineru.cli.fast_api:app --host 0.0.0.0 --port 5282
+Restart=on-failure
+RestartSec=5
+User=root
+
+[Install]
+WantedBy=multi-user.target

+ 8 - 44
pdf_converter_v2/utils/paddleocr_fallback.py

@@ -45,10 +45,10 @@ MINERU_LOCK_FILE = "/tmp/mineru_service_lock"
 MINERU_COUNT_FILE = "/tmp/mineru_service_count"
 
 # PaddleOCR 推理设备:NPU 环境下需设为 npu 或 npu:0,否则会走 CPU 并可能段错误
-# 通过环境变量 PADDLE_OCR_DEVICE 指定;未设置时默认 npu:0,便于 NPU 容器内直接启动 API 时也能走 NPU
+# 通过环境变量 PADDLE_OCR_DEVICE 指定,例如:export PADDLE_OCR_DEVICE=npu:0
 def _paddle_ocr_device_args() -> list:
-    """返回 PaddleOCR 命令的 --device 参数列表(未设置时默认 npu:0;设为空字符串则不添加)"""
-    device = os.getenv("PADDLE_OCR_DEVICE", "npu:0").strip()
+    """返回 PaddleOCR 命令的 --device 参数列表(若未设置则返回空列表)"""
+    device = os.getenv("PADDLE_OCR_DEVICE", "").strip()
     if device:
         return ["--device", device]
     return []
@@ -129,37 +129,12 @@ def _decrement_service_count(lock_file: object) -> int:
         return 0
 
 
-def _systemd_available() -> bool:
-    """检测当前环境是否有 systemd(容器内通常无 systemd,无法操作 mineru-api.service)"""
-    # 容器内通常没有 /run/systemd/system,先做快速判断
-    if not os.path.exists("/run/systemd/system"):
-        return False
-    try:
-        r = subprocess.run(
-            ["systemctl", "is-system-running"],
-            capture_output=True,
-            text=True,
-            timeout=3,
-            check=False,
-        )
-        out = (r.stderr or "") + (r.stdout or "")
-        if "Failed to connect to bus" in out or "not been booted with systemd" in out or "Connection refused" in out:
-            return False
-        return True
-    except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
-        return False
-
-
 def stop_mineru_service() -> bool:
     """停止mineru-api.service以释放GPU内存(线程安全)
-    容器内无 systemd 时直接返回 True,不报错。
     
     Returns:
-        True表示成功停止或已停止/无需操作,False表示失败
+        True表示成功停止或已停止,False表示失败
     """
-    if not _systemd_available():
-        logger.debug("[PaddleOCR] 无 systemd,跳过停止 mineru-api.service")
-        return True
     lock_file = _acquire_service_lock()
     if not lock_file:
         # 如果无法获取锁,等待一小段时间后检查服务状态
@@ -225,14 +200,10 @@ def stop_mineru_service() -> bool:
 
 def start_mineru_service() -> bool:
     """启动mineru-api.service(线程安全)
-    容器内无 systemd 时直接返回 True,不报错。
     
     Returns:
-        True表示成功启动或已启动/无需操作,False表示失败
+        True表示成功启动或已启动,False表示失败
     """
-    if not _systemd_available():
-        logger.debug("[PaddleOCR] 无 systemd,跳过启动 mineru-api.service")
-        return True
     lock_file = _acquire_service_lock()
     if not lock_file:
         # 如果无法获取锁,等待一小段时间后检查服务状态
@@ -288,17 +259,10 @@ def start_mineru_service() -> bool:
         if result.returncode == 0:
             logger.info("[PaddleOCR] 成功启动mineru-api.service")
             return True
-        err = (result.stderr or "") + (result.stdout or "")
-        if "Failed to connect to bus" in err or "not been booted with systemd" in err:
-            logger.debug("[PaddleOCR] 无 systemd(容器环境),跳过启动 mineru-api.service")
-            return True
-        logger.warning(f"[PaddleOCR] 启动mineru-api.service失败: {result.stderr}")
-        return False
+        else:
+            logger.warning(f"[PaddleOCR] 启动mineru-api.service失败: {result.stderr}")
+            return False
     except Exception as e:
-        err_str = str(e)
-        if "Failed to connect to bus" in err_str or "not been booted" in err_str:
-            logger.debug("[PaddleOCR] 无 systemd(容器环境),跳过启动 mineru-api.service")
-            return True
         logger.warning(f"[PaddleOCR] 启动mineru-api.service时出错: {e}")
         return False
     finally:

+ 98 - 0
pdf_converter_v2/utils/pdf_splitter.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+"""
+PDF 按页切割工具
+将大 PDF 按固定页数切分为多个小 PDF,用于分段转换以降低单次请求内存。
+"""
+
+import tempfile
+from pathlib import Path
+from typing import List
+
+from .logging_config import get_logger
+
+logger = get_logger("pdf_converter_v2.utils.pdf_splitter")
+
+try:
+    import PyPDF2
+    PYPDF2_AVAILABLE = True
+except ImportError:
+    PYPDF2_AVAILABLE = False
+
+PDF2_REQUIRED_MSG = "PyPDF2 未安装,无法进行 PDF 切割与页数检测。请安装: pip install PyPDF2"
+
+
+def _require_pypdf2() -> None:
+    if not PYPDF2_AVAILABLE:
+        raise RuntimeError(PDF2_REQUIRED_MSG)
+
+
+def get_pdf_page_count(pdf_path: str) -> int:
+    """获取 PDF 页数。若无法读取则返回 0。未安装 PyPDF2 时抛出 RuntimeError。"""
+    _require_pypdf2()
+    try:
+        with open(pdf_path, "rb") as f:
+            reader = PyPDF2.PdfReader(f)
+            return len(reader.pages)
+    except Exception as e:
+        logger.warning(f"[PDF切割] 获取页数失败 {pdf_path}: {e}")
+        return 0
+
+
+def split_pdf_by_pages(
+    input_pdf: str,
+    output_dir: str,
+    chunk_size: int = 50,
+) -> List[str]:
+    """
+    将 PDF 按 chunk_size 页一段切分为多个临时 PDF 文件。
+
+    :param input_pdf: 输入 PDF 路径
+    :param output_dir: 存放切分后 PDF 的目录(一般为临时目录)
+    :param chunk_size: 每段页数,默认 50
+    :return: 切分后的 PDF 文件路径列表,按页码顺序;失败返回空列表。未安装 PyPDF2 时抛出 RuntimeError。
+    """
+    _require_pypdf2()
+
+    input_path = Path(input_pdf)
+    out_path = Path(output_dir)
+    out_path.mkdir(parents=True, exist_ok=True)
+
+    # 整个切割过程保持文件打开,否则 writer.add_page(reader.pages[i]) 时 PyPDF2 会从已关闭的 stream 读取,触发 seek of closed file
+    try:
+        with open(input_pdf, "rb") as f:
+            reader = PyPDF2.PdfReader(f)
+            total = len(reader.pages)
+            if total <= 0:
+                return []
+            if total <= chunk_size:
+                return [input_pdf]
+
+            chunk_paths: List[str] = []
+            start = 0
+            idx = 0
+            while start < total:
+                end = min(start + chunk_size, total)
+                chunk_pdf = out_path / f"chunk_{idx}_{input_path.stem}.pdf"
+                try:
+                    writer = PyPDF2.PdfWriter()
+                    for i in range(start, end):
+                        writer.add_page(reader.pages[i])
+                    with open(chunk_pdf, "wb") as w:
+                        writer.write(w)
+                    chunk_paths.append(str(chunk_pdf))
+                    logger.info(f"[PDF切割] 段 {idx + 1}: 页 {start + 1}-{end}/{total} -> {chunk_pdf.name}")
+                except Exception as e:
+                    logger.exception(f"[PDF切割] 写入段 {idx} 失败: {e}")
+                    for p in chunk_paths:
+                        try:
+                            Path(p).unlink(missing_ok=True)
+                        except Exception:
+                            pass
+                    return []
+                start = end
+                idx += 1
+
+            return chunk_paths
+    except Exception as e:
+        logger.error(f"[PDF切割] 读取 PDF 失败 {input_pdf}: {e}")
+        return []

+ 93 - 0
pdf_converter_v2/utils/pdf_watermark_remover.py

@@ -120,3 +120,96 @@ def remove_watermark_from_pdf(
         import traceback
         traceback.print_exc()
         return False
+
+
+def crop_header_footer_from_pdf(
+    input_pdf: str,
+    output_pdf: str,
+    header_ratio: float = 0.05,
+    footer_ratio: float = 0.05,
+    auto_detect: bool = False,
+    dpi: int = 200
+) -> bool:
+    """
+    对 PDF 文件进行页眉页脚裁剪处理。
+
+    处理流程:
+    1. 将 PDF 的每一页转换为图片
+    2. 对每张图片进行页眉页脚裁剪
+    3. 将处理后的图片合并为新的 PDF
+
+    Args:
+        input_pdf: 输入 PDF 文件路径
+        output_pdf: 输出 PDF 文件路径
+        header_ratio: 页眉裁剪比例(0-1),默认 0.05 表示裁剪顶部 5%
+        footer_ratio: 页脚裁剪比例(0-1),默认 0.05 表示裁剪底部 5%
+        auto_detect: 是否自动检测页眉页脚边界
+        dpi: PDF 转图片的 DPI
+
+    Returns:
+        bool: 是否成功
+    """
+    try:
+        from pdf2image import convert_from_path
+        from PIL import Image
+        from utils.image_preprocessor import crop_header_footer, check_opencv_available
+
+        if not check_opencv_available():
+            print("⚠ OpenCV 未安装,无法进行页眉页脚裁剪")
+            return False
+
+        temp_dir = tempfile.mkdtemp(prefix="pdf_crop_hf_")
+        temp_path = Path(temp_dir)
+
+        try:
+            print(f"正在将 PDF 转换为图片(DPI={dpi})...")
+            images = convert_from_path(input_pdf, dpi=dpi)
+            print(f"✓ 转换完成,共 {len(images)} 页")
+
+            processed_images = []
+            for i, image in enumerate(images, 1):
+                print(f"处理第 {i}/{len(images)} 页...", end="\r")
+                original_path = temp_path / f"page_{i}_original.png"
+                image.save(str(original_path), "PNG")
+                cropped_path = temp_path / f"page_{i}_cropped.png"
+                crop_header_footer(
+                    str(original_path),
+                    output_path=str(cropped_path),
+                    header_ratio=header_ratio,
+                    footer_ratio=footer_ratio,
+                    auto_detect=auto_detect,
+                )
+                processed_img = Image.open(cropped_path)
+                processed_images.append(processed_img)
+
+            print("\n✓ 所有页面处理完成")
+            print("正在生成 PDF...")
+            if processed_images:
+                first_image = processed_images[0]
+                other_images = processed_images[1:] if len(processed_images) > 1 else []
+                first_image.save(
+                    output_pdf,
+                    "PDF",
+                    resolution=dpi,
+                    save_all=True,
+                    append_images=other_images,
+                )
+                print(f"✓ PDF 生成完成: {output_pdf}")
+                return True
+            else:
+                print("⚠ 没有处理任何图片")
+                return False
+        finally:
+            try:
+                shutil.rmtree(temp_dir)
+            except Exception as e:
+                print(f"⚠ 清理临时目录失败: {e}")
+    except ImportError as e:
+        print(f"⚠ 缺少必要的库: {e}")
+        print("请安装: pip install pdf2image pillow opencv-python")
+        return False
+    except Exception as e:
+        print(f"⚠ 页眉页脚裁剪失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return False