Wiktionary:Palette/numbered

From Wiktionary, the free dictionary
Jump to navigation Jump to search

The process for generating the numbered colors in Wiktionary:Palette is outlined on this page.

Algorithm

The basic process is outlined as follows:

  1. Start from a set of base colors
  2. Adjust each color to meet contrast requirements

This results in a color set of 10*N colors for N base colors, with each variant of a base color identified by an index 0 through 9. 0 is the closest to the background color and 9 the furthest from the background color.

Base colors

Color name Hex value Example
red #e63949
scarlet #e0562d
orange #d9760f
amber #cf9211
yellow #baa72f
lime #97b53c
green #60b334
teal #32b378
cyan #28a0b5
blue #3271f0
indigo #584ee6
purple #7f38e8
magenta #9d2cdb
rose #a6448c
grey #666666
brown #856142

Variants

Variants are derived from the base color by using a value called an offset

If not otherwise specified, colors are expressed as where each value .

To generate a variant, we use an operation (adjust) below, which takes the base color and the offset :

The operation (lighten) lightens a color by a certain factor :

The operation (deepen) deepens a color by a certain factor . It is implemented by converting the color from RGB to HSV (with ; is not altered and thus its range is insignificant), applying the operation to the HSV values and the factor , and then converting back to RGB, where:

and

Contrast requirements

The contrast requirements apply with the variant used as the background color and the default text color, either black or white, as the foreground color.

In light mode, the text color is the default black text color, #202122. In dark mode, it is the default white text color, #eaecf0.

Both of these requirements apply, and whichever is stricter will take priority. In light mode, the contrast is against the black foreground color, and in night mode, against the white foreground color:

Index WCAG APCA Notes
0 15.00 +100 (light), −94.5 dark)
1 13.25 +95 (light), −93 (dark)
2 11.50 ±90 APCA Bronze Simple: body text preferred contrast is ≥+90 or ≤−90
3 9.25 ±85
4 7.50 ±75 WCAG: AAA for body text. APCA Bronze Simple: body text minimum contrast is ≥+75 or ≤−75
5 6.00 ±67.5
6 4.50 ±60 WCAG: AA for body text. APCA Bronze Simple: minimum requirement for content text is ≥+60 or ≤−60
7 3.00 ±52.5
8 2.10 ±45
9 1.40 ±30 APCA Bronze Simple: absolute minimum requirement for any kind of text is ≥+30 or ≤−30

Note that the requirements regarding APCA contrast for indices 0 and 1 in dark mode are different from those in light mode, since the contrast of the default foreground against the default background in the default dark color scheme is only approximately −94.7.

The color variants, and therefore offsets, that are tried are all those that result in distinct colors when expressed as 6-digit hexadecimal colors. Then, in light mode, we pick the minimum offset for which the quantized color (as if converted to 6-digit hex and back) meets both contrast requirements, and in dark mode, we pick the maximum offset for which the quantized color meets both contrast requirements. This ensures that the color is dark as possible in light mode, and as light as possible in dark mode.

Implementation

The below is a Python reference implementation of this algorithm.

import colorsys
import re


IDENTIFICATION = "[[Wiktionary:Palette/numbered]]"
HEX_COLOR_RGB = re.compile(r"^#[0-9A-Fa-f]{6}$")


def _hex_to_rgb_comp(x):
    return int(x, 16) / 255.0


def hex_to_rgb(hex_color):
    """
    Converts a color in hex format into the internal format.
    (R, G, B, with each value lying within [0, 1]).
    """

    if not HEX_COLOR_RGB.match(hex_color):
        raise ValueError("hex color format not supported")
    r, g, b = hex_color[1:3], hex_color[3:5], hex_color[5:7]
    return (_hex_to_rgb_comp(r), _hex_to_rgb_comp(g), _hex_to_rgb_comp(b))


def rgb_to_hex(c):
    """Converts a color into hex format."""

    r, g, b = c
    r = int(round(r * 255.0))
    g = int(round(g * 255.0))
    b = int(round(b * 255.0))
    return "#{:02x}{:02x}{:02x}".format(r, g, b)


