ImagePdfBytesInfo.java

/*
    This file is part of the iText (R) project.
    Copyright (c) 1998-2025 Apryse Group NV
    Authors: Apryse Software.

    This program is offered under a commercial and under the AGPL license.
    For commercial licensing, contact us at https://itextpdf.com/sales.  For AGPL licensing, see below.

    AGPL licensing:
    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    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 <https://www.gnu.org/licenses/>.
 */
package com.itextpdf.kernel.pdf.xobject;

import com.itextpdf.commons.utils.MessageFormatUtil;
import com.itextpdf.io.codec.PngWriter;
import com.itextpdf.io.codec.TIFFConstants;
import com.itextpdf.io.codec.TiffWriter;
import com.itextpdf.kernel.actions.data.ITextCoreProductData;
import com.itextpdf.kernel.exceptions.KernelExceptionMessageConstant;
import com.itextpdf.kernel.exceptions.PdfException;
import com.itextpdf.kernel.pdf.PdfArray;
import com.itextpdf.kernel.pdf.PdfIndirectReference;
import com.itextpdf.kernel.pdf.PdfName;
import com.itextpdf.kernel.pdf.PdfNumber;
import com.itextpdf.kernel.pdf.PdfObject;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfStream;
import com.itextpdf.kernel.pdf.PdfString;
import com.itextpdf.kernel.pdf.colorspace.PdfColorSpace;
import com.itextpdf.kernel.pdf.colorspace.PdfDeviceCs;
import com.itextpdf.kernel.pdf.colorspace.PdfDeviceCs.Gray;
import com.itextpdf.kernel.pdf.colorspace.PdfSpecialCs.Separation;
import com.itextpdf.kernel.pdf.function.BaseInputOutPutConvertors.IInputConversionFunction;
import com.itextpdf.kernel.pdf.function.BaseInputOutPutConvertors.IOutputConversionFunction;
import com.itextpdf.kernel.pdf.function.IPdfFunction;
import com.itextpdf.kernel.pdf.xobject.PdfImageXObject.ImageBytesRetrievalProperties;
import com.itextpdf.kernel.utils.BitmapImagePixels;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

class ImagePdfBytesInfo {

    private static final String TIFFTAG_SOFTWARE_VALUE = "iText\u00ae " +
            ITextCoreProductData.getInstance().getVersion() + " \u00a9" + ITextCoreProductData.getInstance()
            .getSinceCopyrightYear() + "-" + ITextCoreProductData.getInstance().getToCopyrightYear()
            + " Apryse Group NV";
    private final int width;
    private final int height;
    private final List<IPdfFunction> colorTransformations = new ArrayList<IPdfFunction>();
    private final PdfImageXObject imageXObject;
    private final ImageBytesRetrievalProperties properties;
    private double[] decodeArray = null;
    /**
     * The number of color channels in the output
     */
    private int channels;
    /**
     * Is there an alpha channel of an alpha mask in the output
     */
    private boolean alphaChannel;
    /**
     * color depth of output image
     */
    private int colorDepth;
    /**
     * palette information, null when not required
     */
    private Palette palette = null;
    private PdfColorSpace sourceColorSpace;
    private PdfColorSpace targetColorSpace;
    private PdfImageXObject transparencyMask;
    private OutputFileType outputFileType;
    private byte[] iccData;

    public ImagePdfBytesInfo(PdfImageXObject imageXObject, ImageBytesRetrievalProperties properties) {
        this.properties =properties;
        this.imageXObject = imageXObject;
        if (properties.isApplyDecodeArray()
                && imageXObject.getPdfObject().containsKey(PdfName.Decode)) {
            decodeArray = imageXObject.getPdfObject().getAsArray(PdfName.Decode).toDoubleArray();
        }

        extractColorInfo(imageXObject);

        width = (int) imageXObject.getWidth();
        height = (int) imageXObject.getHeight();
    }

    public OutputFileType getImageType() {
        return outputFileType;
    }

