ContrastAnalyzer.java

/*
    This file is part of the iText (R) project.
    Copyright (c) 1998-2026 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.contrast;

import com.itextpdf.commons.utils.MessageFormatUtil;
import com.itextpdf.kernel.colors.Color;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.colors.DeviceCmyk;
import com.itextpdf.kernel.colors.DeviceGray;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.colors.PatternColor;
import com.itextpdf.kernel.contrast.ContrastResult.OverlappingArea;
import com.itextpdf.kernel.exceptions.PdfException;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.geom.IShape;
import com.itextpdf.kernel.geom.Line;
import com.itextpdf.kernel.geom.Path;
import com.itextpdf.kernel.geom.Point;
import com.itextpdf.kernel.geom.Subpath;
import com.itextpdf.kernel.logs.KernelLogMessageConstant;
import com.itextpdf.kernel.pdf.PdfDictionary;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.Point.LongPoint;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.PolyTree;
import com.itextpdf.kernel.pdf.canvas.parser.PdfCanvasProcessor;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.ClipperBridge;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.DefaultClipper;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.IClipper;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.IClipper.ClipType;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.IClipper.PolyFillType;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.IClipper.PolyType;
import com.itextpdf.kernel.pdf.canvas.parser.clipper.Paths;
import com.itextpdf.kernel.pdf.canvas.parser.listener.IEventListener;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Analyzes color contrast ratios between text and backgrounds in PDF pages.
 * This class is designed to help identify accessibility issues related to insufficient
 * contrast between text and background colors, which is important for WCAG compliance.
 *
 *
 * <h2>Features</h2>
 * <p>
 * *<b>Text-to-Background Contrast Analysis:</b> Calculates contrast ratios between text
 * and all overlapping background elements on a page.
 * *<b>Individual Character Analysis:</b> Optional character-by-character analysis for
 * improved accuracy (enabled by default).
 * *<b>Multiple Background Handling:</b> Correctly handles cases where text overlaps
 * multiple backgrounds by analyzing all intersecting backgrounds.
 * *<b>Color Space Support:</b> Supports DeviceRGB, DeviceGray, and DeviceCMYK color spaces.
 * Other color spaces may not be fully supported.
 * *<b>Geometric Calculations:</b> Uses polygon intersection algorithms to accurately
 * determine which backgrounds affect which text elements.
 * *<b>Default Background:</b> Assumes a white background for text that doesn't overlap
 * any explicit background elements.
 *
 *
 * <h2>Current Limitations</h2>
 * <p>
 * *<b>Clipping Path Support:</b> Clipped-out text is currently still processed
 * and analyzed. The analyzer does not respect clipping paths, so text that would be invisible
 * due to clipping will still appear in the contrast results.
 * *<b>Layer Visibility :</b> Content on PDF layers (Optional Content Groups) is
 * always analyzed regardless of layer visibility state. Content on hidden layers will be
 * included in the analysis as if they were visible.
 * *<b>Complex Color Spaces:</b> Advanced color spaces (Lab, ICC-based, Separation, DeviceN, etc.)
 * may not convert accurately to RGB for contrast calculations.
 * *<b>Transparency/Opacity:</b> Does not account for opacity or transparency effects.
 * All elements are treated as fully opaque.
 * *<b>Images as Backgrounds:</b> Currently only analyzes vector path backgrounds.
 * Images used as backgrounds are not considered in the contrast analysis.
 * *<b>Text Rendering Modes:</b> Only analyzes fill color. Stroke color for outlined text
 * is not considered.
 * *<b>Text on Text:</b> Text on Text not supported.
 * *<b>Performance:</b> Character-by-character analysis can be computationally expensive
 * for pages with large amounts of text.
 * *<b>Images</b>
 * Text drawn over images is not analyzed for contrast currently.l
 *
 * @see ContrastResult
 * @see OverlappingArea
 * @see ColorContrastCalculator
 */
//TODO DEVSIX-9718 Improve clip path handling in contrast analysis
public class ContrastAnalyzer {

    private static final Logger LOGGER = LoggerFactory.getLogger(ContrastAnalyzer.class);

    private boolean checkForIndividualCharacters;
    private int maxAmountOfPointInPolygon = 30;

