/*
 * 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: IFRenderer.java 1761020 2016-09-16 11:17:35Z ssteiner $ */

package org.apache.fop.render.intermediate;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Stack;

import javax.xml.transform.stream.StreamResult;

import org.w3c.dom.Document;
import org.xml.sax.SAXException;

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

import org.apache.batik.parser.AWTTransformProducer;

import org.apache.xmlgraphics.xmp.Metadata;
import org.apache.xmlgraphics.xmp.schemas.DublinCoreAdapter;
import org.apache.xmlgraphics.xmp.schemas.DublinCoreSchema;
import org.apache.xmlgraphics.xmp.schemas.XMPBasicAdapter;
import org.apache.xmlgraphics.xmp.schemas.XMPBasicSchema;

import org.apache.fop.Version;
import org.apache.fop.accessibility.StructureTreeElement;
import org.apache.fop.apps.FOPException;
import org.apache.fop.apps.FOUserAgent;
import org.apache.fop.apps.MimeConstants;
import org.apache.fop.area.Area;
import org.apache.fop.area.AreaTreeObject;
import org.apache.fop.area.Block;
import org.apache.fop.area.BlockViewport;
import org.apache.fop.area.BookmarkData;
import org.apache.fop.area.CTM;
import org.apache.fop.area.DestinationData;
import org.apache.fop.area.OffDocumentExtensionAttachment;
import org.apache.fop.area.OffDocumentItem;
import org.apache.fop.area.PageSequence;
import org.apache.fop.area.PageViewport;
import org.apache.fop.area.RegionViewport;
import org.apache.fop.area.Trait;
import org.apache.fop.area.inline.AbstractTextArea;
import org.apache.fop.area.inline.ForeignObject;
import org.apache.fop.area.inline.Image;
import org.apache.fop.area.inline.InlineArea;
import org.apache.fop.area.inline.InlineParent;
import org.apache.fop.area.inline.InlineViewport;
import org.apache.fop.area.inline.Leader;
import org.apache.fop.area.inline.SpaceArea;
import org.apache.fop.area.inline.TextArea;
import org.apache.fop.area.inline.WordArea;
import org.apache.fop.datatypes.URISpecification;
import org.apache.fop.fo.extensions.ExtensionAttachment;
import org.apache.fop.fo.extensions.xmp.XMPMetadata;
import org.apache.fop.fonts.Font;
import org.apache.fop.fonts.FontInfo;
import org.apache.fop.fonts.FontTriplet;
import org.apache.fop.fonts.LazyFont;
import org.apache.fop.fonts.Typeface;
import org.apache.fop.render.AbstractPathOrientedRenderer;
import org.apache.fop.render.Renderer;
import org.apache.fop.render.intermediate.extensions.AbstractAction;
import org.apache.fop.render.intermediate.extensions.ActionSet;
import org.apache.fop.render.intermediate.extensions.Bookmark;
import org.apache.fop.render.intermediate.extensions.BookmarkTree;
import org.apache.fop.render.intermediate.extensions.GoToXYAction;
import org.apache.fop.render.intermediate.extensions.Link;
import org.apache.fop.render.intermediate.extensions.NamedDestination;
import org.apache.fop.render.intermediate.extensions.URIAction;
import org.apache.fop.render.pdf.PDFEventProducer;
import org.apache.fop.traits.BorderProps;
import org.apache.fop.traits.RuleStyle;

/**
 * This renderer implementation is an adapter to the {@link IFPainter} interface. It is used
 * to generate content using FOP's intermediate format.
 */
public class IFRenderer extends AbstractPathOrientedRenderer {

    //TODO Many parts of the Renderer infrastructure are using floats (coordinates in points)
    //instead of ints (in millipoints). A lot of conversion to and from is performed.
    //When the new IF is established, the Renderer infrastructure should be revisited so check
    //if optimizations can be done to avoid int->float->int conversions.

    /** logging instance */
    protected static final Log log = LogFactory.getLog(IFRenderer.class);

    /** XML MIME type */
    public static final String IF_MIME_TYPE = MimeConstants.MIME_FOP_IF;

    private IFDocumentHandler documentHandler;
    private IFPainter painter;

    /** If not null, the XMLRenderer will mimic another renderer by using its font setup. */
    protected Renderer mimic;

    private boolean inPageSequence;

    private Stack graphicContextStack = new Stack();
    private Stack viewportDimensionStack = new Stack();
    private IFGraphicContext graphicContext = new IFGraphicContext();
    //private Stack groupStack = new Stack();

    private Metadata documentMetadata;

    /**
     * Maps XSL-FO element IDs to their on-page XY-positions
     * Must be used in conjunction with the page reference to fully specify the details
     * of a "go-to" action.
     */
    private Map idPositions = new java.util.HashMap();

    /**
     * The "go-to" actions in idGoTos that are not complete yet
     */
    private List unfinishedGoTos = new java.util.ArrayList();
    // can't use a Set because PDFGoTo.equals returns true if the target is the same,
    // even if the object number differs

    /** Maps unique PageViewport key to page indices (for link target handling) */
    protected Map pageIndices = new java.util.HashMap();

    private BookmarkTree bookmarkTree;
    private List deferredDestinations = new java.util.ArrayList();
    private List deferredLinks = new java.util.ArrayList();
    private ActionSet actionSet = new ActionSet();

    private TextUtil textUtil = new TextUtil();

    private Stack<String> ids = new Stack<String>();

    /**
     * Main constructor
     *
     * @param userAgent the user agent that contains configuration details. This cannot be null.
     */
    public IFRenderer(FOUserAgent userAgent) {
        super(userAgent);
    }

    /** {@inheritDoc} */
    public String getMimeType() {
        return IF_MIME_TYPE;
    }

    /**
     * Sets the {@link IFDocumentHandler} to be used by the {@link IFRenderer}.
     * @param documentHandler the {@link IFDocumentHandler}
     */
    public void setDocumentHandler(IFDocumentHandler documentHandler) {
        this.documentHandler = documentHandler;
    }

    /** {@inheritDoc} */
    public void setupFontInfo(FontInfo inFontInfo) throws FOPException {
        if (this.documentHandler == null) {
            this.documentHandler = createDefaultDocumentHandler();
        }
        IFUtil.setupFonts(this.documentHandler, inFontInfo);
        this.fontInfo = inFontInfo;
    }

