如何使用Java PDFBox 2.0.8库创建一个可访问的PDF,并且还可以通过PAC 2工具进行验证?

17

背景

我在GitHub上有一个小项目,尝试创建一个符合508条款(compliant)的PDF文档,其中包含复杂的表格结构和表单元素。http://www.access-for-all.ch/en/pdf-lab/pdf-accessibility-checker-pac.html是一个推荐的工具,用于验证这些PDF文档,我的程序生成的PDF文档通过了大部分检查。我也会在运行时了解到每个字段的含义,因此添加标签来组织元素不应该是问题。

问题

PAC 2工具似乎对输出PDF中的两个特定项存在问题。特别地,我的单选按钮(widget)注释未嵌套在表单结构元素内,而我的标记内容未被标记(文本和表格单元格)。 PAC 2验证了在左上角单元格内的结构元素,但未验证标记内容

然而,PAC 2将标记内容识别为错误(即文本/路径对象未标记)。 此外,单选按钮(widgets)被检测到,但似乎没有API将它们添加到表单结构元素中。

我尝试过的方法

我在本网站和其他相关主题的网站上查看了几个问题,包括Tagged PDF with PDFBox,但似乎几乎没有PDF/UA的示例和非常少有用的文档(我发现的)。我发现的最有用的提示是解释标记PDF规范的网站,例如https://taggedpdf.com/508-pdf-help-center/object-not-tagged/
问题是:是否可以使用Apache PDFBox创建一个包含标记内容和单选按钮小部件注释的PAC 2可验证PDF?如果可能,是否可以使用更高级别(非弃用)的PDFBox API来实现?
附注:这实际上是我的第一个StackExchange问题(尽管我已经广泛使用该网站),我希望一切都井然有序!请随意添加任何必要的编辑,并询问我可能需要澄清的任何问题。此外,我在GitHub上有一个示例程序,可以生成我的PDF文档,网址为https://github.com/chris271/UAPDFBox
编辑1:Output PDF Document的直接链接。
*编辑2: 在使用了一些较低级别的PDFBox API并查看了使用PDFDebugger的完全兼容PDF的原始数据流后,我能够生成与兼容PDF的内容结构几乎相同的PDF ...但是,相同的错误出现了,即文本对象未被标记,并且我真的无法决定接下来该怎么做...任何指导将不胜感激!
编辑3: 并排的原始PDF内容比较。
编辑4: 生成的PDF的内部结构。

generated PDF

以及符合要求的PDF

compliant PDF

编辑5:在 Tilman Hausherr 的建议下,我已成功修复标记路径/文本对象的PAC 2错误!如果我能解决“注释小部件未嵌套在表单结构元素内”的问题,我将添加答案。


我曾经写过一些单选按钮的代码,但它不是高级的,也没有包含任何标记。PDFBox非常低级。 - Tilman Hausherr
@TilmanHausherr 嘿,谢谢!是的,我查看了您在我之前的研究中提供的答案,它实际上帮助我解决了一个单独的问题。此外,我注意到您在我链接的问题中提到了BMC,BDC,EMC,MP和DP操作符。在看了这里 https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmarkReference_v9.pdf 他们所做的事情后,我想知道标记标记内容会有多有用。主要问题将是访问由 PDPageContentStream 生成的内容并对其进行标记... - GurpusMaximus
我建议查看源代码下载中的RemoveAllText.java示例,或者也可以参考这个解决方案https://stackoverflow.com/questions/45246141/setting-overprint-true-for-a-specific-colorspace-on-pdf-not-the-entire-pdf-pa,该解决方案展示了如何操作内容流。 - Tilman Hausherr
接下来要找出的是结构树是否相应地被更改。为此,请将PDFDebugger切换到“显示内部结构”,然后转到结构树。另一件事是,您提到了单选按钮,但在您的并排比较中,您显示了主文本。 - Tilman Hausherr
哇,我简直不敢相信我在调试器中错过了那个选项卡,谢谢!至于并排显示,我目前对单选按钮不是很关心,但我肯定应该更清楚地表明它仅适用于文本内容。至于内部结构,我已经添加了一张截图...我无法确定是否有遗漏,但似乎文本可能不存在。 - GurpusMaximus
StructureTreeRoot 中缺少 ParentTree。根据 PDF 规范,“如果任何结构元素包含内容项,则需要 ParentTree”。 - Tilman Hausherr
1个回答

18

