StructureTreeCopier.java

/*
    This file is part of the iText (R) project.
    Copyright (c) 1998-2025 Apryse Group NV
    Authors: Apryse Software.

    This program is offered under a commercial and under the AGPL license.
    For commercial licensing, contact us at https://itextpdf.com/sales.  For AGPL licensing, see below.

    AGPL licensing:
    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
package com.itextpdf.kernel.pdf.tagging;

import com.itextpdf.io.logs.IoLogMessageConstant;
import com.itextpdf.kernel.exceptions.PdfException;
import com.itextpdf.kernel.exceptions.KernelExceptionMessageConstant;
import com.itextpdf.kernel.pdf.PdfArray;
import com.itextpdf.kernel.pdf.PdfDictionary;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfName;
import com.itextpdf.kernel.pdf.PdfNumber;
import com.itextpdf.kernel.pdf.PdfObject;
import com.itextpdf.kernel.pdf.PdfPage;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.itextpdf.kernel.pdf.tagutils.TagTreeIterator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Internal helper class which is used to copy, clone or move tag structure across documents.
 */
class StructureTreeCopier {

    private static List<PdfName> ignoreKeysForCopy = new ArrayList<PdfName>();

    private static List<PdfName> ignoreKeysForClone = new ArrayList<PdfName>();

    static {
        ignoreKeysForCopy.add(PdfName.K);
        ignoreKeysForCopy.add(PdfName.P);
        ignoreKeysForCopy.add(PdfName.Pg);
        ignoreKeysForCopy.add(PdfName.Obj);
        ignoreKeysForCopy.add(PdfName.NS);

        ignoreKeysForClone.add(PdfName.K);
        ignoreKeysForClone.add(PdfName.P);
    }

    /**
     * Copies structure to a {@code destDocument}.
     * <br/><br/>
     * NOTE: Works only for {@code PdfStructTreeRoot} that is read from the document opened in reading mode,
     * otherwise an exception is thrown.
     *
     * @param destDocument document to copy structure to. Shall not be current document.
     * @param page2page  association between original page and copied page.
     */
    public static void copyTo(PdfDocument destDocument, Map<PdfPage, PdfPage> page2page, PdfDocument callingDocument) {
        if (!destDocument.isTagged())
            return;

        copyTo(destDocument, page2page, callingDocument, false);
    }

    /**
     * Copies structure to a {@code destDocument} and insert it in a specified position in the document.
     * <br/><br/>
     * NOTE: Works only for {@code PdfStructTreeRoot} that is read from the document opened in reading mode,
     * otherwise an exception is thrown.
     * <br/>
     * Also, to insert a tagged page into existing tag structure, existing tag structure shouldn't be flushed, otherwise
     * an exception may be raised.
     *
     * @param destDocument       document to copy structure to.
     * @param insertBeforePage indicates where the structure to be inserted.
     * @param page2page        association between original page and copied page.
     */
    public static void copyTo(PdfDocument destDocument, int insertBeforePage, Map<PdfPage, PdfPage> page2page, PdfDocument callingDocument) {
        if (!destDocument.isTagged())
            return;

        copyTo(destDocument, insertBeforePage, page2page, callingDocument, false);
    }

    /**
     * Move tag structure of page to other place in the same document
     *
     * @param document document in which modifications will take place (should be opened in read-write mode)
     * @param from page, which tag structure will be moved
     * @param insertBefore indicates before what page number structure will be inserted to
     */
    public static void move(PdfDocument document, PdfPage from, int insertBefore) {
        if (!document.isTagged() || insertBefore < 1 || insertBefore > document.getNumberOfPages() + 1 )
            return;

        int fromNum = document.getPageNumber(from);
        if (fromNum == 0 || fromNum == insertBefore || fromNum + 1 == insertBefore)
            return;

        int destStruct;
        int currStruct = 0;
        if (fromNum > insertBefore) {
            destStruct = currStruct = separateStructure(document, 1, insertBefore, 0);
            currStruct = separateStructure(document, insertBefore, fromNum, currStruct);
            currStruct = separateStructure(document, fromNum, fromNum + 1, currStruct);
        } else {
            currStruct = separateStructure(document, 1, fromNum, 0);
            currStruct = separateStructure(document, fromNum, fromNum + 1, currStruct);
            destStruct = currStruct = separateStructure(document, fromNum + 1, insertBefore, currStruct);
        }

        Set<PdfDictionary> topsToMove = new HashSet<>();
        Collection<PdfMcr> mcrs = document.getStructTreeRoot().getPageMarkedContentReferences(from);
        if (mcrs != null) {
            for (PdfMcr mcr : mcrs) {
                PdfDictionary top = getTopmostParent(mcr);
                if (top != null) {
                    if (top.isFlushed()) {
                        throw new PdfException(KernelExceptionMessageConstant.CANNOT_MOVE_FLUSHED_TAG);
                    }
                    topsToMove.add(top);
                }
            }
        }

        List<PdfDictionary> orderedTopsToMove = new ArrayList<>();
        PdfArray tops = document.getStructTreeRoot().getKidsObject();
        for (int i = 0; i < tops.size(); ++i) {
            PdfDictionary top = tops.getAsDictionary(i);
            if (topsToMove.contains(top)) {
                orderedTopsToMove.add(top);
                tops.remove(i);
                if (i < destStruct) {
                    --destStruct;
                }
            }
        }
        for (PdfDictionary top : orderedTopsToMove) {
            document.getStructTreeRoot().addKidObject(destStruct++, top);
        }
    }