    private void handleIFException(IFException ife) {
        if (ife.getCause() instanceof SAXException) {
            throw new RuntimeException(ife.getCause());
        } else {
            throw new RuntimeException(ife);
        }
    }

    private void handleIFExceptionWithIOException(IFException ife) throws IOException {
        Throwable cause = ife.getCause();
        if (cause instanceof IOException) {
            throw (IOException) cause;
        } else {
            handleIFException(ife);
        }
    }

    /** {@inheritDoc} */
    public boolean supportsOutOfOrder() {
        return (this.documentHandler != null
                ? this.documentHandler.supportsPagesOutOfOrder() : false);
    }

    /**
     * Returns the document navigation handler if available/supported.
     * @return the document navigation handler or null if not supported
     */
    protected IFDocumentNavigationHandler getDocumentNavigationHandler() {
        return this.documentHandler.getDocumentNavigationHandler();
    }

    /**
     * Indicates whether document navigation features are supported by the document handler.
     * @return true if document navigation features are available
     */
    protected boolean hasDocumentNavigation() {
        return getDocumentNavigationHandler() != null;
    }

    /**
     * Creates a default {@link IFDocumentHandler} when none has been set.
     * @return the default IFDocumentHandler
     */
    protected IFDocumentHandler createDefaultDocumentHandler() {
        FOUserAgent userAgent = getUserAgent();
        IFSerializer serializer = new IFSerializer(new IFContext(userAgent));
        if (userAgent.isAccessibilityEnabled()) {
            userAgent.setStructureTreeEventHandler(serializer.getStructureTreeEventHandler());
        }
        return serializer;
    }

    /** {@inheritDoc} */
    public void startRenderer(OutputStream outputStream)
                throws IOException {
        try {
            if (outputStream != null) {
                StreamResult result = new StreamResult(outputStream);
                if (getUserAgent().getOutputFile() != null) {
                    result.setSystemId(
                            getUserAgent().getOutputFile().toURI().toURL().toExternalForm());
                }
                if (this.documentHandler == null) {
                    this.documentHandler = createDefaultDocumentHandler();
                }
                this.documentHandler.setResult(result);
            }
            super.startRenderer(null);
            if (log.isDebugEnabled()) {
                log.debug("Rendering areas via IF document handler ("
                        + this.documentHandler.getClass().getName() + ")...");
            }
            documentHandler.startDocument();
            documentHandler.startDocumentHeader();
        } catch (IFException e) {
            handleIFExceptionWithIOException(e);
        }
    }

    /** {@inheritDoc} */
    public void stopRenderer() throws IOException {
        try {
            if (this.inPageSequence) {
                documentHandler.endPageSequence();
                this.inPageSequence = false;
            }
            documentHandler.startDocumentTrailer();

            //Wrap up document navigation
            if (hasDocumentNavigation()) {
                finishOpenGoTos();
                Iterator iter = this.deferredDestinations.iterator();
                while (iter.hasNext()) {
                    NamedDestination dest = (NamedDestination)iter.next();
                    iter.remove();
                    getDocumentNavigationHandler().renderNamedDestination(dest);
                }

                if (this.bookmarkTree != null) {
                    getDocumentNavigationHandler().renderBookmarkTree(this.bookmarkTree);
                }
            }

            documentHandler.endDocumentTrailer();
            documentHandler.endDocument();
        } catch (IFException e) {
            handleIFExceptionWithIOException(e);
        }
        pageIndices.clear();
        idPositions.clear();
        actionSet.clear();
        super.stopRenderer();
        log.debug("Rendering finished.");
    }

    @Override
    public void setDocumentLocale(Locale locale) {
        documentHandler.setDocumentLocale(locale);
    }

    /** {@inheritDoc} */
    public void processOffDocumentItem(OffDocumentItem odi) {
        if (odi instanceof DestinationData) {
            // render Destinations
            renderDestination((DestinationData) odi);
        } else if (odi instanceof BookmarkData) {
            // render Bookmark-Tree
            renderBookmarkTree((BookmarkData) odi);
        } else if (odi instanceof OffDocumentExtensionAttachment) {
            ExtensionAttachment attachment = ((OffDocumentExtensionAttachment)odi).getAttachment();
            if (XMPMetadata.CATEGORY.equals(attachment.getCategory())) {
                renderXMPMetadata((XMPMetadata)attachment);
            } else {
                try {
                    this.documentHandler.handleExtensionObject(attachment);
                } catch (IFException ife) {
                    handleIFException(ife);
                }
            }
        }
    }

    private void renderDestination(DestinationData dd) {
        if (!hasDocumentNavigation()) {
            return;
        }
        String targetID = dd.getIDRef();
        if (targetID == null || targetID.length() == 0) {
            throw new IllegalArgumentException("DestinationData must contain a ID reference");
        }
        PageViewport pv = dd.getPageViewport();
        if (pv != null) {
            GoToXYAction action = getGoToActionForID(targetID, pv.getPageIndex());
            NamedDestination namedDestination = new NamedDestination(targetID, action);
            this.deferredDestinations.add(namedDestination);
        } else {
            //Warning already issued by AreaTreeHandler (debug level is sufficient)
            log.debug("Unresolved destination item received: " + dd.getIDRef());
        }
    }

    /**
     * Renders a Bookmark-Tree object
     * @param bookmarks the BookmarkData object containing all the Bookmark-Items
     */
    protected void renderBookmarkTree(BookmarkData bookmarks) {
        assert this.bookmarkTree == null;
        if (!hasDocumentNavigation()) {
            return;
        }
        this.bookmarkTree = new BookmarkTree();
        for (int i = 0; i < bookmarks.getCount(); i++) {
            BookmarkData ext = bookmarks.getSubData(i);
            Bookmark b = renderBookmarkItem(ext);
            bookmarkTree.addBookmark(b);
        }
    }