浏览了大量的PDF规范和许多PDFBox示例后,我成功解决了PAC 2报告的所有问题。创建验证过的PDF(具有复杂的表格结构)涉及几个步骤,完整的源代码可在这里的github上获得。下面我将尝试概述代码的主要部分。(这里不会解释某些方法调用!)

步骤1(设置元数据)

各种设置信息,如文档标题和语言

//Setup new document
    pdf = new PDDocument();
    acroForm = new PDAcroForm(pdf);
    pdf.getDocumentInformation().setTitle(title);
    //Adjust other document metadata
    PDDocumentCatalog documentCatalog = pdf.getDocumentCatalog();
    documentCatalog.setLanguage("English");
    documentCatalog.setViewerPreferences(new PDViewerPreferences(new COSDictionary()));
    documentCatalog.getViewerPreferences().setDisplayDocTitle(true);
    documentCatalog.setAcroForm(acroForm);
    documentCatalog.setStructureTreeRoot(structureTreeRoot);
    PDMarkInfo markInfo = new PDMarkInfo();
    markInfo.setMarked(true);
    documentCatalog.setMarkInfo(markInfo);

直接将所有字体嵌入资源中。

//Set AcroForm Appearance Characteristics
    PDResources resources = new PDResources();
    defaultFont = PDType0Font.load(pdf,
            new PDTrueTypeFont(PDType1Font.HELVETICA.getCOSObject()).getTrueTypeFont(), true);
    resources.put(COSName.getPDFName("Helv"), defaultFont);
    acroForm.setNeedAppearances(true);
    acroForm.setXFA(null);
    acroForm.setDefaultResources(resources);
    acroForm.setDefaultAppearance(DEFAULT_APPEARANCE);

为PDF/UA规范添加XMP元数据。

//Add UA XMP metadata based on specs at https://taggedpdf.com/508-pdf-help-center/pdfua-identifier-missing/
    XMPMetadata xmp = XMPMetadata.createXMPMetadata();
    xmp.createAndAddDublinCoreSchema();
    xmp.getDublinCoreSchema().setTitle(title);
    xmp.getDublinCoreSchema().setDescription(title);
    xmp.createAndAddPDFAExtensionSchemaWithDefaultNS();
    xmp.getPDFExtensionSchema().addNamespace("http://www.aiim.org/pdfa/ns/schema#", "pdfaSchema");
    xmp.getPDFExtensionSchema().addNamespace("http://www.aiim.org/pdfa/ns/property#", "pdfaProperty");
    xmp.getPDFExtensionSchema().addNamespace("http://www.aiim.org/pdfua/ns/id/", "pdfuaid");
    XMPSchema uaSchema = new XMPSchema(XMPMetadata.createXMPMetadata(),
            "pdfaSchema", "pdfaSchema", "pdfaSchema");
    uaSchema.setTextPropertyValue("schema", "PDF/UA Universal Accessibility Schema");
    uaSchema.setTextPropertyValue("namespaceURI", "http://www.aiim.org/pdfua/ns/id/");
    uaSchema.setTextPropertyValue("prefix", "pdfuaid");
    XMPSchema uaProp = new XMPSchema(XMPMetadata.createXMPMetadata(),
            "pdfaProperty", "pdfaProperty", "pdfaProperty");
    uaProp.setTextPropertyValue("name", "part");
    uaProp.setTextPropertyValue("valueType", "Integer");
    uaProp.setTextPropertyValue("category", "internal");
    uaProp.setTextPropertyValue("description", "Indicates, which part of ISO 14289 standard is followed");
    uaSchema.addUnqualifiedSequenceValue("property", uaProp);
    xmp.getPDFExtensionSchema().addBagValue("schemas", uaSchema);
    xmp.getPDFExtensionSchema().setPrefix("pdfuaid");
    xmp.getPDFExtensionSchema().setTextPropertyValue("part", "1");
    XmpSerializer serializer = new XmpSerializer();
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    serializer.serialize(xmp, baos, true);
    PDMetadata metadata = new PDMetadata(pdf);
    metadata.importXMPMetadata(baos.toByteArray());
    pdf.getDocumentCatalog().setMetadata(metadata);

步骤2(设置文档标记结构)

您需要将根结构元素和所有必要的结构元素作为子元素添加到根元素中。

//Adds a DOCUMENT structure element as the structure tree root.
void addRoot() {
    PDStructureElement root = new PDStructureElement(StandardStructureTypes.DOCUMENT, null);
    root.setAlternateDescription("The document's root structure element.");
    root.setTitle("PDF Document");
    pdf.getDocumentCatalog().getStructureTreeRoot().appendKid(root);
    currentElem = root;
    rootElem = root;
}