def quantize(color):
    """Quantizes a color so that it corresponds exactly to a 6-digit hex color."""

    r, g, b = color
    r = int(round(r * 255)) / 255
    g = int(round(g * 255)) / 255
    b = int(round(b * 255)) / 255
    return r, g, b


def _wcag_to_srgb(x):
    if x <= 0.03928:
        return x / 12.2
    else:
        return ((x + 0.055) / 1.055) ** 2.4


def wcag_luminance(color):
    """Computes the sRGB luminance of a color according to WCAG."""
    rs, gs, bs = color
    r = _wcag_to_srgb(rs)
    g = _wcag_to_srgb(gs)
    b = _wcag_to_srgb(bs)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def wcag_contrast(fgcolor, bgcolor):
    """Computes the WCAG contrast ratio between two colors."""
    fgluma = wcag_luminance(fgcolor)
    bgluma = wcag_luminance(bgcolor)
    ratio = (fgluma + 0.05) / (bgluma + 0.05)
    if ratio < 1:
        ratio = 1.0 / ratio
    return ratio


def _simple_srgb_luma(color):
    # <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
    # <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>
    
    r, g, b = color
    s_rco, s_gco, s_bco = 0.2126729, 0.7151522, 0.0721750
    main_trc = 2.4
    return clamp(0, s_rco * (r ** main_trc) + s_gco * (g ** main_trc) + s_bco * (b ** main_trc), 1)


def _apca_f_sc(y_c):
    # <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
    # <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>
    if y_c <= 0:
        return 0
    elif y_c <= 0.022:
        return y_c + (0.022 - y_c) ** 1.414
    else:
        return y_c


def apca_contrast(fgcolor, bgcolor):
    """Computes the APCA contrast value between a foreground and background color."""
    # <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
    # <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>

    ys_fg = _simple_srgb_luma(fgcolor)
    ys_bg = _simple_srgb_luma(bgcolor)

    y_fg = _apca_f_sc(ys_fg)
    y_bg = _apca_f_sc(ys_bg)

    if y_bg > y_fg:
        s_apc = (y_bg ** 0.56 - y_fg ** 0.57) * 1.14
    else:
        s_apc = (y_bg ** 0.65 - y_fg ** 0.62) * 1.14

    if abs(s_apc) < 0.1:
        l_c = 0
    elif s_apc > 0:
        l_c = (s_apc - 0.027) * 100
    else:
        l_c = (s_apc + 0.027) * 100

    return l_c


def clamp(minimum, x, maximum):
    """Forces the value x between the endpoints minimum and maximum."""
    assert minimum <= maximum
    return max(minimum, min(maximum, x))


def _lighten_comp(component, power):
    if not 0 <= power <= 1:
        raise ValueError("power must be within [0, 1]")
    return power + component * (1 - power)


def lighten(color, power):
    """Lightens a color by a certain power [0, 1]."""
    if not 0 <= power <= 1:
        raise ValueError("power must be within [0, 1]")
    r, g, b = color
    return (_lighten_comp(r, power), _lighten_comp(g, power), _lighten_comp(b, power))


def deepen(color, power):
    """Deepens a color by a certain power [0, 1]."""
    if not 0 <= power <= 1:
        raise ValueError("power must be within [0, 1]")
    r, g, b = color
    h, s, v = colorsys.rgb_to_hsv(r, g, b)
    s_new = (1 - (1 - s) * (1 - power)) * (s if s < 0.5 else 1)
    v_new = v * (1 - power)
    return colorsys.hsv_to_rgb(h, s_new, v_new)


def adjust(color, offset):
    """
    Adjusts a color according to the offset. 
    If the offset is positive, the color is lightened.
    If the offset is negative, the color is deepened.
    """
    if offset > 0:
        return lighten(color, offset)
    elif offset < 0:
        return deepen(color, -offset)
    return color


