Skip to content

Commit

Permalink
WCAG Contrast Checker Module (#215)
Browse files Browse the repository at this point in the history
* 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 <leitejoab@gmail.com>
  • Loading branch information
AnaFerreira015 and jlsneto authored Oct 2, 2024
1 parent 990bc61 commit a115dd3
Show file tree
Hide file tree
Showing 13 changed files with 737 additions and 9 deletions.
1 change: 1 addition & 0 deletions cereja/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from . import experimental
from ._requests import request
from . import scraping
from . import wcag

VERSION = "2.0.2.final.0"

Expand Down
1 change: 1 addition & 0 deletions cereja/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
# Aliases here
from ._utils import get_batch_strides as stride_values
from . import colors
from . import typography
87 changes: 84 additions & 3 deletions cereja/utils/colors/_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]

Expand Down
26 changes: 21 additions & 5 deletions cereja/utils/colors/converters.py
Original file line number Diff line number Diff line change
@@ -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("#")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cereja/utils/typography/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .converters import *
from ._typography import Typography
121 changes: 121 additions & 0 deletions cereja/utils/typography/_typography.py
Original file line number Diff line number Diff line change
@@ -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}')"
Loading

0 comments on commit a115dd3

Please sign in to comment.