返回

PDFBox 添加图片 Alt 文本:搞定 PDF 无障碍访问

java

PDFBox 图片添加 Alt 文本:搞定 PDF 无障碍

咱们在用 Java 的 PDFBox 库生成 PDF 文件时,有时需要让生成的 PDF 对屏幕阅读器友好,也就是实现“无障碍访问”(Accessibility)。一个关键点就是给 PDF 里的图片加上替代文本(Alternative Text,简称 Alt Text),这样视力障碍的用户也能通过屏幕阅读器了解图片内容。

不少朋友参照网上的教程,尝试用 PDMarkedContentPDStructureElement 来给图片打标签、加 Alt 文本。就像下面这段代码一样:

// --- 问题代码片段 ---
for (int pageidx = 0; pageidx < totalPage; pageidx++) {
    PDPageContentStream contentstream = new PDPageContentStream(
        doc, doc.getPage(pageidx), PDPageContentStream.AppendMode.APPEND, true);
    PDPage page = doc.getPage(pageidx);

    COSDictionary dict = new COSDictionary();
    dict.setInt(COSName.MCID, mcid);
    mcid++;

    // 尝试在内容流中标记图片
    contentstream.beginMarkedContent(
        COSName.IMAGE, PDPropertyList.create(dict)); // 这里用了 IMAGE,通常推荐用 Figure
    contentstream.drawImage(image, x, y, px, py);
    contentstream.endMarkedContent();

    contentstream.close();

    // --- 每次循环都创建新的结构树根?这是个大问题!---
    PDStructureTreeRoot root = new PDStructureTreeRoot();
    doc.getDocumentCatalog().setStructureTreeRoot(root); // 会覆盖之前的设置

    PDStructureElement parent =
        new PDStructureElement(StandardStructureTypes.Figure, root); // 父节点应该是文档级的元素,或者根
    root.appendKid(parent); // 关系可能不对

    PDStructureElement element =
        new PDStructureElement(StandardStructureTypes.Figure, parent); // Figure 下再套 Figure?
    element.setPage(doc.getPage(pageIndex)); // pageIndex 未定义,应该是 pageidx
    element.setAlternateDescription("alternate"); // 设置 Alt 文本

    dict.setString(COSName.ALT, "alternate"); // 在这里给 dict 加 ALT 作用不大

    // --- 尝试在结构树外部创建 MarkedContent?这也是个误区! ---
    PDMarkedContent marked = new PDMarkedContent(COSName.IMAGE, dict); // MarkedContent 应该只在内容流中定义
    marked.addXObject(image); // 这样做是无效的
    element.appendKid(marked); // 不能直接把 MarkedContent 对象加为 Kid
}
// --- 问题代码片段结束 ---

这段代码跑完生成的 PDF,用 PAC(PDF Accessibility Checker)这类工具一检查,嘿,图片还是显示“未标记”(untagged)。白忙活了!那问题到底出在哪儿?又该怎么修正呢?

问题出在哪?

上面的代码主要踩了几个坑:

  1. 结构树 (StructureTreeRoot) 创建位置不对PDStructureTreeRoot 代表整个 PDF 文档的逻辑结构根。它应该在文档级别创建,而且只需要创建一次 。代码里把它放在 for 循环里面,每次循环都 new 一个新的,还会覆盖掉之前 DocumentCatalog 里的设置。这直接导致最终 PDF 的结构树是混乱或者不完整的。
  2. 结构元素 (PDStructureElement) 层级混乱 :代码里创建了 parentelement 两个 Figure 类型的结构元素,并且 elementparent 的子节点。一般来说,图片(Figure)在逻辑结构上应该是文档、章节或者某个区块(比如 DivSect)的直接子节点,很少会出现 FigureFigure 的情况,除非有特别复杂的图文组合。而且,每次循环都试图把一个新的 parent 加到新的 root 下面,关系完全乱了。
  3. PDMarkedContent 使用方式错误PDMarkedContent 本身不是一个可以独立存在于结构树节点 (PDStructureElement) 中的对象。它的作用是在 PDF 的内容流 (Content Stream) 中,通过 BMC (Begin Marked Content)、BDC (Begin Marked-Content Sequence with Properties) 和 EMC (End Marked Content) 操作符,给一段内容(比如绘制图片的操作)打上标记。这个标记通常包含一个标记类型(如 Figure)和一个唯一的标记内容 ID (MCID) 。结构树里的元素 (PDStructureElement) 之后会通过引用这个 MCID 和它所在的页面,来关联到内容流里的这块具体内容。代码里在循环末尾单独创建 PDMarkedContent 对象并试图 appendKid 是完全错误的理解。
  4. 标记与结构树关联缺失 :虽然代码在内容流里尝试用 beginMarkedContent/endMarkedContent 包裹了 drawImage,并且设置了 MCID,但是后续创建的 PDStructureElement 没有正确地与这个 MCID 关联起来 。仅仅设置页面 (setPage) 和 Alt 文本 (setAlternateDescription) 是不够的。你需要告诉结构元素:“嘿,你去页面 X 找那个 MCID 为 Y 的标记内容,那个就是我代表的图片。”
  5. Alt 文本设置位置 :Alt 文本应该设置在 PDStructureElement 上,通过 setAlternateDescription() 方法。虽然代码里做了,但同时也尝试在 COSDictionary (那个 dict) 上设置 COSName.ALT。后者通常用于内联图片 (Inline Images) 或某些旧的、非标准的做法。对于标准的 Tagged PDF 和 PDF/UA 规范,Alt 文本是结构元素的一个属性。在 beginMarkedContent 的属性字典里设置 ALT 是没用的。