    private Bookmark renderBookmarkItem(BookmarkData bookmarkItem) {

        String targetID = bookmarkItem.getIDRef();
        if (targetID == null || targetID.length() == 0) {
            throw new IllegalArgumentException("DestinationData must contain a ID reference");
        }
        GoToXYAction action = null;
        PageViewport pv = bookmarkItem.getPageViewport();

        if (pv != null) {
            action = getGoToActionForID(targetID, pv.getPageIndex());
        } else {
            //Warning already issued by AreaTreeHandler (debug level is sufficient)
            log.debug("Bookmark with IDRef \"" + targetID + "\" has a null PageViewport.");
        }

        Bookmark b = new Bookmark(
                bookmarkItem.getBookmarkTitle(),
                bookmarkItem.showChildItems(),
                action);
        for (int i = 0; i < bookmarkItem.getCount(); i++) {
            b.addChildBookmark(renderBookmarkItem(bookmarkItem.getSubData(i)));
        }
        return b;
    }

    private void renderXMPMetadata(XMPMetadata metadata) {
        this.documentMetadata = metadata.getMetadata();
    }

    private GoToXYAction getGoToActionForID(String targetID, int pageIndex) {
        // Already a GoToXY present for this target? If not, create.
        GoToXYAction action = (GoToXYAction)actionSet.get(targetID);
        //GoToXYAction action = (GoToXYAction)idGoTos.get(targetID);
        if (action == null) {
            /* if (pageIndex < 0) {
                //pageIndex = page
            } */
            Point position = (Point)idPositions.get(targetID);
            // can the GoTo already be fully filled in?
            if (pageIndex >= 0 && position != null) {
                action = new GoToXYAction(targetID, pageIndex, position);
            } else {
                // Not complete yet, can't use getPDFGoTo:
                action = new GoToXYAction(targetID, pageIndex, null);
                unfinishedGoTos.add(action);
            }
            action = (GoToXYAction)actionSet.put(action);
            //idGoTos.put(targetID, action);
        }
        return action;
    }

    private void finishOpenGoTos() {
        int count = unfinishedGoTos.size();
        if (count > 0) {
            Point defaultPos = new Point(0, 0);  // top-o-page
            while (!unfinishedGoTos.isEmpty()) {
                GoToXYAction action = (GoToXYAction)unfinishedGoTos.get(0);
                noteGoToPosition(action, defaultPos);
            }
            PDFEventProducer eventProducer = PDFEventProducer.Provider.get(
                    getUserAgent().getEventBroadcaster());
            eventProducer.nonFullyResolvedLinkTargets(this, count);
            // dysfunctional if pageref is null
        }
    }

    private void noteGoToPosition(GoToXYAction action, Point position) {
        action.setTargetLocation(position);
        try {
            getDocumentNavigationHandler().addResolvedAction(action);
        } catch (IFException ife) {
            handleIFException(ife);
        }
        unfinishedGoTos.remove(action);
    }

    private void noteGoToPosition(GoToXYAction action, PageViewport pv, Point position) {
        action.setPageIndex(pv.getPageIndex());
        noteGoToPosition(action, position);
    }

    private void saveAbsolutePosition(String id, PageViewport pv,
            int relativeIPP, int relativeBPP, AffineTransform tf) {
        Point position = new Point(relativeIPP, relativeBPP);
        tf.transform(position, position);
        idPositions.put(id, position);
        // is there already a GoTo action waiting to be completed?
        GoToXYAction action = (GoToXYAction)actionSet.get(id);
        if (action != null) {
            noteGoToPosition(action, pv, position);
        }
    }

    private void saveAbsolutePosition(String id, int relativeIPP, int relativeBPP) {
        saveAbsolutePosition(id, this.currentPageViewport,
                             relativeIPP, relativeBPP, graphicContext.getTransform());
    }

    private void saveBlockPosIfTargetable(Block block) {
        String id = getTargetableID(block);
        if (hasDocumentNavigation() && id != null) {
            // FIXME: Like elsewhere in the renderer code, absolute and relative
            //        directions are happily mixed here. This makes sure that the
            //        links point to the right location, but it is not correct.
            int ipp = block.getXOffset();
            int bpp = block.getYOffset() + block.getSpaceBefore();
            int positioning = block.getPositioning();
            if (!(positioning == Block.FIXED || positioning == Block.ABSOLUTE)) {
                ipp += currentIPPosition;
                bpp += currentBPPosition;
            }
            saveAbsolutePosition(id, currentPageViewport, ipp, bpp, graphicContext.getTransform());
        }
    }

    private void saveInlinePosIfTargetable(InlineArea inlineArea) {
        String id = getTargetableID(inlineArea);
        if (hasDocumentNavigation() && id != null) {
            int extraMarginBefore = 5000; // millipoints
            int ipp = currentIPPosition;
            int bpp = currentBPPosition
                + inlineArea.getBlockProgressionOffset() - extraMarginBefore;
            saveAbsolutePosition(id, ipp, bpp);
        }
    }

    private String getTargetableID(Area area) {
        String id = (String) area.getTrait(Trait.PROD_ID);
        if (id == null || id.length() == 0
            || !currentPageViewport.isFirstWithID(id)
            || idPositions.containsKey(id)) {
            return null;
        } else {
            return id;
        }
    }

    /** {@inheritDoc} */
    public void startPageSequence(PageSequence pageSequence) {
        try {
            if (this.inPageSequence) {
                documentHandler.endPageSequence();
                documentHandler.getContext().setLanguage(null);
            } else {
                if (this.documentMetadata == null) {
                    this.documentMetadata = createDefaultDocumentMetadata();
                }
                documentHandler.handleExtensionObject(this.documentMetadata);
                documentHandler.endDocumentHeader();
                this.inPageSequence = true;
            }
            establishForeignAttributes(pageSequence.getForeignAttributes());
            documentHandler.getContext().setLanguage(pageSequence.getLocale());
            documentHandler.startPageSequence(null);
            resetForeignAttributes();
            processExtensionAttachments(pageSequence);
        } catch (IFException e) {
            handleIFException(e);
        }
    }

