Java 实践:定位 PDF 指定内容的坐标

在日常开发中,我们经常需要对 PDF 文件进行二次处理:自动打码、添加水印、标注关键字、电子签章……这些需求都有一个共同的前提——先拿到目标文字在 PDF 页面中的精确坐标。

本文将从 PDF 坐标系的底层原理出发,一步步推导出一个完整、可用于生产的关键字定位实现。


1. 应用场景

场景 说明
PDF 内容打码 找到手机号、身份证等敏感字段的坐标,在原位绘制白色矩形覆盖
关键字高亮标注 找到目标文字坐标后,叠加透明彩色矩形实现高亮
电子签章 / 盖章 在指定文字(如"甲方签字:")右侧精确插入印章图片
表单字段自动填充 定位空白字段位置,再用 iText 填写文字或勾选框
多语言翻译替换 先定位原文坐标,白色覆盖后再于同坐标写入译文

2. 必须先懂的原理:PDF 坐标系

2.1 PDF 使用"左下角原点"坐标系

PDF 的坐标系与屏幕(或 Java Graphics2D)的坐标系完全相反

在这里插入图片描述

  • 屏幕坐标:原点在左上角,Y 轴向递增。
  • PDF 坐标:原点在左下角,Y 轴向递增。

这意味着:当你从 iText 中读取到一个字符的 y = 200,在 A4 页面(高度 841.89pt)上,对应的屏幕 Y 坐标是 841.89 - 200 - charHeight

2.2 PDF 单位:磅(pt)

PDF 的距离单位是磅(Point,pt),换算关系:

1    inch = 72    pt , 1    mm ≈ 2.835    pt 1 \; \text{inch} = 72 \; \text{pt}, \quad 1 \; \text{mm} \approx 2.835 \; \text{pt} 1inch=72pt,1mm2.835pt

A4 纸尺寸:595.28 pt × 841.89 pt(即 210mm × 297mm)。

2.3 页面旋转(Rotation)

PDF 页面可以附带旋转角度(0°、90°、180°、270°)。旋转并不改变页面内容的原始坐标,而是改变渲染方向。若要将坐标映射到视觉上正确的位置,必须对旋转做逆变换。


3. 思路设计:如何精确定位关键字?

3.1 朴素思路:提取全文再 indexOf?

最直接的想法:用 PdfTextExtractor.getTextFromPage() 把一页文字提取成 String,然后 indexOf(keyword) 拿到偏移量——但这个偏移量只是字符串下标,完全无法对应 PDF 中的坐标,因为文字提取时丢弃了所有位置信息。

3.2 正确思路:事件驱动,逐字符捕获

iText 7 提供了一套渲染事件监听机制

解析页面内容流

EventType == RENDER_TEXT

getCharacterRenderInfos

getBoundingBox

PdfCanvasProcessor

IEventListener.eventOccurred

TextRenderInfo

CharacterRenderInfo per char

Rectangle 字符边界框坐标

通过监听 RENDER_TEXT 事件,我们可以拿到每一个字符及其在 PDF 坐标系中的精确边界框(Rectangle)。

3.3 匹配算法:带回溯的前缀累积

拿到字符流后,如何匹配关键字?

注意:PDF 中的字符是流式输出的,没有 “单词边界” 概念,且可能存在跨文本块的情况。

设计一个带回溯的前缀匹配算法:

维护:currentWord(已累积字符串) + currentRects(对应的矩形列表)

每来一个字符 ch:
  1. 追加到 currentWord,并记录 charRect
  2. 若 keyword.startsWith(currentWord):
       a. 若 currentWord == keyword → 完全匹配!记录合并矩形,重置
       b. 否则 → 继续等待下一个字符
  3. 若不是前缀(匹配失败):
       从 accumulated[1], accumulated[2], ... 依次尝试
       找到最长的仍能作为 keyword 前缀的后缀,作为新起点
       (处理 "ABAB" 中查找 "ABAB" 的重叠情况)

