/*
 * 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.printing;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterIOException;
import java.io.IOException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.rendering.RenderDestination;
import org.apache.pdfbox.util.Matrix;

/**
 * Prints pages from a PDF document using any page size or scaling mode.
 *
 * @author John Hewson
 */
public final class PDFPrintable implements Printable
{
    private static final Log LOG = LogFactory.getLog(PDFPrintable.class);
    public static final float RASTERIZE_OFF = 0f;
    public static final float RASTERIZE_DPI_AUTO = -1f;

    private final PDPageTree pageTree;
    private final PDFRenderer renderer;
    
    private final boolean showPageBorder;
    private final Scaling scaling;
    private final float dpi;
    private final boolean center;
    private boolean subsamplingAllowed = false;
    private RenderingHints renderingHints = null;

    /**
     * Creates a new PDFPrintable.
     *
     * @param document the document to print
     */
    public PDFPrintable(PDDocument document)
    {
        this(document, Scaling.SHRINK_TO_FIT);
    }

    /**
     * Creates a new PDFPrintable with the given page scaling.
     *
     * @param document the document to print
     * @param scaling page scaling policy
     */
    public PDFPrintable(PDDocument document, Scaling scaling)
    {
       this(document, scaling, false);
    }

    /**
     * Creates a new PDFPrintable with the given page scaling and with optional page borders shown.
     *
     * @param document the document to print
     * @param scaling page scaling policy
     * @param showPageBorder true if page borders are to be printed
     */
    public PDFPrintable(PDDocument document, Scaling scaling, boolean showPageBorder)
    {
        this(document, scaling, showPageBorder, RASTERIZE_OFF);
    }

    /**
     * Creates a new PDFPrintable with the given page scaling and with optional page borders shown.
     * The image will be rasterized at the given DPI before being sent to the printer.
     *
     * @param document the document to print
     * @param scaling page scaling policy
     * @param showPageBorder true if page borders are to be printed
     * @param dpi if positive non-zero then the image will be rasterized at the given DPI. If
     * set to the special value RASTERIZE_DPI_AUTO, the dpi of the printer will be used.
     */
    public PDFPrintable(PDDocument document, Scaling scaling, boolean showPageBorder, float dpi)
    {
        this(document, scaling, showPageBorder, dpi, true);
    }
    
    /**
     * Creates a new PDFPrintable with the given page scaling and with optional page borders shown.
     * The image will be rasterized at the given DPI before being sent to the printer.
     *
     * @param document the document to print
     * @param scaling page scaling policy
     * @param showPageBorder true if page borders are to be printed
     * @param dpi if positive non-zero then the image will be rasterized at the given DPI. If
     * set to the special value RASTERIZE_DPI_AUTO, the dpi of the printer will be used.
     * @param center true if the content is to be centered on the page (otherwise top-left).
     */
    public PDFPrintable(PDDocument document, Scaling scaling, boolean showPageBorder, float dpi,
                        boolean center)
    {
        this(document, scaling, showPageBorder, dpi, center, new PDFRenderer(document));
    }

    /**
     * Creates a new PDFPrintable with the given page scaling and with optional page borders shown.
     * The image will be rasterized at the given DPI before being sent to the printer.
     *
     * @param document the document to print
     * @param scaling page scaling policy
     * @param showPageBorder true if page borders are to be printed
     * @param dpi if positive non-zero then the image will be rasterized at the given DPI. If
     * set to the special value RASTERIZE_DPI_AUTO, the dpi of the printer will be used.
     * @param center true if the content is to be centered on the page (otherwise top-left).
     * @param renderer the document renderer. Useful if {@link PDFRenderer} has been subclassed.
     */
    public PDFPrintable(PDDocument document, Scaling scaling, boolean showPageBorder, float dpi,
                        boolean center, PDFRenderer renderer)
    {
        this.pageTree = document.getPages();
        this.renderer = renderer;
        this.scaling = scaling;
        this.showPageBorder = showPageBorder;
        this.dpi = dpi;
        this.center = center;
    }

