PDF 排版还原:两栏布局检测算法 --- 一个学生的踩坑实录
作者:何二娃 | 重庆某大学计算机专业学生
为什么我要写这个
最近在做一个 AI 简历诊断平台,其中一个核心功能是上传 PDF 简历后自动解析内容。听起来很简单对吧?用 PDFBox 几行代码就搞定了。
但真正上手才发现------简历 PDF 是一个深坑。
市面上 80% 的简历都采用两栏布局:左边放技能、语言、自我评价,右边放教育经历、工作经历、项目。PDFBox 默认按页面渲染顺序吐字符,左右栏文字是交错的。直接读出来就像这样:
yaml
技能:Python 2020-2023 后端开发工程师 熟练使用 Spring Boot 框架
实际上应该是:
左栏:技能:Python | 右栏:2020-2023 后端开发工程师
熟练掌握:Spring Boot | 熟练使用 Spring Boot 框架
左右栏文字交错在一起,AI 根本没法正确理解。
这篇文章记录了我如何一步步解决这个问题,以及中间踩过的坑。希望对遇到类似问题的同学有帮助。
问题分析
PDF 的渲染顺序是按页面物理位置的,PDFBox 的 PDFTextStripper 默认从左到右、从上到下吐出字符。对于两栏布局:
diff
+-------------------+-------------------+
| 技能 | 工作经历 |
| Python | 2020-2023 |
| Java | XX 公司 |
| 语言 | 项目经历 |
| 中文 英文 | Resume+ 项目 |
+-------------------+-------------------+
吐出来的顺序是:技能 Python Java 语言 中文 英文 工作经历 2020-2023 XX 公司 项目经历 Resume+ 项目
按行读就是:第一行"技能 工作经历",第二行"Python 2020-2023"... 完全没法用。
方案设计
既然默认行为不行,那就自己控制。我的思路分三步:
第一步:在字符级别截获坐标
继承 PDFTextStripper,重写 writeString 方法,在字符被输出前截获每个字符的 x、y 坐标和宽度。
typescript
@Override
protected void writeString(String text, List<TextPosition> textPositions) {
for (TextPosition pos : textPositions) {
String ch = pos.getUnicode();
if (ch == null || ch.isEmpty()) continue;
segments.add(new TextSegment(pos.getX(), pos.getY(), pos.getWidth(), ch));
}
}
这样每个字符都带着坐标信息,我可以自己决定排列顺序。
第二步:按 y 坐标恢复行序
拿到所有字符坐标后,按 y 排序(从上到下),y 相近的合并为同一行。这里遇到第一个坑------
坑 1:行高怎么确定?
不同操作系统渲染同一份 PDF,行高不一样。Mac 和 Windows 的行间距有差异。我一开始写死了 14px,结果 Mac 导出的简历行全乱了。
解法: 不写死,改为统计相邻字符 y 差值的分布,取出现次数最多的差值作为行高。
ini
private float estimateLineHeight() {
Map<Integer, Integer> gapCount = new HashMap<>();
float prevY = segments.get(0).y;
for (int i = 1; i < segments.size(); i++) {
float diff = segments.get(i).y - prevY;
int gap = Math.round(diff);
if (gap > 2 && gap < 80) {
gapCount.merge(gap, 1, Integer::sum);
}
prevY = segments.get(i).y;
}
return gapCount.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(e -> (float) e.getKey())
.orElse(14f);
}
用众数而不是平均数,因为 PDF 中大部分相邻字符在同行(y 相同),少数换行时的 y 跳变才是我们要的值。
此外,字符 y 坐标有 ±1px 的微小抖动,直接用 == 判断是否同行会出错。我用了 70% 行高的容差:
javascript
if (currentLine == null || Math.abs(seg.y - currentLine.y) > lineHeight * 0.7f) {
// 换行
}
第三步:检测两栏布局
这是核心算法。怎么判断一页 PDF 是两栏还是单栏?
我的思路是:遍历每一行,检查行内部是否存在大空隙。
如果超过 20% 的行,内部存在大于该行总宽度 30% 的空隙,就判定为两栏。
ini
private boolean detectTwoColumn(List<TextLine> lines, float lineHeight) {
if (lines.size() < 8) return false; // 行太少,不判
int gapCount = 0;
for (TextLine line : lines) {
List<TextSegment> segs = line.segments;
if (segs.size() < 3) continue;
float minX = Float.MAX_VALUE, maxX = 0;
for (TextSegment s : segs) {
if (s.x < minX) minX = s.x;
float right = s.x + s.width;
if (right > maxX) maxX = right;
}
float span = maxX - minX;
if (span < 30) continue; // 跳过短行(坑!)
for (int i = 1; i < segs.size(); i++) {
float gap = segs.get(i).x - (segs.get(i - 1).x + segs.get(i - 1).width);
if (gap > span * 0.3f) {
gapCount++;
break;
}
}
}
return gapCount > lines.size() * 0.2f;
}
坑 2:窄栏宽度不足 30px 时误判
有些简历左栏只有几个字(如"技能""语言"),宽度本身就很窄。行内空隙比例会异常大,导致误判为两栏。我加了 if (span < 30) continue 跳过这些短行。
第四步:重构阅读顺序
判定为两栏后,需要把每行的文字按左右分开:
scss
float splitX = findColumnSplitX(lines);
List<TextLine> leftCol = new ArrayList<>();
List<TextLine> rightCol = new ArrayList<>();
for (TextLine line : lines) {
TextLine leftPart = new TextLine(line.y);
TextLine rightPart = new TextLine(line.y);
for (TextSegment seg : line.segments) {
if (seg.x + seg.width / 2 < splitX) {
leftPart.addSegment(seg);
} else {
rightPart.addSegment(seg);
}
}
if (!leftPart.segments.isEmpty()) leftCol.add(leftPart);
if (!rightPart.segments.isEmpty()) rightCol.add(rightPart);
}
// 先输出左栏全部内容,再输出右栏全部内容
for (TextLine line : leftCol) result.append(line.getText()).append("\n");
for (TextLine line : rightCol) result.append(line.getText()).append("\n");
寻找左右分栏线的方法:收集所有字符的 x 坐标,排序后在中间 35%-65% 区间找最大间隙,间隙的中点就是分栏线。
最终效果
输入:交错的两栏文字
输出:左栏全部内容 → 右栏全部内容(阅读顺序正确)
用十几份不同排版风格的简历测试,都能正确还原。(但是还需要更多测试数据,遇到横向排版的还是会有一两个字识别错误)
完整代码约 220 行 ,只有一个 Java 文件: github.com/xiaoxiaoyu6...
写在最后
这其实不是个复杂的问题,但真正动手做的时候,各种边界情况会冒出来。
几个经验:
- 不要相信写死的阈值------行高、宽度这些,不同环境下完全不同。用数据驱动的方式去估算。
- 真实数据比理论推导重要------很多边界情况是实际测试才发现的(Mac/Windows 差异、字符抖动)。
- 220 行能解决的问题,不要写 2000 行------保持简单,够用就好。
这个算法是我在简历诊断项目中的一部分,整个项目已上线运行,目前有 20+ 真实用户在使用。
如果你也在做 PDF 解析相关的工作,欢迎来我的项目看看,一起交流。 因为我还是学生如果写的不好前辈们轻点喷,何二娃也是第一次做。还有需要东西要向前辈们学习。
- GitHub:github.com/xiaoxiaoyu6...