这个算法的关键在于回溯步骤,它处理了关键字中存在重叠前缀的场景(类似 KMP 算法的思想)。


4. 完整实现解析

这里用到了 itext 的依赖包,需要导入依赖

        <dependency>
            <groupId>com.itextpdf</groupId>
            <artifactId>itext7-core</artifactId>
            <version>8.0.5</version>
            <type>pom</type>
        </dependency>
        <!-- 如处理中文 PDF,建议添加以下依赖 -->
        <dependency>
            <groupId>com.itextpdf</groupId>
            <artifactId>font-asian</artifactId>
            <version>8.0.5</version>
        </dependency>

4.1 结果数据类

public static class PdfKeywordResult {
    public int pageNumber;          // 页码(从 1 开始)
    public String text;             // 匹配到的关键字

    // PDF 原始坐标(左下角原点,单位 pt)
    public float originalX, originalY, originalWidth, originalHeight;

    // 屏幕坐标(左上角原点,已处理旋转,单位 pt)
    public float x, y, width, height;

    @Override
    public String toString() {
        return String.format(
            "Page[%d] Keyword:'%s' -> ScreenCoord: (%.2f, %.2f, %.2f, %.2f) | Original: (%.2f, %.2f, %.2f, %.2f)",
            pageNumber, text, x, y, width, height,
            originalX, originalY, originalWidth, originalHeight);
    }
}

同时保留原始坐标与屏幕坐标,是因为两者在不同场景下都有用:

  • 原始坐标:用 iText 向 PDF 写内容时需要(iText 使用 PDF 原生坐标系)
  • 屏幕坐标:在 Swing/JavaFX 等 UI 上预览、绘制高亮框时需要

4.2 主流程:逐页处理

public static List<PdfKeywordResult> findKeyword(String pdfPath, String keyword) throws IOException {
    List<PdfKeywordResult> results = new ArrayList<>();
    try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(pdfPath))) {
        for (int pageNum = 1; pageNum <= pdfDoc.getNumberOfPages(); pageNum++) {
            PdfPage page = pdfDoc.getPage(pageNum);
            int rotation = page.getRotation();           // ① 记录页面旋转角度
            float pageHeight = page.getPageSize().getHeight(); // ② 记录页面高度(坐标转换用)

            List<PdfKeywordResult> pageResults = new ArrayList<>();
            int finalPageNum = pageNum;

            // ③ 创建事件监听器(见 4.3)
            IEventListener listener = buildListener(keyword, finalPageNum, pageResults);

            // ④ 执行页面内容解析,触发事件
            PdfCanvasProcessor processor = new PdfCanvasProcessor(listener);
            processor.processPageContent(page);

            // ⑤ 对每个匹配结果做旋转修正 + 坐标系转换(见 4.4、4.5)
            for (PdfKeywordResult raw : pageResults) {
                Rectangle rawRect = new Rectangle(
                    raw.originalX, raw.originalY, raw.originalWidth, raw.originalHeight);
                Rectangle rotated = handlePageRotation(rawRect, rotation, page.getPageSize());
                Rectangle screen  = convertToScreenCoordinates(rotated, pageHeight);

                raw.x = screen.getX();
                raw.y = screen.getY();
                raw.width  = screen.getWidth();
                raw.height = screen.getHeight();
                results.add(raw);
            }
        }
    }
    return results;
}

4.3 核心:事件监听器与字符匹配

