/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/* $Id: PDFDocument.java 1903803 2022-09-01 09:36:44Z ssteiner $ */

package org.apache.fop.pdf;

// Java
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.apache.xmlgraphics.image.loader.util.SoftMapCache;

import org.apache.fop.pdf.StandardStructureAttributes.Table.Scope;
import org.apache.fop.pdf.xref.CrossReferenceStream;
import org.apache.fop.pdf.xref.CrossReferenceTable;
import org.apache.fop.pdf.xref.TrailerDictionary;

/* image support modified from work of BoBoGi */
/* font support based on work by Takayuki Takeuchi */

/**
 * Class representing a PDF document.
 *
 * The document is built up by calling various methods and then finally
 * output to given filehandle using output method.
 *
 * A PDF document consists of a series of numbered objects preceded by a
 * header and followed by an xref table and trailer. The xref table
 * allows for quick access to objects by listing their character
 * positions within the document. For this reason the PDF document must
 * keep track of the character position of each object.  The document
 * also keeps direct track of the /Root, /Info and /Resources objects.
 *
 * Modified by Mark Lillywhite, mark-fop@inomial.com. The changes
 * involve: ability to output pages one-at-a-time in a streaming
 * fashion (rather than storing them all for output at the end);
 * ability to write the /Pages object after writing the rest
 * of the document; ability to write to a stream and flush
 * the object list; enhanced trailer output; cleanups.
 *
 */
public class PDFDocument {

    /** the encoding to use when converting strings to PDF commands */
    public static final String ENCODING = "ISO-8859-1";

    /** the counter for object numbering */
    protected int objectcount;

    /** the logger instance */
    private Log log = LogFactory.getLog("org.apache.fop.pdf");

    /** the current character position */
    protected long position;

    /** the character position of each object */
    protected List<Long> indirectObjectOffsets = new ArrayList<Long>();

    protected List<PDFStructElem> structureTreeElements;

    /** List of objects to write in the trailer */
    protected List<PDFObject> trailerObjects = new ArrayList<PDFObject>();

    /** the objects themselves */
    protected List<PDFObject> objects = new LinkedList<PDFObject>();

    /** Controls the PDF version of this document */
    private VersionController versionController;

    /** Indicates which PDF profiles are active (PDF/A, PDF/X etc.) */
    private PDFProfile pdfProfile = new PDFProfile(this);

    /** the /Root object */
    private PDFRoot root;

    /** The root outline object */
    private PDFOutline outlineRoot;

    /** The /Pages object (mark-fop@inomial.com) */
    private PDFPages pages;

    /** the /Info object */
    private PDFInfo info;

    /** the /Resources object */
    private PDFResources resources;

    /** the document's encryption, if it exists */
    private PDFEncryption encryption;

    /** the colorspace (0=RGB, 1=CMYK) */
    private PDFDeviceColorSpace colorspace
        = new PDFDeviceColorSpace(PDFDeviceColorSpace.DEVICE_RGB);

    /** the counter for Pattern name numbering (e.g. 'Pattern1') */
    private int patternCount;

    /** the counter for Shading name numbering */
    private int shadingCount;

    /** the counter for XObject numbering */
    private int xObjectCount;

    protected int gStateObjectCount;

    /* TODO: Should be modified (works only for image subtype) */
    private Map<String, PDFXObject> xObjectsMap = new HashMap<String, PDFXObject>();
    private SoftMapCache xObjectsMapFast = new SoftMapCache(false);

    private Map<String, PDFFont> fontMap = new HashMap<String, PDFFont>();

    private Map<String, List<String>> filterMap = new HashMap<String, List<String>>();

    private List<PDFGState> gstates = new ArrayList<PDFGState>();

    private List<PDFFunction> functions = new ArrayList<PDFFunction>();

    private List<PDFShading> shadings = new ArrayList<PDFShading>();

    private List<PDFPattern> patterns = new ArrayList<PDFPattern>();

    private List<PDFLink> links = new ArrayList<PDFLink>();

    private List<PDFDestination> destinations;

    private List<PDFFileSpec> filespecs = new ArrayList<PDFFileSpec>();

    private List<PDFGoToRemote> gotoremotes = new ArrayList<PDFGoToRemote>();

    private List<PDFGoTo> gotos = new ArrayList<PDFGoTo>();

    private List<PDFLaunch> launches = new ArrayList<PDFLaunch>();

    protected List<PDFPage> pageObjs = new ArrayList<PDFPage>();

    private List<PDFLayer> layers;

