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 +36 -0
- hexrgba/__main__.py +10 -0
- hexrgba/_alpha.py +56 -0
- hexrgba/_blend.py +62 -0
- hexrgba/_cli.py +125 -0
- hexrgba/_color.py +141 -0
- hexrgba/_errors.py +32 -0
- hexrgba/_hex.py +89 -0
- hexrgba/_names.py +164 -0
- hexrgba/_wcag.py +70 -0
- hexrgba/py.typed +0 -0
- hexrgba-0.2.1.dist-info/METADATA +109 -0
- hexrgba-0.2.1.dist-info/RECORD +16 -0
- hexrgba-0.2.1.dist-info/WHEEL +4 -0
- hexrgba-0.2.1.dist-info/entry_points.txt +2 -0
- hexrgba-0.2.1.dist-info/licenses/LICENSE +21 -0
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
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,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.
|