class ContrastedColorMaker:
    """
    Generates variants of the base color that meets contrast requirements
    as a background color against the specified reference foreground color.
    """

    def __init__(self, base_color, reference, night_mode):
        self._night_mode = night_mode
        # generate all possible colors
        div = 1024
        options = {}
        self._base_color = base_color
        self._colors = {}
        self._wcag = []
        self._apca = []
        for i in range(-div, div + 1):
            offset = clamp(-1, i / div, 1)
            color = quantize(adjust(base_color, offset))
            if color not in options:
                options[color] = offset
                self._colors[offset] = color
                self._wcag.append((wcag_contrast(reference, color), offset))
                self._apca.append((abs(apca_contrast(reference, color)), offset))
        self._wcag.sort()
        self._apca.sort()

    def for_contrast(self, contrast_wcag, contrast_apca):
        """
        Returns a variant of the color that meets the WCAG and APCA
        requirements (the latter is optional). If no such color exists,
        returns None.
        """

        wcag_meets = set(offset for contrast, offset in self._wcag if contrast >= contrast_wcag)
        if contrast_apca is None:
            meets = wcag_meets
        else:
            apca_meets = set(offset for contrast, offset in self._apca if contrast >= contrast_apca)
            meets = wcag_meets & apca_meets

        if meets:
            return self._colors[(max if self._night_mode else min)(meets)]

        return None


BASE = {
    "red":         hex_to_rgb("#e63949"),
    "scarlet":     hex_to_rgb("#e0562d"),
    "orange":      hex_to_rgb("#d9760f"),
    "amber":       hex_to_rgb("#cf9211"),
    "yellow":      hex_to_rgb("#baa72f"),
    "lime":        hex_to_rgb("#97b53c"),
    "green":       hex_to_rgb("#60b334"),
    "teal":        hex_to_rgb("#32b378"),
    "cyan":        hex_to_rgb("#28a0b5"),
    "blue":        hex_to_rgb("#3271f0"),
    "indigo":      hex_to_rgb("#584ee6"),
    "purple":      hex_to_rgb("#7f38e8"),
    "magenta":     hex_to_rgb("#9d2cdb"),
    "rose":        hex_to_rgb("#a6448c"),
    "grey":        hex_to_rgb("#666666"),
    "brown":       hex_to_rgb("#856142"),
}
WHITE_BG = "#ffffff"
BLACK_FG = "#202122"
WHITE_FG = "#eaecf0"
BLACK_BG = "#101418"


white = hex_to_rgb(WHITE_FG)
black = hex_to_rgb(BLACK_FG)
CONTRASTS = {
    0: (15.00, 100, 94.5),
    1: (13.25, 95, 93),
    2: (11.50, 90, 90),
    3: ( 9.25, 85, 85),
    4: ( 7.50, 75, 75),
    5: ( 6.00, 67.5, 67.5),
    6: ( 4.50, 60, 60),
    7: ( 3.00, 52.5, 52.5),
    8: ( 2.10, 45, 45),
    9: ( 1.40, 30, 30),
}


colors_light = {}
colors_night = {}


for base_color_name, base_color in BASE.items():
    tints_light = colors_light[base_color_name] = {}
    tints_night = colors_night[base_color_name] = {}

    contrasted_color_maker_light = ContrastedColorMaker(base_color, black, False)
    contrasted_color_maker_night = ContrastedColorMaker(base_color, white, True)

    for tint_number, (contrast_wcag, contrast_apca, contrast_apca_night) in CONTRASTS.items():
        tint_light = contrasted_color_maker_light.for_contrast(contrast_wcag, contrast_apca)
        if tint_light is None:
            raise ValueError(f"light mode contrast requirement cannot be met (WCAG: {contrast_wcag}, APCA: {contrast_apca}, base color: {rgb_to_hex(base_color)}, foreground color: {rgb_to_hex(black)})")
        
        tint_night = contrasted_color_maker_night.for_contrast(contrast_wcag, contrast_apca_night)
        if tint_night is None:
            raise ValueError(f"night mode contrast requirement cannot be met (WCAG: {contrast_wcag}, APCA: {-contrast_apca_night}, base color: {rgb_to_hex(base_color)}, foreground color: {rgb_to_hex(white)})")

        tints_light[tint_number] = tint_light
        tints_night[tint_number] = tint_night


