/*
 * 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.
 */
package org.apache.pdfbox.multipdf;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDDocumentNameDestinationDictionary;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.PDStructureElementNameTreeNode;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.COSObjectable;
import org.apache.pdfbox.pdmodel.common.PDDestinationOrAction;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.common.PDNumberTreeNode;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkInfo;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDParentTreeValue;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.graphics.color.PDOutputIntent;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;

/**
 * This class will take a list of pdf documents and merge them, saving the
 * result in a new document.
 *
 * @author Ben Litchfield
 */
public class PDFMergerUtility
{
    /**
     * Log instance.
     */
    private static final Log LOG = LogFactory.getLog(PDFMergerUtility.class);

    private final List<Object> sources;
    private String destinationFileName;
    private OutputStream destinationStream;
    private boolean ignoreAcroFormErrors = false;
    private PDDocumentInformation destinationDocumentInformation = null;
    private PDMetadata destinationMetadata = null;

    private DocumentMergeMode documentMergeMode = DocumentMergeMode.PDFBOX_LEGACY_MODE;
    private AcroFormMergeMode acroFormMergeMode = AcroFormMergeMode.PDFBOX_LEGACY_MODE;
    
    /**
     * The mode to use when merging documents:
     * 
     * <ul>
     * <li>{@link DocumentMergeMode#OPTIMIZE_RESOURCES_MODE} Optimizes resource handling such as
     *      closing documents early. <strong>Not all document elements are merged</strong> compared to
     *      the PDFBOX_LEGACY_MODE. Currently supported are:
     *      <ul>
     *          <li>Page content and resources
     *      </ul>  
     * <li>{@link DocumentMergeMode#PDFBOX_LEGACY_MODE} Keeps all files open until the
     *      merge has been completed. This is  currently necessary to merge documents
     *      containing a Structure Tree.
     * </ul>
     */
    public enum DocumentMergeMode
    {
        OPTIMIZE_RESOURCES_MODE,
        PDFBOX_LEGACY_MODE
    }
    
    /**
     * The mode to use when merging AcroForm between documents:
     * 
     * <ul>
     * <li>{@link AcroFormMergeMode#JOIN_FORM_FIELDS_MODE} fields with the same fully qualified name
     *      will be merged into one with the widget annotations of the merged fields 
     *      becoming part of the same field.
     * <li>{@link AcroFormMergeMode#PDFBOX_LEGACY_MODE} fields with the same fully qualified name
     *      will be renamed and treated as independent. This mode was used in versions
     *      of PDFBox up to 2.x.
     * </ul>
     */
    public enum AcroFormMergeMode
    {
        JOIN_FORM_FIELDS_MODE,
        PDFBOX_LEGACY_MODE
    }

    /**
     * Instantiate a new PDFMergerUtility.
     */
    public PDFMergerUtility()
    {
        sources = new ArrayList<>();
    }

    /**
     * Get the merge mode to be used for merging AcroForms between documents
     * 
     * {@link AcroFormMergeMode}
     * 
     * @return the current AcroFormMergeMode
     */
    public AcroFormMergeMode getAcroFormMergeMode()
    {
        return acroFormMergeMode;
    }

    /**
     * Set the merge mode to be used for merging AcroForms between documents
     * 
     * {@link AcroFormMergeMode}
     * 
     * @param theAcroFormMergeMode AcroFormMergeMode to be used
     * 
     */
    public void setAcroFormMergeMode(AcroFormMergeMode theAcroFormMergeMode)
    {
        this.acroFormMergeMode = theAcroFormMergeMode;
    }
    
    /**
     * Get the merge mode to be used for merging documents
     * 
     * {@link DocumentMergeMode}
     * 
     * @return the current DocumentMergeMode
     */
    public DocumentMergeMode getDocumentMergeMode()
    {
        return documentMergeMode;
    }

    /**
     * Set the merge mode to be used for merging documents
     * 
     * {@link DocumentMergeMode}
     * 
     * @param theDocumentMergeMode DocumentMergeMode to be used
     */
    public void setDocumentMergeMode(DocumentMergeMode theDocumentMergeMode)
    {
        this.documentMergeMode = theDocumentMergeMode;
    }

    /**
     * Get the name of the destination file.
     *
     * @return Returns the destination.
     */
    public String getDestinationFileName()
    {
        return destinationFileName;
    }

    /**
     * Set the name of the destination file.
     *
     * @param destination The destination to set.
     */
    public void setDestinationFileName(String destination)
    {
        destinationFileName = destination;
    }

    /**
     * Get the destination OutputStream.
     *
     * @return Returns the destination OutputStream.
     */
    public OutputStream getDestinationStream()
    {
        return destinationStream;
    }

    /**
     * Set the destination OutputStream.
     *
     * @param destStream The destination to set.
     */
    public void setDestinationStream(OutputStream destStream)
    {
        destinationStream = destStream;
    }

    /**
     * Get the destination document information that is to be set in
     * {@link #mergeDocuments(org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction) }.
     * The default is null, which means that it is ignored.
     *
     * @return The destination document information.
     */
    public PDDocumentInformation getDestinationDocumentInformation()
    {
        return destinationDocumentInformation;
    }

    /**
     * Set the destination document information that is to be set in
     * {@link #mergeDocuments(org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction) }.
     * The default is null, which means that it is ignored.
     *
     * @param info The destination document information.
     */
    public void setDestinationDocumentInformation(PDDocumentInformation info)
    {
        destinationDocumentInformation = info;
    }

    /**
     * Set the destination metadata that is to be set in
     * {@link #mergeDocuments(org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction) }.
     * The default is null, which means that it is ignored.
     *
     * @return The destination metadata.
     */
    public PDMetadata getDestinationMetadata()
    {
        return destinationMetadata;
    }

    /**
     * Set the destination metadata that is to be set in
     * {@link #mergeDocuments(org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction) }.
     * The default is null, which means that it is ignored.
     *
     * @param meta The destination metadata.
     */
    public void setDestinationMetadata(PDMetadata meta)
    {
        destinationMetadata = meta;
    }

    /**
     * Add a source file to the list of files to merge.
     *
     * @param source Full path and file name of source document.
     * 
     * @throws FileNotFoundException If the file doesn't exist
     */
    public void addSource(String source) throws FileNotFoundException
    {
        addSource(new File(source));
    }

