diff --git a/coloraide/color.py b/coloraide/color.py index c04ab671..e48d66b6 100644 --- a/coloraide/color.py +++ b/coloraide/color.py @@ -186,7 +186,7 @@ def __init__( def __len__(self) -> int: """Get number of channels.""" - return len(self._space.CHANNELS) + 1 + return len(self._space.channels) @overload def __getitem__(self, i: str | int) -> float: @@ -531,14 +531,14 @@ def to_dict( self, *, nans: bool = True, - precision: int | None = None, - precision_alpha: int | None = None + precision: int | Sequence[int] | None = None ) -> Mapping[str, Any]: """Return color as a data object.""" - # Assume precision of other coordinates for alpha if only precision is specified - if precision is not None and precision_alpha is None: + if precision is None or isinstance(precision, int): precision_alpha = precision + else: + precision_alpha = util.get_index(precision, len(self._space.channels) - 1, self.PRECISION) return { 'space': self.space(), @@ -1293,38 +1293,51 @@ def contrast(self, color: ColorInput, method: str | None = None) -> float: return contrast.contrast(method, self, color) @overload - def get(self, name: str, *, nans: bool = True, precision: int | None = None) -> float: + def get(self, + name: str, + *, + nans: bool = True, + precision: int | Sequence[int] | None = None + ) -> float: ... @overload - def get(self, name: list[str] | tuple[str, ...], *, nans: bool = True, precision: int | None = None) -> Vector: + def get( + self, + name: list[str] | tuple[str, ...], + *, + nans: bool = True, + precision: int | Sequence[int] | None = None + ) -> Vector: ... def get( self, name: str | list[str] | tuple[str, ...], *, nans: bool = True, - precision: int | None = None + precision: int | Sequence[int] | None = None ) -> float | Vector: """Get channel.""" + is_plist = precision is not None and not isinstance(precision, int) + # Handle single channel if isinstance(name, str): # Handle space.channel if '.' in name: space, channel = name.split('.', 1) + obj = self.convert(space, norm=nans) if nans: - v = self.convert(space)[channel] + v = obj[channel] else: - obj = self.convert(space, norm=nans) i = obj._space.get_channel_index(channel) v = obj._space.resolve_channel(i, obj._coords) elif nans: v = self[name] else: i = self._space.get_channel_index(name) - v = self._space.resolve_channel(i, self._coords) - return v if precision is None else alg.round_to(v, precision) + v = self._space.resolve_channel(self._space.get_channel_index(name), self._coords) + return v if precision is None else alg.round_to(v, util.get_index(precision, 0) if is_plist else precision) # type: ignore[arg-type] # Handle list of channels else: @@ -1332,7 +1345,7 @@ def get( obj = self values = [] - for n in name: + for e, n in enumerate(name): # Handle space.channel space, channel = n.split('.', 1) if '.' in n else (original_space, n) if space != current_space: @@ -1340,11 +1353,12 @@ def get( current_space = space if nans: v = obj[channel] - values.append(v if precision is None else alg.round_to(v, precision)) else: i = obj._space.get_channel_index(channel) v = obj._space.resolve_channel(i, obj._coords) - values.append(v if precision is None else alg.round_to(v, precision)) + values.append( + v if precision is None else alg.round_to(v, util.get_index(precision, e) if is_plist else precision) # type: ignore[arg-type] + ) return values def set( # noqa: A003 @@ -1406,22 +1420,38 @@ def set( # noqa: A003 return self - def coords(self, *, nans: bool = True, precision: int | None = None) -> Vector: + def coords(self, *, nans: bool = True, precision: int | Sequence[int] | None = None) -> Vector: """Get the color channels and optionally remove undefined values.""" - if nans: - value = self[:-1] + # Full precision + if precision is None: + if nans: + return self[:-1] + else: + return [ + self._space.resolve_channel(index, self._coords) + for index in range(len(self._coords) - 1) + ] + # Specific precision requested + elif isinstance(precision, int): + return [ + alg.round_to(self[index] if nans else self._space.resolve_channel(index, self._coords), precision) + for index in range(len(self._coords) - 1) + ] + # Channel specific list of precision else: - value = [self._space.resolve_channel(index, self._coords) for index in range(len(self._coords) - 1)] - return value if precision is None else [alg.round_to(v, precision) for v in value] + return [ + alg.round_to( + self[index] if nans else self._space.resolve_channel(index, self._coords), + util.get_index(precision, index, self.PRECISION) + ) + for index in range(len(self._coords) - 1) + ] def alpha(self, *, nans: bool = True, precision: int | None = None) -> float: """Get the alpha channel.""" - if nans: - value = self[-1] - else: - value = self._space.resolve_channel(-1, self._coords) + value = self[-1] if nans else self._space.resolve_channel(-1, self._coords) return value if precision is None else alg.round_to(value, precision) diff --git a/coloraide/css/serialize.py b/coloraide/css/serialize.py index 9065313a..d2d26b64 100644 --- a/coloraide/css/serialize.py +++ b/coloraide/css/serialize.py @@ -37,8 +37,7 @@ def color_function( obj: Color, func: str | None, alpha: bool | None, - precision: int, - precision_alpha: int, + precision: int | Sequence[int], fit: str | bool | dict[str, Any], none: bool, percent: bool | Sequence[bool], @@ -70,10 +69,10 @@ def color_function( # - A list of booleans will attempt formatting the associated channel as percent, # anything not specified is assumed `False`. if isinstance(percent, bool): - plist = obj._space._percents if percent else [] - else: - diff = l - len(percent) - plist = list(percent) + ([False] * diff) if diff > 0 else list(percent) + percent = obj._space._percents if percent else [] + + # Ensure precision list is filled + is_precision_list = not isinstance(precision, int) # Iterate the coordinates formatting them by scaling the values, formatting for percent, etc. for idx, value in enumerate(coords): @@ -84,7 +83,7 @@ def color_function( string.append(COMMA if legacy else SPACE) channel = channels[idx] - if not (channel.flags & FLG_ANGLE) and plist and plist[idx]: + if not (channel.flags & FLG_ANGLE) and percent and util.get_index(percent, idx, False): span, offset = channel.span, channel.offset else: span = offset = 0.0 @@ -94,7 +93,7 @@ def color_function( string.append( util.fmt_float( value, - precision_alpha if is_last else precision, + util.get_index(precision, idx, obj.PRECISION) if is_precision_list else precision, # type: ignore[arg-type] span, offset ) @@ -178,8 +177,7 @@ def serialize_css( func: str = '', color: bool = False, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, percent: bool | Sequence[bool] = False, @@ -195,12 +193,9 @@ def serialize_css( if precision is None: precision = obj.PRECISION - if precision_alpha is None: - precision_alpha = precision - # Color format if color: - return color_function(obj, None, alpha, precision, precision_alpha, fit, none, percent, False, 1.0) + return color_function(obj, None, alpha, precision, fit, none, percent, False, 1.0) # CSS color names if name: @@ -214,6 +209,6 @@ def serialize_css( # Normal CSS named function format if func: - return color_function(obj, func, alpha, precision, precision_alpha, fit, none, percent, legacy, scale) + return color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) raise RuntimeError('Could not identify a CSS format to serialize to') # pragma: no cover diff --git a/coloraide/spaces/__init__.py b/coloraide/spaces/__init__.py index 03a04236..f13d3e89 100644 --- a/coloraide/spaces/__init__.py +++ b/coloraide/spaces/__init__.py @@ -258,8 +258,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: str | bool | dict[str, Any] = True, none: bool = False, percent: bool | Sequence[bool] = False, @@ -272,7 +271,6 @@ def to_string( color=True, alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, percent=percent diff --git a/coloraide/spaces/hsl/css.py b/coloraide/spaces/hsl/css.py index eaf85200..59ca165e 100644 --- a/coloraide/spaces/hsl/css.py +++ b/coloraide/spaces/hsl/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -45,7 +44,6 @@ def to_string( func='hsl', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/hwb/css.py b/coloraide/spaces/hwb/css.py index 7e91d092..3b8b1208 100644 --- a/coloraide/spaces/hwb/css.py +++ b/coloraide/spaces/hwb/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -36,7 +35,6 @@ def to_string( func='hwb', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/lab/css.py b/coloraide/spaces/lab/css.py index 890dd1c2..bf1805a9 100644 --- a/coloraide/spaces/lab/css.py +++ b/coloraide/spaces/lab/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -33,7 +32,6 @@ def to_string( func='lab', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/lch/css.py b/coloraide/spaces/lch/css.py index 469f1a21..5b401b9c 100644 --- a/coloraide/spaces/lch/css.py +++ b/coloraide/spaces/lch/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -33,7 +32,6 @@ def to_string( func='lch', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/oklab/css.py b/coloraide/spaces/oklab/css.py index 596b6cf8..0f9ef7e5 100644 --- a/coloraide/spaces/oklab/css.py +++ b/coloraide/spaces/oklab/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -33,7 +32,6 @@ def to_string( func='oklab', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/oklch/css.py b/coloraide/spaces/oklch/css.py index cff7d5ce..b8b090ff 100644 --- a/coloraide/spaces/oklch/css.py +++ b/coloraide/spaces/oklch/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -33,7 +32,6 @@ def to_string( func='oklch', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/spaces/srgb/css.py b/coloraide/spaces/srgb/css.py index 5dced2c5..48a2c05d 100644 --- a/coloraide/spaces/srgb/css.py +++ b/coloraide/spaces/srgb/css.py @@ -18,8 +18,7 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, - precision_alpha: int | None = None, + precision: int | Sequence[int] | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -38,7 +37,6 @@ def to_string( func='rgb', alpha=alpha, precision=precision, - precision_alpha=precision_alpha, fit=fit, none=none, color=color, diff --git a/coloraide/util.py b/coloraide/util.py index 1d4b896d..8883e023 100644 --- a/coloraide/util.py +++ b/coloraide/util.py @@ -4,7 +4,7 @@ from functools import wraps from . import algebra as alg from .types import Vector, VectorLike -from typing import Any, Callable +from typing import Any, Callable, Sequence DEF_PREC = 5 DEF_FIT_TOLERANCE = 0.000075 @@ -184,6 +184,15 @@ def constrain_hue(hue: float) -> float: return hue % 360 if not math.isnan(hue) else hue +def get_index(obj: Sequence[Any], idx: int, default: Any = None) -> Any: + """Get sequence value at index or return default if not present.""" + + try: + return obj[idx] + except IndexError: + return default + + def cmp_coords(c1: VectorLike, c2: VectorLike) -> bool: """Compare coordinates.""" diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 8cb8550a..a96cc571 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -5,10 +5,9 @@ - **NEW**: Officially support Python 3.13. - **NEW**: Define HTML output representation for Jupyter via `_repr_html_`. - **NEW**: `get()`, `coords()`, `alpha()`, `to_dict()` can now return channel values with a specified precision via - the new `precision` parameter. -- **NEW**: `to_string()` and `to_dict()` now have an optional `precision_alpha` parameter to control the precision of - alpha channel independently from other channels. If only `precision` is specified, the alpha channel will assume the - same precision as all other channels. + the new `precision` parameter. Per channel precision can be controlled if a list of precision is given. +- **NEW**: `to_string()` support for per channel precision was added and `precision` can now accept a list of + precision. - **NEW**: Remove deprecated `model` parameter from `cam16` ∆E method. Space should be used instead. - **NEW**: Remove deprecated `algebra.npow` function. `algebra.spow` should be used instead. - **NEW**: New generic `minde-chroma` gamut mapping method that allows specifying any Lab-ish or LCh-ish space to diff --git a/docs/src/markdown/api/index.md b/docs/src/markdown/api/index.md index 286caaa5..eaae131a 100644 --- a/docs/src/markdown/api/index.md +++ b/docs/src/markdown/api/index.md @@ -516,7 +516,8 @@ Return def to_dict( self, *, - nans: bool = True + nans: bool = True, + precision: int | Sequence[int] | None = None ) -> Mapping[str, Any]: ... ``` @@ -540,6 +541,7 @@ Parameters Parameters | Defaults | Description ---------- | ------------- | ----------- `nans` | `#!py True` | Return channel values having undefined values resolved as defined values. + `precision`| `#!py None` | If `precision` is not `#!py None`, the return values will be rounded according to the precision. If the precision is a sequence, each returned channel will be rounded according to the precision at the corresponding index in the sequence. Return @@ -1410,12 +1412,31 @@ Return ## `#!py Color.get` {#get} ```py +@overload +def get(self, + name: str, + *, + nans: bool = True, + precision: int | Sequence[int] | None = None +) -> float: + ... + +@overload def get( self, - name: str | list[str] | tuple[str, ...], + name: list[str] | tuple[str, ...], *, - nans: bool = True -) -> float | list[float]: + nans: bool = True, + precision: int | Sequence[int] | None = None +) -> Vector: + ... + +def get( + self, name: str | list[str] | tuple[str, ...], + *, + nans: bool = True, + precision: int | Sequence[int] | None = None +) -> float | Vector: ... ``` @@ -1433,6 +1454,7 @@ Parameters ---------- | ------------------ | ----------- `name` | | Channel name or sequence of channel names. Channel names can define the color space and channel name to retrieve value from a different color space. `nans` | `#!py True` | Determines whether an undefined value is allowed to be returned. If disabled, undefined values will be resolved before returning. + `precision`| `#!py None` | If `precision` is not `#!py None`, the return value will be rounded according to the precision. If the precision is a sequence, each returned coordinate will be rounded according to the precision at the corresponding index in the sequence. Return - Returns a numerical value that is stored internally for the specified channel, or a calculated value in the case @@ -1488,7 +1510,8 @@ Return def coords( self, *, - nans: bool = True + nans: bool = True, + precision: int | Sequence[int] | None = None ) -> Vector: ... ``` @@ -1505,6 +1528,7 @@ Parameters Parameters | Defaults | Description ---------- | ------------ | ----------- `nans` | `#!py True` | If `nans` is set to `#!py False`, all undefined values will be returned as defined. + `precision`| `#!py None` | If `precision` is not `#!py None`, the return value will be rounded according to the precision. If the precision is a sequence, each returned coordinate will be rounded according to the precision at the corresponding index in the sequence. Return @@ -1518,7 +1542,8 @@ Return def alpha( self, *, - nans: bool = True + nans: bool = True, + precision: int | None = None ) -> float: ... ``` @@ -1534,6 +1559,7 @@ Parameters Parameters | Defaults | Description ---------- | ------------ | ----------- `nans` | `#!py True` | If `nans` is set to `#!py False`, an undefined value will be returned as defined. + `precision`| `#!py None` | If `precision` is not `#!py None`, the return value will be rounded according to the precision. Return diff --git a/docs/src/markdown/color.md b/docs/src/markdown/color.md index 3d194a47..c77b56f7 100644 --- a/docs/src/markdown/color.md +++ b/docs/src/markdown/color.md @@ -75,11 +75,11 @@ print(d) Color(d) ``` -If you need to control alpha precision separately, you can also specify the alpha channels precision separately with -`precision_alpha`. This can be useful if you have radically different scaling between alpha and color coordinates. +If you need to control channel precision separately for a given channel, you can provide a list of precision where each +index in the list corresponds to the channel at that index. Omitted channels will assume default precision. ```py play -d = Color('purple').set('alpha', 0.75).convert('lab').to_dict(precision=0, precision_alpha=3) +d = Color('purple').set('alpha', 0.75).convert('lab').to_dict(precision=[0, 0, 0, 3]) print(d) Color(d) ``` diff --git a/docs/src/markdown/manipulation.md b/docs/src/markdown/manipulation.md index ddb55036..d75ce078 100644 --- a/docs/src/markdown/manipulation.md +++ b/docs/src/markdown/manipulation.md @@ -118,6 +118,15 @@ color.coords(precision=2) color.alpha(precision=1) ``` +If per channel precision control is desired for `coords()` a list can be provided where each index in the list +corresponds to the given channel at that index. + +```py play +color = Color("purple") +color +color.coords(precision=[2, 3, 5]) +``` + /// new | New in 4.0: Precision Output Control /// @@ -213,6 +222,15 @@ color.get('lightness', precision=2) color.get('alpha', precision=1) ``` +Channels can be requested with per channel precision control by providing a list of precision. Each index in the +precision list corresponds to input index of each channel in the order they passed in. + +```py play +color = Color('orange') +color +color.get(['alpha', 'oklch.lightness', 'oklch.hue'], precision=[5, 3, 0]) +``` + /// warning | Indirect Channel Modifications Indirect channel modification is very useful, but keep in mind that it may give you access to color spaces that are incompatible due to gamut size. Additionally, the feature converts the color to the target color space, modifies it, diff --git a/docs/src/markdown/strings.md b/docs/src/markdown/strings.md index dfbbe3a2..ae964e2b 100644 --- a/docs/src/markdown/strings.md +++ b/docs/src/markdown/strings.md @@ -62,20 +62,27 @@ doing and the values you are working in. Color("rgb(30.3456% 75% 100%)").to_string(precision=1) ``` -### Alpha Precision +There are some times where the channel coordinates need to have different precision, for instance the alpha channel +which always has values between 0 - 1 may be a very different scale than CIELab which scales lightness between 0 - 100. +In these cases, it may be desirable to use a different precision for alpha, especially when rounding other channels to +integers. -/// new | New in 4.0: Alpha Precision Control -/// +If needed, users can control precision per channel by providing a list of precision, each index in the list +corresponding to the channel at that index. + +```py play +Color("rgb(30.3456% 75% 100% / 0.75)").to_string(precision=[0, 0, 0, 3]) +``` -There are some times where the channel coordinates may be scaled differently than the alpha channel which is always -between 0 - 1. In these cases, it may be desirable to use a different precision for alpha, especially when rounding -other channels to integers. `precision_alpha` allows for the alpha channel precision to be handled independently of all -other channels. If only `precision` is provided, `precision_alpha` is assumed to be the same. +If a channel is omitted, the default precision is assumed for that channel. ```py play -Color("rgb(30.3456% 75% 100% / 0.75)").to_string(precision=0, precision_alpha=5) +Color("rgb(30.3456% 75% 100% / 0.75)").to_string(precision=[0, 0, 0]) ``` +/// new | New in 4.0: Per Channel Precision Control +/// + ### Fit `fit` is set to `#!py3 True` by default and controls whether colors are fit to their gamut or not. Some color spaces are diff --git a/tests/test_api.py b/tests/test_api.py index 30adfa23..55e31124 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,7 +33,7 @@ def test_to_string_alpha_precision(self): ) self.assertEqual( - Color('purple').convert('lab').set('alpha', 0.5234).to_string(precision=0, precision_alpha=3), + Color('purple').convert('lab').set('alpha', 0.5234).to_string(precision=[0, 0, 0, 3]), 'lab(30 56 -36 / 0.523)' ) @@ -129,7 +129,7 @@ def test_color_dict_precision(self): self.assertEqual(d, {'space': 'lab', 'coords': [29.7, 56.1, -36.3], 'alpha': 0.751}) - d2 = c1.to_dict(precision=0, precision_alpha=2) + d2 = c1.to_dict(precision=[0, 0, 0, 2]) self.assertEqual(d2, {'space': 'lab', 'coords': [30.0, 56.0, -36.0], 'alpha': 0.75}) def test_dict_input(self): diff --git a/tests/test_compositing.py b/tests/test_compositing.py index d9e44609..70f1e631 100644 --- a/tests/test_compositing.py +++ b/tests/test_compositing.py @@ -10,7 +10,9 @@ class TestCompositing(util.ColorAsserts, unittest.TestCase): def test_disable_compose(self): """Test that we can disable either blend or alpha compositing.""" - c1 = Color.layer([Color('#07c7ed').set('alpha', 0.5), '#fc3d99'], blend='multiply', operator=False, space="srgb") + c1 = Color.layer( + [Color('#07c7ed').set('alpha', 0.5), '#fc3d99'], blend='multiply', operator=False, space="srgb" + ) c2 = Color.layer([c1, '#fc3d99'], blend=False, space="srgb") self.assertColorEqual( Color.layer([Color('#07c7ed').set('alpha', 0.5), '#fc3d99'], blend='multiply', space="srgb"),