diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dca7b46bae..2d9dcf56a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [6.0.1] - 2025-01-13 + +### Added +- Support for HSL colors and Named colors. [#4957](https://github.com/plotly/plotly.py/issues/4957) +- Function to converge from HSL to RGB: `hsl_to_rgb`. + +### Fixed +- Fix a bug `validate_colors` that caused invalid colors within lists to pass without raising errors [#4957](https://github.com/plotly/plotly.py/issues/4957) + + ## [6.0.0rc0] - 2024-11-27 ### Added diff --git a/packages/python/plotly/_plotly_utils/colors/__init__.py b/packages/python/plotly/_plotly_utils/colors/__init__.py index 794c20d2e52..3d45721ed00 100644 --- a/packages/python/plotly/_plotly_utils/colors/__init__.py +++ b/packages/python/plotly/_plotly_utils/colors/__init__.py @@ -278,6 +278,22 @@ ], } +PLOTLY_NAMED_COLORS = { + "red": (255, 0, 0), + "blue": (0, 0, 255), + "green": (0, 255, 0), + "yellow": (255, 255, 0), + "black": (0, 0, 0), + "white": (255, 255, 255), + "purple": (128, 0, 128), + "orange": (255, 165, 0), + "pink": (255, 192, 203), + "brown": (165, 42, 42), + "gold": (255, 215, 0), + "silver": (192, 192, 192), + "gray": (128, 128, 128), +} + def color_parser(colors, function): """ @@ -322,12 +338,15 @@ def validate_colors(colors, colortype="tuple"): # color in the plotly colorscale. In resolving this issue we # will be removing the immediate line below colors = [colors_list[0]] + [colors_list[-1]] - elif "rgb" in colors or "#" in colors: + elif "rgb" in colors or "#" in colors or "hsl" in colors: colors = [colors] + elif colors in PLOTLY_NAMED_COLORS: + colors = [PLOTLY_NAMED_COLORS[colors]] + else: raise exceptions.PlotlyError( "If your colors variable is a string, it must be a " - "Plotly scale, an rgb color or a hex color." + "Plotly scale, a named color, an rgb color, a hex color, or an hsl color." ) elif isinstance(colors, tuple): @@ -355,6 +374,28 @@ def validate_colors(colors, colortype="tuple"): colors[j] = each_color + if "hsl" in each_color: + try: + each_color = color_parser(each_color, hsl_to_rgb) + except ValueError as e: + raise exceptions.PlotlyError(f"Invalid HSL color: {each_color}. {str(e)}") + each_color = color_parser(each_color, unconvert_from_RGB_255) + colors[j] = each_color + + elif isinstance(colors, list): + validated_colors = [] + for each_color in colors: + if each_color in NAMED_COLORS: + validated_colors.append(NAMED_COLORS[each_color]) + elif "rgb" in each_color or "#" in each_color or "hsl" in each_color: + validated_colors.append(each_color) + else: + raise exceptions.PlotlyError( + f"Invalid color in list: {each_color}. Each color must be a valid " + "Plotly scale, named color, rgb color, hex color, or hsl color." + ) + colors = validated_colors + if isinstance(each_color, tuple): for value in each_color: if value > 1.0: @@ -766,6 +807,65 @@ def hex_to_rgb(value): for i in range(0, hex_total_length, rgb_section_length) ) +def hsl_to_rgb(value): + """ + Converts an HSL color to RGB + + :param (string) value: HSL color string + + :rtype (tuple) (r_value, g_value, b_value): tuple of rgb values + """ + + def _hue_to_rgb(p, q, t): + """ + Helper function for hsl_to_rgb + """ + if t < 0: + t += 1 + if t > 1: + t -= 1 + if t < 1 / 6: + return p + (q - p) * 6 * t + if t < 1 / 2: + return q + if t < 2 / 3: + return p + (q - p) * (2 / 3 - t) * 6 + return p + + + value = value.replace(" ", "").lower() + + # Validate the format + if not value.startswith("hsl(") or not value.endswith(")"): + raise ValueError(f"Invalid HSL format: {value}. Expected format: 'hsl(h, s%, l%)'") + + try: + # Parsing the hsl string (usually of the form "hsl(h, s%, l%)") + h, s, l = value.lstrip("hsl(").rstrip(")%").split(",") + h, s, l = float(h) / 360, float(s.strip("%")) / 100, float(l.strip("%")) / 100 + except Exception: + raise ValueError(f"Malformed HSL string: {value}") + + if not (0 <= s <= 1 and 0 <= l <= 1): + raise ValueError(f"Saturation and lightness must be between 0% and 100%. Got: s={s*100}%, l={l*100}%") + + if not (0 <= h <= 1): + raise ValueError(f"Hue must be between 0 and 360. Got: {h*360}") + + + # Convert HSL to RGB + if s == 0: # Achromatic + r = g = b = l + + else: + q = l * (1 + s) if l < 0.5 else l + s - l * s + p = 2 * l - q + r = _hue_to_rgb(p, q, h + 1 / 3) + g = _hue_to_rgb(p, q, h) + b = _hue_to_rgb(p, q, h - 1 / 3) + + return (round(r * 255), round(g * 255), round(b * 255)) + def colorscale_to_colors(colorscale): """ diff --git a/packages/python/plotly/plotly/graph_objs/layout/_title.py b/packages/python/plotly/plotly/graph_objs/layout/_title.py index f1500f4c492..a3b1eeac13e 100644 --- a/packages/python/plotly/plotly/graph_objs/layout/_title.py +++ b/packages/python/plotly/plotly/graph_objs/layout/_title.py @@ -31,13 +31,13 @@ def automargin(self): margins. If `yref='paper'` then the margin will expand to ensure that the title doesn’t overlap with the edges of the container. If `yref='container'` then the margins will ensure - that the title doesn’t overlap with the plot area, tick labels, - and axis titles. If `automargin=true` and the margins need to - be expanded, then y will be set to a default 1 and yanchor will - be set to an appropriate default to ensure that minimal margin - space is needed. Note that when `yref='paper'`, only 1 or 0 are - allowed y values. Invalid values will be reset to the default - 1. + that the title doesn’t overlap with the plot area, tick + labels, and axis titles. If `automargin=true` and the margins + need to be expanded, then y will be set to a default 1 and + yanchor will be set to an appropriate default to ensure that + minimal margin space is needed. Note that when `yref='paper'`, + only 1 or 0 are allowed y values. Invalid values will be reset + to the default 1. The 'automargin' property must be specified as a bool (either True, or False) @@ -365,14 +365,14 @@ def _prop_descriptions(self): figure margins. If `yref='paper'` then the margin will expand to ensure that the title doesn’t overlap with the edges of the container. If `yref='container'` then - the margins will ensure that the title doesn’t overlap - with the plot area, tick labels, and axis titles. If - `automargin=true` and the margins need to be expanded, - then y will be set to a default 1 and yanchor will be - set to an appropriate default to ensure that minimal - margin space is needed. Note that when `yref='paper'`, - only 1 or 0 are allowed y values. Invalid values will - be reset to the default 1. + the margins will ensure that the title doesn’t + overlap with the plot area, tick labels, and axis + titles. If `automargin=true` and the margins need to be + expanded, then y will be set to a default 1 and yanchor + will be set to an appropriate default to ensure that + minimal margin space is needed. Note that when + `yref='paper'`, only 1 or 0 are allowed y values. + Invalid values will be reset to the default 1. font Sets the title font. pad @@ -449,14 +449,14 @@ def __init__( figure margins. If `yref='paper'` then the margin will expand to ensure that the title doesn’t overlap with the edges of the container. If `yref='container'` then - the margins will ensure that the title doesn’t overlap - with the plot area, tick labels, and axis titles. If - `automargin=true` and the margins need to be expanded, - then y will be set to a default 1 and yanchor will be - set to an appropriate default to ensure that minimal - margin space is needed. Note that when `yref='paper'`, - only 1 or 0 are allowed y values. Invalid values will - be reset to the default 1. + the margins will ensure that the title doesn’t + overlap with the plot area, tick labels, and axis + titles. If `automargin=true` and the margins need to be + expanded, then y will be set to a default 1 and yanchor + will be set to an appropriate default to ensure that + minimal margin space is needed. Note that when + `yref='paper'`, only 1 or 0 are allowed y values. + Invalid values will be reset to the default 1. font Sets the title font. pad diff --git a/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py b/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py index 2fb8e7831d9..c434f8e7f0f 100644 --- a/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py +++ b/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py @@ -237,3 +237,65 @@ def test_n_colors(self): ] self.assertEqual(generated_colorscale, expected_colorscale) + + def test_hsl_support(self): + # Test valid HSL input + valid_hsl = "hsl(240, 100%, 50%)" # Blue + expected_rgb = (0, 0, 255) + self.assertEqual(colors.hsl_to_rgb(valid_hsl), expected_rgb) + + # Test HSL input with spaces + valid_hsl_with_spaces = "hsl( 120 , 100% , 25% )" # Dark Green + expected_rgb_with_spaces = (0, 64, 0) + self.assertEqual( + colors.hsl_to_rgb(valid_hsl_with_spaces), expected_rgb_with_spaces + ) + + # Test malformed HSL input + invalid_hsl = "hsl(120, 100, 50%)" # Missing '%' + with self.assertRaises(ValueError): + colors.hsl_to_rgb(invalid_hsl) + + # Test HSL with out-of-range values + out_of_range_hsl = "hsl(400, 120%, 50%)" + with self.assertRaises(ValueError): + colors.hsl_to_rgb(out_of_range_hsl) + + # Test validate_colors with a list containing HSL + colors_list = ["hsl(0, 100%, 50%)", "hsl(120, 100%, 25%)"] + expected_colors = [(255, 0, 0), (0, 64, 0)] + self.assertEqual( + colors.validate_colors(colors_list, colortype="rgb"), expected_colors + ) + + # Test validate_colors with an invalid HSL in the list + invalid_colors_list = ["hsl(0, 100%, 50%)", "hsl(120, 100, 25%)"] + with self.assertRaises(PlotlyError): + colors.validate_colors(invalid_colors_list) + + def test_named_colors(self): + # Test valid named color + valid_named_color = "red" + self.assertEqual( + colors.validate_colors(valid_named_color), + [valid_named_color], + "Valid named color 'red' should pass validation.", + ) + + # Test invalid named color + invalid_named_color = "notacolor" + with self.assertRaisesRegex(PlotlyError, "Invalid named color: notacolor"): + colors.validate_colors(invalid_named_color) + + # Test list of valid named colors + valid_named_colors_list = ["red", "blue", "green"] + self.assertEqual( + colors.validate_colors(valid_named_colors_list), + valid_named_colors_list, + "Valid named colors list should pass validation.", + ) + + # Test list with an invalid named color + mixed_named_colors_list = ["red", "invalidcolor", "blue"] + with self.assertRaisesRegex(PlotlyError, "Invalid named color: invalidcolor"): + colors.validate_colors(mixed_named_colors_list)