    /**
     * @return structure tree index of first separated (cloned) top
     */
    private static int separateStructure(PdfDocument document, int beforePage) {
        return separateStructure(document, 1, beforePage, 0);
    }

    private static int separateStructure(PdfDocument document, int startPage, int beforePage, int startPageStructTopIndex) {
        if (!document.isTagged() || 1 > startPage || startPage > beforePage || beforePage > document.getNumberOfPages() + 1) {
            return -1;
        } else if (beforePage == startPage) {
            return startPageStructTopIndex;
        } else if(beforePage == document.getNumberOfPages() + 1) {
            return document.getStructTreeRoot().getKidsObject().size();
        }
        // Here we separate the structure tree in two parts: struct elems that belong to the pages which indexes are
        // less then separateBeforePage and those struct elems that belong to other pages. Some elems might belong
        // to both parts and actually these are the ones that we are looking for.
        Set<PdfObject> firstPartElems = new HashSet<>();
        for (int i = startPage; i < beforePage; ++i) {
            PdfPage pageOfFirstHalf = document.getPage(i);
            Collection<PdfMcr> pageMcrs = document.getStructTreeRoot().getPageMarkedContentReferences(pageOfFirstHalf);
            if (pageMcrs != null) {
                for (PdfMcr mcr : pageMcrs) {
                    firstPartElems.add(mcr.getPdfObject());
                    PdfDictionary top = addAllParentsToSet(mcr, firstPartElems);
                    if (top != null && top.isFlushed()) {
                        throw new PdfException(
                                KernelExceptionMessageConstant.TAG_FROM_THE_EXISTING_TAG_STRUCTURE_IS_FLUSHED_CANNOT_ADD_COPIED_PAGE_TAGS);
                    }
                }
            }
        }

        List<PdfDictionary> clonedTops = new ArrayList<>();
        PdfArray tops = document.getStructTreeRoot().getKidsObject();

        // Now we "walk" through all the elems which belong to the first part, and look for the ones that contain both
        // kids from first and second part. We clone found elements and move kids from the second part to cloned elems.
        int lastTopBefore = startPageStructTopIndex - 1;
        for (int i = 0; i < tops.size(); ++i) {
            PdfDictionary top = tops.getAsDictionary(i);
            if (firstPartElems.contains(top)) {
                lastTopBefore = i;

                LastClonedAncestor lastCloned = new LastClonedAncestor();
                lastCloned.ancestor = top;
                PdfDictionary topClone = top.clone(ignoreKeysForClone);
                topClone.put(PdfName.P, document.getStructTreeRoot().getPdfObject());
                lastCloned.clone = topClone;

                separateKids(top, firstPartElems, lastCloned, document);

                if (topClone.containsKey(PdfName.K)) {
                    topClone.makeIndirect(document);
                    clonedTops.add(topClone);
                }
            }
        }

        for (int i = 0; i < clonedTops.size(); ++i) {
            document.getStructTreeRoot().addKidObject(lastTopBefore + 1 + i, clonedTops.get(i));
        }
        return lastTopBefore + 1;
    }