    private List<PDFNavigator> navigators;

    private List<PDFNavigatorAction> navigatorActions;

    private PDFFactory factory;

    private FileIDGenerator fileIDGenerator;

    private boolean accessibilityEnabled;

    private boolean mergeFontsEnabled;

    private boolean linearizationEnabled;

    private boolean formXObjectEnabled;

    protected boolean outputStarted;

    /**
     * Creates an empty PDF document.
     *
     * The constructor creates a /Root and /Pages object to
     * track the document but does not write these objects until
     * the trailer is written. Note that the object ID of the
     * pages object is determined now, and the xref table is
     * updated later. This allows Pages to refer to their
     * Parent before we write it out.
     *
     * @param prod the name of the producer of this pdf document
     */
    public PDFDocument(String prod) {
        this(prod, null);
        versionController = VersionController.getDynamicVersionController(Version.V1_4, this);
    }

    /**
     * Creates an empty PDF document.
     *
     * The constructor creates a /Root and /Pages object to
     * track the document but does not write these objects until
     * the trailer is written. Note that the object ID of the
     * pages object is determined now, and the xref table is
     * updated later. This allows Pages to refer to their
     * Parent before we write it out.
     *
     * @param prod the name of the producer of this pdf document
     * @param versionController the version controller of this PDF document
     */
    public PDFDocument(String prod, VersionController versionController) {

        this.factory = new PDFFactory(this);

        /* create the /Root, /Info and /Resources objects */
        this.pages = getFactory().makePages();

        // Create the Root object
        this.root = getFactory().makeRoot(this.pages);

        // Create the Resources object
        this.resources = getFactory().makeResources();

        // Make the /Info record
        this.info = getFactory().makeInfo(prod);

        this.versionController = versionController;
    }

    /**
     * Returns the current PDF version.
     *
     * @return returns the PDF version
     */
    public Version getPDFVersion() {
        return versionController.getPDFVersion();
    }

    /**
     * Sets the PDF version of this document.
     *
     * @param version the PDF version
     * @throws IllegalStateException if the version of this PDF is not allowed to change.
     */
    public void setPDFVersion(Version version) {
        versionController.setPDFVersion(version);
    }

    /** @return the String representing the current PDF version */
    public String getPDFVersionString() {
        return versionController.getPDFVersion().toString();
    }

    /** @return the PDF profile currently active. */
    public PDFProfile getProfile() {
        return this.pdfProfile;
    }

    /**
     * Returns the factory for PDF objects.
     *
     * @return the {@link PDFFactory} object
     */
    public PDFFactory getFactory() {
        return this.factory;
    }

    /**
     * Converts text to a byte array for writing to a PDF file.
     *
     * @param text text to convert/encode
     * @return the resulting <code>byte</code> array
     */
    public static byte[] encode(String text) {
        try {
            return text.getBytes(ENCODING);
        } catch (UnsupportedEncodingException uee) {
            return text.getBytes();
        }
    }

    /**
     * Flushes the given text buffer to an output stream with the right encoding and resets
     * the text buffer. This is used to efficiently switch between outputting text and binary
     * content.
     * @param textBuffer the text buffer
     * @param out the output stream to flush the text content to
     * @throws IOException if an I/O error occurs while writing to the output stream
     */
    public static void flushTextBuffer(StringBuilder textBuffer, OutputStream out)
            throws IOException {
        out.write(encode(textBuffer.toString()));
        textBuffer.setLength(0);
    }

    /**
     * Sets the producer of the document.
     *
     * @param producer string indicating application producing the PDF
     */
    public void setProducer(String producer) {
        this.info.setProducer(producer);
    }

    /**
      * Sets the creation date of the document.
      *
      * @param date Date to be stored as creation date in the PDF.
      */
    public void setCreationDate(Date date) {
        this.info.setCreationDate(date);
    }

    /**
      * Sets the creator of the document.
      *
      * @param creator string indicating application creating the document
      */
    public void setCreator(String creator) {
        this.info.setCreator(creator);
    }

    /**
     * Sets the filter map to use for filters in this document.
     *
     * @param map the map of filter lists for each stream type
     */
    public void setFilterMap(Map<String, List<String>> map) {
        this.filterMap = map;
    }

    /**
     * Returns the {@link PDFFilter}s map used for filters in this document.
     *
     * @return the map of filters being used
     */
    public Map<String, List<String>> getFilterMap() {
        return this.filterMap;
    }

    /**
     * Returns the {@link PDFPages} object associated with the root object.
     *
     * @return the {@link PDFPages} object
     */
    public PDFPages getPages() {
        return this.pages;
    }