    /**
     * Creates a new {@link ContrastAnalyzer} with default settings.
     *
     * @param checkForIndividualCharacters {@code true} to analyze each character separately, {@code false} to analyze
     *                                     whole text as it
     *                                     would be processed by the PDF renderer. @see
     *                                     ContrastAnalyzer#setCheckForIndividualCharacters(boolean)
     */
    public ContrastAnalyzer(boolean checkForIndividualCharacters) {
        setCheckForIndividualCharacters(checkForIndividualCharacters);
    }

    /**
     * Sets the maximum number of points allowed in a polygon for contrast calculations.
     * <p>
     * This setting helps prevent performance issues when processing complex shapes.
     * If either the text or background polygon exceeds this number of points,
     * the contrast calculation between them will be skipped.
     * This is particularly useful for handling complex vector graphics
     * The default value is 30 points.
     *
     * @param maxAmountOfPointInPolygon the maximum number of points allowed in a polygon
     */
    public void setMaxAmountOfPointInPolygon(int maxAmountOfPointInPolygon) {
        this.maxAmountOfPointInPolygon = maxAmountOfPointInPolygon;
    }

    /**
     * Sets whether to check contrast for individual characters.
     * <p>
     * When enabled (default), each character in a text string is analyzed separately for contrast.
     * This provides more accurate results as different characters may have different backgrounds,
     * but it significantly impacts performance on pages with large amounts of text.
     * <p>
     * When disabled, entire text render operations are analyzed as a single unit, which is faster
     * but may miss contrast issues that only affect specific characters within a text string.
     *
     * @param checkForIndividualCharacters true to analyze each character separately, false to analyze whole text as it
     *                                     would be processed by the PDF renderer
     *
     * @return the {@link ContrastAnalyzer} instance for method chaining
     */
    public final ContrastAnalyzer setCheckForIndividualCharacters(boolean checkForIndividualCharacters) {
        this.checkForIndividualCharacters = checkForIndividualCharacters;
        return this;
    }

    /**
     * Analyzes the contrast ratios between text and backgrounds on the given PDF page.
     * <p>
     * This method processes all text and background elements on the page, calculating contrast
     * ratios for each text element against all overlapping backgrounds. The analysis includes:
     * <p>
     * *Extracting all text render operations and their bounding boxes
     * *Extracting all path render operations that serve as backgrounds
     * *Computing geometric intersections between text and backgrounds
     * *Calculating contrast ratios using WCAG formulas
     * *Handling cases where text overlaps multiple backgrounds
     *
     * @param page the PDF page to analyze for contrast issues
     *
     * @return a list of contrast results, one for each text element that has overlapping backgrounds.
     * Returns an empty list if no text elements with backgrounds are found.
     *
     * @throws PdfException if unsupported shape segments are encountered during analysis
     */
    public List<ContrastResult> checkPageContrast(PdfPage page) {
        if (isPageOrUnderlyingStreamFlushed(page)) {
            LOGGER.warn(KernelLogMessageConstant.PAGE_IS_FLUSHED_NO_CONTRAST);
            return new ArrayList<>();
        }

        List<ContrastResult> contrastResults = new ArrayList<>();
        List<ColorInfo> renderInfoList = new ArrayList<>();
        //Add one render info with white background to compare with all other render infos
        addDefaultBackground(renderInfoList, page);
        IEventListener listener = new ColorInfoListener(page, renderInfoList, this.checkForIndividualCharacters);
        PdfCanvasProcessor canvasProcessor = new FontResolvingDocumentProcessor(listener,
                (fontDict) -> page.getDocument().getFont(fontDict));

        int pageNumber = page.getDocument().getPageNumber(page);
        canvasProcessor.processPageContent(page);
        for (int i = 0; i < renderInfoList.size(); i++) {
            ContrastResult textContrastInformation = calculateContrastOfTextRenderer(renderInfoList,
                    renderInfoList.get(i), pageNumber, i);
            if (textContrastInformation != null) {
                contrastResults.add(textContrastInformation);
            }
        }
        return contrastResults;
    }

