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")); + } +}