    public byte[] getProcessedImageData(byte[] intialBytes) throws IOException {
        if (channels > 1 && colorDepth != 8 && colorDepth != 16) {
            throw new com.itextpdf.io.exceptions.IOException(
                    KernelExceptionMessageConstant.COLOR_DEPTH_IS_NOT_SUPPORTED_FOR_COLORSPACE)
                    .setMessageParams(colorDepth, sourceColorSpace.getName());
        }

        byte[] data = PdfReader.decodeBytes(intialBytes, imageXObject.getPdfObject());

        if ( decodeArray != null && !isNeutralDecodeArray(decodeArray)) {
            data = applyDecoding(data);
        }
        for (IPdfFunction fct : colorTransformations) {
            data = fct.calculateFromByteArray(data, 0, data.length, 1, 1);
        }
        if (transparencyMask != null) {
            data = applytransparency(data);
        }

        ImageProcesser proc;

        if (outputFileType == OutputFileType.PNG) {
            proc = new PngImageProcessor(data, transparencyMask, palette, iccData, targetColorSpace, colorDepth, width,
                    height);
        } else {
            proc = new TiffImageProcessor(data, transparencyMask, palette, iccData, targetColorSpace, colorDepth, width,
                    height);
        }
        return proc.processImage();
    }

    int getPngColorType() {
        if (outputFileType == OutputFileType.PNG) {
            PngImageProcessor proc = new PngImageProcessor(new byte[0], transparencyMask, palette, iccData,
                    targetColorSpace, colorDepth, width, height);
            return proc.getColorTypeFromColorSpace(targetColorSpace).ordinal();
        }
        return -1;
    }

    private boolean isNeutralDecodeArray(double[] decodeArray) {
        for (int i = 0; i <= channels / 2; i++) {
            if (decodeArray[i * 2] > 0.0 && decodeArray[i * 2 + 1] < 1.0) {
                return false;
            }
        }
        return true;
    }