    /**
     * Add a source file to the list of files to merge.
     *
     * @param source File representing source document
     * 
     * @throws FileNotFoundException If the file doesn't exist
     */
    public void addSource(File source) throws FileNotFoundException
    {
        sources.add(source);
    }

    /**
     * Add a source to the list of documents to merge.
     *
     * @param source RandomAccessRead representing source document
     */
    public void addSource(RandomAccessRead source)
    {
        sources.add(source);
    }

    /**
     * Add a list of sources to the list of documents to merge.
     *
     * @param sourcesList List of RandomAccessRead objects representing source documents
     */
    public void addSources(List<RandomAccessRead> sourcesList)
    {
        sources.addAll(sourcesList);
    }

    /**
     * Merge the list of source documents, saving the result in the destination file. The source
     * list is not reset after merge. If you want to merge one document at a time, then it's better
     * to use
     * {@link #appendDocument(org.apache.pdfbox.pdmodel.PDDocument, org.apache.pdfbox.pdmodel.PDDocument)}.
     *
     * @param streamCacheCreateFunction a function to create an instance of a stream cache; in case of <code>null</code>
     * unrestricted main memory is used
     * 
     * @throws IOException If there is an error saving the document.
     */
    public void mergeDocuments(StreamCacheCreateFunction streamCacheCreateFunction) throws IOException
    {
        mergeDocuments(streamCacheCreateFunction, CompressParameters.DEFAULT_COMPRESSION);
    }

    /**
     * Merge the list of source documents, saving the result in the destination file. The source
     * list is not reset after merge. If you want to merge one document at a time, then it's better
     * to use
     * {@link #appendDocument(org.apache.pdfbox.pdmodel.PDDocument, org.apache.pdfbox.pdmodel.PDDocument)}.
     *
     * @param streamCacheCreateFunction a function to create an instance of a stream cache; in case of <code>null</code>
     * unrestricted main memory is used
     * @param compressParameters defines if compressed object streams are enabled
     * 
     * @throws IOException If there is an error saving the document.
     */
    public void mergeDocuments(StreamCacheCreateFunction streamCacheCreateFunction,
            CompressParameters compressParameters) throws IOException
    {
        if (documentMergeMode == DocumentMergeMode.PDFBOX_LEGACY_MODE)
        {
            legacyMergeDocuments(streamCacheCreateFunction, compressParameters);
        }
        else if (documentMergeMode == DocumentMergeMode.OPTIMIZE_RESOURCES_MODE)
        {
            optimizedMergeDocuments(streamCacheCreateFunction, compressParameters);
        }
    }
    
    private void optimizedMergeDocuments(StreamCacheCreateFunction streamCacheCreateFunction,
            CompressParameters compressParameters) throws IOException
    {
        StreamCacheCreateFunction strmCacheFunc = streamCacheCreateFunction != null ? streamCacheCreateFunction
                : IOUtils.createMemoryOnlyStreamCache();
        try (PDDocument destination = new PDDocument(strmCacheFunc))
        {
            PDFCloneUtility cloner = new PDFCloneUtility(destination);
            PDPageTree destinationPageTree = destination.getPages(); // cache PageTree
            for (Object sourceObject : sources)
            {
                PDDocument sourceDoc = null;
                try
                {
                    if (sourceObject instanceof File)
                    {
                        sourceDoc = Loader.loadPDF((File) sourceObject);
                    }
                    else
                    {
                        sourceDoc = Loader.loadPDF((RandomAccessRead) sourceObject);
                    }
                    for (PDPage page : sourceDoc.getPages())
                    {
                        PDPage newPage = new PDPage(cloner.cloneForNewDocument(page.getCOSObject()));
                        newPage.setCropBox(page.getCropBox());
                        newPage.setMediaBox(page.getMediaBox());
                        newPage.setRotation(page.getRotation());
                        PDResources resources = page.getResources();
                        if (resources != null)
                        {
                            // this is smart enough to just create references for resources that are used on multiple
                            // pages
                            newPage.setResources(new PDResources(
                                    cloner.cloneForNewDocument(resources.getCOSObject())));
                        }
                        else
                        {
                            newPage.setResources(new PDResources());
                        }
                        destinationPageTree.add(newPage);
                    }
                }
                finally
                {
                    IOUtils.closeQuietly(sourceDoc);
                }
            }
            
            if (destinationStream == null)
            {
                destination.save(destinationFileName, compressParameters);
            }
            else
            {
                destination.save(destinationStream, compressParameters);
            }
        }
    }
    
    
    /**
     * Merge the list of source documents, saving the result in the destination file.
     *
     * @param streamCacheCreateFunction a function to create an instance of a stream cache; in case of <code>null</code>
     * unrestricted main memory is used
     * 
     * @throws IOException If there is an error saving the document.
     */
    private void legacyMergeDocuments(StreamCacheCreateFunction streamCacheCreateFunction,
            CompressParameters compressParameters) throws IOException
    {
        if (!sources.isEmpty())
        {
            // Make sure that:
            // - first Exception is kept
            // - all PDDocuments are closed
            // - all FileInputStreams are closed
            // - there's a way to see which errors occurred

            List<PDDocument> tobeclosed = new ArrayList<>(sources.size());
            StreamCacheCreateFunction strmCacheFunc = streamCacheCreateFunction != null ? streamCacheCreateFunction
                    : IOUtils.createMemoryOnlyStreamCache();
            try (PDDocument destination = new PDDocument(strmCacheFunc))
            {
                for (Object sourceObject : sources)
                {
                    PDDocument sourceDoc = null;
                    if (sourceObject instanceof File)
                    {
                        sourceDoc = Loader.loadPDF((File) sourceObject);
                    }
                    else
                    {
                        sourceDoc = Loader.loadPDF((RandomAccessRead) sourceObject);
                    }
                    tobeclosed.add(sourceDoc);
                    appendDocument(destination, sourceDoc);
                }
                
                // optionally set meta data
                if (destinationDocumentInformation != null)
                {
                    destination.setDocumentInformation(destinationDocumentInformation);
                }
                if (destinationMetadata != null)
                {
                    destination.getDocumentCatalog().setMetadata(destinationMetadata);
                }
                
                if (destinationStream == null)
                {
                    destination.save(destinationFileName, compressParameters);
                }
                else
                {
                    destination.save(destinationStream, compressParameters);
                }
            }
            finally
            {
                for (PDDocument doc : tobeclosed)
                {
                    IOUtils.closeAndLogException(doc, LOG, "PDDocument", null);
                }
            }
        }
    }