简单说,就是:结构树搭错了地方,搭错了结构;标记打了,但没跟结构树上的“名牌”挂上钩;PDMarkedContent 被当成了可以塞进结构树的东西,这是不对的。

正确姿势:给 PDFBox 图片加上 Alt 文本

好,知道了问题所在,咱们来一步步修正。核心思路是:先搭好整个文档的结构树骨架,然后在每个页面绘制图片时,在内容流里用 BMC/BDCEMC 把图片操作“圈”起来并打上带 MCID 的标记,最后在结构树里创建对应的 Figure 元素,并让它正确引用到那个 MCID,同时设置好 Alt 文本。

第一步:初始化文档和结构树 (只需一次)

在开始添加任何页面内容之前,先搞定文档级别的设置。

import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.*;
import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent;
import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDPropertyList;
import org.apache.pdfbox.pdmodel.documentinterchange. KDocument structTree; structure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.structure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.StandardStructureTypes;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

// ... 在你的代码开头部分 ...

PDDocument doc = new PDDocument();

// 1. 让 PDF 成为 "Tagged PDF" - 这是无障碍的基础
PDDocumentCatalog catalog = doc.getDocumentCatalog();
PDMarkInfo markInfo = new PDMarkInfo();
markInfo.setMarked(true); // 告诉处理器这个 PDF 是带标签的
catalog.setMarkInfo(markInfo);

// 2. 创建并设置结构树根 (整个文档只需一个)
PDStructureTreeRoot structureTreeRoot = new PDStructureTreeRoot();
catalog.setStructureTreeRoot(structureTreeRoot);

// (可选但推荐) 设置 Role Map,明确结构类型(如 Figure)的标准含义
Map<String, String> roleMap = new HashMap<>();
roleMap.put("Figure", StandardStructureTypes.FIGURE); // 映射自定义标签(如果用的话)到标准标签
// 你可以添加更多映射,比如 P -> StandardStructureTypes.P 等
structureTreeRoot.setRoleMap(new COSDictionary());
for (Map.Entry<String, String> entry : roleMap.entrySet()) {
    structureTreeRoot.getRoleMap().setName(entry.getKey(), entry.getValue());
}

// 3. 创建一个顶级的结构元素,比如 "Document",所有页面内容结构挂在它下面
PDStructureElement rootElement = new PDStructureElement(StandardStructureTypes.DOCUMENT, structureTreeRoot);
structureTreeRoot.appendKid(rootElement); // 将 Document 设为根的子节点

// 4. 准备一个计数器给 MCID,确保整个文档中 MCID 是唯一的
int mcidCounter = 0;

// ... 后面开始循环创建页面和添加内容 ...

这段代码做了几件重要的事:

  • 标记文档为 Marked,这是启用 Tagged PDF 的信号。
  • 创建了唯一的 PDStructureTreeRoot 并关联到文档目录。
  • (可选)设置了 Role Map,让 PDF 阅读器更准确理解你的标签。
  • 创建了顶层结构元素 Document,它是所有页面内容的逻辑父节点。
  • 初始化了一个 mcidCounter 用于生成唯一的 MCID

第二步:在页面内容流中标记并绘制图片

现在,在你的页面循环里,我们要正确地使用 Marked Content 操作符来包裹图片绘制。

