table_parser.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. """
  2. table_parser.py - PDF 表格解析模块
  3. 负责:
  4. - 使用 pdfplumber 从页面中提取表格
  5. - 根据单元格坐标信息检测合并单元格(跨行/跨列)
  6. - 提取每个单元格的文本内容
  7. """
  8. import logging
  9. from dataclasses import dataclass, field
  10. from typing import Optional
  11. import pdfplumber.table
  12. logger = logging.getLogger(__name__)
  13. @dataclass
  14. class CellInfo:
  15. """单个表格单元格的信息"""
  16. text: str # 单元格文本内容
  17. row_span: int = 1 # 跨行数(垂直合并)
  18. col_span: int = 1 # 跨列数(水平合并)
  19. bbox: Optional[tuple] = None # 原始坐标 (x0, top, x1, bottom)
  20. @dataclass
  21. class TableInfo:
  22. """解析后的表格结构"""
  23. rows: int = 0 # 总行数
  24. cols: int = 0 # 总列数
  25. cells: list = field(default_factory=list) # 二维列表: cells[row][col] -> CellInfo
  26. col_widths: list = field(default_factory=list) # 每列宽度(相对比例)
  27. bbox: Optional[tuple] = None # 表格在页面上的坐标
  28. page_num: int = 0 # 所在页码(0-indexed)
  29. cross_page: bool = False # 是否跨页表格
  30. original_pages: list = field(default_factory=list) # 跨页时记录所有页码(1-indexed)
  31. BBOX_EPSILON = 5.0 # pt,bbox 判断容差(约 1.8mm)
  32. def _detect_merge_spans(grid, n_rows, n_cols, rows_bottoms=None):
  33. """检测合并单元格的跨行跨列范围,自动处理 T 形冲突。
  34. 检测策略(基于 pdfplumber 的网格特性):
  35. - pdfplumber 将表格表示为网格 grid[r][c]
  36. - 一个合并单元格在它的起始位置 (r, c) 存储 bbox(非 None)
  37. - 被它覆盖的其他网格位置均为 None
  38. - 从起始位置向右/向下扫描连续的 None 确定 col_span 和 row_span
  39. BBox 验证(核心修复):
  40. - 仅靠 None 扫描可能产生假阳性纵向合并(如 page 24 的"合计"行,
  41. 其中下方 None 单元格实际属于水平合并而非纵向合并)。
  42. - 因此,对 row_span > 1 的候选,验证单元格 bbox 底部确实延伸到
  43. 目标行的底部,否则缩减行跨度。
  44. T 形冲突处理:
  45. - DOCX 不允许非矩形合并。
  46. - 按 row-major 顺序认领区域,后处理的合并若冲突则自动缩减。
  47. Args:
  48. grid: grid[r][c] = bbox tuple or None
  49. n_rows: 总行数
  50. n_cols: 总列数
  51. rows_bottoms: list[float] 每行的底部 y 坐标,用于 bbox 验证
  52. Returns:
  53. dict: {(r, c): (row_span, col_span)} 映射
  54. """
  55. # ---- 第一遍:初步检测所有可能的合并 ----
  56. candidates = [] # [(r, c, row_span, col_span), ...]
  57. for r in range(n_rows):
  58. for c in range(n_cols):
  59. cell = grid[r][c]
  60. if cell is None:
  61. continue
  62. x0, top, x1, bottom = cell
  63. # 列跨度:向右扫描连续 None
  64. col_span = 1
  65. for cc in range(c + 1, n_cols):
  66. if grid[r][cc] is None:
  67. col_span += 1
  68. else:
  69. break
  70. # 行跨度:向下扫描连续 None
  71. row_span = 1
  72. for rr in range(r + 1, n_rows):
  73. if grid[rr][c] is None:
  74. row_span += 1
  75. else:
  76. break
  77. # ---- BBox 验证 ----
  78. # 纵向合并验证:bbox 底部必须延伸到目标行的底部
  79. if row_span > 1 and rows_bottoms is not None:
  80. target_bottom = rows_bottoms[r + row_span - 1]
  81. if bottom < target_bottom - BBOX_EPSILON:
  82. # bbox 不够长,往回缩减
  83. reduced = False
  84. for rs in range(row_span - 1, 0, -1):
  85. if bottom >= rows_bottoms[r + rs - 1] - BBOX_EPSILON:
  86. row_span = rs
  87. reduced = True
  88. break
  89. if not reduced:
  90. row_span = 1
  91. # 验证矩形完整性
  92. if row_span > 1 or col_span > 1:
  93. all_clear = True
  94. for rr in range(r, r + row_span):
  95. for cc in range(c, c + col_span):
  96. if rr == r and cc == c:
  97. continue
  98. if grid[rr][cc] is not None:
  99. all_clear = False
  100. break
  101. if not all_clear:
  102. break
  103. if not all_clear:
  104. row_span = 1
  105. col_span = 1
  106. candidates.append((r, c, row_span, col_span))
  107. # ---- 第二遍:按 row-major 排序,解决冲突 ----
  108. candidates.sort(key=lambda x: (x[0], x[1]))
  109. claimed = set()
  110. merge_spans = {}
  111. for r, c, row_span, col_span in candidates:
  112. if (r, c) in claimed:
  113. merge_spans[(r, c)] = (0, 0)
  114. continue
  115. if row_span == 1 and col_span == 1:
  116. claimed.add((r, c))
  117. merge_spans[(r, c)] = (1, 1)
  118. continue
  119. actual_row_span = row_span
  120. actual_col_span = col_span
  121. # 先缩减列跨度:检查每行中是否有已被认领的列
  122. for rr in range(r, r + actual_row_span):
  123. for cc in range(c + 1, c + actual_col_span):
  124. if (rr, cc) in claimed:
  125. actual_col_span = cc - c
  126. break
  127. if rr > r:
  128. for cc in range(c + 1, c + actual_col_span):
  129. if (rr, cc) in claimed:
  130. actual_col_span = min(actual_col_span, cc - c)
  131. break
  132. # 再缩减行跨度:检查每列中是否有已被认领的行
  133. for cc in range(c, c + actual_col_span):
  134. for rr in range(r + 1, r + actual_row_span):
  135. if (rr, cc) in claimed:
  136. actual_row_span = rr - r
  137. break
  138. actual_row_span = max(1, actual_row_span)
  139. actual_col_span = max(1, actual_col_span)
  140. # 认领区域
  141. for rr in range(r, r + actual_row_span):
  142. for cc in range(c, c + actual_col_span):
  143. claimed.add((rr, cc))
  144. merge_spans[(r, c)] = (actual_row_span, actual_col_span)
  145. return merge_spans
  146. def parse_table(
  147. table: pdfplumber.table.Table,
  148. page_num: int = 0,
  149. ) -> TableInfo:
  150. """将一个 pdfplumber Table 对象解析为 TableInfo,检测合并单元格。
  151. pdfplumber 的 Table 对象中,被合并"覆盖"的网格位置返回 None,
  152. 我们利用这个信息来推断每个单元格的 row_span 和 col_span。
  153. 核心检测策略:
  154. 1. 遍历表格网格 grid[r][c]。
  155. 2. 对于非 None 的单元格,向右扫描连续的 None 来计算列跨度,
  156. 向下扫描连续的 None 来计算行跨度。
  157. 3. 验证合并矩形区域的完整性。
  158. """
  159. n_rows = len(table.rows)
  160. n_cols = len(table.columns)
  161. if n_rows == 0 or n_cols == 0:
  162. logger.warning(f"第 {page_num+1} 页发现空表格,跳过")
  163. return TableInfo()
  164. # 构建原始网格:grid[r][c] = bbox tuple or None
  165. grid: list = []
  166. for r in range(n_rows):
  167. row_cells = []
  168. for c in range(n_cols):
  169. cell = table.rows[r].cells[c]
  170. row_cells.append(cell)
  171. grid.append(row_cells)
  172. # ---- 1. 检测合并范围 ----
  173. rows_bottoms = [table.rows[r].bbox[3] for r in range(n_rows)]
  174. merge_spans = _detect_merge_spans(
  175. grid, n_rows, n_cols,
  176. rows_bottoms=rows_bottoms,
  177. )
  178. # ---- 2. 提取文字内容 ----
  179. try:
  180. raw_text = table.extract()
  181. except Exception as e:
  182. logger.warning(f"表格文字提取失败: {e}")
  183. raw_text = None
  184. # ---- 2.5 清理不必要的换行符(PDF 换行包装 vs 结构性换行) ----
  185. if raw_text:
  186. try:
  187. from .text_scorer import get_scorer
  188. _scorer = get_scorer()
  189. _score_fn = _scorer.score
  190. except Exception:
  191. _score_fn = None
  192. def _clean_nl(text: str) -> str:
  193. """去除 PDF 换行包装引入的 \\n,保留结构性换行。"""
  194. if '\n' not in text:
  195. return text
  196. parts = text.split('\n')
  197. # 规则1:某行只有 1 个字符 → 包装换行,去
  198. if any(len(p.strip()) <= 1 for p in parts):
  199. return text.replace('\n', '')
  200. # 规则2:括号分裂如 "(万\n元)" → 去
  201. open_br = set('(({[〈《「『【')
  202. close_br = set('))}]〉》」』】')
  203. for i in range(1, len(parts)):
  204. prev, curr = parts[i-1].strip(), parts[i].strip()
  205. if prev and curr:
  206. if prev[-1] in open_br or curr[0] in close_br:
  207. return text.replace('\n', '')
  208. # 规则3:分值模式 "文字\n数字" → 保
  209. last = parts[-1].strip()
  210. if last.replace('.','').isdigit() and len(parts) > 1:
  211. before = '\n'.join(parts[:-1]).strip()
  212. if len(before) > 1:
  213. return text
  214. # 规则4:NLP 评分(文本够长时)
  215. cleaned = text.replace('\n', '')
  216. if _score_fn and len(cleaned) >= 6:
  217. try:
  218. s_c = _score_fn(cleaned)
  219. line_scores = [_score_fn(p.strip()) for p in parts if p.strip()]
  220. if line_scores:
  221. max_l = max(line_scores)
  222. if s_c > max_l * 1.2:
  223. return cleaned
  224. if s_c > 0.01 and max_l < 0.008:
  225. return cleaned
  226. except Exception:
  227. pass
  228. return text # 保守保留
  229. for r in range(n_rows):
  230. for c in range(n_cols):
  231. raw = raw_text[r][c]
  232. if raw:
  233. raw_text[r][c] = _clean_nl(raw)
  234. # ---- 3. 构建 CellInfo 网格 ----
  235. cells: list = []
  236. for r in range(n_rows):
  237. row_cells = []
  238. for c in range(n_cols):
  239. if grid[r][c] is None:
  240. # 被合并覆盖,填充占位
  241. row_cells.append(CellInfo(text="", row_span=0, col_span=0))
  242. else:
  243. rs, cs = merge_spans.get((r, c), (1, 1))
  244. # 从 extract() 结果中获取文本
  245. text = ""
  246. if raw_text and r < len(raw_text) and c < len(raw_text[r]):
  247. raw = raw_text[r][c]
  248. if raw is not None:
  249. text = raw
  250. row_cells.append(CellInfo(
  251. text=text,
  252. row_span=rs,
  253. col_span=cs,
  254. bbox=grid[r][c],
  255. ))
  256. cells.append(row_cells)
  257. # ---- 4. 计算每列相对宽度 ----
  258. col_widths = []
  259. for c in range(n_cols):
  260. col_bbox = table.columns[c].bbox # (x0, top, x1, bottom)
  261. w = col_bbox[2] - col_bbox[0]
  262. col_widths.append(w)
  263. total_w = sum(col_widths)
  264. if total_w > 0:
  265. col_widths = [w / total_w for w in col_widths]
  266. return TableInfo(
  267. rows=n_rows,
  268. cols=n_cols,
  269. cells=cells,
  270. col_widths=col_widths,
  271. bbox=table.bbox,
  272. page_num=page_num,
  273. )
  274. def tables_on_page(page, page_num: int) -> list:
  275. """提取一页中的所有表格。
  276. Returns:
  277. list[TableInfo]: 页面上所有解析后的表格列表
  278. """
  279. try:
  280. raw_tables = page.find_tables()
  281. except Exception as e:
  282. logger.error(f"第 {page_num+1} 页表格检测失败: {e}")
  283. return []
  284. results = []
  285. for table in raw_tables:
  286. try:
  287. info = parse_table(table, page_num)
  288. if info.rows > 0 and info.cols > 0:
  289. results.append(info)
  290. except Exception as e:
  291. logger.warning(f"第 {page_num+1} 页中某个表格解析失败: {e}")
  292. return results