PDF 转移动端 HTML
这个 skill 解决什么
保单类 PDF 在手机上直接看体验很差:表格跨页、表头重复、字号小、要横向拖动。本 skill 把 PDF 忠实转成一个自包含的 HTML 文件,手机浏览器直接打开即可,内容严格等于原文,不添加原文没有的标题、注释、装饰。
核心保证(每一项都有自动校验兜底):
| 保证 | 实现 |
|---|---|
| 表格列数自适应 | 按几何网格识别列边界,支持 2 / 3 / 4 / 5+ 列;列宽按内容估算为百分比,随屏宽自适应 |
| 嵌套表头 | 多行表头按 rowspan / colspan 还原到 <thead> |
| 跨页续表 | 续页首行断行自动并回上一行;逐页重复的表头按结构去重 |
| 合并单元格 | 分类列(如科室)按几何线推导 rowspan,纵向合并 |
| 加粗还原 | 逐字符识别,中文伪粗体与拉丁 Bold 都覆盖 |
| 无表格文档 | 自动退化,只输出白底黑字的段落 |
| 内容纯净 | verify.py 逐字符比对,多字 / 漏字都报错 |
用法
# 1) 转换
python scripts/convert.py <input.pdf> <output.html> [--title 页面标题]
# 2) 校验(必做)
python scripts/verify.py <input.pdf> <output.html>
# 3) 批量自检(含内置样例,可选传真实 PDF)
python scripts/selftest.py [--pdf a.pdf --pdf b.pdf]
依赖:pdfplumber、pymupdf。若当前 python 没装这两个包,直接用 WorkBuddy 的隔离环境(已装好):
~/.workbuddy/binaries/python/envs/default/bin/python scripts/convert.py <in.pdf> <out.html>
convert.py 会在 stdout 打印统计行(页数 / 表格页 / 列数 / 列标签 / 表头行 / 数据行 / 分类块 / 段落数),警告走 stderr。stderr 有警告必须人工核对。
--title 不传时取 PDF 文件名。文件里的 <title> 只影响浏览器标签,不影响正文。
校验怎么读
verify.py 三项检查,退出码 0 才算通过:
- 内容纯净:HTML 文字必须全部出现在 PDF 里(
多余字符必须为 0),PDF 的字也不能丢(完全缺失字符必须为 0)。重复区域去重一栏允许非零,那是逐页重复的表头被按结构去重。 - 网格自洽:每行被
colspan/rowspan覆盖的列数必须相等。这类错误不改文字、只让整行消失或错位,光比文字看不出来,必须单独校验。 - 窄屏数值列余量:数值列是
nowrap,按 360px 屏宽算出可用宽度与所需宽度,余量为负说明会被裁切。 - 加粗匹配支持跨页拆分:排版折行拼回后,一个
<strong>可能横跨页边界(跨页续表合并),PDF 粗体流按页拼接找不到完整子串。校验时允许按合并边界拆成两半分别匹配,每一半仍必须逐字符命中。
处理流程(都是踩过坑后的正确做法)
1. 表格页判定与几何网格
有表格线的页为表格页(page.find_tables()),其余按正文处理。
列边界:取贯穿表格的竖线,按 x 聚类(容差 1.8pt)。判据是共线线段合并后的覆盖比例(union_cover),不是单段长度。PDF 里表格线常按单元格逐段绘制,用单段长度判断会把 4 列表格判成 3 列。同理行边界用横线合并覆盖。
行列跨度:某条候选分界线上没有线,就说明被上方 / 左侧单元格的 rowspan / colspan 覆盖,据此推导跨度。
不要用 t.cells:真实保单里同一个短语单元格可能被切成多块,跨页处尤其碎。
2. 空行带必须丢,但 gid 不能跳号(关键坑)
一个行带(两条横线之间的带)可能所有列都被上方单元格的 rowspan 覆盖,此时渲染不会产生 <tr>,必须丢弃,否则它会进入分类块的行数统计,让分类单元格的 rowspan 超出实际行数,科室纵向错位。
但丢的时候 gid 必须照常自增:gid 是「几何行号」,跨行单元格的 span_bands 是按几何行数记的。若丢行带时把 gid 也跳过,pos(gid + span_bands) 就会多算,吃掉下面一整行真实数据(症状:某行文字整行消失,但字符数校验不一定报错,要靠网格校验抓)。
3. 跨页续表合并
续页第一个行带满足全部条件才并入上一页最后一行:值列(金额)为空、上一行文本不以终止符结尾、且该带里每个有字的格子都能在上一行找到同列的格子。位置比对是必要条件,只看「金额为空」会把无关行并掉。
4. 分类列分块
分类列(科室)的单元格从数据单元格里摘出,改由「块」表达:一个块 = 连续若干行共用一个 rowspan 的分类格。块的行数按保留后的行数计,与 rowspan 严格对齐(sum(n) == len(rows),不一致会打警告)。
5. 加粗
pymupdf get_texttrace() 取每页字符的笔画信息:中文伪粗体 = type=1 的重绘笔画,拉丁 / 数字 = 字体名含 Bold。与 pdfplumber 的 (x0, top) 同坐标系数值取整到 0.1 后按 ±0.1 查 9 邻域匹配,逐字符标记再合并同粗细片段。
不要用文本子串匹配:单字符加粗片段和重复文本都会产生歧义。
6. 文本规整
同一视觉行内编号与中文基线可能差约 2pt,先按 top 容差聚子行、子行内按 x 排序,否则条款编号会被挤到行尾。汉字与数字 / 字母之间由字距产生的空格删除;原文真实存在的空格(JR/T 0083)保留。
表格单元格内的排版折行必须拼回(lines_html)。PDF 单元格按 PDF 自己的列宽折行,折行位置与 HTML 渲染列宽无关,原样保留 <br> 就是「移植皮 / 瓣或肢 / 体组织坏死」这类莫名换行。拼回规则与正文段落一致:只有下一行以编号 / 项目符号开头(真正的列表结构,ENUM_RE)才保留换行,其余行无缝拼接(中文无连字符断词,拼接安全)。粗体跨行拼接后用 merge_segs 合并相邻同粗细片段。
7. 列宽
按内容估各列需求宽度(term_width,汉字 2 / 其余 1),归一化成百分比:
- 下限:数值列 16%、分类列 13%、文字列 22%(防止被挤没)。
- 上限:分类 / 数值列不超过 40%;表里一个文字列都没有时(如「科室 + 金额」两列)放宽到 55%,让比例贴近原表而不是两列各半。超出的份额转给文字列,每次转完要重新归一化,否则上限会被重新放大。
nowrap 只加在 td.num(数据格)。表头 th.num 允许换行,否则长表头在窄屏会溢出。
8. 无表格文档
检测不到表格时输出纯段落,不引入任何表格样式。页边带的文字只有「多页重复出现」或「纯页码」才丢弃,单页的标题属于正文必须保留。
验收动作
verify.py三项全过,退出码 0。- 与 PDF 原文对照首条、末条、跨页续行处的文本完整性。
- 同类文档交叉验证(有多份同结构文档时最有力):同一产品的不同保障计划,条款文字应逐字一致,只有保额等变体字段不同。把两份输出的表格行、段落、分类块(
td.cat的名称与rowspan)提取出来做集合比对:
- 条款文字、分类块划分必须完全一致;
- 差异只应出现在保额(数值列)和随保额联动的段落上。
若条款文字出现差异,基本可以断定有一份丢字或错位。这比单份校验更能暴露问题,因为丢字往往不影响字符计数(去重后照样平衡)。
- 需要看真实渲染时,在 HTML 里注入一段脚本读
getBoundingClientRect(),用 Chrome 无头模式--dump-dom取回测量值:
bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --disable-gpu --virtual-time-budget=4000 \
--dump-dom "file:///tmp/probe.html" > /tmp/dom.html
这比截图可靠:无头模式的视口宽度与本机窗口 / 截图宽度可能不一致,截出来的图不代表手机上的效果;而列宽是百分比,直接读测量值可以算出任意屏宽下的实际像素。
注意:文件一旦放进被编辑器接管的目录,编辑器会往标签上注入 data-page-node-id 之类的属性。校验脚本的正则要容忍标签带属性(<strong[^>]*>、<col[^>]*style="width:..."[^>]*>),否则会误判成「找不到加粗」「没有列宽」。
适用范围与限制
- 适用:文字型 PDF(可选中文字),正文段落 + 表格的文档结构。
- 不适用:扫描件图片 PDF(需 OCR,本 skill 不做)。一页有多个表格时只处理第一个并打警告。
- 已实测:大地财险手术意外险特别约定(6 页 / 3 列 / 11 科室块 / 60 数据行 / 211 处加粗)、两张投保须知(无表格)、以及
examples/下 4 个合成样例(2 列、4 列嵌套表头跨页、无表格、2 列合并)。 examples/里的样例由scripts/gen_cases.py生成,可用于回归;scripts/selftest.py一键跑完转换 + 校验。