PDFBox 添加图片 Alt 文本:搞定 PDF 无障碍访问
2025-04-14 02:50:56
PDFBox 图片添加 Alt 文本:搞定 PDF 无障碍
咱们在用 Java 的 PDFBox 库生成 PDF 文件时,有时需要让生成的 PDF 对屏幕阅读器友好,也就是实现“无障碍访问”(Accessibility)。一个关键点就是给 PDF 里的图片加上替代文本(Alternative Text,简称 Alt Text),这样视力障碍的用户也能通过屏幕阅读器了解图片内容。
不少朋友参照网上的教程,尝试用 PDMarkedContent
和 PDStructureElement
来给图片打标签、加 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)。白忙活了!那问题到底出在哪儿?又该怎么修正呢?
问题出在哪?
上面的代码主要踩了几个坑:
- 结构树 (
StructureTreeRoot
) 创建位置不对 :PDStructureTreeRoot
代表整个 PDF 文档的逻辑结构根。它应该在文档级别创建,而且只需要创建一次 。代码里把它放在for
循环里面,每次循环都 new 一个新的,还会覆盖掉之前DocumentCatalog
里的设置。这直接导致最终 PDF 的结构树是混乱或者不完整的。 - 结构元素 (
PDStructureElement
) 层级混乱 :代码里创建了parent
和element
两个Figure
类型的结构元素,并且element
是parent
的子节点。一般来说,图片(Figure)在逻辑结构上应该是文档、章节或者某个区块(比如Div
或Sect
)的直接子节点,很少会出现Figure
套Figure
的情况,除非有特别复杂的图文组合。而且,每次循环都试图把一个新的parent
加到新的root
下面,关系完全乱了。 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
是完全错误的理解。- 标记与结构树关联缺失 :虽然代码在内容流里尝试用
beginMarkedContent
/endMarkedContent
包裹了drawImage
,并且设置了MCID
,但是后续创建的PDStructureElement
没有正确地与这个MCID
关联起来 。仅仅设置页面 (setPage
) 和 Alt 文本 (setAlternateDescription
) 是不够的。你需要告诉结构元素:“嘿,你去页面 X 找那个MCID
为 Y 的标记内容,那个就是我代表的图片。” - Alt 文本设置位置 :Alt 文本应该设置在
PDStructureElement
上,通过setAlternateDescription()
方法。虽然代码里做了,但同时也尝试在COSDictionary
(那个dict
) 上设置COSName.ALT
。后者通常用于内联图片 (Inline Images) 或某些旧的、非标准的做法。对于标准的 Tagged PDF 和 PDF/UA 规范,Alt 文本是结构元素的一个属性。在beginMarkedContent
的属性字典里设置ALT
是没用的。
简单说,就是:结构树搭错了地方,搭错了结构;标记打了,但没跟结构树上的“名牌”挂上钩;PDMarkedContent
被当成了可以塞进结构树的东西,这是不对的。
正确姿势:给 PDFBox 图片加上 Alt 文本
好,知道了问题所在,咱们来一步步修正。核心思路是:先搭好整个文档的结构树骨架,然后在每个页面绘制图片时,在内容流里用 BMC/BDC
和 EMC
把图片操作“圈”起来并打上带 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
包含了这个唯一的MCID
。COSName.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 文本?
- 逻辑阅读顺序(如果有其他文本内容)是否合理?
进阶技巧与注意事项
-
装饰性图片 (Artifacts) : 如果图片纯粹是装饰性的,没有信息价值(比如背景纹理、分隔线图案),应该标记为 "Artifact"。这样屏幕阅读器会跳过它。
- 方法一:在
beginMarkedContent
时使用/Artifact
标签,而不是/Figure
。 - 方法二:在结构树中不为它创建
Figure
元素,而是通过特殊方式标记内容流中的相应部分为 Artifact(更复杂些,通常在标记时指定)。PDFBox 中可能需要查阅特定 API 如何直接标记为 Artifact。一个简单做法是,在beginMarkedContent
的properties
字典里添加COSName.TYPE
为COSName.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
- 方法一:在
-
图片与标题组合 (Figure with Caption) : 如果图片有标题文字,最佳实践是创建一个
Figure
结构元素,它下面再包含一个Image
元素(关联到图片内容 MCID)和一个Caption
元素(关联到标题文字内容的 MCID)。Figure
元素本身可以不设 Alt 文本,或者只设一个整体的简短,详细描述放在Image
元素的 Alt 文本里。 -
结构层级 : 对于复杂的文档,简单的
Document -> Figure
结构可能不够。你可能需要根据文档的实际逻辑,创建Part
(篇),Sect
(章节),Div
(区块) 等结构元素,形成更精细的树状结构。图片 (Figure
) 应该放在它所属的逻辑区块下。 -
PDF/UA 标准 : 追求更高标准的无障碍,应该参考 PDF/UA (Universal Accessibility) 规范。它对 Tagged PDF 有更严格的要求。
-
PDFBox 版本 : API 细节可能随 PDFBox 版本变化。确保你使用的 PDFBox 版本(推荐用较新稳定版)支持这些无障碍特性,并查阅对应版本的文档确认 API 用法。
通过以上步骤和注意事项,应该就能解决 PDFBox 生成的 PDF 中图片 Alt 文本缺失导致的可访问性问题了。记住,核心是在内容流中标记内容 + 在结构树中建立逻辑表示并关联 + 设置 Alt 文本 。