    /**
     * Value indicating if the renderer is allowed to subsample images before drawing, according to
     * image dimensions and requested scale.
     *
     * Subsampling may be faster and less memory-intensive in some cases, but it may also lead to
     * loss of quality, especially in images with high spatial frequency.
     *
     * @return true if subsampling of images is allowed, false otherwise.
     */
    public boolean isSubsamplingAllowed()
    {
        return subsamplingAllowed;
    }

    /**
     * Sets a value instructing the renderer whether it is allowed to subsample images before
     * drawing. The subsampling frequency is determined according to image size and requested scale.
     *
     * Subsampling may be faster and less memory-intensive in some cases, but it may also lead to
     * loss of quality, especially in images with high spatial frequency.
     *
     * @param subsamplingAllowed The new value indicating if subsampling is allowed.
     */
    public void setSubsamplingAllowed(boolean subsamplingAllowed)
    {
        this.subsamplingAllowed = subsamplingAllowed;
    }

    /**
     * Get the rendering hints.
     *
     * @return the rendering hints or null if none are set.
     */
    public RenderingHints getRenderingHints()
    {
        return renderingHints;
    }

    /**
     * Set the rendering hints. Use this to influence rendering quality and speed. If you don't set
     * them yourself or pass null, PDFBox will decide <b><u>at runtime</u></b> depending on the
     * destination.
     *
     * @param renderingHints
     */
    public void setRenderingHints(RenderingHints renderingHints)
    {
        this.renderingHints = renderingHints;
    }

