From a115dd3222f4520ddcd04c55b28e49b09d4ac6fb Mon Sep 17 00:00:00 2001 From: Ana Ferreira Date: Wed, 2 Oct 2024 15:54:51 -0300 Subject: [PATCH] WCAG Contrast Checker Module (#215) * improves: improve split_sequence and add map_values utils * feat: Create a function to calculate the distance to Manhattan (#212) * Create RGB normalization function * feat: Checks the contrast ratio between two colors and determines the level of WCAG compliance * feat: Calculates contrast ratio based on font size and type * feat: Create tests for contrast ratio --------- Co-authored-by: jlsneto --- cereja/__init__.py | 1 + cereja/utils/__init__.py | 1 + cereja/utils/colors/_color.py | 87 +++++++++++- cereja/utils/colors/converters.py | 26 +++- cereja/utils/typography/__init__.py | 2 + cereja/utils/typography/_typography.py | 121 +++++++++++++++++ cereja/utils/typography/converters.py | 176 +++++++++++++++++++++++++ cereja/wcag/__init__.py | 23 ++++ cereja/wcag/validator/__init__.py | 1 + cereja/wcag/validator/_color.py | 85 ++++++++++++ tests/testscolors.py | 48 ++++++- tests/typography/teststypography.py | 112 ++++++++++++++++ tests/wcag/validator/testscolors.py | 63 +++++++++ 13 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 cereja/utils/typography/__init__.py create mode 100644 cereja/utils/typography/_typography.py create mode 100644 cereja/utils/typography/converters.py create mode 100644 cereja/wcag/__init__.py create mode 100644 cereja/wcag/validator/__init__.py create mode 100644 cereja/wcag/validator/_color.py create mode 100644 tests/typography/teststypography.py create mode 100644 tests/wcag/validator/testscolors.py diff --git a/cereja/__init__.py b/cereja/__init__.py index 19a859e..aab8155 100644 --- a/cereja/__init__.py +++ b/cereja/__init__.py @@ -47,6 +47,7 @@ from . import experimental from ._requests import request from . import scraping +from . import wcag VERSION = "2.0.2.final.0" diff --git a/cereja/utils/__init__.py b/cereja/utils/__init__.py index def9aa2..5c9a4b4 100644 --- a/cereja/utils/__init__.py +++ b/cereja/utils/__init__.py @@ -25,3 +25,4 @@ # Aliases here from ._utils import get_batch_strides as stride_values from . import colors +from . import typography diff --git a/cereja/utils/colors/_color.py b/cereja/utils/colors/_color.py index 4a54de4..e58ca74 100644 --- a/cereja/utils/colors/_color.py +++ b/cereja/utils/colors/_color.py @@ -6,8 +6,19 @@ __all__ = ['Color'] - class Color: + # Constants for calculating relative luminance + GAMMA_THRESHOLD = 0.03928 + GAMMA_CORRECTION_FACTOR = 12.92 + LINEAR_CORRECTION_FACTOR = 1.055 + LINEAR_CORRECTION_OFFSET = 0.055 + GAMMA_EXPONENT = 2.4 + + # Constants for RGB sensitivity coefficients + LUMINANCE_RED_COEFFICIENT = 0.2126 + LUMINANCE_GREEN_COEFFICIENT = 0.7152 + LUMINANCE_BLUE_COEFFICIENT = 0.0722 + def __init__(self, red, green, blue, alpha=None): self.red = red self.green = green @@ -43,6 +54,76 @@ def hsv(self): def cmyk(self): return _converters.rgb_to_cmyk(self.red, self.green, self.blue) + def normalize(self): + return Color(*_converters.normalize_rgb(self.red, self.green, self.blue)) + + @property + def luminance(self) -> float: + """ + Calculates the relative luminance of the current color. + + This method computes the relative luminance of a color based on its RGB values, + normalized and adjusted using gamma correction. The luminance is calculated by applying + coefficients that reflect human sensitivity to different color components (red, green, and blue). + + Returns: + float: The calculated relative luminance of the color. The luminance value is used + in various standards (e.g., WCAG) to evaluate color contrast for accessibility. + """ + def apply_gamma_correction(value: float) -> float: + """ + Applies gamma correction to the normalized RGB value. + + Args: + value (float): The normalized RGB component value (0-1). + + Returns: + float: The gamma-corrected value. + """ + if value <= Color.GAMMA_THRESHOLD: + return value / Color.GAMMA_CORRECTION_FACTOR + else: + return ((value + Color.LINEAR_CORRECTION_OFFSET) / Color.LINEAR_CORRECTION_FACTOR) ** Color.GAMMA_EXPONENT + + # Normalize RGB values (0-255 to 0-1) + r_normalized, g_normalized, b_normalized = self.normalize() + + # Apply gamma correction to the normalized RGB values + r_adjusted = apply_gamma_correction(r_normalized) + g_adjusted = apply_gamma_correction(g_normalized) + b_adjusted = apply_gamma_correction(b_normalized) + + # Calculate luminance using weighted coefficients for red, green, and blue + luminance = ( + Color.LUMINANCE_RED_COEFFICIENT * r_adjusted + + Color.LUMINANCE_GREEN_COEFFICIENT * g_adjusted + + Color.LUMINANCE_BLUE_COEFFICIENT * b_adjusted + ) + + return luminance + + @staticmethod + def contrast_ratio(first_luminance: float, second_luminance: float) -> float: + """ + Calculates the contrast ratio between two luminance values. + + This function computes the contrast ratio between two colors based on their luminance values. + The contrast ratio is defined by the formula (L1 + 0.05) / (L2 + 0.05), where L1 is the + luminance of the lighter color and L2 is the luminance of the darker color. + + Args: + first_luminance (float): The luminance of the first color. + second_luminance (float): The luminance of the second color. + + Returns: + float: The contrast ratio between the two colors. The result is a value between 1 (no contrast) + and 21 (maximum contrast), where higher values indicate better readability. + """ + l1 = max(first_luminance, second_luminance) + l2 = min(first_luminance, second_luminance) + + return (l1 + 0.05) / (l2 + 0.05) + @classmethod def from_hex(cls, hex_value): val_parsed = _converters.parse_hex(hex_value) @@ -77,10 +158,10 @@ def interpolate(self, other, factor): def generate_gradient(self, other, steps): """ - Generate a gradient between two colors. + Generate a gradient between two validator. @param other: color to interpolate with @param steps: number of steps in the gradient - @return: list of colors + @return: list of validator """ return [self.interpolate(other, i / steps) for i in range(steps)] diff --git a/cereja/utils/colors/converters.py b/cereja/utils/colors/converters.py index 8ed2226..aa9f117 100644 --- a/cereja/utils/colors/converters.py +++ b/cereja/utils/colors/converters.py @@ -1,8 +1,26 @@ from __future__ import annotations __all__ = ["rgb_to_hex", "hex_to_rgb", "rgb_to_hsl", "hsl_to_rgb", "rgb_to_hsv", "hsv_to_rgb", "rgb_to_cmyk", - "cmyk_to_rgb"] + "cmyk_to_rgb", "normalize_rgb"] +def normalize_rgb(r, g, b): + """ + Normalizes the RGB values from 0-255 to the 0-1 range. + + This function converts the RGB color components, typically in the 0-255 range, + to values in the 0-1 range. It is useful in various contexts of image processing, + graphics, and color calculations, where color values need to be worked on in a normalized format. + + Args: + r (int): The red component value, ranging from 0 to 255. + g (int): The green component value, ranging from 0 to 255. + b (int): The blue component value, ranging from 0 to 255. + + Returns: + tuple[float, float, float]: A tuple containing the normalized red, + green, and blue values, each in the 0-1 range. + """ + return r / 255.0, g / 255.0, b / 255.0 def parse_hex(hex_value): hex_value = hex_value.lstrip("#") @@ -55,9 +73,7 @@ def hex_to_rgba(hex_value): def rgb_to_hsl(r, g, b): - r /= 255.0 - g /= 255.0 - b /= 255.0 + r, g, b = normalize_rgb(r, g, b) max_c = max(r, g, b) min_c = min(r, g, b) l = (max_c + min_c) / 2.0 @@ -104,7 +120,7 @@ def hsl_to_rgba(h, s, l): def rgb_to_hsv(r, g, b): - r, g, b = r / 255.0, g / 255.0, b / 255.0 + r, g, b = normalize_rgb(r, g, b) max_c = max(r, g, b) min_c = min(r, g, b) delta = max_c - min_c diff --git a/cereja/utils/typography/__init__.py b/cereja/utils/typography/__init__.py new file mode 100644 index 0000000..a9eece5 --- /dev/null +++ b/cereja/utils/typography/__init__.py @@ -0,0 +1,2 @@ +from .converters import * +from ._typography import Typography diff --git a/cereja/utils/typography/_typography.py b/cereja/utils/typography/_typography.py new file mode 100644 index 0000000..8e84cc9 --- /dev/null +++ b/cereja/utils/typography/_typography.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from . import converters as _converters + +__all__ = ['Typography'] + +class Typography: + + def __init__(self, value: float, unit: str = 'px'): + self.value = value + self.unit = unit + + @property + def px(self) -> float: + """ + Converts the current value to pixels (px). + """ + if self.unit == 'pt': + return _converters.pt_to_px(self.value) + elif self.unit in ['em', 'rem']: + return self.value * _converters.DEFAULT_FONT_SIZE_PX + return self.value + + @property + def pt(self) -> float: + """ + Converts the current value to points (pt). + """ + if self.unit == 'px': + return _converters.px_to_pt(self.value) + elif self.unit == 'em': + return _converters.em_to_pt(self.value, _converters.DEFAULT_FONT_SIZE_PX) + elif self.unit == 'rem': + return _converters.rem_to_pt(self.value, _converters.DEFAULT_FONT_SIZE_PX) + return self.value + + @property + def em(self) -> float: + """ + Converts the current value to em, using the base font size. + """ + if self.unit == 'px': + return self.value / _converters.DEFAULT_FONT_SIZE_PX + elif self.unit == 'pt': + px_value = _converters.pt_to_px(self.value) + return px_value / _converters.DEFAULT_FONT_SIZE_PX + return self.value + + @property + def rem(self) -> float: + """ + Converts the current value to rem, based on the default font size. + """ + return self.em # rem is based on the root font size, similar to em + + @classmethod + def from_px(cls, px_value: float): + """ + Initializes the Typography from a pixel (px) value. + """ + return cls(px_value, 'px') + + @classmethod + def from_pt(cls, pt_value: float): + """ + Initializes the Typography from a point (pt) value. + """ + return cls(pt_value, 'pt') + + @classmethod + def from_em(cls, em_value: float): + """ + Initializes the Typography from an em value. + """ + return cls(em_value, 'em') + + @classmethod + def from_rem(cls, rem_value: float): + """ + Initializes the Typography from a rem value. + """ + return cls(rem_value, 'rem') + + @classmethod + def parse_font_size(cls, font_size: str) -> tuple[float, str]: + """ + Parse the font size string and extract the numeric value and the unit. + + Args: + font_size (str): The font size as a string, e.g., '16px', '12pt', '1.5em', '2rem'. + + Returns: + tuple: A tuple containing the numeric value and the unit as separate elements. + """ + import re + match = re.match(r"([\d.]+)(px|pt|em|rem)", font_size) + if not match: + raise ValueError(f"Invalid font size format: {font_size}") + + value = float(match.group(1)) # Extract the numeric value + unit = match.group(2) # Extract the unit (px, pt, em, rem) + return value, unit + + def __eq__(self, other): + """ + Checks equality between two Typography instances by comparing their values in pixels. + """ + if isinstance(other, Typography): + return self.px == other.px + return False + + def __ne__(self, other): + """ + Checks inequality between two Typography instances. + """ + return not self.__eq__(other) + + def __repr__(self): + """ + Provides a string representation of the Typography instance. + """ + return f"Typography(value={self.value}, unit='{self.unit}')" diff --git a/cereja/utils/typography/converters.py b/cereja/utils/typography/converters.py new file mode 100644 index 0000000..41d6f3e --- /dev/null +++ b/cereja/utils/typography/converters.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +__all__ = [ + "px_to_pt", "pt_to_px", "em_to_pt", "pt_to_em", "em_to_px", "pt_to_rem", "rem_to_px", "rem_to_pt", "pt_to_rem" +] + +PIXELS_PER_INCH = 96 # Screen resolution standard (96 PPI) +POINTS_PER_INCH = 72 # 1 inch = 72 pt +DEFAULT_FONT_SIZE_PX = 16 # Default font size for em/rem (16px) + +def px_to_pt(px: float) -> float: + """ + Converts pixels (px) to points (pt), assuming a screen resolution of 96 PPI. + + This function converts a pixel value to its equivalent in points, based on the standard + screen resolution of 96 pixels per inch (PPI). + + Args: + px (float): The pixel value to convert. Must be a non-negative value. + + Returns: + float: The equivalent value in points (pt). + + Raises: + ValueError: If the pixel value is negative. + """ + if px < 0: + raise ValueError("Pixel value cannot be negative.") + return (px * POINTS_PER_INCH) / PIXELS_PER_INCH + +def pt_to_px(pt: float) -> float: + """ + Converts points (pt) to pixels (px), assuming a screen resolution of 96 PPI. + + This function converts a point value to its equivalent in pixels, based on the standard + screen resolution of 96 pixels per inch (PPI). + + Args: + pt (float): The point value to convert. Must be a non-negative value. + + Returns: + float: The equivalent value in pixels (px). + + Raises: + ValueError: If the point value is negative. + """ + if pt < 0: + raise ValueError("Point value cannot be negative.") + return (pt * PIXELS_PER_INCH) / POINTS_PER_INCH + +def em_to_pt(em: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts em units to points (pt), based on a given base font size in pixels. + + This function calculates the equivalent value in points for a given em value, + based on the provided base font size in pixels. + + Args: + em (float): The em value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in points (pt). + + Raises: + ValueError: If the em value is negative. + """ + if em < 0: + raise ValueError("EM value cannot be negative.") + return px_to_pt(em * base_font_size_px) + +def pt_to_em(pt: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts points (pt) to em units, based on a given base font size in pixels. + + This function calculates the equivalent em value for a given point size, using + the provided base font size in pixels. + + Args: + pt (float): The point value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in em units. + + Raises: + ValueError: If the point value is negative. + """ + if pt < 0: + raise ValueError("Point value cannot be negative.") + px_value = pt_to_px(pt) + return px_value / base_font_size_px + +def em_to_px(em: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts em units to pixels (px), based on a given base font size in pixels. + + This function calculates the equivalent value in pixels for a given em value, + using the provided base font size in pixels. + + Args: + em (float): The em value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in pixels (px). + + Raises: + ValueError: If the em value is negative. + """ + if em < 0: + raise ValueError("EM value cannot be negative.") + return em * base_font_size_px + +def pt_to_rem(pt: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts points (pt) to rem units, based on a given base font size in pixels. + + This function calculates the equivalent rem value for a given point size, + using the provided base font size in pixels. + + Args: + pt (float): The point value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in rem units. + + Raises: + ValueError: If the point value is negative. + """ + if pt < 0: + raise ValueError("Point value cannot be negative.") + return pt_to_em(pt, base_font_size_px) + +def rem_to_px(rem: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts rem units to pixels (px), based on a given base font size in pixels. + + This function calculates the equivalent value in pixels for a given rem value, + using the provided base font size in pixels. + + Args: + rem (float): The rem value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in pixels (px). + + Raises: + ValueError: If the rem value is negative. + """ + if rem < 0: + raise ValueError("REM value cannot be negative.") + return rem * base_font_size_px + +def rem_to_pt(rem: float, base_font_size_px: float = DEFAULT_FONT_SIZE_PX) -> float: + """ + Converts rem units to points (pt), based on a given base font size in pixels. + + This function calculates the equivalent value in points for a given rem value, + using the provided base font size in pixels. + + Args: + rem (float): The rem value to convert. Must be a non-negative value. + base_font_size_px (float): The base font size in pixels. Default is 16px. + + Returns: + float: The equivalent value in points (pt). + + Raises: + ValueError: If the rem value is negative. + """ + if rem < 0: + raise ValueError("REM value cannot be negative.") + return px_to_pt(rem * base_font_size_px) \ No newline at end of file diff --git a/cereja/wcag/__init__.py b/cereja/wcag/__init__.py new file mode 100644 index 0000000..7169f6b --- /dev/null +++ b/cereja/wcag/__init__.py @@ -0,0 +1,23 @@ +""" +Copyright (c) 2019 The Cereja Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from . import validator +from .validator import contrast_checker diff --git a/cereja/wcag/validator/__init__.py b/cereja/wcag/validator/__init__.py new file mode 100644 index 0000000..8213d1c --- /dev/null +++ b/cereja/wcag/validator/__init__.py @@ -0,0 +1 @@ +from ._color import contrast_checker \ No newline at end of file diff --git a/cereja/wcag/validator/_color.py b/cereja/wcag/validator/_color.py new file mode 100644 index 0000000..3f7d57a --- /dev/null +++ b/cereja/wcag/validator/_color.py @@ -0,0 +1,85 @@ +from cereja.utils.colors import Color +from cereja.utils.typography import Typography + +__all__ = ['contrast_checker'] + +large_text_thresholds = (4.5, 3) # AAA: 4.5, AA: 3 +small_text_thresholds = (7, 4.5) # AAA: 7, AA: 4.5 + +def contrast_checker(first_color, other_color, font_size: str, is_bold=False) -> dict: + """ + Checks the contrast ratio between two colors and determines the WCAG compliance level + considering font size and whether the text is bold. + + This function calculates the contrast ratio between two colors and evaluates whether it meets + WCAG compliance at AA and AAA levels. The compliance level depends on the text size: + - Large text: >= 18pt or >= 14pt if bold. + - Small text: Any font size smaller than what is defined for large text. + + Args: + first_color (Any): The first color, which can be a hex string, tuple (RGB or RGBA), or a Color object. + other_color (Any): The second color, which can be a hex string, tuple (RGB or RGBA), or a Color object. + font_size (str): The font size (e.g., '16px', '12pt', '1.5em', '2rem'). Must include a valid unit. + is_bold (bool): Indicates whether the text is bold. Default is False. + + Returns: + dict: A dictionary showing if the contrast passes or fails for Small Text and Large Text at AA and AAA levels. + The return is structured as follows: + { + 'Element Type': { + 'Small Text': { + 'AA': 'Pass' or 'Fail', + 'AAA': 'Pass' or 'Fail', + }, + 'Large Text': { + 'AA': 'Pass' or 'Fail', + 'AAA': 'Pass' or 'Fail', + } + }, + 'contrast_ratio': 'X.XX:1' # The calculated contrast ratio + } + """ + first_color = Color.parse(first_color) + other_color = Color.parse(other_color) + + # Extract the numeric value and unit from the font size + font_size_value, font_unit = Typography.parse_font_size(font_size) + + typography = Typography(font_size_value, font_unit) + font_size_pt = typography.pt + + # Determine if the font is "large" or "normal" based on size and whether it's bold + if is_bold: + # Bold fonts use a threshold of 14pt for large text + is_large_text = font_size_pt >= 14 + else: + # Normal fonts use a threshold of 18pt for large text + is_large_text = font_size_pt >= 18 + + contrast_ratio = first_color.contrast_ratio(first_color.luminance, other_color.luminance) + + small_text_level = { + 'AA': 'Pass' if contrast_ratio >= small_text_thresholds[1] else 'Fail', + 'AAA': 'Pass' if contrast_ratio >= small_text_thresholds[0] else 'Fail' + } + + large_text_level = { + 'AA': 'Pass' if contrast_ratio >= large_text_thresholds[1] else 'Fail', + 'AAA': 'Pass' if contrast_ratio >= large_text_thresholds[0] else 'Fail' + } + + result = { + 'Element Type': { + 'Small Text': { + 'AA': small_text_level['AA'], + 'AAA': small_text_level['AAA'], + }, + 'Large Text': { + 'AA': large_text_level['AA'], + 'AAA': large_text_level['AAA'], + } + }, + 'contrast_ratio': f"{round(contrast_ratio, 2)}:1", + } + + return result diff --git a/tests/testscolors.py b/tests/testscolors.py index ed4f5c8..f892a97 100644 --- a/tests/testscolors.py +++ b/tests/testscolors.py @@ -1,5 +1,8 @@ import unittest -from cereja.utils.colors import rgb_to_hsl, hsl_to_rgb, rgb_to_hsv, hsv_to_rgb, rgb_to_cmyk, cmyk_to_rgb, Color +from cereja.utils.colors import ( + rgb_to_hsl, hsl_to_rgb, rgb_to_hsv, hsv_to_rgb, + rgb_to_cmyk, cmyk_to_rgb, Color, normalize_rgb +) class TestColor(unittest.TestCase): @@ -48,6 +51,36 @@ def test_to_rgba(self): self.assertEqual(color.hsv, (0, 100, 100)) self.assertEqual(color.cmyk, (0, 100, 100, 0)) + def test_luminance(self): + color = Color(255, 255, 255) # White + self.assertAlmostEqual(color.luminance, 1.0) + + color = Color(0, 0, 0) # Black + self.assertAlmostEqual(color.luminance, 0.0) + + color = Color(255, 0, 0) # Red + self.assertAlmostEqual(color.luminance, 0.2126, places=4) + + def test_contrast_ratio(self): + white = Color(255, 255, 255) + black = Color(0, 0, 0) + red = Color(255, 0, 0) + + # Contrast ratio between white and black should be 21:1 + self.assertAlmostEqual( + white.contrast_ratio(white.luminance, black.luminance), 21.0, places=1 + ) + + # Contrast ratio between white and red + self.assertAlmostEqual( + white.contrast_ratio(white.luminance, red.luminance), 3.998, places=3 + ) + + # Contrast ratio between black and red + self.assertAlmostEqual( + black.contrast_ratio(black.luminance, red.luminance), 5.25, places=2 + ) + class TestConverters(unittest.TestCase): @@ -77,6 +110,19 @@ def test_cmyk_to_rgb(self): r, g, b = cmyk_to_rgb(0, 100, 100, 0) self.assertEqual((r, g, b), (255, 0, 0)) + def test_normalize_rgb(self): + # Test normalization of RGB values (255, 0, 0) + normalized = normalize_rgb(255, 0, 0) + self.assertEqual(normalized, (1.0, 0.0, 0.0)) + + # Test normalization of RGB values (128, 128, 128) + normalized = normalize_rgb(128, 128, 128) + self.assertEqual(normalized, (0.5019607843137255, 0.5019607843137255, 0.5019607843137255)) + + # Test normalization of RGB values (0, 255, 0) + normalized = normalize_rgb(0, 255, 0) + self.assertEqual(normalized, (0.0, 1.0, 0.0)) + if __name__ == '__main__': unittest.main() diff --git a/tests/typography/teststypography.py b/tests/typography/teststypography.py new file mode 100644 index 0000000..6c8bdc8 --- /dev/null +++ b/tests/typography/teststypography.py @@ -0,0 +1,112 @@ +import unittest +from cereja.utils.typography import ( + px_to_pt, pt_to_px, em_to_pt, pt_to_em, em_to_px, pt_to_rem, rem_to_px, rem_to_pt +) + + +class TestTypographyConverters(unittest.TestCase): + """ + Unit tests for typography conversion functions. Each test checks for the correct conversion + between pixel (px), point (pt), em, and rem units, using standard assumptions such as + 96 PPI (pixels per inch) for screen resolution and 16px as the base font size for em/rem. + """ + + def test_px_to_pt(self): + """ + Test conversion from pixels (px) to points (pt). + + The function should correctly convert 96px to 72pt and 48px to 36pt. + Additionally, the test checks for ValueError when a negative pixel value is provided. + """ + self.assertAlmostEqual(px_to_pt(96), 72.0) # 96px should be 72pt + self.assertAlmostEqual(px_to_pt(48), 36.0) # 48px should be 36pt + with self.assertRaises(ValueError): + px_to_pt(-1) # Negative values should raise an error + + def test_pt_to_px(self): + """ + Test conversion from points (pt) to pixels (px). + + The function should correctly convert 72pt to 96px and 36pt to 48px. + Additionally, the test checks for ValueError when a negative point value is provided. + """ + self.assertAlmostEqual(pt_to_px(72), 96.0) + self.assertAlmostEqual(pt_to_px(36), 48.0) + with self.assertRaises(ValueError): + pt_to_px(-1) + + def test_em_to_pt(self): + """ + Test conversion from em units to points (pt). + + The function should correctly convert 1em to 12pt (based on a 16px base font size), + and 1.5em to 18pt. Additionally, the test checks for ValueError when a negative em value is provided. + """ + self.assertAlmostEqual(em_to_pt(1), 12.0) + self.assertAlmostEqual(em_to_pt(1.5), 18.0) + with self.assertRaises(ValueError): + em_to_pt(-1) + + def test_pt_to_em(self): + """ + Test conversion from points (pt) to em units. + + The function should correctly convert 12pt to 1em (based on a 16px base font size), + and 18pt to 1.5em. Additionally, the test checks for ValueError when a negative point value is provided. + """ + self.assertAlmostEqual(pt_to_em(12), 1.0) + self.assertAlmostEqual(pt_to_em(18), 1.5) + with self.assertRaises(ValueError): + pt_to_em(-1) + + def test_em_to_px(self): + """ + Test conversion from em units to pixels (px). + + The function should correctly convert 1em to 16px (based on a 16px base font size), + and 1.5em to 24px. Additionally, the test checks for ValueError when a negative em value is provided. + """ + self.assertAlmostEqual(em_to_px(1), 16.0) + self.assertAlmostEqual(em_to_px(1.5), 24.0) + with self.assertRaises(ValueError): + em_to_px(-1) + + def test_pt_to_rem(self): + """ + Test conversion from points (pt) to rem units. + + The function should correctly convert 12pt to 1rem (based on a 16px base font size), + and 18pt to 1.5rem. Additionally, the test checks for ValueError when a negative point value is provided. + """ + self.assertAlmostEqual(pt_to_rem(12), 1.0) + self.assertAlmostEqual(pt_to_rem(18), 1.5) + with self.assertRaises(ValueError): + pt_to_rem(-1) + + def test_rem_to_px(self): + """ + Test conversion from rem units to pixels (px). + + The function should correctly convert 1rem to 16px (based on a 16px base font size), + and 1.5rem to 24px. Additionally, the test checks for ValueError when a negative rem value is provided. + """ + self.assertAlmostEqual(rem_to_px(1), 16.0) + self.assertAlmostEqual(rem_to_px(1.5), 24.0) + with self.assertRaises(ValueError): + rem_to_px(-1) + + def test_rem_to_pt(self): + """ + Test conversion from rem units to points (pt). + + The function should correctly convert 1rem to 12pt (based on a 16px base font size), + and 1.5rem to 18pt. Additionally, the test checks for ValueError when a negative rem value is provided. + """ + self.assertAlmostEqual(rem_to_pt(1), 12.0) + self.assertAlmostEqual(rem_to_pt(1.5), 18.0) + with self.assertRaises(ValueError): + rem_to_pt(-1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/wcag/validator/testscolors.py b/tests/wcag/validator/testscolors.py new file mode 100644 index 0000000..0bb75fd --- /dev/null +++ b/tests/wcag/validator/testscolors.py @@ -0,0 +1,63 @@ +import unittest +from cereja.utils.colors import Color +from cereja.wcag.validator import contrast_checker + +class TestContrastChecker(unittest.TestCase): + + def test_contrast_checker_small_text(self): + """ + Test the contrast checker for small text with AA and AAA compliance. + """ + result = contrast_checker("#FFFFFF", "#000000", "12pt") + self.assertEqual(result['Element Type']['Small Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Small Text']['AAA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AAA'], 'Pass') + self.assertEqual(result['contrast_ratio'], '21.0:1') + + def test_contrast_checker_large_text(self): + """ + Test the contrast checker for large text with AA and AAA compliance. + """ + result = contrast_checker("#FFFFFF", "#777777", "18pt") + self.assertEqual(result['Element Type']['Small Text']['AA'], 'Fail') + self.assertEqual(result['Element Type']['Small Text']['AAA'], 'Fail') + self.assertEqual(result['Element Type']['Large Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AAA'], 'Fail') + self.assertEqual(result['contrast_ratio'], '4.48:1') + + def test_contrast_checker_bold_large_text(self): + """ + Test the contrast checker for large bold text with AA and AAA compliance. + """ + result = contrast_checker("#FFFFFF", "#777777", "14pt", is_bold=True) + self.assertEqual(result['Element Type']['Small Text']['AA'], 'Fail') + self.assertEqual(result['Element Type']['Small Text']['AAA'], 'Fail') + self.assertEqual(result['Element Type']['Large Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AAA'], 'Fail') + self.assertEqual(result['contrast_ratio'], '4.48:1') + + def test_contrast_checker_fail(self): + """ + Test a case where both small and large text fail AA and AAA compliance. + """ + result = contrast_checker("#FFFFFF", "#AAAAAA", "16px") + self.assertEqual(result['Element Type']['Small Text']['AA'], 'Fail') + self.assertEqual(result['Element Type']['Small Text']['AAA'], 'Fail') + self.assertEqual(result['Element Type']['Large Text']['AA'], 'Fail') + self.assertEqual(result['Element Type']['Large Text']['AAA'], 'Fail') + self.assertEqual(result['contrast_ratio'], '2.32:1') + + def test_contrast_checker_high_contrast(self): + """ + Test a case with a very high contrast ratio. + """ + result = contrast_checker("#FFFFFF", "#00008B", "16px") + self.assertEqual(result['Element Type']['Small Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Small Text']['AAA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AA'], 'Pass') + self.assertEqual(result['Element Type']['Large Text']['AAA'], 'Pass') + self.assertEqual(result['contrast_ratio'], '15.3:1') + +if __name__ == '__main__': + unittest.main()