| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- """
- 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
|