IEventListener listener = new IEventListener() {
    private final StringBuilder currentWord = new StringBuilder();
    private final List<Rectangle> currentRects = new ArrayList<>();

    @Override
    public void eventOccurred(IEventData data, EventType type) {
        if (!(data instanceof TextRenderInfo)) return;
        TextRenderInfo tri = (TextRenderInfo) data;

        // iText 7 最佳实践:拆到单字符级别
        for (TextRenderInfo charInfo : tri.getCharacterRenderInfos()) {
            String ch = charInfo.getText();
            if (ch == null || ch.isEmpty()) continue;

            // CharacterRenderInfo 提供精确的单字符边界框
            CharacterRenderInfo cri = new CharacterRenderInfo(charInfo);
            Rectangle charRect = cri.getBoundingBox();

            currentWord.append(ch);
            currentRects.add(charRect);

            String full = currentWord.toString();
            if (keyword.startsWith(full)) {
                if (full.equals(keyword)) {
                    // ✅ 完全匹配
                    Rectangle merged = mergeRectangles(currentRects);
                    PdfKeywordResult result = new PdfKeywordResult();
                    result.pageNumber  = finalPageNum;
                    result.text        = keyword;
                    result.originalX   = merged.getX();
                    result.originalY   = merged.getY();
                    result.originalWidth  = merged.getWidth();
                    result.originalHeight = merged.getHeight();
                    pageResults.add(result);

                    currentWord.setLength(0);
                    currentRects.clear();
                }
                // else:仍是前缀,继续等待
            } else {
                // ❌ 匹配失败:回溯找最长可用前缀
                String accumulated = currentWord.toString();
                List<Rectangle> accRects = new ArrayList<>(currentRects);
                currentWord.setLength(0);
                currentRects.clear();

                for (int start = 1; start < accumulated.length(); start++) {
                    String suffix = accumulated.substring(start);
                    if (keyword.startsWith(suffix)) {
                        currentWord.append(suffix);
                        currentRects.addAll(accRects.subList(start, accRects.size()));
                        break;
                    }
                }
            }
        }
    }

    @Override
    public Set<EventType> getSupportedEvents() {
        return new HashSet<>(Collections.singletonList(EventType.RENDER_TEXT));
    }
};
关键细节:为什么用 CharacterRenderInfo 而不是 TextRenderInfo.getBaseline()

TextRenderInfo 提供的 getBaseline() / getAscentLine() 返回的是整个文本片段的基线,无法精确到单字符。
CharacterRenderInfo.getBoundingBox() 是 iText 7 专门封装的单字符边界矩形,精度更高,也是官方推荐做法。

4.4 辅助方法:合并矩形

将关键字所有字符的边界框合并为一个包围矩形:

private Rectangle mergeRectangles(List<Rectangle> rects) {
    if (rects.isEmpty()) return null;
    float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
    float maxX = Float.MIN_VALUE, maxY = Float.MIN_VALUE;
    for (Rectangle r : rects) {
        minX = Math.min(minX, r.getX());
        minY = Math.min(minY, r.getY());
        maxX = Math.max(maxX, r.getX() + r.getWidth());
        maxY = Math.max(maxY, r.getY() + r.getHeight());
    }
    return new Rectangle(minX, minY, maxX - minX, maxY - minY);
}

4.5 辅助方法:页面旋转修正

当 PDF 页面存在旋转时,需要将字符坐标反向旋转到视觉正确位置:

private static Rectangle handlePageRotation(Rectangle rect, int rotation, Rectangle pageSize) {
    if (rotation == 0) return new Rectangle(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight());
    float W = pageSize.getWidth(), H = pageSize.getHeight();
    float x = rect.getX(), y = rect.getY(), w = rect.getWidth(), h = rect.getHeight();

    switch (rotation) {
        case 90:  return new Rectangle(y,         W - x - w, h, w);
        case 180: return new Rectangle(W - x - w, H - y - h, w, h);
        case 270: return new Rectangle(H - y - h, x,         h, w);
        default:  return rect;
    }
}

旋转变换原理(以 90° 为例):

( x ′ y ′ ) = ( 0 1 − 1 0 ) ( x y ) + ( 0 W ) \begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} 0 & 1 \\ -1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} 0 \\ W \end{pmatrix} (xy)=(0110)(xy)+(0W)

即:x' = yy' = W - x,再考虑矩形宽高互换。

4.6 辅助方法:PDF 坐标转屏幕坐标