    /**
     * append all pages from source to destination.
     *
     * @param destination the document to receive the pages
     * @param source the document originating the new pages
     *
     * @throws IOException If there is an error accessing data from either
     * document.
     */
    public void appendDocument(PDDocument destination, PDDocument source) throws IOException
    {
        if (source.getDocument().isClosed())
        {
            throw new IOException("Error: source PDF is closed.");
        }
        if (destination.getDocument().isClosed())
        {
            throw new IOException("Error: destination PDF is closed.");
        }

        PDDocumentCatalog srcCatalog = source.getDocumentCatalog();
        if (isDynamicXfa(srcCatalog.getAcroForm()))
        {
            throw new IOException("Error: can't merge source document containing dynamic XFA form content.");
        }

        PDDocumentInformation destInfo = destination.getDocumentInformation();
        PDDocumentInformation srcInfo = source.getDocumentInformation();
        mergeInto(srcInfo.getCOSObject(), destInfo.getCOSObject(), Collections.<COSName>emptySet());

        // use the highest version number for the resulting pdf
        float destVersion = destination.getVersion();
        float srcVersion = source.getVersion();

        if (destVersion < srcVersion)
        {
            destination.setVersion(srcVersion);
        }

        int pageIndexOpenActionDest = -1;
        PDDocumentCatalog destCatalog = destination.getDocumentCatalog();
        if (destCatalog.getOpenAction() == null)
        {
            // PDFBOX-3972: get local dest page index, it must be reassigned after the page cloning
            PDDestinationOrAction openAction = null;
            try
            {
                openAction = srcCatalog.getOpenAction();
            }
            catch (IOException ex)
            {
                // PDFBOX-4223
                LOG.error("Invalid OpenAction ignored", ex);
            }
            PDDestination openActionDestination = null;
            if (openAction instanceof PDActionGoTo)
            {
                openActionDestination = ((PDActionGoTo) openAction).getDestination();
            }
            else if (openAction instanceof PDDestination)
            {
                openActionDestination = (PDDestination) openAction;
            }
            // note that it can also be something else, e.g. PDActionJavaScript, then do nothing

            if (openActionDestination instanceof PDPageDestination)
            {
                PDPage page = ((PDPageDestination) openActionDestination).getPage();
                if (page != null)
                {
                    pageIndexOpenActionDest = srcCatalog.getPages().indexOf(page);
                }
            }

            destCatalog.setOpenAction(openAction);
        }

        PDFCloneUtility cloner = new PDFCloneUtility(destination);

        mergeAcroForm(cloner, destCatalog, srcCatalog);

        COSArray destThreads = destCatalog.getCOSObject().getCOSArray(COSName.THREADS);
        COSArray srcThreads = (COSArray) cloner.cloneForNewDocument(destCatalog.getCOSObject().getDictionaryObject(
                COSName.THREADS));
        if (destThreads == null)
        {
            destCatalog.getCOSObject().setItem(COSName.THREADS, srcThreads);
        }
        else
        {
            destThreads.addAll(srcThreads);
        }

        PDDocumentNameDictionary destNames = destCatalog.getNames();
        PDDocumentNameDictionary srcNames = srcCatalog.getNames();
        if (srcNames != null)
        {
            if (destNames == null)
            {
                destCatalog.getCOSObject().setItem(COSName.NAMES,
                        cloner.cloneForNewDocument(srcNames.getCOSObject()));
            }
            else
            {
                cloner.cloneMerge(srcNames, destNames);
            }
        }
        
        if (destNames != null && destNames.getCOSObject().containsKey(COSName.ID_TREE))
        {
            // found in 001031.pdf from PDFBOX-4417 and doesn't belong there
            destNames.getCOSObject().removeItem(COSName.ID_TREE);
            LOG.warn("Removed /IDTree from /Names dictionary, doesn't belong there");
        }

        PDDocumentNameDestinationDictionary srcDests = srcCatalog.getDests();
        if (srcDests != null)
        {
            PDDocumentNameDestinationDictionary destDests = destCatalog.getDests();
            if (destDests == null)
            {
                destCatalog.getCOSObject().setItem(COSName.DESTS,
                        cloner.cloneForNewDocument(srcDests.getCOSObject()));
            }
            else
            {
                cloner.cloneMerge(srcDests, destDests);
            }
        }

        PDDocumentOutline srcOutline = srcCatalog.getDocumentOutline();
        if (srcOutline != null)
        {
            PDDocumentOutline destOutline = destCatalog.getDocumentOutline();
            if (destOutline == null || destOutline.getFirstChild() == null)
            {
                PDDocumentOutline cloned = new PDDocumentOutline(
                        cloner.cloneForNewDocument(srcOutline.getCOSObject()));
                destCatalog.setDocumentOutline(cloned);
            }
            else
            {
                // search last sibling for dest, because /Last entry is sometimes wrong
                PDOutlineItem destLastOutlineItem = destOutline.getFirstChild();
                while (true)
                {
                    PDOutlineItem outlineItem = destLastOutlineItem.getNextSibling();
                    if (outlineItem == null)
                    {
                        break;
                    }
                    destLastOutlineItem = outlineItem;
                }
                for (PDOutlineItem item : srcOutline.children())
                {
                    // get each child, clone its dictionary, remove siblings info,
                    // append outline item created from there
                    COSDictionary clonedDict = cloner.cloneForNewDocument(item.getCOSObject());
                    clonedDict.removeItem(COSName.PREV);
                    clonedDict.removeItem(COSName.NEXT);
                    PDOutlineItem clonedItem = new PDOutlineItem(clonedDict);
                    destLastOutlineItem.insertSiblingAfter(clonedItem);
                    destLastOutlineItem = destLastOutlineItem.getNextSibling();
                }
            }
        }

        PageMode destPageMode = destCatalog.getPageMode();
        if (destPageMode == null)
        {
            PageMode srcPageMode = srcCatalog.getPageMode();
            destCatalog.setPageMode(srcPageMode);
        }

        COSDictionary srcLabels = srcCatalog.getCOSObject().getCOSDictionary(COSName.PAGE_LABELS);
        if (srcLabels != null)
        {
            int destPageCount = destination.getNumberOfPages();
            COSArray destNums;
            COSDictionary destLabels = destCatalog.getCOSObject().getCOSDictionary(COSName.PAGE_LABELS);
            if (destLabels == null)
            {
                destLabels = new COSDictionary();
                destNums = new COSArray();
                destLabels.setItem(COSName.NUMS, destNums);
                destCatalog.getCOSObject().setItem(COSName.PAGE_LABELS, destLabels);
            }
            else
            {
                destNums = (COSArray) destLabels.getDictionaryObject(COSName.NUMS);
            }
            COSArray srcNums = (COSArray) srcLabels.getDictionaryObject(COSName.NUMS);
            if (srcNums != null)
            {
                int startSize = destNums.size();
                for (int i = 0; i < srcNums.size(); i += 2)
                {
                    COSBase base = srcNums.getObject(i);
                    if (!(base instanceof COSNumber))
                    {
                        LOG.error("page labels ignored, index " + i + " should be a number, but is " + base);
                        // remove what we added
                        while (destNums.size() > startSize)
                        {
                            destNums.remove(startSize);
                        }
                        break;
                    }
                    COSNumber labelIndex = (COSNumber) base;
                    long labelIndexValue = labelIndex.intValue();
                    destNums.add(COSInteger.get(labelIndexValue + destPageCount));
                    destNums.add(cloner.cloneForNewDocument(srcNums.getObject(i + 1)));
                }
            }
        }

        COSStream destMetadata = destCatalog.getCOSObject().getCOSStream(COSName.METADATA);
        COSStream srcMetadata = srcCatalog.getCOSObject().getCOSStream(COSName.METADATA);
        if (destMetadata == null && srcMetadata != null)
        {
            try
            {
                PDStream newStream = new PDStream(destination, srcMetadata.createInputStream(), (COSName) null);                
                mergeInto(srcMetadata, newStream.getCOSObject(),
                        new HashSet<>(Arrays.asList(COSName.FILTER, COSName.LENGTH)));                
                destCatalog.getCOSObject().setItem(COSName.METADATA, newStream);
            }
            catch (IOException ex)
            {
                // PDFBOX-4227 cleartext XMP stream with /Flate 
                LOG.error("Metadata skipped because it could not be read", ex);
            }
        }

        COSDictionary destOCP = destCatalog.getCOSObject().getCOSDictionary(COSName.OCPROPERTIES);
        COSDictionary srcOCP = srcCatalog.getCOSObject().getCOSDictionary(COSName.OCPROPERTIES);
        if (destOCP == null && srcOCP != null)
        {
            destCatalog.getCOSObject().setItem(COSName.OCPROPERTIES, cloner.cloneForNewDocument(srcOCP));
        }
        else if (destOCP != null && srcOCP != null)
        {
            cloner.cloneMerge(srcOCP, destOCP);
        }

        mergeOutputIntents(cloner, srcCatalog, destCatalog);

        // merge logical structure hierarchy
        boolean mergeStructTree = false;
        int destParentTreeNextKey = -1;
        Map<Integer, COSObjectable> srcNumberTreeAsMap = null;
        Map<Integer, COSObjectable> destNumberTreeAsMap = null;
        PDStructureTreeRoot srcStructTree = srcCatalog.getStructureTreeRoot();
        PDStructureTreeRoot destStructTree = destCatalog.getStructureTreeRoot();
        if (destStructTree == null && srcStructTree != null)
        {
            // create a dummy structure tree in the destination, so that the source
            // tree is cloned. (We can't just copy the tree reference due to PDFBOX-3999)
            destStructTree = new PDStructureTreeRoot();
            destCatalog.setStructureTreeRoot(destStructTree);
            destStructTree.setParentTree(new PDNumberTreeNode(PDParentTreeValue.class));
            // PDFBOX-4429: remove bogus StructParent(s)
            for (PDPage page : destCatalog.getPages())
            {
                page.getCOSObject().removeItem(COSName.STRUCT_PARENTS);
                for (PDAnnotation ann : page.getAnnotations())
                {
                    ann.getCOSObject().removeItem(COSName.STRUCT_PARENT);
                }
            }
        }
        if (destStructTree != null)
        {
            PDNumberTreeNode destParentTree = destStructTree.getParentTree();
            destParentTreeNextKey = destStructTree.getParentTreeNextKey();
            if (destParentTree != null)
            {
                destNumberTreeAsMap = getNumberTreeAsMap(destParentTree);
                if (destParentTreeNextKey < 0)
                {
                    if (destNumberTreeAsMap.isEmpty())
                    {
                        destParentTreeNextKey = 0;
                    }
                    else
                    {
                        destParentTreeNextKey = Collections.max(destNumberTreeAsMap.keySet()) + 1;
                    }
                }
                if (destParentTreeNextKey >= 0 && srcStructTree != null)
                {
                    PDNumberTreeNode srcParentTree = srcStructTree.getParentTree();
                    if (srcParentTree != null)
                    {
                        srcNumberTreeAsMap = getNumberTreeAsMap(srcParentTree);
                        if (!srcNumberTreeAsMap.isEmpty())
                        {
                            mergeStructTree = true;
                        }
                    }
                }
            }
        }

        Map<COSDictionary, COSDictionary> objMapping = new HashMap<>();
        int pageIndex = 0;
        PDPageTree destinationPageTree = destination.getPages(); // cache PageTree
        for (PDPage page : srcCatalog.getPages())
        {
            PDPage newPage = new PDPage(cloner.cloneForNewDocument(page.getCOSObject()));
            if (!mergeStructTree)
            {
                // PDFBOX-4429: remove bogus StructParent(s)
                newPage.getCOSObject().removeItem(COSName.STRUCT_PARENTS);
                for (PDAnnotation ann : newPage.getAnnotations())
                {
                    ann.getCOSObject().removeItem(COSName.STRUCT_PARENT);
                }
            }
            newPage.setCropBox(page.getCropBox());
            newPage.setMediaBox(page.getMediaBox());
            newPage.setRotation(page.getRotation());
            PDResources resources = page.getResources();
            if (resources != null)
            {
                // this is smart enough to just create references for resources that are used on multiple pages
                newPage.setResources(new PDResources(
                        cloner.cloneForNewDocument(resources.getCOSObject())));
            }
            else
            {
                newPage.setResources(new PDResources());
            }
            if (mergeStructTree)
            {
                // add the value of the destination ParentTreeNextKey to every source element 
                // StructParent(s) value so that these don't overlap with the existing values
                updateStructParentEntries(newPage, destParentTreeNextKey);
                objMapping.put(page.getCOSObject(), newPage.getCOSObject());
                List<PDAnnotation> oldAnnots = page.getAnnotations();
                List<PDAnnotation> newAnnots = newPage.getAnnotations();
                for (int i = 0; i < oldAnnots.size(); i++)
                {
                    objMapping.put(oldAnnots.get(i).getCOSObject(), newAnnots.get(i).getCOSObject());
                }
                // TODO update mapping for XObjects
            }
            destinationPageTree.add(newPage);
            
            if (pageIndex == pageIndexOpenActionDest)
            {
                // PDFBOX-3972: reassign the page.
                // The openAction is either a PDActionGoTo or a PDPageDestination
                PDDestinationOrAction openAction = destCatalog.getOpenAction();
                PDPageDestination pageDestination;
                if (openAction instanceof PDActionGoTo)
                {
                    pageDestination = (PDPageDestination) ((PDActionGoTo) openAction).getDestination();
                }
                else
                {
                    pageDestination = (PDPageDestination) openAction;
                }
                pageDestination.setPage(newPage);
            }
            ++pageIndex;
        }
        if (mergeStructTree)
        {
            updatePageReferences(cloner, srcNumberTreeAsMap, objMapping);
            int maxSrcKey = -1;
            for (Map.Entry<Integer, COSObjectable> entry : srcNumberTreeAsMap.entrySet())
            {
                int srcKey = entry.getKey();
                maxSrcKey = Math.max(srcKey, maxSrcKey);
                destNumberTreeAsMap.put(destParentTreeNextKey + srcKey,
                        cloner.cloneForNewDocument(entry.getValue().getCOSObject()));
            }
            destParentTreeNextKey += maxSrcKey + 1;
            PDNumberTreeNode newParentTreeNode = new PDNumberTreeNode(PDParentTreeValue.class);

            // Note that all elements are stored flatly. This could become a problem for large files
            // when these are opened in a viewer that uses the tagging information.
            // If this happens, then ​PDNumberTreeNode should be improved with a convenience method that
            // stores the map into a B+Tree, see https://en.wikipedia.org/wiki/B+_tree
            newParentTreeNode.setNumbers(destNumberTreeAsMap);

            destStructTree.setParentTree(newParentTreeNode);
            destStructTree.setParentTreeNextKey(destParentTreeNextKey);

            mergeKEntries(cloner, srcStructTree, destStructTree);
            mergeRoleMap(srcStructTree, destStructTree);
            mergeIDTree(cloner, srcStructTree, destStructTree);
            mergeMarkInfo(destCatalog, srcCatalog);
            mergeLanguage(destCatalog, srcCatalog);
            mergeViewerPreferences(destCatalog, srcCatalog);
        }
    }

