itextpdf-master/xtra/src/main/java/com/itextpdf/text/pdf/pdfcleanup/PdfCleanUpRenderListener.java
/*
 *
 * This file is part of the iText (R) project.
    Copyright (c) 1998-2022 iText Group NV
 * Authors: Bruno Lowagie, Paulo Soares, et al.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License version 3
 * as published by the Free Software Foundation with the addition of the
 * following permission added to Section 15 as permitted in Section 7(a):
 * FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
 * ITEXT GROUP. ITEXT GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT
 * OF THIRD PARTY RIGHTS
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Affero General Public License for more details.
 * You should have received a copy of the GNU Affero General Public License
 * along with this program; if not, see http://www.gnu.org/licenses or write to
 * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA, 02110-1301 USA, or download the license from the following URL:
 * http://itextpdf.com/terms-of-use/
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU Affero General Public License.
 *
 * In accordance with Section 7(b) of the GNU Affero General Public License,
 * a covered work must retain the producer line in every PDF that is created
 * or manipulated using iText.
 *
 * You can be released from the requirements of the license by purchasing
 * a commercial license. Buying such a license is mandatory as soon as you
 * develop commercial activities involving the iText software without
 * disclosing the source code of your own applications.
 * These activities include: offering paid services to customers as an ASP,
 * serving PDFs on the fly in a web application, shipping iText with a closed
 * source product.
 *
 * For more information, please contact iText Software Corp. at this
 * address: sales@itextpdf.com
 */
package com.itextpdf.text.pdf.pdfcleanup;

import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.exceptions.UnsupportedPdfException;
import com.itextpdf.text.pdf.PdfBoolean;
import com.itextpdf.text.pdf.PdfContentByte;
import com.itextpdf.text.pdf.PdfDictionary;
import com.itextpdf.text.pdf.PdfName;
import com.itextpdf.text.pdf.PdfObject;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.parser.ExtRenderListener;
import com.itextpdf.text.pdf.parser.ImageRenderInfo;
import com.itextpdf.text.pdf.parser.LineDashPattern;
import com.itextpdf.text.pdf.parser.LineSegment;
import com.itextpdf.text.pdf.parser.Matrix;
import com.itextpdf.text.pdf.parser.Path;
import com.itextpdf.text.pdf.parser.PathConstructionRenderInfo;
import com.itextpdf.text.pdf.parser.PathPaintingRenderInfo;
import com.itextpdf.text.pdf.parser.PdfImageObject;
import com.itextpdf.text.pdf.parser.TextRenderInfo;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.MemoryCacheImageOutputStream;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;

import org.apache.commons.imaging.ImageFormats;
import org.apache.commons.imaging.ImageInfo;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.ImagingConstants;
import org.apache.commons.imaging.formats.tiff.constants.TiffConstants;

class PdfCleanUpRenderListener implements ExtRenderListener {

    private static final Color CLEANED_AREA_FILL_COLOR = Color.WHITE;

    private PdfStamper pdfStamper;
    private PdfCleanUpRegionFilter filter;
    private List<PdfCleanUpContentChunk> chunks = new ArrayList<PdfCleanUpContentChunk>();
    private Stack<PdfCleanUpContext> contextStack = new Stack<PdfCleanUpContext>();
    private int strNumber = 1; // Represents ordinal number of string under processing. Needed for processing TJ operator.

    // Represents current path as if there were no segments to cut
    private Path unfilteredCurrentPath = new Path();

    // Represents actual current path to be stroked ("actual" means that it is filtered current path)
    private Path currentStrokePath = new Path();

    // Represents actual current path to be filled.
    private Path currentFillPath = new Path();

    // Represents the latest path used as a clipping path in the new content stream.
    private Path newClippingPath;

    private boolean clipPath;
    private int clippingRule;

    public PdfCleanUpRenderListener(PdfStamper pdfStamper, PdfCleanUpRegionFilter filter) {
        this.pdfStamper = pdfStamper;
        this.filter = filter;
    }

    public void renderText(TextRenderInfo renderInfo) {
        if (renderInfo.getPdfString().toUnicodeString().length() == 0) {
            return;
        }

        for (TextRenderInfo ri : renderInfo.getCharacterRenderInfos()) {
            boolean isAllowed = filter.allowText(ri);
            LineSegment baseline = ri.getUnscaledBaseline();

            chunks.add(new PdfCleanUpContentChunk.Text(ri.getPdfString(), baseline.getStartPoint(),
                    baseline.getEndPoint(), isAllowed, strNumber));
        }

        ++strNumber;
    }