    private void extractColorInfo(PdfImageXObject imageXObject) {
        if (imageXObject.getPdfObject().containsKey(PdfName.BitsPerComponent)) {
            colorDepth = imageXObject.getPdfObject().getAsNumber(PdfName.BitsPerComponent).intValue();
        } else {
            colorDepth = 1;
        }
        if (properties.isApplyTransparency() && imageXObject.getPdfObject().containsKey(PdfName.SMask)) {
            alphaChannel = true;
            transparencyMask = new PdfImageXObject(imageXObject.getPdfObject().getAsStream(PdfName.SMask));
        }
        PdfObject colorSpace;

        if (imageXObject.isMask() || imageXObject.isSoftMask()) {
            this.sourceColorSpace = new Gray();
            colorSpace = sourceColorSpace.getPdfObject();
        } else {
            colorSpace = imageXObject.getPdfObject().get(PdfName.ColorSpace);
            this.sourceColorSpace = PdfColorSpace.makeColorSpace(colorSpace);
        }
        this.targetColorSpace = sourceColorSpace;
        outputFileType = OutputFileType.PNG;

        if (colorSpace.isName()) {
            switch (((PdfName) colorSpace).getValue()) {
                case "DeviceGray":
                    channels = 1;
                    break;
                case "DeviceRGB":
                    channels = 3;
                    break;
                case "DeviceCMYK":
                    channels = 4;
                    outputFileType = OutputFileType.TIFF;
                    break;
                default:
                    throw new com.itextpdf.io.exceptions.IOException(
                            KernelExceptionMessageConstant.COLOR_SPACE_IS_NOT_SUPPORTED)
                            .setMessageParams(((PdfName) colorSpace).getValue());
            }
        } else {
            if (colorSpace.isArray()) {
                PdfArray csArray = (PdfArray) colorSpace;
                switch (((PdfName) csArray.get(0)).getValue()) {
                    case "Indexed":
                        palette = new Palette(csArray, colorDepth);
                        long color0 = isPaletteBlackAndWhite(palette);
                        if (colorDepth == 1 && color0 >= 0) {
                            targetColorSpace = new PdfDeviceCs.Gray();
                            if (color0 == 1 && decodeArray == null) {
                                decodeArray = new double[] {1.0, 0.0};
                            }
                            palette = null;
                            break;
                        }
                        if ((properties.isApplyTransparency() && alphaChannel)
                                || palette.getBaseColorspace().getNumberOfComponents() == 1) {
                            targetColorSpace = palette.getBaseColorspace();
                            colorTransformations.add(new DeIndexingTransformation(palette));
                            palette = null;
                        }
                        break;
                    case "DeviceN":
                    case "NChannel":
                        throw new com.itextpdf.io.exceptions.IOException(
                                KernelExceptionMessageConstant.COLOR_SPACE_IS_NOT_SUPPORTED)
                                .setMessageParams(csArray.get(0).toString());
                    case "Separation":
                        Separation separationCs = (Separation) this.sourceColorSpace;
                        if (properties.isApplyTintTransformations()) {
                            colorTransformations.add(separationCs.getTintTransformation());
                            targetColorSpace = separationCs.getBaseCs();
                            if (targetColorSpace.getName() != PdfName.DeviceRGB
                                    && targetColorSpace.getName() != PdfName.CalRGB
                            ) {
                                throw new UnsupportedOperationException(
                                        KernelExceptionMessageConstant
                                                .GET_IMAGEBYTES_FOR_SEPARATION_COLOR_ONLY_SUPPORTS_RGB);
                            }
                            if (colorDepth < 8) {
                                throw new com.itextpdf.io.exceptions.IOException(
                                        KernelExceptionMessageConstant
                                                .COLOR_DEPTH_IS_NOT_SUPPORTED_FOR_SEPARATION_ALTERNATE_COLORSPACE)
                                        .setMessageParams(colorDepth, targetColorSpace.getName());
                            }
                        } else {
                            targetColorSpace = new Gray();
                        }
                        break;
                    case "ICCBased":
                        PdfStream iccStream = null;
                        if (csArray.get(1).isIndirectReference()) {
                            iccStream = (PdfStream) ((PdfIndirectReference) csArray.get(1)).getRefersTo();
                        } else {
                            iccStream = (PdfStream) csArray.get(1);
                        }
                        if (targetColorSpace.getNumberOfComponents() > 3) {
                            outputFileType = OutputFileType.TIFF;
                        }
                        int iccComponents = targetColorSpace.getNumberOfComponents();
                        if (iccComponents != 1 && iccComponents != 3 && iccComponents != 4) {
                            throw new com.itextpdf.io.exceptions.IOException(
                                    KernelExceptionMessageConstant.N_VALUE_IS_NOT_SUPPORTED)
                                    .setMessageParams(iccComponents);
                        }
                        iccData = iccStream.getBytes();
                        break;
                    case "CalGray":
                    case "CalRGB":
                        break;
                    default:
                        throw new com.itextpdf.io.exceptions.IOException(
                                KernelExceptionMessageConstant.COLOR_SPACE_IS_NOT_SUPPORTED)
                                .setMessageParams(csArray.get(0));
                }
                channels = targetColorSpace.getNumberOfComponents();
            }
        }
    }

    private static long isPaletteBlackAndWhite(Palette palette) {
        // more than 2 values
        if (palette.getHiVal() > 1) {
            return -1;
        }
        long color0 = -1;
        for (int c = 0; c < palette.getBaseColorspace().getNumberOfComponents(); c++) {
            for (int i = 0; i < 2; i++) {
                switch ((int) palette.getColor(i)[c]) {
                    case 0:
                        if (i == 0) {
                            color0 = 0;
                        }
                        break;
                    case 0xff:
                        if (i == 0) {
                            color0 = 1;
                        }
                        break;
                    default:
                        return -1;
                }
            }
        }
        return color0;
    }

