You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@xmlgraphics.apache.org by jg...@apache.org on 2026/03/02 15:53:35 UTC

(xmlgraphics-fop-pdf-images) branch main updated: FOP-3122 Copy bookmarks

This is an automated email from the ASF dual-hosted git repository.

jgoncalves pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/xmlgraphics-fop-pdf-images.git


The following commit(s) were added to refs/heads/main by this push:
     new 02e5306  FOP-3122 Copy bookmarks
02e5306 is described below

commit 02e530600f61c3050b674045b6dac9ebb558ec9d
Author: Joao Goncalves <an...@gmail.com>
AuthorDate: Fri Jan 16 15:43:15 2026 -0600

    FOP-3122 Copy bookmarks
---
 .../fop/render/pdf/pdfbox/CloneAnnotations.java    |   3 +-
 .../fop/render/pdf/pdfbox/PDFBoxAdapter.java       | 163 ++++++++++++++++++---
 .../fop/render/pdf/pdfbox/PDFBoxAdapterUtil.java   |  61 ++++++++
 .../render/pdf/pdfbox/PDFBoxAdapterTestCase.java   |  17 ++-
 .../org/apache/fop/render/pdf/pdfbox/bookmarks.pdf | Bin 0 -> 6974 bytes
 5 files changed, 218 insertions(+), 26 deletions(-)

diff --git a/src/java/org/apache/fop/render/pdf/pdfbox/CloneAnnotations.java b/src/java/org/apache/fop/render/pdf/pdfbox/CloneAnnotations.java
index 7beaeba..5d09cc9 100644
--- a/src/java/org/apache/fop/render/pdf/pdfbox/CloneAnnotations.java
+++ b/src/java/org/apache/fop/render/pdf/pdfbox/CloneAnnotations.java
@@ -82,7 +82,8 @@ public class CloneAnnotations implements HandleAnnotations<COSObject> {
             COSDictionary dictionary = (COSDictionary) ((COSObject) annot).getObject();
             COSBase parent = dictionary.getItem(COSName.PARENT);
             if (parent != null) {
-                clonedAnnot.put(COSName.PARENT.getName(), pdfBoxAdapter.cloneForNewDocument(parent, parent, exclude));
+                clonedAnnot.put(COSName.PARENT.getName(), new PDFCloner(this.pdfBoxAdapter, true)
+                        .cloneForNewDocument(parent, parent, exclude));
             }
         }
     }
diff --git a/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapter.java b/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapter.java
index fe8727c..a89e7ac 100644
--- a/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapter.java
+++ b/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapter.java
@@ -25,6 +25,7 @@ import java.awt.geom.Rectangle2D;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -34,6 +35,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeMap;
 
 import org.apache.commons.io.IOUtils;
 
@@ -49,6 +51,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
 import org.apache.pdfbox.pdmodel.PDResources;
 import org.apache.pdfbox.pdmodel.common.PDRectangle;
 import org.apache.pdfbox.pdmodel.common.PDStream;
+import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
 import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
 
 import org.apache.fop.events.EventBroadcaster;
@@ -61,6 +64,7 @@ import org.apache.fop.pdf.PDFFormXObject;
 import org.apache.fop.pdf.PDFNumber;
 import org.apache.fop.pdf.PDFObject;
 import org.apache.fop.pdf.PDFPage;
+import org.apache.fop.pdf.PDFReference;
 import org.apache.fop.pdf.PDFRoot;
 import org.apache.fop.pdf.PDFStream;
 