    private void mergeViewerPreferences(PDDocumentCatalog destCatalog, PDDocumentCatalog srcCatalog)
    {
        PDViewerPreferences srcViewerPreferences = srcCatalog.getViewerPreferences();
        if (srcViewerPreferences == null)
        {
            return;
        }
        PDViewerPreferences destViewerPreferences = destCatalog.getViewerPreferences();
        if (destViewerPreferences == null)
        {
            destViewerPreferences = new PDViewerPreferences(new COSDictionary());
            destCatalog.setViewerPreferences(destViewerPreferences);
        }
        mergeInto(srcViewerPreferences.getCOSObject(), destViewerPreferences.getCOSObject(),
                  Collections.<COSName>emptySet());

        // check the booleans - set to true if one is set and true
        if (srcViewerPreferences.hideToolbar() || destViewerPreferences.hideToolbar())
        {
            destViewerPreferences.setHideToolbar(true);
        }
        if (srcViewerPreferences.hideMenubar() || destViewerPreferences.hideMenubar())
        {
            destViewerPreferences.setHideMenubar(true);
        }
        if (srcViewerPreferences.hideWindowUI() || destViewerPreferences.hideWindowUI())
        {
            destViewerPreferences.setHideWindowUI(true);
        }
        if (srcViewerPreferences.fitWindow() || destViewerPreferences.fitWindow())
        {
            destViewerPreferences.setFitWindow(true);
        }
        if (srcViewerPreferences.centerWindow() || destViewerPreferences.centerWindow())
        {
            destViewerPreferences.setCenterWindow(true);
        }
        if (srcViewerPreferences.displayDocTitle() || destViewerPreferences.displayDocTitle())
        {
            destViewerPreferences.setDisplayDocTitle(true);
        }
    }