    /**
     * Checks if the page or any of its underlying content streams have been flushed.
     * <p>
     * Flushed content cannot be processed for contrast analysis as the content stream
     * data is no longer available in memory. This method verifies both the page itself
     * and all its content streams to determine if analysis is possible.
     *
     * @param page the PDF page to check
     *
     * @return {@code true} if the page or any of its content streams are flushed, {@code false} otherwise
     */
    private boolean isPageOrUnderlyingStreamFlushed(PdfPage page) {
        if (page.isFlushed()) {
            return true;
        }

        for (int i = 0; i < page.getContentStreamCount(); i++) {
            if (page.getContentStream(i).isFlushed()) {
                return true;
            }
        }
        return false;
    }


    /**
     * Calculates the contrast result for a single text renderer against all background elements.
     * <p>
     * This method processes backgrounds from top to bottom (end to start of the list) to prioritize
     * topmost elements. If the text is fully covered by the topmost element(s), processing stops.
     * Otherwise, it continues analyzing all background elements below and sums up the intersection
     * areas until reaching a background that completely covers the text.
     * <p>
     * Only text render information is processed; background elements passed as possibleTextRenderer
     * will return null.
     *
     * @param allRenderers         the complete list of all render information objects (text and backgrounds) on the
     *                             page
     * @param possibleTextRenderer the renderer to analyze, must be a TextContrastInformation to be processed
     * @param pageNumber           the page number where the renderer is located
     *
     * @return a {@link ContrastResult} containing all background intersections and their contrast ratios,
     * or {@code null} if possibleTextRenderer is not text or has no intersecting backgrounds
     */
    private ContrastResult calculateContrastOfTextRenderer(List<ColorInfo> allRenderers, ColorInfo possibleTextRenderer,
            int pageNumber, int currentDepth) {
        if (!(possibleTextRenderer instanceof TextColorInfo)) {
            // we only calculate contrast between text and background
            // so we will only process text render infos here
            return null;
        }
        TextColorInfo textContrastInfo = (TextColorInfo) possibleTextRenderer;
        final ContrastResult contrastResult = new ContrastResult(textContrastInfo, pageNumber);

        for (int j = currentDepth - 1; j >= 0; j--) {
            final ColorInfo backGround = allRenderers.get(j);
            if (backGround instanceof TextColorInfo) {
                //We are only interested in background and clip render infos here
                continue;
            }

            final boolean hasTooManyPoints =
                    textContrastInfo.getPath().getSubpaths().size() > this.maxAmountOfPointInPolygon
                            || backGround.getPath().getSubpaths().size() > this.maxAmountOfPointInPolygon;

            if (hasTooManyPoints) {
                // instead of warning we could kinda flatten the paths here to reduce the amount of points
                // the big amount of background mainly happens on svg images with lot of details
                LOGGER.warn("Skipping contrast calculation between text and background for "
                        + "text: '" + textContrastInfo.getText() + "' on page " + pageNumber
                        + " because one of them has too "
                        + "many points in polygon. Text points: " + textContrastInfo.getPath().getSubpaths().size()
                        + " Background points: " + backGround.getPath().getSubpaths().size()
                        + " if this is intended you can increase the maxAmountOfPointInPolygon property.");
                continue;
            }

            final PolyTree intersectionPathBetweenTextAndBackground = calculateIntersectionPath(
                    textContrastInfo.getPath(), backGround.getPath());

            if (intersectionPathBetweenTextAndBackground.getTotalSize() != 0) {
                final DeviceRgb color1 = convertToRGB(textContrastInfo.getColor());
                final DeviceRgb color2 = convertToRGB(backGround.getColor());
                //fast check first for unsupported color spaces to avoid unnecessary calculations
                if (color1 == null || color2 == null) {
                    //Means color space can't be converted to be usable for contrast calculation
                    continue;
                }

                final OverlappingArea overlappingArea = new OverlappingArea((BackgroundColorInfo) backGround,
                        ColorContrastCalculator.contrastRatio(color1, color2));

                contrastResult.addContrastResult(overlappingArea);
                final Path unionOfAllIntersectionPaths = getOutlinesOfAllPoints(
                        contrastResult.getOverlappingAreas().stream().map(p -> p.getBackgroundRenderInfo().getPath())
                                .collect(Collectors.toList()));

                final PolyTree outlinePaths = calculateIntersectionPath(textContrastInfo.getPath(),
                        unionOfAllIntersectionPaths);
                final Path intersectionOutlinePath = new ClipperBridge(unionOfAllIntersectionPaths).convertToPath(
                        outlinePaths);
                final double intersectionAreaAll = calculatePolygonArea(convertPathToPoints(intersectionOutlinePath));
                final double textRenderArea = calculatePolygonArea(convertPathToPoints(textContrastInfo.getPath()));
                final double intersectionAreaCoversText = intersectionAreaAll / textRenderArea;
                overlappingArea.setOverlapRatio(intersectionAreaCoversText);

                if (intersectionAreaAll >= textRenderArea - 0.01) {
                    //The text render info is completely covered by the union of all background render infos
                    // we can stop processing more background render infos because all the underlying colors
                    // do not
                    // matter for the final contrast ratio as they should not be visible anyway
                    break;
                }
            }
        }
        final boolean hasIntersectionWithBackGround = !contrastResult.getOverlappingAreas().isEmpty();
        return hasIntersectionWithBackGround ? contrastResult : null;
    }


