# SPDX-License-Identifier: MIT # colour — decide what a colour pair or a ray does, and calculate light. # # Two halves that share nothing but the subject. # # STRUCTURE. You supply two kinds of record as facts. # # Colour pairs (`colour.Pair`), by the relative luminance of the text and of # its background, each from 0.0 (black) to 1.0 (white). The rules conclude # whether the pair clears each of the four contrast levels of the Web Content # Accessibility Guidelines: AA (4.5 to 1), AAA (7 to 1), and the levels for # large text, AA (3 to 1) and AAA (4.5 to 1). A pair that fails a level is # marked as failing it and kept; nothing is dropped: # # import colour # colour.pair("grey", colour.luminance_hex("#777777"), 1.0) # colour.pair("ink", colour.luminance_hex("#000000"), 1.0) # print(colour.levels("grey"), colour.levels("ink")) # n = why_not(colour.Pair.grey.aa) # # Rays (`Ray`), by the refractive index of the medium the ray leaves, the # index of the medium it meets, and the sine of its angle of incidence. The # rules conclude whether it is totally reflected: # # colour.ray("glass_air", 1.52, 1.0, 0.8) # print(colour.total("glass_air")) # # Each conclusion answers `why`, and `why_not` names the premise that failed. # A corrected fact withdraws what rested on it: a luminance rewritten moves the # pair across a level. Where two sources disagree about a fact, both values # are reasoned about, no query refuses, and `unsettled()` names what they # disagree about. # # CALCULATION. Value scripts that return a value and write nothing: sRGB # linear light and relative luminance, a colour given as `#rrggbb`, the # contrast ratio, hue, saturation and lightness, Snell's law, the critical # angle, the thin lens, Wien's law and the frequency of a wavelength. # # What the scripts do not cover, said here once: colour spaces other than # sRGB, the saturation of the HSV model (the one computed is HSL's), spectra, # the sign conventions of a diverging lens, and the wavelength dependence of a # refractive index. A critical angle exists only for light going into a rarer # medium (the second index no larger than the first); into a denser medium # `critical_angle` refuses, as `math.asin` does for a sine above 1. A lens with # the object at the focus has no image distance, the image being at infinity, # and `lens_image` refuses with a division by zero. # # Sources, fetched 2026-11-08: # - sRGB was proposed by HP and Microsoft in 1996 and became IEC # 61966-2-1:1999. Its transfer function uses 0.04045, 12.92, 0.055, 1.055 # and 2.4, and the Y values of its red, green and blue primaries are # 0.2126, 0.7152 and 0.0722 (https://en.wikipedia.org/wiki/SRGB). # - WCAG success criterion 1.4.3 (level AA) asks a contrast ratio of at # least 4.5:1, and 3:1 for large-scale text; criterion 1.4.6 (level AAA) # asks 7:1 and 4.5:1 for large-scale text, with the ratio # (L1 + 0.05) / (L2 + 0.05) for the lighter L1 and the darker L2 # (https://www.w3.org/TR/WCAG21/). # - Snell's law: the ratio of the sines of the angles of incidence and # refraction is the ratio of the refractive indices. The largest angle of # incidence that still refracts is the critical angle, and beyond it the # light is totally reflected (https://en.wikipedia.org/wiki/Snell%27s_law). # - At 589 nm the refractive index of water is 1.333, of window glass 1.52 # and of diamond 2.417 (https://en.wikipedia.org/wiki/Refractive_index). # - Wien's displacement constant is 2.897771955e-3 m K and the peak # wavelength is that over the temperature; the effective temperature of # the Sun is 5778 K, whose peak falls near 500 nm # (https://en.wikipedia.org/wiki/Wien%27s_displacement_law). # - The speed of light is 299,792,458 m/s, exactly (the SI definition of the # metre), which turns a wavelength into a frequency. # - The lens equation, 1/f = 1/u + 1/v, for a thin lens in air with the # real-is-positive convention. # # Floats are IEEE doubles. `math.radians` and `math.degrees` here round once, # where CPython 3.11 rounds twice, so an angle can differ from CPython's in # its last digit; sines and arcsines of the same radians agree. import math # ── Pairs and rays as facts ────────────────────────────────────────────────── class Pair: # A colour pair, by a name you choose: `fg` the relative luminance of the # text and `bg` of its background, 0.0 to 1.0. `ratio` is the contrast # ratio when the text is the lighter, `inverse` when the background is, so # the one that applies is at least 1. The rules conclude `aa` and `aaa` # (4.5 and 7, for ordinary text) and `large_aa` and `large_aaa` (3 and 4.5, # for large-scale text, which WCAG sets at 18 point or 14 point bold). A # pair that clears none of them has no value for it. fg: real bg: real ratio: real = (fg + 0.05) / (bg + 0.05) inverse: real = (bg + 0.05) / (fg + 0.05) aa: bool aaa: bool large_aa: bool large_aaa: bool @rule def aa_light_text(): for p in Pair: if p.fg > p.bg and p.ratio >= 4.5: p.aa = True @rule def aa_dark_text(): for p in Pair: if p.bg >= p.fg and p.inverse >= 4.5: p.aa = True @rule def aaa_light_text(): for p in Pair: if p.fg > p.bg and p.ratio >= 7.0: p.aaa = True @rule def aaa_dark_text(): for p in Pair: if p.bg >= p.fg and p.inverse >= 7.0: p.aaa = True @rule def large_aa_light_text(): for p in Pair: if p.fg > p.bg and p.ratio >= 3.0: p.large_aa = True @rule def large_aa_dark_text(): for p in Pair: if p.bg >= p.fg and p.inverse >= 3.0: p.large_aa = True @rule def large_aaa_light_text(): for p in Pair: if p.fg > p.bg and p.ratio >= 4.5: p.large_aaa = True @rule def large_aaa_dark_text(): for p in Pair: if p.bg >= p.fg and p.inverse >= 4.5: p.large_aaa = True class Ray: # A ray meeting a boundary, by a name you choose: `n1` the refractive # index of the medium it leaves, `n2` of the medium it meets, each 1 or # more, as every medium's is for visible light, and `sin_i` the sine of # its angle of incidence, 0.0 to 1.0. `sin_t` is the sine of # the refracted angle that Snell's law asks for, `n1 * sin_i / n2`. The # rule concludes `total` when that sine would exceed 1, which no angle # has: the ray is totally reflected. At exactly 1 the ray runs along the # boundary, the critical angle, and refracts. n1: real n2: real sin_i: real sin_t: real = n1 * sin_i / n2 total: bool @rule def total_internal_reflection(): for r in Ray: if r.n1 > r.n2 and r.sin_i <= 1.0 and r.sin_t > 1.0: r.total = True # ── Writing facts ──────────────────────────────────────────────────────────── def pair(name: string, text: real, background: real): # A colour pair by the relative luminance of its text and background. update(Pair(name).fg, text, "pairs") update(Pair(name).bg, background, "pairs") def ray(name: string, from_index: real, into_index: real, sine_of_incidence: real): # A ray from a medium of one refractive index into another. update(Ray(name).n1, from_index, "rays") update(Ray(name).n2, into_index, "rays") update(Ray(name).sin_i, sine_of_incidence, "rays") # ── Reading what the rules concluded ───────────────────────────────────────── # # Each reader reads every value in force, so a fact two sources disagree # about is reported with both values rather than refused. def holds(values: claim_list, name: string) -> bool: for v in values: parts = v.path.split(".") if parts[len(parts) - 2] == name and v.value == True: return True return False def levels(name: string) -> list: # The contrast levels a pair clears, from "AA", "AAA", "large AA" and # "large AAA", in that order; empty for a pair that clears none. out = [] a = list_values(Pair.aa) b = list_values(Pair.aaa) c = list_values(Pair.large_aa) d = list_values(Pair.large_aaa) if holds(a, name): out = out + ["AA"] if holds(b, name): out = out + ["AAA"] if holds(c, name): out = out + ["large AA"] if holds(d, name): out = out + ["large AAA"] return out def total(name: string) -> bool: # Whether the ray is totally reflected. vs = list_values(Ray.total) return holds(vs, name) def unsettled() -> list: # Each pair or ray that holds two different values for a fact you write, # as "pair x fg" and so on, sorted. out = [] for f in ["fg", "bg", "n1", "n2", "sin_i"]: if f == "fg": vs = list_values(Pair.fg) label = "pair " elif f == "bg": vs = list_values(Pair.bg) label = "pair " elif f == "n1": vs = list_values(Ray.n1) label = "ray " elif f == "n2": vs = list_values(Ray.n2) label = "ray " else: vs = list_values(Ray.sin_i) label = "ray " seen = {} for v in vs: parts = v.path.split(".") s = label + parts[len(parts) - 2] + " " + parts[len(parts) - 1] x = str(v.value) if s in seen and seen[s] != x and s not in out: out = out + [s] seen[s] = x return sorted(out) # ── Calculation: colour ────────────────────────────────────────────────────── def srgb_linear(c: int) -> float: # The linear light of an sRGB channel c, 0 to 255: c/255 divided by 12.92 # up to 0.04045, and ((c/255 + 0.055) / 1.055) to the 2.4 above it. s = c / 255.0 if s <= 0.04045: return s / 12.92 return ((s + 0.055) / 1.055) ** 2.4 def luminance(r: int, g: int, b: int) -> float: # The relative luminance of an sRGB colour, 0.0 for black to 1.0 for # white: 0.2126 R + 0.7152 G + 0.0722 B in linear light. return 0.2126 * srgb_linear(r) + 0.7152 * srgb_linear(g) + 0.0722 * srgb_linear(b) def luminance_hex(text: string) -> float: # The relative luminance of a colour written `#rrggbb`, in hexadecimal. # Anything else refuses. if len(text) != 7 or text[0:1] != "#": raise ValueError("a colour is written #rrggbb, six hexadecimal digits") return luminance(int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)) def contrast_ratio(l1: float, l2: float) -> float: # The contrast ratio of two relative luminances, the lighter plus 0.05 # over the darker plus 0.05: from 1.0 for equal ones to 21.0 for black and # white. if l1 >= l2: return (l1 + 0.05) / (l2 + 0.05) return (l2 + 0.05) / (l1 + 0.05) def contrast_hex(a: string, b: string) -> float: # The contrast ratio of two colours written `#rrggbb`. return contrast_ratio(luminance_hex(a), luminance_hex(b)) def hsl(r: int, g: int, b: int) -> list: # Hue in degrees (0.0 up to 360.0), saturation and lightness (0.0 to 1.0) # of an sRGB colour given as three channels, 0 to 255. A grey has hue 0.0 # and saturation 0.0. x = r / 255.0 y = g / 255.0 z = b / 255.0 hi = max(x, max(y, z)) lo = min(x, min(y, z)) light = (hi + lo) / 2.0 if hi == lo: return [0.0, 0.0, light] d = hi - lo if light > 0.5: sat = d / (2.0 - hi - lo) else: sat = d / (hi + lo) if hi == x: h = (y - z) / d if y < z: h = h + 6.0 elif hi == y: h = (z - x) / d + 2.0 else: h = (x - y) / d + 4.0 return [h * 60.0, sat, light] # ── Calculation: light ─────────────────────────────────────────────────────── def refraction_angle(n1: float, n2: float, incidence_deg: float) -> float: # The angle of the refracted ray in degrees, by Snell's law, for a ray at # incidence_deg from the normal going from index n1 into n2. It refuses # (`math.asin` of a sine above 1) where the ray is totally reflected. # The angle is taken modulo 360 degrees first, which math.fmod does # exactly, so a turn's worth of degrees loses no digits to radians. Where # n1 x sine / n2 is not a normal real, it is worked on the mantissas and # the powers of two apart, as math.frexp gives them. s = math.sin(math.radians(math.fmod(incidence_deg, 360.0))) t = n1 * s / n2 if not (math.isfinite(t) and abs(t) >= 2.2250738585072014e-308): a = math.frexp(n1) b = math.frexp(s) c = math.frexp(n2) t = math.ldexp(a[0] * b[0] / c[0], a[1] + b[1] - c[1]) return math.degrees(math.asin(t)) def critical_angle(n1: float, n2: float) -> float: # The critical angle in degrees for light going from index n1 into n2, # asin(n2 / n1). It exists only into a rarer medium, n2 no larger than n1; # otherwise it refuses. return math.degrees(math.asin(n2 / n1)) def lens_image(focal_length: float, object_distance: float) -> float: # The image distance of a thin lens, 1 / (1/f - 1/u), in the unit of the # two lengths, with a converging lens (f positive) and a real object # (u positive); a positive result is a real image. With the object at the # focus the image is at infinity and it refuses. return 1.0 / (1.0 / focal_length - 1.0 / object_distance) def lens_magnification(focal_length: float, object_distance: float) -> float: # The magnification of the same thin lens, -v/u; negative for an inverted # image. It refuses with the object at the focus. It is taken as # f / (f - u), which -v/u is, so no reciprocal can pass the range of a real # and no difference of two near ones loses its digits; of halves where # f - u passes the largest real. d = focal_length - object_distance if math.isinf(d) and math.isfinite(focal_length) and math.isfinite(object_distance): return (0.5 * focal_length) / (0.5 * focal_length - 0.5 * object_distance) return focal_length / d def wien_peak(temperature_k: float) -> float: # The wavelength in metres at which a black body at the temperature in # kelvin radiates most strongly per unit wavelength, 2.897771955e-3 / T. return 2.897771955e-3 / temperature_k def frequency_hz(wavelength_nm: float) -> float: # The frequency in hertz of light of the wavelength in nanometres in a # vacuum, 299792458 over the wavelength in metres. return 299792458.0 / (wavelength_nm * 1e-9)