    private void mergeLanguage(PDDocumentCatalog destCatalog, PDDocumentCatalog srcCatalog)
    {
        if (destCatalog.getLanguage() == null)
        {
            String srcLanguage = srcCatalog.getLanguage();
            if (srcLanguage != null)
            {
                destCatalog.setLanguage(srcLanguage);
            }
        }
    }

    private void mergeMarkInfo(PDDocumentCatalog destCatalog, PDDocumentCatalog srcCatalog)
    {
        PDMarkInfo destMark = destCatalog.getMarkInfo();
        PDMarkInfo srcMark = srcCatalog.getMarkInfo();
        if (destMark == null)
        {
            destMark = new PDMarkInfo();
        }
        if (srcMark == null)
        {
            srcMark = new PDMarkInfo();
        }
        destMark.setMarked(true);
        destMark.setSuspect(srcMark.isSuspect() || destMark.isSuspect());
        destMark.setSuspect(srcMark.usesUserProperties() || destMark.usesUserProperties());
        destCatalog.setMarkInfo(destMark);
    }

    private void mergeKEntries(PDFCloneUtility cloner,
                      PDStructureTreeRoot srcStructTree,
                      PDStructureTreeRoot destStructTree) throws IOException
    {
        COSBase srcKEntry = srcStructTree.getK();
        COSArray srcKArray = new COSArray();
        COSBase clonedSrcKEntry = cloner.cloneForNewDocument(srcKEntry);
        if (clonedSrcKEntry instanceof COSArray)
        {
            srcKArray.addAll((COSArray) clonedSrcKEntry);
        }
        else if (clonedSrcKEntry instanceof COSDictionary)
        {
            srcKArray.add(clonedSrcKEntry);
        }

        if (srcKArray.size() == 0)
        {
            return;
        }

        COSArray dstKArray = new COSArray();
        COSBase dstKEntry = destStructTree.getK();
        if (dstKEntry instanceof COSArray)
        {
            dstKArray.addAll((COSArray) dstKEntry);
        }
        else if (dstKEntry instanceof COSDictionary)
        {
            dstKArray.add(dstKEntry);
        }

        if (dstKArray.size() == 1 && dstKArray.getObject(0) instanceof COSDictionary)
        {
            // Only one element in the destination. If it is a /Document and its children
            // are /Document or /Part, then we can insert there
            COSDictionary topKDict = (COSDictionary) dstKArray.getObject(0);
            if (COSName.DOCUMENT.equals(topKDict.getCOSName(COSName.S)))
            {
                COSArray kLevelOneArray = topKDict.getCOSArray(COSName.K);
                if (kLevelOneArray != null)
                {
                    boolean onlyDocuments = hasOnlyDocumentsOrParts(kLevelOneArray);
                    if (onlyDocuments)
                    {
                        // insert src elements at level 1
                        kLevelOneArray.addAll(srcKArray);
                        updateParentEntry(kLevelOneArray, topKDict, COSName.PART);
                        return;
                    }
                }
            }
        }

        if (dstKArray.size() == 0)
        {
            updateParentEntry(srcKArray, destStructTree.getCOSObject(), null);
            destStructTree.setK(srcKArray);
            return;
        }

        // whatever this is, merge this under a new /Document element
        dstKArray.addAll(srcKArray);
        COSDictionary kLevelZeroDict = new COSDictionary();
        // If it is all Document, then make it all Part
        COSName newStructureType = hasOnlyDocumentsOrParts(dstKArray) ? COSName.PART : null;
        updateParentEntry(dstKArray, kLevelZeroDict, newStructureType);
        kLevelZeroDict.setItem(COSName.K, dstKArray);
        kLevelZeroDict.setItem(COSName.P, destStructTree);
        kLevelZeroDict.setItem(COSName.S, COSName.DOCUMENT);
        destStructTree.setK(kLevelZeroDict);
    }