def make_swatch_bg(colors, fg_color):
    lines = []
    for base_color_name, tints in colors.items():
        if lines:
            lines.append('|-')
        else:
            lines.append('{|')
        for tint_number, color in tints.items():
            name = f"{base_color_name}-{tint_number}"
            lines.append(f'| style="background:{rgb_to_hex(color)}" | <div style="color:{fg_color};padding:15px 5px">{name}</div>')
    lines.append("|}")
    return "\n".join(lines)


def make_swatch_fg(colors, bg_color):
    lines = []
    for base_color_name, tints in colors.items():
        if lines:
            lines.append('|-')
        else:
            lines.append("{| " + f'style="background:{bg_color}"')
        for tint_number, color in tints.items():
            name = f"{base_color_name}-{tint_number}"
            lines.append(f'| <div style="color:{rgb_to_hex(color)};padding:5px">{name}</div>')
    lines.append("|}")
    return "\n".join(lines)


def make_unified_swatch_bg(colors):
    lines = []
    for base_color_name, tints in colors.items():
        if lines:
            lines.append('|-')
        else:
            lines.append("{| " + f'class="wikt-auto-contrast-exempt" style="color:var(--color-base)"')
        for tint_number, color in tints.items():
            name = f"{base_color_name}-{tint_number}"
            lines.append(f'| style="background-color:var(--wikt-palette-{name},{rgb_to_hex(color)})" | <div style="padding:15px 5px">{name}</div>')
    lines.append("|}")
    return "\n".join(lines)


def make_unified_swatch_fg(colors):
    lines = []
    for base_color_name, tints in colors.items():
        if lines:
            lines.append('|-')
        else:
            lines.append("{| " + f'class="wikt-auto-contrast-exempt" style="background:var(--background-color-base)"')
        for tint_number, color in tints.items():
            name = f"{base_color_name}-{tint_number}"
            lines.append(f'| <div style="color:var(--wikt-palette-{name},{rgb_to_hex(color)});padding:5px">{name}</div>')
    lines.append("|}")
    return "\n".join(lines)


def make_palette(colors_light, colors_night):
    def wrap_frame(prefix, indent, suffix):
        def _wrap(lines):
            return prefix + "\n" + "\n".join(indent + line for line in lines) + "\n" + suffix
        return _wrap

    def gather_colors(colors):
        rules = ["/* Autogenerated with " + IDENTIFICATION + " */"]
        for base_color_name, tints in colors.items():
            for tint_number, color in tints.items():
                name = f"{base_color_name}-{tint_number}"
                rules.append(f"--wikt-palette-{name}: {rgb_to_hex(color)};")
        return rules

    light_block = wrap_frame(":root, .skin-invert, .notheme {", "\t", "}")
    night_block_1 = wrap_frame("@media screen {\n\thtml.skin-theme-clientpref-night {", "\t\t", "\t}\n}")
    night_block_2 = wrap_frame("@media screen and (prefers-color-scheme: dark) {\n\thtml.skin-theme-clientpref-os {".strip(), "\t\t", "\t}\n}")

    rules_light = gather_colors(colors_light)
    rules_night = gather_colors(colors_night)

    return light_block(rules_light) + "\n\n/* Styles need to be duplicated exactly between these two selectors. */\n" + night_block_1(rules_night) + "\n" + night_block_2(rules_night)


def print_swatches():
    print("==Swatches==")
    print("")

    print("===Light mode===")
    print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
    print(make_swatch_bg(colors_light, BLACK_FG))
    print("")
    print(make_swatch_fg(colors_light, WHITE_BG))
    print("")

    print("===Night mode===")
    print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
    print(make_swatch_bg(colors_night, WHITE_FG))
    print("")
    print(make_swatch_fg(colors_night, BLACK_BG))


def print_unified_swatch():
    print("==Swatch==")
    print("Automatically adjusts for light/dark mode.")
    print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
    print(make_unified_swatch_bg(colors_light))
    print("")
    print(make_unified_swatch_fg(colors_light))


def print_palette():
    print(make_palette(colors_light, colors_night))


if __name__ == "__main__":
    #print_swatches()
    print_unified_swatch()
    #print_palette()

Swatch

Automatically adjusts for light/dark mode.