    @Override
    public int print(Graphics graphics, PageFormat pageFormat, int pageIndex)
            throws PrinterException
    {
        if (pageIndex < 0 || pageIndex >= pageTree.getCount())
        {
            return NO_SUCH_PAGE;
        }

        // work on a private copy so the caller's Graphics2D state (transform, clip, color,
        // background, stroke) is never mutated. Disposing the copy in the finally block
        // releases its resources without affecting the original.
        Graphics2D printerGraphics = (Graphics2D) graphics.create();
        Graphics2D graphics2D = printerGraphics;

        try
        {
            // capture the DPI that will be used for rasterizing the image
            // if rasterizing is specified
            float rasterDpi = dpi;
            if (rasterDpi == RASTERIZE_DPI_AUTO)
            {
                AffineTransform transform = graphics2D.getTransform();
                rasterDpi = new Matrix(transform).getScalingFactorX() * 72.0f;
                if (LOG.isDebugEnabled())
                {
                    LOG.debug("auto raster dpi: " + rasterDpi + ", g2d: " + graphics2D + ", g2d transform: " + transform);
                }
            }

            PDPage page = pageTree.get(pageIndex);
            PDRectangle cropBox = getRotatedCropBox(page);

            // the imageable area is the area within the page margins
            final double imageableWidth = pageFormat.getImageableWidth();
            final double imageableHeight = pageFormat.getImageableHeight();

            double scale = 1;
            if (scaling != Scaling.ACTUAL_SIZE)
            {
                // scale to fit
                double scaleX = imageableWidth / cropBox.getWidth();
                double scaleY = imageableHeight / cropBox.getHeight();
                scale = Math.min(scaleX, scaleY);

                // only shrink to fit when enabled
                if (scale > 1 && scaling == Scaling.SHRINK_TO_FIT)
                {
                    scale = 1;
                }
                
                // only stretch to fit when enabled
                if (scale < 1 && scaling == Scaling.STRETCH_TO_FIT)
                {
                    scale = 1;
                }
            }

            // set the graphics origin to the origin of the imageable area (i.e the margins)
            graphics2D.translate(pageFormat.getImageableX(), pageFormat.getImageableY());

            // center on page
            if (center)
            {
                double dx = (imageableWidth - cropBox.getWidth() * scale) / 2;
                double dy = (imageableHeight - cropBox.getHeight() * scale) / 2;
                if (dx >= 0 && dy >= 0)
                {
                    graphics2D.translate(dx, dy);
                }
                else
                {
                    // PDFBOX-3117 and https://lists.apache.org/thread/12s9tc93ofgmjfq1dpqfps9p725l0wwr
                    LOG.warn("Centering disabled because of negative translation value (" +
                            dx + "," + dy + ")");
                }
            }

            AffineTransform printerBorderTransform = printerGraphics.getTransform();
            double borderScale = scale;

            // rasterize to bitmap (optional)
            BufferedImage image = null;
            if (rasterDpi > 0)
            {
                float dpiScale = rasterDpi / 72;
                if (LOG.isDebugEnabled())
                {
                    LOG.debug("rasterDpi: " + rasterDpi + ", dpiScale: " + dpiScale);
                }
                image = new BufferedImage(Math.max(1, (int) (imageableWidth * dpiScale / scale)),
                                          Math.max(1, (int) (imageableHeight * dpiScale / scale)),
                                          BufferedImage.TYPE_INT_ARGB);

                graphics2D = image.createGraphics();

                // rescale
                printerGraphics.scale(scale / dpiScale, scale / dpiScale);
                scale = dpiScale;
            }

            // draw to graphics using PDFRender
            graphics2D.setBackground(Color.WHITE);
            renderer.setSubsamplingAllowed(subsamplingAllowed);
            renderer.setRenderingHints(renderingHints);
            renderer.renderPageToGraphics(pageIndex, graphics2D, (float) scale, (float) scale, RenderDestination.PRINT);

            // draw rasterized bitmap (optional)
            if (image != null)
            {
                printerGraphics.setBackground(Color.WHITE);
                printerGraphics.clearRect(0, 0, image.getWidth(), image.getHeight());
                printerGraphics.drawImage(image, 0, 0, null);
            }

            // draw crop box on the printer graphics (always, whether rasterizing or not).
            // Drawing after the blit avoids losing the thin stroke during raster scale-down.
            if (showPageBorder)
            {
                printerGraphics.setTransform(printerBorderTransform);
                printerGraphics.setClip(0, 0, (int) imageableWidth, (int) imageableHeight);
                printerGraphics.scale(borderScale, borderScale);
                printerGraphics.setColor(Color.GRAY);
                printerGraphics.setStroke(new BasicStroke(0.5f));
                printerGraphics.drawRect(0, 0, (int) cropBox.getWidth(), (int) cropBox.getHeight());
            }

            return PAGE_EXISTS;
        }
        catch (IOException e)
        {
            throw new PrinterIOException(e);
        }
        finally
        {
            if (graphics2D != printerGraphics)
            {
                graphics2D.dispose();
            }
            printerGraphics.dispose();
        }
    }

    /**
     * This will find the CropBox with rotation applied, for this page by looking up the hierarchy
     * until it finds them.
     *
     * @return The CropBox at this level in the hierarchy.
     */
    static PDRectangle getRotatedCropBox(PDPage page)
    {
        PDRectangle cropBox = page.getCropBox();
        int rotationAngle = page.getRotation();
        if (rotationAngle == 90 || rotationAngle == 270)
        {
            return new PDRectangle(cropBox.getLowerLeftY(), cropBox.getLowerLeftX(),
                                   cropBox.getHeight(), cropBox.getWidth());
        }
        else
        {
            return cropBox;
        }
    }

    /**
     * This will find the MediaBox with rotation applied, for this page by looking up the hierarchy
     * until it finds them.
     *
     * @return The MediaBox at this level in the hierarchy.
     */
    static PDRectangle getRotatedMediaBox(PDPage page)
    {
        PDRectangle mediaBox = page.getMediaBox();
        int rotationAngle = page.getRotation();
        if (rotationAngle == 90 || rotationAngle == 270)
        {
            return new PDRectangle(mediaBox.getLowerLeftY(), mediaBox.getLowerLeftX(),
                                   mediaBox.getHeight(), mediaBox.getWidth());
        }
        else
        {
            return mediaBox;
        }
    }
}