    /**
     * Get the {@link PDFRoot} object for this document.
     *
     * @return the {@link PDFRoot} object
     */
    public PDFRoot getRoot() {
        return this.root;
    }

    /**
     * Get the Structural Tree Collection for this document
     * @return
     */
    public List<PDFStructElem> getStructureTreeElements() {
        return structureTreeElements;
    }

    /**
     * Creates and returns a StructTreeRoot object.
     *
     * @param parentTree the value of the ParenTree entry
     * @return the structure tree root
     */
    public PDFStructTreeRoot makeStructTreeRoot(PDFParentTree parentTree) {
        PDFStructTreeRoot structTreeRoot = new PDFStructTreeRoot(parentTree);
        assignObjectNumber(structTreeRoot);
        addTrailerObject(structTreeRoot);
        root.setStructTreeRoot(structTreeRoot);
        structureTreeElements = new ArrayList<PDFStructElem>();
        return structTreeRoot;
    }

    /**
     * Adds the given element to the structure tree.
     */
    public void registerStructureElement(PDFStructElem structElem) {
        assignObjectNumber(structElem);
        structureTreeElements.add(structElem);
    }

    /**
     * Assigns the given scope to the given element and adds it to the structure tree. The
     * scope may not be added if it's not compatible with this document's PDF version.
     */
    public void registerStructureElement(PDFStructElem structElem, Scope scope) {
        registerStructureElement(structElem);
        versionController.addTableHeaderScopeAttribute(structElem, scope);
    }

    /**
     * Get the {@link PDFInfo} object for this document.
     *
     * @return the {@link PDFInfo} object
     */
    public PDFInfo getInfo() {
        return this.info;
    }

    /**
     * Registers a {@link PDFObject} in this PDF document.
     * The object is assigned a new object number.
     *
     * @param obj {@link PDFObject} to add
     * @return the added {@link PDFObject} added (with its object number set)
     */
    public PDFObject registerObject(PDFObject obj) {
        assignObjectNumber(obj);
        addObject(obj);
        if (obj instanceof AbstractPDFStream) {
            ((AbstractPDFStream) obj).registerChildren();
        }
        return obj;
    }

    /**
     * Registers a {@link PDFObject} in this PDF document at end.
     * The object is assigned a new object number.
     *
     * @param obj {@link PDFObject} to add
     * @return the added {@link PDFObject} added (with its object number set)
     */
    <T extends PDFObject> T registerTrailerObject(T obj) {
        assignObjectNumber(obj);
        addTrailerObject(obj);
        return obj;
    }

    /**
     * Assigns the {@link PDFObject} an object number,
     * and sets the parent of the {@link PDFObject} to this document.
     *
     * @param obj {@link PDFObject} to assign a number to
     */
    public void assignObjectNumber(PDFObject obj) {
        if (outputStarted && isLinearizationEnabled()) {
            throw new IllegalStateException("Can't assign number after start of output");
        }
        if (obj == null) {
            throw new NullPointerException("obj must not be null");
        }
        if (obj.hasObjectNumber()) {
            throw new IllegalStateException(
                "Error registering a PDFObject: "
                    + "PDFObject already has an object number");
        }
        PDFDocument currentParent = obj.getDocument();
        if (currentParent != null && currentParent != this) {
            throw new IllegalStateException(
                "Error registering a PDFObject: "
                    + "PDFObject already has a parent PDFDocument");
        }

        obj.setObjectNumber(this);

        if (currentParent == null) {
            obj.setDocument(this);
        }
    }