red-0
red-1
red-2
red-3
red-4
red-5
red-6
red-7
red-8
red-9
scarlet-0
scarlet-1
scarlet-2
scarlet-3
scarlet-4
scarlet-5
scarlet-6
scarlet-7
scarlet-8
scarlet-9
orange-0
orange-1
orange-2
orange-3
orange-4
orange-5
orange-6
orange-7
orange-8
orange-9
amber-0
amber-1
amber-2
amber-3
amber-4
amber-5
amber-6
amber-7
amber-8
amber-9
yellow-0
yellow-1
yellow-2
yellow-3
yellow-4
yellow-5
yellow-6
yellow-7
yellow-8
yellow-9
lime-0
lime-1
lime-2
lime-3
lime-4
lime-5
lime-6
lime-7
lime-8
lime-9
green-0
green-1
green-2
green-3
green-4
green-5
green-6
green-7
green-8
green-9
teal-0
teal-1
teal-2
teal-3
teal-4
teal-5
teal-6
teal-7
teal-8
teal-9
cyan-0
cyan-1
cyan-2
cyan-3
cyan-4
cyan-5
cyan-6
cyan-7
cyan-8
cyan-9
blue-0
blue-1
blue-2
blue-3
blue-4
blue-5
blue-6
blue-7
blue-8
blue-9
indigo-0
indigo-1
indigo-2
indigo-3
indigo-4
indigo-5
indigo-6
indigo-7
indigo-8
indigo-9
purple-0
purple-1
purple-2
purple-3
purple-4
purple-5
purple-6
purple-7
purple-8
purple-9
magenta-0
magenta-1
magenta-2
magenta-3
magenta-4
magenta-5
magenta-6
magenta-7
magenta-8
magenta-9
rose-0
rose-1
rose-2
rose-3
rose-4
rose-5
rose-6
rose-7
rose-8
rose-9
grey-0
grey-1
grey-2
grey-3
grey-4
grey-5
grey-6
grey-7
grey-8
grey-9
brown-0
brown-1
brown-2
brown-3
brown-4
brown-5
brown-6
brown-7
brown-8
brown-9
red-0
red-1
red-2
red-3
red-4
red-5
red-6
red-7
red-8
red-9
scarlet-0
scarlet-1
scarlet-2
scarlet-3
scarlet-4
scarlet-5
scarlet-6
scarlet-7
scarlet-8
scarlet-9
orange-0
orange-1
orange-2
orange-3
orange-4
orange-5
orange-6
orange-7
orange-8
orange-9
amber-0
amber-1
amber-2
amber-3
amber-4
amber-5
amber-6
amber-7
amber-8
amber-9
yellow-0
yellow-1
yellow-2
yellow-3
yellow-4
yellow-5
yellow-6
yellow-7
yellow-8
yellow-9
lime-0
lime-1
lime-2
lime-3
lime-4
lime-5
lime-6
lime-7
lime-8
lime-9
green-0
green-1
green-2
green-3
green-4
green-5
green-6
green-7
green-8
green-9
teal-0
teal-1
teal-2
teal-3
teal-4
teal-5
teal-6
teal-7
teal-8
teal-9
cyan-0
cyan-1
cyan-2
cyan-3
cyan-4
cyan-5
cyan-6
cyan-7
cyan-8
cyan-9
blue-0
blue-1
blue-2
blue-3
blue-4
blue-5
blue-6
blue-7
blue-8
blue-9
indigo-0
indigo-1
indigo-2
indigo-3
indigo-4
indigo-5
indigo-6
indigo-7
indigo-8
indigo-9
purple-0
purple-1
purple-2
purple-3
purple-4
purple-5
purple-6
purple-7
purple-8
purple-9
magenta-0
magenta-1
magenta-2
magenta-3
magenta-4
magenta-5
magenta-6
magenta-7
magenta-8
magenta-9
rose-0
rose-1
rose-2
rose-3
rose-4
rose-5
rose-6
rose-7
rose-8
rose-9
grey-0
grey-1
grey-2
grey-3
grey-4
grey-5
grey-6
grey-7
grey-8
grey-9
brown-0
brown-1
brown-2
brown-3
brown-4
brown-5
brown-6
brown-7
brown-8
brown-9