private static Rectangle convertToScreenCoordinates(Rectangle rect, float pageHeight) {
    // PDF Y 轴向上,屏幕 Y 轴向下;矩形顶边的 PDF Y = rect.getY() + rect.getHeight()
    float screenY = pageHeight - (rect.getY() + rect.getHeight());
    return new Rectangle(rect.getX(), screenY, rect.getWidth(), rect.getHeight());
}

转换示意:

在这里插入图片描述


5. 完整可运行代码

import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfCanvasProcessor;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.CharacterRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.IEventListener;

import java.io.IOException;
import java.util.*;
import java.util.function.Function;

public class PdfKeywordLocator {

    // 与你的 main 方法兼容的结果类
    public static class PdfKeywordResult {
        public int pageNumber;
        public String text;
        public float originalX, originalY, originalWidth, originalHeight;
        public float x, y, width, height;

        @Override
        public String toString() {
            return String.format("Page[%d] Keyword:'%s' -> ScreenCoord: (%.2f, %.2f, %.2f, %.2f) | Original: (%.2f, %.2f, %.2f, %.2f)",
                    pageNumber, text, x, y, width, height, originalX, originalY, originalWidth, originalHeight);
        }
    }

    /**
     * 查找 PDF 中所有关键字的位置
     * @param pdfPath PDF 文件路径
     * @param keyword 要查找的关键字(区分大小写)
     * @return 位置信息列表
     */
    public static List<PdfKeywordResult> findKeyword(String pdfPath, String keyword) throws IOException {
        List<PdfKeywordResult> results = new ArrayList<>();
        try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(pdfPath))) {
            for (int pageNum = 1; pageNum <= pdfDoc.getNumberOfPages(); pageNum++) {
                PdfPage page = pdfDoc.getPage(pageNum);
                int rotation = page.getRotation();
                float pageHeight = page.getPageSize().getHeight();

                // 存储当前页的所有匹配结果
                List<PdfKeywordResult> pageResults = new ArrayList<>();

                int finalPageNum = pageNum;
                IEventListener listener = new IEventListener() {
                    private final StringBuilder currentWord = new StringBuilder();
                    private final List<Rectangle> currentRects = new ArrayList<>();

                    @Override
                    public void eventOccurred(IEventData data, EventType type) {
                        if (data instanceof TextRenderInfo) {
                            TextRenderInfo tri = (TextRenderInfo) data;
                            // 拆成单个字符处理
                            for (TextRenderInfo charInfo : tri.getCharacterRenderInfos()) {
                                String ch = charInfo.getText();
                                if (ch == null || ch.isEmpty()) continue;

                                // 获取单个字符的边界框(iText 7 推荐方式)
                                CharacterRenderInfo cri = new CharacterRenderInfo(charInfo);
                                Rectangle charRect = cri.getBoundingBox();

                                // 尝试匹配关键字
                                currentWord.append(ch);
                                currentRects.add(charRect);

                                String full = currentWord.toString();
                                if (keyword.startsWith(full)) {
                                    // 尚未完全匹配,继续累积
                                    if (full.equals(keyword)) {
                                        // 完全匹配:计算所有字符的合并矩形
                                        Rectangle merged = mergeRectangles(currentRects);
                                        // 保存结果(原始坐标)
                                        PdfKeywordResult result = new PdfKeywordResult();
                                        result.pageNumber = finalPageNum;
                                        result.text = keyword;
                                        assert merged != null;
                                        result.originalX = merged.getX();
                                        result.originalY = merged.getY();
                                        result.originalWidth = merged.getWidth();
                                        result.originalHeight = merged.getHeight();
                                        pageResults.add(result);

                                        // 重置,准备下一个匹配
                                        currentWord.setLength(0);
                                        currentRects.clear();
                                    }
                                } else {
                                    // 匹配失败:从已积累字符串的第 1 位开始回溯,
                                    // 找到最长的能作为 keyword 前缀的子串,作为新起点
                                    String accumulated = currentWord.toString();
                                    List<Rectangle> accRects = new ArrayList<>(currentRects);
                                    currentWord.setLength(0);
                                    currentRects.clear();

                                    // 从 accumulated 的第 1 位(跳过第 0 位)逐步尝试
                                    for (int start = 1; start < accumulated.length(); start++) {
                                        String suffix = accumulated.substring(start);
                                        if (keyword.startsWith(suffix)) {
                                            currentWord.append(suffix);
                                            currentRects.addAll(accRects.subList(start, accRects.size()));
                                            break;
                                        }
                                    }
                                }
                            }
                        }
                    }

                    @Override
                    public Set<EventType> getSupportedEvents() {
                        return new HashSet<>(Collections.singletonList(EventType.RENDER_TEXT));
                    }

                    private Rectangle mergeRectangles(List<Rectangle> rects) {
                        if (rects.isEmpty()) return null;
                        float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
                        float maxX = Float.MIN_VALUE, maxY = Float.MIN_VALUE;
                        for (Rectangle r : rects) {
                            minX = Math.min(minX, r.getX());
                            minY = Math.min(minY, r.getY());
                            maxX = Math.max(maxX, r.getX() + r.getWidth());
                            maxY = Math.max(maxY, r.getY() + r.getHeight());
                        }
                        return new Rectangle(minX, minY, maxX - minX, maxY - minY);
                    }
                };

                PdfCanvasProcessor processor = new PdfCanvasProcessor(listener);
                processor.processPageContent(page);

                // 处理该页找到的所有结果:页面旋转 + 坐标转换
                for (PdfKeywordResult raw : pageResults) {
                    Rectangle rawRect = new Rectangle(raw.originalX, raw.originalY,
                            raw.originalWidth, raw.originalHeight);
                    // 1. 处理页面旋转
                    Rectangle rotated = handlePageRotation(rawRect, rotation, page.getPageSize());
                    // 2. 转换为屏幕坐标(左上角原点)
                    Rectangle screen = convertToScreenCoordinates(rotated, pageHeight);

                    raw.originalX = rawRect.getX();
                    raw.originalY = rawRect.getY();
                    raw.originalWidth = rawRect.getWidth();
                    raw.originalHeight = rawRect.getHeight();

                    raw.x = screen.getX();
                    raw.y = screen.getY();
                    raw.width = screen.getWidth();
                    raw.height = screen.getHeight();

                    results.add(raw);
                }
            }
        }
        return results;
    }

    private static Rectangle handlePageRotation(Rectangle rect, int rotation, Rectangle pageSize) {
        if (rotation == 0) return new Rectangle(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight());
        float width = pageSize.getWidth();
        float height = pageSize.getHeight();
        float x = rect.getX();
        float y = rect.getY();
        float w = rect.getWidth();
        float h = rect.getHeight();

        switch (rotation) {
            case 90:
                return new Rectangle(y, width - x - w, h, w);
            case 180:
                return new Rectangle(width - x - w, height - y - h, w, h);
            case 270:
                return new Rectangle(height - y - h, x, h, w);
            default:
                return rect;
        }
    }

    private static Rectangle convertToScreenCoordinates(Rectangle rect, float pageHeight) {
        float screenY = pageHeight - (rect.getY() + rect.getHeight());
        return new Rectangle(rect.getX(), screenY, rect.getWidth(), rect.getHeight());
    }


    /**
     * 按照规则提取 pdf 文档中的内容
     * @param pdfPath PDF 文件路径
     * @return 订单号列表(如 ["PO-02974"])
     */
    public static List<String> extractPdfContent(String pdfPath, Function<String, List<String>> function) throws IOException {
        List<String> orderNumbers = new ArrayList<>();
        try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(pdfPath))) {
            for (int pageNum = 1; pageNum <= pdfDoc.getNumberOfPages(); pageNum++) {
                String pageText = PdfTextExtractor.getTextFromPage(pdfDoc.getPage(pageNum));
                orderNumbers.addAll(function.apply(pageText));
            }
        }
        return orderNumbers;
    }
}