    private static void copyTo(PdfDocument destDocument, int insertBeforePage, Map<PdfPage, PdfPage> page2page, PdfDocument callingDocument, boolean copyFromDestDocument) {
        if (!destDocument.isTagged())
            return;

        int insertIndex = separateStructure(destDocument, insertBeforePage);
        //Opposite should never happened.
        if (insertIndex > 0) {
            copyTo(destDocument, page2page, callingDocument, copyFromDestDocument, insertIndex);
        }
    }

    /**
     * Copies structure to a {@code destDocument}.
     *
     * @param destDocument document to cpt structure to.
     * @param page2page  association between original page and copied page.
     * @param copyFromDestDocument indicates if <code>page2page</code> keys and values represent pages from {@code destDocument}.
     */
    private static void copyTo(PdfDocument destDocument, Map<PdfPage, PdfPage> page2page, PdfDocument callingDocument
            , boolean copyFromDestDocument) {
        copyTo(destDocument, page2page, callingDocument, copyFromDestDocument, -1);
    }

    private static void copyTo(PdfDocument destDocument, Map<PdfPage, PdfPage> page2page, PdfDocument callingDocument
            , boolean copyFromDestDocument, int insertIndex) {
        CopyStructureResult copiedStructure = copyStructure(destDocument, page2page, callingDocument, copyFromDestDocument);
        PdfStructTreeRoot destStructTreeRoot = destDocument.getStructTreeRoot();
        destStructTreeRoot.makeIndirect(destDocument);
        for (PdfDictionary copied : copiedStructure.getTopsList()) {
            destStructTreeRoot.addKidObject(insertIndex, copied);
            if (insertIndex > -1) {
                ++insertIndex;
            }
        }

        if (!copyFromDestDocument) {
            if (!copiedStructure.getCopiedNamespaces().isEmpty()) {
                destStructTreeRoot.getNamespacesObject().addAll(copiedStructure.getCopiedNamespaces());
            }

            PdfDictionary srcRoleMap = callingDocument.getStructTreeRoot().getRoleMap();
            PdfDictionary destRoleMap =  destStructTreeRoot.getRoleMap();
            for (Map.Entry<PdfName, PdfObject> mappingEntry: srcRoleMap.entrySet()) {
                if (!destRoleMap.containsKey(mappingEntry.getKey())) {
                    destRoleMap.put(mappingEntry.getKey(), mappingEntry.getValue());

                } else if (!mappingEntry.getValue().equals(destRoleMap.get(mappingEntry.getKey()))) {
                    String srcMapping = mappingEntry.getKey() + " -> " + mappingEntry.getValue();
                    String destMapping = mappingEntry.getKey() + " -> " + destRoleMap.get(mappingEntry.getKey());

                    Logger logger = LoggerFactory.getLogger(StructureTreeCopier.class);
                    logger.warn(MessageFormat.format(
                            IoLogMessageConstant.ROLE_MAPPING_FROM_SOURCE_IS_NOT_COPIED_ALREADY_EXIST, srcMapping,
                            destMapping));
                }
            }
        }
    }

    private static CopyStructureResult copyStructure(PdfDocument destDocument, Map<PdfPage, PdfPage> page2page
            , PdfDocument callingDocument, boolean copyFromDestDocument) {
        PdfDocument fromDocument = copyFromDestDocument ? destDocument : callingDocument;
        Map<PdfDictionary, PdfDictionary> topsToFirstDestPage = new HashMap<>();
        Set<PdfObject> objectsToCopy = new HashSet<>();
        Map<PdfDictionary, PdfDictionary> page2pageDictionaries = new HashMap<>();
        for (Map.Entry<PdfPage, PdfPage> page : page2page.entrySet()) {
            page2pageDictionaries.put(page.getKey().getPdfObject(), page.getValue().getPdfObject());
            Collection<PdfMcr> mcrs = fromDocument.getStructTreeRoot().getPageMarkedContentReferences(page.getKey());
            if (mcrs != null) {
                for (PdfMcr mcr : mcrs) {
                    if (mcr instanceof PdfMcrDictionary || mcr instanceof PdfObjRef) {
                        objectsToCopy.add(mcr.getPdfObject());
                    }
                    PdfDictionary top = addAllParentsToSet(mcr, objectsToCopy);
                    if (top != null) {
                        if (top.isFlushed()) {
                            throw new PdfException(KernelExceptionMessageConstant.CANNOT_COPY_FLUSHED_TAG);
                        }
                        if (!topsToFirstDestPage.containsKey(top)) {
                            topsToFirstDestPage.put(top, page.getValue().getPdfObject());
                        }
                    }
                }
            }
        }

        List<PdfDictionary> topsInOriginalOrder = new ArrayList<>();
        for (IStructureNode kid : fromDocument.getStructTreeRoot().getKids()) {
            if (kid == null)  continue;

            PdfDictionary kidObject = ((PdfStructElem) kid).getPdfObject();
            if (topsToFirstDestPage.containsKey(kidObject)) {
                topsInOriginalOrder.add(kidObject);
            }
        }
        StructElemCopyingParams structElemCopyingParams = new StructElemCopyingParams(objectsToCopy, destDocument, page2pageDictionaries, copyFromDestDocument);
        PdfStructTreeRoot destStructTreeRoot = destDocument.getStructTreeRoot();
        destStructTreeRoot.makeIndirect(destDocument);
        List<PdfDictionary> copiedTops = new ArrayList<>();
        for (PdfDictionary top : topsInOriginalOrder) {
            PdfDictionary copied = copyObject(top, topsToFirstDestPage.get(top), false, structElemCopyingParams);
            copiedTops.add(copied);
        }
        return new CopyStructureResult(copiedTops, structElemCopyingParams.getCopiedNamespaces());
    }