@@ -476,12 +480,129 @@ public class PDFBoxAdapter {
     }
 
     private void bindOptionalContent(PDDocument sourceDoc) throws IOException {
-        /*
-         * PDOptionalContentProperties ocProperties =
-         * sourceDoc.getDocumentCatalog().getOCProperties(); PDFDictionary ocDictionary =
-         * (PDFDictionary) cloneForNewDocument(ocProperties); if (ocDictionary != null) {
-         * this.pdfDoc.getRoot().put(COSName.OCPROPERTIES.getName(), ocDictionary); }
-         */
+        PDOptionalContentProperties ocProperties = sourceDoc.getDocumentCatalog().getOCProperties();
+        PDFDictionary ocDictionary = (PDFDictionary) cloneForNewDocument(ocProperties);
+        if (ocDictionary != null) {
+            this.pdfDoc.getRoot().put(COSName.OCPROPERTIES.getName(), ocDictionary);
+        }
+        COSDictionary catalog = sourceDoc.getDocumentCatalog().getCOSObject();
+        copyToRoot(catalog.getDictionaryObject(COSName.VIEWER_PREFERENCES), COSName.VIEWER_PREFERENCES.getName());
+        copyToRoot(catalog.getDictionaryObject("Requirements"), "Requirements");
+        copyOutlineToRoot(catalog.getDictionaryObject(COSName.NAMES), COSName.NAMES.getName());
+        copyToRoot(catalog.getDictionaryObject(COSName.COLLECTION), COSName.COLLECTION.getName());
+        copyOutlineToRoot(catalog.getItem(COSName.OUTLINES), COSName.OUTLINES.getName());
+    }
+
+    private void copyToRoot(COSBase input, String type) throws IOException {
+        if (input != null) {
+            PDFDictionary output = (PDFDictionary) pdfDoc.getRoot().get(type);
+            PDFDictionary cloned = (PDFDictionary) cloneForNewDocument(input);
+            if (output != null) {
+                copyDict(output, cloned);
+            }
+            pdfDoc.getRoot().put(type, cloned);
+        }
+    }
+
+    private void copyOutlineToRoot(COSBase input, String type) throws IOException {
+        if (input != null) {
+            PDFDictionary output = (PDFDictionary) pdfDoc.getRoot().get(type);
+            PDFDictionary cloned = (PDFDictionary) new PDFCloner(this, true).cloneForNewDocument(input);
+            if (output != null) {
+                copyDict(output, cloned);
+            }
+            movePage(cloned, new ArrayList<>());
+            pdfDoc.getRoot().put(type, cloned);
+        }
+    }
+
+    private void movePage(PDFDictionary cloned, List<Object> visited) {
+        for (String k : cloned.keySet()) {
+            Object obj = cloned.get(k);
+            if (obj instanceof PDFDictionary && !visited.contains(obj)) {
+                visited.add(obj);
+                movePage((PDFDictionary) obj, visited);
+            }
+            if (obj instanceof PDFArray && !visited.contains(obj)) {
+                visited.add(obj);
+                movePage((PDFArray) obj, visited, k);
+            }
+        }
+    }
+
+    private void movePage(PDFArray array, List<Object> visited, String k) {
+        for (int i = 0; i < array.length(); i++) {
+            Object obj = array.get(i);
+            if (obj instanceof PDFDictionary && !visited.contains(obj)) {
+                visited.add(obj);
+                movePage((PDFDictionary) obj, visited);
+            }
+            if (obj instanceof PDFArray && !visited.contains(obj)) {
+                if (("Dest".equals(k) || "Names".equals(k)) && ((PDFArray) obj).get(0) instanceof PDFReference) {
+                    PDFReference ref = (PDFReference) ((PDFArray) obj).get(0);
+                    array.set(i, ref.getObject());
+                }
+                visited.add(obj);
+                movePage((PDFArray) obj, visited, k);
+            }
+        }
+    }
+
+    private void copyDict(PDFDictionary output, PDFDictionary cloned) {
+        for (String k : output.keySet()) {
+            Object existingObj = output.get(k);
+            if (existingObj instanceof PDFDictionary) {
+                PDFDictionary c = (PDFDictionary) cloned.get(k);
+                if (existingObj == c) {
+                    continue;
+                }
+                if (c == null) {
+                    cloned.put(k, existingObj);
+                } else {
+                    copyDict((PDFDictionary) existingObj, c);
+                }
+            } else if (existingObj instanceof PDFArray) {
+                PDFArray existing = (PDFArray) existingObj;
+                PDFArray clonedArray = (PDFArray) cloned.get(k);
+                if (k.equals("Names") && existing.length() > 0 && clonedArray != null) {
+                    Map<String, Object[]> sortData = new TreeMap<>();
+                    readNamesData(sortData, existing);
+                    readNamesData(sortData, clonedArray);
+                    clonedArray.clear();
+                    for (Object[] sortedData : sortData.values()) {
+                        clonedArray.add(sortedData[0]);
+                        clonedArray.add(sortedData[1]);
+                    }
+                } else if (clonedArray != null) {
+                    for (int i = 0; i < existing.length(); i++) {
+                        clonedArray.add(existing.get(i));
+                    }
+                }
+            } else {
+                cloned.put(k, existingObj);
+            }
+        }
+    }
+
+    private void readNamesData(Map<String, Object[]> sortData, PDFArray array) {
+        Object[] data = new Object[2];
+        for (int i = 0; i < array.length(); i++) {
+            Object o = array.get(i);
+            if (o instanceof PDFDictionary) {
+                data[1] = o;
+                Object name = ((PDFDictionary)o).get("F");
+                if (name == null) {
+                    name = data[0];
+                }
+                if (name instanceof byte[]) {
+                    name = new String((byte[])name, StandardCharsets.UTF_8);
+                }
+                sortData.put((String) name, data);
+                data = new Object[2];
+            } else {
+                data[0] = o;
+            }
+        }
     }
 
     private void handleAnnotations(PDDocument sourceDoc, PDPage page, AffineTransform pageAdjust, Rectangle pos)