6. 调用示例

6.1 查找关键字

public static void main(String[] args) throws IOException {
    String pdfPath = "D:/documents/contract.pdf";
    String keyword = "甲方签字";

    List<PdfKeywordLocator.PdfKeywordResult> results =
            PdfKeywordLocator.findKeyword(pdfPath, keyword);

    for (PdfKeywordLocator.PdfKeywordResult r : results) {
        System.out.println(r);
        // 输出示例:
        // Page[3] Keyword:'甲方签字' -> ScreenCoord: (72.00, 650.40, 56.80, 14.20) | Original: (72.00, 177.29, 56.80, 14.20)
    }
}

6.2 配合 iText 7 在关键字位置绘制白色矩形(打码)

public static void whiteoutKeyword(String srcPath, String destPath, String keyword) throws IOException {
    try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(srcPath), new PdfWriter(destPath))) {
        List<PdfKeywordLocator.PdfKeywordResult> results =
                PdfKeywordLocator.findKeyword(srcPath, keyword);

        for (PdfKeywordLocator.PdfKeywordResult r : results) {
            PdfPage page = pdfDoc.getPage(r.pageNumber);
            PdfCanvas canvas = new PdfCanvas(page);

            // 注意:向 PDF 写内容时使用原始坐标(originalX/Y),而非屏幕坐标
            canvas.setFillColor(ColorConstants.WHITE)
                  .rectangle(r.originalX - 2, r.originalY - 2,
                             r.originalWidth + 4, r.originalHeight + 4)
                  .fill();
        }
    }
}

