ploughshares/check_contrast.py

171 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
import re
import math
import os
import sys
def luminance(r, g, b):
"""
Calculate the relative luminance of an RGB color
Formula from WCAG 2.0: https://www.w3.org/TR/WCAG20-TECHS/G17.html
"""
r = r / 255
g = g / 255
b = b / 255
r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4
g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4
b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(lum1, lum2):
"""
Calculate contrast ratio between two luminance values
Formula from WCAG 2.0: https://www.w3.org/TR/WCAG20-TECHS/G17.html
"""
lighter = max(lum1, lum2)
darker = min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
def hex_to_rgb(hex_color):
"""Convert hex color to RGB tuple"""
hex_color = hex_color.lstrip('#')
if len(hex_color) == 3:
hex_color = ''.join([c*2 for c in hex_color])
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def parse_css_colors(css_file):
"""Extract color definitions from CSS file"""
with open(css_file, 'r') as f:
css_content = f.read()
# Find root variables
root_vars = {}
root_match = re.search(r':root\s*{([^}]+)}', css_content)
if root_match:
root_content = root_match.group(1)
var_matches = re.findall(r'--([a-zA-Z0-9-]+):\s*([^;]+);', root_content)
for name, value in var_matches:
if '#' in value:
root_vars[f'--{name}'] = value.strip()
# Find color properties
color_pairs = []
# Background-color and color pairs
elements = re.findall(r'([.#]?[a-zA-Z0-9_-]+)\s*{([^}]+)}', css_content)
for selector, properties in elements:
bg_color = None
text_color = None
bg_match = re.search(r'background-color:\s*([^;]+);', properties)
if bg_match:
bg_color = bg_match.group(1).strip()
color_match = re.search(r'(?<!background-)color:\s*([^;]+);', properties)
if color_match:
text_color = color_match.group(1).strip()
if bg_color and text_color:
# Resolve variables
if bg_color.startswith('var('):
var_match = re.search(r'var\(([^)]+)\)', bg_color)
if var_match:
var_name = var_match.group(1)
if var_name in root_vars:
bg_color = root_vars[var_name]
if text_color.startswith('var('):
var_match = re.search(r'var\(([^)]+)\)', text_color)
if var_match:
var_name = var_match.group(1)
if var_name in root_vars:
text_color = root_vars[var_name]
# Only add hex colors for now
if bg_color.startswith('#') and text_color.startswith('#'):
color_pairs.append((selector, bg_color, text_color))
return color_pairs, root_vars
def check_contrast_ratio(color_pairs):
"""Check contrast ratios for color pairs"""
results = []
for selector, bg_color, text_color in color_pairs:
try:
bg_rgb = hex_to_rgb(bg_color)
text_rgb = hex_to_rgb(text_color)
bg_luminance = luminance(*bg_rgb)
text_luminance = luminance(*text_rgb)
ratio = contrast_ratio(bg_luminance, text_luminance)
status = "PASS" if ratio >= 4.5 else "FAIL"
if 3.0 <= ratio < 4.5:
status = "PASS (Large Text Only)"
results.append({
"selector": selector,
"background": bg_color,
"text": text_color,
"ratio": ratio,
"status": status
})
except Exception as e:
print(f"Error processing {selector}: {e}")
return results
def main():
css_file = "docker/ploughshares/static/css/custom.css"
if not os.path.exists(css_file):
print(f"CSS file not found: {css_file}")
return
print(f"Analyzing contrast ratios in {css_file}...")
color_pairs, root_vars = parse_css_colors(css_file)
print("\nCSS Variables:")
for name, value in root_vars.items():
print(f" {name}: {value}")
print("\nContrast Ratio Analysis:")
results = check_contrast_ratio(color_pairs)
if not results:
print("No color pairs found for contrast analysis.")
return
# Sort by ratio (ascending)
results.sort(key=lambda x: x["ratio"])
print(f"\n{'Selector':<30} {'Background':<15} {'Text':<15} {'Ratio':<10} {'Status'}")
print("-" * 80)
for result in results:
print(f"{result['selector']:<30} {result['background']:<15} {result['text']:<15} {result['ratio']:.2f} {result['status']}")
# Summary
fails = sum(1 for r in results if r['status'] == 'FAIL')
large_only = sum(1 for r in results if r['status'] == 'PASS (Large Text Only)')
passes = sum(1 for r in results if r['status'] == 'PASS')
print("\nSummary:")
print(f" Total color pairs analyzed: {len(results)}")
print(f" Passed: {passes}")
print(f" Passed (Large Text Only): {large_only}")
print(f" Failed: {fails}")
print("\nRecommendations:")
if fails > 0:
print(" - Increase contrast for failing elements to at least 4.5:1")
if large_only > 0:
print(" - Ensure text with lower contrast (3:1-4.5:1) is used only for large text (18pt+)")
print(" - Use WebAIM Contrast Checker for verification: https://webaim.org/resources/contrastchecker/")
print(" - Test with real users, including those with visual impairments")
if __name__ == "__main__":
main()