    private static PdfDictionary copyObject(PdfDictionary source, PdfDictionary destPage, boolean parentChangePg, StructElemCopyingParams copyingParams) {
        PdfDictionary copied;
        if (copyingParams.isCopyFromDestDocument()) {
            copied = source.clone(ignoreKeysForClone);
            if (source.isIndirect()) {
                copied.makeIndirect(copyingParams.getToDocument());
            }

            PdfDictionary pg = source.getAsDictionary(PdfName.Pg);
            if (pg != null) {
                if (copyingParams.isCopyFromDestDocument()) {
                    if (pg != destPage) {
                        copied.put(PdfName.Pg, destPage);
                        parentChangePg = true;
                    } else {
                        parentChangePg = false;
                    }
                }
            }
        } else {
            copied = source.copyTo(copyingParams.getToDocument(), ignoreKeysForCopy, true);

            PdfObject obj = source.get(PdfName.Obj);
            if (obj instanceof PdfDictionary) {
                PdfDictionary objDic = (PdfDictionary) obj;
                // Link annotations could be not added to the toDocument, so we need to identify this case.
                // When obj.copyTo is called, and annotation was already copied, we would get this already created copy.
                // If it was already copied and added, /P key would be set. Otherwise /P won't be set.
                objDic = objDic.copyTo(copyingParams.getToDocument(), Arrays.asList(PdfName.P), false);
                copied.put(PdfName.Obj, objDic);
            }

            PdfDictionary nsDict = source.getAsDictionary(PdfName.NS);
            if (nsDict != null) {
                PdfDictionary copiedNsDict = copyNamespaceDict(nsDict, copyingParams);
                copied.put(PdfName.NS, copiedNsDict);
            }

            PdfDictionary pg = source.getAsDictionary(PdfName.Pg);
            if (pg != null) {
                PdfDictionary pageAnalog = copyingParams.getPage2page().get(pg);
                if (pageAnalog == null) {
                    pageAnalog = destPage;
                    parentChangePg = true;
                } else {
                    parentChangePg = false;
                }
                copied.put(PdfName.Pg, pageAnalog);
            }
        }

        PdfObject k = source.get(PdfName.K);
        PdfDictionary lastCopiedTrPage = null;
        if (k != null) {
            if (k.isArray()) {
                PdfArray kArr = (PdfArray) k;
                PdfArray newArr = new PdfArray();
                for (int i = 0; i < kArr.size(); i++) {
                    PdfObject copiedKid = copyObjectKid(kArr.get(i), copied, destPage, parentChangePg, copyingParams
                            , lastCopiedTrPage);
                    if (copiedKid != null) {
                        newArr.add(copiedKid);
                        if (copiedKid instanceof PdfDictionary
                                && PdfName.TR.equals(((PdfDictionary) copiedKid).getAsName(PdfName.S))) {
                            lastCopiedTrPage = destPage;
                        }
                    }
                }

                if (!newArr.isEmpty()) {
                    if (newArr.size() == 1) {
                        copied.put(PdfName.K, newArr.get(0));
                    } else {
                        copied.put(PdfName.K, newArr);
                    }
                }
            } else {
                PdfObject copiedKid = copyObjectKid(k, copied, destPage, parentChangePg, copyingParams
                        , lastCopiedTrPage);
                if (copiedKid != null) {
                    copied.put(PdfName.K, copiedKid);
                }
            }
        }
        return copied;
    }

