ColorContrastChecker.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.kernel.contrast.ContrastResult.OverlappingArea;
import com.itextpdf.kernel.exceptions.PdfException;
import com.itextpdf.kernel.logs.KernelLogMessageConstant;
import com.itextpdf.kernel.pdf.PdfObject;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.validation.IValidationChecker;
import com.itextpdf.kernel.validation.IValidationContext;
import com.itextpdf.kernel.validation.ValidationType;
import com.itextpdf.kernel.validation.context.PdfPageValidationContext;
import java.util.List;
/**
* A validation checker that analyzes color contrast in PDF documents to ensure compliance
* with Web Content Accessibility Guidelines (WCAG) standards.
* <p>
* This checker validates the contrast ratio between text and background colors to ensure
* readability for users with visual impairments. It supports both WCAG 2.0 Level AA and
* Level AAA conformance levels.
* <p>
* Features: @see {@link ContrastAnalyzer} for details.
* <p>
* Current Limitations @see {@link ContrastAnalyzer} for details.
*/
public class ColorContrastChecker implements IValidationChecker {
private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(ColorContrastChecker.class);
/**
* Flag indicating whether to analyze contrast at the individual glyph level.
* When true, each glyph is analyzed separately for precise contrast checking.
*/
private final boolean checkIndividualGlyphs;
/**
* Flag indicating whether to throw an exception when contrast requirements are not met.
* When false, warnings are logged instead.
*/
private final boolean throwExceptionOnFailure;
private double minimalPercentualCoverage = 0.1;
/**
* Flag indicating whether to check for WCAG AA compliance.
* WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text.
*/
private boolean checkWcagAA;
/**
* Flag indicating whether to check for WCAG AAA compliance.
* WCAG AAA requires a contrast ratio of at least 7:1 for normal text and 4.5:1 for large text.
*/
private boolean checkWcagAAA;
/**
* Creates a new ColorContrastChecker with the specified configuration.
*
* @param checkIndividualGlyphs if {@code true}, contrast is checked at the individual glyph level;
* if {@code false}, contrast is checked at the text block level.
* Individual glyph checking is more precise but may impact performance.
* @param throwExceptionOnFailure if {@code true}, a {@link PdfException} is thrown when contrast
* requirements are not met; if {@code false}, warnings are logged instead.
*/
public ColorContrastChecker(boolean checkIndividualGlyphs, boolean throwExceptionOnFailure) {
this.checkIndividualGlyphs = checkIndividualGlyphs;
this.throwExceptionOnFailure = throwExceptionOnFailure;
setCheckWcagAA(true);
setCheckWcagAAA(true);
setMinimalPercentualCoverage(0.1);
}
/**
* Sets the minimal percentual coverage of text area that must be covered by a background
* element for its contrast ratio to be considered in the analysis.
* <p>
* For example, if set to 0.1 (10%), only background elements that cover at least 10% of the
* text area will be included in the contrast analysis. This helps filter out insignificant backgrounds
* that do not meaningfully affect text readability. Like underlines or small decorative elements.
*
* @param minimalPercentualCoverage the minimal percentual coverage (between 0.0 and 1.0)
*/
public final void setMinimalPercentualCoverage(double minimalPercentualCoverage) {
if (minimalPercentualCoverage < 0.0 || minimalPercentualCoverage > 1.0) {
throw new IllegalArgumentException("Minimal percentual coverage must be a value between 0.0 and 1.0");
}
this.minimalPercentualCoverage = minimalPercentualCoverage;
}
/**
* Sets whether to check for WCAG AA compliance.
* WCAG AA requires a contrast ratio of at least 4.5:1 for normal text
* and 3:1 for large text (18pt+ or 14pt+ bold).
*
* @param checkWcagAA true to enable WCAG AA compliance checking, false to disable
*
* @return this ColorContrastChecker instance for method chaining
*/
public final ColorContrastChecker setCheckWcagAA(boolean checkWcagAA) {
this.checkWcagAA = checkWcagAA;
logWarningIfBothChecksDisabled();
return this;
}
/**
* Sets whether to check for WCAG AAA compliance.
* WCAG AAA requires a contrast ratio of at least 7:1 for normal text
* and 4.5:1 for large text (18pt+ or 14pt+ bold).
*
* @param checkWcagAAA true to enable WCAG AAA compliance checking, false to disable
*
* @return this ColorContrastChecker instance for method chaining
*/
public final ColorContrastChecker setCheckWcagAAA(boolean checkWcagAAA) {
this.checkWcagAAA = checkWcagAAA;
logWarningIfBothChecksDisabled();
return this;
}
/**
* Validates the given context for color contrast compliance.
* <p>
* This method is called by the validation framework to check color contrast
* when a PDF page is being validated. It only processes validation contexts
* of type {@link ValidationType#PDF_PAGE}.
*
* @param validationContext the validation context containing the PDF page to validate
*/
@Override
public void validate(IValidationContext validationContext) {
if (validationContext.getType() == ValidationType.PDF_PAGE) {
PdfPageValidationContext pageContext = (PdfPageValidationContext) validationContext;
checkContrast(pageContext.getPage());
}
}
/**
* Determines if a PDF object is ready to be flushed to the output stream.
* <p>
* This implementation always returns true as color contrast checking does not
* impose any restrictions on when objects can be flushed.
*
* @param object the PDF object to check
*
* @return always {@code true}
*/
@Override
public boolean isPdfObjectReadyToFlush(PdfObject object) {
return true;
}
/**
* Logs a warning if both WCAG AA and AAA compliance checks are disabled.
* This helps alert users that no contrast validation will be performed.
*/
private void logWarningIfBothChecksDisabled() {
if (!checkWcagAA && !checkWcagAAA) {
LOGGER.warn(KernelLogMessageConstant.BOTH_WCAG_AA_AND_AAA_COMPLIANCE_CHECKS_DISABLED);
}
}
/**
* Performs color contrast analysis on the specified PDF page.
* <p>
* This method analyzes all text on the page and checks if it meets the enabled
* WCAG compliance levels (AA and/or AAA). For each non-compliant text element,
* it either throws a {@link PdfException} or logs a warning, depending on the
* configuration.
* <p>
* The method skips processing entirely if both WCAG AA and AAA checks are disabled.
*
* @param page the PDF page to analyze for color contrast compliance
*
* @throws PdfException if throwExceptionOnFailure is true and non-compliant text is found
*/
private void checkContrast(PdfPage page) {
if (!checkWcagAA && !checkWcagAAA) {
// No checks enabled, skip processing
return;
}
List<ContrastResult> contrastResults = new ContrastAnalyzer(checkIndividualGlyphs).checkPageContrast(page);
for (ContrastResult contrastResult : contrastResults) {
TextColorInfo textContrastInformation = contrastResult.getTextRenderInfo();
for (OverlappingArea overlappingArea : contrastResult.getOverlappingAreas()) {
if (overlappingArea.getOverlapRatio() < minimalPercentualCoverage) {
continue;
}
// Only check compliance levels that are enabled
boolean isCompliantAAA = !checkWcagAAA || WCagChecker.isTextWcagAAACompliant(
textContrastInformation.getFontSize(), overlappingArea.getContrastRatio());
boolean isCompliantAA = isCompliantAAA && (!checkWcagAA || WCagChecker.isTextWcagAACompliant(
textContrastInformation.getFontSize(), overlappingArea.getContrastRatio()));
// Report only if at least one enabled check fails
if (!isCompliantAA || !isCompliantAAA) {
String message = generateMessage(isCompliantAAA, isCompliantAA, contrastResult,
overlappingArea.getContrastRatio());
if (this.throwExceptionOnFailure) {
message = "Color contrast check failed: " + message;
throw new PdfException(message);
} else {
LOGGER.warn(message);
}
}
}
}
}
private String generateMessage(boolean isCompliantAAA, boolean isCompliantAA,
ContrastResult contrastResult, double contrastRatio) {
TextColorInfo textContrastInformation = contrastResult.getTextRenderInfo();
StringBuilder message = new StringBuilder();
message.append("Page ").append(contrastResult.getPageNumber()).append(": ");
if (textContrastInformation.getText() != null) {
message.append("Text: '");
message.append(textContrastInformation.getText());
message.append("', ");
}
if (textContrastInformation.getParent() != null) {
message.append(" parent text: '");
message.append(textContrastInformation.getParent());
message.append("' ");
}
message.append("with font size: ").append(contrastResult.getTextRenderInfo().getFontSize()).append(" pt ");
message.append("has contrast ratio: ").append(formatFloatWithoutStringFormat(contrastRatio)).append(". ");
if (checkWcagAA && !isCompliantAA) {
message.append("It is not WCAG AA compliant. ");
}
if (checkWcagAAA && !isCompliantAAA) {
message.append("It is not WCAG AAA compliant. ");
}
return message.toString();
}
private String formatFloatWithoutStringFormat(double value) {
//2 decimal places
long intValue = (long) value;
long decimalValue = (long) Math.round((value - intValue) * 100);
if (decimalValue < 10) {
return intValue + "." + "0" + decimalValue;
}
return intValue + "." + decimalValue;
}
}