// ... 假设你的循环和图片加载逻辑 ...
PDImageXObject image = PDImageXObject.createFromFile("path/to/your/qrcode.png", doc); // 加载图片
float x = 50; // 图片 X 坐标
float y = 700; // 图片 Y 坐标
float px = image.getWidth() / 4; // 图片宽度 (示例,根据需要调整)
float py = image.getHeight() / 4; // 图片高度 (示例,根据需要调整)

for (int pageidx = 0; pageidx < totalPage; pageidx++) {
    PDPage page = new PDPage(PDRectangle.A4); // 创建新页面
    doc.addPage(page);

    // 把页面也加入到逻辑结构中(例如,作为 Document 的子节点)
    // 注意:这里简化处理,把所有 Figure 都挂在 Document 下。
    // 实际项目中,可能需要更复杂的结构,如 Page -> Article -> Figure
    // 但对于仅添加图片 Alt Text,挂在 Document 下通常也够用。

    PDPageContentStream contentStream = null;
    try {
        contentStream = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true);

        // ----- 核心部分:标记图片内容 -----

        // 1. 为当前图片准备属性列表,包含唯一的 MCID
        COSDictionary properties = new COSDictionary();
        properties.setInt(COSName.MCID, mcidCounter); // 使用计数器分配唯一 MCID
        PDPropertyList propertyList = PDPropertyList.create(properties);

        // 2. 开始标记内容块,使用标准结构类型 Figure
        // 用 BDC (Begin Marked-Content Sequence with Properties) 操作符更好,可以直接关联属性
        contentStream.beginMarkedContent(COSName.FIGURE, propertyList);

        // 3. 绘制图片
        contentStream.drawImage(image, x, y, px, py);

        // 4. 结束标记内容块
        contentStream.endMarkedContent();

        // ----- 标记结束 -----

    } finally {
        if (contentStream != null) {
            contentStream.close();
        }
    }

    // ----- 结构树部分:创建 Figure 元素并关联 -----

    // 5. 创建 Figure 结构元素
    PDStructureElement figureElement = new PDStructureElement(StandardStructureTypes.FIGURE, rootElement); // 父节点是之前创建的 Document 元素

    // 6. 设置 Figure 元素对应的页面
    figureElement.setPage(page); // 指向当前页面

    // 7. **关键:**  将结构元素与内容流中的标记内容关联起来
    // 通过引用标记内容的 MCID 实现。PDFBox 会处理底层关联。
    // 重要的是类型 (FIGURE) 和 MCID 要匹配上。
    // 对于用 BDC/BMC + MCID 的情况,只需确保 figureElement 的类型和 MCID 与标记内容一致,
    // 并且设置了正确的页面。PDFBox 在生成时会查找页面上对应 MCID 的内容。
    // 我们只需要把结构元素添加到结构树即可,它会自动“认领”内容流中对应 MCID 的内容。
    // (注意:一些低版本的 PDFBox 可能需要手动调用类似 `addMarkedContentReference` 的方法,
    // 但在新版本中,将带有 MCID 的 K Dictionary 条目加入 Structure Element 通常是自动处理的
    // 当你设置 page 并把它加入父节点时。)

    // 在创建 Properties 时传入的字典会被 PDFBox 用来建立联系
    figureElement.setCOSObject(properties); // 把包含 MCID 的字典也关联给 StructureElement 可能有助于某些情况(需要验证 API 行为)
                                             // 实际上,PDFBox 应该能根据 StuctElem 的类型和 Page 找到对应 MCID 的内容。
                                             // 最稳妥的方式是确认 K 数组的生成方式。
                                             // 替代或补充方案:如果 PDFBox 版本支持,查找 `addMarkedContentReference` 或类似方法。
                                             // 如果没有,确保 `figureElement` 被正确添加到父节点 `rootElement` 下。

    // 8. **设置 Alt 文本** 
    String altText = "这是一个二维码图片,包含了网站链接。"; // **换成你实际的、有意义的** 
    figureElement.setAlternateDescription(altText);

    // 9. 将 Figure 元素添加到 Document 元素的子节点列表
    rootElement.appendKid(figureElement);

    // 10. 递增 MCID 计数器,为下一个标记内容准备
    mcidCounter++;

    // 更新 Y 坐标,避免下一页图片重叠(如果多页的话)
    y -= (py + 20); // 只是个例子
}

// ... 在所有页面处理完毕后 ...

// 保存文档
doc.save("path/to/your/accessible_document.pdf");
doc.close();