@@ -496,28 +617,22 @@ public class PDFBoxAdapter {
         PDFBoxAdapterUtil.moveAnnotations(page, pageAnnotations, pageAdjust, pos);
 
         //Pseudo-cache the target page in place of the original source page.
-        //This essentially replaces the original page reference with the target page.
-        COSObject cosPage = null;
-        COSDictionary parentDic = (COSDictionary) page.getCOSObject().getDictionaryObject(COSName.PARENT, COSName.P);
-        COSArray kids = (COSArray) parentDic.getDictionaryObject(COSName.KIDS);
+        //This essentially replaces the original page reference with the target page
+        List kids = PDFBoxAdapterUtil.getKids(sourceDoc);
         for (int i = 0; i < kids.size(); i++) {
             //Hopefully safe to cast, as kids need to be indirect objects
-            COSObject kid = (COSObject) kids.get(i);
-            if (!pageNumbers.containsKey(i)) {
-                PDFArray a = new PDFArray();
-                a.add(null);
-                pdfDoc.assignObjectNumber(a);
-                pdfDoc.addTrailerObject(a);
-                pageNumbers.put(i, a);
-            }
-            cacheClonedObject(kid, pageNumbers.get(i));
-            if (kid.getObject() == page.getCOSObject()) {
-                cosPage = kid;
+            Object kid = kids.get(i);
+            if (getCachedClone(kid) == null) {
+                if (!pageNumbers.containsKey(i)) {
+                    PDFArray a = new PDFArray();
+                    a.add(null);
+                    pdfDoc.assignObjectNumber(a);
+                    pdfDoc.addTrailerObject(a);
+                    pageNumbers.put(i, a);
+                }
+                cacheClonedObject(kid, pageNumbers.get(i));
             }
         }
-        if (cosPage == null) {
-            throw new IOException("Illegal PDF. Page not part of parent page node.");
-        }
 
         Set<?> fields = copyAnnotations(page, srcAcroForm);
         boolean formAlreadyCopied = getCachedClone(srcAcroForm) != null;
diff --git a/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterUtil.java b/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterUtil.java
index 151f23d..9d2fc0b 100644
--- a/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterUtil.java
+++ b/src/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterUtil.java
@@ -23,10 +23,14 @@ import java.awt.geom.AffineTransform;
 import java.awt.geom.Rectangle2D;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.TreeMap;
 
 import org.apache.commons.io.IOUtils;
@@ -36,6 +40,7 @@ import org.apache.pdfbox.cos.COSDictionary;
 import org.apache.pdfbox.cos.COSName;
 import org.apache.pdfbox.cos.COSObject;
 import org.apache.pdfbox.cos.COSStream;
+import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.pdmodel.PDPage;
 import org.apache.pdfbox.pdmodel.common.PDRectangle;
 import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
@@ -229,4 +234,60 @@ public final class PDFBoxAdapterUtil {
                 (float)rectangleT.getWidth(), (float)rectangleT.getHeight());
         return rect;
     }
+
+    public static List getKids(PDDocument sourceDoc) {
+        List kids = new ArrayList();
+        List<PDPage> pages = getAllKids(sourceDoc.getDocumentCatalog().getPages().getCOSObject(), new HashSet<>());
+
+        for (PDPage page : pages) {
+            PDPage parent = getParent(page);
+            COSArray kidsArray = (COSArray) parent.getCOSObject().getDictionaryObject(COSName.KIDS);
+            for (Object kid : kidsArray.toList()) {
+                if (!kids.contains(kid)) {
+                    kids.add(kid);
+                }
+            }
+        }
+
+        return kids;
+    }
+
+    private static List<PDPage> getAllKids(COSDictionary page, Set<COSBase> seen) {
+        if (page == null) {
+            return Collections.emptyList();
+        }
+
+        COSArray kids = (COSArray)page.getDictionaryObject(COSName.KIDS);
+        if (kids == null) {
+            return Collections.emptyList();
+        }
+
+        List<PDPage> result = new ArrayList<>();
+        for (int i = 0; i < kids.size(); ++i) {
+            if (!seen.contains(kids.get(i))) {
+                COSBase obj = kids.getObject(i);
+                if (obj instanceof COSDictionary) {
+                    COSDictionary kid = (COSDictionary)obj;
+                    if (COSName.PAGE.equals(kid.getDictionaryObject(COSName.TYPE))) {
+                        result.add(new PDPage(kid));
+                    } else {
+                        result.addAll(getAllKids(kid, seen));
+                    }
+                }
+
+                seen.add(kids.get(i));
+            }
+        }
+
+        return result;
+    }
+
+    private static PDPage getParent(PDPage page) {
+        COSDictionary parentDic = page.getCOSObject().getCOSDictionary(COSName.PARENT, COSName.P);
+        if (parentDic != null) {
+            return new PDPage(parentDic);
+        }
+
+        return page;
+    }
 }
