简历PDF解析,我一个学生被两栏布局整不会了

PDF 排版还原:两栏布局检测算法 --- 一个学生的踩坑实录

作者:何二娃 | 重庆某大学计算机专业学生

项目源码:github.com/xiaoxiaoyu6...


为什么我要写这个

最近在做一个 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...


写在最后

这其实不是个复杂的问题,但真正动手做的时候,各种边界情况会冒出来。

几个经验:

  1. 不要相信写死的阈值------行高、宽度这些,不同环境下完全不同。用数据驱动的方式去估算。
  2. 真实数据比理论推导重要------很多边界情况是实际测试才发现的(Mac/Windows 差异、字符抖动)。
  3. 220 行能解决的问题,不要写 2000 行------保持简单,够用就好。

这个算法是我在简历诊断项目中的一部分,整个项目已上线运行,目前有 20+ 真实用户在使用。

如果你也在做 PDF 解析相关的工作,欢迎来我的项目看看,一起交流。 因为我还是学生如果写的不好前辈们轻点喷,何二娃也是第一次做。还有需要东西要向前辈们学习。


相关推荐
zlpzlpzyd1 小时前
slf4j中jcl-over-slf4j、jul-to-slf4j、log4j-over-slf4j、slf4j-api的区别是什么
java·开发语言·log4j
贺国亚1 小时前
线程基础与生命周期- 并发编程
java·后端
人道领域1 小时前
【LeetCode刷题日记】222.极速计算完全二叉树节点数:O(log²n)算法揭秘
java·数据结构·算法·leetcode·深度优先
小碗羊肉1 小时前
【JavaWeb | 第十篇】Spring中的事务控制
java·后端·spring
SimonKing1 小时前
美团不做外卖做浏览器了,而且是AI浏览器:Tabbit
java·后端·程序员
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第48题】【JVM篇】第8题:JVM 里的有几种 ClassLoader?为什么会有多种?
java·开发语言·jvm·面试
才疏学浅7431 小时前
批量下载鹏程实验室数据的方法
java·开发语言·word