第三步:整合与解释

上面第二步的代码展示了如何在循环中处理每个页面上的图片:

  • 为每个要标记的图片分配一个全局唯一mcidCounter
  • 使用 contentStream.beginMarkedContent(COSName.FIGURE, propertyList) 开始标记,其中 propertyList 包含了这个唯一的 MCIDCOSName.FIGURE 指明这段内容逻辑上是个“图片”。
  • contentStream.drawImage() 正常绘制图片。
  • contentStream.endMarkedContent() 结束标记。
  • 重点来了 :创建一个 PDStructureElement,类型同样设为 StandardStructureTypes.FIGURE
  • 设置它属于哪个页面 (figureElement.setPage(page))。
  • 设置 Alt 文本 (figureElement.setAlternateDescription("..."))。这才是屏幕阅读器会读出来的内容。
  • 把它添加到正确的父结构元素下 (rootElement.appendKid(figureElement))。rootElement 就是我们在第一步创建的那个 Document 元素。通过这个层级关系和页面引用,PDFBox 和 PDF 阅读器就能将这个结构元素与页面上对应 MCID 的绘制操作关联起来。

这样,每个图片在物理绘制时被“贴上”了带 MCID 的标签,同时在逻辑结构树里有一个对应的“名牌”(Figure 元素),这个名牌上写着 Alt 文本,并且知道自己对应哪个页面的哪个标签(通过 MCID)。

第四步:检查与验证

生成 PDF 后,务必 再次使用 PAC (推荐) 或 Adobe Acrobat Pro 的辅助功能检查工具来验证。检查点:

  • 图片是否不再报“未标记”错误?
  • 选中图片相关的结构元素时,能否看到正确的 Alt 文本?
  • 逻辑阅读顺序(如果有其他文本内容)是否合理?

进阶技巧与注意事项

  1. 装饰性图片 (Artifacts) : 如果图片纯粹是装饰性的,没有信息价值(比如背景纹理、分隔线图案),应该标记为 "Artifact"。这样屏幕阅读器会跳过它。

    • 方法一:在 beginMarkedContent 时使用 /Artifact 标签,而不是 /Figure
    • 方法二:在结构树中不为它创建 Figure 元素,而是通过特殊方式标记内容流中的相应部分为 Artifact(更复杂些,通常在标记时指定)。PDFBox 中可能需要查阅特定 API 如何直接标记为 Artifact。一个简单做法是,在 beginMarkedContentproperties 字典里添加 COSName.TYPECOSName.ARTIFACT
      // 标记为 Artifact
      COSDictionary artifactProps = new COSDictionary();
      // 可能需要设置 Artifact 类型,比如 Decoration, Layout 等
      // artifactProps.setName(COSName.TYPE, "Decoration"); // 查阅 PDF 标准确定 Artifact 属性细节
      contentStream.beginMarkedContent(COSName.ARTIFACT, PDPropertyList.create(artifactProps));
      contentStream.drawImage(decorativeImage, ...);
      contentStream.endMarkedContent();
      // 不需要为 Artifact 在结构树里创建对应的 Element
      
  2. 图片与标题组合 (Figure with Caption) : 如果图片有标题文字,最佳实践是创建一个 Figure 结构元素,它下面再包含一个 Image 元素(关联到图片内容 MCID)和一个 Caption 元素(关联到标题文字内容的 MCID)。Figure 元素本身可以不设 Alt 文本,或者只设一个整体的简短,详细描述放在 Image 元素的 Alt 文本里。

  3. 结构层级 : 对于复杂的文档,简单的 Document -> Figure 结构可能不够。你可能需要根据文档的实际逻辑,创建 Part (篇), Sect (章节), Div (区块) 等结构元素,形成更精细的树状结构。图片 (Figure) 应该放在它所属的逻辑区块下。

  4. PDF/UA 标准 : 追求更高标准的无障碍,应该参考 PDF/UA (Universal Accessibility) 规范。它对 Tagged PDF 有更严格的要求。

  5. PDFBox 版本 : API 细节可能随 PDFBox 版本变化。确保你使用的 PDFBox 版本(推荐用较新稳定版)支持这些无障碍特性,并查阅对应版本的文档确认 API 用法。

通过以上步骤和注意事项,应该就能解决 PDFBox 生成的 PDF 中图片 Alt 文本缺失导致的可访问性问题了。记住,核心是在内容流中标记内容 + 在结构树中建立逻辑表示并关联 + 设置 Alt 文本