From e341be22d9550122cae283cec439d46caf81f296 Mon Sep 17 00:00:00 2001 From: mgr34 Date: Fri, 29 May 2026 17:48:54 -0400 Subject: [PATCH] Write fox:alt-text to /Contents on internal link annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF/UA-1 (ISO 14289-1 §7.18.5) requires every link annotation to carry an alternate description in its /Contents key. FOP set this only for external (URI) links, where the alt text travels with the PDFUri action. Internal (GoTo) links got /Alt on the Link structure element but no /Contents on the annotation, so each one failed validation. PDFDocumentNavigationHandler.renderLink now reads the alternate text back from the Link structure element's /Alt (which already holds fox:alt-text) and sets it on the PDFLink for both link types. PDFLink.toPDFString emits /Contents from this explicit value when present, otherwise falling back to the URI action's alt text as before. Adds PDFLinkContentsTestCase covering internal links with and without an alternate description, the external-link fallback, and explicit precedence. --- .../main/java/org/apache/fop/pdf/PDFLink.java | 36 +++++-- .../pdf/PDFDocumentNavigationHandler.java | 9 ++ .../fop/pdf/PDFLinkContentsTestCase.java | 98 +++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 fop-core/src/test/java/org/apache/fop/pdf/PDFLinkContentsTestCase.java diff --git a/fop-core/src/main/java/org/apache/fop/pdf/PDFLink.java b/fop-core/src/main/java/org/apache/fop/pdf/PDFLink.java index 7665206322b..3af526f65bd 100644 --- a/fop-core/src/main/java/org/apache/fop/pdf/PDFLink.java +++ b/fop-core/src/main/java/org/apache/fop/pdf/PDFLink.java @@ -45,6 +45,7 @@ public class PDFLink extends PDFObject { private String color; private PDFAction action; private Integer structParent; + private String contents; /** * create objects associated with a link annotation (GoToR) @@ -81,6 +82,18 @@ public void setStructParent(int structParent) { this.structParent = structParent; } + /** + * Sets the alternate description written to the /Contents entry of the link + * annotation dictionary. PDF/UA-1 (ISO 14289-1 §7.18.5) requires every link + * annotation to carry such a description. This is used for internal + * (GoTo) links, whose action does not otherwise convey alternate text. + * + * @param contents the alternate description, typically from fox:alt-text + */ + public void setContents(String contents) { + this.contents = contents; + } + /** * {@inheritDoc} */ @@ -101,16 +114,21 @@ public String toPDFString() { + this.action.getAction() + "\n" + "/H /I\n" + (this.structParent != null ? "/StructParent " + this.structParent.toString() + "\n" : ""); - if (action instanceof PDFUri) { - String altText = ((PDFUri) action).getAltText(); - if (altText != null && !altText.isEmpty()) { - if (getDocumentSafely().isEncryptionActive()) { - altText = new String(encodeText(altText), StandardCharsets.ISO_8859_1); - } else { - altText = PDFText.escapeText(altText); - } - dict += "/Contents " + altText + "\n"; + // PDF/UA-1 requires every link annotation to carry an alternate + // description in /Contents. An explicitly set value (used for internal + // GoTo links) takes precedence; otherwise fall back to the alternate + // text carried by a URI action for external links. + String altText = this.contents; + if ((altText == null || altText.isEmpty()) && action instanceof PDFUri) { + altText = ((PDFUri) action).getAltText(); + } + if (altText != null && !altText.isEmpty()) { + if (getDocumentSafely().isEncryptionActive()) { + altText = new String(encodeText(altText), StandardCharsets.ISO_8859_1); + } else { + altText = PDFText.escapeText(altText); } + dict += "/Contents " + altText + "\n"; } dict += fFlag + "\n>>"; return dict; diff --git a/fop-core/src/main/java/org/apache/fop/render/pdf/PDFDocumentNavigationHandler.java b/fop-core/src/main/java/org/apache/fop/render/pdf/PDFDocumentNavigationHandler.java index 657d8189918..c1dbc89d685 100644 --- a/fop-core/src/main/java/org/apache/fop/render/pdf/PDFDocumentNavigationHandler.java +++ b/fop-core/src/main/java/org/apache/fop/render/pdf/PDFDocumentNavigationHandler.java @@ -114,6 +114,15 @@ public void renderLink(Link link) throws IFException { PDFStructElem structure = (PDFStructElem) link.getAction().getStructureTreeElement(); if (documentHandler.getUserAgent().isAccessibilityEnabled() && structure != null) { documentHandler.getLogicalStructureHandler().addLinkContentItem(pdfLink, structure); + // Propagate the link's alternate text (fox:alt-text, stored as + // /Alt on the Link structure element) to the annotation's + // /Contents entry, as required by PDF/UA-1 (ISO 14289-1 + // §7.18.5). External (URI) links also carry this via their + // action, but internal (GoTo) links otherwise would not. + Object altText = structure.get("Alt"); + if (altText instanceof String) { + pdfLink.setContents((String) altText); + } } documentHandler.getCurrentPage().addAnnotation(pdfLink); } diff --git a/fop-core/src/test/java/org/apache/fop/pdf/PDFLinkContentsTestCase.java b/fop-core/src/test/java/org/apache/fop/pdf/PDFLinkContentsTestCase.java new file mode 100644 index 00000000000..81ebb858b98 --- /dev/null +++ b/fop-core/src/test/java/org/apache/fop/pdf/PDFLinkContentsTestCase.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* $Id$ */ + +package org.apache.fop.pdf; + +import java.awt.geom.Rectangle2D; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Tests that {@link PDFLink} writes the {@code /Contents} alternate description + * required by PDF/UA-1 (ISO 14289-1 §7.18.5) for internal (GoTo) links, not + * just external (URI) links. + */ +public class PDFLinkContentsTestCase { + + private PDFLink newRegisteredLink(PDFDocument doc, PDFAction action) { + PDFLink link = new PDFLink(new Rectangle2D.Double(0, 0, 100, 20)); + link.setAction(action); + doc.registerObject(action); + doc.registerObject(link); + return link; + } + + /** + * An internal link (PDFGoTo action) carries no alternate text of its own, so + * previously no {@code /Contents} was written. With the alternate text set + * explicitly (as the renderer now does from the Link structure element's + * /Alt), the annotation dictionary must contain it. + */ + @Test + public void testInternalLinkWritesContents() { + PDFDocument doc = new PDFDocument("test"); + PDFLink link = newRegisteredLink(doc, new PDFGoTo("1 0 R")); + link.setContents("Jump to target section"); + + assertTrue(link.toPDFString().contains("/Contents (Jump to target section)")); + } + + /** + * Without an explicit description, an internal link still emits no + * {@code /Contents} (unchanged behaviour for documents that are not + * accessibility-enabled). + */ + @Test + public void testInternalLinkWithoutContents() { + PDFDocument doc = new PDFDocument("test"); + PDFLink link = newRegisteredLink(doc, new PDFGoTo("1 0 R")); + + assertFalse(link.toPDFString().contains("/Contents")); + } + + /** + * For external links the {@code /Contents} value still falls back to the URI + * action's alternate text when none is set explicitly. + */ + @Test + public void testExternalLinkFallsBackToActionAltText() { + PDFDocument doc = new PDFDocument("test"); + PDFLink link = newRegisteredLink(doc, new PDFUri("https://example.com", "Example website")); + + assertTrue(link.toPDFString().contains("/Contents (Example website)")); + } + + /** + * An explicitly set description takes precedence over the URI action's + * alternate text and is written only once. + */ + @Test + public void testExplicitContentsTakesPrecedence() { + PDFDocument doc = new PDFDocument("test"); + PDFLink link = newRegisteredLink(doc, new PDFUri("https://example.com", "Action alt text")); + link.setContents("Explicit contents"); + + String pdf = link.toPDFString(); + assertTrue(pdf.contains("/Contents (Explicit contents)")); + assertFalse(pdf.contains("Action alt text")); + } +}