    /**
     * Adds a {@link PDFObject} to this document.
     * The object <em>MUST</em> have an object number assigned.
     *
     * @param obj {@link PDFObject} to add
     */
    public void addObject(PDFObject obj) {
        if (obj == null) {
            throw new NullPointerException("obj must not be null");
        }
        if (!obj.hasObjectNumber()) {
            throw new IllegalStateException(
                "Error adding a PDFObject: "
                    + "PDFObject doesn't have an object number");
        }

        //Add object to list
        this.objects.add(obj);

        //Add object to special lists where necessary
        if (obj instanceof PDFFunction) {
            this.functions.add((PDFFunction) obj);
        }
        if (obj instanceof PDFShading) {
            final String shadingName = "Sh" + (++this.shadingCount);
            ((PDFShading)obj).setName(shadingName);
            this.shadings.add((PDFShading) obj);
        }
        if (obj instanceof PDFPattern) {
            final String patternName = "Pa" + (++this.patternCount);
            ((PDFPattern)obj).setName(patternName);
            this.patterns.add((PDFPattern) obj);
        }
        if (obj instanceof PDFFont) {
            final PDFFont font = (PDFFont)obj;
            this.fontMap.put(font.getName(), font);
        }
        if (obj instanceof PDFGState) {
            this.gstates.add((PDFGState) obj);
        }
        if (obj instanceof PDFPage) {
            this.pages.notifyKidRegistered((PDFPage)obj);
            pageObjs.add((PDFPage) obj);
        }
        if (obj instanceof PDFLaunch) {
            this.launches.add((PDFLaunch) obj);
        }
        if (obj instanceof PDFLink) {
            this.links.add((PDFLink) obj);
        }
        if (obj instanceof PDFFileSpec) {
            this.filespecs.add((PDFFileSpec) obj);
        }
        if (obj instanceof PDFGoToRemote) {
            this.gotoremotes.add((PDFGoToRemote) obj);
        }
        if (obj instanceof PDFLayer) {
            if (this.layers == null) {
                this.layers = new ArrayList<PDFLayer>();
            }
            this.layers.add((PDFLayer) obj);
        }
        if (obj instanceof PDFNavigator) {
            if (this.navigators == null) {
                this.navigators = new ArrayList<PDFNavigator>();
            }
            this.navigators.add((PDFNavigator) obj);
        }
        if (obj instanceof PDFNavigatorAction) {
            if (this.navigatorActions == null) {
                this.navigatorActions = new ArrayList<PDFNavigatorAction>();
            }
            this.navigatorActions.add((PDFNavigatorAction) obj);
        }
    }

    /**
     * Add trailer object.
     * Adds an object to the list of trailer objects.
     *
     * @param obj the PDF object to add
     */
    public void addTrailerObject(PDFObject obj) {
        this.trailerObjects.add(obj);

        if (obj instanceof PDFGoTo) {
            this.gotos.add((PDFGoTo) obj);
        }
    }

    /**
     * Apply the encryption filter to a PDFStream if encryption is enabled.
     *
     * @param stream PDFStream to encrypt
     */
    public void applyEncryption(AbstractPDFStream stream) {
        if (isEncryptionActive()) {
            this.encryption.applyFilter(stream);
        }
    }

    /**
     * Enables PDF encryption.
     *
     * @param params The encryption parameters for the pdf file
     */
    public void setEncryption(PDFEncryptionParams params) {
        getProfile().verifyEncryptionAllowed();
        fileIDGenerator = FileIDGenerator.getRandomFileIDGenerator();
        this.encryption = PDFEncryptionManager.newInstance(params, this);
        if (this.encryption != null) {
            PDFObject pdfObject = (PDFObject)this.encryption;
            addTrailerObject(pdfObject);
            try {
                if (encryption.getPDFVersion().compareTo(versionController.getPDFVersion()) > 0) {
                    versionController.setPDFVersion(encryption.getPDFVersion());
                }
            } catch (IllegalStateException ise) {
                log.warn("Configured encryption requires PDF version " + encryption.getPDFVersion()
                        + " but version has been set to " + versionController.getPDFVersion() + ".");
                throw ise;
            }
        } else {
            log.warn("PDF encryption is unavailable. PDF will be generated without encryption.");
            if (params.getEncryptionLengthInBits() == 256) {
                log.warn("Make sure the JCE Unlimited Strength Jurisdiction Policy files are available."
                        + "AES 256 encryption cannot be performed without them.");
            }
        }
    }

    /**
     * Indicates whether encryption is active for this PDF or not.
     *
     * @return boolean True if encryption is active
     */
    public boolean isEncryptionActive() {
        return this.encryption != null;
    }

    /**
     * Returns the active Encryption object.
     *
     * @return the Encryption object
     */
    public PDFEncryption getEncryption() {
        return this.encryption;
    }

    private Object findPDFObject(List<? extends PDFObject> list, PDFObject compare) {
        for (PDFObject obj : list) {
            if (compare.contentEquals(obj)) {
                return obj;
            }
        }
        return null;
    }

    /**
     * Looks through the registered functions to see if one that is equal to
     * a reference object exists
     *
     * @param compare reference object
     * @return the function if it was found, null otherwise
     */
    protected PDFFunction findFunction(PDFFunction compare) {
        return (PDFFunction)findPDFObject(this.functions, compare);
    }