    private boolean hasOnlyDocumentsOrParts(COSArray kLevelOneArray)
    {
        for (int i = 0; i < kLevelOneArray.size(); ++i)
        {
            COSBase base = kLevelOneArray.getObject(i);
            if (!(base instanceof COSDictionary))
            {
                return false;
            }
            COSDictionary dict = (COSDictionary) base;
            COSName sEntry = dict.getCOSName(COSName.S);
            if (!COSName.DOCUMENT.equals(sEntry) && !COSName.PART.equals(sEntry))
            {
                return false;
            }
        }
        return true;
    }

    /**
     * Update the P reference to the new parent dictionary.
     *
     * @param kArray the kids array
     * @param newParent the new parent
     * @param newStructureType the new structure type in /S or null so it doesn't get replaced
     */
    private void updateParentEntry(COSArray kArray, COSDictionary newParent, COSName newStructureType)
    {
        for (int i = 0; i < kArray.size(); i++)
        {
            COSBase subEntry = kArray.getObject(i);
            if (subEntry instanceof COSDictionary)
            {
                COSDictionary dictEntry = (COSDictionary) subEntry;
                dictEntry.setItem(COSName.P, newParent);
                if (newStructureType != null)
                {
                    dictEntry.setItem(COSName.S, newStructureType);
                }
            }
        }
    }

    private void mergeIDTree(PDFCloneUtility cloner,
            PDStructureTreeRoot srcStructTree,
            PDStructureTreeRoot destStructTree) throws IOException
    {
        PDNameTreeNode<PDStructureElement> srcIDTree = srcStructTree.getIDTree();
        if (srcIDTree == null)
        {
            return;
        }
        PDNameTreeNode<PDStructureElement> destIDTree = destStructTree.getIDTree();
        if (destIDTree == null)
        {
            destIDTree = new PDStructureElementNameTreeNode();
        }
        Map<String, PDStructureElement> srcNames = getIDTreeAsMap(srcIDTree);
        Map<String, PDStructureElement> destNames = getIDTreeAsMap(destIDTree);
        for (Map.Entry<String, PDStructureElement> entry : srcNames.entrySet())
        {
            if (destNames.containsKey(entry.getKey()))
            {
                LOG.warn("key " + entry.getKey() + " already exists in destination IDTree");
            }
            else
            {
                destNames.put(entry.getKey(),
                              new PDStructureElement(cloner.cloneForNewDocument(entry.getValue().getCOSObject())));
            }
        }
        destIDTree = new PDStructureElementNameTreeNode();
        destIDTree.setNames(destNames);
        destStructTree.setIDTree(destIDTree);
        // Note that all elements are stored flatly. This could become a problem for large files
        // when these are opened in a viewer that uses the tagging information.
        // If this happens, then PDNameTreeNode should be improved with a convenience method that
        // stores the map into a B+Tree, see https://en.wikipedia.org/wiki/B+_tree
    }

    // PDNameTreeNode.getNames() only brings one level, this is why we need this
    // might be made public at a later time, or integrated into PDNameTreeNode with template.
    static Map<String, PDStructureElement> getIDTreeAsMap(PDNameTreeNode<PDStructureElement> idTree)
            throws IOException
    {
        Map<String, PDStructureElement> names = idTree.getNames();
        if (names == null)
        {
            names = new LinkedHashMap<>();
        }
        else
        {
            // must copy because the map is read only
            names = new LinkedHashMap<>(names);
        }
        List<PDNameTreeNode<PDStructureElement>> kids = idTree.getKids();
        if (kids != null)
        {
            for (PDNameTreeNode<PDStructureElement> kid : kids)
            {
                names.putAll(getIDTreeAsMap(kid));
            }
        }
        return names;
    }

    // PDNumberTreeNode.getNumbers() only brings one level, this is why we need this
    // might be made public at a later time, or integrated into PDNumberTreeNode.
    static Map<Integer, COSObjectable> getNumberTreeAsMap(PDNumberTreeNode tree)
            throws IOException
    {
        Map<Integer, COSObjectable> numbers = tree.getNumbers();
        if (numbers == null)
        {
            numbers = new LinkedHashMap<>();
        }
        else
        {
            // must copy because the map is read only
            numbers = new LinkedHashMap<>(numbers);
        }
        List<PDNumberTreeNode> kids = tree.getKids();
        if (kids != null)
        {
            for (PDNumberTreeNode kid : kids)
            {
                numbers.putAll(getNumberTreeAsMap(kid));
            }
        }
        return numbers;
    }