    private static PdfObject copyObjectKid(PdfObject kid, PdfDictionary copiedParent, PdfDictionary destPage,
                                           boolean parentChangePg, StructElemCopyingParams copyingParams,
                                           PdfDictionary lastCopiedTrPage) {
        if (kid.isNumber()) {
            if (!parentChangePg) {
                copyingParams.getToDocument().getStructTreeRoot().getParentTreeHandler()
                        .registerMcr(new PdfMcrNumber((PdfNumber) kid, new PdfStructElem(copiedParent)));
                return kid;
            }
        } else if (kid.isDictionary()) {
            PdfDictionary kidAsDict = (PdfDictionary) kid;
            //if element is TD and its parent is TR which was copied, then we copy it in any case
            if (copyingParams.getObjectsToCopy().contains(kidAsDict) ||
                    shouldTableElementBeCopied(kidAsDict, copiedParent)) {
                //if TR element is not connected to any page,
                //it should be copied to the same page as the last copied TR which connected to page
                PdfDictionary destination = destPage;
                if (PdfName.TR.equals(kidAsDict.getAsName(PdfName.S))
                        && !copyingParams.getObjectsToCopy().contains(kidAsDict)) {
                    if (McrCheckUtil.isTrContainsMcr(kidAsDict)){
                        return null;
                    }

                    if (lastCopiedTrPage == null) {
                        return null;
                    } else {
                        destination = lastCopiedTrPage;
                    }
                }
                boolean hasParent = kidAsDict.containsKey(PdfName.P);
                PdfDictionary copiedKid = copyObject(kidAsDict, destination, parentChangePg, copyingParams);

                if (hasParent) {
                    copiedKid.put(PdfName.P, copiedParent);
                } else {
                    PdfMcr mcr;
                    if (copiedKid.containsKey(PdfName.Obj)) {
                        mcr = new PdfObjRef(copiedKid, new PdfStructElem(copiedParent));
                        PdfDictionary contentItemObject = (PdfDictionary) copiedKid.get(PdfName.Obj);
                        if (PdfName.Link.equals(contentItemObject.getAsName(PdfName.Subtype))
                                && !contentItemObject.containsKey(PdfName.P)) {
                            // Some link annotations may be not copied, because their destination page is not copied.
                            return null;
                        }
                        contentItemObject.put(PdfName.StructParent, new PdfNumber((int) copyingParams.getToDocument().getNextStructParentIndex()));
                    } else {
                        mcr = new PdfMcrDictionary(copiedKid, new PdfStructElem(copiedParent));
                    }
                    copyingParams.getToDocument().getStructTreeRoot().getParentTreeHandler().registerMcr(mcr);
                }
                return copiedKid;
            }
        }
        return null;
    }

    static boolean shouldTableElementBeCopied(PdfDictionary obj, PdfDictionary parent) {
        PdfName role = obj.getAsName(PdfName.S);
        return ((PdfName.TD.equals(role) || PdfName.TH.equals(role)) && PdfName.TR.equals(parent.get(PdfName.S)))
                || PdfName.TR.equals(role);
    }