注意事项:iText 在向现有 PDF 追加内容时,使用的依然是 PDF 原生坐标系(左下角原点)。因此绘制时应使用 originalX/Y,只有在 UI 展示场景才需要 x/y(屏幕坐标)。


7. 方案对比与选型建议

方案 实现难度 精度 适用场景
正则 + 文本提取PdfTextExtractor 无坐标信息 仅需要提取文字内容,不关心位置
iText 7 事件监听(本文方案) 字符级精度 需要精确定位坐标,主流选择
Apache PDFBox PDFTextStripper 词组级精度 开源免费,精度稍低于 iText 7
Tesseract OCR 依赖图像质量 扫描件 PDF(无文字层)时使用

选型建议:

  • 有文字层的 PDF(即数字 PDF) → iText 7 事件监听,精度最高
  • 扫描件 PDF(图片型) → OCR 方案(如 Tesseract + 坐标映射)
  • 只需文字内容不需坐标 → PdfTextExtractor.getTextFromPage() 足矣

8. 性能分析

操作 时间复杂度 说明
逐页遍历 P 为总页数
事件监听字符处理 C 为单页总字符数
前缀匹配 + 回溯 O(C × K) K 为关键字长度,最差情况
矩形合并 O(K) 合并关键字长度内的字符矩形
整体 O(P × C × K) 实际 C×K 极小,接近 O(P×C)

实测参考:一份 50 页的 A4 商业合同 PDF,查找 10 个关键字,耗时约 200~500ms,满足绝大多数业务需求。


9. 实际应用

之前接到一个需求就是对固定格式的PDF采购单翻译成中文,原本是打算用模板+OCR/AI去实现,但是OCR和AI识别表格数据总是有偏差,考虑到采购单的格式固定,待翻译的字段也基本固定,于是就采用先定位需要翻译的字符串,然后用白色覆盖掉原内容,接着替换成翻译之后的内容。

例如一下采购单,我只需要翻译框选的内容即可,我只需要先将对应的字符串给定位出来:

在这里插入图片描述