    private void mergeRoleMap(PDStructureTreeRoot srcStructTree, PDStructureTreeRoot destStructTree)
    {
        COSDictionary srcDict = srcStructTree.getCOSObject().getCOSDictionary(COSName.ROLE_MAP);
        if (srcDict == null)
        {
            return;
        }
        COSDictionary destDict = destStructTree.getCOSObject().getCOSDictionary(COSName.ROLE_MAP);
        if (destDict == null)
        {
            destStructTree.getCOSObject().setItem(COSName.ROLE_MAP, srcDict); // clone not needed
            return;
        }
        for (Map.Entry<COSName, COSBase> entry : srcDict.entrySet())
        {
            COSBase destValue = destDict.getDictionaryObject(entry.getKey());
            if (destValue != null && destValue.equals(entry.getValue()))
            {
                // already exists, but identical
                continue;
            }
            if (destDict.containsKey(entry.getKey()))
            {
                LOG.warn("key " + entry.getKey() + " already exists in destination RoleMap");
            }
            else
            {
                destDict.setItem(entry.getKey(), entry.getValue());
            }
        }
    }

    private void mergeAcroForm(PDFCloneUtility cloner, PDDocumentCatalog destCatalog,
            PDDocumentCatalog srcCatalog ) throws IOException
    {
        try
        {
            PDAcroForm destAcroForm = destCatalog.getAcroForm();
            PDAcroForm srcAcroForm = srcCatalog.getAcroForm();
            
            if (destAcroForm == null && srcAcroForm != null)
            {
                destCatalog.getCOSObject().setItem(COSName.ACRO_FORM,
                        cloner.cloneForNewDocument(srcAcroForm.getCOSObject()));       
                
            }
            else
            {
                if (srcAcroForm != null)
                {
                    if (acroFormMergeMode == AcroFormMergeMode.PDFBOX_LEGACY_MODE)
                    {
                        acroFormLegacyMode(cloner, destAcroForm, srcAcroForm);
                    }
                    else if (acroFormMergeMode == AcroFormMergeMode.JOIN_FORM_FIELDS_MODE)
                    {
                        acroFormJoinFieldsMode(cloner, destAcroForm, srcAcroForm);
                    }
                }
            }
        }
        catch (IOException e)
        {
            // if we are not ignoring exceptions, we'll re-throw this
            if (!ignoreAcroFormErrors)
            {
                throw new IOException(e);
            }
        }
    }
    
    /*
     * Merge the contents of the source form into the destination form for the
     * destination file.
     *
     * @param cloner the object cloner for the destination document
     * @param destAcroForm the destination form
     * @param srcAcroForm the source form
     * @throws IOException If an error occurs while adding the field.
     */
    private void acroFormJoinFieldsMode(PDFCloneUtility cloner, PDAcroForm destAcroForm, PDAcroForm srcAcroForm)
            throws IOException
    {
        acroFormLegacyMode(cloner, destAcroForm, srcAcroForm);
    }
    
    /*
     * Merge the contents of the source form into the destination form for the
     * destination file.
     *
     * @param cloner the object cloner for the destination document
     * @param destAcroForm the destination form
     * @param srcAcroForm the source form
     * @throws IOException If an error occurs while adding the field.
     */
    private void acroFormLegacyMode(PDFCloneUtility cloner, PDAcroForm destAcroForm, PDAcroForm srcAcroForm)
            throws IOException
    {
        List<PDField> srcFields = srcAcroForm.getFields();
        COSArray destFields;

        if (!srcFields.isEmpty())
        {
            // if a form is merged multiple times using PDFBox the newly generated
            // fields starting with dummyFieldName may already exist. We need to determine the last unique 
            // number used and increment that.
            final String prefix = "dummyFieldName";
            final int prefixLength = prefix.length();

            for (PDField destField : destAcroForm.getFieldTree())
            {
                String fieldName = destField.getPartialName();
                if (fieldName != null && fieldName.startsWith(prefix))
                {
                    String suffix = fieldName.substring(prefixLength);
                    if (suffix.matches("\\d+"))
                    {
                        nextFieldNum = Math.max(nextFieldNum, Integer.parseInt(suffix) + 1);
                    }
                }
            }

            // get the destinations root fields. Could be that the entry doesn't exist
            // or is of wrong type
            COSBase base = destAcroForm.getCOSObject().getItem(COSName.FIELDS);
            if (base instanceof COSArray)
            {
                destFields = (COSArray) base;
            }
            else
            {
                destFields = new COSArray();
            }
            
            for (PDField srcField : srcAcroForm.getFields())
            {
                COSDictionary dstField = cloner.cloneForNewDocument(srcField.getCOSObject());
                // if the form already has a field with this name then we need to rename this field
                // to prevent merge conflicts.
                if (destAcroForm.getField(srcField.getFullyQualifiedName()) != null)
                {
                    dstField.setString(COSName.T, prefix + nextFieldNum++);
                }
                destFields.add(dstField);
            }
            destAcroForm.getCOSObject().setItem(COSName.FIELDS,destFields);
        }
    }


    // copy outputIntents to destination, but avoid duplicate OutputConditionIdentifier,
    // except when it is missing or is named "Custom".
    private void mergeOutputIntents(PDFCloneUtility cloner, 
            PDDocumentCatalog srcCatalog, PDDocumentCatalog destCatalog) throws IOException
    {
        List<PDOutputIntent> srcOutputIntents = srcCatalog.getOutputIntents();
        List<PDOutputIntent> dstOutputIntents = destCatalog.getOutputIntents();
        for (PDOutputIntent srcOI : srcOutputIntents)
        {
            String srcOCI = srcOI.getOutputConditionIdentifier();
            if (srcOCI != null && !"Custom".equals(srcOCI))
            {
                // is that identifier already there?
                boolean skip = false;
                for (PDOutputIntent dstOI : dstOutputIntents)
                {
                    if (dstOI.getOutputConditionIdentifier().equals(srcOCI))
                    {
                        skip = true;
                        break;
                    }
                }
                if (skip)
                {
                    continue;
                }
            }
            destCatalog.addOutputIntent(new PDOutputIntent(
                    cloner.cloneForNewDocument(srcOI.getCOSObject())));
            dstOutputIntents.add(srcOI);
        }
    }