    private static DeviceRgb convertToRGB(Color color) {
        if (color == null) {
            return null;
        }
        if (color instanceof DeviceRgb) {
            return (DeviceRgb) color;
        } else if (color instanceof DeviceGray) {
            float gray = color.getColorValue()[0];
            return new DeviceRgb(gray, gray, gray);
        } else if (color instanceof DeviceCmyk) {
            return Color.convertCmykToRgb((DeviceCmyk) color);
        } else if (color instanceof PatternColor) {
            return null;
        } else {
            float[] components = color.getColorValue();
            if (components.length == 1) {
                return new DeviceRgb(components[0], components[0], components[0]);
            } else if (components.length == 3) {
                return new DeviceRgb(components[0], components[1], components[2]);
            } else {
                LOGGER.warn(MessageFormatUtil.format(KernelLogMessageConstant.UNSUPPORTED_COLOR_SPACE_CONTRAST,
                        color.getClass().getName()));
                return null;
            }
        }
    }


    /**
     * Calculates the area of a polygon defined by an array of vertices.
     * This method first computes the convex hull of the points to handle cases where
     * the points may not be in order or form a complex polygon, then calculates the
     * area using the shoelace formula.
     *
     * @param vertices the array of points defining the polygon vertices
     *
     * @return the area of the polygon in square units
     */
    private static double calculatePolygonArea(Point[] vertices) {
        //We calculate the convex hull of the points to avoid issues with complex polygons and the Points not being
        // in order.
        List<Point> hull = ConvexHullArea.convexHull(Arrays.asList(vertices));
        return polygonArea(hull);
    }


    /**
     * Calculates the area of a polygon using the shoelace formula.
     * The polygon is defined by an ordered list of vertices.
     *
     * @param polygon the list of points defining the polygon vertices in order
     *
     * @return the area of the polygon, or 0 if the polygon has fewer than 3 vertices
     */
    //Shoelace formula
    private static double polygonArea(List<Point> polygon) {
        int n = polygon.size();
        if (n < 3) {
            return 0;
        }

        double area = 0;
        for (int i = 0; i < n; i++) {
            Point p1 = polygon.get(i);
            Point p2 = polygon.get((i + 1) % n);
            area += (p1.getX() * p2.getY()) - (p2.getX() * p1.getY());
        }
        return Math.abs(area) / 2.0;
    }

    /**
     * Computes the union of multiple paths to create a single outline path.
     * This is used to determine the total area covered by multiple overlapping backgrounds.
     * Uses the Clipper library to perform polygon union operations.
     *
     * @param pathPoints the list of paths to combine
     *
     * @return a {@link Path} representing the union of all input paths, or an empty Path if the operation fails
     */
    private static Path getOutlinesOfAllPoints(List<Path> pathPoints) {
        Path[] pathsArray = pathPoints.toArray(new Path[0]);
        ClipperBridge clipperBridge = new ClipperBridge(pathsArray);
        DefaultClipper clipper = new DefaultClipper();

        for (Path path : pathPoints) {
            Point[] pathAsPointsTextRender = convertPathToPoints(path);
            clipperBridge.addPolygonToClipper(clipper, pathAsPointsTextRender, IClipper.PolyType.SUBJECT);
        }

        Paths paths = new Paths();
        boolean result = clipper.execute(ClipType.UNION, paths, IClipper.PolyFillType.NON_ZERO, PolyFillType.NON_ZERO);
        if (!result) {
            return new Path();
        }
        Path resultPath = new Path();
        for (List<LongPoint> longPoints : paths) {
            List<Point> floatPoints = clipperBridge.convertToFloatPoints(longPoints);
            Subpath subpath = new Subpath();
            if (floatPoints.isEmpty()) {
                continue;
            }
            subpath.setStartPoint(floatPoints.get(0));
            for (int i = 1; i < floatPoints.size(); i++) {
                subpath.addSegment(new Line(floatPoints.get(i - 1), floatPoints.get(i)));
            }
            subpath.setClosed(true);
            resultPath.addSubpath(subpath);
        }

        return resultPath;
    }