    private Metadata createDefaultDocumentMetadata() {
        Metadata xmp = new Metadata();
        DublinCoreAdapter dc = DublinCoreSchema.getAdapter(xmp);
        if (getUserAgent().getTitle() != null) {
            dc.setTitle(getUserAgent().getTitle());
        }
        if (getUserAgent().getAuthor() != null) {
            dc.addCreator(getUserAgent().getAuthor());
        }
        if (getUserAgent().getKeywords() != null) {
            dc.addSubject(getUserAgent().getKeywords());
        }
        XMPBasicAdapter xmpBasic = XMPBasicSchema.getAdapter(xmp);
        if (getUserAgent().getProducer() != null) {
            xmpBasic.setCreatorTool(getUserAgent().getProducer());
        } else {
            xmpBasic.setCreatorTool(Version.getVersion());
        }
        xmpBasic.setMetadataDate(new java.util.Date());
        if (getUserAgent().getCreationDate() != null) {
            xmpBasic.setCreateDate(getUserAgent().getCreationDate());
        } else {
            xmpBasic.setCreateDate(xmpBasic.getMetadataDate());
        }
        return xmp;
    }

    /** {@inheritDoc} */
    public void preparePage(PageViewport page) {
        super.preparePage(page);
    }

    /** {@inheritDoc} */
    public void renderPage(PageViewport page) throws IOException, FOPException {
        if (log.isTraceEnabled()) {
            log.trace("renderPage() " + page);
        }
        try {
            pageIndices.put(page.getKey(), page.getPageIndex());
            Rectangle viewArea = page.getViewArea();
            Dimension dim = new Dimension(viewArea.width, viewArea.height);

            establishForeignAttributes(page.getForeignAttributes());
            documentHandler.getContext().setPageIndex(page.getPageIndex());
            documentHandler.getContext().setPageNumber(page.getPageNumber());
            documentHandler.startPage(page.getPageIndex(), page.getPageNumberString(),
                    page.getSimplePageMasterName(), dim);
            resetForeignAttributes();
            documentHandler.startPageHeader();

            //Add page attachments to page header
            processExtensionAttachments(page);

            documentHandler.endPageHeader();
            this.painter = documentHandler.startPageContent();
            super.renderPage(page);
            this.painter = null;
            documentHandler.endPageContent();

            documentHandler.startPageTrailer();
            if (hasDocumentNavigation()) {
                Iterator iter = this.deferredLinks.iterator();
                while (iter.hasNext()) {
                    Link link = (Link)iter.next();
                    iter.remove();
                    getDocumentNavigationHandler().renderLink(link);
                }
            }
            documentHandler.endPageTrailer();

            establishForeignAttributes(page.getForeignAttributes());
            documentHandler.endPage();
            documentHandler.getContext().setPageIndex(-1);
            resetForeignAttributes();
        } catch (IFException e) {
            handleIFException(e);
        }
    }

    private void processExtensionAttachments(AreaTreeObject area) throws IFException {
        if (area.hasExtensionAttachments()) {
            for (ExtensionAttachment attachment : area.getExtensionAttachments()) {
                this.documentHandler.handleExtensionObject(attachment);
            }
        }
    }

    private void establishForeignAttributes(Map foreignAttributes) {
        documentHandler.getContext().setForeignAttributes(foreignAttributes);
    }

    private void resetForeignAttributes() {
        documentHandler.getContext().resetForeignAttributes();
    }

    private void establishStructureTreeElement(StructureTreeElement structureTreeElement) {
        documentHandler.getContext().setStructureTreeElement(structureTreeElement);
    }

    private void resetStructurePointer() {
        documentHandler.getContext().resetStructureTreeElement();
    }

    /** {@inheritDoc} */
    protected void saveGraphicsState() {
        graphicContextStack.push(graphicContext);
        graphicContext = (IFGraphicContext)graphicContext.clone();
    }

    /** {@inheritDoc} */
    protected void restoreGraphicsState() {
        while (graphicContext.getGroupStackSize() > 0) {
            IFGraphicContext.Group[] groups = graphicContext.dropGroups();
            for (int i = groups.length - 1; i >= 0; i--) {
                try {
                    groups[i].end(painter);
                } catch (IFException ife) {
                    handleIFException(ife);
                }
            }
        }
        graphicContext = (IFGraphicContext)graphicContextStack.pop();
    }