每个标记的内容元素(文本和背景图形)都需要具有MCID和相应的标签,以便在父树中引用,这将在第3步中解释。
//Assign an id for the next marked content element.
private void setNextMarkedContentDictionary(String tag) {
    currentMarkedContentDictionary = new COSDictionary();
    currentMarkedContentDictionary.setName("Tag", tag);
    currentMarkedContentDictionary.setInt(COSName.MCID, currentMCID);
    currentMCID++;
}

屏幕阅读器无法检测到工件(背景图形)。需要检测到文本,因此在添加文本时使用P结构元素。

            //Set up the next marked content element with an MCID and create the containing TD structure element.
            PDPageContentStream contents = new PDPageContentStream(
                    pdf, pages.get(pageIndex), PDPageContentStream.AppendMode.APPEND, false);
            currentElem = addContentToParent(null, StandardStructureTypes.TD, pages.get(pageIndex), currentRow);

            //Make the actual cell rectangle and set as artifact to avoid detection.
            setNextMarkedContentDictionary(COSName.ARTIFACT.getName());
            contents.beginMarkedContent(COSName.ARTIFACT, PDPropertyList.create(currentMarkedContentDictionary));

            //Draws the cell itself with the given colors and location.
            drawDataCell(table.getCell(i, j).getCellColor(), table.getCell(i, j).getBorderColor(),
                    x + table.getRows().get(i).getCellPosition(j),
                    y + table.getRowPosition(i),
                    table.getCell(i, j).getWidth(), table.getRows().get(i).getHeight(), contents);
            contents.endMarkedContent();
            currentElem = addContentToParent(COSName.ARTIFACT, StandardStructureTypes.P, pages.get(pageIndex), currentElem);
            contents.close();
            //Draw the cell's text as a P structure element
            contents = new PDPageContentStream(
                    pdf, pages.get(pageIndex), PDPageContentStream.AppendMode.APPEND, false);
            setNextMarkedContentDictionary(COSName.P.getName());
            contents.beginMarkedContent(COSName.P, PDPropertyList.create(currentMarkedContentDictionary));
            //... Code to draw actual text...//
            //End the marked content and append it's P structure element to the containing TD structure element.
            contents.endMarkedContent();
            addContentToParent(COSName.P, null, pages.get(pageIndex), currentElem);
            contents.close();

注释小部件(在这种情况下是表单对象)需要嵌套在表单结构元素内。

//Add a radio button widget.
            if (!table.getCell(i, j).getRbVal().isEmpty()) {
                PDStructureElement fieldElem = new PDStructureElement(StandardStructureTypes.FORM, currentElem);
                radioWidgets.add(addRadioButton(
                        x + table.getRows().get(i).getCellPosition(j) -
                                radioWidgets.size() * 10 + table.getCell(i, j).getWidth() / 4,
                        y + table.getRowPosition(i),
                        table.getCell(i, j).getWidth() * 1.5f, 20,
                        radioValues, pageIndex, radioWidgets.size()));
                fieldElem.setPage(pages.get(pageIndex));
                COSArray kArray = new COSArray();
                kArray.add(COSInteger.get(currentMCID));
                fieldElem.getCOSObject().setItem(COSName.K, kArray);
                addWidgetContent(annotationRefs.get(annotationRefs.size() - 1), fieldElem, StandardStructureTypes.FORM, pageIndex);
            }

//Add a text field in the current cell.
            if (!table.getCell(i, j).getTextVal().isEmpty()) {
                PDStructureElement fieldElem = new PDStructureElement(StandardStructureTypes.FORM, currentElem);
                addTextField(x + table.getRows().get(i).getCellPosition(j),
                        y + table.getRowPosition(i),
                        table.getCell(i, j).getWidth(), table.getRows().get(i).getHeight(),
                        table.getCell(i, j).getTextVal(), pageIndex);
                fieldElem.setPage(pages.get(pageIndex));
                COSArray kArray = new COSArray();
                kArray.add(COSInteger.get(currentMCID));
                fieldElem.getCOSObject().setItem(COSName.K, kArray);
                addWidgetContent(annotationRefs.get(annotationRefs.size() - 1), fieldElem, StandardStructureTypes.FORM, pageIndex);
            }

步骤3

在所有内容元素都被写入内容流并设置标签结构之后,需要返回并将父树添加到结构树根。注意:上述代码中的一些方法调用(addWidgetContent()和addContentToParent())设置了必要的COSDictionary对象。