    private static PdfDictionary copyNamespaceDict(PdfDictionary srcNsDict, StructElemCopyingParams copyingParams) {
        List<PdfName> excludeKeys = Collections.<PdfName>singletonList(PdfName.RoleMapNS);
        PdfDocument toDocument = copyingParams.getToDocument();
        PdfDictionary copiedNsDict = srcNsDict.copyTo(toDocument, excludeKeys, false);
        copyingParams.addCopiedNamespace(copiedNsDict);

        PdfDictionary srcRoleMapNs = srcNsDict.getAsDictionary(PdfName.RoleMapNS);
        // if this src namespace was already copied (or in the process of copying) it will contain role map already
        PdfDictionary copiedRoleMap = copiedNsDict.getAsDictionary(PdfName.RoleMapNS);
        if (srcRoleMapNs != null && copiedRoleMap == null) {
            copiedRoleMap = new PdfDictionary();
            copiedNsDict.put(PdfName.RoleMapNS, copiedRoleMap);

            for (Map.Entry<PdfName, PdfObject> entry : srcRoleMapNs.entrySet()) {
                PdfObject copiedMapping;
                if (entry.getValue().isArray()) {
                    PdfArray srcMappingArray = (PdfArray) entry.getValue();
                    if (srcMappingArray.size() > 1 && srcMappingArray.get(1).isDictionary()) {
                        PdfArray copiedMappingArray = new PdfArray();
                        copiedMappingArray.add(srcMappingArray.get(0).copyTo(toDocument));
                        PdfDictionary copiedNamespace = copyNamespaceDict(srcMappingArray.getAsDictionary(1), copyingParams);
                        copiedMappingArray.add(copiedNamespace);
                        copiedMapping = copiedMappingArray;
                    } else {
                        Logger logger = LoggerFactory.getLogger(StructureTreeCopier.class);
                        logger.warn(MessageFormat.format(
                                IoLogMessageConstant.ROLE_MAPPING_FROM_SOURCE_IS_NOT_COPIED_INVALID,
                                entry.getKey().toString()));
                        continue;
                    }
                } else {
                    copiedMapping = entry.getValue().copyTo(toDocument);
                }
                PdfName copiedRoleFrom = (PdfName) entry.getKey().copyTo(toDocument);
                copiedRoleMap.put(copiedRoleFrom, copiedMapping);
            }
        }

        return copiedNsDict;
    }

    private static void separateKids(PdfDictionary structElem, Set<PdfObject> firstPartElems, LastClonedAncestor lastCloned, PdfDocument document) {
        PdfObject k = structElem.get(PdfName.K);

        // If /K entry is not a PdfArray - it would be a kid which we won't clone at the moment, because it won't contain
        // kids from both parts at the same time. It would either be cloned as an ancestor later, or not cloned at all.
        // If it's kid is struct elem - it would definitely be structElem from the first part, so we simply call separateKids for it.
        if (!k.isArray()) {
            if (k.isDictionary() && PdfStructElem.isStructElem((PdfDictionary) k)) {
                separateKids((PdfDictionary) k, firstPartElems, lastCloned, document);
            }
        } else {
            PdfArray kids = (PdfArray) k;

            for (int i = 0; i < kids.size(); ++i) {
                PdfObject kid = kids.get(i);
                PdfDictionary dictKid = null;
                if (kid.isDictionary()) {
                    dictKid = (PdfDictionary) kid;
                }

                if (dictKid != null && PdfStructElem.isStructElem(dictKid)) {
                    if (firstPartElems.contains(kid)) {
                        separateKids((PdfDictionary) kid, firstPartElems, lastCloned, document);
                    } else {
                        if (dictKid.isFlushed()) {
                            throw new PdfException(
                                    KernelExceptionMessageConstant.TAG_FROM_THE_EXISTING_TAG_STRUCTURE_IS_FLUSHED_CANNOT_ADD_COPIED_PAGE_TAGS);
                        }

                        // elems with no kids will not be marked as from the first part,
                        // but nonetheless we don't want to move all of them to the second part; we just leave them as is
                        if (dictKid.containsKey(PdfName.K)) {
                            cloneParents(structElem, lastCloned, document);

                            kids.remove(i--);
                            PdfStructElem.addKidObject(lastCloned.clone, -1, kid);
                        }
                    }
                } else {
                    if (!firstPartElems.contains(kid)) {
                        cloneParents(structElem, lastCloned, document);

                        PdfMcr mcr;
                        if (dictKid != null) {
                            if (dictKid.get(PdfName.Type).equals(PdfName.MCR)) {
                                mcr = new PdfMcrDictionary(dictKid, new PdfStructElem(lastCloned.clone));
                            } else {
                                mcr = new PdfObjRef(dictKid, new PdfStructElem(lastCloned.clone));
                            }
                        } else {
                            mcr = new PdfMcrNumber((PdfNumber) kid, new PdfStructElem(lastCloned.clone));
                        }

                        kids.remove(i--);
                        PdfStructElem.addKidObject(lastCloned.clone, -1, kid);
                        // re-register mcr
                        document.getStructTreeRoot().getParentTreeHandler().registerMcr(mcr);
                    }
                }
            }
        }

        if (lastCloned.ancestor == structElem) {
            lastCloned.ancestor = lastCloned.ancestor.getAsDictionary(PdfName.P);
            lastCloned.clone = lastCloned.clone.getAsDictionary(PdfName.P);
        }
    }

