walson.wang пре 4 недеља
комит
2a06946699

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+# Claude
+.claude/
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+.venv/
+venv/
+env/
+
+# IDE
+.idea/
+.vscode/
+
+# Project-specific (data & models)
+data/
+
+# Models: track only .gitkeep, ignore actual model files
+models/*
+!models/.gitkeep
+
+# OS
+Thumbs.db
+.DS_Store
+
+# Dependency lockfiles (auto-generated by uv)
+uv.lock

+ 11 - 0
README.md

@@ -0,0 +1,11 @@
+# Proposa AI
+
+AI 驱动的 PDF 表格提取与文档生成工具。
+
+## 模型下载
+
+本项目使用的 文本困惑度 模型基于 GPT-2 Chinese CLUE Corpus Small(ONNX 格式),请从 ModelScope 下载:
+
+- [GPT-2 Chinese CLUE Corpus Small (ONNX)](https://modelscope.cn/models/Maiteka/gpt2-chinese-cluecorpussmall-onnx/summary)
+
+下载后将模型文件放入 `models/` 目录。

+ 0 - 0
models/.gitkeep


+ 268 - 0
pdf_table_to_docx/README.md

@@ -0,0 +1,268 @@
+# pdf_table_to_docx
+
+从文字型 PDF 中提取表格并生成 DOCX 文件,保留合并单元格、处理跨页表格、利用 NLP 模型优化文本合理性。
+
+## 快速开始
+
+```bash
+# 安装依赖
+pip install pdfplumber python-docx onnxruntime tokenizers
+
+# 单 DOCX 模式(所有表格依次排列)
+python -m pdf_table_to_docx document.pdf
+
+# 目录模式(JSON 元数据 + 每表独立 DOCX)
+python -m pdf_table_to_docx document.pdf --dir
+
+# 指定输出
+python -m pdf_table_to_docx document.pdf --dir -o output_dir/
+```
+
+## 处理流程
+
+```
+PDF 输入
+    │
+    ├── 1. 逐页扫描 ─────────────────────────────────────────┐
+    │      │  pdfplumber.find_tables()                        │
+    │      ▼                                                  │
+    │   提取表格网格 grid[r][c]                                │
+    │   (每个单元格 = bbox 四元组 or None)                     │
+    │                                                          │
+    ├── 2. 合并单元格检测 ─────────────────────────────────────┤
+    │      │                                                   │
+    │      ├─ 扫描连续的 None → 初测跨行/跨列跨度               │
+    │      ├─ bbox 验证 → 消除假阳性纵向合并                    │
+    │      │  (单元格底部必须延伸到目标行底部)                  │
+    │      └─ T 形冲突解决 → 按 row-major 顺序认领区域          │
+    │                                                          │
+    ├── 3. 文本清洗 ───────────────────────────────────────────┤
+    │      │                                                   │
+    │      └─ NLP 辅助换行符清理                               │
+    │         规则 + GPT-2 评分对比 → 去包装换行,留结构换行     │
+    │                                                          │
+    ├── 4. 跨页表格合并 ───────────────────────────────────────┤
+    │      │                                                   │
+    │      ├─ 阶段一(严格匹配):列数+列宽+页边距+表头比对     │
+    │      └─ 阶段二(宽松匹配):列数+页边距+连续性信号        │
+    │          ├─ 空首列延续                                   │
+    │          ├─ NLP 文本延续(GPT-2 评分拼接提升)            │
+    │          └─ 纵向合并语义断层修复                          │
+    │                                                          │
+    ├── 5. DOCX 生成 ──────────────────────────────────────────┤
+    │      │                                                   │
+    │      ├─ python-docx 重建表格网格                          │
+    │      ├─ gridSpan / vMerge 写入 XML                        │
+    │      ├─ 列宽比例、字体、对齐、边框                        │
+    │      └─ JSON 元数据(页号、跨页标记、行列数、预览)       │
+    │                                                          │
+    └── DOCX 输出                                              │
+       (单文件 or 每表独立)                                    │
+```
+
+## 关键步骤详解
+
+### 1. 合并单元格检测
+
+PDF 没有"合并单元格"的原生概念。pdfplumber 将表格解析为网格 `grid[r][c]`,其中被合并覆盖的网格位置为 `None`。
+
+**三步检测法**:
+
+```
+原始网格:
+  [0,0]=CellA  [0,1]=None    [0,2]=CellB  [0,3]=None
+  [1,0]=None   [1,1]=CellC   [1,2]=None   [1,3]=CellD
+
+第一步:扫描连续的 None
+  CellA 在 [0,0]→ 右扫发现 [0,1]=None → col_span=2
+               → 下扫发现 [1,0]=None → row_span=2
+  CellA 实际合并范围 = 2×2(覆盖 [0,0]~[1,1])
+  
+第二步:bbox 验证纵向合并
+  对 row_span>1 的候选,检查单元格底部是否 ≥ 目标行底部
+  防止「合计」行跨越导致的假阳性合并
+  
+第三步:T 形冲突处理
+  按 row-major 顺序认领区域,冲突时自动缩减
+  如纵向合并先认领了 [17,2],横向合并到 [17,0] 时
+  检测到冲突 → 缩减至 col_span=1
+```
+
+### 2. 跨页表格检测与合并
+
+#### 两阶段判断
+
+```
+阶段一(严格匹配):
+  列数相同 + 列宽分布相似(|w1-w2|<10%)→ 肯定是跨页
+
+阶段二(宽松匹配)—— 三个连续性信号:
+  
+  信号 A(空首列延续):
+    表格 4 末行: ['分项服务方案', ..., '4']
+    表格 5 首行: ['' , '节能管理', ..., '4']
+                  ↑ 首列为空 → t4 的分类延续到 t5
+
+  信号 B(NLP 文本延续):
+    「资源投入」(0.013) + 「和管理」(0.005)
+    → 拼接「资源投入和管理」(0.056) → 提升 4.3 倍 → 是延续
+
+  信号 C(全表空首列):
+    t5 前 5 行首列全部为空 → 续表模式
+```
+
+#### 语义断层修复
+
+跨页合并后,纵向合并组在分页处断裂,导致上一页的分类标签无法覆盖下一页:
+
+```
+合并前:
+  行4-7: [分项服务方案, ...]      ← page 23
+  行8-12: ['' , ...]              ← page 24(空标签)
+
+修复后:
+  行4-12: [分项服务方案, ...]     ← 扩展合并覆盖
+```
+
+实现方式:合并后扫描各列,若 t1 末行有纵向合并且延伸到 t1 底部、t2 对应位置为空,则扩展 t1 的 `row_span` 覆盖到 t2 区域。
+
+### 3. 文字合理性(NLP 辅助清洗)
+
+使用中文 GPT-2 模型(ONNX 格式,约 473MB)计算文本的**自然度评分**。
+
+#### 评分原理
+
+```python
+score(text) = 1 / (1 + PPL / 10)
+```
+
+其中 `PPL = exp(mean(loss))`,loss 为模型对每个 token 的交叉熵。
+
+#### 应用一:跨页文本延续检测
+
+当列宽不匹配时,用 NLP 判断两段文本是否应该拼接:
+
+| 文本片段 | 单独评分 | 拼接评分 | 提升 | 结论 |
+|---|---|---|---|---|
+| 「资源投入」 | 0.013 | 0.056 | 4.3× | 应拼接 |
+| 「和管理」 | 0.005 | | | |
+| 「采购」 | 0.071 | 0.088 | 1.25× | 应拼接 |
+| 「需求。」 | 0.003 | | | |
+
+#### 应用二:换行符清理
+
+PDF 表格中常因列宽不足产生包装换行。混合规则+NLP 判断:
+
+```
+规则系统 → 4 级判断
+  1. 单行 ≤1 字符 → 包装换行,删
+  2. 括号分裂 "(万\n元)" → 删  
+  3. 分值模式 "文字\n数字" → 保留
+  4. NLP 评分提升 > 1.2× → 删(合并后更自然)
+  5. 默认 → 保留(保守策略)
+```
+
+示例结果:
+
+| 原文 | 处理后 | 原因 |
+|---|---|---|
+| ★采购预算金额(万\n元) | ★采购预算金额(万元) | 括号分裂 |
+| 内容\n部门 | 内容\n部门 | 两行标题,保留 |
+| 绿化整洁\n20 | 绿化整洁\n20 | 分值模式,保留 |
+| 被考核\n单位意\n见 | 被考核单位意见 | NLP 提升显著 |
+| 会场清洁卫\n生\n20 | 会场清洁卫生20 | 碎片合并 |
+
+#### 模型文件
+
+模型位置:`models/gpt2-chinese-cluecorpussmall-onnx/`
+
+- 模型来源:[uer/gpt2-chinese-cluecorpussmall](https://huggingface.co/uer/gpt2-chinese-cluecorpussmall)
+- 导出格式:ONNX(CPU 推理,单次约 21ms)
+- 评分特性:BERT WordPiece 分词器,会对空白字符做了标准化处理
+
+如未下载模型,不影响表格提取和 DOCX 生成功能,仅跳过 NLP 相关的文本优化步骤。
+
+## 文件结构
+
+```
+pdf_table_to_docx/
+├── __init__.py          # 包入口
+├── __main__.py          # python -m 调用
+├── cli.py               # 命令行界面(单文件/目录模式)
+├── table_parser.py      # 表格解析 + 合并单元格检测 + 换行清理
+├── docx_writer.py       # DOCX 文档生成
+├── extractor.py         # 主协调器 + 跨页检测合并
+└── text_scorer.py       # NLP 文本评分(GPT-2 ONNX)
+```
+
+## 输出示例
+
+### 目录模式结构
+
+```
+output_dir/
+├── tables.json                # 全局元数据
+│   ├── pdf_file               # 源文件名
+│   ├── total_tables           # 表格总数
+│   └── tables[]               # 每个表格的详细信息
+│       ├── id                 # 序号
+│       ├── file               # 对应 DOCX 路径
+│       ├── pages              # 所在页码(1-indexed)
+│       ├── rows, cols         # 行列数
+│       ├── cross_page         # 是否跨页
+│       └── preview            # 首行前 5 列预览
+└── tables/
+    ├── table_001.docx
+    ├── table_002.docx
+    └── ...
+```
+
+### JSON 元数据示例
+
+```json
+{
+  "pdf_file": "招标文件.pdf",
+  "total_tables": 36,
+  "tables": [
+    {
+      "id": 1,
+      "file": "tables/table_001.docx",
+      "pages": [5],
+      "rows": 3,
+      "cols": 3,
+      "cross_page": false,
+      "preview": ["包件号", "包件名称", "★采购预算金额(万元)"]
+    },
+    {
+      "id": 4,
+      "file": "tables/table_004.docx",
+      "pages": [23, 24],
+      "rows": 26,
+      "cols": 5,
+      "cross_page": true,
+      "preview": ["评审内容", "评审因素", "类型", "评审标准", "分值"]
+    }
+  ]
+}
+```
+
+## CLI 参考
+
+```
+python -m pdf_table_to_docx <pdf_path> [options]
+
+选项:
+  -o, --output PATH     输出路径(单文件 .docx / 目录模式文件夹)
+  --dir, --directory    目录模式(JSON + 每表独立 DOCX)
+  -v, --verbose         详细日志
+  --page-width W        页面宽度 cm(默认 21.0 = A4)
+  --page-height H       页面高度 cm(默认 29.7 = A4)
+  --margin M            页边距 cm(默认 2.0)
+```
+
+## 依赖
+
+- **pdfplumber** — PDF 文字提取和表格检测
+- **python-docx** — DOCX 文档生成
+- **onnxruntime** — GPT-2 模型推理(可选,用于 NLP 文本优化)
+- **tokenizers** — HuggingFace 分词器(可选)

+ 13 - 0
pdf_table_to_docx/__init__.py

@@ -0,0 +1,13 @@
+"""pdf_table_to_docx - 从 PDF 提取表格并生成 DOCX 文件。
+
+功能:
+- 从文字型 PDF 中检测和提取表格
+- 保留合并单元格(跨行/跨列)格式
+- 检测跨页表格并自动合并为完整表格
+- 生成格式一致的 DOCX 文件
+"""
+
+from .extractor import PDFTableExtractor
+
+__all__ = ["PDFTableExtractor"]
+__version__ = "1.0.0"

+ 4 - 0
pdf_table_to_docx/__main__.py

@@ -0,0 +1,4 @@
+"""支持 python -m pdf_table_to_docx 调用。"""
+from .cli import main
+
+main()

+ 139 - 0
pdf_table_to_docx/cli.py

@@ -0,0 +1,139 @@
+"""
+cli.py - 命令行入口
+
+用法:
+    # 单文件模式(所有表格放入一个 DOCX)
+    python -m pdf_table_to_docx <pdf_path>
+
+    # 目录模式(JSON 元数据 + 每个表格独立 DOCX)
+    python -m pdf_table_to_docx <pdf_path> --dir
+
+示例:
+    python -m pdf_table_to_docx data/sample.pdf
+    python -m pdf_table_to_docx data/sample.pdf -o output.docx
+    python -m pdf_table_to_docx data/sample.pdf --dir -o output_dir/
+    python -m pdf_table_to_docx data/sample.pdf --dir --verbose
+"""
+
+import argparse
+import logging
+import sys
+
+from .extractor import PDFTableExtractor
+
+
+def setup_logging(verbose: bool = False):
+    """配置日志输出。"""
+    level = logging.DEBUG if verbose else logging.INFO
+    fmt = "%(asctime)s [%(levelname)s] %(message)s"
+    datefmt = "%H:%M:%S"
+
+    logging.basicConfig(
+        level=level,
+        format=fmt,
+        datefmt=datefmt,
+        stream=sys.stderr,
+    )
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="从 PDF 中提取表格并生成 DOCX 文件",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  %(prog)s document.pdf                          # 单 DOCX 模式
+  %(prog)s document.pdf --dir                    # 目录模式(JSON + 每表独立 DOCX)
+  %(prog)s document.pdf -o result.docx           # 指定输出文件
+  %(prog)s document.pdf --dir -o output_dir/     # 指定输出目录
+  %(prog)s document.pdf --verbose                # 详细日志
+  %(prog)s document.pdf --page-width 42 --page-height 29.7  # A3 横版
+        """,
+    )
+
+    parser.add_argument(
+        "pdf_path",
+        help="输入的 PDF 文件路径",
+    )
+    parser.add_argument(
+        "-o", "--output",
+        help="输出路径(单文件模式时是 .docx,目录模式时是文件夹路径)",
+    )
+    parser.add_argument(
+        "--dir", "--directory",
+        action="store_true",
+        dest="dir_mode",
+        help="目录模式:输出 JSON 元数据 + 每个表格独立的 DOCX 文件",
+    )
+    parser.add_argument(
+        "--verbose", "-v",
+        action="store_true",
+        help="输出详细日志",
+    )
+    parser.add_argument(
+        "--page-width",
+        type=float,
+        default=21.0,
+        help="页面宽度,单位厘米(默认 21.0,即 A4)",
+    )
+    parser.add_argument(
+        "--page-height",
+        type=float,
+        default=29.7,
+        help="页面高度,单位厘米(默认 29.7,即 A4)",
+    )
+    parser.add_argument(
+        "--margin",
+        type=float,
+        default=2.0,
+        help="页边距,单位厘米(默认 2.0)",
+    )
+
+    args = parser.parse_args()
+
+    setup_logging(verbose=args.verbose)
+
+    logger = logging.getLogger(__name__)
+
+    try:
+        extractor = PDFTableExtractor(args.pdf_path)
+
+        if args.dir_mode:
+            # ---- 目录模式 ----
+            output_dir = extractor.to_docx_directory(
+                output_dir=args.output,
+                page_width_cm=args.page_width,
+                page_height_cm=args.page_height,
+                margin_cm=args.margin,
+            )
+            if output_dir:
+                print(f"\n✅ 表格已提取至目录: {output_dir}", file=sys.stderr)
+                print(f"   元数据: {output_dir}/tables.json", file=sys.stderr)
+                print(f"   表格:   {output_dir}/tables/", file=sys.stderr)
+            else:
+                print("\n⚠️  未检测到表格或处理失败", file=sys.stderr)
+                sys.exit(1)
+        else:
+            # ---- 单文件模式(默认) ----
+            output = extractor.to_docx(
+                output_path=args.output,
+                page_width_cm=args.page_width,
+                page_height_cm=args.page_height,
+                margin_cm=args.margin,
+            )
+            if output:
+                print(f"\n✅ 表格已提取并保存至: {output}", file=sys.stderr)
+            else:
+                print("\n⚠️  未检测到表格或处理失败", file=sys.stderr)
+                sys.exit(1)
+
+    except FileNotFoundError as e:
+        logger.error(e)
+        sys.exit(1)
+    except Exception as e:
+        logger.exception(f"处理失败: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 220 - 0
pdf_table_to_docx/docx_writer.py

@@ -0,0 +1,220 @@
+"""
+docx_writer.py - DOCX 文档生成模块
+
+负责:
+- 使用 python-docx 创建 Word 文档
+- 根据 TableInfo 在文档中重建表格
+- 设置合并单元格(跨行/跨列)
+- 设置单元格对齐、边框等基本格式
+"""
+
+import logging
+from typing import Optional
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml.ns import qn, nsdecls
+from docx.oxml import parse_xml
+from docx.shared import Emu, Pt, Cm
+from docx.table import _Cell
+
+from .table_parser import TableInfo
+
+logger = logging.getLogger(__name__)
+
+
+def _get_alignment(text: str) -> int:
+    """从文本内容中猜测对齐方式。"""
+    if not text or not text.strip():
+        return WD_ALIGN_PARAGRAPH.LEFT
+    stripped = text.strip()
+    if stripped.replace(",", "").replace(".", "").replace("-", "").isdigit():
+        return WD_ALIGN_PARAGRAPH.RIGHT
+    if len(stripped) <= 5:
+        return WD_ALIGN_PARAGRAPH.CENTER
+    return WD_ALIGN_PARAGRAPH.LEFT
+
+
+def _convert_cm_to_emu(cm_value: float) -> int:
+    """将厘米转换为 EMU(docx 内部单位)。"""
+    return int(round(cm_value * 360000))
+
+
+def _merge_cells(table, r: int, c: int, row_span: int, col_span: int):
+    """合并表格中的单元格。"""
+    if row_span <= 1 and col_span <= 1:
+        return
+    end_r = r + row_span - 1
+    end_c = c + col_span - 1
+    start_cell = table.cell(r, c)
+    end_cell = table.cell(end_r, end_c)
+    start_cell.merge(end_cell)
+
+
+def write_table_to_doc(doc, table_info: TableInfo, body_width_cm: float):
+    """将单个 TableInfo 写入到已打开的 doc 对象中。
+
+    Args:
+        doc: Document 对象
+        table_info: 要写入的表格
+        body_width_cm: 正文宽度(厘米),用于计算列宽
+    """
+    if table_info.rows == 0 or table_info.cols == 0:
+        return
+
+    doc_table = doc.add_table(
+        rows=table_info.rows,
+        cols=table_info.cols,
+    )
+    doc_table.autofit = False
+    doc_table.allow_autofit = False
+
+    # ---- 设置列宽 ----
+    usable_width = body_width_cm * 0.95
+    if table_info.col_widths:
+        for c_idx, ratio in enumerate(table_info.col_widths):
+            width_emu = _convert_cm_to_emu(usable_width * ratio)
+            for row in doc_table.rows:
+                row.cells[c_idx].width = Emu(width_emu)
+
+    # ---- 填充内容、合并单元格 ----
+    for r in range(table_info.rows):
+        for c in range(table_info.cols):
+            cell_info = table_info.cells[r][c]
+
+            if cell_info.row_span == 0 or cell_info.col_span == 0:
+                continue
+
+            _merge_cells(doc_table, r, c, cell_info.row_span, cell_info.col_span)
+            cell = doc_table.cell(r, c)
+
+            text = cell_info.text or ""
+            cell.text = ""
+
+            lines = text.split("\n")
+            for li, line in enumerate(lines):
+                if li > 0:
+                    cell.add_paragraph()
+                run = cell.paragraphs[li].add_run(line.strip())
+                run.font.size = Pt(10)
+                run.font.name = "宋体"
+                run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+            alignment = _get_alignment(text)
+            for para in cell.paragraphs:
+                para.alignment = alignment
+
+            cell.vertical_alignment = 1  # CENTER
+
+    # ---- 设置表格边框 ----
+    tbl = doc_table._tbl
+    tblPr = (
+        tbl.tblPr
+        if tbl.tblPr is not None
+        else parse_xml(f'<w:tblPr {nsdecls("w")}/>')
+    )
+
+    borders = parse_xml(
+        f'<w:tblBorders {nsdecls("w")}>'
+        '  <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '</w:tblBorders>'
+    )
+    tblPr.append(borders)
+
+    logger.info(
+        f"写入表格: {table_info.rows} 行 x {table_info.cols} 列(含合并单元格)"
+    )
+
+
+def build_document(
+    tables: list,
+    output_path: str,
+    page_width_cm: float = 21.0,
+    page_height_cm: float = 29.7,
+    margin_cm: float = 2.0,
+) -> str:
+    """将所有表格依次写入一个 DOCX 文件。
+
+    Args:
+        tables: list[TableInfo]
+        output_path: 输出路径
+        page_width_cm: 页面宽度
+        page_height_cm: 页面高度
+        margin_cm: 页边距
+
+    Returns:
+        str: 输出文件路径
+    """
+    doc = Document()
+
+    section = doc.sections[0]
+    section.page_width = Cm(page_width_cm)
+    section.page_height = Cm(page_height_cm)
+    section.top_margin = Cm(margin_cm)
+    section.bottom_margin = Cm(margin_cm)
+    section.left_margin = Cm(margin_cm)
+    section.right_margin = Cm(margin_cm)
+
+    body_width = page_width_cm - 2 * margin_cm
+
+    for t_idx, table_info in enumerate(tables):
+        if table_info.rows == 0 or table_info.cols == 0:
+            continue
+        if t_idx > 0:
+            doc.add_paragraph()
+        write_table_to_doc(doc, table_info, body_width)
+
+    doc.save(output_path)
+    logger.info(f"文档已保存至: {output_path}")
+    return output_path
+
+
+def build_single_table_document(
+    table_info: TableInfo,
+    output_path: str,
+    page_width_cm: float = 21.0,
+    page_height_cm: float = 29.7,
+    margin_cm: float = 2.0,
+    table_label: str = "",
+) -> str:
+    """将单个表格写入独立的 DOCX 文件。
+
+    Args:
+        table_info: 要写入的表格
+        output_path: 输出路径
+        page_width_cm: 页面宽度
+        page_height_cm: 页面高度
+        margin_cm: 页边距
+        table_label: 可选标签,写入文档标题
+
+    Returns:
+        str: 输出文件路径
+    """
+    doc = Document()
+
+    section = doc.sections[0]
+    section.page_width = Cm(page_width_cm)
+    section.page_height = Cm(page_height_cm)
+    section.top_margin = Cm(margin_cm)
+    section.bottom_margin = Cm(margin_cm)
+    section.left_margin = Cm(margin_cm)
+    section.right_margin = Cm(margin_cm)
+
+    body_width = page_width_cm - 2 * margin_cm
+
+    if table_label:
+        p = doc.add_paragraph()
+        run = p.add_run(table_label)
+        run.font.size = Pt(12)
+        run.font.bold = True
+
+    write_table_to_doc(doc, table_info, body_width)
+
+    doc.save(output_path)
+    logger.info(f"单表文档已保存至: {output_path}")
+    return output_path

+ 446 - 0
pdf_table_to_docx/extractor.py

@@ -0,0 +1,446 @@
+"""
+extractor.py - PDF 表格提取与 DOCX 生成主模块
+
+负责:
+- 打开 PDF,逐页提取表格
+- 检测跨页表格并自动合并
+- 协调 table_parser 和 docx_writer 完成完整流程
+- 支持单文件输出和目录式输出(JSON + 每表独立 DOCX)
+"""
+
+import json
+import logging
+import os
+import re
+
+import pdfplumber
+
+from .table_parser import TableInfo, tables_on_page
+from .docx_writer import build_document, build_single_table_document
+from .text_scorer import get_scorer
+
+logger = logging.getLogger(__name__)
+
+
+# 跨页表格检测用容差
+WIDTH_MATCH_EPSILON = 0.1   # 列宽比例差异小于此值认为匹配
+PAGE_EDGE_EPSILON = 80      # pt,距页边距小于此值认为"靠近边缘"
+
+
+def _table_first_row_preview(table: TableInfo, max_chars: int = 20) -> list:
+    """提取表格首行前几列作为预览。"""
+    preview = []
+    if table.rows == 0:
+        return preview
+    for c in range(min(table.cols, 5)):
+        cell = table.cells[0][c]
+        if cell:
+            txt = cell.text.strip().replace("\n", " ")[:max_chars]
+            preview.append(txt)
+        else:
+            preview.append("")
+    return preview
+
+
+def _has_continuity_signal(t1: TableInfo, t2: TableInfo) -> bool:
+    """检查跨页表格之间的内容连续性信号。
+
+    当列宽不完全匹配时,用以下信号判断是否为同表跨页:
+
+    信号 A——空首列延续:t2 的首行第 0 列为空,但 t1 对应位置有内容
+    (考虑纵向合并,从下往上取 t1 末列第一个非空单元格)。
+
+    信号 B——NLP 文本延续:使用语言模型评分,判断 t1 与 t2 交界处
+    的文本是否可以拼接成完整语义单元(如「资源投入」+「和管理」)。
+
+    信号 C——全表空首列:t2 在首列几乎全部为空,是 t1 分类标签的续表。
+    """
+    if t1.rows == 0 or t2.rows == 0:
+        return False
+
+    signals = []
+
+    # ---- 辅助:获取 t1 末行指定列的第一个非空文本 ----
+    def _last_nonempty(tbl: TableInfo, col: int) -> str:
+        for r in range(tbl.rows - 1, -1, -1):
+            txt = tbl.cells[r][col].text.strip()
+            if txt:
+                return txt
+        return ""
+
+    # ---- 信号 A:空首列延续 ----
+    last_nonempty_t1_col0 = _last_nonempty(t1, 0)
+    first_cell_t2 = t2.cells[0][0].text.strip()
+    if not first_cell_t2 and last_nonempty_t1_col0:
+        signals.append("A")
+
+    # ---- 信号 B:NLP 文本延续(基于语言模型评分) ----
+    try:
+        scorer = get_scorer()
+        for c in range(min(t1.cols, t2.cols)):
+            t1_last = _last_nonempty(t1, c)
+            t2_first = t2.cells[0][c].text.strip()
+            if not t1_last or not t2_first:
+                continue
+            if len(t1_last) < 2 or len(t2_first) < 2:
+                continue
+            if scorer.is_continuation(t1_last, t2_first, threshold=1.5):
+                logger.debug(f"NLP 文本延续信号: '{t1_last[-20:]}' + '{t2_first[:20]}'")
+                signals.append("B")
+                break
+    except FileNotFoundError as e:
+        logger.warning(f"语言模型未加载,跳过 NLP 检测: {e}")
+    except Exception as e:
+        logger.debug(f"NLP 评分异常(不影响主流程): {e}")
+
+    # ---- 信号 C:全表空首列 -> 可能是续表 ----
+    all_first_col_empty = all(
+        not t2.cells[r][0].text.strip()
+        for r in range(min(t2.rows, 5))
+    )
+    if all_first_col_empty and last_nonempty_t1_col0:
+        signals.append("C")
+
+    return len(signals) > 0
+
+
+def _tables_span_pages(t1: TableInfo, t2: TableInfo,
+                       page1_height: float = None,
+                       page2_height: float = None) -> bool:
+    """判断两个表格是否属于跨页的同一个表格。
+
+    两阶段判断:
+    阶段一(严格匹配):列数相同 + 列宽匹配 + 页面位置 + 非重复表头
+    阶段二(宽松匹配):列数相同 + 页面位置 + 内容连续性信号
+
+    宽松匹配用于处理 pdfplumber 列宽解析不一致的情况
+    (如跨页后因合并单元格导致列边界不同)。
+    """
+    if t1.cols != t2.cols:
+        return False
+
+    # ---- 公共前提:页面位置靠近 ----
+    if t1.bbox and page1_height:
+        if page1_height - t1.bbox[3] > PAGE_EDGE_EPSILON:
+            return False
+    if t2.bbox and page2_height:
+        if t2.bbox[1] > PAGE_EDGE_EPSILON:
+            return False
+
+    # ---- 公共前提:首行不是重复表头 ----
+    if t1.rows > 0 and t2.rows > 0:
+        if _table_first_row_preview(t1) == _table_first_row_preview(t2):
+            return False
+
+    # ---- 阶段一:列宽严格匹配 ----
+    if len(t1.col_widths) == len(t2.col_widths) and len(t1.col_widths) > 0:
+        widths_match = True
+        for i in range(len(t1.col_widths)):
+            if abs(t1.col_widths[i] - t2.col_widths[i]) > WIDTH_MATCH_EPSILON:
+                widths_match = False
+                break
+        if widths_match:
+            return True
+
+    # ---- 阶段二:内容连续性宽松匹配 ----
+    if _has_continuity_signal(t1, t2):
+        logger.debug(
+            f"列宽不匹配但检测到延续信号,视为跨页:"
+            f"第 {t1.page_num+1} 页 → 第 {t2.page_num+1} 页"
+        )
+        return True
+
+    return False
+
+
+def _merge_tables(t1: TableInfo, t2: TableInfo) -> TableInfo:
+    """合并两个跨页表格,并修复纵向合并的语义断层。"""
+    skip_header = False
+    if t1.rows > 0 and t2.rows > 0:
+        header_same = True
+        for c in range(min(t1.cols, t2.cols)):
+            if t1.cells[0][c].text.strip() != t2.cells[0][c].text.strip():
+                header_same = False
+                break
+        if header_same:
+            skip_header = True
+
+    t2_start = 1 if skip_header else 0
+    merged_rows = t1.rows + (t2.rows - t2_start)
+    merged_cells = []
+    for r in range(t1.rows):
+        merged_cells.append(list(t1.cells[r]))
+    for r in range(t2_start, t2.rows):
+        merged_cells.append(list(t2.cells[r]))
+
+    # ---- 修复纵向合并的语义断层 ----
+    # t1 的纵向合并标签(如"分项服务方案")被分页截断,
+    # t2 对应列首行为空。此时应扩展 t1 的合并覆盖到 t2 区域。
+    for c in range(t1.cols):
+        # t1 部分:从下往上找最后一个非空文本的合并起始单元格
+        t1_origin_r = None
+        t1_span = 0
+        for r in range(t1.rows - 1, -1, -1):
+            cell = merged_cells[r][c]
+            if cell.row_span > 0 and cell.text.strip():
+                t1_origin_r = r
+                t1_span = cell.row_span
+                break
+
+        # t2 部分:第一个合并起始单元格
+        t2_start_r = None
+        t2_span = 0
+        for r in range(t1.rows, merged_rows):
+            cell = merged_cells[r][c]
+            if cell.row_span > 0:
+                t2_start_r = r
+                t2_span = max(cell.row_span, 1)
+                break
+
+        if (t1_origin_r is not None and t2_start_r is not None
+                and t1_span > 0 and t2_span > 0):
+            t1_merge_end = t1_origin_r + t1_span - 1
+            reaches_bottom = (t1_merge_end >= t1.rows - 1)
+            t2_text = merged_cells[t2_start_r][c].text.strip()
+
+            if not t2_text and reaches_bottom:
+                new_end = t2_start_r + t2_span - 1
+                new_span = new_end - t1_origin_r + 1
+                merged_cells[t1_origin_r][c].row_span = new_span
+                for rr in range(t2_start_r, min(t2_start_r + t2_span, merged_rows)):
+                    merged_cells[rr][c].row_span = 0
+                    merged_cells[rr][c].col_span = 0
+
+    all_pages = list(t1.original_pages or [t1.page_num + 1])
+    all_pages.extend(t2.original_pages or [t2.page_num + 1])
+
+    return TableInfo(
+        rows=merged_rows,
+        cols=t1.cols,
+        cells=merged_cells,
+        col_widths=t1.col_widths,
+        bbox=None,
+        page_num=t1.page_num,
+        cross_page=True,
+        original_pages=sorted(set(all_pages)),
+    )
+
+
+def extract_tables_from_pdf(pdf_path: str) -> list:
+    """从 PDF 中提取所有表格,包括跨页表格的合并。
+
+    Returns:
+        list[TableInfo]: 所有合并后的完整表格列表
+    """
+    if not os.path.exists(pdf_path):
+        raise FileNotFoundError(f"PDF 文件不存在: {pdf_path}")
+
+    all_tables = []
+
+    with pdfplumber.open(pdf_path) as pdf:
+        total_pages = len(pdf.pages)
+        logger.info(f"PDF 共 {total_pages} 页,开始提取表格...")
+
+        # ---- 阶段 1:逐页提取表格 ----
+        page_tables = []
+        for page_num, page in enumerate(pdf.pages):
+            tables = tables_on_page(page, page_num)
+            if tables:
+                page_tables.append((page_num, tables))
+                logger.info(f"第 {page_num+1} 页: 发现 {len(tables)} 个表格")
+
+        if not page_tables:
+            logger.info("未检测到任何表格")
+            return []
+
+        # ---- 阶段 2:展平并合并跨页表格 ----
+        flat_tables = []
+        for page_num, tables in page_tables:
+            for table in tables:
+                table.page_num = page_num
+                table.original_pages = [page_num + 1]
+                flat_tables.append(table)
+
+        merged = []
+        i = 0
+        while i < len(flat_tables):
+            current = flat_tables[i]
+
+            if i + 1 < len(flat_tables):
+                next_table = flat_tables[i + 1]
+                if next_table.page_num == current.page_num + 1:
+                    # 获取两页的高度用于位置判断
+                    p1_h = pdf.pages[current.page_num].height
+                    p2_h = pdf.pages[next_table.page_num].height
+                    if _tables_span_pages(
+                        current, next_table,
+                        page1_height=p1_h, page2_height=p2_h,
+                    ):
+                        logger.info(
+                            f"检测到跨页表格:第 {current.page_num+1} 页 → "
+                            f"第 {next_table.page_num+1} 页"
+                        )
+                        merged_table = _merge_tables(current, next_table)
+                        merged.append(merged_table)
+                        i += 2
+                        continue
+
+            merged.append(current)
+            i += 1
+
+        all_tables = merged
+
+    logger.info(f"表格提取完成,共 {len(all_tables)} 个完整表格")
+    for idx, t in enumerate(all_tables):
+        pages_str = (
+            f"(跨页: {t.original_pages})" if t.cross_page else ""
+        )
+        logger.info(f"  表格 {idx+1}: {t.rows} 行 x {t.cols} 列 {pages_str}")
+
+    return all_tables
+
+
+def _sanitize_filename(name: str) -> str:
+    """清理文件名中的非法字符。"""
+    return re.sub(r'[\\/:*?"<>|]', "_", name)
+
+
+def _build_json_metadata(tables: list, pdf_path: str, output_dir: str) -> dict:
+    """构建表格元数据 JSON。"""
+    pdf_name = os.path.basename(pdf_path)
+    metadata = {
+        "pdf_file": pdf_name,
+        "pdf_path": os.path.abspath(pdf_path),
+        "total_tables": len(tables),
+        "tables": [],
+    }
+
+    for idx, t in enumerate(tables):
+        preview = _table_first_row_preview(t)
+        entry = {
+            "id": idx + 1,
+            "file": f"tables/table_{idx+1:03d}.docx",
+            "pages": t.original_pages or [t.page_num + 1],
+            "rows": t.rows,
+            "cols": t.cols,
+            "cross_page": t.cross_page,
+            "preview": preview,
+        }
+        metadata["tables"].append(entry)
+
+    return metadata
+
+
+class PDFTableExtractor:
+    """PDF 表格提取器,封装完整流程。"""
+
+    def __init__(self, pdf_path: str):
+        self.pdf_path = pdf_path
+        self.tables = []
+
+    def extract(self) -> list:
+        """提取 PDF 中的表格。"""
+        self.tables = extract_tables_from_pdf(self.pdf_path)
+        return self.tables
+
+    def to_docx(
+        self,
+        output_path: str = None,
+        page_width_cm: float = 21.0,
+        page_height_cm: float = 29.7,
+        margin_cm: float = 2.0,
+    ) -> str:
+        """提取表格并生成单个 DOCX 文件(所有表格依次排列)。
+
+        Returns:
+            str: DOCX 文件路径
+        """
+        if not self.tables:
+            self.extract()
+        if not self.tables:
+            logger.warning("未提取到任何表格,无法生成 DOCX")
+            return ""
+
+        if not output_path:
+            base = os.path.splitext(self.pdf_path)[0]
+            output_path = f"{base}_表格输出.docx"
+
+        return build_document(
+            self.tables,
+            output_path,
+            page_width_cm=page_width_cm,
+            page_height_cm=page_height_cm,
+            margin_cm=margin_cm,
+        )
+
+    def to_docx_directory(
+        self,
+        output_dir: str = None,
+        page_width_cm: float = 21.0,
+        page_height_cm: float = 29.7,
+        margin_cm: float = 2.0,
+    ) -> str:
+        """提取表格并生成目录式输出:
+        - output_dir/tables.json         全局元数据
+        - output_dir/tables/table_NNN.docx  每个表格独立文件
+
+        Args:
+            output_dir: 输出目录,默认与 PDF 同目录 + "_表格输出"
+            page_width_cm: 页面宽度
+            page_height_cm: 页面高度
+            margin_cm: 页边距
+
+        Returns:
+            str: 输出目录路径
+        """
+        if not self.tables:
+            self.extract()
+        if not self.tables:
+            logger.warning("未提取到任何表格,无法生成输出目录")
+            return ""
+
+        if not output_dir:
+            base = os.path.splitext(self.pdf_path)[0]
+            output_dir = f"{base}_表格输出"
+
+        os.makedirs(output_dir, exist_ok=True)
+
+        # ---- 创建表格子目录 ----
+        tables_dir = os.path.join(output_dir, "tables")
+        os.makedirs(tables_dir, exist_ok=True)
+
+        # ---- 写入每个表格的独立 DOCX ----
+        for idx, table_info in enumerate(self.tables):
+            if table_info.rows == 0 or table_info.cols == 0:
+                continue
+
+            docx_name = f"table_{idx+1:03d}.docx"
+            docx_path = os.path.join(tables_dir, docx_name)
+
+            label = f"表格 {idx+1}"
+            pages = table_info.original_pages or [table_info.page_num + 1]
+            if table_info.cross_page:
+                label += f"(跨页: 第 {pages[0]}-{pages[-1]} 页)"
+            else:
+                label += f"(第 {pages[0]} 页)"
+
+            build_single_table_document(
+                table_info,
+                docx_path,
+                page_width_cm=page_width_cm,
+                page_height_cm=page_height_cm,
+                margin_cm=margin_cm,
+                table_label=label,
+            )
+
+        # ---- 写入 JSON 元数据 ----
+        meta = _build_json_metadata(self.tables, self.pdf_path, output_dir)
+        json_path = os.path.join(output_dir, "tables.json")
+        with open(json_path, "w", encoding="utf-8") as f:
+            json.dump(meta, f, ensure_ascii=False, indent=2)
+
+        logger.info(f"目录输出完成,共 {len(self.tables)} 个表格")
+        logger.info(f"  元数据: {json_path}")
+        logger.info(f"  表格目录: {tables_dir}/")
+
+        return output_dir

+ 352 - 0
pdf_table_to_docx/table_parser.py

@@ -0,0 +1,352 @@
+"""
+table_parser.py - PDF 表格解析模块
+
+负责:
+- 使用 pdfplumber 从页面中提取表格
+- 根据单元格坐标信息检测合并单元格(跨行/跨列)
+- 提取每个单元格的文本内容
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import Optional
+
+import pdfplumber.table
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class CellInfo:
+    """单个表格单元格的信息"""
+    text: str                       # 单元格文本内容
+    row_span: int = 1               # 跨行数(垂直合并)
+    col_span: int = 1               # 跨列数(水平合并)
+    bbox: Optional[tuple] = None    # 原始坐标 (x0, top, x1, bottom)
+
+
+@dataclass
+class TableInfo:
+    """解析后的表格结构"""
+    rows: int = 0                           # 总行数
+    cols: int = 0                           # 总列数
+    cells: list = field(default_factory=list)  # 二维列表: cells[row][col] -> CellInfo
+    col_widths: list = field(default_factory=list)  # 每列宽度(相对比例)
+    bbox: Optional[tuple] = None            # 表格在页面上的坐标
+    page_num: int = 0                       # 所在页码(0-indexed)
+    cross_page: bool = False                # 是否跨页表格
+    original_pages: list = field(default_factory=list)  # 跨页时记录所有页码(1-indexed)
+
+
+BBOX_EPSILON = 5.0  # pt,bbox 判断容差(约 1.8mm)
+
+
+def _detect_merge_spans(grid, n_rows, n_cols, rows_bottoms=None):
+    """检测合并单元格的跨行跨列范围,自动处理 T 形冲突。
+
+    检测策略(基于 pdfplumber 的网格特性):
+    - pdfplumber 将表格表示为网格 grid[r][c]
+    - 一个合并单元格在它的起始位置 (r, c) 存储 bbox(非 None)
+    - 被它覆盖的其他网格位置均为 None
+    - 从起始位置向右/向下扫描连续的 None 确定 col_span 和 row_span
+
+    BBox 验证(核心修复):
+    - 仅靠 None 扫描可能产生假阳性纵向合并(如 page 24 的"合计"行,
+      其中下方 None 单元格实际属于水平合并而非纵向合并)。
+    - 因此,对 row_span > 1 的候选,验证单元格 bbox 底部确实延伸到
+      目标行的底部,否则缩减行跨度。
+
+    T 形冲突处理:
+    - DOCX 不允许非矩形合并。
+    - 按 row-major 顺序认领区域,后处理的合并若冲突则自动缩减。
+
+    Args:
+        grid: grid[r][c] = bbox tuple or None
+        n_rows: 总行数
+        n_cols: 总列数
+        rows_bottoms: list[float] 每行的底部 y 坐标,用于 bbox 验证
+
+    Returns:
+        dict: {(r, c): (row_span, col_span)} 映射
+    """
+    # ---- 第一遍:初步检测所有可能的合并 ----
+    candidates = []  # [(r, c, row_span, col_span), ...]
+    for r in range(n_rows):
+        for c in range(n_cols):
+            cell = grid[r][c]
+            if cell is None:
+                continue
+
+            x0, top, x1, bottom = cell
+
+            # 列跨度:向右扫描连续 None
+            col_span = 1
+            for cc in range(c + 1, n_cols):
+                if grid[r][cc] is None:
+                    col_span += 1
+                else:
+                    break
+
+            # 行跨度:向下扫描连续 None
+            row_span = 1
+            for rr in range(r + 1, n_rows):
+                if grid[rr][c] is None:
+                    row_span += 1
+                else:
+                    break
+
+            # ---- BBox 验证 ----
+            # 纵向合并验证:bbox 底部必须延伸到目标行的底部
+            if row_span > 1 and rows_bottoms is not None:
+                target_bottom = rows_bottoms[r + row_span - 1]
+                if bottom < target_bottom - BBOX_EPSILON:
+                    # bbox 不够长,往回缩减
+                    reduced = False
+                    for rs in range(row_span - 1, 0, -1):
+                        if bottom >= rows_bottoms[r + rs - 1] - BBOX_EPSILON:
+                            row_span = rs
+                            reduced = True
+                            break
+                    if not reduced:
+                        row_span = 1
+
+            # 验证矩形完整性
+            if row_span > 1 or col_span > 1:
+                all_clear = True
+                for rr in range(r, r + row_span):
+                    for cc in range(c, c + col_span):
+                        if rr == r and cc == c:
+                            continue
+                        if grid[rr][cc] is not None:
+                            all_clear = False
+                            break
+                    if not all_clear:
+                        break
+                if not all_clear:
+                    row_span = 1
+                    col_span = 1
+
+            candidates.append((r, c, row_span, col_span))
+
+    # ---- 第二遍:按 row-major 排序,解决冲突 ----
+    candidates.sort(key=lambda x: (x[0], x[1]))
+
+    claimed = set()
+    merge_spans = {}
+
+    for r, c, row_span, col_span in candidates:
+        if (r, c) in claimed:
+            merge_spans[(r, c)] = (0, 0)
+            continue
+
+        if row_span == 1 and col_span == 1:
+            claimed.add((r, c))
+            merge_spans[(r, c)] = (1, 1)
+            continue
+
+        actual_row_span = row_span
+        actual_col_span = col_span
+
+        # 先缩减列跨度:检查每行中是否有已被认领的列
+        for rr in range(r, r + actual_row_span):
+            for cc in range(c + 1, c + actual_col_span):
+                if (rr, cc) in claimed:
+                    actual_col_span = cc - c
+                    break
+            if rr > r:
+                for cc in range(c + 1, c + actual_col_span):
+                    if (rr, cc) in claimed:
+                        actual_col_span = min(actual_col_span, cc - c)
+                        break
+
+        # 再缩减行跨度:检查每列中是否有已被认领的行
+        for cc in range(c, c + actual_col_span):
+            for rr in range(r + 1, r + actual_row_span):
+                if (rr, cc) in claimed:
+                    actual_row_span = rr - r
+                    break
+
+        actual_row_span = max(1, actual_row_span)
+        actual_col_span = max(1, actual_col_span)
+
+        # 认领区域
+        for rr in range(r, r + actual_row_span):
+            for cc in range(c, c + actual_col_span):
+                claimed.add((rr, cc))
+
+        merge_spans[(r, c)] = (actual_row_span, actual_col_span)
+
+    return merge_spans
+
+
+def parse_table(
+    table: pdfplumber.table.Table,
+    page_num: int = 0,
+) -> TableInfo:
+    """将一个 pdfplumber Table 对象解析为 TableInfo,检测合并单元格。
+
+    pdfplumber 的 Table 对象中,被合并"覆盖"的网格位置返回 None,
+    我们利用这个信息来推断每个单元格的 row_span 和 col_span。
+
+    核心检测策略:
+    1. 遍历表格网格 grid[r][c]。
+    2. 对于非 None 的单元格,向右扫描连续的 None 来计算列跨度,
+       向下扫描连续的 None 来计算行跨度。
+    3. 验证合并矩形区域的完整性。
+    """
+    n_rows = len(table.rows)
+    n_cols = len(table.columns)
+
+    if n_rows == 0 or n_cols == 0:
+        logger.warning(f"第 {page_num+1} 页发现空表格,跳过")
+        return TableInfo()
+
+    # 构建原始网格:grid[r][c] = bbox tuple or None
+    grid: list = []
+    for r in range(n_rows):
+        row_cells = []
+        for c in range(n_cols):
+            cell = table.rows[r].cells[c]
+            row_cells.append(cell)
+        grid.append(row_cells)
+
+    # ---- 1. 检测合并范围 ----
+    rows_bottoms = [table.rows[r].bbox[3] for r in range(n_rows)]
+    merge_spans = _detect_merge_spans(
+        grid, n_rows, n_cols,
+        rows_bottoms=rows_bottoms,
+    )
+
+    # ---- 2. 提取文字内容 ----
+    try:
+        raw_text = table.extract()
+    except Exception as e:
+        logger.warning(f"表格文字提取失败: {e}")
+        raw_text = None
+
+    # ---- 2.5 清理不必要的换行符(PDF 换行包装 vs 结构性换行) ----
+    if raw_text:
+        try:
+            from .text_scorer import get_scorer
+            _scorer = get_scorer()
+            _score_fn = _scorer.score
+        except Exception:
+            _score_fn = None
+
+        def _clean_nl(text: str) -> str:
+            """去除 PDF 换行包装引入的 \\n,保留结构性换行。"""
+            if '\n' not in text:
+                return text
+            parts = text.split('\n')
+
+            # 规则1:某行只有 1 个字符 → 包装换行,去
+            if any(len(p.strip()) <= 1 for p in parts):
+                return text.replace('\n', '')
+
+            # 规则2:括号分裂如 "(万\n元)" → 去
+            open_br = set('(({[〈《「『【')
+            close_br = set('))}]〉》」』】')
+            for i in range(1, len(parts)):
+                prev, curr = parts[i-1].strip(), parts[i].strip()
+                if prev and curr:
+                    if prev[-1] in open_br or curr[0] in close_br:
+                        return text.replace('\n', '')
+
+            # 规则3:分值模式 "文字\n数字" → 保
+            last = parts[-1].strip()
+            if last.replace('.','').isdigit() and len(parts) > 1:
+                before = '\n'.join(parts[:-1]).strip()
+                if len(before) > 1:
+                    return text
+
+            # 规则4:NLP 评分(文本够长时)
+            cleaned = text.replace('\n', '')
+            if _score_fn and len(cleaned) >= 6:
+                try:
+                    s_c = _score_fn(cleaned)
+                    line_scores = [_score_fn(p.strip()) for p in parts if p.strip()]
+                    if line_scores:
+                        max_l = max(line_scores)
+                        if s_c > max_l * 1.2:
+                            return cleaned
+                        if s_c > 0.01 and max_l < 0.008:
+                            return cleaned
+                except Exception:
+                    pass
+            return text  # 保守保留
+
+        for r in range(n_rows):
+            for c in range(n_cols):
+                raw = raw_text[r][c]
+                if raw:
+                    raw_text[r][c] = _clean_nl(raw)
+
+    # ---- 3. 构建 CellInfo 网格 ----
+    cells: list = []
+    for r in range(n_rows):
+        row_cells = []
+        for c in range(n_cols):
+            if grid[r][c] is None:
+                # 被合并覆盖,填充占位
+                row_cells.append(CellInfo(text="", row_span=0, col_span=0))
+            else:
+                rs, cs = merge_spans.get((r, c), (1, 1))
+
+                # 从 extract() 结果中获取文本
+                text = ""
+                if raw_text and r < len(raw_text) and c < len(raw_text[r]):
+                    raw = raw_text[r][c]
+                    if raw is not None:
+                        text = raw
+
+                row_cells.append(CellInfo(
+                    text=text,
+                    row_span=rs,
+                    col_span=cs,
+                    bbox=grid[r][c],
+                ))
+        cells.append(row_cells)
+
+    # ---- 4. 计算每列相对宽度 ----
+    col_widths = []
+    for c in range(n_cols):
+        col_bbox = table.columns[c].bbox  # (x0, top, x1, bottom)
+        w = col_bbox[2] - col_bbox[0]
+        col_widths.append(w)
+
+    total_w = sum(col_widths)
+    if total_w > 0:
+        col_widths = [w / total_w for w in col_widths]
+
+    return TableInfo(
+        rows=n_rows,
+        cols=n_cols,
+        cells=cells,
+        col_widths=col_widths,
+        bbox=table.bbox,
+        page_num=page_num,
+    )
+
+
+def tables_on_page(page, page_num: int) -> list:
+    """提取一页中的所有表格。
+
+    Returns:
+        list[TableInfo]: 页面上所有解析后的表格列表
+    """
+    try:
+        raw_tables = page.find_tables()
+    except Exception as e:
+        logger.error(f"第 {page_num+1} 页表格检测失败: {e}")
+        return []
+
+    results = []
+    for table in raw_tables:
+        try:
+            info = parse_table(table, page_num)
+            if info.rows > 0 and info.cols > 0:
+                results.append(info)
+        except Exception as e:
+            logger.warning(f"第 {page_num+1} 页中某个表格解析失败: {e}")
+
+    return results

+ 182 - 0
pdf_table_to_docx/text_scorer.py

@@ -0,0 +1,182 @@
+"""
+text_scorer.py - 基于语言模型的文本完整性评分模块
+
+使用中文 GPT-2 模型(ONNX 格式)计算文本的自然度评分,
+用于判断:
+1. 两个文本片段是否应该拼接(跨页内容延续检测)
+2. 文本中的换行符是否需要去除
+"""
+
+import logging
+import os
+from typing import Optional
+
+import numpy as np
+import onnxruntime as ort
+from tokenizers import Tokenizer
+
+logger = logging.getLogger(__name__)
+
+# 模型路径
+MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
+                         "models", "gpt2-chinese-cluecorpussmall-onnx")
+MODEL_PATH = os.path.join(MODEL_DIR, "model.onnx")
+TOKENIZER_PATH = os.path.join(MODEL_DIR, "tokenizer.json")
+
+# 特殊 token ID(BERT WordPiece)
+CLS_ID = 101
+SEP_ID = 102
+PAD_ID = 0
+
+# 最大序列长度
+MAX_LEN = 64
+
+
+class TextScorer:
+    """使用中文 GPT-2 计算文本自然度评分的单例封装。"""
+
+    _instance = None
+
+    def __new__(cls):
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+            cls._instance._initialized = False
+        return cls._instance
+
+    def __init__(self):
+        if self._initialized:
+            return
+        self._initialized = True
+        self._session = None
+        self._tokenizer = None
+
+    def _ensure_loaded(self):
+        if self._session is not None:
+            return
+        if not os.path.exists(MODEL_PATH):
+            raise FileNotFoundError(
+                f"模型文件不存在: {MODEL_PATH}\n"
+                f"请确认 models/gpt2-chinese-cluecorpussmall-onnx/ 已下载"
+            )
+        self._session = ort.InferenceSession(MODEL_PATH)
+        self._tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
+        logger.info(f"文本评分模型已加载: {MODEL_PATH}")
+
+    def score(self, text: str) -> float:
+        """计算文本的自然度评分(0~1),越高越自然完整。
+
+        基于困惑度(Perplexity)转换:score = 1 / (1 + PPL/10)
+        """
+        self._ensure_loaded()
+        if not text or not text.strip():
+            return 1.0
+
+        tokens = self._tokenizer.encode(text.strip())
+        ids = tokens.ids
+
+        if len(ids) > MAX_LEN - 2:
+            ids = ids[:MAX_LEN - 2]
+
+        input_ids = [CLS_ID] + ids + [SEP_ID]
+        seq_len = len(input_ids)
+        padding = [PAD_ID] * (MAX_LEN - seq_len)
+        input_ids_padded = input_ids + padding
+        attn_mask = [1] * seq_len + [0] * (MAX_LEN - seq_len)
+
+        inputs = {
+            'input_ids': np.array([input_ids_padded], dtype=np.int64),
+            'attention_mask': np.array([attn_mask], dtype=np.int64),
+        }
+        logits = self._session.run(None, inputs)[0]
+
+        # 计算每个位置的交叉熵损失(预测下一个 token)
+        losses = []
+        for i in range(seq_len - 1):
+            pred = logits[0, i, :]
+            true_id = input_ids[i + 1]
+            # softmax + cross-entropy
+            pred_max = np.max(pred)
+            exp_pred = np.exp(pred - pred_max)
+            probs = exp_pred / np.sum(exp_pred)
+            loss = -np.log(max(probs[true_id], 1e-10))
+            losses.append(loss)
+
+        avg_loss = np.mean(losses) if losses else 0
+        ppl = np.exp(avg_loss)
+        return float(1.0 / (1.0 + ppl / 10.0))
+
+    def is_continuation(self, text1: str, text2: str,
+                        threshold: float = 1.5) -> bool:
+        """判断 text2 是否是 text1 的内容延续。
+
+        比较 text1、text2 和拼接后文本的评分。
+        如果拼接后评分显著高于各自评分,说明是延续关系。
+
+        Args:
+            text1: 前半段文本
+            text2: 后半段文本
+            threshold: 拼接提升倍率阈值(默认 1.5 倍)
+
+        Returns:
+            bool: 是否构成内容延续
+        """
+        if not text1 or not text2:
+            return False
+        if len(text1.strip()) < 2 or len(text2.strip()) < 2:
+            return False
+
+        s1 = self.score(text1)
+        s2 = self.score(text2)
+        sc = self.score(text1.strip() + text2.strip())
+
+        # 拼接后评分 > 各自最大评分的 threshold 倍
+        best_individual = max(s1, s2)
+        if best_individual <= 0:
+            return False
+        improvement = sc / best_individual
+
+        # 同时检查:拼接后不低于单独评分,且明显提升
+        return sc > best_individual and improvement >= threshold
+
+    def clean_newlines(self, text: str,
+                       threshold: float = 1.1) -> str:
+        """去除文本中对自然度有负面影响的换行符。
+
+        比较原文和去除 \\n 后的评分,若去除后评分提升超过 threshold,
+        则返回去除换行符的版本。
+
+        Args:
+            text: 原始文本
+            threshold: 评分提升阈值(默认 1.1 倍)
+
+        Returns:
+            str: 清理后的文本
+        """
+        if not text or '\n' not in text:
+            return text
+
+        cleaned = text.replace('\n', '')
+        if cleaned == text:
+            return text
+
+        s_orig = self.score(text)
+        s_clean = self.score(cleaned)
+
+        if s_clean > s_orig * threshold:
+            logger.debug(f"去除换行符: '{text[:30]}' -> '{cleaned[:30]}'"
+                         f" (评分 {s_orig:.4f} -> {s_clean:.4f})")
+            return cleaned
+
+        return text
+
+
+# 全局单例
+_scorer = None
+
+
+def get_scorer() -> TextScorer:
+    """获取全局 TextScorer 单例。"""
+    global _scorer
+    if _scorer is None:
+        _scorer = TextScorer()
+    return _scorer

+ 9 - 0
pyproject.toml

@@ -0,0 +1,9 @@
+[project]
+name = "yuan"
+version = "0.1.0"
+description = "Add your description here"
+requires-python = ">=3.13"
+dependencies = [
+    "openai>=2.44.0",
+    "pymupdf>=1.28.0",
+]