diff --git a/test/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterTestCase.java b/test/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterTestCase.java
index a79e53a..8a93cc3 100644
--- a/test/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterTestCase.java
+++ b/test/java/org/apache/fop/render/pdf/pdfbox/PDFBoxAdapterTestCase.java
@@ -39,6 +39,7 @@ import org.junit.Test;
 
 import static org.junit.Assert.assertEquals;
 
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Matchers.any;
 import static org.mockito.Mockito.mock;
@@ -101,6 +102,7 @@ public class PDFBoxAdapterTestCase {
     protected static final String SMASK = "smask.pdf";
     protected static final String ACCESSIBLERADIOBUTTONS = "accessibleradiobuttons.pdf";
     protected static final String PATTERN = "pattern.pdf";
+    protected static final String BOOKMARKS = "bookmarks.pdf";
     protected static final String FORMROTATED = "formrotated.pdf";
     protected static final String SOFTMASK = "softmask.pdf";
     protected static final String DCT_ARRAY = "dctArray.pdf";
@@ -201,6 +203,9 @@ public class PDFBoxAdapterTestCase {
             pdfdoc.output(os);
             String out = os.toString(StandardCharsets.UTF_8.name());
             assertTrue(out.contains("/Parent "));
+            assertFalse(out.contains("/Kids "));
+            pdfdoc.outputTrailer(os);
+            out = os.toString(StandardCharsets.UTF_8.name());
             assertTrue(out.contains("/Kids "));
         }
     }
@@ -625,7 +630,7 @@ public class PDFBoxAdapterTestCase {
         loadPage(pdfdoc, FORMROTATED);
         ByteArrayOutputStream bos = new ByteArrayOutputStream();
         pdfdoc.output(bos);
-        Assert.assertFalse(bos.toString(StandardCharsets.UTF_8.name()).contains("/R 90"));
+        assertFalse(bos.toString(StandardCharsets.UTF_8.name()).contains("/R 90"));
     }
 
     private String getAnnotationsID(PDFPage page) throws IOException {
@@ -783,4 +788,14 @@ public class PDFBoxAdapterTestCase {
         assertTrue(output.contains("/ImageMatrix [148 0 0 78 0 0]"));
         assertTrue(output.contains("/ImageMatrix [192 0 0 192 0 0]"));
     }
+
+    @Test
+    public void testBookmarks() throws IOException {
+        PDFDocument pdfdoc = new PDFDocument("");
+        loadPage(pdfdoc, BOOKMARKS);
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        PDFDictionary outlines = (PDFDictionary) pdfdoc.getRoot().get("Outlines");
+        outlines.output(bos);
+        assertEquals("<< /First 1 0 R /Last 1 0 R >>", bos.toString("UTF-8"));
+    }
 }
diff --git a/test/resources/org/apache/fop/render/pdf/pdfbox/bookmarks.pdf b/test/resources/org/apache/fop/render/pdf/pdfbox/bookmarks.pdf
new file mode 100644
index 0000000..775b55e
Binary files /dev/null and b/test/resources/org/apache/fop/render/pdf/pdfbox/bookmarks.pdf differ


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@xmlgraphics.apache.org
For additional commands, e-mail: commits-help@xmlgraphics.apache.org