hexrgba 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hexrgba/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Parse, validate and normalize CSS hex colors and named colors, with exact alpha,
2
+ CSS serialization, WCAG contrast and alpha blending."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ._alpha import alpha_to_byte, css_alpha
7
+ from ._blend import blend
8
+ from ._color import Color, is_color, parse_color
9
+ from ._errors import ChannelError, ColorError, ColorNameError, HexColorError
10
+ from ._hex import is_hex, normalize, parse, shorten, to_hex
11
+ from ._names import NAMED_COLORS
12
+ from ._wcag import contrast_ratio, passes_contrast, relative_luminance
13
+
14
+ __version__ = "0.2.1"
15
+
16
+ __all__ = [
17
+ "NAMED_COLORS",
18
+ "ChannelError",
19
+ "Color",
20
+ "ColorError",
21
+ "ColorNameError",
22
+ "HexColorError",
23
+ "alpha_to_byte",
24
+ "blend",
25
+ "contrast_ratio",
26
+ "css_alpha",
27
+ "is_color",
28
+ "is_hex",
29
+ "normalize",
30
+ "parse",
31
+ "parse_color",
32
+ "passes_contrast",
33
+ "relative_luminance",
34
+ "shorten",
35
+ "to_hex",
36
+ ]
hexrgba/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m hexrgba``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
hexrgba/_alpha.py ADDED
@@ -0,0 +1,56 @@
1
+ """Exact conversions between an 8-bit alpha byte and the 0..1 alpha of CSS.
2
+
3
+ The serialization follows CSS Color Module Level 4, section 16.1.1 "Serializing legacy
4
+ alpha values" (https://www.w3.org/TR/css-color-4/#serializing-alpha-values).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from fractions import Fraction
11
+
12
+ from ._errors import ChannelError, shown
13
+ from ._hex import check_channel
14
+
15
+
16
+ def alpha_to_byte(alpha: float) -> int:
17
+ """Convert a 0..1 alpha into an alpha byte from 0 to 255.
18
+
19
+ The value is multiplied by 255 and rounded to the nearest integer, halves rounding
20
+ up. A float is read as its shortest decimal form (``repr``), the number you typed, so
21
+ ``0.3`` gives 77 (76.5 rounded up) rather than 76 from the binary value just below
22
+ 0.3. Both ``a / 255`` and the string from :func:`css_alpha` convert back to the byte
23
+ ``a`` they came from, for every ``a`` from 0 to 255.
24
+
25
+ Raises ChannelError for booleans, non-numbers, NaN and values outside 0..1.
26
+ """
27
+ if isinstance(alpha, bool) or not isinstance(alpha, (int, float)):
28
+ raise ChannelError(f"alpha must be a number from 0 to 1, got {shown(alpha)}")
29
+ if not 0 <= alpha <= 1:
30
+ raise ChannelError(f"alpha must be from 0 to 1, got {shown(alpha)}")
31
+ exact = Fraction(alpha) if isinstance(alpha, int) else Fraction(repr(float(alpha)))
32
+ return math.floor(exact * 255 + Fraction(1, 2))
33
+
34
+
35
+ def css_alpha(byte: int) -> str:
36
+ """Serialize an alpha byte as the CSS legacy alpha number, for example "0.5".
37
+
38
+ If a whole percentage ``n`` (0 to 100) satisfies ``round(n * 2.55) == byte`` the
39
+ result is ``n / 100``, so 237 gives "0.93". Otherwise it is ``byte / 0.255`` rounded
40
+ to an integer and divided by 1000, so 236 gives "0.925". Halves round up, there is
41
+ always a leading zero and trailing zeros are dropped; 255 gives "1" and 0 gives "0".
42
+ """
43
+ check_channel(byte, "alpha")
44
+ for percent in range(101):
45
+ if (percent * 255 + 50) // 100 == byte:
46
+ return _decimal(percent, 100)
47
+ return _decimal((byte * 2000 + 255) // 510, 1000)
48
+
49
+
50
+ def _decimal(numerator: int, denominator: int) -> str:
51
+ """Render numerator / denominator (denominator 100 or 1000) without trailing zeros."""
52
+ whole, rest = divmod(numerator, denominator)
53
+ if rest == 0:
54
+ return str(whole)
55
+ width = len(str(denominator)) - 1
56
+ return f"{whole}.{rest:0{width}d}".rstrip("0")
hexrgba/_blend.py ADDED
@@ -0,0 +1,62 @@
1
+ """Alpha compositing of one color over another.
2
+
3
+ Implements simple alpha compositing (source-over) from Compositing and Blending Level 1,
4
+ section 5.1 (https://www.w3.org/TR/compositing-1/#simplealphacompositing):
5
+
6
+ co = Cs x as + Cb x ab x (1 - as) ao = as + ab x (1 - as) Co = co / ao
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from fractions import Fraction
13
+
14
+ from ._color import Color, parse_color
15
+
16
+ _HALF = Fraction(1, 2)
17
+
18
+
19
+ def to_color(value: Color | str) -> Color:
20
+ """Return value unchanged if it is a Color, else parse it leniently."""
21
+ return value if isinstance(value, Color) else parse_color(value)
22
+
23
+
24
+ def composite(
25
+ source: tuple[Fraction, Fraction, Fraction],
26
+ source_alpha: Fraction,
27
+ backdrop: tuple[Fraction, Fraction, Fraction],
28
+ backdrop_alpha: Fraction,
29
+ ) -> tuple[tuple[Fraction, Fraction, Fraction], Fraction]:
30
+ """Composite exactly on 0..1 values, returning the un-premultiplied color and alpha."""
31
+ out_alpha = source_alpha + backdrop_alpha * (1 - source_alpha)
32
+ if out_alpha == 0:
33
+ zero = Fraction(0)
34
+ return (zero, zero, zero), zero
35
+ channels = tuple(
36
+ (cs * source_alpha + cb * backdrop_alpha * (1 - source_alpha)) / out_alpha
37
+ for cs, cb in zip(source, backdrop, strict=True)
38
+ )
39
+ return (channels[0], channels[1], channels[2]), out_alpha
40
+
41
+
42
+ def _byte(unit: Fraction) -> int:
43
+ return math.floor(unit * 255 + _HALF)
44
+
45
+
46
+ def blend(foreground: Color | str, background: Color | str) -> Color:
47
+ """Composite foreground over background and return the result as a Color.
48
+
49
+ The math is exact (fractions); each output channel is rounded to the nearest byte
50
+ once, halves rounding up. An opaque foreground returns itself; a fully transparent
51
+ foreground returns the background. If both are fully transparent the result is
52
+ transparent black. Strings are parsed with :func:`parse_color` (lenient).
53
+ """
54
+ fg = to_color(foreground)
55
+ bg = to_color(background)
56
+ (r, g, b), alpha = composite(
57
+ (Fraction(fg.r, 255), Fraction(fg.g, 255), Fraction(fg.b, 255)),
58
+ Fraction(fg.a, 255),
59
+ (Fraction(bg.r, 255), Fraction(bg.g, 255), Fraction(bg.b, 255)),
60
+ Fraction(bg.a, 255),
61
+ )
62
+ return Color(_byte(r), _byte(g), _byte(b), _byte(alpha))
hexrgba/_cli.py ADDED
@@ -0,0 +1,125 @@
1
+ """Command-line interface: ``hexrgba normalize | convert | contrast | blend``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from . import __version__
11
+ from ._blend import blend
12
+ from ._color import Color, parse_color
13
+ from ._errors import ColorError
14
+ from ._wcag import contrast_ratio, passes_contrast
15
+
16
+ _EXAMPLES = """exit codes: 0 success, 1 the pair fails --level, 2 bad usage or invalid color
17
+
18
+ examples (quote a leading # so the shell does not read it as a comment):
19
+ hexrgba normalize "#FFAA00" -> #ffaa00
20
+ hexrgba normalize ffaa0080 -> #ffaa0080
21
+ hexrgba normalize "#aabbcc" --short -> #abc
22
+ hexrgba normalize RebeccaPurple -> #663399
23
+ hexrgba convert "#ff00ffed" --to css -> rgba(255, 0, 255, 0.93)
24
+ hexrgba convert "#00ffff" --to name -> aqua
25
+ hexrgba convert transparent --to channels -> 0 0 0 0
26
+ hexrgba contrast "#767676" white -> 4.54:1
27
+ hexrgba contrast "#777" white --level AA -> exits 1: 4.47:1 is below 4.5:1
28
+ hexrgba blend "#0000ff80" red -> #7f0080
29
+ """
30
+
31
+ _FORMATS = ("hex", "short", "css", "channels", "name")
32
+
33
+
34
+ # Exit codes: 0 success, 1 the pair fails --level, 2 bad usage or an invalid color.
35
+ EXIT_FAILS_LEVEL = 1
36
+ EXIT_BAD_INPUT = 2
37
+
38
+
39
+ def _build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(
41
+ prog="hexrgba",
42
+ description="Normalize, convert, compare and blend CSS hex and named colors.",
43
+ epilog=_EXAMPLES,
44
+ formatter_class=argparse.RawDescriptionHelpFormatter,
45
+ )
46
+ parser.add_argument("--version", action="version", version=f"hexrgba {__version__}")
47
+ sub = parser.add_subparsers(dest="command", required=True)
48
+
49
+ norm = sub.add_parser("normalize", help="print the canonical lower-case hex form")
50
+ norm.add_argument("color", help="hex color or CSS named color")
51
+ norm.add_argument("--short", action="store_true", help="use #rgb / #rgba when lossless")
52
+ _add_strict(norm)
53
+
54
+ conv = sub.add_parser("convert", help="print the color in another notation")
55
+ conv.add_argument("color", help="hex color or CSS named color")
56
+ conv.add_argument("--to", choices=_FORMATS, default="css", help="output (default css)")
57
+ _add_strict(conv)
58
+
59
+ con = sub.add_parser("contrast", help="print the WCAG contrast ratio of two colors")
60
+ con.add_argument("foreground", help="text color (may be translucent)")
61
+ con.add_argument("background", help="opaque background color")
62
+ con.add_argument("--level", choices=("AA", "AAA"), help="exit 1 if the pair fails it")
63
+ con.add_argument(
64
+ "--large", action="store_true", help="with --level, use the large-text threshold"
65
+ )
66
+
67
+ ble = sub.add_parser("blend", help="composite a color over a background")
68
+ ble.add_argument("foreground", help="color on top (usually translucent)")
69
+ ble.add_argument("background", help="color underneath")
70
+ return parser
71
+
72
+
73
+ def _add_strict(parser: argparse.ArgumentParser) -> None:
74
+ parser.add_argument("--strict", action="store_true", help="follow the CSS grammar exactly")
75
+
76
+
77
+ def _convert(color: Color, target: str) -> str:
78
+ if target == "hex":
79
+ return color.hex
80
+ if target == "short":
81
+ return color.to_hex(short=True)
82
+ if target == "channels":
83
+ return " ".join(str(channel) for channel in color.as_tuple())
84
+ if target == "name":
85
+ if color.name is None:
86
+ raise ColorError(f"no CSS named color equals {color.hex}")
87
+ return color.name
88
+ return color.to_css()
89
+
90
+
91
+ def _ratio_text(ratio: float) -> str:
92
+ # Truncate rather than round, so a failing 4.499 never prints as a passing 4.50.
93
+ return f"{math.floor(ratio * 100) / 100:.2f}:1"
94
+
95
+
96
+ def _run(args: argparse.Namespace) -> int:
97
+ if args.command == "normalize":
98
+ print(parse_color(args.color, strict=args.strict).to_hex(short=args.short))
99
+ elif args.command == "convert":
100
+ print(_convert(parse_color(args.color, strict=args.strict), args.to))
101
+ elif args.command == "contrast":
102
+ ratio = contrast_ratio(args.foreground, args.background)
103
+ print(_ratio_text(ratio))
104
+ if args.level and not passes_contrast(
105
+ args.foreground, args.background, level=args.level, large=args.large
106
+ ):
107
+ kind = "large text" if args.large else "normal text"
108
+ print(f"hexrgba: fails WCAG {args.level} for {kind}", file=sys.stderr)
109
+ return EXIT_FAILS_LEVEL
110
+ else:
111
+ print(blend(args.foreground, args.background).hex)
112
+ return 0
113
+
114
+
115
+ def main(argv: Sequence[str] | None = None) -> int:
116
+ """Run the command-line interface and return a process exit code."""
117
+ parser = _build_parser()
118
+ args = parser.parse_args(argv)
119
+ if args.command == "contrast" and args.large and not args.level:
120
+ parser.error("--large only applies together with --level")
121
+ try:
122
+ return _run(args)
123
+ except (ColorError, TypeError) as exc:
124
+ print(f"hexrgba: error: {exc}", file=sys.stderr)
125
+ return EXIT_BAD_INPUT
hexrgba/_color.py ADDED
@@ -0,0 +1,141 @@
1
+ """The frozen ``Color`` value type and CSS color parsing (hex and named keywords)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ._alpha import alpha_to_byte, css_alpha
8
+ from ._errors import ColorNameError, HexColorError, shown
9
+ from ._hex import check_channel, decode, is_hex_body, shorten, to_hex
10
+ from ._names import NAMED_COLORS
11
+
12
+ # CSS keywords are ASCII case-insensitive: only A-Z fold, so look-alikes such as
13
+ # the Kelvin sign (U+212A) never match "k" the way str.lower() would make them.
14
+ _ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
15
+ _NAME_BY_HEX: dict[str, str] = {}
16
+ for _name, _value in NAMED_COLORS.items():
17
+ _NAME_BY_HEX.setdefault(_value, _name)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Color:
22
+ """An sRGB color with integer channels ``r``, ``g``, ``b`` and alpha ``a``, 0 to 255.
23
+
24
+ Instances are immutable, hashable and compare by value. ``a`` defaults to 255
25
+ (fully opaque). Construction raises ChannelError for any channel that is not an int
26
+ from 0 to 255.
27
+ """
28
+
29
+ r: int
30
+ g: int
31
+ b: int
32
+ a: int = 255
33
+
34
+ def __post_init__(self) -> None:
35
+ for name in ("r", "g", "b", "a"):
36
+ check_channel(getattr(self, name), name)
37
+
38
+ @classmethod
39
+ def parse(cls, text: str, *, strict: bool = False) -> Color:
40
+ """Parse a hex color or CSS named color; see :func:`parse_color`."""
41
+ return parse_color(text, strict=strict)
42
+
43
+ @classmethod
44
+ def from_rgba(cls, r: int, g: int, b: int, alpha: float = 1.0) -> Color:
45
+ """Build a color from byte channels and a 0..1 alpha (see :func:`alpha_to_byte`)."""
46
+ return cls(r, g, b, alpha_to_byte(alpha))
47
+
48
+ @property
49
+ def alpha(self) -> float:
50
+ """The alpha as a float from 0 to 1, exactly ``a / 255``."""
51
+ return self.a / 255
52
+
53
+ @property
54
+ def is_opaque(self) -> bool:
55
+ """True when the alpha byte is 255."""
56
+ return self.a == 255
57
+
58
+ @property
59
+ def hex(self) -> str:
60
+ """Canonical lower-case hex: #rrggbb when opaque, #rrggbbaa otherwise."""
61
+ if self.is_opaque:
62
+ return to_hex(self.r, self.g, self.b)
63
+ return to_hex(self.r, self.g, self.b, self.a)
64
+
65
+ @property
66
+ def name(self) -> str | None:
67
+ """The CSS keyword for this exact color, or None.
68
+
69
+ ``transparent`` names (0, 0, 0, 0). When two keywords share a value (``aqua`` and
70
+ ``cyan``, ``gray`` and ``grey``) the first in the spec's alphabetical table wins.
71
+ """
72
+ if self == _TRANSPARENT:
73
+ return "transparent"
74
+ return _NAME_BY_HEX.get(self.hex) if self.is_opaque else None
75
+
76
+ def to_hex(self, *, short: bool = False) -> str:
77
+ """Return :attr:`hex`, or the lossless #rgb / #rgba form when ``short=True``."""
78
+ return shorten(self.hex) if short else self.hex
79
+
80
+ def to_css(self) -> str:
81
+ """Serialize as CSS does for sRGB values: ``rgb(r, g, b)`` when opaque, otherwise
82
+ ``rgba(r, g, b, alpha)`` with the alpha from :func:`css_alpha`."""
83
+ if self.is_opaque:
84
+ return f"rgb({self.r}, {self.g}, {self.b})"
85
+ return f"rgba({self.r}, {self.g}, {self.b}, {css_alpha(self.a)})"
86
+
87
+ def with_alpha(self, alpha: float) -> Color:
88
+ """Return a copy with a 0..1 alpha, converted by :func:`alpha_to_byte`."""
89
+ return Color(self.r, self.g, self.b, alpha_to_byte(alpha))
90
+
91
+ def as_tuple(self) -> tuple[int, int, int, int]:
92
+ """Return ``(r, g, b, a)``, the same shape :func:`parse` returns."""
93
+ return (self.r, self.g, self.b, self.a)
94
+
95
+ def __str__(self) -> str:
96
+ return self.hex
97
+
98
+
99
+ _TRANSPARENT = Color(0, 0, 0, 0)
100
+
101
+
102
+ def parse_color(text: str, *, strict: bool = False) -> Color:
103
+ """Parse a CSS hex color (#rgb, #rgba, #rrggbb, #rrggbbaa), a named color or
104
+ ``transparent`` into a :class:`Color`.
105
+
106
+ Hex digits and keywords are matched ASCII case-insensitively, as CSS requires.
107
+ ``strict=True`` follows the CSS grammar exactly: hex needs its leading ``#`` and no
108
+ surrounding whitespace is allowed. The default lenient mode also accepts a missing
109
+ ``#`` and strips surrounding whitespace, like :func:`parse`.
110
+
111
+ Raises HexColorError for a malformed hex color, ColorNameError for an unknown
112
+ keyword and TypeError when text is not a str.
113
+ """
114
+ if not isinstance(text, str):
115
+ raise TypeError(f"color must be a str, got {type(text).__name__}")
116
+ body = text.strip()
117
+ if strict and body != text:
118
+ error = HexColorError if body.startswith("#") else ColorNameError
119
+ raise error(f"surrounding whitespace is not allowed in strict mode: {shown(text)}")
120
+ if body.startswith("#"):
121
+ return Color(*decode(body[1:], text))
122
+ key = body.translate(_ASCII_LOWER)
123
+ if key == "transparent":
124
+ return _TRANSPARENT
125
+ value = NAMED_COLORS.get(key)
126
+ if value is not None:
127
+ return Color(*decode(value[1:], text))
128
+ if is_hex_body(body):
129
+ if strict:
130
+ raise HexColorError(f"hex color needs a leading '#' in strict mode: {shown(text)}")
131
+ return Color(*decode(body, text))
132
+ raise ColorNameError(f"not a hex color or CSS named color: {shown(text)}")
133
+
134
+
135
+ def is_color(text: str, *, strict: bool = False) -> bool:
136
+ """Report whether :func:`parse_color` accepts text with the same ``strict`` setting."""
137
+ try:
138
+ parse_color(text, strict=strict)
139
+ except (ValueError, TypeError):
140
+ return False
141
+ return True
hexrgba/_errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Typed errors. Every error is a ``ValueError``, so 0.1.0 handlers keep working."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ColorError(ValueError):
7
+ """Base class for every error this library raises about a color value."""
8
+
9
+
10
+ class HexColorError(ColorError):
11
+ """Raised when a string is not a valid CSS hex color."""
12
+
13
+
14
+ class ChannelError(HexColorError):
15
+ """Raised when a channel or alpha value is outside its range or of the wrong type.
16
+
17
+ It subclasses HexColorError because 0.1.0 raised HexColorError for a bad channel in
18
+ ``to_hex``, and handlers written for that keep working.
19
+ """
20
+
21
+
22
+ _SHOWN_LIMIT = 40
23
+
24
+
25
+ def shown(value: object) -> str:
26
+ """Return ``repr(value)`` cut to a short prefix, so huge inputs keep messages small."""
27
+ text = repr(value)
28
+ return text if len(text) <= _SHOWN_LIMIT else text[:_SHOWN_LIMIT] + "..."
29
+
30
+
31
+ class ColorNameError(ColorError):
32
+ """Raised when a string is neither a hex color nor a CSS named color keyword."""
hexrgba/_hex.py ADDED
@@ -0,0 +1,89 @@
1
+ """Core parsing, validation and normalization for CSS hex colors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._errors import ChannelError, HexColorError, shown
6
+
7
+ _VALID_LENGTHS = frozenset({3, 4, 6, 8})
8
+ _HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
9
+
10
+
11
+ def _body(value: str) -> str:
12
+ text = value.strip()
13
+ return text[1:] if text.startswith("#") else text
14
+
15
+
16
+ def is_hex_body(body: str) -> bool:
17
+ """Report whether body (no hash) has 3, 4, 6 or 8 ASCII hex digits."""
18
+ return len(body) in _VALID_LENGTHS and all(ch in _HEX_DIGITS for ch in body)
19
+
20
+
21
+ def decode(body: str, original: str) -> tuple[int, int, int, int]:
22
+ """Decode a hash-less hex body into channels, naming original in the error."""
23
+ if not is_hex_body(body):
24
+ raise HexColorError(f"not a valid hex color: {shown(original)}")
25
+ if len(body) in (3, 4):
26
+ body = "".join(ch * 2 for ch in body)
27
+ r = int(body[0:2], 16)
28
+ g = int(body[2:4], 16)
29
+ b = int(body[4:6], 16)
30
+ a = int(body[6:8], 16) if len(body) == 8 else 255
31
+ return r, g, b, a
32
+
33
+
34
+ def is_hex(value: str) -> bool:
35
+ """Report whether value is a hex color in #rgb, #rgba, #rrggbb or #rrggbbaa form."""
36
+ return is_hex_body(_body(value))
37
+
38
+
39
+ def parse(value: str) -> tuple[int, int, int, int]:
40
+ """Parse a hex color into (r, g, b, a) channels as integers from 0 to 255.
41
+
42
+ Shorthand (#rgb, #rgba) is expanded by doubling each digit, the leading hash is
43
+ optional, and a missing alpha channel defaults to 255 (fully opaque).
44
+ """
45
+ return decode(_body(value), value)
46
+
47
+
48
+ def check_channel(value: int, name: str) -> int:
49
+ """Return value if it is an int from 0 to 255, else raise ChannelError."""
50
+ if not isinstance(value, int) or isinstance(value, bool):
51
+ raise ChannelError(f"{name} must be an int from 0 to 255, got {shown(value)}")
52
+ if not 0 <= value <= 255:
53
+ raise ChannelError(f"{name} must be from 0 to 255, got {value}")
54
+ return value
55
+
56
+
57
+ def _channel(value: int, name: str) -> str:
58
+ return f"{check_channel(value, name):02x}"
59
+
60
+
61
+ def to_hex(r: int, g: int, b: int, a: int | None = None) -> str:
62
+ """Build a canonical lowercase hex string from channels.
63
+
64
+ With no alpha the result is #rrggbb, with alpha it is #rrggbbaa.
65
+ """
66
+ out = "#" + _channel(r, "r") + _channel(g, "g") + _channel(b, "b")
67
+ if a is not None:
68
+ out += _channel(a, "a")
69
+ return out
70
+
71
+
72
+ def shorten(value: str) -> str:
73
+ """Return the shortest lossless form of a hex color: #rgb or #rgba when every pair
74
+ repeats its digit, otherwise the canonical #rrggbb or #rrggbbaa from normalize."""
75
+ full = normalize(value)
76
+ pairs = [full[i : i + 2] for i in range(1, len(full), 2)]
77
+ if all(pair[0] == pair[1] for pair in pairs):
78
+ return "#" + "".join(pair[0] for pair in pairs)
79
+ return full
80
+
81
+
82
+ def normalize(value: str, *, short: bool = False) -> str:
83
+ """Return the canonical form: #rrggbb when fully opaque, #rrggbbaa otherwise.
84
+
85
+ With ``short=True`` the result is shortened to #rgb or #rgba when that is lossless.
86
+ """
87
+ r, g, b, a = parse(value)
88
+ full = to_hex(r, g, b) if a == 255 else to_hex(r, g, b, a)
89
+ return shorten(full) if short else full
hexrgba/_names.py ADDED
@@ -0,0 +1,164 @@
1
+ """The CSS named color table, one keyword per row.
2
+
3
+ Copied programmatically from the table in section 6.1 of CSS Color Module Level 4,
4
+ https://www.w3.org/TR/css-color-4/#named-colors (Candidate Recommendation Draft of
5
+ 9 October 2026, fetched 2026-10-12). Rows keep the spec order, which is alphabetical.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from types import MappingProxyType
11
+
12
+ _TABLE: dict[str, str] = {
13
+ "aliceblue": "#f0f8ff",
14
+ "antiquewhite": "#faebd7",
15
+ "aqua": "#00ffff",
16
+ "aquamarine": "#7fffd4",
17
+ "azure": "#f0ffff",
18
+ "beige": "#f5f5dc",
19
+ "bisque": "#ffe4c4",
20
+ "black": "#000000",
21
+ "blanchedalmond": "#ffebcd",
22
+ "blue": "#0000ff",
23
+ "blueviolet": "#8a2be2",
24
+ "brown": "#a52a2a",
25
+ "burlywood": "#deb887",
26
+ "cadetblue": "#5f9ea0",
27
+ "chartreuse": "#7fff00",
28
+ "chocolate": "#d2691e",
29
+ "coral": "#ff7f50",
30
+ "cornflowerblue": "#6495ed",
31
+ "cornsilk": "#fff8dc",
32
+ "crimson": "#dc143c",
33
+ "cyan": "#00ffff",
34
+ "darkblue": "#00008b",
35
+ "darkcyan": "#008b8b",
36
+ "darkgoldenrod": "#b8860b",
37
+ "darkgray": "#a9a9a9",
38
+ "darkgreen": "#006400",
39
+ "darkgrey": "#a9a9a9",
40
+ "darkkhaki": "#bdb76b",
41
+ "darkmagenta": "#8b008b",
42
+ "darkolivegreen": "#556b2f",
43
+ "darkorange": "#ff8c00",
44
+ "darkorchid": "#9932cc",
45
+ "darkred": "#8b0000",
46
+ "darksalmon": "#e9967a",
47
+ "darkseagreen": "#8fbc8f",
48
+ "darkslateblue": "#483d8b",
49
+ "darkslategray": "#2f4f4f",
50
+ "darkslategrey": "#2f4f4f",
51
+ "darkturquoise": "#00ced1",
52
+ "darkviolet": "#9400d3",
53
+ "deeppink": "#ff1493",
54
+ "deepskyblue": "#00bfff",
55
+ "dimgray": "#696969",
56
+ "dimgrey": "#696969",
57
+ "dodgerblue": "#1e90ff",
58
+ "firebrick": "#b22222",
59
+ "floralwhite": "#fffaf0",
60
+ "forestgreen": "#228b22",
61
+ "fuchsia": "#ff00ff",
62
+ "gainsboro": "#dcdcdc",
63
+ "ghostwhite": "#f8f8ff",
64
+ "gold": "#ffd700",
65
+ "goldenrod": "#daa520",
66
+ "gray": "#808080",
67
+ "green": "#008000",
68
+ "greenyellow": "#adff2f",
69
+ "grey": "#808080",
70
+ "honeydew": "#f0fff0",
71
+ "hotpink": "#ff69b4",
72
+ "indianred": "#cd5c5c",
73
+ "indigo": "#4b0082",
74
+ "ivory": "#fffff0",
75
+ "khaki": "#f0e68c",
76
+ "lavender": "#e6e6fa",
77
+ "lavenderblush": "#fff0f5",
78
+ "lawngreen": "#7cfc00",
79
+ "lemonchiffon": "#fffacd",
80
+ "lightblue": "#add8e6",
81
+ "lightcoral": "#f08080",
82
+ "lightcyan": "#e0ffff",
83
+ "lightgoldenrodyellow": "#fafad2",
84
+ "lightgray": "#d3d3d3",
85
+ "lightgreen": "#90ee90",
86
+ "lightgrey": "#d3d3d3",
87
+ "lightpink": "#ffb6c1",
88
+ "lightsalmon": "#ffa07a",
89
+ "lightseagreen": "#20b2aa",
90
+ "lightskyblue": "#87cefa",
91
+ "lightslategray": "#778899",
92
+ "lightslategrey": "#778899",
93
+ "lightsteelblue": "#b0c4de",
94
+ "lightyellow": "#ffffe0",
95
+ "lime": "#00ff00",
96
+ "limegreen": "#32cd32",
97
+ "linen": "#faf0e6",
98
+ "magenta": "#ff00ff",
99
+ "maroon": "#800000",
100
+ "mediumaquamarine": "#66cdaa",
101
+ "mediumblue": "#0000cd",
102
+ "mediumorchid": "#ba55d3",
103
+ "mediumpurple": "#9370db",
104
+ "mediumseagreen": "#3cb371",
105
+ "mediumslateblue": "#7b68ee",
106
+ "mediumspringgreen": "#00fa9a",
107
+ "mediumturquoise": "#48d1cc",
108
+ "mediumvioletred": "#c71585",
109
+ "midnightblue": "#191970",
110
+ "mintcream": "#f5fffa",
111
+ "mistyrose": "#ffe4e1",
112
+ "moccasin": "#ffe4b5",
113
+ "navajowhite": "#ffdead",
114
+ "navy": "#000080",
115
+ "oldlace": "#fdf5e6",
116
+ "olive": "#808000",
117
+ "olivedrab": "#6b8e23",
118
+ "orange": "#ffa500",
119
+ "orangered": "#ff4500",
120
+ "orchid": "#da70d6",
121
+ "palegoldenrod": "#eee8aa",
122
+ "palegreen": "#98fb98",
123
+ "paleturquoise": "#afeeee",
124
+ "palevioletred": "#db7093",
125
+ "papayawhip": "#ffefd5",
126
+ "peachpuff": "#ffdab9",
127
+ "peru": "#cd853f",
128
+ "pink": "#ffc0cb",
129
+ "plum": "#dda0dd",
130
+ "powderblue": "#b0e0e6",
131
+ "purple": "#800080",
132
+ "rebeccapurple": "#663399",
133
+ "red": "#ff0000",
134
+ "rosybrown": "#bc8f8f",
135
+ "royalblue": "#4169e1",
136
+ "saddlebrown": "#8b4513",
137
+ "salmon": "#fa8072",
138
+ "sandybrown": "#f4a460",
139
+ "seagreen": "#2e8b57",
140
+ "seashell": "#fff5ee",
141
+ "sienna": "#a0522d",
142
+ "silver": "#c0c0c0",
143
+ "skyblue": "#87ceeb",
144
+ "slateblue": "#6a5acd",
145
+ "slategray": "#708090",
146
+ "slategrey": "#708090",
147
+ "snow": "#fffafa",
148
+ "springgreen": "#00ff7f",
149
+ "steelblue": "#4682b4",
150
+ "tan": "#d2b48c",
151
+ "teal": "#008080",
152
+ "thistle": "#d8bfd8",
153
+ "tomato": "#ff6347",
154
+ "turquoise": "#40e0d0",
155
+ "violet": "#ee82ee",
156
+ "wheat": "#f5deb3",
157
+ "white": "#ffffff",
158
+ "whitesmoke": "#f5f5f5",
159
+ "yellow": "#ffff00",
160
+ "yellowgreen": "#9acd32",
161
+ }
162
+
163
+ #: Every named color keyword mapped to its canonical lower-case ``#rrggbb`` value.
164
+ NAMED_COLORS = MappingProxyType(_TABLE)
hexrgba/_wcag.py ADDED
@@ -0,0 +1,70 @@
1
+ """WCAG 2.x relative luminance, contrast ratio and the AA / AAA text thresholds.
2
+
3
+ Formulas from the WCAG 2.2 definitions (W3C Recommendation, 12 December 2024):
4
+ https://www.w3.org/TR/WCAG22/#dfn-relative-luminance and
5
+ https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio. Thresholds from success criteria
6
+ 1.4.3 (https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum) and 1.4.6
7
+ (https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._blend import blend, to_color
13
+ from ._color import Color
14
+ from ._errors import ColorError
15
+
16
+ # (level, large text) -> minimum ratio. Compared without rounding, as WCAG requires.
17
+ _THRESHOLDS = {("AA", False): 4.5, ("AA", True): 3.0, ("AAA", False): 7.0, ("AAA", True): 4.5}
18
+
19
+
20
+ def _linear(byte: int) -> float:
21
+ srgb = byte / 255
22
+ return srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4
23
+
24
+
25
+ def relative_luminance(color: Color | str) -> float:
26
+ """Return the WCAG relative luminance, 0.0 for black to 1.0 for white.
27
+
28
+ ``L = 0.2126 * R + 0.7152 * G + 0.0722 * B`` on linearized sRGB channels, with the
29
+ 0.04045 threshold of the current definition. Alpha is ignored: composite a
30
+ translucent color first with :func:`blend`.
31
+ """
32
+ c = to_color(color)
33
+ return 0.2126 * _linear(c.r) + 0.7152 * _linear(c.g) + 0.0722 * _linear(c.b)
34
+
35
+
36
+ def contrast_ratio(foreground: Color | str, background: Color | str) -> float:
37
+ """Return the WCAG contrast ratio ``(L1 + 0.05) / (L2 + 0.05)``, from 1.0 to 21.0.
38
+
39
+ L1 is the lighter luminance, so the order of two opaque colors does not matter. A
40
+ translucent foreground is first composited over the background with :func:`blend`
41
+ and rounded to 8-bit channels, as a browser paints it.
42
+ The background must be opaque, since what lies behind it is unknown: a translucent
43
+ background raises ColorError.
44
+ """
45
+ bg = to_color(background)
46
+ if not bg.is_opaque:
47
+ raise ColorError(f"background must be opaque to measure contrast, got {bg.hex}")
48
+ fg = blend(foreground, bg)
49
+ first, second = relative_luminance(fg), relative_luminance(bg)
50
+ lighter, darker = max(first, second), min(first, second)
51
+ return (lighter + 0.05) / (darker + 0.05)
52
+
53
+
54
+ def passes_contrast(
55
+ foreground: Color | str,
56
+ background: Color | str,
57
+ *,
58
+ level: str = "AA",
59
+ large: bool = False,
60
+ ) -> bool:
61
+ """Report whether the pair meets WCAG text contrast at ``level`` "AA" or "AAA".
62
+
63
+ The minimum ratios are 4.5 (AA), 3 (AA, large text), 7 (AAA) and 4.5 (AAA, large
64
+ text). ``level`` is case-insensitive. The ratio is not rounded first, so 4.499 fails
65
+ 4.5. Raises ValueError for an unknown level.
66
+ """
67
+ key = (level.upper() if isinstance(level, str) else level, large)
68
+ if key not in _THRESHOLDS:
69
+ raise ValueError(f"level must be 'AA' or 'AAA', (any case), got {level!r}")
70
+ return contrast_ratio(foreground, background) >= _THRESHOLDS[key]
hexrgba/py.typed ADDED
File without changes
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: hexrgba
3
+ Version: 0.2.1
4
+ Summary: CSS hex and named colors: parse, normalize, exact alpha, rgba(), WCAG contrast and blending
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/hexrgba
7
+ Project-URL: Issues, https://github.com/JaviLianes8/hexrgba/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/hexrgba/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: blend,color,contrast,css,hex,named-colors,normalize,parse,rgba,wcag
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: build; extra == 'dev'
22
+ Requires-Dist: mypy; extra == 'dev'
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Requires-Dist: twine; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # hexrgba
29
+
30
+ Parse, validate and normalize CSS hex colors and named colors, with exact alpha handling,
31
+ CSS `rgb()` / `rgba()` output, WCAG contrast and alpha blending. Typed, zero dependencies.
32
+
33
+ ## Install
34
+
35
+ ```
36
+ pip install hexrgba
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from hexrgba import Color, contrast_ratio, is_hex, normalize, parse, parse_color, to_hex
43
+
44
+ is_hex("#f00") # True
45
+ parse("#ff000080") # (255, 0, 0, 128)
46
+ normalize("#F00") # "#ff0000"
47
+ normalize("#AABBCC", short=True) # "#abc"
48
+ to_hex(255, 0, 128, 64) # "#ff008040"
49
+
50
+ color = parse_color("RebeccaPurple") # Color(r=102, g=51, b=153, a=255)
51
+ color.hex # "#663399"
52
+ color.to_css() # "rgb(102, 51, 153)"
53
+ parse_color("#ff00ffed").to_css() # "rgba(255, 0, 255, 0.93)"
54
+ parse_color("#00ffff").name # "aqua"
55
+ Color.from_rgba(255, 0, 0, 0.5).hex # "#ff000080"
56
+
57
+ contrast_ratio("#767676", "white") # 4.54...
58
+ ```
59
+
60
+ What it covers:
61
+
62
+ - **All four hex forms** (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) and **all 148 CSS named
63
+ colors** plus `transparent`, matched ASCII case-insensitively as CSS requires.
64
+ - **Strict or lenient.** `strict=True` follows the CSS grammar (leading `#`, no surrounding
65
+ whitespace). The default also accepts a missing `#` and strips surrounding
66
+ Python whitespace (`str.strip()`).
67
+ - **A frozen `Color`** with `hex`, `to_hex(short=True)`, `to_css()`, `name`, `alpha`,
68
+ `with_alpha()` and `from_rgba()`.
69
+ - **Exact alpha.** `alpha_to_byte` and `css_alpha` follow CSS Color 4; every alpha byte
70
+ round-trips through its 0..1 float and through its CSS string.
71
+ - **WCAG 2.x** `relative_luminance`, `contrast_ratio` and `passes_contrast` (AA / AAA).
72
+ - **Blending** of a translucent color over a background with `blend`.
73
+ - **Typed errors**, all `ValueError`: `ColorError`, `HexColorError`, `ColorNameError`,
74
+ `ChannelError`.
75
+
76
+ The full guide, with the exact rules and the sources they come from, is in
77
+ [GUIDE.md](GUIDE.md).
78
+
79
+ ## Command line
80
+
81
+ ```
82
+ hexrgba normalize "#FFAA00" # #ffaa00
83
+ hexrgba normalize "#aabbcc" --short # #abc
84
+ hexrgba convert "#ff00ffed" --to css # rgba(255, 0, 255, 0.93)
85
+ hexrgba convert "#00ffff" --to name # aqua
86
+ hexrgba contrast "#767676" white # 4.54:1
87
+ hexrgba contrast "#777" white --level AA # prints 4.47:1, exits 1
88
+ hexrgba blend "#0000ff80" red # #7f0080
89
+ ```
90
+
91
+ Quote a leading `#`: most shells read it as the start of a comment. Errors go to stderr.
92
+ Exit codes: 0 success, 1 the pair fails `--level`, 2 bad usage or an invalid color. `python -m hexrgba` works too.
93
+
94
+ ## Why
95
+
96
+ `webcolors` is built around the CSS named-color tables, and most other color packages pull
97
+ in a wider model of color spaces. `hexrgba` stays small and exact: the hex and keyword
98
+ syntax of CSS, checked against the spec tables and the Web Platform Tests vectors, an
99
+ alpha conversion that never drifts, and the WCAG contrast math, with no dependencies.
100
+
101
+ ## License
102
+
103
+ MIT. See [LICENSE](LICENSE).
104
+
105
+ ## Links
106
+
107
+ - Website: https://lianes8studio.com
108
+ - GitHub: https://github.com/JaviLianes8
109
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,16 @@
1
+ hexrgba/__init__.py,sha256=9Cf4qBwBlW97uWC2qUjGQC8sfnY4wvGznG6F_2Xglo8,920
2
+ hexrgba/__main__.py,sha256=5-6tPw_mmezSTz4Gzuu9YObksMe5frssWwKa0Dtziwg,156
3
+ hexrgba/_alpha.py,sha256=QEklpgRrDcXZ5xd0WrWG1PNpp0uzOJB_4lrIUbZekwg,2419
4
+ hexrgba/_blend.py,sha256=lXf4POQhXOfkwPpH8NyoCxmWXxR661xCrjI5DBmQvEI,2286
5
+ hexrgba/_cli.py,sha256=qxg6VDiF9598vlap1LXkWDnHsRQonpJ3eq6VUi0DyDE,5040
6
+ hexrgba/_color.py,sha256=PN5C8KtNZTS_RPvMbuQ8wPPSl-_N8tXXt1AajJoKIqo,5479
7
+ hexrgba/_errors.py,sha256=U9q80RF1LGknT5MCrp8mCOneYpaYbhGXD5BcLji9kHo,977
8
+ hexrgba/_hex.py,sha256=kVXiunGCV7Th1t19e2q9t0gcZm-dHfkDM30n00pci-U,3178
9
+ hexrgba/_names.py,sha256=A0Kp-yMT6Xp1FdTgXQUoEWO0nRHtbh6uCusOKYpK6OY,4668
10
+ hexrgba/_wcag.py,sha256=qfIGIXZhEQlOsL_6psZdw5Z8WScuVG4dmvabjtMc_Jw,2952
11
+ hexrgba/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ hexrgba-0.2.1.dist-info/METADATA,sha256=c1LkganAl8QWukGXDInMkSbcmM6GCgMmqQjobIppS3w,4178
13
+ hexrgba-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ hexrgba-0.2.1.dist-info/entry_points.txt,sha256=reKvZdBTUG_jz6woh-znKy3xHXdZzCIqzBMmzzJJ1Ac,46
15
+ hexrgba-0.2.1.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
16
+ hexrgba-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hexrgba = hexrgba._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.