【pdf2md-2:关键核心】PDF 转 Markdown 技术拆解:两阶段流水线、四级标题检测与段落智能合并
上一篇文章展示了 PDF 转 Markdown 工具的功能效果。这篇深入拆解两个核心模块的架构设计和关键算法——标题怎么检测、段落怎么合并、表格怎么处理以及实战中踩过的坑。说明程序实现和本文内容均有借助AI生成。在线体验点击立即体验 PDF 转 Markdown一、整体架构两阶段流水线核心思路先解析再转换。架构流程图如下解析阶段pdf_parser.py约 1200 行负责把 PDF 打散成原子级的结构数据——每个字的字号、字体、坐标每张图的位置和像素每个书签的层级和页码每个表格的行列结构。转换阶段md_converter.py约 900 行根据这些原子数据推断文档的逻辑结构——哪些文本是标题、哪些文本属于同一个段落、哪些文本是重复的页眉页脚——然后渲染为 Markdown 字符串。这个两阶段设计的好处是关注点分离解析器不需要懂 Markdown 语法转换器不需要关心 PDF 字节流。加一个输出格式比如 HTML只需要写一个新的转换器解析器完全复用。二、数据模型解析到转换的桥梁两个阶段之间通过一组数据类dataclass传递信息dataclassclassPDFContent:title:str# 文档标题来自元数据author:str# 作者page_count:int# 总页数bookmarks:List[BookmarkItem]# 书签树pages:List[PageContent]# 各页内容dataclassclassPageContent:page_index:intwidth:float# 页面宽度height:float# 页面高度text_blocks:List[TextBlock]# 文本块带字体信息和坐标image_blocks:List[ImageBlock]# 图片link_blocks:List[LinkBlock]# 超链接table_blocks:List[TableBlock]# 表格来自 LR 模块lr_headings:List[LRHeadingInfo]# LR 识别的标题元素lr_paragraphs:List[Tuple]# LR 段落边界框其中最核心的是TextBlock——每个文本块带着完整的样式信息dataclassclassTextBlock:text:strfont_name:strfont_size:float0.0is_bold:boolFalseis_italic:boolFalsebbox:Tuple[float,float,float,float](0,0,0,0)# left, bottom, right, toppage_index:int0这些字段在转换阶段全都会用到——font_size用于标题检测is_bold用于兜底标题判断bbox坐标用于段落合并和页眉页脚过滤。数据模型层级图如下图所示三、核心算法 1四级级联标题检测标题检测是转换质量的核心——检测不准整个 Markdown 的结构就乱了。因此我设计了一个四级级联策略按优先级依次命中高优先级命中后直接跳过低优先级。如下图所示3.1 第一优先级PDF 书签匹配PDF 书签Bookmark是 PDF 作者或排版工具在文件中显式标记的目录结构它直接告诉你第三章 xxx在第几页、属于第几级。这是最可靠的标题信号源。做法是把书签树展平为一个映射表(page_index, title) → heading_level然后对每个文本块做模糊匹配for(bm_page,bm_title),bm_levelinbm_heading_map.items():ifbm_pagepage.page_index:ifbm_titleintextortextinbm_titleor\ normalize(bm_title)normalize(text):heading_levelbm_levelbreak为什么用包含而不是精确匹配因为书签文本和页面实际文本经常存在微小差异——多一个空格、编号格式略不同。用包含关系匹配可以容忍这些差异。3.2 第二优先级字号排名启发式当 PDF 没有书签、或书签没覆盖的标题就用字号来判断。核心思路是统计全文档的字号分布出现次数最多的字号就是正文字号body_size所有比正文大 20% 以上的字号按从大到小排序依次映射为 H1、H2、H3……body_sizemax(size_char_count,keysize_char_count.get)# 使用最多的字号min_heading_sizebody_size*1.20# 至少大 20%heading_sizessorted([fsforfsinsizesiffsmin_heading_size],reverseTrue,)# heading_sizes[0] → H1, heading_sizes[1] → H2, ...为什么阈值是 1.20 而不是 1.0因为实际中目录条目、表格标题等文字的字号经常比正文大一点点比如 10.5pt vs 9pt如果阈值太低这些非标题内容会被错误升级为标题。20% 是经过 20 多份 PDF 反复测试调出来的平衡值。另外同字号但粗体的短文本≤50 字符也会被提升为低级标题比如####但长文本粗体的整段正文不会。以下是_compute_font_stats()的完整实现——它负责完成上述统计 → 排名 → 映射的全过程# ---- md_converter.py: _compute_font_stats() ----def_compute_font_stats(pages:List[PageContent])-Dict:size_char_count:Dict[float,int]{}forpageinpages:forblockinpage.text_blocks:fsround(block.font_size,1)iffs0:char_lenlen(block.text)size_char_count[fs]size_char_count.get(fs,0)char_lenifnotsize_char_count:return{body_size:12.0,heading_sizes:[],size_rank_map:{}}# 出现字符数最多的字号 → 正文字号body_sizemax(size_char_count,keysize_char_count.get)# 至少比正文大 20% 才算标题字号min_heading_sizebody_size*1.20heading_sizessorted([fsforfsinsize_char_countiffsmin_heading_size],reverseTrue,)# 从大到小映射为 H1, H2, H3 ...size_rank_map:Dict[float,int]{}forrank,fsinenumerate(heading_sizes):levelmin(rank1,6)size_rank_map[fs]levelreturn{body_size:body_size,heading_sizes:heading_sizes,size_rank_map:size_rank_map,}3.3 第三优先级LR 确认 编号模式有些标题的字号和正文完全一样在中文学术论文中很常见但 Foxit SDK 的 LRLayout Recognition模块识别出了它是标题。此时结合中文编号模式来确定层级ifheading_level0andis_lr_headingandlen(text)80:num_levelheading_level_from_numbering(text)ifnum_level0:heading_levelmin(num_level,6)编号模式的识别规则模式示例映射层级第X章第三章 研究方法H1N.M3.2 研究设计H2按小数点深度N.M.K3.2.1 变量定义H3N一研究背景H4注意这里有一个重要的文本长度守卫条件len(text) 80——超过 80 字符的文本即使 LR 说它是标题也不采信。因为 LR 模块有时会把整段正文误分类为标题后面踩坑实录会详细说。3.4 第四优先级粗体 编号模式兜底如果前三级都没命中但文本是粗体、长度较短 60 字符、且匹配了编号模式也升级为标题ifheading_level0andlen(text)60:num_levelheading_level_from_numbering(text)ifnum_level0andblock.is_bold:heading_levelmin(num_level,6)这是最后的兜底——只有同时满足粗体编号短文本三个条件才会触发误判概率极低。四级级联完整代码上面分别讲了每一级的逻辑下面是它们在_convert_page()中实际组合在一起的样子——一个清晰的 if-elif 链条# ---- md_converter.py: _convert_page() 核心片段 ----# --- Heading detection (priority cascade) ---heading_level0# 1) 书签匹配最可靠for(bm_page,bm_title),bm_levelinbm_heading_map.items():ifbm_pagepage.page_index:ifbm_titleand(bm_titleintextortextinbm_titleor_normalize(bm_title)_normalize(text)):heading_levelbm_levelbreak# 2) 字号排名ifheading_level0:heading_level_heading_level_from_font(block.font_size,block.is_bold,font_stats,text)# 3) LR 确认 编号模式ifheading_level0andis_lr_headingandlen(text)80:num_level_heading_level_from_numbering(text)ifnum_level0:heading_levelmin(num_level,6)else:max_rankmax(font_stats.get(size_rank_map,{}).values(),default0)heading_levelmin(max_rank1,6)# 4) 粗体 编号模式兜底ifheading_level0andlen(text)60:num_level_heading_level_from_numbering(text)ifnum_level0andblock.is_bold:heading_levelmin(num_level,6)# --- 输出 ---ifheading_level0:prefix#*heading_level items.append((y_pos,heading,f{prefix}{md_text},blk_bbox))else:items.append((y_pos,body,md_text,blk_bbox))可以看到四级之间完全是串行的未命中就降级关系。每一级的守卫条件len(text) 80、block.is_bold确保误判不会向下传播。四、核心算法 2段落智能合并这可能是整个项目中最令人头疼的部分。问题本质PDF 内部的文字存储方式和我们看到的段落完全是两回事。一个段落可能被 PDF 排版引擎拆成十几个独立的文本对象每个对象就是一行。当你逐字符提取时换行位置是排版引擎决定的跟逻辑段落边界毫无关系。如果简单地每个 TextBlock 单独成段输出就是一行一行的碎片。多层合并策略以下段落合并流程图展示三层合并的判断逻辑第一层LR 段落分组最优先SDK 的 LR 模块不仅能识别标题和表格还能识别段落的边界框。如果两个相邻的文本块都落在同一个 LR 段落的边界框内那它们就是同一个段落直接合并cur_lr_idxfind_lr_paragraph(bbox)ifcur_lr_idx0andcur_lr_idxprev_lr_idx:# 同一个 LR 段落 → 合并para_parts.append(text)这是最准确的信号——LR 模块做了专业的版面分析它说这些文本属于同一段落大概率是对的。第二层几何间距启发式当 LR 数据不可用时通过行间距判断间距 行高 × 1.8 → 大概率是新段落输出段落分隔间距在正常范围内 → 进入第三层续行检测这里有一个body_size 校验细节很重要某些 PDF 的 SDK 返回的font_size是非标准单位比如 240 代表 12pt如果直接拿来算行高会导致阈值荒谬地大。我做了一个兜底avg_block_hsum(block_heights)/len(block_heights)ifbody_sizeavg_block_h*3:body_sizeavg_block_h# 回退到平均块高度第三层续行检测兜底对于间距接近但不确定的情况通过多个信号综合判断是否是续行def_looks_wrapped_continuation(prev_bbox,cur_bbox,prev_text,cur_text,gap):# 1. 左边距是否对齐偏差 1.2 倍字号ifabs(cur_left-prev_left)max(body_size*1.2,10.0):returnFalse# 2. 是否在同一列排除双栏布局的误合并ifabs(cur_center-prev_center)page_width*0.35:returnFalse# 3. 当前行以列表编号开头→ 不合并iflist_start_re.match(cur_text):returnFalse# 4. 前一行以句末标点结尾且非全宽行→ 新段落ifprev_text.endswith((。,,,.,!)):ifnotis_full_width_line(prev_bbox):returnFalsereturnTrue# 通过所有检查 → 合并这里第 4 点很精巧只有当前一行以句末标点结尾 AND 行宽没有占满整行时才判定为段落结束。因为如果前一行以句号结尾但占满了整行很可能只是段落中间的一句话恰好在行尾结束后面还有内容。后处理层假空行清理最后还有一个全局后处理扫描输出的 Markdown清理段内假空行def_collapse_spurious_blank_lines(md_text):# 如果前行不以 。.!? 结尾# 且后行以中文/英文/数字开头# 且两行之间有空行# → 该空行是多余的移除五、核心算法 3页眉页脚过滤PDF 的每一页都可能有重复的页眉页脚。不过滤的话每隔几段就蹦出一行第 X 页。频率统计策略def_detect_header_footer_texts(pages,margin_ratio0.10,min_repeat3):forpageinpages:# 取页面上下各 10% 区域的文本块top_thresholdpage.height*(1.0-margin_ratio)# PDF 坐标0底部bottom_thresholdpage.height*margin_ratioforblockinpage.text_blocks:y_centre(block.bbox[1]block.bbox[3])/2.0ify_centretop_thresholdory_centrebottom_threshold:# 归一化文本后统计出现频率text_page_count[normalize(block.text)]1# 出现 ≥ 3 次的文本 → 页眉或页脚hf_texts{tfort,cintext_page_count.items()ifcmin_repeat}页码的特殊处理页码每页都变“第1页”、“第2页”……不能用重复文本检测。但页码的位置是固定的。我按位置分桶10pt 精度如果同一位置桶出现在大多数页面则该位置的纯数字文本全部标记为页码# 页码位置分桶zonetopifin_topelsebottomx_bucketround(block.bbox[0]/10.0)*10page_num_position_counts[(zone,x_bucket)]1# 如果某个位置桶出现次数 50% 页数ifcountmax(min_repeat,page_count*0.5):hf_texts.add(f__PAGE_NUM__{zone}_{x_bucket})【图 5页面区域划分示意】示意图一个 PDF 页面上下各用蓝色虚线框标出 10% 的页眉/页脚检测区域。页眉区域标注频率统计重复文本 → 过滤页脚区域标注位置分桶固定位置的数字 → 过滤。六、表格处理从 LR 检测到管道表格渲染LR 表格提取流程Foxit SDK 的 LR 模块输出一棵结构化元素树表格部分的层次和 HTML 类似Table ├── THead (表头组) │ └── TR (行) │ ├── TH (表头单元格) │ └── TH ├── TBody (表体组) │ ├── TR │ │ ├── TD (数据单元格) │ │ └── TD │ └── TR │ ├── TD │ └── TD └── TFoot (表footer组)遍历这棵树对每个单元格通过GetBBox()获取边界框然后用TextPage提取该区域的文字。合并单元格的信息通过ColSpan/RowSpan属性读取。Markdown 管道表格渲染提取到的表格数据需要渲染为 Markdown 的管道表格。主要的复杂度在于处理合并单元格# 构建 grid[行][列] 二维数组forcellinrow:# ColSpan文本放第一列后续列留空forcinrange(colspan):grid[ri][cursorc]textifc0else# RowSpan后续行对应列标记已占用forrinrange(1,rowspan):occupied[rir][cursorc]True# 渲染为 Markdown| | .join(grid_row) |Markdown 本身不支持 RowSpan我的处理方式是后续行的被合并列留空——这在 GitHub Flavored Markdown 下渲染效果尚可。七、踩坑实录 1伪表格——长标题被误判为表格这是项目开发中最有意思的一个问题。现象一份 170 多页的项目申报报告中标题3.2 企业自身发展、管理等创新点及对项目建设的推进作用被输出成了一个表格而不是标题。排查通过日志发现LR 模块将这个标题检测为了一个 1 行 2 列的表格。根因是这个标题太长在 PDF 中换行了3.2 企业自身发展、管理等创新点及对项目建设的推进作用 用LR 看到两行左不对齐的文字块将它们判定为表格行的两个单元格。更隐蔽的是文本顺序问题——如果简单拼接两个单元格文本得到的是3.2 用 企业自身发展…推进作因为那个换行位置的用字在 PDF 中恰好落在了第一列区域内。三步修复第 1 步伪表格检测。完整的_is_false_table()函数包含三条检测路径——空行/参考文献模式、全参考文献判定、以及单行伪标题判定# ---- pdf_parser.py: _is_false_table() 核心逻辑 ----def_is_false_table(rows:List[List[TableCell]])-bool:ifnotrows:returnTrueempty_rows0bib_rows0non_empty_rows0forrowinrows:row_text .join(c.text.strip()forcinrow).strip()ifnotrow_text:empty_rows1continuenon_empty_rows1if_RE_BIB_NUM.match(row_text):# 匹配 [1], [23] 等参考文献编号bib_rows1# 路径 1: 多数行为空 非空行多为参考文献 → 假表格ifnon_empty_rows0andbib_rows/non_empty_rows0.5\andempty_rowsnon_empty_rows:returnTrue# 路径 2: 全部非空行都是参考文献 → 假表格ifnon_empty_rows0andbib_rowsnon_empty_rows:returnTrue# 路径 3: 单行少列 合并文本匹配章节编号 → 伪表格换行标题max_colsmax(len(row)forrowinrows)ifrowselse0ifnon_empty_rows1andmax_cols3:all_textre.sub(r\s, , .join(c.text.strip()forrowinrowsforcinrow),).strip()if_RE_SECTION_HEADING.match(all_text):returnTruereturnFalse第 2 步正确的文本提取。不用单元格文本拼接而是在整个表格 BBox 范围内用 TextPage 按字符顺序提取——这样字符顺序和原文一致。第 3 步CJK 空格修复。换行位置可能产生多余空格如推进作 用对中文字符间的空白做规范化all_textre.sub(r(?[\u4e00-\u9fff])\s(?[\u4e00-\u9fff]),,all_text)八、踩坑实录 2LR 漏检表格——无边框表格完全未识别现象一份学术论文中有一张表是一个 3 列 6 行的表格但 LR 模块完全没有识别它。表格内容散落成一堆######小标题和零散文本。根因这个表格在 PDF 中没有可见边框线文字字号比正文还小。LR 模块把其中的短文本误分类为 heading 而非 table cell。启发式重建我写了一个约 400 行的_reconstruct_missed_tables()函数作为后备步骤做法1. 定位表格用正则^表\s*\d[\-–—.]\d\s扫描文本块找到表格标题2. 收集候选标题下方、同字号的文本块作为候选3. 列网格检测对候选块的 x 坐标聚类间距 ≤15 合并确定列边界4. 跨列块拆分用正则拆分横跨多列的文本块如Puhakka(2006) 机会识别…→ 两部分5. 自适应行分组对每列内的 y 间距排序找最大跳变点作为行分组阈值6. 锚列行对齐选最右列为锚列描述列间距最明显其他列通过 y 重叠映射到锚列行7. 结果验证要求 ≥2 行、≥2 列、≥2 行有多列内容更深层的问题行数不对重建后的表格是 2 行而不是 4 行——_group_cells()的自适应间距阈值将 4 个标签合并为 2 组。修复方案是引入 LR heading 锚点用 LR heading 的 y 坐标作为行的锚点通过句末感知打分拆分描述列。以下是实际的打分和分割逻辑# ---- pdf_parser.py: heading-based 行分割_reconstruct_missed_tables 内部----_RE_SENT_ENDre.compile(r[。\]】」』][。\s]*$)# gap_info: [(间隙中点y, 实际间隙, entry_index), ...]# h_mids: 相邻 heading 中心点的中值线分割参考线forhminh_mids:# 对每个 heading 边界best_eiNonebest_scorefloat(-inf)forgm,ag,eiingap_info:# 遍历所有可能的分割点ifeiinused:continuedistabs(gm-hm)ifdistmax(80,med_gap*6):# 距离太远的不考虑continuescore-dist# 越近越好if_RE_SENT_END.search(entries[ei][1]):score30# 以句号结尾 → 加分ifagmed_gap*1.5:score20# 间距大于中位数 → 加分ifscorebest_score:best_scorescore best_eieiifbest_eiisnotNone:splits.append(best_ei)used.add(best_ei)splits.sort()# 按 splits 位置将 entries 分组每组对应表格一行打分公式的核心思想是优先选择前一行以句号结尾且间距较大的位置——这正是行边界最自然的位置。同时加入触发条件只有当 heading 数 gap-based 行数且gap-based 行数 ≤ 3且最长 cell ≥ 80 字符时才启用这个修复。这样已经正确重建的表格不受影响。九、踩坑实录 3LR 把整页正文误判为标题现象某份论文 PDF 的正文段落内出现大量\r字符段落被打碎。根因三个问题叠加LR 误分类LR 模块把整页正文误判为一个标题合并文本用\r分隔。代码直接使用了这个文本\r泄漏到输出中。font_size 异常SDK 返回font_size240实际约 12pt导致几何阈值全部偏大。行宽阈值过严用page_width * 0.78判断全宽行但实际文本只占 71% 页宽。多管齐下修复修复做法LR heading 守卫当 LR heading 合并文本 ≥ 120 字符时判定为误分类的正文段落不使用合并文本body_size 校验计算 body block 平均高度若body_size avg_height × 3则回退有效页宽取所有 body block 最大宽度作为有效页宽而非整个页面宽度句末标点以句末标点结尾的非全宽行判定为段落结束每一个修复都很小但叠加在一起才能解决问题。这就是为什么需要多信号融合的策略——单一信号源永远不够可靠。十、总结多信号融合 后验校正做了这个项目的一个核心体会是PDF 转 Markdown 不是文本提取问题而是文档结构理解问题。文字可以轻松拿到但这段文字是什么角色——是标题、是正文、是表格里的内容、还是页脚的页码——这个判断才是真正困难的。设计原则总结为两个关键词1. 多信号融合——不依赖单一信号源。标题检测用了书签、字号、LR、编号模式四路段落合并用了 LR 段落、几何间距、续行检测、后处理清理四层。2. 后验校正——对 SDK 的输出做质量检验。伪表格检测校验了这个表格真的是表格吗LR 漏检后备处理了LR 没检测到但实际上存在的表格120 字符守卫处理了LR 说是标题但实际上是正文。下一篇文章将聚焦 Foxit PDF SDK 本身——逐字符提取 API 怎么用、LR 模块怎么初始化和遍历、SDK 11.0 有哪些坑。如果你打算用 Foxit SDK 做类似的 PDF 处理工具那篇会是一个实用的速查指南。技术栈Python 3.10 Foxit PDF SDK 11.x FastAPI Jinja2*本文为 PDF 转 Markdown 系列第 2 篇第 1 篇【pdf2md-1开篇】高保真PDF转MarkDown附源码标题/表格/图片全还原)