//Adds the parent tree to root struct element to identify tagged content
void addParentTree() {
    COSDictionary dict = new COSDictionary();
    nums.add(numDictionaries);
    for (int i = 1; i < currentStructParent; i++) {
        nums.add(COSInteger.get(i));
        nums.add(annotDicts.get(i - 1));
    }
    dict.setItem(COSName.NUMS, nums);
    PDNumberTreeNode numberTreeNode = new PDNumberTreeNode(dict, dict.getClass());
    pdf.getDocumentCatalog().getStructureTreeRoot().setParentTreeNextKey(currentStructParent);
    pdf.getDocumentCatalog().getStructureTreeRoot().setParentTree(numberTreeNode);
}

如果所有的小部件注释和标记内容都正确添加到结构树和父树中,那么您应该从PAC 2和PDFDebugger中得到类似于这样的结果。

Verified PDF

Debugger

感谢Tilman Hausherr指导我解决了这个问题!我很可能会根据其他人的建议对这个答案进行一些编辑以增加其清晰度。
编辑1:
如果您想要像我生成的表格结构一样,您还需要添加正确的表格标记以完全符合508标准...“Scope”、“ColSpan”、“RowSpan”或“Headers”属性将需要正确地添加到每个表格单元格结构元素中,类似于thisthis。这种标记的主要目的是允许屏幕阅读软件(如JAWS)以可理解的方式读取表格内容。这些属性可以通过以下类似的方式添加...
private void addTableCellMarkup(Cell cell, int pageIndex, PDStructureElement currentRow) {
    COSDictionary cellAttr = new COSDictionary();
    cellAttr.setName(COSName.O, "Table");
    if (cell.getCellMarkup().isHeader()) {
        currentElem = addContentToParent(null, StandardStructureTypes.TH, pages.get(pageIndex), currentRow);
        currentElem.getCOSObject().setString(COSName.ID, cell.getCellMarkup().getId());
        if (cell.getCellMarkup().getScope().length() > 0) {
            cellAttr.setName(COSName.getPDFName("Scope"), cell.getCellMarkup().getScope());
        }
        if (cell.getCellMarkup().getColspan() > 1) {
            cellAttr.setInt(COSName.getPDFName("ColSpan"), cell.getCellMarkup().getColspan());
        }
        if (cell.getCellMarkup().getRowSpan() > 1) {
            cellAttr.setInt(COSName.getPDFName("RowSpan"), cell.getCellMarkup().getRowSpan());
        }
    } else {
        currentElem = addContentToParent(null, StandardStructureTypes.TD, pages.get(pageIndex), currentRow);
    }
    if (cell.getCellMarkup().getHeaders().length > 0) {
        COSArray headerA = new COSArray();
        for (String s : cell.getCellMarkup().getHeaders()) {
            headerA.add(new COSString(s));
        }
        cellAttr.setItem(COSName.getPDFName("Headers"), headerA);
    }
    currentElem.getCOSObject().setItem(COSName.A, cellAttr);
}

请确保对于每个标记为文本内容的结构元素都执行类似 currentElem.setAlternateDescription(currentCell.getText()); 的操作,以便JAWS可以读取该文本。
注意:每个字段(单选按钮和文本框)都需要一个唯一名称,以避免设置多个字段值。GitHub已更新具有表格标记和改进表单字段的更复杂示例PDF!

请问addContentToParent函数究竟是做什么的? - Gábor Lipták
2
@GáborLipták 如果你还没有看过链接的问题,请务必查看link。即使我在我的项目中从未使用过图像,你可能仍需要向包装图像的结构元素的字典中添加一个字符串...类似于structureElement.getCOSObject().setString(COSName.ALT, "ALT TEXT"); 希望这可以帮到你! - GurpusMaximus
谢谢,我会尝试的。 - Gábor Lipták
我只想补充一下,addContentToParent()函数在GurpusMaximus的github存储库中定义,他在上面提供了链接,这是该存储库中该函数的永久链接:https://github.com/chris271/UAPDFBox/blob/master/src/com/wi/test/util/PDFormBuilder.java#L176 - SomeGuy
我去了GitHub的存储库并运行了PAC 2021无障碍工具。在检查“详细结果”时,发现了一些结构问题。我认为这些结构问题是在将PDMarkedContent实例附加到树中时引起的。有没有办法修复这个问题?这是一个屏幕截图:https://i.imgur.com/WoIQeKp.png - SomeGuy
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接