测试代码:

    public static void main(String[] args) throws IOException {
        String pdfPath = "src/test/java/com/mike/test/pdf/1.pdf";

        List<String> keywords = Arrays.asList("PURCHASE ORDER",
                "Purchase Order No: PO-02974","Date","Ex. Factory","Terms","Supplier Reference","SupplierCurrency",
                "Part Number","Part Description","Supplier Code","HS Code","HST Code","Quantity","Price","Amount",
                "Total Order","Before Tax","Tax","Total",
                "NOTES");

        Map<String, List<PdfKeywordLocator.PdfKeywordResult>> map = new HashMap<>();
        for (String keyword : keywords) {
            map.put(keyword, handlerSingleKeyword(pdfPath, keyword));
        }

        File input = new File(pdfPath);
        File output = new File("output/PO-replaced3.pdf");

        // 1. 加载 PDF 文档
        try (PDDocument document = PDDocument.load(input)) {

            // 2. 获取要操作的第1页(PDFBox中页码从0开始)
            PDPage page = document.getPage(0);

            // 3. 准备向该页面添加新的内容流,使用追加模式 APPEND
            try (PDPageContentStream cs = new PDPageContentStream(document, page,
                    PDPageContentStream.AppendMode.APPEND, true, true)) {

                // 4. 设置画笔颜色为白色并填充
                cs.setNonStrokingColor(Color.RED);

                for (Map.Entry<String, List<PdfKeywordLocator.PdfKeywordResult>> entry : map.entrySet()) {
                    String key = entry.getKey();
                    List<PdfKeywordLocator.PdfKeywordResult> value = entry.getValue();
                    System.out.println("当前处理关键字: " + key);

                    for (PdfKeywordLocator.PdfKeywordResult pdfKeywordResult : value) {
                        float[] rect = {pdfKeywordResult.originalX, pdfKeywordResult.originalY, pdfKeywordResult.originalWidth, pdfKeywordResult.originalHeight};
                        float x = rect[0]; // 矩形左下角X坐标
                        float y = rect[1]; // 矩形左下角Y坐标(原点在左下角)
                        float width = rect[2];
                        float height = rect[3];

                        cs.addRect(x, y, width, height);
                        cs.fill(); // 执行填充,完成遮盖
                    }
                }

            } // 内容流自动关闭并保存更改

            // 6. 保存修改后的PDF
            document.save(output);
        }

        handler();
    }

效果:

在这里插入图片描述

10. 常见问题与注意事项

Q1:为什么有些关键字找不到?
PDF 中的文字存储顺序不一定是视觉阅读顺序,同一行文字可能被拆成多个文本块,中间插入其他文本块。遇到此问题可以先用 PdfTextExtractor 提取文本,确认关键字确实存在,再排查文本块分割问题。

Q2:中文关键字能正常匹配吗?
可以。iText 7 的字符渲染事件对 Unicode 字符(包括中文)完全支持,CharacterRenderInfo 能正确返回每个汉字的边界框。

Q3:加密 PDF 怎么处理?
在创建 PdfReader 时传入密码:

new PdfReader(pdfPath, new ReaderProperties().setPassword("your_password".getBytes()))

Q4:坐标精度够用吗?
float 精度为约 7 位有效数字,对于 PDF 坐标(通常在 0~1000 pt 范围内)完全足够。如有极高精度需求可改用 double


11. 总结

本文从 PDF 坐标系的底层原理出发,完整推导并实现了PDF字符串定位问题:

  1. 理解 PDF 坐标系:左下角原点,Y 轴向上,单位为磅(pt)
  2. 事件驱动字符捕获:利用 iText 7 的 IEventListener + PdfCanvasProcessor,逐字符获取精确坐标
  3. 带回溯的前缀匹配:处理跨文本块、重叠前缀等边界情况
  4. 旋转修正 + 坐标系转换:让结果同时具备 PDF 原生坐标(写入用)和屏幕坐标(显示用)

这个方案已在生产环境的采购单翻译、PDF 内容打码等场景中稳定运行,欢迎结合实际需求扩展使用。


更多推荐