    private void pushGroup(IFGraphicContext.Group group) {
        graphicContext.pushGroup(group);
        try {
            group.start(painter);
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }

    /** {@inheritDoc} */
    protected List breakOutOfStateStack() {
        log.debug("Block.FIXED --> break out");
        List breakOutList = new java.util.ArrayList();
        while (!this.graphicContextStack.empty()) {
            //Handle groups
            IFGraphicContext.Group[] groups = graphicContext.getGroups();
            for (int j = groups.length - 1; j >= 0; j--) {
                try {
                    groups[j].end(painter);
                } catch (IFException ife) {
                    handleIFException(ife);
                }
            }

            breakOutList.add(0, this.graphicContext);
            graphicContext = (IFGraphicContext)graphicContextStack.pop();
        }
        return breakOutList;
    }

    /** {@inheritDoc} */
    protected void restoreStateStackAfterBreakOut(List breakOutList) {
        log.debug("Block.FIXED --> restoring context after break-out");
        for (Object aBreakOutList : breakOutList) {
            graphicContextStack.push(graphicContext);
            this.graphicContext = (IFGraphicContext) aBreakOutList;

            //Handle groups
            IFGraphicContext.Group[] groups = graphicContext.getGroups();
            for (IFGraphicContext.Group group : groups) {
                try {
                    group.start(painter);
                } catch (IFException ife) {
                    handleIFException(ife);
                }
            }
        }
        log.debug("restored.");
    }

    /** {@inheritDoc} */
    protected void concatenateTransformationMatrix(AffineTransform at) {
        if (!at.isIdentity()) {
            concatenateTransformationMatrixMpt(ptToMpt(at), false);
        }
    }

    private void concatenateTransformationMatrixMpt(AffineTransform at, boolean force) {
        if (force || !at.isIdentity()) {
            if (log.isTraceEnabled()) {
                log.trace("-----concatenateTransformationMatrix: " + at);
            }
            IFGraphicContext.Group group = new IFGraphicContext.Group(at);
            pushGroup(group);
        }
    }

    /** {@inheritDoc} */
    protected void beginTextObject() {
        //nop - Ignore, handled by painter internally
    }

    /** {@inheritDoc} */
    protected void endTextObject() {
        //nop - Ignore, handled by painter internally
    }

    /** {@inheritDoc} */
    protected void renderRegionViewport(RegionViewport viewport) {
        Dimension dim = new Dimension(viewport.getIPD(), viewport.getBPD());
        viewportDimensionStack.push(dim);
        super.renderRegionViewport(viewport);
        viewportDimensionStack.pop();
    }

    /** {@inheritDoc} */
    protected void renderBlockViewport(BlockViewport bv, List children) {
        //Essentially the same code as in the super class but optimized for the IF

        // Handle new layer.
        boolean inNewLayer = false;
        if (maybeStartLayer(bv)) {
            inNewLayer = true;
        }

        //This is the content-rect
        Dimension dim = new Dimension(bv.getIPD(), bv.getBPD());
        viewportDimensionStack.push(dim);

        // save positions
        int saveIP = currentIPPosition;
        int saveBP = currentBPPosition;

        CTM ctm = bv.getCTM();
        int borderPaddingStart = bv.getBorderAndPaddingWidthStart();
        int borderPaddingBefore = bv.getBorderAndPaddingWidthBefore();

        if (bv.getPositioning() == Block.ABSOLUTE
                || bv.getPositioning() == Block.FIXED) {

            //For FIXED, we need to break out of the current viewports to the
            //one established by the page. We save the state stack for restoration
            //after the block-container has been painted. See below.
            List breakOutList = null;
            if (bv.getPositioning() == Block.FIXED) {
                breakOutList = breakOutOfStateStack();
            }

            AffineTransform positionTransform = new AffineTransform();
            positionTransform.translate(bv.getXOffset(), bv.getYOffset());

            //"left/"top" (bv.getX/YOffset()) specify the position of the content rectangle
            positionTransform.translate(-borderPaddingStart, -borderPaddingBefore);

            //Free transformation for the block-container viewport
            String transf;
            transf = bv.getForeignAttributeValue(FOX_TRANSFORM);
            if (transf != null) {
                AffineTransform freeTransform = AWTTransformProducer.createAffineTransform(transf);
                positionTransform.concatenate(freeTransform);
            }

            saveGraphicsState();
            //Viewport position
            concatenateTransformationMatrixMpt(positionTransform, false);

            //Background and borders
            float bpwidth = (borderPaddingStart + bv.getBorderAndPaddingWidthEnd());
            float bpheight = (borderPaddingBefore + bv.getBorderAndPaddingWidthAfter());
            drawBackAndBorders(bv, 0, 0,
                    (dim.width + bpwidth) / 1000f, (dim.height + bpheight) / 1000f);

            //Shift to content rectangle after border painting
            AffineTransform contentRectTransform = new AffineTransform();
            contentRectTransform.translate(borderPaddingStart, borderPaddingBefore);
            concatenateTransformationMatrixMpt(contentRectTransform, false);

            //saveGraphicsState();
            //Set up coordinate system for content rectangle
            AffineTransform contentTransform = ctm.toAffineTransform();
            //concatenateTransformationMatrixMpt(contentTransform);
            startViewport(contentTransform, bv.getClipRectangle());

            currentIPPosition = 0;
            currentBPPosition = 0;
            renderBlocks(bv, children);

            endViewport();
            //restoreGraphicsState();
            restoreGraphicsState();

            if (breakOutList != null) {
                restoreStateStackAfterBreakOut(breakOutList);
            }

            currentIPPosition = saveIP;
            currentBPPosition = saveBP;
        } else {

            currentBPPosition += bv.getSpaceBefore();

            //borders and background in the old coordinate system
            handleBlockTraits(bv);

            //Advance to start of content area
            currentIPPosition += bv.getStartIndent();

            CTM tempctm = new CTM(containingIPPosition, currentBPPosition);
            ctm = tempctm.multiply(ctm);

            //Now adjust for border/padding
            currentBPPosition += borderPaddingBefore;

            startVParea(ctm, bv.getClipRectangle());
            currentIPPosition = 0;
            currentBPPosition = 0;
            renderBlocks(bv, children);
            endVParea();

            currentIPPosition = saveIP;
            currentBPPosition = saveBP;

            currentBPPosition += bv.getAllocBPD();
        }
        viewportDimensionStack.pop();
        maybeEndLayer(bv, inNewLayer);
    }

    /** {@inheritDoc} */
    public void renderInlineViewport(InlineViewport viewport) {
        StructureTreeElement structElem
                = (StructureTreeElement) viewport.getTrait(Trait.STRUCTURE_TREE_ELEMENT);
        establishStructureTreeElement(structElem);
        pushID(viewport);
        Dimension dim = new Dimension(viewport.getIPD(), viewport.getBPD());
        viewportDimensionStack.push(dim);
        super.renderInlineViewport(viewport);
        viewportDimensionStack.pop();
        resetStructurePointer();
        popID(viewport);
    }

    /** {@inheritDoc} */
    protected void startVParea(CTM ctm, Rectangle clippingRect) {
        if (log.isTraceEnabled()) {
            log.trace("startVParea() ctm=" + ctm + ", clippingRect=" + clippingRect);
        }
        AffineTransform at = new AffineTransform(ctm.toArray());
        startViewport(at, clippingRect);
        if (log.isTraceEnabled()) {
            log.trace("startVPArea: " + at + " --> " + graphicContext.getTransform());
        }
    }

    private void startViewport(AffineTransform at, Rectangle clipRect) {
        saveGraphicsState();
        try {
            IFGraphicContext.Viewport viewport = new IFGraphicContext.Viewport(
                    at, (Dimension)viewportDimensionStack.peek(), clipRect);
            graphicContext.pushGroup(viewport);
            viewport.start(painter);
        } catch (IFException e) {
            handleIFException(e);
        }
    }

    /** {@inheritDoc} */
    protected void endVParea() {
        log.trace("endVParea()");
        endViewport();
        if (log.isTraceEnabled()) {
            log.trace("endVPArea() --> " + graphicContext.getTransform());
        }
    }

    private void endViewport() {
        restoreGraphicsState();
    }

    /** {@inheritDoc} */
    protected void startLayer(String layer) {
        if (log.isTraceEnabled()) {
            log.trace("startLayer() layer=" + layer);
        }
        saveGraphicsState();
        pushGroup(new IFGraphicContext.Group(layer));
    }

    /** {@inheritDoc} */
    protected void endLayer() {
        if (log.isTraceEnabled()) {
            log.trace("endLayer()");
        }
        restoreGraphicsState();
    }

    /** {@inheritDoc} */
    protected void renderInlineArea(InlineArea inlineArea) {
        saveInlinePosIfTargetable(inlineArea);
        pushID(inlineArea);
        super.renderInlineArea(inlineArea);
        popID(inlineArea);
    }

    /** {@inheritDoc} */
    public void renderInlineParent(InlineParent ip) {
        // stuff we only need if a link must be created:
        Rectangle ipRect = null;
        AbstractAction action = null;
        // make sure the rect is determined *before* calling super!
        int ipp = currentIPPosition;
        int bpp = currentBPPosition + ip.getBlockProgressionOffset();
        ipRect = new Rectangle(ipp, bpp, ip.getIPD(), ip.getBPD());
        AffineTransform transform = graphicContext.getTransform();
        ipRect = transform.createTransformedShape(ipRect).getBounds();

        // render contents
        super.renderInlineParent(ip);

        boolean linkTraitFound = false;

        // try INTERNAL_LINK first
        Trait.InternalLink intLink = (Trait.InternalLink) ip.getTrait(Trait.INTERNAL_LINK);
        if (intLink != null) {
            linkTraitFound = true;
            String pvKey = intLink.getPVKey();
            String idRef = intLink.getIDRef();
            boolean pvKeyOK = pvKey != null && pvKey.length() > 0;
            boolean idRefOK = idRef != null && idRef.length() > 0;
            if (pvKeyOK && idRefOK) {
                Integer pageIndex = (Integer)pageIndices.get(pvKey);
                action = getGoToActionForID(idRef, (pageIndex != null ? pageIndex : -1));
            } else {
                //Warnings already issued by AreaTreeHandler
            }
        }

        // no INTERNAL_LINK, look for EXTERNAL_LINK
        if (!linkTraitFound) {
            Trait.ExternalLink extLink = (Trait.ExternalLink) ip.getTrait(Trait.EXTERNAL_LINK);
            if (extLink != null) {
                String extDest = extLink.getDestination();
                if (extDest != null && extDest.length() > 0) {
                    linkTraitFound = true;
                    action = new URIAction(extDest, extLink.newWindow());
                    action = actionSet.put(action);
                }
            }
        }

        // warn if link trait found but not allowed, else create link
        if (linkTraitFound) {
            StructureTreeElement structElem
                    = (StructureTreeElement) ip.getTrait(Trait.STRUCTURE_TREE_ELEMENT);
            action.setStructureTreeElement(structElem);
            Link link = new Link(action, ipRect);
            this.deferredLinks.add(link);
        }
    }

    /** {@inheritDoc} */
    protected void renderBlock(Block block) {
        if (log.isTraceEnabled()) {
            log.trace("renderBlock() " + block);
        }
        saveBlockPosIfTargetable(block);
        pushID(block);
        IFContext context = documentHandler.getContext();
        Locale oldLocale = context.getLanguage();
        context.setLanguage(block.getLocale());
        String oldLocation = context.getLocation();
        context.setLocation(block.getLocation());
        super.renderBlock(block);
        context.setLocation(oldLocation);
        context.setLanguage(oldLocale);
        popID(block);
    }

    private void pushID(Area area) {
        String prodID = (String) area.getTrait(Trait.PROD_ID);
        if (prodID != null) {
            ids.push(prodID);
            documentHandler.getContext().setID(prodID);
        }
    }

    private void popID(Area area) {
        String prodID = (String) area.getTrait(Trait.PROD_ID);
        if (prodID != null) {
            ids.pop();
            documentHandler.getContext().setID(ids.empty() ? "" : ids.peek());
        }
    }

    private Typeface getTypeface(String fontName) {
        Typeface tf = fontInfo.getFonts().get(fontName);
        if (tf instanceof LazyFont) {
            tf = ((LazyFont)tf).getRealFont();
        }
        return tf;
    }

    /** {@inheritDoc} */
    protected void renderText(TextArea text) {
        if (log.isTraceEnabled()) {
            log.trace("renderText() " + text);
        }
        renderInlineAreaBackAndBorders(text);
        Color ct = (Color) text.getTrait(Trait.COLOR);

        beginTextObject();

        String fontName = getInternalFontNameForArea(text);
        int size = (Integer) text.getTrait(Trait.FONT_SIZE);
        StructureTreeElement structElem
                = (StructureTreeElement) text.getTrait(Trait.STRUCTURE_TREE_ELEMENT);
        establishStructureTreeElement(structElem);

        // This assumes that *all* CIDFonts use a /ToUnicode mapping
        Typeface tf = getTypeface(fontName);

        FontTriplet triplet = (FontTriplet)text.getTrait(Trait.FONT);
        try {
            painter.setFont(triplet.getName(), triplet.getStyle(), triplet.getWeight(),
                    "normal", size, ct);
        } catch (IFException e) {
            handleIFException(e);
        }

        int rx = currentIPPosition + text.getBorderAndPaddingWidthStart();
        int bl = currentBPPosition + text.getBlockProgressionOffset() + text.getBaselineOffset();
        textUtil.flush();
        textUtil.setStartPosition(rx, bl);
        textUtil.setSpacing(text.getTextLetterSpaceAdjust(), text.getTextWordSpaceAdjust());
        documentHandler.getContext().setHyphenated(text.isHyphenated());
        super.renderText(text);

        textUtil.flush();
        renderTextDecoration(tf, size, text, bl, rx);
        documentHandler.getContext().setHyphenated(false);
        resetStructurePointer();
    }

    /** {@inheritDoc} */
    protected void renderWord(WordArea word) {
        Font font = getFontFromArea(word.getParentArea());
        String s = word.getWord();

        int[][] dp = word.getGlyphPositionAdjustments();
        Area parentArea = word.getParentArea();
        assert (parentArea instanceof AbstractTextArea);
        if (dp == null) {
            renderTextWithAdjustments(s, word.getLetterAdjustArray(), word.isReversed(),
                    font, (AbstractTextArea) parentArea);
        } else if (IFUtil.isDPOnlyDX(dp)) {
            renderTextWithAdjustments(s, IFUtil.convertDPToDX(dp), word.isReversed(),
                    font, (AbstractTextArea) parentArea);
        } else {
            renderTextWithAdjustments(s, dp, word.isReversed(),
                    font, (AbstractTextArea) parentArea);
        }

        super.renderWord(word);
    }

    /** {@inheritDoc} */
    protected void renderSpace(SpaceArea space) {
        Font font = getFontFromArea(space.getParentArea());
        String s = space.getSpace();

        Area parentArea = space.getParentArea();
        assert (parentArea instanceof AbstractTextArea);
        AbstractTextArea textArea = (AbstractTextArea) parentArea;
        renderTextWithAdjustments(s, (int[]) null, false, font, textArea);

        /* COMBINED is always false
        if (textUtil.COMBINED && space.isAdjustable()) {
            //Used for justified text, for example
            int tws = textArea.getTextWordSpaceAdjust()
                         + 2 * textArea.getTextLetterSpaceAdjust();
            if (tws != 0) {
                textUtil.adjust(tws);
            }
        }
        */
        super.renderSpace(space);
    }

    /**
     * Does low-level rendering of text using DX only position adjustments.
     * @param s text to render
     * @param dx an array of widths for letter adjustment (may be null)
     * @param reversed if true then text has been reversed (from logical order)
     * @param font to font in use
     * @param parentArea the parent text area to retrieve certain traits from
     */
    private void renderTextWithAdjustments(String s,
                              int[] dx, boolean reversed,
                              Font font, AbstractTextArea parentArea) {
        int l = s.length();
        if (l == 0) {
            return;
        }
        for (int i = 0; i < l; i++) {
            char ch = s.charAt(i);
            textUtil.addChar(ch);
            int glyphAdjust = 0;
            /* COMBINED is always false
            if (textUtil.COMBINED && font.hasChar(ch)) {
                int tls = (i < l - 1 ? parentArea.getTextLetterSpaceAdjust() : 0);
                glyphAdjust += tls;
            }
            */
            if (dx != null && i < l) {
                glyphAdjust += dx[i];
            }
            textUtil.adjust(glyphAdjust);
        }
    }

    /**
     * Does low-level rendering of text using generalized position adjustments.
     * @param s text to render
     * @param dp an array of 4-tuples, expressing [X,Y] placment
     * adjustments and [X,Y] advancement adjustments, in that order (may be null)
     * @param reversed if true then text has been reversed (from logical order)
     * @param font to font in use
     * @param parentArea the parent text area to retrieve certain traits from
     */
    private void renderTextWithAdjustments(String s,
                              int[][] dp, boolean reversed,
                              Font font, AbstractTextArea parentArea) {
        // assert !textUtil.COMBINED;
        for (int i = 0, n = s.length(); i < n; i++) {
            textUtil.addChar(s.charAt(i));
            if (dp != null) {
                textUtil.adjust(dp[i]);
            }
        }
    }

    private class TextUtil {
        private static final int INITIAL_BUFFER_SIZE = 16;
        private int[][] dp = new int[INITIAL_BUFFER_SIZE][];
        private final StringBuffer text = new StringBuffer();
        private int startx;
        private int starty;
        private int tls;
        private int tws;
        // private final static boolean COMBINED = false; // no longer referenced

        void addChar(char ch) {
            text.append(ch);
        }

        void adjust(int dx) {
            if (dx != 0) {
                adjust(new int[] {
                        dx,                         // xPlaAdjust
                        0,                          // yPlaAdjust
                        dx,                         // xAdvAdjust
                        0                           // yAdvAdjust
                    });
            }
        }

        void adjust(int[] pa) {
            if (!IFUtil.isPAIdentity(pa)) {
                int idx = text.length();
                if (idx > dp.length - 1) {
                    int newSize = Math.max(dp.length, idx + 1) + INITIAL_BUFFER_SIZE;
                    int[][] newDP = new int[newSize][];
                    // reuse prior DP[0]...DP[dp.length-1]
                    System.arraycopy(dp, 0, newDP, 0, dp.length);
                    // switch to new DP, leaving DP[dp.length]...DP[newDP.length-1] unpopulated
                    dp = newDP;
                }
                if (dp[idx - 1] == null) {
                    dp[idx - 1] = new int[4];
                }
                IFUtil.adjustPA(dp[idx - 1], pa);
            }
        }

        void reset() {
            if (text.length() > 0) {
                text.setLength(0);
                for (int i = 0, n = dp.length; i < n; i++) {
                    dp[i] = null;
                }
            }
        }

        void setStartPosition(int x, int y) {
            this.startx = x;
            this.starty = y;
        }

        void setSpacing(int tls, int tws) {
            this.tls = tls;
            this.tws = tws;
        }

        void flush() {
            if (text.length() > 0) {
                try {
                    /* if (COMBINED) { // COMBINED is always false
                        painter.drawText(startx, starty, 0, 0,
                                         trimAdjustments(dp, text.length()), text.toString());
                    } else { */
                        painter.drawText(startx, starty, tls, tws,
                                         trimAdjustments(dp, text.length()), text.toString());
                    /* } */
                } catch (IFException e) {
                    handleIFException(e);
                }
                reset();
            }
        }

        /**
         * Trim adjustments array <code>dp</code> to be no greater length than
         * text length, and where trailing all-zero entries are removed.
         * @param dp a position adjustments array (or null)
         * @param textLength the length of the associated text
         * @return either the original value of <code>dp</code> or a copy
         * of its first N significant adjustment entries, such that N is
         * no greater than text length, and the last entry has a non-zero
         * adjustment.
         */
        private int[][] trimAdjustments(int[][] dp, int textLength) {
            if (dp != null) {
                int tl = textLength;
                int pl = dp.length;
                int i  = (tl < pl) ? tl : pl;
                while (i > 0) {
                    int[] pa = dp [ i - 1 ];
                    if ((pa != null) && !IFUtil.isPAIdentity(pa)) {
                        break;
                    } else {
                        i--;
                    }
                }
                if (i == 0) {
                    dp = null;
                } else if (i < pl) {
                    dp = IFUtil.copyDP(dp, 0, i);
                }
            }
            return dp;
        }
    }

    /** {@inheritDoc} */
    public void renderImage(Image image, Rectangle2D pos) {
        drawImage(image.getURL(), pos, image.getForeignAttributes());
    }

    /** {@inheritDoc} */
    protected void drawImage(String uri, Rectangle2D pos, Map foreignAttributes) {
        Rectangle posInt = new Rectangle(
                currentIPPosition + (int)pos.getX(),
                currentBPPosition + (int)pos.getY(),
                (int)pos.getWidth(),
                (int)pos.getHeight());
        uri = URISpecification.getURL(uri);
        try {
            establishForeignAttributes(foreignAttributes);
            painter.drawImage(uri, posInt);
            resetForeignAttributes();
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }

    /** {@inheritDoc} */
    public void renderForeignObject(ForeignObject fo, Rectangle2D pos) {
        endTextObject();
        Rectangle posInt = new Rectangle(
                currentIPPosition + (int)pos.getX(),
                currentBPPosition + (int)pos.getY(),
                (int)pos.getWidth(),
                (int)pos.getHeight());
        Document doc = fo.getDocument();
        try {
            establishForeignAttributes(fo.getForeignAttributes());
            painter.drawImage(doc, posInt);
            resetForeignAttributes();
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }

    /** {@inheritDoc} */
    public void renderLeader(Leader area) {
        renderInlineAreaBackAndBorders(area);

        int style = area.getRuleStyle();
        int ruleThickness = area.getRuleThickness();
        int startx = currentIPPosition + area.getBorderAndPaddingWidthStart();
        int starty = currentBPPosition + area.getBlockProgressionOffset() + (ruleThickness / 2);
        int endx = currentIPPosition
                        + area.getBorderAndPaddingWidthStart()
                        + area.getIPD();
        Color col = (Color)area.getTrait(Trait.COLOR);

        Point start = new Point(startx, starty);
        Point end = new Point(endx, starty);
        try {
            painter.drawLine(start, end, ruleThickness, col, RuleStyle.valueOf(style));
        } catch (IFException ife) {
            handleIFException(ife);
        }

        super.renderLeader(area);
    }

    /** {@inheritDoc} */
    protected void clip() {
        throw new IllegalStateException("Not used");
    }

    /** {@inheritDoc} */
    protected void clipRect(float x, float y, float width, float height) {
        pushGroup(new IFGraphicContext.Group());
        try {
            painter.clipRect(toMillipointRectangle(x, y, width, height));
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }
    /** {@inheritDoc} */
    protected void clipBackground(float startx, float starty,
            float width, float height,
            BorderProps bpsBefore, BorderProps bpsAfter,
            BorderProps bpsStart, BorderProps bpsEnd) {
        pushGroup(new IFGraphicContext.Group());
        Rectangle rect = toMillipointRectangle(startx, starty, width, height);
        try {
            painter.clipBackground(rect,
                 bpsBefore,  bpsAfter, bpsStart,  bpsEnd);
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }
    /** {@inheritDoc} */
    protected void closePath() {
        throw new IllegalStateException("Not used");
    }

    /** {@inheritDoc} */
    protected void drawBackground(float startx, float starty,
            float width, float height,
            Trait.Background back,
            BorderProps bpsBefore, BorderProps bpsAfter,
            BorderProps bpsStart, BorderProps bpsEnd) {
        if (painter.isBackgroundRequired(bpsBefore, bpsAfter, bpsStart, bpsEnd)) {
            super.drawBackground(startx, starty, width, height,
                     back,  bpsBefore,  bpsAfter,
                     bpsStart,  bpsEnd);
        }
    }

    /** {@inheritDoc} */
    protected void drawBorders(float startx, float starty,
            float width, float height,
            BorderProps bpsBefore, BorderProps bpsAfter,
            BorderProps bpsStart, BorderProps bpsEnd, int level, Color innerBackgroundColor) {
        Rectangle rect = toMillipointRectangle(startx, starty, width, height);
        try {
            BorderProps bpsTop = bpsBefore;
            BorderProps bpsBottom = bpsAfter;
            BorderProps bpsLeft;
            BorderProps bpsRight;
            if ((level == -1) || ((level & 1) == 0)) {
                bpsLeft = bpsStart;
                bpsRight = bpsEnd;
            } else {
                bpsLeft = bpsEnd;
                bpsRight = bpsStart;
            }
            painter.drawBorderRect(rect, bpsTop, bpsBottom, bpsLeft, bpsRight, innerBackgroundColor);
        } catch (IFException ife) {
            handleIFException(ife);
        }
    }

    /** {@inheritDoc} */
    protected void drawBorderLine(float x1, float y1, float x2, float y2, boolean horz,
            boolean startOrBefore, int style, Color col) {
        //Simplified implementation that is only used by renderTextDecoration()
        //drawBorders() is overridden and uses the Painter's high-level method drawBorderRect()
        updateColor(col, true);
        fillRect(x1, y1, x2 - x1, y2 - y1);
    }

    private int toMillipoints(float coordinate) {
        return Math.round(coordinate * 1000);
    }

    private Rectangle toMillipointRectangle(float x, float y, float width, float height) {
        return new Rectangle(
                toMillipoints(x),
                toMillipoints(y),
                toMillipoints(width),
                toMillipoints(height));
    }

    /** {@inheritDoc} */
    protected void fillRect(float x, float y, float width, float height) {
        try {
            painter.fillRect(
                    toMillipointRectangle(x, y, width, height),
                    this.graphicContext.getPaint());
        } catch (IFException e) {
            handleIFException(e);
        }
    }

    /** {@inheritDoc} */
    protected void moveTo(float x, float y) {
        throw new IllegalStateException("Not used");
    }

    /** {@inheritDoc} */
    protected void lineTo(float x, float y) {
        throw new IllegalStateException("Not used");
    }

    /** {@inheritDoc} */
    protected void updateColor(Color col, boolean fill) {
        if (fill) {
            this.graphicContext.setPaint(col);
        } else {
            this.graphicContext.setColor(col);
        }

    }

}
