Skip to content

Commit

Permalink
Merge pull request #416 from maykinmedia/fix/797-text-contrast
Browse files Browse the repository at this point in the history
[#797] Added text color contrast check to siteconfiguration admin
  • Loading branch information
alextreme authored Jan 13, 2023
2 parents 38cdd5c + 8102ac0 commit a5aa32b
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 50 deletions.
43 changes: 42 additions & 1 deletion src/open_inwoner/configurations/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.flatpages.admin import FlatPageAdmin
from django.contrib.flatpages.forms import FlatpageForm
from django.contrib.flatpages.models import FlatPage
Expand All @@ -9,6 +9,7 @@

from open_inwoner.ckeditor5.widgets import CKEditorWidget

from ..utils.colors import ACCESSIBLE_CONTRAST_RATIO, get_contrast_ratio
from .models import SiteConfiguration, SiteConfigurationPage


Expand Down Expand Up @@ -137,6 +138,46 @@ class SiteConfigurarionAdmin(OrderedInlineModelAdminMixin, SingletonModelAdmin):
)
inlines = [SiteConfigurationPageInline]

def report_contrast_ratio(self, request, obj):
def check_contrast_ratio(label1, color1, label2, color2, expected_ratio):
ratio = get_contrast_ratio(color1, color2)
if ratio < expected_ratio:
message = "'{label1}' ({color1}) en '{label2}' ({color2}) hebben niet genoeg contrast: {ratio}:1 waar {expected}:1 wordt verwacht.".format(
label1=label1,
color1=color1,
label2=label2,
color2=color2,
ratio=round(ratio, 1),
expected=expected_ratio,
)
self.message_user(request, message, messages.WARNING)

check_contrast_ratio(
_("Primary color"),
obj.primary_color,
_("Primary font color"),
obj.primary_font_color,
ACCESSIBLE_CONTRAST_RATIO,
)
check_contrast_ratio(
_("Secondary color"),
obj.secondary_color,
_("Secondary font color"),
obj.secondary_font_color,
ACCESSIBLE_CONTRAST_RATIO,
)
check_contrast_ratio(
_("Accent color"),
obj.accent_color,
_("Accent font color"),
obj.accent_font_color,
ACCESSIBLE_CONTRAST_RATIO,
)

def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
self.report_contrast_ratio(request, obj)


class FlatPageAdminForm(FlatpageForm):
class Meta:
Expand Down
53 changes: 4 additions & 49 deletions src/open_inwoner/configurations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from open_inwoner.pdc.utils import PRODUCT_PATH_NAME

from ..utils.colors import hex_to_hsl
from .choices import ColorTypeChoices


Expand Down Expand Up @@ -381,15 +382,15 @@ def clean(self):

@property
def get_primary_color(self):
return self.hex_to_hsl(self.primary_color)
return hex_to_hsl(self.primary_color)

@property
def get_secondary_color(self):
return self.hex_to_hsl(self.secondary_color)
return hex_to_hsl(self.secondary_color)

@property
def get_accent_color(self):
return self.hex_to_hsl(self.accent_color)
return hex_to_hsl(self.accent_color)

@property
def get_ordered_flatpages(self):
Expand All @@ -403,52 +404,6 @@ def google_enabled(self):
def matomo_enabled(self):
return self.matomo_url and self.matomo_site_id

def hex_to_hsl(self, color):
# Convert hex to RGB first
r = 0
g = 0
b = 0
if len(color) == 4:
r = "0x" + color[1] + color[1]
g = "0x" + color[2] + color[2]
b = "0x" + color[3] + color[3]
elif len(color) == 7:
r = "0x" + color[1] + color[2]
g = "0x" + color[3] + color[4]
b = "0x" + color[5] + color[6]

# Then to HSL
r = int(r, 16) / 255
g = int(g, 16) / 255
b = int(b, 16) / 255
cmin = min(r, g, b)
cmax = max(r, g, b)
delta = cmax - cmin
h = 0
s = 0
l = 0

if delta == 0:
h = 0
elif cmax == r:
h = ((g - b) / delta) % 6
elif cmax == g:
h = (b - r) / delta + 2
else:
h = (r - g) / delta + 4

h = round(h * 60)

if h < 0:
h += 360

l = (cmax + cmin) / 2
s = 0 if delta == 0 else delta / (1 - abs(2 * l - 1))
s = int((s * 100))
l = int((l * 100))

return h, s, l

def get_help_text(self, request):
current_path = request.get_full_path()

Expand Down
44 changes: 44 additions & 0 deletions src/open_inwoner/configurations/tests/test_colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django.urls import reverse
from django.utils.translation import gettext as _

from django_webtest import WebTest

from open_inwoner.accounts.tests.factories import UserFactory


class TestConfigurationColors(WebTest):
def setUp(self):
super().setUp()
self.user = UserFactory(is_superuser=True, is_staff=True)