    /**
     * Calculates the intersection between a text path and a background path.
     * This determines which parts of the text overlap with which backgrounds, enabling
     * accurate contrast calculations only for overlapping regions.
     * Uses the Clipper library for polygon intersection operations.
     *
     * @param textPath       the path representing the text bounding box
     * @param backgroundPath the path representing the background shape
     *
     * @return a {@link PolyTree} representing the intersection, or an empty {@link PolyTree}
     * if there is no intersection
     */
    private static PolyTree calculateIntersectionPath(Path textPath, Path backgroundPath) {
        final ClipperBridge clipperBridge = new ClipperBridge(textPath, backgroundPath);
        final DefaultClipper clipper = new DefaultClipper();

        final Point[] pathAsPointsTextRender = convertPathToPoints(textPath);
        clipperBridge.addPolygonToClipper(clipper, pathAsPointsTextRender, IClipper.PolyType.SUBJECT);

        final Point[] pathsAsPointBackgroundRender = convertPathToPoints(backgroundPath);
        clipperBridge.addPolygonToClipper(clipper, pathsAsPointBackgroundRender, PolyType.CLIP);

        final PolyTree paths = new PolyTree();
        boolean result = clipper.execute(ClipType.INTERSECTION, paths, IClipper.PolyFillType.NON_ZERO,
                PolyFillType.NON_ZERO);
        if (!result) {
            return new PolyTree();
        }
        return paths;
    }

    private static Point[] convertPathToPoints(Path path) {
        List<Point> points = new ArrayList<>();
        for (Subpath subpath : path.getSubpaths()) {
            for (IShape segment : subpath.getSegments()) {
                points.addAll(segment.getBasePoints());
            }
        }
        return points.toArray(new Point[0]);
    }

    /**
     * Adds a default white background that covers the entire page to the contrast information list.
     * This ensures that all text elements have at least one background to compare against, even if
     * they don't overlap with any explicitly drawn backgrounds in the PDF.
     *
     * @param contrastInfoList the list to add the default background to
     * @param page             the PDF page whose dimensions define the background rectangle
     */
    private static void addDefaultBackground(List<ColorInfo> contrastInfoList, PdfPage page) {
        Path backgroundPath = new Path();
        Subpath backgroundSubpath = new Subpath();
        backgroundSubpath.setStartPoint(0, 0);
        backgroundSubpath.addSegment(new Line(new Point(0, 0), new Point(page.getPageSize().getWidth(), 0)));
        backgroundSubpath.addSegment(new Line(new Point(page.getPageSize().getWidth(), 0),
                new Point(page.getPageSize().getWidth(), page.getPageSize().getHeight())));
        backgroundSubpath.addSegment(new Line(new Point(page.getPageSize().getWidth(), page.getPageSize().getHeight()),
                new Point(0, page.getPageSize().getHeight())));
        backgroundSubpath.addSegment(new Line(new Point(0, page.getPageSize().getHeight()), new Point(0, 0)));
        backgroundPath.addSubpath(backgroundSubpath);

        contrastInfoList.add(new BackgroundColorInfo(ColorConstants.WHITE, backgroundPath));
    }

    private static final class FontResolvingDocumentProcessor extends PdfCanvasProcessor {
        private final Function<PdfDictionary, PdfFont> fontSupplier;

        public FontResolvingDocumentProcessor(IEventListener listener, Function<PdfDictionary, PdfFont> fontSupplier) {
            super(listener);
            this.fontSupplier = fontSupplier;
        }

        @Override
        protected PdfFont getFont(PdfDictionary fontDict) {
            PdfFont font = fontSupplier.apply(fontDict);
            if (font != null) {
                return font;
            }
            return super.getFont(fontDict);
        }
    }
}