    private int nextFieldNum = 1;


    /**
     * Indicates if acroform errors are ignored or not.
     *
     * @return true if acroform errors are ignored
     */
    public boolean isIgnoreAcroFormErrors()
    {
        return ignoreAcroFormErrors;
    }

    /**
     * Set to true to ignore acroform errors.
     *
     * @param ignoreAcroFormErrorsValue true if acroform errors should be
     * ignored
     */
    public void setIgnoreAcroFormErrors(boolean ignoreAcroFormErrorsValue)
    {
        ignoreAcroFormErrors = ignoreAcroFormErrorsValue;
    }

    /**
     * Update the Pg and Obj references to the new (merged) page.
     */
    private void updatePageReferences(PDFCloneUtility cloner,
            Map<Integer, COSObjectable> numberTreeAsMap,
            Map<COSDictionary, COSDictionary> objMapping) throws IOException
    {
        for (COSObjectable obj : numberTreeAsMap.values())
        {
            if (obj == null)
            {
                continue;
            }
            PDParentTreeValue val = (PDParentTreeValue) obj;
            COSBase base = val.getCOSObject();
            if (base instanceof COSArray)
            {
                updatePageReferences(cloner, (COSArray) base, objMapping);
            }
            else
            {
                updatePageReferences(cloner, (COSDictionary) base, objMapping);
            }
        }
    }

    /**
     * Update the Pg and Obj references to the new (merged) page.
     *
     * @param parentTreeEntry
     * @param objMapping mapping between old and new references
     */
    private void updatePageReferences(PDFCloneUtility cloner,
            COSDictionary parentTreeEntry, Map<COSDictionary, COSDictionary> objMapping)
            throws IOException
    {
        COSDictionary pageDict = parentTreeEntry.getCOSDictionary(COSName.PG);
        if (objMapping.containsKey(pageDict))
        {
            parentTreeEntry.setItem(COSName.PG, objMapping.get(pageDict));
        }
        COSDictionary objDict = parentTreeEntry.getCOSDictionary(COSName.OBJ);
        if (objDict != null)
        {
            if (objMapping.containsKey(objDict))
            {
                parentTreeEntry.setItem(COSName.OBJ, objMapping.get(objDict));
            }
            else
            {
                // PDFBOX-3999: clone objects that are not in mapping to make sure that
                // these don't remain attached to the source document
                COSBase item = parentTreeEntry.getItem(COSName.OBJ);
                if (item instanceof COSObject)
                {
                    LOG.debug("clone potential orphan object in structure tree: " + item +
                            ", Type: " + objDict.getNameAsString(COSName.TYPE) +
                            ", Subtype: " + objDict.getNameAsString(COSName.SUBTYPE) +
                            ", T: " + objDict.getNameAsString(COSName.T));
                }
                else
                {
                    // don't display in full because of stack overflow
                    LOG.debug("clone potential orphan object in structure tree" +
                            ", Type: " + objDict.getNameAsString(COSName.TYPE) +
                            ", Subtype: " + objDict.getNameAsString(COSName.SUBTYPE) +
                            ", T: " + objDict.getNameAsString(COSName.T));
                }
                parentTreeEntry.setItem(COSName.OBJ, cloner.cloneForNewDocument(objDict));
            }
        }
        COSBase kSubEntry = parentTreeEntry.getDictionaryObject(COSName.K);
        if (kSubEntry instanceof COSArray)
        {
            updatePageReferences(cloner, (COSArray) kSubEntry, objMapping);
        }
        else if (kSubEntry instanceof COSDictionary)
        {
            updatePageReferences(cloner, (COSDictionary) kSubEntry, objMapping);
        }
    }

    private void updatePageReferences(PDFCloneUtility cloner,
            COSArray parentTreeEntry, Map<COSDictionary, COSDictionary> objMapping)
            throws IOException
    {
        for (int i = 0; i < parentTreeEntry.size(); i++)
        {
            COSBase subEntry = parentTreeEntry.getObject(i);
            if (subEntry instanceof COSArray)
            {
                updatePageReferences(cloner, (COSArray) subEntry, objMapping);
            }
            else if (subEntry instanceof COSDictionary)
            {
                updatePageReferences(cloner, (COSDictionary) subEntry, objMapping);
            }
        }
    }

    /**
     * Update the StructParents and StructParent values in a PDPage.
     *
     * @param page the new page
     * @param structParentOffset the offset which should be applied
     */
    private void updateStructParentEntries(PDPage page, int structParentOffset) throws IOException
    {
        int structParents = page.getStructParents();
        if (structParents >= 0)
        {
            page.setStructParents(structParents + structParentOffset);
        }
        List<PDAnnotation> annots = page.getAnnotations();
        List<PDAnnotation> newannots = new ArrayList<>(annots.size());
        annots.forEach(annot ->
        {
            int structParent = annot.getStructParent();
            if (structParent >= 0)
            {
                annot.setStructParent(structParent + structParentOffset);
            }
            newannots.add(annot);
        });
        page.setAnnotations(newannots);
    }
    
    /**
     * Test for dynamic XFA content.
     * 
     * @param acroForm the AcroForm
     * @return true if there is a dynamic XFA form.
     */
    private boolean isDynamicXfa(PDAcroForm acroForm)
    {
        return acroForm != null && acroForm.xfaIsDynamic();
    }

    /**
     * This will add all of the dictionaries keys/values to this dictionary, but
     * only if they are not in an exclusion list and if they don't already
     * exist. If a key already exists in this dictionary then nothing is
     * changed.
     *
     * @param src The source dictionary to get the keys/values from.
     * @param dst The destination dictionary to merge the keys/values into.
     * @param exclude Names of keys that shall be skipped.
     */
    private void mergeInto(COSDictionary src, COSDictionary dst, Set<COSName> exclude)
    {
        for (Map.Entry<COSName, COSBase> entry : src.entrySet())
        {
            if (!exclude.contains(entry.getKey()) && !dst.containsKey(entry.getKey()))
            {
                dst.setItem(entry.getKey(), entry.getValue());
            }
        }
    }
}