    private static void cloneParents(PdfDictionary structElem, LastClonedAncestor lastCloned, PdfDocument document) {
        if (lastCloned.ancestor != structElem) {
            PdfDictionary structElemClone = (PdfDictionary) structElem.clone(ignoreKeysForClone).makeIndirect(document);
            PdfDictionary currClone = structElemClone;
            PdfDictionary currElem = structElem;
            while (currElem.get(PdfName.P) != lastCloned.ancestor) {
                PdfDictionary parent = currElem.getAsDictionary(PdfName.P);
                PdfDictionary parentClone = (PdfDictionary) parent.clone(ignoreKeysForClone).makeIndirect(document);
                currClone.put(PdfName.P, parentClone);
                parentClone.put(PdfName.K, currClone);
                currClone = parentClone;
                currElem = parent;
            }
            PdfStructElem.addKidObject(lastCloned.clone, -1, currClone);
            lastCloned.clone = structElemClone;
            lastCloned.ancestor = structElem;
        }
    }

    /**
     * @return the topmost parent added to set. If encountered flushed element - stops and returns this flushed element.
     */
    private static PdfDictionary addAllParentsToSet(PdfMcr mcr, Set<PdfObject> set) {
        List<PdfDictionary> allParents = retrieveParents(mcr, true);
        set.addAll(allParents);
        return allParents.isEmpty() ? null : allParents.get(allParents.size() - 1);
    }

    /**
     * Gets the topmost non-root structure element parent. May be flushed.
     *
     * @param mcr starting element
     * @return topmost non-root structure element parent, or {@code null} if it doesn't have any
     */
    private static PdfDictionary getTopmostParent(PdfMcr mcr) {
        return retrieveParents(mcr, false).get(0);
    }

    private static List<PdfDictionary> retrieveParents(PdfMcr mcr, boolean all) {
        final Set<PdfDictionary> parents = new LinkedHashSet<>();
        final IStructureNode firstParent = mcr.getParent();
        PdfDictionary previous = null;
        PdfDictionary current = firstParent instanceof PdfStructElem ? ((PdfStructElem) firstParent).getPdfObject() : null;

        while (current != null
               && !PdfName.StructTreeRoot.equals(current.getAsName(PdfName.Type))
               && !parents.contains(current)) {
            if (all) {
                parents.add(current);
            }
            previous = current;
            current = previous.isFlushed() ? null : previous.getAsDictionary(PdfName.P);
        }
        if (!all) {
            parents.add(previous);
        }
        return new ArrayList<>(parents);
    }

    static class LastClonedAncestor {
        PdfDictionary ancestor;
        PdfDictionary clone;
    }

    private static class StructElemCopyingParams {
        private final Set<PdfObject> objectsToCopy;
        private final PdfDocument toDocument;
        private final Map<PdfDictionary, PdfDictionary> page2page;
        private final boolean copyFromDestDocument;

        private final Set<PdfObject> copiedNamespaces;

        public StructElemCopyingParams(Set<PdfObject> objectsToCopy, PdfDocument toDocument, Map<PdfDictionary, PdfDictionary> page2page, boolean copyFromDestDocument) {
            this.objectsToCopy = objectsToCopy;
            this.toDocument = toDocument;
            this.page2page = page2page;
            this.copyFromDestDocument = copyFromDestDocument;
            this.copiedNamespaces = new LinkedHashSet<>();
        }

        public Set<PdfObject> getObjectsToCopy() {
            return objectsToCopy;
        }

        public PdfDocument getToDocument() {
            return toDocument;
        }

        public Map<PdfDictionary, PdfDictionary> getPage2page() {
            return page2page;
        }

        public boolean isCopyFromDestDocument() {
            return copyFromDestDocument;
        }

        public void addCopiedNamespace(PdfDictionary copiedNs) {
            copiedNamespaces.add(copiedNs);
        }

        public Set<PdfObject> getCopiedNamespaces() {
            return copiedNamespaces;
        }
    }

    private static class CopyStructureResult {
        private final List<PdfDictionary> topsList;
        private final Set<PdfObject> copiedNamespaces;

        public CopyStructureResult(List<PdfDictionary> topsList, Set<PdfObject> copiedNamespaces) {
            this.topsList = topsList;
            this.copiedNamespaces = copiedNamespaces;
        }

        public Set<PdfObject> getCopiedNamespaces() {
            return copiedNamespaces;
        }

        public List<PdfDictionary> getTopsList() {
            return topsList;
        }
    }
}