diff --git a/CHANGES.md b/CHANGES.md
index 03aea04e..88dc801a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,15 @@
# ColorHelper
+## 3.6.1
+
+- **FIX**: Fix issues with [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS))
+ support.
+- **FIX**: Remove unnecessary dependencies.
+
+## 3.6.0
+
+- **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)).
+
## 3.5.0
- **NEW**: `generic` rule will now allow scanning in strings by default. If this
@@ -13,6 +23,9 @@
channels must require those channels to be input as percentages per the CSS
level 4 specifications. This affects the string output for the `color()` format
as well.
+- **FIX**: Latest `coloraide` improves gamut mapping.
+- **FIX**: Small gamut fitting adjustments.
+- **FIX**: Fix issue with duplicate previews when working with clone views.
## 3.4.0
diff --git a/ColorHelper.sublime-settings b/ColorHelper.sublime-settings
index fb593327..c237a505 100644
--- a/ColorHelper.sublime-settings
+++ b/ColorHelper.sublime-settings
@@ -211,6 +211,14 @@
"output": [
{"space": "srgb", "format": {"hex": true}}
]
+ },
+ // for color codes like `&HAABBGGRR` and `&HBBGGRR`
+ "ass_abgr": {
+ "class": "ColorHelper.custom.ass_abgr.ColorAssABGR",
+ "filters": ["srgb"],
+ "output": [
+ {"space": "srgb", "format": {"upper": true}}
+ ]
}
},
@@ -280,7 +288,7 @@
"PackageDev/Package/Sublime Text Theme/Sublime Text Theme"
],
"color_class": "sublime-colormod",
- "color_trigger": "(?i)(?:\\b(?'.format(
@@ -331,7 +331,7 @@ def get_channel(self, channel, label, color_filter):
coord = cutil.no_nan(getattr(clone, color_filter)) - step
setattr(clone, color_filter, coord)
- if not clone.in_gamut():
+ if not clone.in_gamut(tolerance=0):
temp.append(self.get_spacer(width=count))
break
elif color_filter == "alpha" and (coord < 0 or coord > 1.0):
@@ -382,7 +382,7 @@ def get_channel(self, channel, label, color_filter):
coord = cutil.no_nan(getattr(clone, color_filter)) + step
setattr(clone, color_filter, coord)
- if not clone.in_gamut():
+ if not clone.in_gamut(tolerance=0):
html.append(self.get_spacer(width=count))
break
elif color_filter == "alpha" and (coord < 0 or coord > 1.0):
@@ -515,7 +515,7 @@ def handle_href(self, href):
self.view.run_command(
cmd,
{
- "initial": Color(color, filters=util.SRGB_SPACES).to_string(**DEFAULT),
+ "initial": Color(color, filters=util.CSS_SRGB_SPACES).to_string(**DEFAULT),
"on_done": on_done, "on_cancel": on_cancel
}
)
diff --git a/ch_preview.py b/ch_preview.py
index effd71d5..fce98d3c 100644
--- a/ch_preview.py
+++ b/ch_preview.py
@@ -103,7 +103,7 @@ def on_navigate(self, href):
"""Handle color box click."""
self.view.sel().clear()
- for k, v in self.previews[self.view.id()].items():
+ for k, v in self.previews[self.view.buffer_id()].items():
if href == v.uid:
phantom = self.view.query_phantom(v.pid)
if phantom:
@@ -231,7 +231,7 @@ def source_iter(self, visible_region, bounds):
def get_color_class(self, pt, classes):
"""Get color class based on selection scope."""
- view_id = self.view.id()
+ view_id = self.view.buffer_id()
if not self.color_classes[view_id] or self.view.settings().get('color_helper.refresh', True):
util.debug("Clear color class stash")
self.view.settings().set('color_helper.refresh', False)
@@ -283,7 +283,7 @@ def do_search(self, force=False):
settings = self.view.settings()
colors = []
- view_id = self.view.id()
+ view_id = self.view.buffer_id()
# Allow per view scan override
option = settings.get("color_helper.scan_override", None)
@@ -413,14 +413,15 @@ def do_search(self, force=False):
# Calculate a reasonable border color for our image at this location and get color strings
hsl = Color(
mdpopups.scope2style(self.view, self.view.scope_name(pt))['background'],
- filters=util.SRGB_SPACES
+ filters=util.CSS_SRGB_SPACES
).convert("hsl")
hsl.lightness = hsl.lightness + (30 if hsl.luminance() < 0.5 else -30)
preview_border = hsl.convert("srgb", fit=True).to_string(**util.HEX)
color = Color(obj.color)
title = ''
- if not color.in_gamut("srgb"):
+ check_space = 'srgb' if color.space() not in util.SRGB_SPACES else color.space()
+ if not color.in_gamut(check_space):
title = ' title="Preview out of gamut"'
if self.show_out_of_gamut_preview:
srgb = color.convert("srgb", fit=True)
@@ -431,7 +432,7 @@ def do_search(self, force=False):
preview2 = self.out_of_gamut
preview_border = self.out_of_gamut_border
else:
- srgb = color.convert("srgb")
+ srgb = color.convert("srgb", fit=True)
preview1 = srgb.to_string(**util.HEX_NA)
preview2 = srgb.to_string(**util.HEX)
@@ -473,7 +474,7 @@ def do_search(self, force=False):
def add_phantoms(self, colors):
"""Add phantoms."""
- i = self.view.id()
+ i = self.view.buffer_id()
for html, pt, start, end, unique_id in colors:
pid = self.view.add_phantom(
'color_helper',
@@ -486,21 +487,21 @@ def add_phantoms(self, colors):
def reset_previous(self):
"""Reset previous region."""
- self.previous_region[self.view.id()] = sublime.Region(0)
+ self.previous_region[self.view.buffer_id()] = sublime.Region(0)
def erase_phantoms(self):
"""Erase phantoms."""
# Obliterate!
self.view.erase_phantoms('color_helper')
- self.previews[self.view.id()].clear()
+ self.previews[self.view.buffer_id()].clear()
self.reset_previous()
def run(self, clear=False, force=False):
"""Run."""
self.view = self.window.active_view()
- ids = set([view.id() for view in self.window.views()])
+ ids = set([view.buffer_id() for view in self.window.views()])
keys = set(self.previews.keys())
diff = keys - ids
@@ -509,7 +510,7 @@ def run(self, clear=False, force=False):
del self.previous_region[i]
del self.color_classes[i]
- i = self.view.id()
+ i = self.view.buffer_id()
if i not in self.previews:
self.previews[i] = {}
if i not in self.previous_region:
diff --git a/ch_tool_blend.py b/ch_tool_blend.py
index 572e6e98..1a5e0d82 100644
--- a/ch_tool_blend.py
+++ b/ch_tool_blend.py
@@ -178,13 +178,12 @@ def preview(self, text):
orig = Color(color)
message = ""
color_string = ""
- if not orig.in_gamut('srgb'):
+ check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space()
+ if not orig.in_gamut(check_space):
orig = orig.fit("srgb")
message = '
* preview out of gamut'
color_string = "Gamut Mapped: {}
".format(orig.to_string())
- srgb = orig.convert('srgb', fit=True)
- else:
- srgb = orig.convert('srgb')
+ srgb = orig.convert('srgb', fit=True)
color_string += "Color: {}".format(color.to_string(**util.DEFAULT))
preview = srgb.to_string(**util.HEX_NA)
preview_alpha = srgb.to_string(**util.HEX)
diff --git a/ch_tool_colormod.py b/ch_tool_colormod.py
index 6f8555c0..a3107efa 100644
--- a/ch_tool_colormod.py
+++ b/ch_tool_colormod.py
@@ -65,7 +65,7 @@ def initial_text(self):
# Basically, if the file already supports `color-mod` input,
# then we want to return the text raw if it parses.
try:
- color = self.color_mod_class(text, filters=util.SRGB_SPACES)
+ color = self.color_mod_class(text, filters=util.CSS_SRGB_SPACES)
except Exception:
pass
if color is None:
@@ -93,9 +93,10 @@ def preview(self, text):
srgb = Color(color).convert("srgb")
preview_border = self.default_border
message = ""
- if not srgb.in_gamut():
- srgb.fit()
+ check_space = 'srgb' if srgb.space() not in util.SRGB_SPACES else srgb.space()
+ if not srgb.in_gamut(check_space):
message = '
* preview out of gamut'
+ srgb.fit(in_place=True)
preview = srgb.to_string(**util.HEX_NA)
preview_alpha = srgb.to_string(**util.HEX)
preview_border = self.default_border
diff --git a/ch_tool_contrast.py b/ch_tool_contrast.py
index b26f20f9..cb83ef85 100644
--- a/ch_tool_contrast.py
+++ b/ch_tool_contrast.py
@@ -56,7 +56,7 @@ def parse_color(string, start=0, second=False):
more = None
ratio = None
# First color
- color = Color.match(string, start=start, fullmatch=False, filters=util.SRGB_SPACES)
+ color = Color.match(string, start=start, fullmatch=False, filters=util.CSS_SRGB_SPACES)
if color:
start = color.end
if color.end != length:
@@ -178,7 +178,7 @@ def initial_text(self):
pass
if color is not None:
color = Color(color)
- if color.space() not in util.SRGB_SPACES:
+ if color.space() not in util.CSS_SRGB_SPACES:
color = color.convert("srgb", fit=True)
return color.to_string(**util.DEFAULT)
return ''
diff --git a/ch_tool_diff.py b/ch_tool_diff.py
index 7efa7cc1..929a5e2a 100644
--- a/ch_tool_diff.py
+++ b/ch_tool_diff.py
@@ -179,13 +179,12 @@ def preview(self, text):
orig = Color(color)
message = ""
color_string = ""
- if not orig.in_gamut('srgb'):
+ check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space()
+ if not orig.in_gamut(check_space):
orig = orig.fit("srgb")
message = '
* preview out of gamut'
color_string = "Gamut Mapped: {}
".format(orig.to_string())
- srgb = orig.convert('srgb', fit=True)
- else:
- srgb = orig.convert('srgb')
+ srgb = orig.convert('srgb', fit=True)
color_string += "Color: {}".format(color.to_string(**util.DEFAULT))
preview = srgb.to_string(**util.HEX_NA)
preview_alpha = srgb.to_string(**util.HEX)
diff --git a/ch_tool_edit.py b/ch_tool_edit.py
index ccb46613..22975749 100644
--- a/ch_tool_edit.py
+++ b/ch_tool_edit.py
@@ -215,13 +215,12 @@ def preview(self, text):
orig = Color(color)
message = ""
color_string = ""
- if not orig.in_gamut('srgb'):
+ check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space()
+ if not orig.in_gamut(check_space):
orig = orig.fit("srgb")
message = '
* preview out of gamut'
color_string = "Gamut Mapped: {}
".format(orig.to_string())
- srgb = orig.convert('srgb', fit=True)
- else:
- srgb = orig.convert('srgb')
+ srgb = orig.convert('srgb', fit=True)
color_string += "Color: {}".format(color.to_string(**util.DEFAULT))
preview = srgb.to_string(**util.HEX_NA)
preview_alpha = srgb.to_string(**util.HEX)
diff --git a/ch_util.py b/ch_util.py
index 40502036..6a165c6b 100644
--- a/ch_util.py
+++ b/ch_util.py
@@ -17,11 +17,12 @@
PALETTE_CONFIG = 'ColorHelper.palettes'
REQUIRED_COLOR_VERSION = (0, 1, 0, 'alpha', 19)
-UPDATE_COLORS = re.compile(RE_DEFAULT_MATCH.format(**{'color_space': r'[-a-z0-9]+'}))
+UPDATE_COLORS = re.compile(RE_DEFAULT_MATCH.format(**{'color_space': r'[-a-z0-9]+', 'channels': 3}))
COLOR_FMT_1_0 = (0, 1, 0, 'alpha', 19)
PALETTE_FMT = (1, 0)
-RE_COLOR_START = r"(?i)(?:\b(?&H)?(?P[0-9a-fA-F]{1,8})(?P&|\b)")
+
+ @classmethod
+ def match(cls, string: str, start: int = 0, fullmatch: bool = True):
+ """Match a color string."""
+
+ m = cls.MATCH.match(string, start)
+ if m is not None and (not fullmatch or m.end(0) == len(string)):
+ return cls.split_channels(m.group("color")), m.end(0)
+ return None, None
+
+ @classmethod
+ def translate_channel(cls, channel: int, value: str):
+ """Translate channel string."""
+
+ if -1 <= channel <= 2:
+ return _parse.norm_hex_channel(value)
+
+ @classmethod
+ def split_channels(cls, color: str):
+ """Split string into the appropriate channels."""
+
+ # convert `RR` / `GGRR` / `BBGGRR` to `AABBGGRR`
+ # consecutive leading 0s can be omitted and the alpha is 00 (opaque) by default
+ color = color.zfill(8)
+
+ # deal with `AABBGGRR`
+ if len(color) == 8:
+ return cls.null_adjust(
+ (
+ cls.translate_channel(0, "#" + color[6:]), # RR
+ cls.translate_channel(1, "#" + color[4:6]), # GG
+ cls.translate_channel(2, "#" + color[2:4]), # BB
+ ),
+ 1 - cls.translate_channel(-1, "#" + color[:2]), # AA
+ )
+
+ raise RuntimeError("Something is wrong in code logics.")
+
+ def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=True, **kwargs):
+ """Convert color to `&HAABBGGRR`."""
+
+ options = kwargs
+ a = util.no_nan(self.alpha)
+ show_alpha = alpha is not False and (alpha is True or a < 1.0)
+
+ template = "&H{:02x}{:02x}{:02x}{:02x}" if show_alpha else "&H{:02x}{:02x}{:02x}"
+ if options.get("upper"):
+ template = template.upper()
+
+ # Always fit hex
+ method = None if not isinstance(fit, str) else fit
+ coords = util.no_nan(parent.fit(method=method).coords())
+ if show_alpha:
+ value = template.format(
+ int(util.round_half_up(a * 255.0)),
+ int(util.round_half_up(coords[2] * 255.0)),
+ int(util.round_half_up(coords[1] * 255.0)),
+ int(util.round_half_up(coords[0] * 255.0)),
+ )
+ else:
+ value = template.format(
+ int(util.round_half_up(coords[2] * 255.0)),
+ int(util.round_half_up(coords[1] * 255.0)),
+ int(util.round_half_up(coords[0] * 255.0)),
+ )
+ return value
+
+
+class ColorAssABGR(Color):
+ """Color class for ASS `ABGR` colors."""
+
+ CS_MAP = copy.copy(Color.CS_MAP)
+ CS_MAP["srgb"] = AssABGR
diff --git a/custom/hex_0x.py b/custom/hex_0x.py
index 77dca83c..bfda1045 100644
--- a/custom/hex_0x.py
+++ b/custom/hex_0x.py
@@ -1,6 +1,6 @@
"""Custon color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`."""
from ..lib.coloraide import Color
-from ..lib.coloraide.css.spaces.srgb import SRGB
+from ..lib.coloraide.spaces.srgb.css import SRGB
from ..lib.coloraide.spaces import _parse
from ..lib.coloraide import util
import copy
diff --git a/custom/st_colormod.py b/custom/st_colormod.py
index c4a38c1e..5eb275b6 100644
--- a/custom/st_colormod.py
+++ b/custom/st_colormod.py
@@ -235,8 +235,7 @@ def _adjust(self, string, start=0):
if color is not None:
self._color = color
- if not self._color.in_gamut():
- self._color.fit(method="clip", in_place=True)
+ self._color.fit(method="clip", in_place=True)
while not done:
m = None
@@ -264,8 +263,7 @@ def _adjust(self, string, start=0):
else:
break
- if not self._color.in_gamut():
- self._color.fit(method="clip", in_place=True)
+ self._color.fit(method="clip", in_place=True)
else:
raise ValueError('Could not calculate base color')
except Exception:
diff --git a/custom/tmtheme.py b/custom/tmtheme.py
index 7a7e28a8..9e6983e5 100644
--- a/custom/tmtheme.py
+++ b/custom/tmtheme.py
@@ -1,6 +1,6 @@
"""Custom color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`."""
from ..lib.coloraide import Color
-from ..lib.coloraide.css.spaces.srgb import SRGB
+from ..lib.coloraide.spaces.srgb.css import SRGB
from ..lib.coloraide.spaces import _parse
from ..lib.coloraide import util
import copy
diff --git a/dependencies.json b/dependencies.json
index 7b7d0f68..44930629 100644
--- a/dependencies.json
+++ b/dependencies.json
@@ -1,13 +1,7 @@
{
"*": {
">=3124": [
- "pygments",
- "python-markdown",
- "mdpopups",
- "python-jinja2",
- "markupsafe",
- "pyyaml",
- "pymdownx"
+ "mdpopups"
]
}
}
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 9d6be82d..1db6e125 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -6,6 +6,7 @@ Changelog
ColorAide
ColorAide's
ColorHelper
+ColorHelper's
ColorMod
ColorPicker
Control's
@@ -20,6 +21,7 @@ Inline
JSON
KDE
LCH
+LSP
MERCHANTABILITY
MacOS
MkDocs
diff --git a/docs/src/markdown/faq.md b/docs/src/markdown/faq.md
index 76a1eaa8..e5538fce 100644
--- a/docs/src/markdown/faq.md
+++ b/docs/src/markdown/faq.md
@@ -1,5 +1,67 @@
# Frequently Asked Questions
+## Duplicate Colors?
+
+If you are seeing duplicate color previews, it may be because you have an LSP server installed that is injecting its own
+previews or some other package. While we can't provide an exhaustive list, we've provided a few known examples.
+
+Often, the duplicate colors may have a slightly different style, and when you click them, they will not not open the
+ColorHelper dialog.
+
+### LSP
+Two such examples are [`LSP-css`](https://packagecontrol.io/packages/LSP-css) and
+[`LSP-json`](https://packagecontrol.io/packages/LSP-json).
+
+The solution is to disable either ColorHelper or the color provider for the LSP package. If you are here, you probably
+enjoy ColorHelper's features and would prefer to disable the LSP package provider. If so, you can do the following.
+
+For `LSP-css`:
+
+In case it's `LSP-css`, you can disable the color boxes as follows: Run `Preferences: LSP-css Settings` from the
+Command Palette. Then add:
+
+```js
+ "disabled_capabilities": {
+ "colorProvider": true,
+ },
+```
+
+For `LSP-json`:
+
+In case it's `LSP-json`, you can disable the color boxes as follows: Run `Preferences: LSP-json Settings` from the
+Command Palette. Then add:
+
+```js
+{
+ "disabled_capabilities": {
+ // the trigger characters are too blunt, we'll specify auto_complete_selector manually
+ "completionProvider": {
+ "triggerCharacters": true
+ },
+ "colorProvider": true
+ }
+}
+```
+
+There may be other LSP packages. It is assumed the approach would be similar for all of them. Just make sure to check
+what the default disabled capabilities are and copy them over in addition to adding your own.
+
+### Advanced Substation Alpha (ASS)
+
+One package that comes with color previews out of the box is the [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)) package.
+
+You can disable their color previews by modifying it's settings with the following.
+
+Navigate to `Preferences -> Package Settings -> Advanced Substation Alpha (ASS) -> Settings`. Then add:
+
+```js
+{
+ // when to show a color phantom beside a color code?
+ // can be "never", "always" or "hover"
+ "show_color_phantom": "never",
+}
+```
+
## Hex Uppercase
> How do I output hex in uppercase?
diff --git a/docs/src/requirements.txt b/docs/src/requirements.txt
index 299e5aff..29804f9e 100644
--- a/docs/src/requirements.txt
+++ b/docs/src/requirements.txt
@@ -1,4 +1,4 @@
-mkdocs_pymdownx_material_extras>=1.2.2
+mkdocs_pymdownx_material_extras>=1.4
mkdocs-git-revision-date-localized-plugin
mkdocs-minify-plugin
pyspelling
diff --git a/lib/coloraide/__init__.py b/lib/coloraide/__init__.py
index 0eb5883c..861fa795 100644
--- a/lib/coloraide/__init__.py
+++ b/lib/coloraide/__init__.py
@@ -1,6 +1,6 @@
"""ColorAide Library."""
from .__meta__ import __version_info__, __version__ # noqa: F401
-from .css import Color
+from .color import Color
from .color.match import ColorMatch
from .color.interpolate import Piecewise, Lerp
from .util import NaN
diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py
index 901f0111..57cc1da6 100644
--- a/lib/coloraide/__meta__.py
+++ b/lib/coloraide/__meta__.py
@@ -188,5 +188,5 @@ def parse_version(ver):
return Version(major, minor, micro, release, pre, post, dev)
-__version_info__ = Version(0, 1, 0, "alpha", 19)
+__version_info__ = Version(0, 1, 0, "alpha", 24)
__version__ = __version_info__._get_canonical()
diff --git a/lib/coloraide/color/__init__.py b/lib/coloraide/color/__init__.py
index 277db793..119743c6 100644
--- a/lib/coloraide/color/__init__.py
+++ b/lib/coloraide/color/__init__.py
@@ -9,12 +9,12 @@
from . import match
from .. import util
from ..spaces.hsv import HSV
-from ..spaces.srgb import SRGB
+from ..spaces.srgb.css import SRGB
from ..spaces.srgb_linear import SRGBLinear
-from ..spaces.hsl import HSL
-from ..spaces.hwb import HWB
-from ..spaces.lab import Lab
-from ..spaces.lch import Lch
+from ..spaces.hsl.css import HSL
+from ..spaces.hwb.css import HWB
+from ..spaces.lab.css import Lab
+from ..spaces.lch.css import Lch
from ..spaces.lab_d65 import LabD65
from ..spaces.lch_d65 import LchD65
from ..spaces.display_p3 import DisplayP3
@@ -28,12 +28,14 @@
from ..spaces.jzazbz import Jzazbz
from ..spaces.jzczhz import JzCzhz
from ..spaces.ictcp import ICtCp
+from ..spaces.luv import Luv
+from ..spaces.lchuv import Lchuv
SUPPORTED = (
HSL, HWB, Lab, Lch, LabD65, LchD65, SRGB, SRGBLinear, HSV,
DisplayP3, A98RGB, ProPhotoRGB, Rec2020, XYZ, XYZD65,
- Oklab, Oklch, Jzazbz, JzCzhz, ICtCp
+ Oklab, Oklch, Jzazbz, JzCzhz, ICtCp, Luv, Lchuv
)
@@ -53,6 +55,7 @@ class Color(
PRECISION = util.DEF_PREC
FIT = util.DEF_FIT
DELTA_E = util.DEF_DELTA_E
+ CHROMATIC_ADAPTATION = 'bradford'
def __init__(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, **kwargs):
"""Initialize."""
diff --git a/lib/coloraide/color/convert.py b/lib/coloraide/color/convert/__init__.py
similarity index 82%
rename from lib/coloraide/color/convert.py
rename to lib/coloraide/color/convert/__init__.py
index 6c04c589..8d377bbf 100644
--- a/lib/coloraide/color/convert.py
+++ b/lib/coloraide/color/convert/__init__.py
@@ -1,10 +1,17 @@
"""Convert utilities."""
-from .. import util
+from ... import util
+from . import cat
class Convert:
"""Conversion methods."""
+ def chromatic_adaptation(self, w1, w2, xyz):
+ """Apply chromatic adaption to XYZ coordinates."""
+
+ method = self.CHROMATIC_ADAPTATION
+ return cat.chromatic_adaptation(w1, w2, xyz, method=method)
+
def convert(self, space, *, fit=False, in_place=False):
"""Convert to color space."""
@@ -28,19 +35,19 @@ def convert(self, space, *, fit=False, in_place=False):
coords = self.coords()
if hasattr(self._space, convert_to):
func = getattr(self._space, convert_to)
- coords = func(coords)
+ coords = func(self, coords)
elif hasattr(obj, convert_from):
func = getattr(obj, convert_from)
- coords = func(coords)
+ coords = func(self, coords)
# See if there is an XYZ route
if func is None and self.space() != space:
func = getattr(self._space, '_to_xyz')
- coords = func(coords)
+ coords = func(self, coords)
if space != 'xyz':
func = getattr(obj, '_from_xyz')
- coords = func(coords)
+ coords = func(self, coords)
return self.mutate(space, coords, self.alpha) if in_place else self.new(space, coords, self.alpha)
diff --git a/lib/coloraide/color/convert/cat.py b/lib/coloraide/color/convert/cat.py
new file mode 100644
index 00000000..5a5cfd68
--- /dev/null
+++ b/lib/coloraide/color/convert/cat.py
@@ -0,0 +1,115 @@
+"""Chromatic adaptation transforms."""
+from ... import util
+from ... spaces import WHITES
+from functools import lru_cache
+
+# Conversion matrices
+CATS = {
+ "bradford": [
+ # http://brucelindbloom.com/Eqn_ChromAdapt.html
+ # https://hrcak.srce.hr/file/95370
+ [0.8951000, 0.2664000, -0.1614000],
+ [-0.7502000, 1.7135000, 0.0367000],
+ [0.0389000, -0.0685000, 1.0296000]
+ ],
+ "von-kries": [
+ # http://brucelindbloom.com/Eqn_ChromAdapt.html
+ # https://hrcak.srce.hr/file/95370
+ [0.4002400, 0.7076000, -0.0808100],
+ [-0.2263000, 1.1653200, 0.0457000],
+ [0.0000000, 0.0000000, 0.9182200]
+ ],
+ "xyz-scaling": [
+ # http://brucelindbloom.com/Eqn_ChromAdapt.html
+ # https://hrcak.srce.hr/file/95370
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1]
+ ],
+ "cat02": [
+ # https://en.wikipedia.org/wiki/CIECAM02#CAT02
+ [0.7328000, 0.4296000, -0.1624000],
+ [-0.7036000, 1.6975000, 0.0061000],
+ [0.0030000, 0.0136000, 0.9834000]
+ ],
+ "cmccat97": [
+ # https://hrcak.srce.hr/file/95370
+ [0.8951000, -0.7502000, 0.0389000],
+ [0.2664000, 1.7135000, 0.0685000],
+ [-0.1614000, 0.0367000, 1.0296000],
+ ],
+ "sharp": [
+ # https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf
+ [1.2694000, -0.0988000, -0.1706000],
+ [-0.8364000, 1.8006000, 0.0357000],
+ [0.0297000, -0.0315000, 1.0018000]
+ ],
+ 'cmccat2000': [
+ # https://hrcak.srce.hr/file/95370
+ # https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf
+ [0.7982000, 0.3389000, -0.1371000],
+ [-0.5918000, 1.5512000, 0.0406000],
+ [0.0008000, 0.0239000, 0.9753000]
+ ]
+}
+
+
+@lru_cache(maxsize=20)
+def calc_adaptation_matrices(w1, w2, method='bradford'):
+ """
+ Get the von Kries based adaptation matrix based on the method and illuminants.
+
+ Since these calculated matrices are cached, this greatly reduces
+ performance hit as the initial matrices only have to be calculated
+ once for a given pair of white points and CAT.
+
+ Granted, we are currently, capped at 20 in the cache, but the average user
+ isn't going to be swapping between over 20 methods and white points in a
+ short period of time. We could always increase the cache if necessary.
+ """
+
+ try:
+ m = CATS[method]
+ except KeyError: # pragma: no cover
+ raise ValueError('Unknown chromatic adaptation method encountered: {}'.format(method))
+ mi = util.inv(m)
+
+ try:
+ first = util.dot(m, WHITES[w1])
+ except KeyError: # pragma: no cover
+ raise ValueError('Unknown white point encountered: {}'.format(w1))
+
+ try:
+ second = util.dot(m, WHITES[w2])
+ except KeyError: # pragma: no cover
+ raise ValueError('Unknown white point encountered: {}'.format(w2))
+
+ m2 = util.diag(util.divide(first, second))
+ adapt = util.dot(mi, util.dot(m2, m))
+
+ return adapt, util.inv(adapt)
+
+
+def get_adaptation_matrix(w1, w2, method):
+ """
+ Get the appropriate matrix for chromatic adaptation.
+
+ If the required matrices are not in the cache, they will be calculated.
+ Since white points are sorted by name, regardless of the requested
+ conversion direction, the same matrices will be retrieved from the cache.
+ """
+
+ a, b = sorted([w1, w2])
+ m, mi = calc_adaptation_matrices(a, b, method)
+ return mi if a != w2 else m
+
+
+def chromatic_adaptation(w1, w2, xyz, method='bradford'):
+ """Chromatic adaptation."""
+
+ if w1 == w2:
+ # No adaptation is needed if the white points are identical.
+ return xyz
+ else:
+ # Get the appropriate chromatic adaptation matrix and apply.
+ return util.dot(get_adaptation_matrix(w1, w2, method), xyz)
diff --git a/lib/coloraide/color/distance/delta_e_itp.py b/lib/coloraide/color/distance/delta_e_itp.py
index 5fb8128f..101912be 100644
--- a/lib/coloraide/color/distance/delta_e_itp.py
+++ b/lib/coloraide/color/distance/delta_e_itp.py
@@ -7,13 +7,7 @@
def distance(color1, color2, scalar=720, **kwargs):
- """
- Delta E 1976 color distance formula.
-
- http://www.brucelindbloom.com/index.html?Eqn_DeltaE_CIE76.html
-
- Basically this is Euclidean distance in the Lab space.
- """
+ """Delta E ITP color distance formula."""
i1, t1, p1 = color1.convert('ictcp').coords()
i2, t2, p2 = color2.convert('ictcp').coords()
diff --git a/lib/coloraide/color/gamut/__init__.py b/lib/coloraide/color/gamut/__init__.py
index 009c5ac5..96d4336b 100644
--- a/lib/coloraide/color/gamut/__init__.py
+++ b/lib/coloraide/color/gamut/__init__.py
@@ -52,7 +52,9 @@ def fit(self, space=None, *, method=None, in_place=False):
# If we are perfectly in gamut, don't waste time fitting, just normalize hues.
# If out of gamut, apply mapping/clipping/etc.
- c._space._coords = norm_angles(c) if c.in_gamut(tolerance=0.0) else func(self.clone(), c)
+ c._space._coords, c._space._alpha = (
+ c._space.null_adjust(norm_angles(c) if c.in_gamut(tolerance=0.0) else func(c), self.alpha)
+ )
# Adjust "this" color
return this.update(c)
diff --git a/lib/coloraide/color/gamut/clip.py b/lib/coloraide/color/gamut/clip.py
index 340bdbef..a1f50994 100644
--- a/lib/coloraide/color/gamut/clip.py
+++ b/lib/coloraide/color/gamut/clip.py
@@ -3,7 +3,7 @@
from ... spaces import Angle, GamutBound
-def fit(base, color):
+def fit(color):
"""Gamut clipping."""
channels = util.no_nan(color.coords())
diff --git a/lib/coloraide/color/gamut/lch_chroma.py b/lib/coloraide/color/gamut/lch_chroma.py
index 234472c1..3a472177 100644
--- a/lib/coloraide/color/gamut/lch_chroma.py
+++ b/lib/coloraide/color/gamut/lch_chroma.py
@@ -1,13 +1,16 @@
"""Fit by compressing chroma in Lch."""
+EPSILON = 0.001
-def fit(base, color):
+
+def fit(color):
"""
Gamut mapping via chroma Lch.
- Algorithm comes from https://colorjs.io/docs/gamut-mapping.html.
+ Algorithm originally came from https://colorjs.io/docs/gamut-mapping.html.
+ Some things have been optimized and fixed though to better perform as intended.
- The idea is to hold hue and lightness constant and decrease lightness until
+ The idea is to hold hue and lightness constant and decrease chroma until
color comes under gamut.
We'll use a binary search and at after each stage, we will clip the color
@@ -19,41 +22,41 @@ def fit(base, color):
License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json)
"""
- # Compare clipped against original to
- # judge how far we are off with the worst case fitting
space = color.space()
- clipped = color.clone()
- clipped.fit(space=space, method="clip", in_place=True)
- base_error = base.delta_e(clipped, method="2000")
-
- if base_error > 2.3:
- threshold = .001
- # Compare mapped against desired space
- mapcolor = color.convert("lch")
- error = color.delta_e(mapcolor, method="2000")
- low = 0.0
- high = mapcolor.chroma
-
- # Adjust chroma (using binary search).
- # This helps preserve the color more (in most cases).
- # After each adjustment, see if clipping gets us close enough.
- while (high - low) > threshold and error < base_error:
- clipped = mapcolor.clone()
- clipped.fit(space, method="clip", in_place=True)
- delta = mapcolor.delta_e(clipped, method="2000")
- error = color.delta_e(mapcolor, method="2000")
- if delta - 2 < threshold:
- low = mapcolor.chroma
- else:
- if abs(delta - 2) < threshold: # pragma: no cover
- # Can this occur?
- break
- high = mapcolor.chroma
- mapcolor.chroma = (high + low) / 2
- # Trim off noise allowed by our tolerance
- color.update(mapcolor)
- color.fit(space, method="clip", in_place=True)
- else:
- # We are close enough that we should just clip.
- color.update(clipped)
- return color.coords()
+
+ # If flooring chroma doesn't work, just clip the floored color
+ # because there is no optimal compression.
+ floor = color.clone().set('lch.chroma', 0)
+ if not floor.in_gamut(tolerance=0):
+ return floor.fit(method="clip").coords()
+
+ # If we are already below the JND, just clip as we will gain no
+ # noticeable difference moving forward.
+ clipped = color.fit(method="clip")
+ if color.delta_e(clipped, method="2000") < 2:
+ return clipped.coords()
+
+ # Convert to CIELCH and set our boundaries
+ mapcolor = color.convert("lch")
+ low = 0.0
+ high = mapcolor.chroma
+
+ # Adjust chroma (using binary search).
+ # This helps preserve the other attributes of the color.
+ # Each time we compare the compressed color to it's clipped form
+ # to see how close we are. A delta less than 2 is our target.
+ while (high - low) > EPSILON:
+ delta = mapcolor.delta_e(
+ mapcolor.fit(space, method="clip"),
+ method="2000"
+ )
+
+ if (delta - 2) < EPSILON:
+ low = mapcolor.chroma
+ else:
+ high = mapcolor.chroma
+
+ mapcolor.chroma = (high + low) * 0.5
+
+ # Update and clip off noise
+ return color.update(mapcolor).fit(space, method="clip", in_place=True).coords()
diff --git a/lib/coloraide/color/interpolate.py b/lib/coloraide/color/interpolate.py
index 536cf7d4..61adb72f 100644
--- a/lib/coloraide/color/interpolate.py
+++ b/lib/coloraide/color/interpolate.py
@@ -1,13 +1,14 @@
"""
Interpolation methods.
-A lot of code was ported and or adapted from the https://colorjs.io project. Particularly
-the `interpolate` method and the functions built on top of it, such as `mix` and `steps`.
-
-While we deviate in some ways, a lot of it, at the time of this comment, are a direct port.
+Originally, the base code for `interpolate`, `mix` and `steps` was ported from the
+https://colorjs.io project. Since that time, there has been significant modifications
+that add additional features etc. The base logic though is attributed to the original
+authors.
In general, the logic mimics in many ways the `color-mix` function as outlined in the Level 5
-color draft (Oct 2020), but the approach was modeled directly off of the work done in color.js.
+color draft (Oct 2020), but the initial approach was modeled directly off of the work done in
+color.js.
---
Original Authors: Lea Verou, Chris Lilley
License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json)
@@ -56,6 +57,15 @@ def __init__(self):
def __call__(self, p):
"""Call the interpolator."""
+ @abstractmethod
+ def get_delta(self):
+ """Initialize."""
+
+ def steps(self, steps=2, max_steps=1000, max_delta_e=0):
+ """Steps."""
+
+ return color_steps(self, steps, max_steps, max_delta_e)
+
class InterpolateSingle(Interpolator):
"""Interpolate a single range of two colors."""
@@ -72,6 +82,11 @@ def __init__(self, channels1, channels2, names, create, progress, space, outspac
self.outspace = outspace
self.premultiplied = premultiplied
+ def get_delta(self):
+ """Get the delta."""
+
+ return self.create(self.space, self.channels1).delta_e(self.create(self.space, self.channels2))
+
def __call__(self, p):
"""Run through the coordinates and run the interpolation on them."""
@@ -111,6 +126,11 @@ def __init__(self, stops, interpolators):
self.stops = stops
self.interpolators = interpolators
+ def get_delta(self):
+ """Get the delta total."""
+
+ return [i.get_delta() for i in self.interpolators]
+
def __call__(self, p):
"""Interpolate."""
@@ -287,6 +307,58 @@ def adjust_hues(color1, color2, hue):
color2.set(name, c2)
+def color_steps(interpolator, steps=2, max_steps=1000, max_delta_e=0):
+ """Color steps."""
+
+ if max_delta_e <= 0:
+ actual_steps = steps
+ else:
+ actual_steps = 0
+ deltas = interpolator.get_delta()
+ if not isinstance(deltas, Sequence):
+ deltas = [deltas]
+ actual_steps = sum([d / max_delta_e for d in deltas])
+ actual_steps = max(steps, math.ceil(actual_steps) + 1)
+
+ if max_steps is not None:
+ actual_steps = min(actual_steps, max_steps)
+
+ ret = []
+ if actual_steps == 1:
+ ret = [{"p": 0.5, "color": interpolator(0.5)}]
+ else:
+ step = 1 / (actual_steps - 1)
+ for i in range(actual_steps):
+ p = i * step
+ ret.append({'p': p, 'color': interpolator(p)})
+
+ # Iterate over all the stops inserting stops in between if all colors
+ # if we have any two colors with a max delta greater than what was requested.
+ # We inject between every stop to ensure the midpoint does not shift.
+ if max_delta_e > 0:
+ # Initial check to see if we need to insert more stops
+ m_delta = 0
+ for i, entry in enumerate(ret):
+ if i == 0:
+ continue
+ m_delta = max(m_delta, entry['color'].delta_e(ret[i - 1]['color']))
+
+ while m_delta > max_delta_e:
+ # Inject stops while measuring again to see if it was sufficient
+ m_delta = 0
+ i = 1
+ while i < len(ret) and len(ret) < max_steps:
+ prev = ret[i - 1]
+ cur = ret[i]
+ p = (cur['p'] + prev['p']) / 2
+ color = interpolator(p)
+ m_delta = max(m_delta, color.delta_e(prev['color']), color.delta_e(cur['color']))
+ ret.insert(i, {'p': p, 'color': color})
+ i += 2
+
+ return [i['color'] for i in ret]
+
+
def color_piecewise_lerp(pw, space, out_space, progress, hue, premultiplied):
"""Piecewise Interpolation."""
@@ -387,66 +459,7 @@ def steps(self, color, *, steps=2, max_steps=1000, max_delta_e=0, **interpolate_
Default delta E method used is delta E 76.
"""
- interpolator = self.interpolate(color, **interpolate_args)
-
- if isinstance(color, Piecewise):
- color = self._handle_color_input(color.color)
- elif not isinstance(color, str) and isinstance(color, Sequence):
- color = [self._handle_color_input(c.color if isinstance(c, Piecewise) else c) for c in color]
-
- color = self._handle_color_input(color, sequence=True)
-
- if not isinstance(color, Sequence) and max_delta_e > 0:
- color = [self, color]
-
- if max_delta_e <= 0:
- actual_steps = steps
- else:
- actual_steps = 0
- current = self
- for c in color:
- total_delta = current.delta_e(c)
- actual_steps += total_delta / max_delta_e
- current = c
- actual_steps = max(steps, math.ceil(actual_steps) + 1)
-
- if max_steps is not None:
- actual_steps = min(actual_steps, max_steps)
-
- ret = []
- if actual_steps == 1:
- ret = [{"p": 0.5, "color": interpolator(0.5)}]
- else:
- step = 1 / (actual_steps - 1)
- for i in range(actual_steps):
- p = i * step
- ret.append({'p': p, 'color': interpolator(p)})
-
- # Iterate over all the stops inserting stops in between if all colors
- # if we have any two colors with a max delta greater than what was requested.
- # We inject between every stop to ensure the midpoint does not shift.
- if max_delta_e > 0:
- # Initial check to see if we need to insert more stops
- m_delta = 0
- for i, entry in enumerate(ret):
- if i == 0:
- continue
- m_delta = max(m_delta, entry['color'].delta_e(ret[i - 1]['color']))
-
- while m_delta > max_delta_e:
- # Inject stops while measuring again to see if it was sufficient
- m_delta = 0
- i = 1
- while i < len(ret) and len(ret) < max_steps:
- prev = ret[i - 1]
- cur = ret[i]
- p = (cur['p'] + prev['p']) / 2
- color = interpolator(p)
- m_delta = max(m_delta, color.delta_e(prev['color']), color.delta_e(cur['color']))
- ret.insert(i, {'p': p, 'color': color})
- i += 2
-
- return [i['color'] for i in ret]
+ return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e)
def mix(self, color, percent=util.DEF_MIX, *, in_place=False, **interpolate_args):
"""
diff --git a/lib/coloraide/css/__init__.py b/lib/coloraide/css/__init__.py
deleted file mode 100644
index 765dc2fe..00000000
--- a/lib/coloraide/css/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""CSS Color object."""
-from .spaces.srgb import SRGB
-from .spaces.hsl import HSL
-from .spaces.hwb import HWB
-from .spaces.lab import Lab
-from .spaces.lch import Lch
-from ..color import Color as GenericColor
-
-CSS_OVERRIDES = (HSL, HWB, Lab, Lch, SRGB)
-
-
-class Color(GenericColor):
- """Color wrapper class."""
-
- CS_MAP = {key: value for key, value in GenericColor.CS_MAP.items()}
- for color in CSS_OVERRIDES:
- CS_MAP[color.space()] = color
diff --git a/lib/coloraide/css/spaces/__init__.py b/lib/coloraide/css/spaces/__init__.py
deleted file mode 100644
index b6107843..00000000
--- a/lib/coloraide/css/spaces/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""CSS space overrides to support CSS syntax."""
diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py
index 247a20be..bebf1568 100644
--- a/lib/coloraide/spaces/__init__.py
+++ b/lib/coloraide/spaces/__init__.py
@@ -2,7 +2,6 @@
from abc import ABCMeta
from .. import util
from . import _parse
-from . import _cat
# Technically this form can handle any number of channels as long as any
# extra are thrown away. We only support 6 currently. If we ever support
@@ -10,12 +9,26 @@
RE_DEFAULT_MATCH = r"""(?xi)
color\(\s*
(?:({{color_space}})\s+)?
-((?:{percent}|{float})(?:{space}(?:{percent}|{float})){{{{,6}}}}(?:{slash}(?:{percent}|{float}))?)
+((?:{percent}|{float})(?:{space}(?:{percent}|{float})){{{{,{{channels:d}}}}}}(?:{slash}(?:{percent}|{float}))?)
\s*\)
""".format(
**_parse.COLOR_PARTS
)
+WHITES = {
+ "A": [1.09850, 1.00000, 0.35585],
+ "B": [0.99072, 1.00000, 0.85223],
+ "C": [0.98074, 1.00000, 1.18232],
+ "D50": [0.96422, 1.00000, 0.82521],
+ "D55": [0.95682, 1.00000, 0.92149],
+ "D65": [0.95047, 1.00000, 1.08883],
+ "D75": [0.94972, 1.00000, 1.22638],
+ "E": [1.00000, 1.00000, 1.00000],
+ "F2": [0.99186, 1.00000, 0.67393],
+ "F7": [0.95041, 1.00000, 1.08747],
+ "F11": [1.00962, 1.00000, 0.64350]
+}
+
class Angle(float):
"""Angle type."""
@@ -25,6 +38,10 @@ class Percent(float):
"""Percent type."""
+class OptionalPercent(float):
+ """Optional percent type."""
+
+
class GamutBound(tuple):
"""Bounded gamut value."""
@@ -49,6 +66,8 @@ class Space(
# Color space name
SPACE = ""
+ # Serialized name
+ SERIALIZE = None
# Number of channels
NUM_COLOR_CHANNELS = 3
# Channel names
@@ -69,7 +88,7 @@ class Space(
# space, the values can be greatly out of specification (looking at you HSL).
GAMUT_CHECK = None
# White point
- WHITE = _cat.WHITES["D50"]
+ WHITE = "D50"
def __init__(self, color, alpha=None):
"""Initialize."""
@@ -106,7 +125,7 @@ def __repr__(self):
values.append(value)
return 'color({} {} / {})'.format(
- self.space(),
+ self._serialize()[0],
' '.join(values),
util.fmt_float(util.no_nan(self.alpha), util.DEF_PREC)
)
@@ -131,11 +150,17 @@ def space(cls):
return cls.SPACE
+ @classmethod
+ def _serialize(cls):
+ """Get the serialized name."""
+
+ return (cls.space(),) if cls.SERIALIZE is None else cls.SERIALIZE
+
@classmethod
def white(cls):
"""Get the white color for this color space."""
- return cls.WHITE
+ return WHITES[cls.WHITE]
@property
def alpha(self):
@@ -189,9 +214,11 @@ def to_string(
values.append(value)
if alpha:
- return template.format(self.space(), ' '.join(values), util.fmt_float(a, max(precision, util.DEF_PREC)))
+ return template.format(
+ self._serialize()[0], ' '.join(values), util.fmt_float(a, max(precision, util.DEF_PREC))
+ )
else:
- return template.format(self.space(), ' '.join(values))
+ return template.format(self._serialize()[0], ' '.join(values))
@classmethod
def null_adjust(cls, coords, alpha):
@@ -207,7 +234,7 @@ def match(cls, string, start=0, fullmatch=True):
if (
m is not None and
(
- (m.group(1) and m.group(1).lower() == cls.space())
+ (m.group(1) and m.group(1).lower() in cls._serialize())
) and (not fullmatch or m.end(0) == len(string))
):
@@ -222,9 +249,14 @@ def match(cls, string, start=0, fullmatch=True):
for i, c in enumerate(_parse.RE_CHAN_SPLIT.split(split[0]), 0):
if c and i < cls.NUM_COLOR_CHANNELS:
is_percent = isinstance(cls.RANGE[i][0], Percent)
- if is_percent and not c.endswith('%'):
+ is_optional_percent = isinstance(cls.RANGE[i][0], OptionalPercent)
+ has_percent = c.endswith('%')
+ if is_percent and not has_percent:
# We have an invalid percentage channel
return None, None
+ elif (not is_percent and not is_optional_percent) and has_percent:
+ # Percents are not allowed for this channel.
+ return None, None
channels.append(_parse.norm_color_channel(c, not is_percent))
# Missing channels are filled with zeros
diff --git a/lib/coloraide/spaces/_cat.py b/lib/coloraide/spaces/_cat.py
deleted file mode 100644
index 2f73e053..00000000
--- a/lib/coloraide/spaces/_cat.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Chromatic adaption transforms."""
-from .. import util
-
-white_d65 = [0.95047, 1.00000, 1.08883]
-white_d50 = [0.96422, 1.00000, 0.82521]
-
-WHITES = {
- "D50": white_d50,
- "D65": white_d65
-}
-
-
-def d50_to_d65(xyz):
- """Bradford chromatic adaptation from D50 to D65."""
-
- m = [
- [0.9555766150331048, -0.0230393447160789, 0.0631636322498012],
- [-0.0282895442435549, 1.0099416173711144, 0.0210076549961903],
- [0.0122981657172073, -0.0204830252324494, 1.3299098264497566]
- ]
-
- return util.dot(m, xyz)
-
-
-def d65_to_d50(xyz):
- """Bradford chromatic adaptation from D65 to D50."""
-
- m = [
- [1.0478112436606313, 0.022886602481693, -0.0501269759685289],
- [0.0295423982905749, 0.9904844034904393, -0.0170490956289616],
- [-0.0092344897233095, 0.0150436167934987, 0.752131635474606]
- ]
-
- return util.dot(m, xyz)
-
-
-def chromatic_adaption(w1, w2, xyz):
- """Chromatic adaption."""
-
- if w1 == w2:
- return xyz
- elif w1 == WHITES["D50"] and w2 == WHITES["D65"]:
- return d50_to_d65(xyz)
- elif w1 == WHITES["D65"] and w2 == WHITES["D50"]:
- return d65_to_d50(xyz)
- else: # pragma: no cover
- # Should only occur internally if we are doing something wrong.
- raise ValueError('Unknown white point encountered: {} -> {}'.format(str(w1), str(w2)))
diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py
index 99cf0b91..e7adefff 100644
--- a/lib/coloraide/spaces/a98_rgb.py
+++ b/lib/coloraide/spaces/a98_rgb.py
@@ -1,7 +1,6 @@
"""A98 RGB color class."""
from ..spaces import RE_DEFAULT_MATCH
-from ..spaces import _cat
-from .srgb import SRGB
+from .srgb.base import SRGB
from .xyz import XYZ
from .. import util
import re
@@ -18,9 +17,9 @@ def lin_a98rgb_to_xyz(rgb):
"""
m = [
- [0.5767308871981476, 0.1855539507112141, 0.1881851620906385],
- [0.2973768637115448, 0.6273490714522, 0.0752740648362554],
- [0.0270342603374131, 0.0706872193185578, 0.9911085203440293]
+ [0.5767308871981476, 0.18555395071121408, 0.18818516209063846],
+ [0.2973768637115448, 0.6273490714522, 0.07527406483625539],
+ [0.027034260337413137, 0.0706872193185578, 0.9911085203440293]
]
return util.dot(m, rgb)
@@ -30,9 +29,9 @@ def xyz_to_lin_a98rgb(xyz):
"""Convert XYZ to linear-light a98-rgb."""
m = [
- [2.04136897926008, -0.5649463871751959, -0.3446943843778484],
- [-0.9692660305051867, 1.8760108454466937, 0.0415560175303498],
- [0.0134473872161703, -0.1183897423541256, 1.0154095719504166]
+ [2.04136897926008, -0.5649463871751959, -0.34469438437784844],
+ [-0.9692660305051867, 1.8760108454466937, 0.04155601753034983],
+ [0.013447387216170269, -0.11838974235412557, 1.0154095719504166]
]
return util.dot(m, xyz)
@@ -54,17 +53,17 @@ class A98RGB(SRGB):
"""A98 RGB class."""
SPACE = "a98-rgb"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3))
+ WHITE = "D65"
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_a98rgb_to_xyz(lin_a98rgb(rgb)))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_a98rgb_to_xyz(lin_a98rgb(rgb)))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return gam_a98rgb(xyz_to_lin_a98rgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)))
+ return gam_a98rgb(xyz_to_lin_a98rgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)))
diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py
index c3491382..d0f4d519 100644
--- a/lib/coloraide/spaces/display_p3.py
+++ b/lib/coloraide/spaces/display_p3.py
@@ -1,7 +1,6 @@
"""Display-p3 color class."""
from ..spaces import RE_DEFAULT_MATCH
-from ..spaces import _cat
-from .srgb import SRGB, lin_srgb, gam_srgb
+from .srgb.base import SRGB, lin_srgb, gam_srgb
from .xyz import XYZ
from .. import util
import re
@@ -28,9 +27,9 @@ def xyz_to_lin_p3(xyz):
"""Convert XYZ to linear-light P3."""
m = [
- [2.493180755328967, -0.9312655254971399, -0.4026597237588819],
- [-0.8295031158210786, 1.7626941211197922, 0.0236250887417396],
- [0.0358536257800717, -0.0761889547826522, 0.9570926215180221]
+ [2.493180755328967, -0.9312655254971399, -0.40265972375888187],
+ [-0.8295031158210786, 1.7626941211197922, 0.02362508874173957],
+ [0.035853625780071716, -0.07618895478265224, 0.9570926215180221]
]
return util.dot(m, xyz)
@@ -52,17 +51,17 @@ class DisplayP3(SRGB):
"""Display-p3 class."""
SPACE = "display-p3"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3))
+ WHITE = "D65"
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_p3_to_xyz(lin_p3(rgb)))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_p3_to_xyz(lin_p3(rgb)))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return gam_p3(xyz_to_lin_p3(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)))
+ return gam_p3(xyz_to_lin_p3(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)))
diff --git a/lib/coloraide/spaces/hsl/__init__.py b/lib/coloraide/spaces/hsl/__init__.py
new file mode 100644
index 00000000..f9612ca1
--- /dev/null
+++ b/lib/coloraide/spaces/hsl/__init__.py
@@ -0,0 +1 @@
+"""HSL color class."""
diff --git a/lib/coloraide/spaces/hsl.py b/lib/coloraide/spaces/hsl/base.py
similarity index 82%
rename from lib/coloraide/spaces/hsl.py
rename to lib/coloraide/spaces/hsl/base.py
index 4e57aa0e..93bab7e4 100644
--- a/lib/coloraide/spaces/hsl.py
+++ b/lib/coloraide/spaces/hsl/base.py
@@ -1,8 +1,7 @@
"""HSL class."""
-from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical
-from . import _cat
-from .srgb import SRGB
-from .. import util
+from ...spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical
+from ..srgb.base import SRGB
+from ... import util
import re
@@ -58,9 +57,11 @@ class HSL(Cylindrical, Space):
"""HSL class."""
SPACE = "hsl"
+ SERIALIZE = ("--hsl",)
CHANNEL_NAMES = ("hue", "saturation", "lightness", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
+ GAMUT_CHECK = "srgb"
RANGE = (
GamutBound([Angle(0.0), Angle(360.0)]),
@@ -113,25 +114,25 @@ def null_adjust(cls, coords, alpha):
return coords, alpha
@classmethod
- def _to_srgb(cls, hsl):
+ def _to_srgb(cls, parent, hsl):
"""To sRGB."""
return hsl_to_srgb(hsl)
@classmethod
- def _from_srgb(cls, rgb):
+ def _from_srgb(cls, parent, rgb):
"""From sRGB."""
return srgb_to_hsl(rgb)
@classmethod
- def _to_xyz(cls, hsl):
+ def _to_xyz(cls, parent, hsl):
"""To XYZ."""
- return SRGB._to_xyz(cls._to_srgb(hsl))
+ return SRGB._to_xyz(parent, cls._to_srgb(parent, hsl))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_srgb(SRGB._from_xyz(xyz))
+ return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz))
diff --git a/lib/coloraide/css/spaces/hsl.py b/lib/coloraide/spaces/hsl/css.py
similarity index 98%
rename from lib/coloraide/css/spaces/hsl.py
rename to lib/coloraide/spaces/hsl/css.py
index 72464416..41e67726 100644
--- a/lib/coloraide/css/spaces/hsl.py
+++ b/lib/coloraide/spaces/hsl/css.py
@@ -1,11 +1,11 @@
"""HSL class."""
import re
-from ...spaces import hsl as generic
+from . import base
from ...spaces import _parse
from ... import util
-class HSL(generic.HSL):
+class HSL(base.HSL):
"""HSL class."""
DEF_VALUE = "hsl(0 0% 0% / 1)"
diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py
index 0a60441e..486b22d6 100644
--- a/lib/coloraide/spaces/hsv.py
+++ b/lib/coloraide/spaces/hsv.py
@@ -1,8 +1,7 @@
"""HSV class."""
from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical
-from . import _cat
-from .srgb import SRGB
-from .hsl import HSL
+from .srgb.base import SRGB
+from .hsl.base import HSL
from .. import util
import re
@@ -54,10 +53,11 @@ class HSV(Cylindrical, Space):
"""HSL class."""
SPACE = "hsv"
+ SERIALIZE = ("--hsv",)
CHANNEL_NAMES = ("hue", "saturation", "value", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- GAMUT_CHECK = "hsl"
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ GAMUT_CHECK = "srgb"
+ WHITE = "D65"
RANGE = (
GamutBound([Angle(0.0), Angle(360.0)]),
@@ -110,37 +110,37 @@ def null_adjust(cls, coords, alpha):
return coords, alpha
@classmethod
- def _to_xyz(cls, hsv):
+ def _to_xyz(cls, parent, hsv):
"""To XYZ."""
- return SRGB._to_xyz(cls._to_srgb(hsv))
+ return SRGB._to_xyz(parent, cls._to_srgb(parent, hsv))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_srgb(SRGB._from_xyz(xyz))
+ return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz))
@classmethod
- def _to_hsl(cls, hsv):
+ def _to_hsl(cls, parent, hsv):
"""To HSL."""
return hsv_to_hsl(hsv)
@classmethod
- def _from_hsl(cls, hsl):
+ def _from_hsl(cls, parent, hsl):
"""From HSL."""
return hsl_to_hsv(hsl)
@classmethod
- def _to_srgb(cls, hsv):
+ def _to_srgb(cls, parent, hsv):
"""To sRGB."""
- return HSL._to_srgb(cls._to_hsl(hsv))
+ return HSL._to_srgb(parent, cls._to_hsl(parent, hsv))
@classmethod
- def _from_srgb(cls, rgb):
+ def _from_srgb(cls, parent, rgb):
"""From sRGB."""
- return cls._from_hsl(HSL._from_srgb(rgb))
+ return cls._from_hsl(parent, HSL._from_srgb(parent, rgb))
diff --git a/lib/coloraide/spaces/hwb/__init__.py b/lib/coloraide/spaces/hwb/__init__.py
new file mode 100644
index 00000000..e6d67521
--- /dev/null
+++ b/lib/coloraide/spaces/hwb/__init__.py
@@ -0,0 +1 @@
+"""HWB color class."""
diff --git a/lib/coloraide/spaces/hwb.py b/lib/coloraide/spaces/hwb/base.py
similarity index 70%
rename from lib/coloraide/spaces/hwb.py
rename to lib/coloraide/spaces/hwb/base.py
index 5c5f1f94..6ccdedbc 100644
--- a/lib/coloraide/spaces/hwb.py
+++ b/lib/coloraide/spaces/hwb/base.py
@@ -1,9 +1,8 @@
"""HWB class."""
-from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical
-from . import _cat
-from .srgb import SRGB
-from .hsv import HSV
-from .. import util
+from ...spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical
+from ..srgb.base import SRGB
+from ..hsv import HSV
+from ... import util
import re
@@ -41,10 +40,11 @@ class HWB(Cylindrical, Space):
"""HWB class."""
SPACE = "hwb"
+ SERIALIZE = ("--hwb",)
CHANNEL_NAMES = ("hue", "whiteness", "blackness", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- GAMUT_CHECK = "hsl"
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ GAMUT_CHECK = "srgb"
+ WHITE = "D65"
RANGE = (
GamutBound([Angle(0.0), Angle(360.0)]),
@@ -97,49 +97,49 @@ def null_adjust(cls, coords, alpha):
return coords, alpha
@classmethod
- def _to_xyz(cls, hwb):
+ def _to_xyz(cls, parent, hwb):
"""SRGB to XYZ."""
- return SRGB._to_xyz(cls._to_srgb(hwb))
+ return SRGB._to_xyz(parent, cls._to_srgb(parent, hwb))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""XYZ to SRGB."""
- return cls._from_srgb(SRGB._from_xyz(xyz))
+ return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz))
@classmethod
- def _to_srgb(cls, hwb):
+ def _to_srgb(cls, parent, hwb):
"""To sRGB."""
- return HSV._to_srgb(cls._to_hsv(hwb))
+ return HSV._to_srgb(parent, cls._to_hsv(parent, hwb))
@classmethod
- def _from_srgb(cls, srgb):
+ def _from_srgb(cls, parent, srgb):
"""From sRGB."""
- return cls._from_hsv(HSV._from_srgb(srgb))
+ return cls._from_hsv(parent, HSV._from_srgb(parent, srgb))
@classmethod
- def _to_hsl(cls, hwb):
+ def _to_hsl(cls, parent, hwb):
"""To HSL."""
- return HSV._to_hsl(hwb_to_hsv(hwb))
+ return HSV._to_hsl(parent, hwb_to_hsv(hwb))
@classmethod
- def _from_hsl(cls, hsl):
+ def _from_hsl(cls, parent, hsl):
"""From HSL."""
- return hsv_to_hwb(HSV._from_hsl(hsl))
+ return hsv_to_hwb(HSV._from_hsl(parent, hsl))
@classmethod
- def _to_hsv(cls, hwb):
+ def _to_hsv(cls, parent, hwb):
"""To HSV."""
return hwb_to_hsv(hwb)
@classmethod
- def _from_hsv(cls, hsv):
+ def _from_hsv(cls, parent, hsv):
"""From HSV."""
return hsv_to_hwb(hsv)
diff --git a/lib/coloraide/css/spaces/hwb.py b/lib/coloraide/spaces/hwb/css.py
similarity index 98%
rename from lib/coloraide/css/spaces/hwb.py
rename to lib/coloraide/spaces/hwb/css.py
index 054b2669..de5c6b25 100644
--- a/lib/coloraide/css/spaces/hwb.py
+++ b/lib/coloraide/spaces/hwb/css.py
@@ -1,11 +1,11 @@
"""HWB class."""
import re
-from ...spaces import hwb as generic
+from . import base
from ...spaces import _parse
from ... import util
-class HWB(generic.HWB):
+class HWB(base.HWB):
"""HWB class."""
DEF_VALUE = "hwb(0 0% 0% / 1)"
diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp.py
index bad85410..8f8c1eda 100644
--- a/lib/coloraide/spaces/ictcp.py
+++ b/lib/coloraide/spaces/ictcp.py
@@ -3,9 +3,8 @@
https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf
"""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound
-from . import _cat
-from .xyz_d65 import XYZ
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, OptionalPercent
+from .xyz import XYZ
from .. import util
import re
@@ -85,12 +84,13 @@ class ICtCp(Space):
"""ICtCp class."""
SPACE = "ictcp"
+ SERIALIZE = ("--ictcp",)
CHANNEL_NAMES = ("i", "ct", "cp", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
- GamutUnbound([0, 1]),
+ GamutUnbound([OptionalPercent(0), OptionalPercent(1)]),
GamutUnbound([-0.5, 0.5]),
GamutUnbound([-0.5, 0.5])
)
@@ -132,13 +132,13 @@ def cp(self, value):
self._coords[2] = self._handle_input(value)
@classmethod
- def _to_xyz(cls, ictcp):
+ def _to_xyz(cls, parent, ictcp):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), ictcp_to_xyz_d65(ictcp))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, ictcp_to_xyz_d65(ictcp))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return xyz_d65_to_ictcp(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_d65_to_ictcp(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))
diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz.py
index 31fc40ff..2b8a2dff 100644
--- a/lib/coloraide/spaces/jzazbz.py
+++ b/lib/coloraide/spaces/jzazbz.py
@@ -3,9 +3,8 @@
https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272
"""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound
-from . import _cat
-from .xyz_d65 import XYZ
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, OptionalPercent
+from .xyz import XYZ
from .. import util
import re
@@ -31,21 +30,21 @@
# XYZ transform matrices
xyz_to_lms_m = [
- [0.41478972, 0.579999, 0.0146480],
- [-0.2015100, 1.120649, 0.0531008],
- [-0.0166008, 0.264800, 0.6684799]
+ [0.41478972, 0.579999, 0.014648],
+ [-0.20151, 1.120649, 0.0531008],
+ [-0.0166008, 0.2648, 0.6684799]
]
lms_to_xyz_mi = [
- [1.9242264357876069, -1.0047923125953657, 0.037651404030618],
- [0.3503167620949991, 0.7264811939316552, -0.065384422948085],
- [-0.0909828109828475, -0.3127282905230739, 1.5227665613052603]
+ [1.9242264357876069, -1.0047923125953657, 0.037651404030617994],
+ [0.35031676209499907, 0.7264811939316552, -0.06538442294808501],
+ [-0.09098281098284754, -0.3127282905230739, 1.5227665613052603]
]
# LMS to Izazbz matrices
lms_p_to_izazbz_m = [
[0.5, 0.5, 0],
- [3.524000, -4.066708, 0.542708],
+ [3.524, -4.066708, 0.542708],
[0.199076, 1.096799, -1.295875]
]
@@ -105,12 +104,13 @@ class Jzazbz(Space):
"""Jzazbz class."""
SPACE = "jzazbz"
+ SERIALIZE = ("--jzazbz",)
CHANNEL_NAMES = ("jz", "az", "bz", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
- GamutUnbound([0, 1]),
+ GamutUnbound([OptionalPercent(0), OptionalPercent(1)]),
GamutUnbound([-0.5, 0.5]),
GamutUnbound([-0.5, 0.5])
)
@@ -152,13 +152,13 @@ def bz(self, value):
self._coords[2] = self._handle_input(value)
@classmethod
- def _to_xyz(cls, jzazbz):
+ def _to_xyz(cls, parent, jzazbz):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), jzazbz_to_xyz_d65(jzazbz))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, jzazbz_to_xyz_d65(jzazbz))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return xyz_d65_to_jzazbz(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_d65_to_jzazbz(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))
diff --git a/lib/coloraide/spaces/jzczhz.py b/lib/coloraide/spaces/jzczhz.py
index 6967d299..c64df365 100644
--- a/lib/coloraide/spaces/jzczhz.py
+++ b/lib/coloraide/spaces/jzczhz.py
@@ -3,8 +3,7 @@
https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272
"""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle
-from . import _cat
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, OptionalPercent
from .jzazbz import Jzazbz
from .. import util
import re
@@ -55,12 +54,13 @@ class JzCzhz(Cylindrical, Space):
"""
SPACE = "jzczhz"
+ SERIALIZE = ("--jzczhz",)
CHANNEL_NAMES = ("jz", "chroma", "hue", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
- GamutUnbound([0.0, 1.0]),
+ GamutUnbound([OptionalPercent(0), OptionalPercent(1)]),
GamutUnbound([0.0, 1.0]),
GamutUnbound([Angle(0.0), Angle(360.0)]),
)
@@ -110,25 +110,25 @@ def null_adjust(cls, coords, alpha):
return coords, alpha
@classmethod
- def _to_jzazbz(cls, jzczhz):
+ def _to_jzazbz(cls, parent, jzczhz):
"""To Jzazbz."""
return jzczhz_to_jzazbz(jzczhz)
@classmethod
- def _from_jzazbz(cls, jzazbz):
+ def _from_jzazbz(cls, parent, jzazbz):
"""From Jzazbz."""
return jzazbz_to_jzczhz(jzazbz)
@classmethod
- def _to_xyz(cls, jzczhz):
+ def _to_xyz(cls, parent, jzczhz):
"""To XYZ."""
- return Jzazbz._to_xyz(cls._to_jzazbz(jzczhz))
+ return Jzazbz._to_xyz(parent, cls._to_jzazbz(parent, jzczhz))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_jzazbz(Jzazbz._from_xyz(xyz))
+ return cls._from_jzazbz(parent, Jzazbz._from_xyz(parent, xyz))
diff --git a/lib/coloraide/spaces/lab/__init__.py b/lib/coloraide/spaces/lab/__init__.py
new file mode 100644
index 00000000..4cfdcaab
--- /dev/null
+++ b/lib/coloraide/spaces/lab/__init__.py
@@ -0,0 +1 @@
+"""Lab color class."""
diff --git a/lib/coloraide/spaces/lab.py b/lib/coloraide/spaces/lab/base.py
similarity index 54%
rename from lib/coloraide/spaces/lab.py
rename to lib/coloraide/spaces/lab/base.py
index fac9a915..32e48ea2 100644
--- a/lib/coloraide/spaces/lab.py
+++ b/lib/coloraide/spaces/lab/base.py
@@ -1,22 +1,23 @@
"""Lab class."""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent
-from . import _cat
-from .xyz import XYZ
-from .. import util
+from ...spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent
+from ..xyz import XYZ
+from ... import util
import re
-EPSILON3 = 216 / 24389 # `6^3 / 29^3`
-EPSILON = 24 / 116
-RATIO1 = 16 / 116
-RATIO2 = 108 / 841
-RATIO3 = 841 / 108
+EPSILON = 216 / 24389 # `6^3 / 29^3`
+EPSILON3 = 6 / 29 # Cube root of EPSILON
+KAPPA = 24389 / 27
+KE = 8 # KAPPA * EPSILON = 8
-def lab_to_xyz(lab):
+def lab_to_xyz(lab, white):
"""
Convert Lab to D50-adapted XYZ.
- http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
+ http://www.brucelindbloom.com/Eqn_Lab_to_XYZ.html
+
+ While the derivation is different than the specification, the results are the same as Appendix D:
+ https://www.cdvplus.cz/file/3-publikace-cie15-2004/
"""
l, a, b = lab
@@ -28,22 +29,29 @@ def lab_to_xyz(lab):
# compute `xyz`
xyz = [
- fx ** 3 if fx > EPSILON else (fx - RATIO1) * RATIO2,
- fy ** 3 if fy > EPSILON or l > 8 else (fy - RATIO1) * RATIO2,
- fz ** 3 if fz > EPSILON else (fz - RATIO1) * RATIO2
+ fx ** 3 if fx > EPSILON3 else (116 * fx - 16) / KAPPA,
+ fy ** 3 if l > KE else l / KAPPA,
+ fz ** 3 if fz > EPSILON3 else (116 * fz - 16) / KAPPA
]
# Compute XYZ by scaling `xyz` by reference `white`
- return util.multiply(xyz, Lab.white())
+ return util.multiply(xyz, white)
+
+def xyz_to_lab(xyz, white):
+ """
+ Assuming XYZ is relative to D50, convert to CIE Lab from CIE standard.
-def xyz_to_lab(xyz):
- """Assuming XYZ is relative to D50, convert to CIE Lab from CIE standard."""
+ http://www.brucelindbloom.com/Eqn_XYZ_to_Lab.html
+
+ While the derivation is different than the specification, the results are the same:
+ https://www.cdvplus.cz/file/3-publikace-cie15-2004/
+ """
# compute `xyz`, which is XYZ scaled relative to reference white
- xyz = util.divide(xyz, Lab.white())
+ xyz = util.divide(xyz, white)
# Compute `fx`, `fy`, and `fz`
- fx, fy, fz = [util.cbrt(i) if i > EPSILON3 else (RATIO3 * i) + RATIO1 for i in xyz]
+ fx, fy, fz = [util.cbrt(i) if i > EPSILON else (KAPPA * i + 16) / 116 for i in xyz]
return (
(116.0 * fy) - 16.0,
@@ -104,17 +112,18 @@ class Lab(LabBase):
"""Lab class."""
SPACE = "lab"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D50"]
+ SERIALIZE = ("--lab",)
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D50"
@classmethod
- def _to_xyz(cls, lab):
+ def _to_xyz(cls, parent, lab):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lab_to_xyz(lab))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lab_to_xyz(lab, cls.white()))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return xyz_to_lab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_to_lab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.white())
diff --git a/lib/coloraide/css/spaces/lab.py b/lib/coloraide/spaces/lab/css.py
similarity index 98%
rename from lib/coloraide/css/spaces/lab.py
rename to lib/coloraide/spaces/lab/css.py
index 2b27470d..7d8d6225 100644
--- a/lib/coloraide/css/spaces/lab.py
+++ b/lib/coloraide/spaces/lab/css.py
@@ -1,11 +1,11 @@
"""Lab class."""
import re
-from ...spaces import lab as generic
+from . import base
from ...spaces import _parse
from ... import util
-class Lab(generic.Lab):
+class Lab(base.Lab):
"""Lab class."""
DEF_VALUE = "lab(0% 0 0 / 1)"
diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py
index 6ef1fefa..de510ab7 100644
--- a/lib/coloraide/spaces/lab_d65.py
+++ b/lib/coloraide/spaces/lab_d65.py
@@ -1,8 +1,7 @@
"""Lab D65 class."""
from ..spaces import RE_DEFAULT_MATCH
-from . import _cat
from .xyz import XYZ
-from .lab import LabBase, lab_to_xyz, xyz_to_lab
+from .lab.base import LabBase, lab_to_xyz, xyz_to_lab
import re
@@ -10,17 +9,18 @@ class LabD65(LabBase):
"""Lab D65 class."""
SPACE = "lab-d65"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ SERIALIZE = ("--lab-d65",)
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
@classmethod
- def _to_xyz(cls, lab):
+ def _to_xyz(cls, parent, lab):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lab_to_xyz(lab))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lab_to_xyz(lab, cls.white()))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return xyz_to_lab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_to_lab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.white())
diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py
new file mode 100644
index 00000000..6711049a
--- /dev/null
+++ b/lib/coloraide/spaces/lch/__init__.py
@@ -0,0 +1 @@
+"""Lch color class."""
diff --git a/lib/coloraide/spaces/lch.py b/lib/coloraide/spaces/lch/base.py
similarity index 83%
rename from lib/coloraide/spaces/lch.py
rename to lib/coloraide/spaces/lch/base.py
index 90aef2a8..b43b75ff 100644
--- a/lib/coloraide/spaces/lch.py
+++ b/lib/coloraide/spaces/lch/base.py
@@ -1,8 +1,7 @@
"""Lch class."""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent
-from . import _cat
-from .lab import Lab
-from .. import util
+from ...spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent
+from ..lab.base import Lab
+from ... import util
import re
import math
@@ -108,29 +107,30 @@ class Lch(LchBase):
"""Lch class."""
SPACE = "lch"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D50"]
+ SERIALIZE = ("--lch",)
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D50"
@classmethod
- def _to_lab(cls, lch):
+ def _to_lab(cls, parent, lch):
"""To Lab."""
return lch_to_lab(lch)
@classmethod
- def _from_lab(cls, lab):
+ def _from_lab(cls, parent, lab):
"""To Lab."""
return lab_to_lch(lab)
@classmethod
- def _to_xyz(cls, lch):
+ def _to_xyz(cls, parent, lch):
"""To XYZ."""
- return Lab._to_xyz(cls._to_lab(lch))
+ return Lab._to_xyz(parent, cls._to_lab(parent, lch))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_lab(Lab._from_xyz(xyz))
+ return cls._from_lab(parent, Lab._from_xyz(parent, xyz))
diff --git a/lib/coloraide/css/spaces/lch.py b/lib/coloraide/spaces/lch/css.py
similarity index 98%
rename from lib/coloraide/css/spaces/lch.py
rename to lib/coloraide/spaces/lch/css.py
index 81a93b6e..26d441ae 100644
--- a/lib/coloraide/css/spaces/lch.py
+++ b/lib/coloraide/spaces/lch/css.py
@@ -1,11 +1,11 @@
"""Lch class."""
import re
-from ...spaces import lch as generic
+from . import base
from ...spaces import _parse
from ... import util
-class Lch(generic.Lch):
+class Lch(base.Lch):
"""Lch class."""
DEF_VALUE = "lch(0% 0 0 / 1)"
diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py
index 7fffab07..f2a26c34 100644
--- a/lib/coloraide/spaces/lch_d65.py
+++ b/lib/coloraide/spaces/lch_d65.py
@@ -1,8 +1,7 @@
"""Lch D65 class."""
from ..spaces import RE_DEFAULT_MATCH
-from . import _cat
from .lab_d65 import LabD65
-from .lch import LchBase, lch_to_lab, lab_to_lch
+from .lch.base import LchBase, lch_to_lab, lab_to_lch
import re
@@ -10,29 +9,30 @@ class LchD65(LchBase):
"""Lch D65 class."""
SPACE = "lch-d65"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ SERIALIZE = ("--lch-d65",)
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
@classmethod
- def _to_lab_d65(cls, lchd65):
+ def _to_lab_d65(cls, parent, lchd65):
"""To Lab."""
return lch_to_lab(lchd65)
@classmethod
- def _from_lab_d65(cls, labd65):
+ def _from_lab_d65(cls, parent, labd65):
"""To Lab."""
return lab_to_lch(labd65)
@classmethod
- def _to_xyz(cls, lch):
+ def _to_xyz(cls, parent, lch):
"""To XYZ."""
- return LabD65._to_xyz(cls._to_lab_d65(lch))
+ return LabD65._to_xyz(parent, cls._to_lab_d65(parent, lch))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_lab_d65(LabD65._from_xyz(xyz))
+ return cls._from_lab_d65(parent, LabD65._from_xyz(parent, xyz))
diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py
new file mode 100644
index 00000000..544dec62
--- /dev/null
+++ b/lib/coloraide/spaces/lchuv.py
@@ -0,0 +1,126 @@
+"""LCH class."""
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent
+from .luv import Luv
+from .. import util
+import re
+import math
+
+ACHROMATIC_THRESHOLD = 0.000000000002
+
+
+def luv_to_lchuv(luv):
+ """Luv to Lch(uv)."""
+
+ l, u, v = luv
+
+ c = math.sqrt(u ** 2 + v ** 2)
+ h = math.degrees(math.atan2(v, u))
+
+ # Achromatic colors will often get extremely close, but not quite hit zero.
+ # Essentially, we want to discard noise through rounding and such.
+ if c < ACHROMATIC_THRESHOLD:
+ h = util.NaN
+
+ return [l, c, util.constrain_hue(h)]
+
+
+def lchuv_to_luv(lchuv):
+ """Lch(uv) to Luv."""
+
+ l, c, h = lchuv
+ h = util.no_nan(h)
+
+ # If, for whatever reason (mainly direct user input),
+ # if chroma is less than zero, clamp to zero.
+ if c < 0.0:
+ c = 0.0
+
+ return (
+ l,
+ c * math.cos(math.radians(h)),
+ c * math.sin(math.radians(h))
+ )
+
+
+class Lchuv(Cylindrical, Space):
+ """Lch(uv) class."""
+
+ SPACE = "lchuv"
+ SERIALIZE = ("--lchuv",)
+ CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha")
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
+
+ RANGE = (
+ GamutUnbound([Percent(0), Percent(100.0)]),
+ GamutUnbound([0.0, 176.0]),
+ GamutUnbound([Angle(0.0), Angle(360.0)]),
+ )
+
+ @property
+ def lightness(self):
+ """Lightness."""
+
+ return self._coords[0]
+
+ @lightness.setter
+ def lightness(self, value):
+ """Get true luminance."""
+
+ self._coords[0] = self._handle_input(value)
+
+ @property
+ def chroma(self):
+ """Chroma."""
+
+ return self._coords[1]
+
+ @chroma.setter
+ def chroma(self, value):
+ """chroma."""
+
+ self._coords[1] = self._handle_input(value)
+
+ @property
+ def hue(self):
+ """Hue."""
+
+ return self._coords[2]
+
+ @hue.setter
+ def hue(self, value):
+ """Shift the hue."""
+
+ self._coords[2] = self._handle_input(value)
+
+ @classmethod
+ def null_adjust(cls, coords, alpha):
+ """On color update."""
+
+ if coords[1] < ACHROMATIC_THRESHOLD:
+ coords[2] = util.NaN
+ return coords, alpha
+
+ @classmethod
+ def _to_luv(cls, parent, lchuv):
+ """To Luv."""
+
+ return lchuv_to_luv(lchuv)
+
+ @classmethod
+ def _from_luv(cls, parent, luv):
+ """To Luv."""
+
+ return luv_to_lchuv(luv)
+
+ @classmethod
+ def _to_xyz(cls, parent, lchuv):
+ """To XYZ."""
+
+ return Luv._to_xyz(parent, cls._to_luv(parent, lchuv))
+
+ @classmethod
+ def _from_xyz(cls, parent, xyz):
+ """From XYZ."""
+
+ return cls._from_luv(parent, Luv._from_xyz(parent, xyz))
diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py
new file mode 100644
index 00000000..ec8baddf
--- /dev/null
+++ b/lib/coloraide/spaces/luv.py
@@ -0,0 +1,126 @@
+"""
+Luv class.
+
+https://en.wikipedia.org/wiki/CIELUV
+"""
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent, WHITES
+from .xyz import XYZ
+from .. import util
+import re
+
+
+def xyz_to_uv(xyz):
+ """XYZ to UV."""
+
+ x, y, z = xyz
+ denom = (x + 15 * y + 3 * z)
+ if denom != 0:
+ u = (4 * x) / (x + 15 * y + 3 * z)
+ v = (9 * y) / (x + 15 * y + 3 * z)
+ else:
+ u = v = 0
+
+ return u, v
+
+
+def xyz_to_luv(xyz, white):
+ """XYZ to Luv."""
+
+ u, v = xyz_to_uv(xyz)
+ un, vn = xyz_to_uv(WHITES[white])
+
+ y = xyz[1] / WHITES[white][1]
+ l = 116 * util.nth_root(y, 3) - 16 if y > ((6 / 29) ** 3) else ((29 / 3) ** 3) * y
+
+ return [
+ l,
+ 13 * l * (u - un),
+ 13 * l * (v - vn),
+ ]
+
+
+def luv_to_xyz(luv, white):
+ """Luv to XYZ."""
+
+ l, u, v = luv
+ un, vn = xyz_to_uv(WHITES[white])
+
+ if l != 0:
+ up = (u / ( 13 * l)) + un
+ vp = (v / ( 13 * l)) + vn
+ else:
+ up = vp = 0
+
+ y = WHITES[white][1] * ((l + 16) / 116) ** 3 if l > 8 else WHITES[white][1] * l * ((3 / 29) ** 3)
+
+ if vp != 0:
+ x = y * ((9 * up) / (4 * vp))
+ z = y * ((12 - 3 * up - 20 * vp) / (4 * vp))
+ else:
+ x = z = 0
+
+ return [x, y, z]
+
+
+class Luv(Space):
+ """Oklab class."""
+
+ SPACE = "luv"
+ SERIALIZE = ("--luv",)
+ CHANNEL_NAMES = ("lightness", "u", "v", "alpha")
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
+
+ RANGE = (
+ GamutUnbound([Percent(0), Percent(100.0)]),
+ GamutUnbound([-175.0, 175.0]),
+ GamutUnbound([-175.0, 175.0])
+ )
+
+ @property
+ def lightness(self):
+ """L channel."""
+
+ return self._coords[0]
+
+ @lightness.setter
+ def lightness(self, value):
+ """Get true luminance."""
+
+ self._coords[0] = self._handle_input(value)
+
+ @property
+ def u(self):
+ """U channel."""
+
+ return self._coords[1]
+
+ @u.setter
+ def u(self, value):
+ """U axis."""
+
+ self._coords[1] = self._handle_input(value)
+
+ @property
+ def v(self):
+ """V channel."""
+
+ return self._coords[2]
+
+ @v.setter
+ def v(self, value):
+ """V axis."""
+
+ self._coords[2] = self._handle_input(value)
+
+ @classmethod
+ def _to_xyz(cls, parent, luv):
+ """To XYZ."""
+
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, luv_to_xyz(luv, cls.WHITE))
+
+ @classmethod
+ def _from_xyz(cls, parent, xyz):
+ """From XYZ."""
+
+ return xyz_to_luv(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.WHITE)
diff --git a/lib/coloraide/spaces/oklab.py b/lib/coloraide/spaces/oklab.py
index 83730b34..de7ed14e 100644
--- a/lib/coloraide/spaces/oklab.py
+++ b/lib/coloraide/spaces/oklab.py
@@ -3,8 +3,7 @@
https://bottosson.github.io/posts/oklab/
"""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound
-from . import _cat
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, OptionalPercent
from .xyz import XYZ
from .. import util
import re
@@ -12,25 +11,25 @@
m1 = [
[0.8189330101, 0.0329845436, 0.0482003018],
[0.3618667424, 0.9293118715, 0.2643662691],
- [-0.1288597137, 0.0361456387, 0.6338517070]
+ [-0.1288597137, 0.0361456387, 0.633851707]
]
m2 = [
[0.2104542553, 1.9779984951, 0.0259040371],
- [0.7936177850, -2.4285922050, 0.7827717662],
- [-0.0040720468, 0.4505937099, -0.8086757660]
+ [0.793617785, -2.428592205, 0.7827717662],
+ [-0.0040720468, 0.4505937099, -0.808675766]
]
m1i = [
- [1.2270138511035211, -0.0405801784232806, -0.0763812845057069],
- [-0.5577999806518223, 1.11225686961683, -0.4214819784180127],
+ [1.2270138511035211, -0.04058017842328059, -0.07638128450570689],
+ [-0.5577999806518223, 1.11225686961683, -0.42148197841801266],
[0.2812561489664678, -0.0716766786656012, 1.5861632204407947]
]
m2i = [
[0.9999999984505199, 1.0000000088817607, 1.0000000546724108],
- [0.3963377921737679, -0.1055613423236564, -0.0894841820949658],
- [0.2158037580607588, -0.0638541747717059, -1.2914855378640917]
+ [0.3963377921737679, -0.10556134232365635, -0.08948418209496575],
+ [0.2158037580607588, -0.06385417477170591, -1.2914855378640917]
]
@@ -50,12 +49,13 @@ class Oklab(Space):
"""Oklab class."""
SPACE = "oklab"
+ SERIALIZE = ("--oklab",)
CHANNEL_NAMES = ("lightness", "a", "b", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
- GamutUnbound([0, 1]),
+ GamutUnbound([OptionalPercent(0), OptionalPercent(1)]),
GamutUnbound([-0.5, 0.5]),
GamutUnbound([-0.5, 0.5])
)
@@ -97,13 +97,13 @@ def b(self, value):
self._coords[2] = self._handle_input(value)
@classmethod
- def _to_xyz(cls, oklab):
+ def _to_xyz(cls, parent, oklab):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), oklab_to_xyz_d65(oklab))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, oklab_to_xyz_d65(oklab))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return xyz_d65_to_oklab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_d65_to_oklab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))
diff --git a/lib/coloraide/spaces/oklch.py b/lib/coloraide/spaces/oklch.py
index e6c0821c..3281bbd2 100644
--- a/lib/coloraide/spaces/oklch.py
+++ b/lib/coloraide/spaces/oklch.py
@@ -1,6 +1,5 @@
"""LCH class."""
-from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle
-from . import _cat
+from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, OptionalPercent
from .oklab import Oklab
from .. import util
import re
@@ -47,12 +46,13 @@ class Oklch(Cylindrical, Space):
"""Oklch class."""
SPACE = "oklch"
+ SERIALIZE = ("--oklch",)
CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
- GamutUnbound([0.0, 1.0]),
+ GamutUnbound([OptionalPercent(0), OptionalPercent(1)]),
GamutUnbound([0.0, 1.0]),
GamutUnbound([Angle(0.0), Angle(360.0)]),
)
@@ -102,25 +102,25 @@ def null_adjust(cls, coords, alpha):
return coords, alpha
@classmethod
- def _to_oklab(cls, oklch):
+ def _to_oklab(cls, parent, oklch):
"""To Lab."""
return oklch_to_oklab(oklch)
@classmethod
- def _from_oklab(cls, oklab):
+ def _from_oklab(cls, parent, oklab):
"""To Lab."""
return oklab_to_oklch(oklab)
@classmethod
- def _to_xyz(cls, oklch):
+ def _to_xyz(cls, parent, oklch):
"""To XYZ."""
- return Oklab._to_xyz(cls._to_oklab(oklch))
+ return Oklab._to_xyz(parent, cls._to_oklab(parent, oklch))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return cls._from_oklab(Oklab._from_xyz(xyz))
+ return cls._from_oklab(parent, Oklab._from_xyz(parent, xyz))
diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py
index eb348665..633ff162 100644
--- a/lib/coloraide/spaces/prophoto_rgb.py
+++ b/lib/coloraide/spaces/prophoto_rgb.py
@@ -1,7 +1,6 @@
"""Pro Photo RGB color class."""
from ..spaces import RE_DEFAULT_MATCH
-from . import _cat
-from .srgb import SRGB
+from .srgb.base import SRGB
from .xyz import XYZ
from .. import util
import re
@@ -31,8 +30,8 @@ def xyz_to_lin_prophoto(xyz):
"""Convert XYZ to linear-light prophoto-rgb."""
m = [
- [1.3459433009386652, -0.255607509316767, -0.051111765870885],
- [-0.544598869458717, 1.508167317720767, 0.0205351415866469],
+ [1.3459433009386652, -0.25560750931676696, -0.05111176587088495],
+ [-0.544598869458717, 1.508167317720767, 0.020535141586646915],
[0.0, 0.0, 1.2118127506937628]
]
@@ -73,7 +72,7 @@ def gam_prophoto(rgb):
if abs(i) < ET:
result.append(16.0 * i)
else:
- result.append(util.npow(i, 1.0 / 1.8))
+ result.append(util.nth_root(i, 1.8))
return result
@@ -81,17 +80,17 @@ class ProPhotoRGB(SRGB):
"""Pro Photo RGB class."""
SPACE = "prophoto-rgb"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D50"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3))
+ WHITE = "D50"
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_prophoto_to_xyz(lin_prophoto(rgb)))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_prophoto_to_xyz(lin_prophoto(rgb)))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return gam_prophoto(xyz_to_lin_prophoto(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)))
+ return gam_prophoto(xyz_to_lin_prophoto(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)))
diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py
index 4d7aa2e2..663ca0b3 100644
--- a/lib/coloraide/spaces/rec2020.py
+++ b/lib/coloraide/spaces/rec2020.py
@@ -1,7 +1,6 @@
"""Rec 2020 color class."""
from ..spaces import RE_DEFAULT_MATCH
-from . import _cat
-from .srgb import SRGB
+from .srgb.base import SRGB
from .xyz import XYZ
from .. import util
import re
@@ -26,7 +25,7 @@ def lin_2020(rgb):
if abs_i < BETA45:
result.append(i / 4.5)
else:
- result.append(math.copysign(((abs_i + ALPHA - 1) / ALPHA) ** (1 / 0.45), i))
+ result.append(math.copysign(util.nth_root((abs_i + ALPHA - 1) / ALPHA, 0.45), i))
return result
@@ -69,9 +68,9 @@ def xyz_to_lin_2020(xyz):
"""Convert XYZ to linear-light rec-2020."""
m = [
- [1.7165106697619734, -0.3556416699867159, -0.2533455418219072],
+ [1.7165106697619734, -0.35564166998671587, -0.25334554182190716],
[-0.6666930011826241, 1.6165022083469103, 0.015768750389995],
- [0.017643638767459, -0.0427797816690446, 0.9423050727200183]
+ [0.017643638767459002, -0.04277978166904461, 0.9423050727200183]
]
return util.dot(m, xyz)
@@ -81,17 +80,17 @@ class Rec2020(SRGB):
"""Rec 2020 class."""
SPACE = "rec2020"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3))
+ WHITE = "D65"
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_2020_to_xyz(lin_2020(rgb)))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_2020_to_xyz(lin_2020(rgb)))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return gam_2020(xyz_to_lin_2020(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)))
+ return gam_2020(xyz_to_lin_2020(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)))
diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py
new file mode 100644
index 00000000..f978475c
--- /dev/null
+++ b/lib/coloraide/spaces/srgb/__init__.py
@@ -0,0 +1 @@
+"""SRGB color class."""
diff --git a/lib/coloraide/spaces/srgb.py b/lib/coloraide/spaces/srgb/base.py
similarity index 72%
rename from lib/coloraide/spaces/srgb.py
rename to lib/coloraide/spaces/srgb/base.py
index bebb2f82..ff0ffcd1 100644
--- a/lib/coloraide/spaces/srgb.py
+++ b/lib/coloraide/spaces/srgb/base.py
@@ -1,8 +1,7 @@
"""SRGB color class."""
-from ..spaces import RE_DEFAULT_MATCH, Space, GamutBound
-from . import _cat
-from .xyz import XYZ
-from .. import util
+from ...spaces import RE_DEFAULT_MATCH, Space, GamutBound, OptionalPercent
+from ..xyz import XYZ
+from ... import util
import re
import math
@@ -15,9 +14,9 @@ def lin_srgb_to_xyz(rgb):
"""
m = [
- [0.4124564390896923, 0.357576077643909, 0.180437483266399],
- [0.2126728514056226, 0.715152155287818, 0.0721749933065596],
- [0.0193338955823293, 0.119192025881303, 0.950304078536368]
+ [0.41245643908969226, 0.357576077643909, 0.18043748326639897],
+ [0.21267285140562256, 0.715152155287818, 0.07217499330655959],
+ [0.019333895582329303, 0.11919202588130297, 0.950304078536368]
]
return util.dot(m, rgb)
@@ -27,9 +26,9 @@ def xyz_to_lin_srgb(xyz):
"""Convert XYZ to linear-light sRGB."""
m = [
- [3.2404541621141045, -1.5371385127977162, -0.498531409556016],
- [-0.969266030505187, 1.8760108454466944, 0.0415560175303498],
- [0.0556434309591147, -0.2040259135167538, 1.057225188223179]
+ [3.2404541621141045, -1.5371385127977162, -0.49853140955601605],
+ [-0.969266030505187, 1.8760108454466944, 0.04155601753034984],
+ [0.05564343095911475, -0.20402591351675384, 1.057225188223179]
]
return util.dot(m, xyz)
@@ -65,7 +64,7 @@ def gam_srgb(rgb):
# Mirror linear nature of algorithm on the negative axis
abs_i = abs(i)
if abs_i > 0.0031308:
- result.append(math.copysign(1.055 * (abs_i ** (1 / 2.4)) - 0.055, i))
+ result.append(math.copysign(1.055 * (util.nth_root(abs_i, 2.4)) - 0.055, i))
else:
result.append(12.92 * i)
return result
@@ -78,15 +77,14 @@ class SRGB(Space):
# In addition to the current gamut, check HSL as it is much more sensitive to small
# gamut changes. This is mainly for a better user experience. Colors will still be
# mapped/clipped in the current space, unless specified otherwise.
- GAMUT_CHECK = "hsl"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3))
CHANNEL_NAMES = ("red", "green", "blue", "alpha")
- WHITE = _cat.WHITES["D65"]
+ WHITE = "D65"
RANGE = (
- GamutBound([0.0, 1.0]),
- GamutBound([0.0, 1.0]),
- GamutBound([0.0, 1.0])
+ GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)]),
+ GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)]),
+ GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)])
)
@property
@@ -126,13 +124,13 @@ def blue(self, value):
self._coords[2] = self._handle_input(value)
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""SRGB to XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_srgb_to_xyz(lin_srgb(rgb)))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_srgb_to_xyz(lin_srgb(rgb)))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""XYZ to SRGB."""
- return gam_srgb(xyz_to_lin_srgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)))
+ return gam_srgb(xyz_to_lin_srgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)))
diff --git a/lib/coloraide/css/spaces/_color_names.py b/lib/coloraide/spaces/srgb/color_names.py
similarity index 100%
rename from lib/coloraide/css/spaces/_color_names.py
rename to lib/coloraide/spaces/srgb/color_names.py
diff --git a/lib/coloraide/css/spaces/srgb.py b/lib/coloraide/spaces/srgb/css.py
similarity index 96%
rename from lib/coloraide/css/spaces/srgb.py
rename to lib/coloraide/spaces/srgb/css.py
index 52fd67e2..c3aa69a5 100644
--- a/lib/coloraide/css/spaces/srgb.py
+++ b/lib/coloraide/spaces/srgb/css.py
@@ -1,14 +1,14 @@
"""SRGB color class."""
import re
-from . import _color_names
-from ...spaces import srgb as generic
-from ...spaces import _parse
+from . import color_names
+from . import base
+from .. import _parse
from ... import util
RE_COMPRESS = re.compile(r'(?i)^#({hex})\1({hex})\2({hex})\3(?:({hex})\4)?$'.format(**_parse.COLOR_PARTS))
-class SRGB(generic.SRGB):
+class SRGB(base.SRGB):
"""SRGB class."""
DEF_VALUE = "rgb(0 0 0 / 1)"
@@ -75,7 +75,7 @@ def to_string(
index = int(length / 4)
if length in (8, 4) and h[-index:].lower() == ("f" * index):
h = h[:-index]
- n = _color_names.hex2name(h)
+ n = color_names.hex2name(h)
if n is not None:
value = n
@@ -197,7 +197,7 @@ def match(cls, string, start=0, fullmatch=True):
m = cls.MATCH.match(string, start)
if m is not None and (not fullmatch or m.end(0) == len(string)):
if not string[start:start + 5].lower().startswith(('#', 'rgb(', 'rgba(')):
- string = _color_names.name2hex(string[m.start(0):m.end(0)])
+ string = color_names.name2hex(string[m.start(0):m.end(0)])
if string is not None:
return cls.split_channels(string), m.end(0)
else:
diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py
index 1c5824e5..e55f4929 100644
--- a/lib/coloraide/spaces/srgb_linear.py
+++ b/lib/coloraide/spaces/srgb_linear.py
@@ -1,7 +1,6 @@
"""SRGB Linear color class."""
from ..spaces import RE_DEFAULT_MATCH
-from . import _cat
-from .srgb import SRGB, lin_srgb_to_xyz, xyz_to_lin_srgb, lin_srgb, gam_srgb
+from .srgb.base import SRGB, lin_srgb_to_xyz, xyz_to_lin_srgb, lin_srgb, gam_srgb
from .xyz import XYZ
import re
@@ -10,29 +9,30 @@ class SRGBLinear(SRGB):
"""SRGB linear."""
SPACE = "srgb-linear"
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ SERIALIZE = ("--srgb-linear",)
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
@classmethod
- def _to_srgb(cls, rgb):
+ def _to_srgb(cls, parent, rgb):
"""Linear sRGB to sRGB."""
return gam_srgb(rgb)
@classmethod
- def _from_srgb(cls, rgb):
+ def _from_srgb(cls, parent, rgb):
"""sRGB to linear sRGB."""
return lin_srgb(rgb)
@classmethod
- def _to_xyz(cls, rgb):
+ def _to_xyz(cls, parent, rgb):
"""SRGB Linear to XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_srgb_to_xyz(rgb))
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_srgb_to_xyz(rgb))
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""XYZ to SRGB Linear."""
- return xyz_to_lin_srgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))
+ return xyz_to_lin_srgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))
diff --git a/lib/coloraide/spaces/xyz.py b/lib/coloraide/spaces/xyz.py
index cbdff295..e3444e52 100644
--- a/lib/coloraide/spaces/xyz.py
+++ b/lib/coloraide/spaces/xyz.py
@@ -1,6 +1,5 @@
"""XYZ class."""
from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound
-from . import _cat
import re
@@ -8,9 +7,10 @@ class XYZ(Space):
"""XYZ class."""
SPACE = "xyz"
+ SERIALIZE = ("xyz", "--xyz-d50")
CHANNEL_NAMES = ("x", "y", "z", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D50"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D50"
RANGE = (
GamutUnbound([0.0, 1.0]),
diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py
index c3b27c5b..979e7161 100644
--- a/lib/coloraide/spaces/xyz_d65.py
+++ b/lib/coloraide/spaces/xyz_d65.py
@@ -1,6 +1,5 @@
"""XYZ D65 class."""
from ..spaces import RE_DEFAULT_MATCH, GamutUnbound
-from . import _cat
from .xyz import XYZ
import re
@@ -9,9 +8,10 @@ class XYZD65(XYZ):
"""XYZ D65 class."""
SPACE = "xyz-d65"
+ SERIALIZE = ("--xyz-d65",)
CHANNEL_NAMES = ("x", "y", "z", "alpha")
- DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE))
- WHITE = _cat.WHITES["D65"]
+ DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3))
+ WHITE = "D65"
RANGE = (
GamutUnbound([0.0, 1.0]),
@@ -20,13 +20,13 @@ class XYZD65(XYZ):
)
@classmethod
- def _to_xyz(cls, xyzd65):
+ def _to_xyz(cls, parent, xyzd65):
"""To XYZ."""
- return _cat.chromatic_adaption(cls.white(), XYZ.white(), xyzd65)
+ return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, xyzd65)
@classmethod
- def _from_xyz(cls, xyz):
+ def _from_xyz(cls, parent, xyz):
"""From XYZ."""
- return _cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)
+ return parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)
diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py
index 84e1806a..1ddd33f5 100644
--- a/lib/coloraide/util.py
+++ b/lib/coloraide/util.py
@@ -1,12 +1,10 @@
"""Utilities."""
-import decimal
+import copy
import math
import numbers
-import re
import warnings
from functools import wraps
-RE_FLOAT_TRIM = re.compile(r'^(?P-?\d+)(?P\.0+|(?P\.\d*[1-9])0+)$')
NaN = float('nan')
INF = float('inf')
ACHROMATIC_THRESHOLD = 0.0005
@@ -97,7 +95,7 @@ def is_number(value):
def is_nan(value):
- """Print is "not a number"."""
+ """Check if value is "not a number"."""
return math.isnan(value)
@@ -221,12 +219,131 @@ def divide(a, b):
return value
-def cbrt(x):
- """Cube root."""
+def diag(v, k=0):
+ """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix."""
- if 0 <= x:
- return x ** (1.0 / 3.0)
- return -(-x) ** (1.0 / 3.0)
+ is_vector = isinstance(v[0], numbers.Number)
+ size = len(v)
+ d = []
+
+ if is_vector:
+ # Create a diagonal matrix with the provided values
+ for i, value in enumerate(v):
+ # Check that the matrix is square, we .cannot invert the matrix if it is not
+ d.append([0] * i + [value] + [0] * (size - i - 1))
+ else: # pragma: no cover
+ for r in v:
+ if len(r) != size:
+ raise ValueError('Matrix must be a n x n matrix')
+ if 0 <= k < size:
+ d.append(r[k])
+ k += 1
+ return d
+
+
+def inv(matrix):
+ """
+ Invert the matrix.
+
+ Derived from https://github.com/ThomIves/MatrixInverse.
+
+ While not as performant as using `numpy`, we are often caching any
+ inversion we are doing, so this keeps us from having to require all
+ of `numpy` for the few hits to this we do.
+
+ This is free and unencumbered software released into the public domain.
+
+ Anyone is free to copy, modify, publish, use, compile, sell, or
+ distribute this software, either in source code form or as a compiled
+ binary, for any purpose, commercial or non-commercial, and by any
+ means.
+
+ In jurisdictions that recognize copyright laws, the author or authors
+ of this software dedicate any and all copyright interest in the
+ software to the public domain. We make this dedication for the benefit
+ of the public at large and to the detriment of our heirs and
+ successors. We intend this dedication to be an overt act of
+ relinquishment in perpetuity of all present and future rights to this
+ software under copyright law.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
+
+ For more information, please refer to
+ """
+
+ size = len(matrix)
+ indices = list(range(size))
+ m = copy.deepcopy(matrix)
+
+ # Ensure we have a square matrix
+ for r in m:
+ if len(r) != size: # pragma: no cover
+ raise ValueError('Matrix must be a n x n matrix')
+
+ # Create an identity matrix of the same size as our provided matrix
+ im = diag([1] * size)
+
+ # Iterating through each row, we will scale each row by it's "focus diagonal".
+ # Then using the scaled row, we will adjust the other rows.
+ # ```
+ # [[fd, 0, 0 ]
+ # [0, fd, 0 ]
+ # [0, 0, fd]]
+ # ```
+ for fd in indices:
+ # We will divide each value in the row by the "focus diagonal" value.
+ # If the we have a zero for the given `fd` value, we cannot invert.
+ denom = m[fd][fd]
+ if denom == 0: # pragma: no cover
+ raise ValueError('Matrix is not invertable')
+
+ # We are converting the matrix to the identity and vice versa,
+ # So scale the diagonal such that it will now equal 1.
+ # Additionally, the same operations will be applied to the identity matrix
+ # and will turn it into `m ** -1` (what we are looking for)
+ fd_scalar = 1.0 / denom
+ for j in indices:
+ m[fd][j] *= fd_scalar
+ im[fd][j] *= fd_scalar
+
+ # Now, using the value found at the index `fd` in the remaining rows (excluding `row[fd]`),
+ # Where `cr` is the current row under evaluation, subtract `row[cr][fd] * row[fd] from row[cr]`.
+ for cr in indices[0:fd] + indices[fd + 1:]:
+ # The scalar for the current row
+ cr_scalar = m[cr][fd]
+
+ # Scale each item in the `row[fd]` and subtract it from the current row `row[cr]`
+ for j in indices:
+ m[cr][j] -= cr_scalar * m[fd][j]
+ im[cr][j] -= cr_scalar * im[fd][j]
+
+ # The identify matrix is now the inverse matrix and vice versa.
+ return im
+
+
+def cbrt(n):
+ """Calculate cube root."""
+
+ return nth_root(n, 3)
+
+
+def nth_root(n, p):
+ """Calculate nth root."""
+
+ if p == 0: # pragma: no cover
+ return float('inf')
+
+ if n == 0:
+ # Can't do anything with zero
+ return 0
+
+ return math.copysign(abs(n) ** (p ** -1), n)
def clamp(value, mn=None, mx=None):
@@ -242,34 +359,6 @@ def clamp(value, mn=None, mx=None):
return max(min(value, mx), mn)
-def adjust_precision(f, p):
- """Adjust precision and scale."""
-
- with decimal.localcontext() as ctx:
- if p > 0:
- # Set precision
- ctx.prec = p
- ctx.rounding = decimal.ROUND_HALF_UP
-
- if p == -1:
- # Full precision
- value = decimal.Decimal(f)
- elif p == 0:
- # Just round to integer
- value = decimal.Decimal(round_half_up(f))
- else:
- # Round to precision
- value = (decimal.Decimal(f) * decimal.Decimal('1.0'))
- exp = value.as_tuple().exponent
- if exp < 0 and abs(value.as_tuple().exponent) > p:
- value = value.quantize(decimal.Decimal(10) ** -p)
-
- if value.is_zero():
- value = abs(value)
-
- return float(value)
-
-
def fmt_float(f, p=0):
"""
Set float precision and trim precision zeros.
@@ -281,20 +370,27 @@ def fmt_float(f, p=0):
value = adjust_precision(f, p)
string = ('{{:{}f}}'.format('.53' if p == -1 else '.' + str(p))).format(value)
- m = RE_FLOAT_TRIM.match(string)
- if m:
- string = m.group('keep')
- if m.group('keep2'):
- string += m.group('keep2')
- return string
+ return string if value.is_integer() and p == 0 else string.rstrip('0').rstrip('.')
+
+
+def adjust_precision(f, p=0):
+ """Adjust precision."""
+
+ if p == -1:
+ return f
+
+ elif p == 0:
+ return round_half_up(f)
+
+ else:
+ whole = int(f)
+ digits = 0 if whole == 0 else int(math.log10(-whole if whole < 0 else whole)) + 1
+ return round_half_up(whole if digits >= p else f, p - digits)
def round_half_up(n, scale=0):
"""Round half up."""
- if scale == -1:
- return n
-
mult = 10 ** scale
return math.floor(n * mult + 0.5) / mult
diff --git a/messages.json b/messages.json
index c7a1bdf4..486e6875 100644
--- a/messages.json
+++ b/messages.json
@@ -1,4 +1,4 @@
{
"install": "messages/install.md",
- "3.4.0": "messages/recent.md"
+ "3.6.0": "messages/recent.md"
}
diff --git a/messages/recent.md b/messages/recent.md
index 6775a1a1..5079a749 100644
--- a/messages/recent.md
+++ b/messages/recent.md
@@ -1,4 +1,4 @@
-# ColorHelper 3.3.0
+# ColorHelper 3.6.0
New release!
@@ -7,20 +7,9 @@ prior releases.
Restart of Sublime Text may be required.
-## 3.4.0
-
-- **NEW**: New color difference tool.
-- **NEW**: New blend modes tool.
-- **NEW**: Fix typo. `0xahex` color class should have been named `0xhex` in the
- settings.
-- **NEW**: New `coloraide` brings support for `oklab`, `oklch`, `jzazbz`, `jzczhz`,
- `ICtCp`, D65 variations of CIELAB, CIELCH, and XYZ (none of which are enabled
- as output options by default).
-- **NEW**: Some refactoring of `coloraide` caused custom color classes to get
- updated. User created custom classes may have to get updated to work.
-- **FIX**: Upgrade `coloraide` which fixes issues related to inconsistent use of
- D65 white values in XYZ transforms and Bradford CAT and other lesser bug fixes
- as well. This particularly improves conversions to and from CIELAB.
+## 3.6.0
+
+- **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)).
## Updated from 2.0 to 3.0?
diff --git a/mkdocs.yml b/mkdocs.yml
index f367ace2..99f96574 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -21,6 +21,7 @@ theme:
code: Roboto Mono
features:
- navigation.tabs
+ - navigation.top
nav:
- Getting Started:
diff --git a/support.py b/support.py
index edcc7c0e..c3e1dc5f 100644
--- a/support.py
+++ b/support.py
@@ -5,7 +5,7 @@
import webbrowser
import re
-__version__ = "3.5.0"
+__version__ = "3.6.1"
__pc_name__ = 'ColorHelper'
CSS = '''
@@ -68,24 +68,6 @@ def run(self):
except Exception:
info["mdpopups_version"] = 'Version could not be acquired!'
- try:
- import markdown
- info["markdown_version"] = format_version(markdown, 'version')
- except Exception:
- info["markdown_version"] = 'Version could not be acquired!'
-
- try:
- import jinja2
- info["jinja_version"] = format_version(jinja2, '__version__')
- except Exception:
- info["jinja_version"] = 'Version could not be acquired!'
-
- try:
- import pygments
- info["pygments_version"] = format_version(pygments, '__version__')
- except Exception:
- info["pygments_version"] = 'Version could not be acquired!'
-
msg = textwrap.dedent(
"""\
- ST ver.: %(version)s
@@ -94,9 +76,6 @@ def run(self):
- Plugin ver.: %(plugin_version)s
- Install via PC: %(pc_install)s
- mdpopups ver.: %(mdpopups_version)s
- - markdown ver.: %(markdown_version)s
- - pygments ver.: %(pygments_version)s
- - jinja2 ver.: %(jinja_version)s
""" % info
)
diff --git a/tests/test_json.py b/tests/test_json.py
index dc6c2bc9..e50facd9 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -14,7 +14,7 @@ def _get_json_files(self, pattern, folder='.'):
for root, dirnames, filenames in os.walk(folder):
for filename in fnmatch.filter(filenames, pattern):
yield os.path.join(root, filename)
- dirnames = [d for d in dirnames if d not in ('.svn', '.git', '.tox')]
+ dirnames[:] = [d for d in dirnames if d not in ('.svn', '.git', '.tox')]
def test_json_settings(self):
"""Test each JSON file."""
diff --git a/tox.ini b/tox.ini
index e05bf599..92dc1b52 100644
--- a/tox.ini
+++ b/tox.ini
@@ -27,6 +27,6 @@ commands=
flake8 "{toxinidir}"
[flake8]
-ignore=D202,D203,D401,W504,E741
+ignore=D202,D203,D401,W504,E741,N818
max-line-length=140
exclude=site/*.py,.tox/*,lib/coloraide/*