    private byte[] applytransparency(byte[] imageData) {
        int maskMultiplier = 8 / transparencyMask.getPdfObject().getAsNumber(PdfName.BitsPerComponent).intValue();
        byte[] mask = transparencyMask.getImageBytes(false);

        mask = PdfReader.decodeBytes(mask, transparencyMask.getPdfObject());
        byte[] out = new byte[(imageData.length / channels) * (channels + 1)];
        BitmapImagePixels imageInPix = new BitmapImagePixels(this.width, this.height, colorDepth, channels, imageData);
        BitmapImagePixels imageOutPix = new BitmapImagePixels(this.width, this.height, colorDepth,
                channels + 1, out);
        BitmapImagePixels maskPix = new BitmapImagePixels(this.width, this.height, colorDepth, 1, mask);

        long[] nPix = new long[channels + 1];

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                long[] oPix = imageInPix.getPixelAsLongs(x, y);

                System.arraycopy(oPix, 0, nPix, 0, channels);
                nPix[channels] = maskPix.getPixelAsLongs(x, y)[0] * maskMultiplier;
                imageOutPix.setPixel(x, y, nPix);
            }
        }
        return imageOutPix.getData();
    }

    private byte[] applyDecoding(byte[] imageData) {
        BitmapImagePixels imagePixels = new BitmapImagePixels(width, height, colorDepth,
                sourceColorSpace.getNumberOfComponents(), imageData);

        double[] factors = new double[sourceColorSpace.getNumberOfComponents()];
        double[] floor = new double[sourceColorSpace.getNumberOfComponents()];
        for (int i = 0; i < sourceColorSpace.getNumberOfComponents(); i++) {
            factors[i] = (decodeArray[i * 2 + 1] - decodeArray[i * 2]);
            floor[i] = decodeArray[i * 2] * ((1 << colorDepth) - 1);
        }

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                long[] pix = imagePixels.getPixelAsLongs(x, y);
                for (int c = 0; c < sourceColorSpace.getNumberOfComponents(); c++) {
                    pix[c] = (long) (floor[c] + pix[c] * factors[c]);
                }
                imagePixels.setPixel(x, y, pix);
            }
        }

        return imagePixels.getData();
    }

    public enum OutputFileType {
        TIFF,
        PNG
    }

    public enum PngColorType {
        GRAYSCALE,
        INVALID_1,
        RGB,
        PALETTE,
        GRAYSCALE_ALPHA,
        INVALID_5,
        RGBA
    }

    private static class Palette {
        private final PdfColorSpace baseColorspace;
        private final int hiVal;
        private final int indexBitDepth;
        private final byte[] paletteData;
        private final int paletteChannels;
        public Palette(PdfArray csArray, int indexBitDepth) {
            if (csArray.size() != 4) {
                throw new PdfException(KernelExceptionMessageConstant.PALLET_CONTENT_ERROR);
            }
            this.indexBitDepth = indexBitDepth;
            baseColorspace = PdfColorSpace.makeColorSpace(csArray.get(1));
            paletteChannels = baseColorspace.getNumberOfComponents();
            hiVal = ((PdfNumber) csArray.get(2)).intValue();
            PdfObject data = csArray.get(3);

            if (data.isStream()) {
                paletteData = ((PdfStream) data).getBytes();
            } else if (data.isString()) {
                paletteData = ((PdfString) data).getValueBytes();
            } else {
                paletteData = null;
            }
        }

        public int getIndexBitDepth() {
            return indexBitDepth;
        }

        public byte[] getPaletteData() {
            return paletteData;
        }

        public PdfColorSpace getBaseColorspace() {
            return baseColorspace;
        }

        public long[] getColor(long index) {
            long[] result = new long[paletteChannels];
            for (int c = 0; c < paletteChannels; c++) {
                result[c] = paletteData[(int) index * paletteChannels + c] & 0xff;
            }
            return result;
        }

        public int getHiVal() {
            return hiVal;
        }
    }

    private interface ImageProcesser {
        byte[] processImage() throws IOException;
    }

    private static class TiffImageProcessor implements ImageProcesser {

        private final byte[] imageData;
        private final PdfImageXObject transparencyMask;
        private final PdfColorSpace colorSpace;
        private final int width;
        private final int height;
        private final int colorDepth;
        private final byte[] iccProfile;

        public TiffImageProcessor(byte[] imageData,
                PdfImageXObject transparencyMask,
                Palette palette, byte[] iccProfile,
                PdfColorSpace colorSpace,
                int colorDepth,
                int width, int height) {
            this.imageData = imageData;
            this.transparencyMask = transparencyMask;
            this.iccProfile = iccProfile;
            this.colorSpace = colorSpace;

            this.colorDepth = colorDepth;
            this.width = width;
            this.height = height;
        }

        @Override
        public byte[] processImage() throws IOException {
            java.io.ByteArrayOutputStream ms = new java.io.ByteArrayOutputStream();
            int samples = colorSpace.getNumberOfComponents();
            if (transparencyMask != null) {
                samples++;
            }
            int[] bitsPerSample = new int[samples];
            for (int i = 0; i < samples; i++) {
                bitsPerSample[i] = colorDepth;
            }
            int stride = samples * width;
            TiffWriter wr = new TiffWriter();
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_SAMPLESPERPIXEL,
                    colorSpace.getNumberOfComponents()));
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_BITSPERSAMPLE, bitsPerSample));
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_PHOTOMETRIC,
                    TIFFConstants.PHOTOMETRIC_SEPARATED));
            wr.addField(new TiffWriter.FieldLong(TIFFConstants.TIFFTAG_IMAGEWIDTH, width));
            wr.addField(new TiffWriter.FieldLong(TIFFConstants.TIFFTAG_IMAGELENGTH, height));
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_COMPRESSION, TIFFConstants.COMPRESSION_LZW));
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_PREDICTOR,
                    TIFFConstants.PREDICTOR_HORIZONTAL_DIFFERENCING));
            wr.addField(new TiffWriter.FieldLong(TIFFConstants.TIFFTAG_ROWSPERSTRIP, height));
            wr.addField(new TiffWriter.FieldRational(TIFFConstants.TIFFTAG_XRESOLUTION, new int[] {300, 1}));
            wr.addField(new TiffWriter.FieldRational(TIFFConstants.TIFFTAG_YRESOLUTION, new int[] {300, 1}));
            wr.addField(new TiffWriter.FieldShort(TIFFConstants.TIFFTAG_RESOLUTIONUNIT, TIFFConstants.RESUNIT_INCH));
            wr.addField(new TiffWriter.FieldAscii(TIFFConstants.TIFFTAG_SOFTWARE, TIFFTAG_SOFTWARE_VALUE));
            java.io.ByteArrayOutputStream comp = new java.io.ByteArrayOutputStream();
            TiffWriter.compressLZW(comp, 2, imageData, height, samples, stride);
            byte[] buf = comp.toByteArray();
            wr.addField(new TiffWriter.FieldImage(buf));
            wr.addField(new TiffWriter.FieldLong(TIFFConstants.TIFFTAG_STRIPBYTECOUNTS, buf.length));
            if (iccProfile != null) {
                wr.addField(new TiffWriter.FieldUndefined(TIFFConstants.TIFFTAG_ICCPROFILE, iccProfile));
            }
            wr.writeFile(ms);
            return ms.toByteArray();
        }
    }

    private static class PngImageProcessor implements ImageProcesser {
        private final byte[] imageData;
        private final PdfImageXObject transparencyMask;
        private final PdfColorSpace colorSpace;
        private final int width;
        private final int height;
        private final int colorDepth;
        private final byte[] iccProfile;
        private final Palette palette;

        public PngImageProcessor(byte[] imageData,
                PdfImageXObject transparencyMask,
                Palette palette, byte[] iccProfile,
                PdfColorSpace colorSpace,
                int colorDepth,
                int width, int height) {
            this.imageData = imageData;
            this.transparencyMask = transparencyMask;
            this.palette = palette;
            this.iccProfile = iccProfile;
            this.colorSpace = colorSpace;

            this.colorDepth = colorDepth;
            this.width = width;
            this.height = height;
        }

        public PngColorType getColorTypeFromColorSpace(PdfColorSpace colorSpace) {
            switch (colorSpace.getNumberOfComponents()) {
                case 1:
                    if (palette == null) {
                        if (transparencyMask == null) {
                            return PngColorType.GRAYSCALE;
                        } else {
                            return PngColorType.GRAYSCALE_ALPHA;
                        }
                    } else {
                        return PngColorType.PALETTE;
                    }
                case 3:
                    if (transparencyMask == null) {
                        return PngColorType.RGB;
                    } else {
                        return PngColorType.RGBA;
                    }
                default:
                    throw new UnsupportedOperationException(MessageFormatUtil.format(
                            KernelExceptionMessageConstant.PNG_CHANNEL_ERROR,
                            colorSpace.getNumberOfComponents()));
            }

        }

        @Override
        public byte[] processImage() throws IOException {
            java.io.ByteArrayOutputStream ms = new java.io.ByteArrayOutputStream();

            PngWriter png = new PngWriter(ms);
            PngColorType colorType = getColorTypeFromColorSpace(colorSpace);
            png.writeHeader(width, height, colorDepth, colorType.ordinal());
            if (iccProfile != null) {
                png.writeIccProfile(iccProfile);
            }
            if (palette != null && palette.getPaletteData() != null) {
                png.writePalette(palette.getPaletteData());
            }
            int stride = (width * colorDepth *
                    (colorSpace.getNumberOfComponents() + (transparencyMask == null ? 0 : 1)) + 7) / 8;
            png.writeData(imageData, stride);

            png.writeEnd();
            return ms.toByteArray();
        }
    }

    private class DeIndexingTransformation implements IPdfFunction {
        private final Palette palette;

        public DeIndexingTransformation(Palette palette) {
            this.palette = palette;
        }

        @Override
        public int getFunctionType() {
            return -1;
        }

        @Override
        public boolean checkCompatibilityWithColorSpace(PdfColorSpace alternateSpace) {
            return palette.getBaseColorspace().equals(alternateSpace);
        }

        @Override
        public int getInputSize() {
            return 1;
        }

        @Override
        public int getOutputSize() {
            return palette.getBaseColorspace().getNumberOfComponents();
        }

        @Override
        public double[] getDomain() {
            return new double[] {0, 1};
        }

        @Override
        public void setDomain(double[] value) {
            // not needed because this is not a real PdfFunction
        }

        @Override
        public double[] getRange() {
            double[] range = new double[palette.getBaseColorspace().getNumberOfComponents() * 2];
            for (int i = 0; i < palette.getBaseColorspace().getNumberOfComponents(); i++) {
                range[i * 2] = 0;
                range[i * 2 + 1] = 1;
            }
            return range;
        }

        @Override
        public void setRange(double[] value) {
            // not needed because this is not a real PdfFunction
        }

        @Override
        public double[] calculate(double[] input) {
            return new double[0];
        }

        @Override
        public byte[] calculateFromByteArray(byte[] bytes, int offset, int length, int wordSizeInputLength,
                int wordSizeOutputLength) throws IOException {
            final byte[] output = new byte[palette.getBaseColorspace().getNumberOfComponents()
                    * length * (9 - palette.getIndexBitDepth())];
            BitmapImagePixels indexedPixels = new BitmapImagePixels(width, height, colorDepth, 1, bytes);
            BitmapImagePixels deIndexedPixels = new BitmapImagePixels(width, height, 8,
                    palette.getBaseColorspace().getNumberOfComponents(), output);

            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    long[] color = palette.getColor(indexedPixels.getPixelAsLongs(x, y)[0]);

                    deIndexedPixels.setPixel(x, y, color);
                }
            }
            return deIndexedPixels.getData();
        }

        @Override
        public byte[] calculateFromByteArray(byte[] bytes, int offset, int length, int wordSizeInputLength,
                int wordSizeOutputLength, IInputConversionFunction inputConvertor,
                IOutputConversionFunction outputConvertor) throws IOException {
            return new byte[0];
        }

        @Override
        public double[] clipInput(double[] input) {
            return new double[0];
        }

        @Override
        public double[] clipOutput(double[] input) {
            return new double[0];
        }

        @Override
        public PdfObject getAsPdfObject() {
            return null;
        }
    }
}