def test_contrast_is_checked(self):
response = self.app.get(
# reverse list because django-solo
reverse("admin:configurations_siteconfiguration_changelist"),
user=self.user,
)
form = response.forms["siteconfiguration_form"]
form["name"] = "xyz"
form["primary_color"] = "#FFFFFF"
form["primary_font_color"] = "#FFFFFF"
form["secondary_color"] = "#FFFFFF"
form["secondary_font_color"] = "#FFFFFF"
form["accent_color"] = "#FFFFFF"
form["accent_font_color"] = "#FFFFFF"
response = form.submit("_continue").follow()

messages = list(response.context["messages"])

self.assertEqual(len(messages), 3 + 1)

msg = messages[0]
self.assertEqual(msg.level_tag, "warning")
self.assertIn(str(_("Primary color")), msg.message)

msg = messages[1]
self.assertEqual(msg.level_tag, "warning")
self.assertIn(str(_("Secondary color")), msg.message)

msg = messages[2]
self.assertEqual(msg.level_tag, "warning")
self.assertIn(str(_("Accent color")), msg.message)
84 changes: 84 additions & 0 deletions src/open_inwoner/utils/colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import re

ACCESSIBLE_CONTRAST_RATIO = 4.5


def hex_to_hsl(color):
# Convert hex to RGB first
r = 0
g = 0
b = 0
if len(color) == 4:
r = "0x" + color[1] + color[1]
g = "0x" + color[2] + color[2]
b = "0x" + color[3] + color[3]
elif len(color) == 7:
r = "0x" + color[1] + color[2]
g = "0x" + color[3] + color[4]
b = "0x" + color[5] + color[6]

# Then to HSL
r = int(r, 16) / 255
g = int(g, 16) / 255
b = int(b, 16) / 255
cmin = min(r, g, b)
cmax = max(r, g, b)
delta = cmax - cmin
h = 0
s = 0
l = 0

if delta == 0:
h = 0
elif cmax == r:
h = ((g - b) / delta) % 6
elif cmax == g:
h = (b - r) / delta + 2
else:
h = (r - g) / delta + 4

h = round(h * 60)

if h < 0:
h += 360

l = (cmax + cmin) / 2
s = 0 if delta == 0 else delta / (1 - abs(2 * l - 1))
s = int((s * 100))
l = int((l * 100))

return h, s, l


def hex_to_luminance(hex_color):
"""
https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-procedure
"""
color = re.sub(r"^0x|^#", "", hex_color)
assert len(color) == 6

def calc(c):
if c <= 0.03928:
return c / 12.92
else:
return pow((c + 0.055) / 1.055, 2.4)

hex_red = int(color[0:2], base=16) / 255
hex_green = int(color[2:4], base=16) / 255
hex_blue = int(color[4:6], base=16) / 255

return 0.2126 * calc(hex_red) + 0.7152 * calc(hex_green) + 0.0722 * calc(hex_blue)


def get_contrast_ratio(hex_1, hex_2):
"""
https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-procedure
"""
L1 = hex_to_luminance(hex_1)
L2 = hex_to_luminance(hex_2)

# L1 should be the more luminant
if L1 < L2:
L1, L2 = L2, L1

return (L1 + 0.05) / (L2 + 0.05)
49 changes: 49 additions & 0 deletions src/open_inwoner/utils/tests/test_colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from django.test import TestCase

from open_inwoner.utils.colors import get_contrast_ratio, hex_to_hsl, hex_to_luminance


class ColorUtilsTestCase(TestCase):
def test_hex_to_hsl(self):
tests = [
("#000000", (0, 0, 0)),
("#ffffff", (0, 0, 100)),
("#ff0000", (0, 100, 50)),
("#00ff00", (120, 100, 50)),
("#0000ff", (240, 100, 50)),
("#CCCCFF", (240, 100, 90)),
]

for i, (hex, expected) in enumerate(tests):
with self.subTest(i=i, hex=hex, expected=expected):
actual = hex_to_hsl(hex)
self.assertEqual(actual, expected)

def test_hex_to_luminance(self):
tests = [
("#000000", 0),
("#ffffff", 1),
("#ff0000", 0.2126),
("#00ff00", 0.7152),
("#0000ff", 0.0722),
("#CCCCFF", 0.6324),
]

for i, (hex, expected) in enumerate(tests):
with self.subTest(i=i, hex=hex, expected=expected):
actual = hex_to_luminance(hex)
self.assertAlmostEqual(actual, expected, places=3)

def test_get_contrast_ratio(self):
tests = [
("#000000", "#ffffff", 21.0),
("#ffffff", "#000000", 21.0),
("#ffffff", "#ffffff", 1.0),
("#ffffff", "#ffff00", 1.074),
("#ffffff", "#0000ff", 8.592),
]

for i, (fore_hex, back_hex, expected) in enumerate(tests):
with self.subTest(i=i, hex=hex, expected=expected):
actual = get_contrast_ratio(fore_hex, back_hex)
self.assertAlmostEqual(actual, expected, places=3)

0 comments on commit a5aa32b

Please sign in to comment.