    public void renderImage(ImageRenderInfo renderInfo) {
        List<Rectangle> areasToBeCleaned = getImageAreasToBeCleaned(renderInfo);

        if (areasToBeCleaned == null) {
            chunks.add(new PdfCleanUpContentChunk.Image(false, null));
        } else if ( areasToBeCleaned.size() > 0) {
            try {
                PdfImageObject pdfImage = renderInfo.getImage();
                byte[] imageBytes = processImage(pdfImage.getImageAsBytes(), areasToBeCleaned);

                if (renderInfo.getRef() == null && pdfImage != null) { // true => inline image
                    PdfDictionary dict = pdfImage.getDictionary();
                    PdfObject imageMask = dict.get(PdfName.IMAGEMASK);
                    Image image = Image.getInstance(imageBytes);

                    if (imageMask == null) {
                        imageMask = dict.get(PdfName.IM);
                    }

                    if (imageMask != null && imageMask.equals(PdfBoolean.PDFTRUE)) {
                        image.makeMask();
                    }

                    PdfContentByte canvas = getContext().getCanvas();
                    canvas.addImage(image, 1, 0, 0, 1, 0, 0, true);
                } else if (pdfImage != null && imageBytes != pdfImage.getImageAsBytes()) {
                    chunks.add(new PdfCleanUpContentChunk.Image(true, imageBytes));
                }
            } catch (UnsupportedPdfException pdfException) {
                chunks.add(new PdfCleanUpContentChunk.Image(false, null));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void beginTextBlock() {
    }

    public void endTextBlock() {
    }

    public void modifyPath(PathConstructionRenderInfo renderInfo) {
        List<Float> segmentData = renderInfo.getSegmentData();

        switch (renderInfo.getOperation()) {
            case PathConstructionRenderInfo.MOVETO:
                unfilteredCurrentPath.moveTo(segmentData.get(0), segmentData.get(1));
                break;

            case PathConstructionRenderInfo.LINETO:
                unfilteredCurrentPath.lineTo(segmentData.get(0), segmentData.get(1));
                break;

            case PathConstructionRenderInfo.CURVE_123:
                unfilteredCurrentPath.curveTo(segmentData.get(0), segmentData.get(1), segmentData.get(2),
                        segmentData.get(3), segmentData.get(4), segmentData.get(5));
                break;

            case PathConstructionRenderInfo.CURVE_23:
                unfilteredCurrentPath.curveTo(segmentData.get(0), segmentData.get(1), segmentData.get(2), segmentData.get(3));
                break;

            case PathConstructionRenderInfo.CURVE_13:
                unfilteredCurrentPath.curveFromTo(segmentData.get(0), segmentData.get(1), segmentData.get(2), segmentData.get(3));
                break;

            case PathConstructionRenderInfo.CLOSE:
                unfilteredCurrentPath.closeSubpath();
                break;

            case PathConstructionRenderInfo.RECT:
                unfilteredCurrentPath.rectangle(segmentData.get(0), segmentData.get(1), segmentData.get(2), segmentData.get(3));
                break;
        }
    }

    public Path renderPath(PathPaintingRenderInfo renderInfo) {
        boolean stroke = (renderInfo.getOperation() & PathPaintingRenderInfo.STROKE) != 0;
        boolean fill = (renderInfo.getOperation() & PathPaintingRenderInfo.FILL) != 0;

        float lineWidth = renderInfo.getLineWidth();
        int lineCapStyle = renderInfo.getLineCapStyle();
        int lineJoinStyle = renderInfo.getLineJoinStyle();
        float miterLimit = renderInfo.getMiterLimit();
        LineDashPattern lineDashPattern = renderInfo.getLineDashPattern();

        if (stroke) {
            currentStrokePath = filterCurrentPath(renderInfo.getCtm(), true, -1,
                    lineWidth, lineCapStyle, lineJoinStyle, miterLimit, lineDashPattern);
        }

        if (fill) {
            currentFillPath = filterCurrentPath(renderInfo.getCtm(), false, renderInfo.getRule(),
                    lineWidth, lineCapStyle, lineJoinStyle, miterLimit, lineDashPattern);
        }

        if (clipPath) {
            if (fill && renderInfo.getRule() == clippingRule) {
                newClippingPath = currentFillPath;
            } else {
                newClippingPath = filterCurrentPath(renderInfo.getCtm(), false, clippingRule,
                        lineWidth, lineCapStyle, lineJoinStyle, miterLimit, lineDashPattern);
            }
        }

        unfilteredCurrentPath = new Path();
        return newClippingPath;
    }

    public void clipPath(int rule) {
        clipPath = true;
        clippingRule = rule;
    }

    public boolean isClipped() {
        return clipPath;
    }

    public void setClipped(boolean clipPath) {
        this.clipPath = clipPath;
    }

    public int getClippingRule() {
        return clippingRule;
    }

    public Path getCurrentStrokePath() {
        return currentStrokePath;
    }

    public Path getCurrentFillPath() {
        return currentFillPath;
    }

    public Path getNewClipPath() {
        return newClippingPath;
    }

    public List<PdfCleanUpContentChunk> getChunks() {
        return chunks;
    }

    public PdfCleanUpContext getContext() {
        return contextStack.peek();
    }

    public void registerNewContext(PdfDictionary resources, PdfContentByte canvas) {
        canvas = canvas == null ? new PdfContentByte(pdfStamper.getWriter()) : canvas;
        contextStack.push(new PdfCleanUpContext(resources, canvas));
    }

    public void popContext() {
        contextStack.pop();
    }

    public void clearChunks() {
        chunks.clear();
        strNumber = 1;
    }

    /**
     * @return null if the image is not allowed (either it is fully covered or ctm == null).
     * List of covered image areas otherwise.
     */
    private List<Rectangle> getImageAreasToBeCleaned(ImageRenderInfo renderInfo) {
        return filter.getCoveredAreas(renderInfo);
    }

    private byte[] processImage(byte[] imageBytes, List<Rectangle> areasToBeCleaned) {
        if (areasToBeCleaned.isEmpty()) {
            return imageBytes;
        }

        try {
            BufferedImage image = Imaging.getBufferedImage(imageBytes);
            ImageInfo imageInfo = Imaging.getImageInfo(imageBytes);
            cleanImage(image, areasToBeCleaned);

            // Apache can only read JPEG, so we should use awt for writing in this format
            if (imageInfo.getFormat() == ImageFormats.JPEG) {
                return getJPGBytes(image);
            } else {
                Map<String, Object> params = new HashMap<String, Object>();

                if (imageInfo.getFormat() == ImageFormats.TIFF) {
                    params.put(ImagingConstants.PARAM_KEY_COMPRESSION, TiffConstants.TIFF_COMPRESSION_LZW);
                }

                return Imaging.writeImageToBytes(image, imageInfo.getFormat(), params);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private void cleanImage(BufferedImage image, List<Rectangle> areasToBeCleaned) {
        Graphics2D graphics = image.createGraphics();
        graphics.setColor(CLEANED_AREA_FILL_COLOR);

        // A rectangle in the areasToBeCleaned list is treated to be in standard [0, 1]x[0,1] image space
        // (y varies from bottom to top and x from left to right), so we should scale the rectangle and also
        // invert and shear the y axe
        for (Rectangle rect : areasToBeCleaned) {
            int scaledBottomY = (int) Math.ceil(rect.getBottom() * image.getHeight());
            int scaledTopY = (int) Math.floor(rect.getTop() * image.getHeight());

            int x = (int) Math.ceil(rect.getLeft() * image.getWidth());
            int y = scaledTopY * -1 + image.getHeight();
            int width = (int) Math.floor(rect.getRight() * image.getWidth()) - x;
            int height = scaledTopY - scaledBottomY;

            graphics.fillRect(x, y, width, height);
        }

        graphics.dispose();
    }

    private byte[] getJPGBytes(BufferedImage image) {
        ByteArrayOutputStream outputStream = null;

        try {
            ImageWriter jpgWriter = ImageIO.getImageWritersByFormatName("jpg").next();
            ImageWriteParam jpgWriteParam = jpgWriter.getDefaultWriteParam();
            jpgWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
            jpgWriteParam.setCompressionQuality(1.0f);

            outputStream = new ByteArrayOutputStream();
            jpgWriter.setOutput(new MemoryCacheImageOutputStream((outputStream)));
            IIOImage outputImage = new IIOImage(image, null, null);

            jpgWriter.write(null, outputImage, jpgWriteParam);
            jpgWriter.dispose();
            outputStream.flush();

            return outputStream.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            closeOutputStream(outputStream);
        }
    }

    /**
     * @param fillingRule If the path is contour, pass any value.
     */
    private Path filterCurrentPath(Matrix ctm, boolean stroke, int fillingRule, float lineWidth, int lineCapStyle,
                                   int lineJoinStyle, float miterLimit, LineDashPattern lineDashPattern) {
        Path path = new Path(unfilteredCurrentPath.getSubpaths());

        if (stroke) {
            return filter.filterStrokePath(path, ctm, lineWidth, lineCapStyle, lineJoinStyle,
                    miterLimit, lineDashPattern);
        } else {
            return filter.filterFillPath(path, ctm, fillingRule);
        }
    }

    private void closeOutputStream(OutputStream os) {
        if (os != null) {
            try {
                os.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
