# PageDrawer.java

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

    import java.awt.BasicStroke;
    import java.awt.Color;
    import java.awt.Graphics;
    import java.awt.Graphics2D;
    import java.awt.GraphicsConfiguration;
    import java.awt.GraphicsDevice;
    import java.awt.Image;
    import java.awt.Paint;
    import java.awt.Point;
    import java.awt.Rectangle;
    import java.awt.RenderingHints;
    import java.awt.Shape;
    import java.awt.Stroke;
    import java.awt.TexturePaint;
    import java.awt.Transparency;
    import java.awt.color.ColorSpace;
    import java.awt.geom.AffineTransform;
    import java.awt.geom.Area;
    import java.awt.geom.GeneralPath;
    import java.awt.geom.Path2D;
    import java.awt.geom.PathIterator;
    import java.awt.geom.Point2D;
    import java.awt.geom.Rectangle2D;
    import java.awt.image.BufferedImage;
    import java.awt.image.ColorModel;
    import java.awt.image.ComponentColorModel;
    import java.awt.image.DataBuffer;
    import java.awt.image.DataBufferByte;
    import java.awt.image.Raster;
    import java.awt.image.WritableRaster;
    import java.io.IOException;
    import java.util.ArrayDeque;
    import java.util.ArrayList;
    import java.util.Deque;
    import java.util.HashMap;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;

    import org.apache.commons.logging.Log;
    import org.apache.commons.logging.LogFactory;
    import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
    import org.apache.pdfbox.cos.COSArray;
    import org.apache.pdfbox.cos.COSBase;
    import org.apache.pdfbox.cos.COSDictionary;
    import org.apache.pdfbox.cos.COSName;
    import org.apache.pdfbox.pdmodel.PDResources;
    import org.apache.pdfbox.pdmodel.common.PDRectangle;
    import org.apache.pdfbox.pdmodel.common.function.PDFunction;
    import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDPropertyList;
    import org.apache.pdfbox.pdmodel.font.PDFont;
    import org.apache.pdfbox.pdmodel.font.PDType3Font;
    import org.apache.pdfbox.pdmodel.font.PDVectorFont;
    import org.apache.pdfbox.pdmodel.graphics.PDLineDashPattern;
    import org.apache.pdfbox.pdmodel.graphics.PDXObject;
    import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
    import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
    import org.apache.pdfbox.pdmodel.graphics.color.PDColorSpace;
    import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceGray;
    import org.apache.pdfbox.pdmodel.graphics.color.PDICCBased;
    import org.apache.pdfbox.pdmodel.graphics.color.PDPattern;
    import org.apache.pdfbox.pdmodel.graphics.color.PDSeparation;
    import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
    import org.apache.pdfbox.pdmodel.graphics.form.PDTransparencyGroup;
    import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
    import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
    import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
    import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup.RenderState;
    import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentMembershipDictionary;
    import org.apache.pdfbox.pdmodel.graphics.pattern.PDAbstractPattern;
    import org.apache.pdfbox.pdmodel.graphics.pattern.PDShadingPattern;
    import org.apache.pdfbox.pdmodel.graphics.pattern.PDTilingPattern;
    import org.apache.pdfbox.pdmodel.graphics.shading.PDShading;
    import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
    import org.apache.pdfbox.pdmodel.graphics.state.PDGraphicsState;
    import org.apache.pdfbox.pdmodel.graphics.state.PDSoftMask;
    import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode;
    import org.apache.pdfbox.pdmodel.interactive.annotation.AnnotationFilter;
    import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
    import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationUnknown;
    import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
    import org.apache.pdfbox.util.Matrix;
    import org.apache.pdfbox.util.Vector;

    /**
     * Paints a page in a PDF document to a Graphics context. May be subclassed to provide custom
     * rendering.
     *
     * <p>
     * If you want to do custom graphics processing rather than Graphics2D rendering, then you should
     * subclass {@link PDFGraphicsStreamEngine} instead. Subclassing PageDrawer is only suitable for
     * cases where the goal is to render onto a {@link Graphics2D} surface. In that case you'll also
     * have to subclass {@link PDFRenderer} and override
     * {@link PDFRenderer#createPageDrawer(PageDrawerParameters)}. See the <i>OpaquePDFRenderer.java</i>
     * example in the source code download on how to do this.
     *
     * @author Ben Litchfield
     */
    public class PageDrawer extends PDFGraphicsStreamEngine
    {
        private static final Log LOG = LogFactory.getLog(PageDrawer.class);

        // parent document renderer - note: this is needed for not-yet-implemented resource caching
        private final PDFRenderer renderer;

        private final boolean subsamplingAllowed;

        // the graphics device to draw to, xform is the initial transform of the device (i.e. DPI)
        private Graphics2D graphics;
        private AffineTransform xform;
        private float xformScalingFactorX;
        private float xformScalingFactorY;

        // the page box to draw (usually the crop box but may be another)
        private PDRectangle pageSize;

        // whether image of a transparency group must be flipped
        // needed when in a tiling pattern
        private boolean flipTG = false;

        // clipping winding rule used for the clipping path
        private int clipWindingRule = -1;
        private GeneralPath linePath = new GeneralPath();

        // last clipping path
        private List<Path2D> lastClips;

        // clip when drawPage() is called, can be null, must be intersected when clipping
        private Shape initialClip;

        // shapes of glyphs being drawn to be used for clipping
        private List<Shape> textClippings;

        // glyph caches
        private final Map<PDFont, GlyphCache> glyphCaches = new HashMap<>();

        private final TilingPaintFactory tilingPaintFactory = new TilingPaintFactory(this);

        private final Deque<TransparencyGroup> transparencyGroupStack = new ArrayDeque<>();

        // if greater zero the content is hidden and will not be rendered
        private int nestedHiddenOCGCount;

        private final RenderDestination destination;
        private final RenderingHints renderingHints;
        private final float imageDownscalingOptimizationThreshold;

        /**
        * Default annotations filter, returns all annotations
        */
        private AnnotationFilter annotationFilter = annotation -> true;

        /**
         * Constructor.
         *
         * @param parameters Parameters for page drawing.
         * @throws IOException If there is an error loading properties from the file.
         */
        public PageDrawer(PageDrawerParameters parameters) throws IOException
        {
            super(parameters.getPage());
            this.renderer = parameters.getRenderer();
            this.subsamplingAllowed = parameters.isSubsamplingAllowed();
            this.destination = parameters.getDestination();
            this.renderingHints = parameters.getRenderingHints();
            this.imageDownscalingOptimizationThreshold =
                    parameters.getImageDownscalingOptimizationThreshold();
        }

        /**
         * Return the AnnotationFilter.
         *
         * @return the AnnotationFilter
         */
        public AnnotationFilter getAnnotationFilter()
        {
            return annotationFilter;
        }

        /**
         * Set the AnnotationFilter.
         *
         * <p>Allows to only render annotation accepted by the filter.
         *
         * @param annotationFilter the AnnotationFilter
         */
        public void setAnnotationFilter(AnnotationFilter annotationFilter)
        {
            this.annotationFilter = annotationFilter;
        }

        /**
         * Returns the parent renderer.
         */
        public final PDFRenderer getRenderer()
        {
            return renderer;
        }

        /**
         * Returns the underlying Graphics2D. May be null if drawPage has not yet been called.
         */
        protected final Graphics2D getGraphics()
        {
            return graphics;
        }

        /**
         * Returns the current line path. This is reset to empty after each fill/stroke.
         */
        protected final GeneralPath getLinePath()
        {
            return linePath;
        }

        /**
         * Sets high-quality rendering hints on the current Graphics2D.
         */
        private void setRenderingHints()
        {
            graphics.addRenderingHints(renderingHints);
        }

        /**
         * Draws the page to the requested context.
         *
         * @param g The graphics context to draw onto.
         * @param pageSize The size of the page to draw.
         * @throws IOException If there is an IO error while drawing the page.
         */
        public void drawPage(Graphics2D g, PDRectangle pageSize) throws IOException
        {
            graphics = g;
            xform = graphics.getTransform();
            Matrix m = new Matrix(xform);
            xformScalingFactorX = Math.abs(m.getScalingFactorX());
            xformScalingFactorY = Math.abs(m.getScalingFactorY());
            initialClip = graphics.getClip();
            this.pageSize = pageSize;

            setRenderingHints();

            graphics.translate(0, pageSize.getHeight());
            graphics.scale(1, -1);

            // adjust for non-(0,0) crop box
            graphics.translate(-pageSize.getLowerLeftX(), -pageSize.getLowerLeftY());

            processPage(getPage());

            for (PDAnnotation annotation : getPage().getAnnotations(annotationFilter))
            {
                showAnnotation(annotation);
            }

            graphics = null;
        }

        /**
         * Draws the pattern stream to the requested context.
         *
         * @param g The graphics context to draw onto.
         * @param pattern The tiling pattern to be used.
         * @param colorSpace color space for this tiling.
         * @param color color for this tiling.
         * @param patternMatrix the pattern matrix
         * @throws IOException If there is an IO error while drawing the page.
         */
        void drawTilingPattern(Graphics2D g, PDTilingPattern pattern, PDColorSpace colorSpace,
                                      PDColor color, Matrix patternMatrix) throws IOException
        {
            Graphics2D savedGraphics = graphics;
            graphics = g;

            GeneralPath savedLinePath = linePath;
            linePath = new GeneralPath();
            int savedClipWindingRule = clipWindingRule;
            clipWindingRule = -1;

            List<Path2D> savedLastClips = lastClips;
            lastClips = null;
            Shape savedInitialClip = initialClip;
            initialClip = null;

            boolean savedFlipTG = flipTG;
            flipTG = true;

            setRenderingHints();
            processTilingPattern(pattern, color, colorSpace, patternMatrix);

            flipTG = savedFlipTG;
            graphics = savedGraphics;
            linePath = savedLinePath;
            lastClips = savedLastClips;
            initialClip = savedInitialClip;
            clipWindingRule = savedClipWindingRule;
        }

        private float clampColor(float color)
        {
            return color < 0 ? 0 : (color > 1 ? 1 : color);
        }

        /**
         * Returns an AWT paint for the given PDColor.
         *
         * @param color The color to get a paint for. This can be an actual color or a pattern.
         * @throws IOException
         */
        protected Paint getPaint(PDColor color) throws IOException
        {
            PDColorSpace colorSpace = color.getColorSpace();
            if (colorSpace instanceof PDSeparation &&
                    "None".equals(((PDSeparation) colorSpace).getColorantName()))
            {
                // PDFBOX-4900: "The special colorant name None shall not produce any visible output"
                //TODO better solution needs to be found for all occurences where toRGB is called
                return new Color(0, 0, 0, 0);
            }
            else if (!(colorSpace instanceof PDPattern))
            {
                float[] rgb = colorSpace.toRGB(color.getComponents());
                return new Color(clampColor(rgb[0]), clampColor(rgb[1]), clampColor(rgb[2]));
            }
            else
            {
                PDPattern patternSpace = (PDPattern)colorSpace;
                PDAbstractPattern pattern = patternSpace.getPattern(color);
                if (pattern instanceof PDTilingPattern)
                {
                    PDTilingPattern tilingPattern = (PDTilingPattern) pattern;

                    if (tilingPattern.getPaintType() == PDTilingPattern.PAINT_COLORED)
                    {
                        // colored tiling pattern
                        return tilingPaintFactory.create(tilingPattern, null, null, xform);
                    }
                    else
                    {
                        // uncolored tiling pattern
                        return tilingPaintFactory.create(tilingPattern,
                                patternSpace.getUnderlyingColorSpace(), color, xform);
                    }
                }
                else
                {
                    PDShadingPattern shadingPattern = (PDShadingPattern)pattern;
                    PDShading shading = shadingPattern.getShading();
                    if (shading == null)
                    {
                        LOG.error("shadingPattern is null, will be filled with transparency");
                        return new Color(0,0,0,0);
                    }
                    return shading.toPaint(Matrix.concatenate(getInitialMatrix(),
                                                              shadingPattern.getMatrix()));
                }
            }
        }

        /**
         * Sets the clipping path using caching for performance. We track lastClip manually because
         * {@link Graphics2D#getClip()} returns a new object instead of the same one passed to
         * {@link Graphics2D#setClip(java.awt.Shape) setClip()}. You may need to call this if you override
         * {@link #showGlyph(Matrix, PDFont, int, Vector) showGlyph()}. See
         * <a href="https://issues.apache.org/jira/browse/PDFBOX-5093">PDFBOX-5093</a> for more.
         */
        protected final void setClip()
        {
            List<Path2D> clippingPaths = getGraphicsState().getCurrentClippingPaths();
            if (clippingPaths != lastClips)
            {
                transferClip(graphics);
                if (initialClip != null)
                {
                    // apply the remembered initial clip, but transform it first
                    //TODO see PDFBOX-4583
                }
                lastClips = clippingPaths;
            }
        }

        /**
         * Transfer clip to the destination device. Override this if you want to avoid to do slow
         * intersecting operations but want the destination device to do this (e.g. SVG). You can get
         * the individual clippings via {@link PDGraphicsState#getCurrentClippingPaths()}. See
         * <a href="https://issues.apache.org/jira/browse/PDFBOX-5258">PDFBOX-5258</a> for sample code.
         *
         * @param graphics graphics device
         */
        protected void transferClip(Graphics2D graphics)
        {
            Area clippingPath = getGraphicsState().getCurrentClippingPath();
            if (clippingPath.getPathIterator(null).isDone())
            {
                // PDFBOX-4821: avoid bug with java printing that empty clipping path is ignored by
                // replacing with empty rectangle, works because this is not an empty path
                graphics.setClip(new Rectangle());
            }
            else
            {
                graphics.setClip(clippingPath);
            }
        }

        @Override
        public void beginText() throws IOException
        {
            setClip();
            beginTextClip();
        }

        @Override
        public void endText() throws IOException
        {
            endTextClip();
        }

        /**
         * Begin buffering the text clipping path, if any.
         */
        private void beginTextClip()
        {
            // buffer the text clippings because they represents a single clipping area
            textClippings = new ArrayList<>();
        }

        /**
         * End buffering the text clipping path, if any.
         */
        private void endTextClip()
        {
            PDGraphicsState state = getGraphicsState();
            RenderingMode renderingMode = state.getTextState().getRenderingMode();

            // apply the buffered clip as one area
            if (renderingMode.isClip() && !textClippings.isEmpty())
            {
                // PDFBOX-4150: this is much faster than using textClippingArea.add(new Area(glyph))
                // https://stackoverflow.com/questions/21519007/fast-union-of-shapes-in-java
                GeneralPath path = new GeneralPath(Path2D.WIND_NON_ZERO, textClippings.size());
                for (Shape shape : textClippings)
                {
                    path.append(shape, false);
                }
                state.intersectClippingPath(path);
                textClippings = new ArrayList<>();

                // PDFBOX-3681: lastClip needs to be reset, because after intersection it is still the same
                // object, thus setClip() would believe that it is cached.
                lastClips = null;
            }
        }

        @Override
        protected void showFontGlyph(Matrix textRenderingMatrix, PDFont font, int code,
                Vector displacement) throws IOException
        {
            AffineTransform at = textRenderingMatrix.createAffineTransform();
            at.concatenate(font.getFontMatrix().createAffineTransform());

            // create cache if it does not exist
            PDVectorFont vectorFont = (PDVectorFont) font;
            GlyphCache cache = glyphCaches.get(font);
            if (cache == null)
            {
                cache = new GlyphCache(vectorFont);
                glyphCaches.put(font, cache);
            }

            GeneralPath path = cache.getPathForCharacterCode(code);
            drawGlyph(path, font, code, displacement, at);
        }

        /**
         * Renders a glyph.
         *
         * @param path the GeneralPath for the glyph
         * @param font the font
         * @param code character code
         * @param displacement the glyph's displacement (advance)
         * @param at the transformation
         * @throws IOException if something went wrong
         */
        private void drawGlyph(GeneralPath path, PDFont font, int code, Vector displacement, AffineTransform at) throws IOException
        {
            PDGraphicsState state = getGraphicsState();
            RenderingMode renderingMode = state.getTextState().getRenderingMode();

            if (path != null)
            {
                // Stretch non-embedded glyph if it does not match the height/width contained in the PDF.
                // Vertical fonts have zero X displacement, so the following code scales to 0 if we don't skip it.
                // TODO: How should vertical fonts be handled?
                if (!font.isEmbedded() && !font.isVertical() && !font.isStandard14() && font.hasExplicitWidth(code))
                {
                    float fontWidth = font.getWidthFromFont(code);
                    if (fontWidth > 0 && // ignore spaces
                            Math.abs(fontWidth - displacement.getX() * 1000) > 0.0001)
                    {
                        float pdfWidth = displacement.getX() * 1000;
                        at.scale(pdfWidth / fontWidth, 1);
                    }
                }

                // render glyph
                Shape glyph = at.createTransformedShape(path);

                if (isContentRendered())
                {
                    if (renderingMode.isFill())
                    {
                        graphics.setComposite(state.getNonStrokingJavaComposite());
                        graphics.setPaint(getNonStrokingPaint());
                        setClip();
                        graphics.fill(glyph);
                    }

                    if (renderingMode.isStroke())
                    {
                        graphics.setComposite(state.getStrokingJavaComposite());
                        graphics.setPaint(getStrokingPaint());
                        graphics.setStroke(getStroke());
                        setClip();
                        graphics.draw(glyph);
                    }
                }

                if (renderingMode.isClip())
                {
                    textClippings.add(glyph);
                }
            }
        }

        @Override
        protected void showType3Glyph(Matrix textRenderingMatrix, PDType3Font font, int code,
                Vector displacement) throws IOException
        {
            PDGraphicsState state = getGraphicsState();
            RenderingMode renderingMode = state.getTextState().getRenderingMode();
            if (!RenderingMode.NEITHER.equals(renderingMode))
            {
                super.showType3Glyph(textRenderingMatrix, font, code, displacement);
            }
        }

        @Override
        public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3)
        {
            // to ensure that the path is created in the right direction, we have to create
            // it by combining single lines instead of creating a simple rectangle
            linePath.moveTo((float) p0.getX(), (float) p0.getY());
            linePath.lineTo((float) p1.getX(), (float) p1.getY());
            linePath.lineTo((float) p2.getX(), (float) p2.getY());
            linePath.lineTo((float) p3.getX(), (float) p3.getY());

            // close the subpath instead of adding the last line so that a possible set line
            // cap style isn't taken into account at the "beginning" of the rectangle
            linePath.closePath();
        }

        private Paint applySoftMaskToPaint(Paint parentPaint, PDSoftMask softMask) throws IOException
        {
            if (softMask == null || softMask.getGroup() == null)
            {
                return parentPaint;
            }
            PDColor backdropColor = null;
            if (COSName.LUMINOSITY.equals(softMask.getSubType()))
            {
                COSArray backdropColorArray = softMask.getBackdropColor();
                if (backdropColorArray != null)
                {
                    PDTransparencyGroup form = softMask.getGroup();
                    PDColorSpace colorSpace = form.getGroup().getColorSpace(form.getResources());
                    if (colorSpace != null)
                    {
                        backdropColor = new PDColor(backdropColorArray, colorSpace);
                    }
                }
            }
            TransparencyGroup transparencyGroup = new TransparencyGroup(softMask.getGroup(), true,
                    softMask.getInitialTransformationMatrix(), backdropColor);
            BufferedImage image = transparencyGroup.getImage();
            if (image == null)
            {
                // Adobe Reader ignores empty softmasks instead of using bc color
                // sample file: PDFJS-6967_reduced_outside_softmask.pdf
                return parentPaint;
            }
            BufferedImage gray = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
            if (COSName.ALPHA.equals(softMask.getSubType()))
            {
                gray.setData(image.getAlphaRaster());
            }
            else if (COSName.LUMINOSITY.equals(softMask.getSubType()))
            {
                Graphics g = gray.getGraphics();
                g.drawImage(image, 0, 0, null);
                g.dispose();
            }
            else
            {
                throw new IOException("Invalid soft mask subtype.");
            }
            gray = adjustImage(gray);

            Rectangle2D tpgBounds = transparencyGroup.getBounds();
            return new SoftMask(parentPaint, gray, tpgBounds, backdropColor, softMask.getTransferFunction());
        }

        // returns the image adjusted for applySoftMaskToPaint().
        private BufferedImage adjustImage(BufferedImage gray)
        {
            AffineTransform at = new AffineTransform(xform);
            at.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);

            Rectangle originalBounds = new Rectangle(gray.getWidth(), gray.getHeight());
            Rectangle2D transformedBounds = at.createTransformedShape(originalBounds).getBounds2D();
            at.preConcatenate(AffineTransform.getTranslateInstance(-transformedBounds.getMinX(),
                    -transformedBounds.getMinY()));

            int width = (int) Math.ceil(transformedBounds.getWidth());
            int height = (int) Math.ceil(transformedBounds.getHeight());

            if (width == gray.getWidth() && height == gray.getHeight() && at.isIdentity())
            {
                return gray;
            }

            BufferedImage transformedGray = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
            Graphics2D g2 = (Graphics2D) transformedGray.getGraphics();
            g2.drawImage(gray, at, null);
            g2.dispose();
            return transformedGray;
        }

        // returns the stroking AWT Paint
        private Paint getStrokingPaint() throws IOException
        {
            PDGraphicsState graphicsState = getGraphicsState();
            return applySoftMaskToPaint(
                    getPaint(graphicsState.getStrokingColor()), graphicsState.getSoftMask());
        }

        /**
         * Returns the non-stroking AWT Paint. You may need to call this if you override
         * {@link #showGlyph(Matrix, PDFont, int, Vector) showGlyph()}. See
         * <a href="https://issues.apache.org/jira/browse/PDFBOX-5093">PDFBOX-5093</a> for more.
         *
         * @return The non-stroking AWT Paint.
         * @throws IOException
         */
        protected final Paint getNonStrokingPaint() throws IOException
        {
            PDGraphicsState graphicsState = getGraphicsState();
            return applySoftMaskToPaint(
                    getPaint(graphicsState.getNonStrokingColor()), graphicsState.getSoftMask());
        }

        // create a new stroke based on the current CTM and the current stroke
        private Stroke getStroke()
        {
            PDGraphicsState state = getGraphicsState();

            // apply the CTM
            float lineWidth = transformWidth(state.getLineWidth());

            // minimum line width as used by Adobe Reader
            if (lineWidth < 0.25)
            {
                lineWidth = 0.25f;
            }

            PDLineDashPattern dashPattern = state.getLineDashPattern();
            // PDFBOX-5168: show an all-zero dash array line invisible like Adobe does
            // must do it here because getDashArray() sets minimum width because of JVM bugs
            float[] dashArray = dashPattern.getDashArray();
            if (isAllZeroDash(dashArray))
            {
                return (Shape p) -> new Area();
            }
            float phaseStart = dashPattern.getPhase();
            dashArray = getDashArray(dashPattern);
            phaseStart = transformWidth(phaseStart);

            int lineCap = Math.min(2, Math.max(0, state.getLineCap())); // legal values 0..2
            int lineJoin = Math.min(2, Math.max(0, state.getLineJoin()));
            float miterLimit = state.getMiterLimit();
            if (miterLimit < 1)
            {
                LOG.warn("Miter limit must be >= 1, value " + miterLimit + " is ignored");
                miterLimit = 10;
            }
            return new BasicStroke(lineWidth, lineCap, lineJoin,
                                   miterLimit, dashArray, phaseStart);
        }

        private boolean isAllZeroDash(float[] dashArray)
        {
            if (dashArray.length > 0)
            {
                for (int i = 0; i < dashArray.length; ++i)
                {
                    if (dashArray[i] != 0)
                    {
                        return false;
                    }
                }
                return true;
            }
            return false;
        }

        private float[] getDashArray(PDLineDashPattern dashPattern)
        {
            float[] dashArray = dashPattern.getDashArray();
            int phase = dashPattern.getPhase();
            // avoid empty, infinite and NaN values (PDFBOX-3360)
            if (dashArray.length == 0 || Float.isInfinite(phase) || Float.isNaN(phase))
            {
                return null;
            }
            for (int i = 0; i < dashArray.length; ++i)
            {
                if (Float.isInfinite(dashArray[i]) || Float.isNaN(dashArray[i]))
                {
                    return null;
                }
            }
            for (int i = 0; i < dashArray.length; ++i)
            {
                // apply the CTM
                float w = transformWidth(dashArray[i]);
                // minimum line dash width avoids JVM crash,
                // see PDFBOX-2373, PDFBOX-2929, PDFBOX-3204, PDFBOX-3813
                // also avoid 0 in array like "[ 0 1000 ] 0 d", see PDFBOX-3724
                if (xformScalingFactorX < 0.5f)
                {
                    // PDFBOX-4492
                    dashArray[i] = Math.max(w, 0.2f);
                }
                else
                {
                    dashArray[i] = Math.max(w, 0.062f);
                }
            }
            return dashArray;
        }

        @Override
        public void strokePath() throws IOException
        {
            if (isContentRendered())
            {
                graphics.setComposite(getGraphicsState().getStrokingJavaComposite());
                graphics.setPaint(getStrokingPaint());
                graphics.setStroke(getStroke());
                setClip();
                graphics.draw(linePath);
            }
            linePath.reset();
        }

        @Override
        public void fillPath(int windingRule) throws IOException
        {
            PDGraphicsState graphicsState = getGraphicsState();
            graphics.setComposite(graphicsState.getNonStrokingJavaComposite());
            setClip();
            linePath.setWindingRule(windingRule);

            // disable anti-aliasing for rectangular paths, this is a workaround to avoid small stripes
            // which occur when solid fills are used to simulate piecewise gradients, see PDFBOX-2302
            // note that we ignore paths with a width/height under 1 as these are fills used as strokes,
            // see PDFBOX-1658 for an example
            Rectangle2D bounds = linePath.getBounds2D();
            boolean noAntiAlias = isRectangular(linePath) && bounds.getWidth() > 1 &&
                                                             bounds.getHeight() > 1;
            if (noAntiAlias)
            {
                graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                                          RenderingHints.VALUE_ANTIALIAS_OFF);
            }

            Shape shape;
            if (graphicsState.getNonStrokingColorSpace() instanceof PDPattern)
            {
                // apply clip to path to avoid oversized device bounds in shading contexts (PDFBOX-2901)
                Area area = new Area(linePath);
                Shape clip = graphics.getClip();
                if (clip != null)
                {
                    area.intersect(new Area(clip));
                }
                intersectShadingBBox(graphicsState.getNonStrokingColor(), area);
                shape = area;
            }
            else
            {
                shape = linePath;
            }
            if (isContentRendered() && !shape.getPathIterator(null).isDone())
            {
                // creating Paint is sometimes a costly operation, so avoid if possible
                graphics.setPaint(getNonStrokingPaint());
                graphics.fill(shape);
            }

            linePath.reset();

            if (noAntiAlias)
            {
                // JDK 1.7 has a bug where rendering hints are reset by the above call to
                // the setRenderingHint method, so we re-set all hints, see PDFBOX-2302
                setRenderingHints();
            }
        }

        // checks whether this is a shading pattern and if yes,
        // get the transformed BBox and intersect with current paint area
        // need to do it here and not in shading getRaster() because it may have been rotated
        private void intersectShadingBBox(PDColor color, Area area) throws IOException
        {
            if (color.getColorSpace() instanceof PDPattern)
            {
                PDColorSpace colorSpace = color.getColorSpace();
                PDAbstractPattern pat = ((PDPattern) colorSpace).getPattern(color);
                if (pat instanceof PDShadingPattern)
                {
                    PDShading shading = ((PDShadingPattern) pat).getShading();
                    PDRectangle bbox = shading.getBBox();
                    if (bbox != null)
                    {
                        Matrix m = Matrix.concatenate(getInitialMatrix(), pat.getMatrix());
                        Area bboxArea = new Area(bbox.transform(m));
                        area.intersect(bboxArea);
                    }
                }
            }
        }

        /**
         * Returns true if the given path is rectangular.
         */
        private boolean isRectangular(GeneralPath path)
        {
            PathIterator iter = path.getPathIterator(null);
            double[] coords = new double[6];
            int count = 0;
            int[] xs = new int[4];
            int[] ys = new int[4];
            while (!iter.isDone())
            {
                switch(iter.currentSegment(coords))
                {
                    case PathIterator.SEG_MOVETO:
                        if (count == 0)
                        {
                            xs[count] = (int)Math.floor(coords[0]);
                            ys[count] = (int)Math.floor(coords[1]);
                        }
                        else
                        {
                            return false;
                        }
                        count++;
                        break;

                    case PathIterator.SEG_LINETO:
                        if (count < 4)
                        {
                            xs[count] = (int)Math.floor(coords[0]);
                            ys[count] = (int)Math.floor(coords[1]);
                        }
                        else
                        {
                            return false;
                        }
                        count++;
                        break;

                    case PathIterator.SEG_CUBICTO:
                        return false;

                    default:
                        break;
                }
                iter.next();
            }

            if (count == 4)
            {
                return xs[0] == xs[1] || xs[0] == xs[2] ||
                       ys[0] == ys[1] || ys[0] == ys[3];
            }
            return false;
        }

        /**
         * Fills and then strokes the path.
         *
         * @param windingRule The winding rule this path will use.
         * @throws IOException If there is an IO error while filling the path.
         */
        @Override
        public void fillAndStrokePath(int windingRule) throws IOException
        {
            // Cloning needed because fillPath() resets linePath
            GeneralPath path = (GeneralPath)linePath.clone();
            fillPath(windingRule);
            linePath = path;
            strokePath();
        }

        @Override
        public void clip(int windingRule)
        {
            // the clipping path will not be updated until the succeeding painting operator is called
            clipWindingRule = windingRule;
        }

        @Override
        public void moveTo(float x, float y)
        {
            linePath.moveTo(x, y);
        }

        @Override
        public void lineTo(float x, float y)
        {
            linePath.lineTo(x, y);
        }

        @Override
        public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
        {
            linePath.curveTo(x1, y1, x2, y2, x3, y3);
        }

        @Override
        public Point2D getCurrentPoint()
        {
            return linePath.getCurrentPoint();
        }

        @Override
        public void closePath()
        {
            linePath.closePath();
        }

        @Override
        public void endPath()
        {
            if (clipWindingRule != -1)
            {
                linePath.setWindingRule(clipWindingRule);

                if (!linePath.getPathIterator(null).isDone())
                {
                    // PDFBOX-4949 / PDF.js 12306: don't clip if "W n" only
                    getGraphicsState().intersectClippingPath(linePath);
                }

                // PDFBOX-3836: lastClip needs to be reset, because after intersection it is still the same
                // object, thus setClip() would believe that it is cached.
                lastClips = null;

                clipWindingRule = -1;
            }
            linePath.reset();
        }

        @Override
        public void drawImage(PDImage pdImage) throws IOException
        {
            if (pdImage instanceof PDImageXObject &&
                isHiddenOCG(((PDImageXObject) pdImage).getOptionalContent()))
            {
                return;
            }
            if (!isContentRendered())
            {
                return;
            }
            Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
            AffineTransform at = ctm.createAffineTransform();

            if (!pdImage.getInterpolate())
            {
                // if the image is scaled down, we use smooth interpolation, eg PDFBOX-2364
                // only when scaled up do we use nearest neighbour, eg PDFBOX-2302 / mori-cvpr01.pdf
                // PDFBOX-4930: we use the sizes of the ARGB image. These can be different
                // than the original sizes of the base image, when the mask is bigger.
                // PDFBOX-5091: also consider subsampling, the sizes are different too.
                BufferedImage bim;
                if (subsamplingAllowed)
                {
                    bim = pdImage.getImage(null, getSubsampling(pdImage, at));
                }
                else
                {
                    bim = pdImage.getImage();
                }
                boolean isScaledUp =
                        bim.getWidth() <= Math.abs(Math.round(ctm.getScalingFactorX() * xformScalingFactorX)) ||
                        bim.getHeight() <= Math.abs(Math.round(ctm.getScalingFactorY() * xformScalingFactorY));
                if (isScaledUp)
                {
                    graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                            RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
                }
            }

            graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
            setClip();

            if (pdImage.isStencil())
            {
                if (getGraphicsState().getNonStrokingColor().getColorSpace() instanceof PDPattern)
                {
                    // The earlier code for stencils (see "else") doesn't work with patterns because the
                    // CTM is not taken into consideration.
                    // this code is based on the fact that it is easily possible to draw the mask and
                    // the paint at the correct place with the existing code, but not in one step.
                    // Thus what we do is to draw both in separate images, then combine the two and draw
                    // the result.
                    // Note that the device scale is not used. In theory, some patterns can get better
                    // at higher resolutions but the stencil would become more and more "blocky".
                    // If anybody wants to do this, have a look at the code in showTransparencyGroup().

                    // draw the paint
                    Paint paint = getNonStrokingPaint();
                    Rectangle2D unitRect = new Rectangle2D.Float(0, 0, 1, 1);
                    Rectangle2D bounds = at.createTransformedShape(unitRect).getBounds2D();
                    GraphicsConfiguration deviceConfiguration = graphics.getDeviceConfiguration();
                    int w;
                    int h;
                    if (deviceConfiguration != null && deviceConfiguration.getBounds() != null)
                    {
                        // PDFBOX-4690: bounds doesn't need to be larger than device bounds (OOM risk)
                        Rectangle deviceBounds = deviceConfiguration.getBounds();
                        w = (int) Math.ceil(Math.min(bounds.getWidth(), deviceBounds.getWidth()));
                        h = (int) Math.ceil(Math.min(bounds.getHeight(), deviceBounds.getHeight()));
                    }
                    else
                    {
                        w = (int) Math.ceil(bounds.getWidth());
                        h = (int) Math.ceil(bounds.getHeight());
                    }
                    BufferedImage renderedPaint = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
                    Graphics2D g = (Graphics2D) renderedPaint.getGraphics();
                    g.translate(-bounds.getMinX(), -bounds.getMinY());
                    g.setPaint(paint);
                    g.setRenderingHints(graphics.getRenderingHints());
                    g.fill(bounds);
                    g.dispose();

                    // draw the mask
                    BufferedImage mask = pdImage.getImage();
                    BufferedImage renderedMask = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
                    g = (Graphics2D) renderedMask.getGraphics();
                    g.translate(-bounds.getMinX(), -bounds.getMinY());
                    AffineTransform imageTransform = new AffineTransform(at);
                    imageTransform.scale(1.0 / mask.getWidth(), -1.0 / mask.getHeight());
                    imageTransform.translate(0, -mask.getHeight());
                    g.setRenderingHints(graphics.getRenderingHints());
                    g.drawImage(mask, imageTransform, null);
                    g.dispose();

                    // apply the mask
                    final int[] transparent = new int[4];
                    int[] alphaPixel = null;
                    WritableRaster raster = renderedPaint.getRaster();
                    WritableRaster alpha = renderedMask.getRaster();
                    for (int y = 0; y < h; y++)
                    {
                        for (int x = 0; x < w; x++)
                        {
                            alphaPixel = alpha.getPixel(x, y, alphaPixel);
                            if (alphaPixel[0] == 255)
                            {
                                raster.setPixel(x, y, transparent);
                            }
                        }
                    }

                    // draw the image
                    graphics.drawImage(renderedPaint,
                            AffineTransform.getTranslateInstance(bounds.getMinX(), bounds.getMinY()),
                            null);
                }
                else
                {
                    // fill the image with stenciled paint
                    BufferedImage image = pdImage.getStencilImage(getNonStrokingPaint());

                    // draw the image
                    drawBufferedImage(image, at);
                }
            }
            else
            {
                if (subsamplingAllowed)
                {
                    int subsampling = getSubsampling(pdImage, at);
                    // draw the subsampled image
                    drawBufferedImage(pdImage.getImage(null, subsampling), at);
                }
                else
                {
                    // subsampling not allowed, draw the image
                    drawBufferedImage(pdImage.getImage(), at);
                }
            }

            if (!pdImage.getInterpolate())
            {
                // JDK 1.7 has a bug where rendering hints are reset by the above call to
                // the setRenderingHint method, so we re-set all hints, see PDFBOX-2302
                setRenderingHints();
            }
        }

        /**
         * Calculated the subsampling frequency for a given PDImage based on the current transformation
         * and its calculated transform
         *
         * @param pdImage PDImage to be drawn
         * @param at Transform that will be applied to the image when drawing
         * @return The rounded-down ratio of image pixels to drawn pixels. Returned value will always be
         * >=1.
         */
        private int getSubsampling(PDImage pdImage, AffineTransform at)
        {
            // calculate subsampling according to the resulting image size
            double scale = Math.abs(at.getDeterminant() * xform.getDeterminant());

            int subsampling = (int) Math.floor(Math.sqrt(pdImage.getWidth() * pdImage.getHeight() / scale));
            if (subsampling > 8)
            {
                subsampling = 8;
            }
            if (subsampling < 1)
            {
                subsampling = 1;
            }
            if (subsampling > pdImage.getWidth() || subsampling > pdImage.getHeight())
            {
                // For very small images it is possible that the subsampling would imply 0 size.
                // To avoid problems, the subsampling is set to no less than the smallest dimension.
                subsampling = Math.min(pdImage.getWidth(), pdImage.getHeight());
            }
            return subsampling;
        }

        private void drawBufferedImage(BufferedImage image, AffineTransform at) throws IOException
        {
            AffineTransform originalTransform = graphics.getTransform();
            AffineTransform imageTransform = new AffineTransform(at);
            int width = image.getWidth();
            int height = image.getHeight();
            imageTransform.scale(1.0 / width, -1.0 / height);
            imageTransform.translate(0, -height);

            PDSoftMask softMask = getGraphicsState().getSoftMask();
            if( softMask != null )
            {
                Rectangle2D rectangle = new Rectangle2D.Float(0, 0, width, height);
                Paint awtPaint = new TexturePaint(image, rectangle);
                awtPaint = applySoftMaskToPaint(awtPaint, softMask);
                graphics.setPaint(awtPaint);
                graphics.transform(imageTransform);
                graphics.fill(rectangle);
                graphics.setTransform(originalTransform);
            }
            else
            {
                COSBase transfer = getGraphicsState().getTransfer();
                if (transfer instanceof COSArray || transfer instanceof COSDictionary)
                {
                    image = applyTransferFunction(image, transfer);
                }

                // PDFBOX-4516, PDFBOX-4527, PDFBOX-4815, PDFBOX-4886, PDFBOX-4863:
                // graphics.drawImage() has terrible quality when scaling down, even when
                // RenderingHints.VALUE_INTERPOLATION_BICUBIC, VALUE_ALPHA_INTERPOLATION_QUALITY,
                // VALUE_COLOR_RENDER_QUALITY and VALUE_RENDER_QUALITY are all set.
                // A workaround is to get a pre-scaled image with Image.getScaledInstance()
                // and then draw that one. To reduce differences in testing
                // (partly because the method needs integer parameters), only smaller scalings
                // will trigger the workaround. Because of the slowness we only do it if the user
                // expects quality rendering and interpolation.
                Matrix imageTransformMatrix = new Matrix(imageTransform);
                Matrix graphicsTransformMatrix = new Matrix(originalTransform);
                float scaleX = Math.abs(imageTransformMatrix.getScalingFactorX() * graphicsTransformMatrix.getScalingFactorX());
                float scaleY = Math.abs(imageTransformMatrix.getScalingFactorY() * graphicsTransformMatrix.getScalingFactorY());

                if ((scaleX < imageDownscalingOptimizationThreshold || scaleY < imageDownscalingOptimizationThreshold) &&
                    RenderingHints.VALUE_RENDER_QUALITY.equals(graphics.getRenderingHint(RenderingHints.KEY_RENDERING)) &&
                    RenderingHints.VALUE_INTERPOLATION_BICUBIC.equals(graphics.getRenderingHint(RenderingHints.KEY_INTERPOLATION)))
                {
                    int w = Math.round(image.getWidth() * scaleX);
                    int h = Math.round(image.getHeight() * scaleY);
                    if (w < 1 || h < 1)
                    {
                        graphics.drawImage(image, imageTransform, null);
                        return;
                    }
                    Image imageToDraw = image.getScaledInstance(w, h, Image.SCALE_SMOOTH);
                    // remove the scale (extracted from w and h, to have it from the rounded values
                    // hoping to reverse the rounding: without this, we get an horizontal line
                    // when rendering PDFJS-8860-Pattern-Size1.pdf at 100% )
                    imageTransform.scale(1f / w * image.getWidth(), 1f / h * image.getHeight());
                    imageTransform.preConcatenate(originalTransform);
                    graphics.setTransform(new AffineTransform());
                    graphics.drawImage(imageToDraw, imageTransform, null);
                    graphics.setTransform(originalTransform);
                }
                else
                {
                    graphics.drawImage(image, imageTransform, null);
                }
            }
        }

        private BufferedImage applyTransferFunction(BufferedImage image, COSBase transfer) throws IOException
        {
            BufferedImage bim;
            if (image.getColorModel().hasAlpha())
            {
                bim = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
            }
            else
            {
                bim = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB);
            }

            // prepare transfer functions (either one per color or one for all)
            // and maps (actually arrays[256] to be faster) to avoid calculating values several times
            Integer[] rMap;
            Integer[] gMap;
            Integer[] bMap;
            PDFunction rf;
            PDFunction gf;
            PDFunction bf;
            if (transfer instanceof COSArray)
            {
                COSArray ar = (COSArray) transfer;
                rf = PDFunction.create(ar.getObject(0));
                gf = PDFunction.create(ar.getObject(1));
                bf = PDFunction.create(ar.getObject(2));
                rMap = new Integer[256];
                gMap = new Integer[256];
                bMap = new Integer[256];
            }
            else
            {
                rf = PDFunction.create(transfer);
                gf = rf;
                bf = rf;
                rMap = new Integer[256];
                gMap = rMap;
                bMap = rMap;
            }

            // apply the transfer function to each color, but keep alpha
            float[] input = new float[1];
            for (int x = 0; x < image.getWidth(); ++x)
            {
                for (int y = 0; y < image.getHeight(); ++y)
                {
                    int rgb = image.getRGB(x, y);
                    int ri = (rgb >> 16) & 0xFF;
                    int gi = (rgb >> 8) & 0xFF;
                    int bi = rgb & 0xFF;
                    int ro;
                    int go;
                    int bo;
                    if (rMap[ri] != null)
                    {
                        ro = rMap[ri];
                    }
                    else
                    {
                        input[0] = (ri & 0xFF) / 255f;
                        ro = (int) (rf.eval(input)[0] * 255);
                        rMap[ri] = ro;
                    }
                    if (gMap[gi] != null)
                    {
                        go = gMap[gi];
                    }
                    else
                    {
                        input[0] = (gi & 0xFF) / 255f;
                        go = (int) (gf.eval(input)[0] * 255);
                        gMap[gi] = go;
                    }
                    if (bMap[bi] != null)
                    {
                        bo = bMap[bi];
                    }
                    else
                    {
                        input[0] = (bi & 0xFF) / 255f;
                        bo = (int) (bf.eval(input)[0] * 255);
                        bMap[bi] = bo;
                    }
                    bim.setRGB(x, y, (rgb & 0xFF000000) | (ro << 16) | (go << 8) | bo);
                }
            }
            return bim;
        }

        @Override
        public void shadingFill(COSName shadingName) throws IOException
        {
            if (!isContentRendered())
            {
                return;
            }
            PDShading shading = getResources().getShading(shadingName);
            if (shading == null)
            {
                LOG.error("shading " + shadingName + " does not exist in resources dictionary");
                return;
            }
            Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();

            graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
            Shape savedClip = graphics.getClip();
            graphics.setClip(null);
            lastClips = null;

            // get the transformed BBox and intersect with current clipping path
            // need to do it here and not in shading getRaster() because it may have been rotated
            PDRectangle bbox = shading.getBBox();
            Area area;
            if (bbox != null)
            {
                area = new Area(bbox.transform(ctm));
                area.intersect(getGraphicsState().getCurrentClippingPath());
            }
            else
            {
                Rectangle2D bounds = shading.getBounds(new AffineTransform(), ctm);
                if (bounds != null)
                {
                    bounds.add(new Point2D.Double(Math.floor(bounds.getMinX() - 1),
                            Math.floor(bounds.getMinY() - 1)));
                    bounds.add(new Point2D.Double(Math.ceil(bounds.getMaxX() + 1),
                            Math.ceil(bounds.getMaxY() + 1)));
                    area = new Area(bounds);
                    area.intersect(getGraphicsState().getCurrentClippingPath());
                }
                else
                {
                    area = getGraphicsState().getCurrentClippingPath();
                }
            }
            if (!area.isEmpty())
            {
                // creating Paint is sometimes a costly operation, so avoid if possible
                Paint paint = shading.toPaint(ctm);
                paint = applySoftMaskToPaint(paint, getGraphicsState().getSoftMask());
                graphics.setPaint(paint);
                graphics.fill(area);
            }
            graphics.setClip(savedClip);
        }

        @Override
        public void showAnnotation(PDAnnotation annotation) throws IOException
        {
            lastClips = null;
            int deviceType = -1;
            GraphicsConfiguration graphicsConfiguration = graphics.getDeviceConfiguration();
            if (graphicsConfiguration != null)
            {
                GraphicsDevice graphicsDevice = graphicsConfiguration.getDevice();
                if (graphicsDevice != null)
                {
                    deviceType = graphicsDevice.getType();
                }
            }
            if (deviceType == GraphicsDevice.TYPE_PRINTER && !annotation.isPrinted())
            {
                return;
            }
            if (deviceType == GraphicsDevice.TYPE_RASTER_SCREEN && annotation.isNoView())
            {
                return;
            }
            if (annotation.isHidden())
            {
                return;
            }
            if (annotation.isInvisible() && annotation instanceof PDAnnotationUnknown)
            {
                // "If set, do not display the annotation if it does not belong to one
                // of the standard annotation types and no annotation handler is available."
                return;
            }
            //TODO support NoZoom, example can be found in p5 of PDFBOX-2348

            if (isHiddenOCG(annotation.getOptionalContent()))
            {
                return;
            }

            PDAppearanceDictionary appearance = annotation.getAppearance();
            if (appearance == null || appearance.getNormalAppearance() == null)
            {
                annotation.constructAppearances(renderer.document);
            }
            if (annotation.isNoRotate() && getCurrentPage().getRotation() != 0)
            {
                PDRectangle rect = annotation.getRectangle();
                AffineTransform savedTransform = graphics.getTransform();
                // "The upper-left corner of the annotation remains at the same point in
                //  default user space; the annotation pivots around that point."
                graphics.rotate(Math.toRadians(getCurrentPage().getRotation()),
                        rect.getLowerLeftX(), rect.getUpperRightY());
                super.showAnnotation(annotation);
                graphics.setTransform(savedTransform);
            }
            else
            {
                super.showAnnotation(annotation);
            }
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void showForm(PDFormXObject form) throws IOException
        {
            if (isHiddenOCG(form.getOptionalContent()))
            {
                return;
            }
            if (isContentRendered())
            {
                GeneralPath savedLinePath = linePath;
                linePath = new GeneralPath();
                super.showForm(form);
                linePath = savedLinePath;
            }
        }

        @Override
        public void showTransparencyGroup(PDTransparencyGroup form) throws IOException
        {
            showTransparencyGroupOnGraphics(form, graphics);
        }

        /**
         * For advanced users, to extract the transparency group into a separate graphics device.
         *
         * @param form
         * @param graphics
         * @throws IOException
         */
        protected void showTransparencyGroupOnGraphics(PDTransparencyGroup form, Graphics2D graphics)
            throws IOException
        {
            if (isHiddenOCG(form.getOptionalContent()))
            {
                return;
            }
            if (!isContentRendered())
            {
                return;
            }
            TransparencyGroup group
                    = new TransparencyGroup(form, false, getGraphicsState().getCurrentTransformationMatrix(), null);
            BufferedImage image = group.getImage();
            if (image == null)
            {
                // image is empty, don't bother
                return;
            }

            graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
            setClip();

            // both the DPI xform and the CTM were already applied to the group, so all we do
            // here is draw it directly onto the Graphics2D device at the appropriate position
            AffineTransform savedTransform = graphics.getTransform();
            AffineTransform transform = new AffineTransform(xform);
            transform.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);
            graphics.setTransform(transform);

            // adjust bbox (x,y) position at the initial scale + cropbox
            PDRectangle bbox = group.getBBox();
            float x = bbox.getLowerLeftX() - pageSize.getLowerLeftX();
            float y = pageSize.getUpperRightY() - bbox.getUpperRightY();

            if (flipTG)
            {
                graphics.translate(0, image.getHeight());
                graphics.scale(1, -1);
            }
            else
            {
                graphics.translate(x * xformScalingFactorX, y * xformScalingFactorY);
            }

            PDSoftMask softMask = getGraphicsState().getSoftMask();
            if (softMask != null)
            {
                Paint awtPaint = new TexturePaint(image,
                        new Rectangle2D.Float(0, 0, image.getWidth(), image.getHeight()));
                awtPaint = applySoftMaskToPaint(awtPaint, softMask);
                graphics.setPaint(awtPaint);
                graphics.fill(
                        new Rectangle2D.Float(0, 0, bbox.getWidth() * xformScalingFactorX, bbox.getHeight() * xformScalingFactorY));
            }
            else
            {
                try
                {
                    graphics.drawImage(image, null, null);
                }
                catch (InternalError ie)
                {
                    LOG.error("Exception drawing image, see JDK-6689349, " +
                              "try rendering into a BufferedImage instead", ie);
                }
            }

            graphics.setTransform(savedTransform);
        }

        /**
         * Transparency group.
         **/
        private final class TransparencyGroup
        {
            private final BufferedImage image;
            private final PDRectangle bbox;

            private final int minX;
            private final int minY;
            private final int maxX;
            private final int maxY;
            private final int width;
            private final int height;

            /**
             * Creates a buffered image for a transparency group result.
             *
             * @param form the transparency group of the form or soft mask.
             * @param isSoftMask true if this is a soft mask.
             * @param ctm the relevant current transformation matrix. For soft masks, this is the CTM at
             * the time the soft mask is set (not at the time the soft mask is used for fill/stroke!),
             * for forms, this is the CTM at the time the form is invoked.
             * @param backdropColor the color according to the /bc entry to be used for luminosity soft
             * masks.
             * @throws IOException
             */
            private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix ctm,
                    PDColor backdropColor) throws IOException
            {
                Graphics2D savedGraphics = graphics;
                List<Path2D> savedLastClips = lastClips;
                Shape savedInitialClip = initialClip;

                // get the CTM x Form Matrix transform
                Matrix transform = Matrix.concatenate(ctm, form.getMatrix());

                // transform the bbox
                GeneralPath transformedBox = form.getBBox().transform(transform);

                // clip the bbox to prevent giant bboxes from consuming all memory
                Area transformed = new Area(transformedBox);
                transformed.intersect(getGraphicsState().getCurrentClippingPath());
                Rectangle2D clipRect = transformed.getBounds2D();
                if (clipRect.isEmpty())
                {
                    image = null;
                    bbox = null;
                    minX = 0;
                    minY = 0;
                    maxX = 0;
                    maxY = 0;
                    width = 0;
                    height = 0;
                    return;
                }
                this.bbox = new PDRectangle((float)clipRect.getX(), (float)clipRect.getY(),
                                            (float)clipRect.getWidth(), (float)clipRect.getHeight());

                // apply the underlying Graphics2D device's DPI transform
                AffineTransform xformOriginal = xform;
                xform = AffineTransform.getScaleInstance(xformScalingFactorX, xformScalingFactorY);
                Rectangle2D bounds = xform.createTransformedShape(clipRect).getBounds2D();

                minX = (int) Math.floor(bounds.getMinX());
                minY = (int) Math.floor(bounds.getMinY());
                maxX = (int) Math.floor(bounds.getMaxX()) + 1;
                maxY = (int) Math.floor(bounds.getMaxY()) + 1;

                width = maxX - minX;
                height = maxY - minY;

                // FIXME - color space
                if (isGray(form.getGroup().getColorSpace(form.getResources())))
                {
                    image = create2ByteGrayAlphaImage(width, height);
                }
                else
                {
                    image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
                }

                boolean needsBackdrop = !isSoftMask && !form.getGroup().isIsolated() &&
                    hasBlendMode(form, new HashSet<>());
                BufferedImage backdropImage = null;
                // Position of this group in parent group's coordinates
                int backdropX = 0;
                int backdropY = 0;
                if (needsBackdrop)
                {
                    if (transparencyGroupStack.isEmpty())
                    {
                        // Use the current page as the parent group.
                        backdropImage = renderer.getPageImage();
                        if (backdropImage == null)
                        {
                            needsBackdrop = false;
                        }
                        else
                        {
                            backdropX = minX;
                            backdropY = backdropImage.getHeight() - maxY;
                        }
                    }
                    else
                    {
                        TransparencyGroup parentGroup = transparencyGroupStack.peek();
                        backdropImage = parentGroup.image;
                        backdropX = minX - parentGroup.minX;
                        backdropY = parentGroup.maxY - maxY;
                    }
                }

                Graphics2D g = image.createGraphics();
                if (needsBackdrop)
                {
                    // backdropImage must be included in group image but not in group alpha.
                    g.drawImage(backdropImage, 0, 0, width, height,
                        backdropX, backdropY, backdropX + width, backdropY + height, null);
                    g = new GroupGraphics(image, g);
                }
                if (isSoftMask && backdropColor != null)
                {
                    // "If the subtype is Luminosity, the transparency group XObject G shall be
                    // composited with a fully opaque backdrop whose colour is everywhere defined
                    // by the soft-mask dictionary's BC entry."
                    g.setBackground(new Color(backdropColor.toRGB()));
                    g.clearRect(0, 0, width, height);
                }

                // flip y-axis
                g.translate(0, image.getHeight());
                g.scale(1, -1);

                boolean savedFlipTG = flipTG;
                flipTG = false;

                // apply device transform (DPI)
                // the initial translation is ignored, because we're not writing into the initial graphics device
                g.transform(xform);

                PDRectangle pageSizeOriginal = pageSize;
                pageSize = new PDRectangle(minX / xformScalingFactorX,
                                           minY / xformScalingFactorY,
                                           (float) (bounds.getWidth() / xformScalingFactorX),
                                            (float) (bounds.getHeight() / xformScalingFactorY));
                int clipWindingRuleOriginal = clipWindingRule;
                clipWindingRule = -1;
                GeneralPath linePathOriginal = linePath;
                linePath = new GeneralPath();

                // adjust the origin
                g.translate(-clipRect.getX(), -clipRect.getY());

                graphics = g;
                setRenderingHints();
                try
                {
                    if (isSoftMask)
                    {
                        processSoftMask(form);
                    }
                    else
                    {
                        transparencyGroupStack.push(this);
                        processTransparencyGroup(form);
                        if (!transparencyGroupStack.isEmpty())
                        {
                            transparencyGroupStack.pop();
                        }
                    }

                    if (needsBackdrop)
                    {
                        ((GroupGraphics) graphics).removeBackdrop(backdropImage, backdropX, backdropY);
                    }
                }
                finally
                {
                    flipTG = savedFlipTG;
                    lastClips = savedLastClips;
                    graphics.dispose();
                    graphics = savedGraphics;
                    initialClip = savedInitialClip;
                    clipWindingRule = clipWindingRuleOriginal;
                    linePath = linePathOriginal;
                    pageSize = pageSizeOriginal;
                    xform = xformOriginal;
                }
            }

            // http://stackoverflow.com/a/21181943/535646
            private BufferedImage create2ByteGrayAlphaImage(int width, int height)
            {
                // gray + alpha
                int[] bandOffsets = new int[] {1, 0};
                int bands = bandOffsets.length;

                // Color Model used for raw GRAY + ALPHA
                final ColorModel CM_GRAY_ALPHA
                    = new ComponentColorModel(
                            ColorSpace.getInstance(ColorSpace.CS_GRAY),
                            true, false, Transparency.TRANSLUCENT, DataBuffer.TYPE_BYTE);

                // Init data buffer of type byte
                DataBuffer buffer = new DataBufferByte(width * height * bands);

                // Wrap the data buffer in a raster
                WritableRaster raster =
                        Raster.createInterleavedRaster(buffer, width, height,
                                width * bands, bands, bandOffsets, new Point(0, 0));

                // Create a custom BufferedImage with the raster and a suitable color model
                return new BufferedImage(CM_GRAY_ALPHA, raster, false, null);
            }

            private boolean isGray(PDColorSpace colorSpace)
            {
                if (colorSpace instanceof PDDeviceGray)
                {
                    return true;
                }
                if (colorSpace instanceof PDICCBased)
                {
                    try
                    {
                        return ((PDICCBased) colorSpace).getAlternateColorSpace() instanceof PDDeviceGray;
                    }
                    catch (IOException ex)
                    {
                        LOG.debug("Couldn't get an alternate ColorSpace", ex);
                        return false;
                    }
                }
                return false;
            }

            BufferedImage getImage()
            {
                return image;
            }

            PDRectangle getBBox()
            {
                return bbox;
            }

            Rectangle2D getBounds()
            {
                // apply the underlying Graphics2D device's DPI transform and y-axis flip
                Rectangle2D r =
                        new Rectangle2D.Double(
                                minX - pageSize.getLowerLeftX() * xformScalingFactorX,
                                (pageSize.getLowerLeftY() + pageSize.getHeight()) * xformScalingFactorY - minY - height,
                                width,
                                height);
                // this adjusts the rectangle to the rotated image to put the soft mask at the correct position
                //TODO
                // 1. change transparencyGroup.getBounds() to getOrigin(), because size isn't used in SoftMask,
                // 2. Is it possible to create the softmask and transparency group in the correct rotation?
                //    (needs rendering identity testing before committing!)
                AffineTransform adjustedTransform = new AffineTransform(xform);
                adjustedTransform.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);
                return adjustedTransform.createTransformedShape(r).getBounds2D();
            }
        }

        private boolean hasBlendMode(PDTransparencyGroup group, Set<COSBase> groupsDone)
        {
            if (groupsDone.contains(group.getCOSObject()))
            {
                // The group was already processed. Avoid endless recursion.
                return false;
            }
            groupsDone.add(group.getCOSObject());

            PDResources resources = group.getResources();
            if (resources == null)
            {
                return false;
            }
            for (COSName name : resources.getExtGStateNames())
            {
                PDExtendedGraphicsState extGState = resources.getExtGState(name);
                if (extGState == null)
                {
                    continue;
                }
                BlendMode blendMode = extGState.getBlendMode();
                if (blendMode != BlendMode.NORMAL)
                {
                    return true;
                }
            }

            // Recursively process nested transparency groups
            for (COSName name : resources.getXObjectNames())
            {
                PDXObject xObject;
                try
                {
                    xObject = resources.getXObject(name);
                }
                catch (IOException ex)
                {
                    continue;
                }
                if (xObject instanceof PDTransparencyGroup &&
                    hasBlendMode((PDTransparencyGroup)xObject, groupsDone))
                {
                    return true;
                }
            }

            return false;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void beginMarkedContentSequence(COSName tag, COSDictionary properties)
        {
            if (nestedHiddenOCGCount > 0)
            {
                nestedHiddenOCGCount++;
                return;
            }
            if (tag == null || getPage().getResources() == null)
            {
                return;
            }
            if (isHiddenOCG(getPage().getResources().getProperties(tag)))
            {
                nestedHiddenOCGCount = 1;
            }
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void endMarkedContentSequence()
        {
            if (nestedHiddenOCGCount > 0)
            {
                nestedHiddenOCGCount--;
            }
        }

        private boolean isContentRendered()
        {
            return nestedHiddenOCGCount <= 0;
        }

        private boolean isHiddenOCG(PDPropertyList propertyList)
        {
            if (propertyList instanceof PDOptionalContentGroup)
            {
                PDOptionalContentGroup group = (PDOptionalContentGroup) propertyList;
                RenderState printState = group.getRenderState(destination);
                if (printState == null)
                {
                    if (!getRenderer().isGroupEnabled(group))
                    {
                        return true;
                    }
                }
                else if (RenderState.OFF.equals(printState))
                {
                    return true;
                }
            }
            else if (propertyList instanceof PDOptionalContentMembershipDictionary)
            {
                return isHiddenOCMD((PDOptionalContentMembershipDictionary) propertyList);
            }
            return false;
        }

        private boolean isHiddenOCMD(PDOptionalContentMembershipDictionary ocmd)
        {
            if (ocmd.getCOSObject().getCOSArray(COSName.VE) != null)
            {
                // support seems to be optional, and is approximated by /P and /OCGS
                LOG.info("/VE entry ignored in Optional Content Membership Dictionary");
            }
            List<PDPropertyList> oCGs = ocmd.getOCGs();
            if (oCGs.isEmpty())
            {
                return false;
            }
            List<Boolean> visibles = new ArrayList<>();
            oCGs.forEach(prop -> visibles.add(!isHiddenOCG(prop)));
            COSName visibilityPolicy = ocmd.getVisibilityPolicy();

            // visible if any of the entries in OCGs are OFF
            if (COSName.ANY_OFF.equals(visibilityPolicy))
            {
                return visibles.stream().noneMatch(v -> !v);
            }

            // visible only if all of the entries in OCGs are ON
            if (COSName.ALL_ON.equals(visibilityPolicy))
            {
                return visibles.stream().anyMatch(v -> !v);
            }

            // visible only if all of the entries in OCGs are OFF
            if (COSName.ALL_OFF.equals(visibilityPolicy))
            {
                return visibles.stream().anyMatch(v -> v);
            }

            // visible if any of the entries in OCGs are ON
            // AnyOn is default
            return visibles.stream().noneMatch(v -> v);
        }
    }