    /**
     * Looks through the registered shadings to see if one that is equal to
     * a reference object exists
     *
     * @param compare reference object
     * @return the shading if it was found, null otherwise
     */
    protected PDFShading findShading(PDFShading compare) {
        return (PDFShading)findPDFObject(this.shadings, compare);
    }

    /**
     * Find a previous pattern.
     * The problem with this is for tiling patterns the pattern
     * data stream is stored and may use up memory, usually this
     * would only be a small amount of data.
     *
     * @param compare reference object
     * @return the shading if it was found, null otherwise
     */
    protected PDFPattern findPattern(PDFPattern compare) {
        return (PDFPattern)findPDFObject(this.patterns, compare);
    }

    /**
     * Finds a font.
     *
     * @param fontname name of the font
     * @return PDFFont the requested font, null if it wasn't found
     */
    protected PDFFont findFont(String fontname) {
        return this.fontMap.get(fontname);
    }

    /**
     * Finds a named destination.
     *
     * @param compare reference object to use as search template
     * @return the link if found, null otherwise
     */
    protected PDFDestination findDestination(PDFDestination compare) {
        int index = getDestinationList().indexOf(compare);
        if (index >= 0) {
            return getDestinationList().get(index);
        } else {
            return null;
        }
    }

    /**
     * Finds a link.
     *
     * @param compare reference object to use as search template
     * @return the link if found, null otherwise
     */
    protected PDFLink findLink(PDFLink compare) {
        return (PDFLink)findPDFObject(this.links, compare);
    }

    /**
     * Finds a file spec.
     *
     * @param compare reference object to use as search template
     * @return the file spec if found, null otherwise
     */
    protected PDFFileSpec findFileSpec(PDFFileSpec compare) {
        return (PDFFileSpec)findPDFObject(this.filespecs, compare);
    }

    /**
     * Finds a goto remote.
     *
     * @param compare reference object to use as search template
     * @return the goto remote if found, null otherwise
     */
    protected PDFGoToRemote findGoToRemote(PDFGoToRemote compare) {
        return (PDFGoToRemote)findPDFObject(this.gotoremotes, compare);
    }

    /**
     * Finds a goto.
     *
     * @param compare reference object to use as search template
     * @return the goto if found, null otherwise
     */
    protected PDFGoTo findGoTo(PDFGoTo compare) {
        return (PDFGoTo)findPDFObject(this.gotos, compare);
    }

    /**
     * Finds a launch.
     *
     * @param compare reference object to use as search template
     * @return the launch if found, null otherwise
     */
    protected PDFLaunch findLaunch(PDFLaunch compare) {
        return (PDFLaunch) findPDFObject(this.launches, compare);
    }

    /**
     * Looks for an existing GState to use
     *
     * @param wanted requested features
     * @param current currently active features
     * @return the GState if found, null otherwise
     */
    protected PDFGState findGState(PDFGState wanted, PDFGState current) {
        PDFGState poss;
        for (PDFGState avail : this.gstates) {
            poss = new PDFGState();
            poss.addValues(current);
            poss.addValues(avail);
            if (poss.contentEquals(wanted)) {
                return avail;
            }
        }
        return null;
    }

    /**
     * Returns the PDF color space object.
     *
     * @return the color space
     */
    public PDFDeviceColorSpace getPDFColorSpace() {
        return this.colorspace;
    }

    /**
     * Returns the color space.
     *
     * @return the color space
     */
    public int getColorSpace() {
        return getPDFColorSpace().getColorSpace();
    }

    /**
     * Set the color space.
     * This is used when creating gradients.
     *
     * @param theColorspace the new color space
     */
    public void setColorSpace(int theColorspace) {
        this.colorspace.setColorSpace(theColorspace);
    }

    /**
     * Returns the font map for this document.
     *
     * @return the map of fonts used in this document
     */
    public Map<String, PDFFont> getFontMap() {
        return this.fontMap;
    }

    /**
     * Get an image from the image map.
     *
     * @param key the image key to look for
     * @return the image or PDFXObject for the key if found
     * @deprecated Use getXObject instead (so forms are treated in the same way)
     */
    @Deprecated
    public PDFImageXObject getImage(String key) {
        return (PDFImageXObject)getXObject(key);
    }

    /**
     * Get an XObject from the image map.
     *
     * @param key the XObject key to look for
     * @return the PDFXObject for the key if found
     */
    public PDFXObject getXObject(String key) {
        Object xObj = xObjectsMapFast.get(key);
        if (xObj != null) {
            return (PDFXObject) xObj;
        }
        return xObjectsMap.get(toHashCode(key));
    }

    private void putXObject(String key, PDFXObject pdfxObject) {
        xObjectsMapFast.clear();
        xObjectsMapFast.put(key, pdfxObject);
        xObjectsMap.put(toHashCode(key), pdfxObject);
    }

    private String toHashCode(String key) {
        if (key.length() < 1024) {
            return key;
        }
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] thedigest = md.digest(key.getBytes("UTF-8"));
            StringBuilder hex = new StringBuilder();
            for (byte b : thedigest) {
                hex.append(String.format("%02x", b));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Adds a destination to the document.
     * @param destination the destination object
     */
    public void addDestination(PDFDestination destination) {
        if (this.destinations == null) {
            this.destinations = new ArrayList<PDFDestination>();
        }
        this.destinations.add(destination);
    }

    /**
     * Gets the list of named destinations.
     *
     * @return the list of named destinations.
     */
    public List<PDFDestination> getDestinationList() {
        if (hasDestinations()) {
            return this.destinations;
        } else {
            return Collections.emptyList();
        }
    }

    /**
     * Gets whether the document has named destinations.
     *
     * @return whether the document has named destinations.
     */
    public boolean hasDestinations() {
        return this.destinations != null && !this.destinations.isEmpty();
    }

    /**
     * Add an image to the PDF document.
     * This adds an image to the PDF objects.
     * If an image with the same key already exists it will return the
     * old {@link PDFXObject}.
     *
     * @param res the PDF resource context to add to, may be null
     * @param img the PDF image to add
     * @return the PDF XObject that references the PDF image data
     */
    public PDFImageXObject addImage(PDFResourceContext res, PDFImage img) {
        // check if already created
        String key = img.getKey();
        PDFImageXObject xObject = (PDFImageXObject)getXObject(key);
        if (xObject != null) {
            if (res != null) {
                res.addXObject(xObject);
            }
            return xObject;
        }

        // setup image
        img.setup(this);
        // create a new XObject
        xObject = new PDFImageXObject(++this.xObjectCount, img);
        registerObject(xObject);
        this.resources.addXObject(xObject);
        if (res != null) {
            res.addXObject(xObject);
        }
        putXObject(key, xObject);
        return xObject;
    }

    /**
     * Add a form XObject to the PDF document.
     * This adds a Form XObject to the PDF objects.
     * If a Form XObject with the same key already exists it will return the
     * old {@link PDFFormXObject}.
     *
     * @param res the PDF resource context to add to, may be null
     * @param cont the PDF Stream contents of the Form XObject
     * @param formres a reference to the PDF Resources for the Form XObject data
     * @param key the key for the object
     * @return the PDF Form XObject that references the PDF data
     */
    public PDFFormXObject addFormXObject(
        PDFResourceContext res,
        PDFStream cont,
        PDFReference formres,
        String key) {

        // check if already created
        PDFFormXObject xObject = (PDFFormXObject)getXObject(key);
        if (xObject != null) {
            if (res != null) {
                res.addXObject(xObject);
            }
            return xObject;
        }

        xObject = new PDFFormXObject(
                ++this.xObjectCount,
                cont,
                formres);
        registerObject(xObject);
        this.resources.addXObject(xObject);
        if (res != null) {
            res.addXObject(xObject);
        }
        putXObject(key, xObject);
        return xObject;
    }

    /**
     * Get the root Outlines object. This method does not write
     * the outline to the PDF document, it simply creates a
     * reference for later.
     *
     * @return the PDF Outline root object
     */
    public PDFOutline getOutlineRoot() {
        if (this.outlineRoot != null) {
            return this.outlineRoot;
        }

        this.outlineRoot = new PDFOutline(null, null, true);
        assignObjectNumber(this.outlineRoot);
        addTrailerObject(this.outlineRoot);
        this.root.setRootOutline(this.outlineRoot);
        return this.outlineRoot;
    }

    /**
     * Get the /Resources object for the document
     *
     * @return the /Resources object
     */
    public PDFResources getResources() {
        return this.resources;
    }

    public void enableAccessibility(boolean enableAccessibility) {
        this.accessibilityEnabled = enableAccessibility;
    }

    /**
     *
     */
    public PDFReference resolveExtensionReference(String id) {
        if (layers != null) {
            for (PDFLayer layer : layers) {
                if (layer.hasId(id)) {
                    return layer.makeReference();
                }
            }
        }
        if (navigators != null) {
            for (PDFNavigator navigator : navigators) {
                if (navigator.hasId(id)) {
                    return navigator.makeReference();
                }
            }
        }
        if (navigatorActions != null) {
            for (PDFNavigatorAction action : navigatorActions) {
                if (action.hasId(id)) {
                    return action.makeReference();
                }
            }
        }
        return null;
    }

    /**
     * Writes out the entire document
     *
     * @param stream the OutputStream to output the document to
     * @throws IOException if there is an exception writing to the output stream
     */
    public void output(OutputStream stream) throws IOException {
        outputStarted = true;
        //Write out objects until the list is empty. This approach (used with a
        //LinkedList) allows for output() methods to create and register objects
        //on the fly even during serialization.
        while (this.objects.size() > 0) {
            PDFObject object = this.objects.remove(0);
            streamIndirectObject(object, stream);
        }
    }

    protected void writeTrailer(OutputStream stream, int first, int last, int size, long mainOffset, long startxref)
            throws IOException {
        TrailerOutputHelper trailerOutputHelper = mayCompressStructureTreeElements()
                ? new CompressedTrailerOutputHelper()
                : new UncompressedTrailerOutputHelper();
        if (structureTreeElements != null) {
            trailerOutputHelper.outputStructureTreeElements(stream);
        }
        TrailerDictionary trailerDictionary = createTrailerDictionary(mainOffset != 0);
        if (mainOffset != 0) {
            trailerDictionary.getDictionary().put("Prev", mainOffset);
        }
        trailerOutputHelper.outputCrossReferenceObject(stream, trailerDictionary, first, last, size);
        String trailer = "\nstartxref\n" + startxref + "\n%%EOF\n";
        stream.write(encode(trailer));
    }

    protected int streamIndirectObject(PDFObject o, OutputStream stream) throws IOException {
        outputStarted = true;
        recordObjectOffset(o);
        int len = outputIndirectObject(o, stream);
        this.position += len;
        return len;
    }

    private void streamIndirectObjects(Collection<? extends PDFObject> objects, OutputStream stream)
            throws IOException {
        for (PDFObject o : objects) {
            streamIndirectObject(o, stream);
        }
    }

    private void recordObjectOffset(PDFObject object) {
        int index = object.getObjectNumber().getNumber() - 1;
        while (indirectObjectOffsets.size() <= index) {
            indirectObjectOffsets.add(null);
        }
        indirectObjectOffsets.set(index, position);
    }

    /**
     * Outputs the given object, wrapped by obj/endobj, to the given stream.
     *
     * @param object an indirect object, as described in Section 3.2.9 of the PDF 1.5
     * Reference.
     * @param stream the stream to which the object must be output
     * @throws IllegalArgumentException if the object is not an indirect object
     */
    public static int outputIndirectObject(PDFObject object, OutputStream stream)
            throws IOException {
        if (!object.hasObjectNumber()) {
            throw new IllegalArgumentException("Not an indirect object");
        }
        byte[] obj = encode(object.getObjectID());
        stream.write(obj);
        int length = object.output(stream);
        byte[] endobj = encode("\nendobj\n");
        stream.write(endobj);
        return obj.length + length + endobj.length;
    }

    /**
     * Write the PDF header.
     *
     * This method must be called prior to formatting
     * and outputting AreaTrees.
     *
     * @param stream the OutputStream to write the header to
     * @throws IOException if there is an exception writing to the output stream
     */
    public void outputHeader(OutputStream stream) throws IOException {
        this.position = 0;

        getProfile().verifyPDFVersion();

        byte[] pdf = encode("%PDF-" + getPDFVersionString() + "\n");
        stream.write(pdf);
        this.position += pdf.length;

        // output a binary comment as recommended by the PDF spec (3.4.1)
        byte[] bin = {
                (byte)'%',
                (byte)0xAA,
                (byte)0xAB,
                (byte)0xAC,
                (byte)0xAD,
                (byte)'\n' };
        stream.write(bin);
        this.position += bin.length;
    }

    /**
     * Write the trailer
     *
     * @param stream the OutputStream to write the trailer to
     * @throws IOException if there is an exception writing to the output stream
     */
    public void outputTrailer(OutputStream stream) throws IOException {
        createDestinations();
        output(stream);
        outputTrailerObjectsAndXref(stream);
    }

    private void createDestinations() {
        if (hasDestinations()) {
            Collections.sort(this.destinations, new DestinationComparator());
            PDFDests dests = getFactory().makeDests(this.destinations);
            if (this.root.getNames() == null) {
                this.root.setNames(getFactory().makeNames());
            }
            this.root.getNames().setDests(dests);
        }
    }

    private void outputTrailerObjectsAndXref(OutputStream stream) throws IOException {
        TrailerOutputHelper trailerOutputHelper = mayCompressStructureTreeElements()
                ? new CompressedTrailerOutputHelper()
                : new UncompressedTrailerOutputHelper();
        if (structureTreeElements != null) {
            trailerOutputHelper.outputStructureTreeElements(stream);
        }
        streamIndirectObjects(trailerObjects, stream);
        TrailerDictionary trailerDictionary = createTrailerDictionary(true);
        long startxref = trailerOutputHelper.outputCrossReferenceObject(stream, trailerDictionary, 0,
                indirectObjectOffsets.size(), indirectObjectOffsets.size());
        String trailer = "\nstartxref\n" + startxref + "\n%%EOF\n";
        stream.write(encode(trailer));
    }

    private boolean mayCompressStructureTreeElements() {
        return accessibilityEnabled
                && versionController.getPDFVersion().compareTo(Version.V1_5) >= 0
                && !isLinearizationEnabled();
    }

    private TrailerDictionary createTrailerDictionary(boolean addRoot) {
        FileIDGenerator gen = getFileIDGenerator();
        TrailerDictionary trailerDictionary = new TrailerDictionary(this);
        if (addRoot) {
            trailerDictionary.setRoot(root).setInfo(info);
        }
        trailerDictionary.setFileID(gen.getOriginalFileID(), gen.getUpdatedFileID());
        if (isEncryptionActive()) {
            trailerDictionary.setEncryption(encryption);
        }
        return trailerDictionary;
    }

    public boolean isMergeFontsEnabled() {
        return mergeFontsEnabled;
    }

    public void setMergeFontsEnabled(boolean mergeFontsEnabled) {
        this.mergeFontsEnabled = mergeFontsEnabled;
        if (mergeFontsEnabled) {
            getResources().createFontsAsObj();
        }
    }

    private interface TrailerOutputHelper {

        void outputStructureTreeElements(OutputStream stream) throws IOException;

        /**
         * @return the offset of the cross-reference object (the value of startxref)
         */
        long outputCrossReferenceObject(OutputStream stream, TrailerDictionary trailerDictionary,
                                        int first, int last, int size)
                throws IOException;
    }

    private class UncompressedTrailerOutputHelper implements TrailerOutputHelper {

        public void outputStructureTreeElements(OutputStream stream)
                throws IOException {
            streamIndirectObjects(structureTreeElements, stream);
        }

        public long outputCrossReferenceObject(OutputStream stream,
                TrailerDictionary trailerDictionary, int first, int last, int size) throws IOException {
            new CrossReferenceTable(trailerDictionary, position,
                    indirectObjectOffsets, first, last, size).output(stream);
            return position;
        }
    }

    private class CompressedTrailerOutputHelper implements TrailerOutputHelper {

        private ObjectStreamManager structureTreeObjectStreams;

        public void outputStructureTreeElements(OutputStream stream)
                throws IOException {
            assert structureTreeElements.size() > 0;
            structureTreeObjectStreams = new ObjectStreamManager(PDFDocument.this);
            for (PDFStructElem structElem : structureTreeElements) {
                structureTreeObjectStreams.add(structElem);
            }
        }

        public long outputCrossReferenceObject(OutputStream stream,
                TrailerDictionary trailerDictionary, int first, int last, int size) throws IOException {
            // Outputting the object streams should not have created new indirect objects
            assert objects.isEmpty();
            new CrossReferenceStream(PDFDocument.this, ++objectcount, trailerDictionary, position,
                    indirectObjectOffsets,
                    structureTreeObjectStreams.getCompressedObjectReferences())
                    .output(stream);
            return position;
        }
    }

    long getCurrentFileSize() {
        return position;
    }

    FileIDGenerator getFileIDGenerator() {
        if (fileIDGenerator == null) {
            try {
                fileIDGenerator = FileIDGenerator.getDigestFileIDGenerator(this);
            } catch (NoSuchAlgorithmException e) {
                fileIDGenerator = FileIDGenerator.getRandomFileIDGenerator();
            }
        }
        return fileIDGenerator;
    }

    public boolean isLinearizationEnabled() {
        return linearizationEnabled;
    }

    public void setLinearizationEnabled(boolean b) {
        linearizationEnabled = b;
    }

    public boolean isFormXObjectEnabled() {
        return formXObjectEnabled;
    }

    public void setFormXObjectEnabled(boolean b) {
        formXObjectEnabled = b;
    }
}
