From b25cbdd3c764683c0fd08bee79c5c3d6f96768aa Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 11 Aug 2026 10:07:30 +0000 Subject: [PATCH 1/5] Fix boxplot conversion by mapping 'none' colors to transparent rgba --- plotly/matplotlylib/renderer.py | 20 ++++++++++++-------- plotly/matplotlylib/tests/test_renderer.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 57a466b80c..fabc662155 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -326,7 +326,7 @@ def draw_bar(self, coll): yaxis="y{0}".format(self.axis_ct), opacity=trace[0]["alpha"], # TODO: get all alphas if array? marker=go.bar.Marker( - color=trace[0]["facecolor"], # TODO: get all + color=_export_color(trace[0]["facecolor"]), # TODO: get all line=dict(width=trace[0]["edgewidth"]), ), ) # TODO ditto @@ -388,9 +388,13 @@ def draw_marked_line(self, **props): self.msg += "... with just markers\n" mode = "markers" if props["linestyle"]: - color = mpltools.merge_color_and_opacity( - props["linestyle"]["color"], props["linestyle"]["alpha"] - ) + if props["linestyle"]["color"] == "none": + # a fully transparent line; plotly rejects "none" as a color + color = "rgba(0,0,0,0)" + else: + color = mpltools.merge_color_and_opacity( + props["linestyle"]["color"], props["linestyle"]["alpha"] + ) if props["coordinates"] == "data": line = go.scatter.Line( @@ -410,22 +414,22 @@ def draw_marked_line(self, **props): if props["coordinates"] == "data": marker = go.scatter.Marker( opacity=props["markerstyle"]["alpha"], - color=props["markerstyle"]["facecolor"], + color=_export_color(props["markerstyle"]["facecolor"]), symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]), size=props["markerstyle"]["markersize"], line=dict( - color=props["markerstyle"]["edgecolor"], + color=_export_color(props["markerstyle"]["edgecolor"]), width=props["markerstyle"]["edgewidth"], ), ) else: shape = dict( opacity=props["markerstyle"]["alpha"], - fillcolor=props["markerstyle"]["facecolor"], + fillcolor=_export_color(props["markerstyle"]["facecolor"]), symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]), size=props["markerstyle"]["markersize"], line=dict( - color=props["markerstyle"]["edgecolor"], + color=_export_color(props["markerstyle"]["edgecolor"]), width=props["markerstyle"]["edgewidth"], ), ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 84fa105778..eb12f7e62d 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -107,6 +107,28 @@ def test_pcolor_rectangles_render(): assert all(len(t.x) >= 4 for t in plotly_fig.data) +def test_boxplot_converts_with_none_marker_facecolor(): + """Boxplot outlier markers use facecolor 'none', which plotly rejects.""" + fig, ax = plt.subplots() + ax.boxplot(np.random.randn(100, 4)) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) > 0 + + +def test_line_with_none_color_converts(): + """Lines with color='none' use the string 'none' for the line color, + which plotly rejects; it must be exported as a transparent line.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color="none") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + assert plotly_fig.data[0].line.color == "rgba(0,0,0,0)" + + def test_eventplot_segments_render(): fig, ax = plt.subplots() ax.eventplot([np.random.randn(20) for _ in range(5)]) From 15283d14783d9cc55abb1911785e2267399116db Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 07:34:20 +0000 Subject: [PATCH 2/5] Unify color export and handle rgba colors --- plotly/matplotlylib/mpltools.py | 46 +++++++++------------- plotly/matplotlylib/renderer.py | 25 ++++++------ plotly/matplotlylib/tests/test_renderer.py | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 35061e2552..e0c79f9ee6 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -12,6 +12,8 @@ from _plotly_utils.colors import hex_to_rgb +from plotly.matplotlylib.mplexporter.utils import export_color + def check_bar_match(old_bar, new_bar): """Check if two bars belong in the same collection (bar chart). @@ -106,26 +108,6 @@ def convert_symbol(mpl_symbol): return "circle" # default -def merge_color_and_opacity(color, opacity): - """ - Merge hex color with an alpha (opacity) to get an rgba tuple. - - :param (str|unicode) color: A hex color string. - :param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'. - :return: (int, int, int, float) The rgba color and alpha tuple. - - """ - if color is None: # None can be used as a placeholder, just bail. - return None - - rgb_tup = hex_to_rgb(color) - if opacity is None: - return "rgb{}".format(rgb_tup) - - rgba_tup = rgb_tup + (opacity,) - return "rgba{}".format(rgba_tup) - - def convert_va(mpl_va): """Convert mpl vertical alignment word to equivalent HTML word. @@ -516,19 +498,29 @@ def prep_ticks(ax, index, ax_type, props): return axis_dict -def _export_color(color): +def _export_color(color, opacity=None): """Export a matplotlib color for use as a plotly color. matplotlib uses "none" for fully transparent colors, which plotly does not - accept, so transparent colors are exported as transparent black. - Colors already exported by the mplexporter (hex or rgba strings) are - passed through unchanged. + accept, so transparent colors are exported as transparent black. Colors + already exported by the mplexporter (hex or rgba strings) keep the alpha + they carry unless an explicit opacity overrides it. """ if color is None: return None - if isinstance(color, str): - return color if color != "none" else "rgba(0,0,0,0)" - return [_export_color(c) for c in color] + if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color): + return [_export_color(c, opacity) for c in color] + if not isinstance(color, str): + color = export_color(color) + if color == "none": + return "rgba(0,0,0,0)" + if color.startswith("#") and opacity is not None: + return "rgba{0}".format(hex_to_rgb(color) + (opacity,)) + if color.startswith("rgb") and opacity not in (None, 1): + rgb = color[color.index("(") + 1 : color.rindex(")")].split(",") + r, g, b = (int(round(float(component))) for component in rgb[:3]) + return "rgba({0}, {1}, {2}, {3})".format(r, g, b, opacity) + return color def prep_xy_axis(ax, props, x_bounds, y_bounds): diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index fabc662155..ba7b533a8d 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -388,13 +388,9 @@ def draw_marked_line(self, **props): self.msg += "... with just markers\n" mode = "markers" if props["linestyle"]: - if props["linestyle"]["color"] == "none": - # a fully transparent line; plotly rejects "none" as a color - color = "rgba(0,0,0,0)" - else: - color = mpltools.merge_color_and_opacity( - props["linestyle"]["color"], props["linestyle"]["alpha"] - ) + color = _export_color( + props["linestyle"]["color"], props["linestyle"]["alpha"] + ) if props["coordinates"] == "data": line = go.scatter.Line( @@ -722,7 +718,8 @@ def draw_text(self, **props): yanchor=yanchor, showarrow=False, # change this later? font=go.layout.annotation.Font( - color=props["style"]["color"], size=props["style"]["fontsize"] + color=_export_color(props["style"]["color"]), + size=props["style"]["fontsize"], ), ) self.plotly_fig["layout"]["annotations"] += (annotation,) @@ -762,7 +759,8 @@ def draw_title(self, **props): annotation = go.layout.Annotation( text=props["text"], font=go.layout.annotation.Font( - color=props["style"]["color"], size=props["style"]["fontsize"] + color=_export_color(props["style"]["color"]), + size=props["style"]["fontsize"], ), xref="paper", yref="paper", @@ -777,7 +775,8 @@ def draw_title(self, **props): self.msg += " Only one subplot found, adding as a plotly title\n" self.plotly_fig["layout"]["title"] = props["text"] title_font = dict( - size=props["style"]["fontsize"], color=props["style"]["color"] + size=props["style"]["fontsize"], + color=_export_color(props["style"]["color"]), ) self.plotly_fig["layout"]["title_font"] = title_font @@ -808,7 +807,8 @@ def draw_xlabel(self, **props): axis_key = "xaxis{0}".format(self.axis_ct) self.plotly_fig["layout"][axis_key]["title"] = str(props["text"]) title_font = dict( - size=props["style"]["fontsize"], color=props["style"]["color"] + size=props["style"]["fontsize"], + color=_export_color(props["style"]["color"]), ) self.plotly_fig["layout"][axis_key]["title_font"] = title_font @@ -839,7 +839,8 @@ def draw_ylabel(self, **props): axis_key = "yaxis{0}".format(self.axis_ct) self.plotly_fig["layout"][axis_key]["title"] = props["text"] title_font = dict( - size=props["style"]["fontsize"], color=props["style"]["color"] + size=props["style"]["fontsize"], + color=_export_color(props["style"]["color"]), ) self.plotly_fig["layout"][axis_key]["title_font"] = title_font diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index eb12f7e62d..15b26bf9b9 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -129,6 +129,46 @@ def test_line_with_none_color_converts(): assert plotly_fig.data[0].line.color == "rgba(0,0,0,0)" +def test_line_with_rgba_color_converts(): + """Line colors that carry their own alpha (rgba tuple or 8-digit hex) + export as rgba strings.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.5)) + ax.plot([0, 1], [1, 0], color="#0000FF80") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)" + assert plotly_fig.data[1].line.color == "rgba(0, 0, 255, 0.5019607843137255)" + + +def test_line_rgba_color_with_separate_alpha_converts(): + """An explicit alpha overrides the alpha carried by the line color.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.2), alpha=0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)" + + +def test_transparent_text_colors_export(): + """Text, title, and axis labels with color 'none' export transparent + fonts.""" + fig, ax = plt.subplots() + ax.text(0.5, 0.5, "text", color="none") + ax.set_title("title", color="none") + ax.set_xlabel("xlabel", color="none") + ax.set_ylabel("ylabel", color="none") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.annotations[0].font.color == "rgba(0,0,0,0)" + assert plotly_fig.layout.title.font.color == "rgba(0,0,0,0)" + assert plotly_fig.layout.xaxis.title.font.color == "rgba(0,0,0,0)" + assert plotly_fig.layout.yaxis.title.font.color == "rgba(0,0,0,0)" + + def test_eventplot_segments_render(): fig, ax = plt.subplots() ax.eventplot([np.random.randn(20) for _ in range(5)]) From b7cef4a02467ee71d0831a847c1a0ff1ecdbfe2d Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 07:52:51 +0000 Subject: [PATCH 3/5] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 346a1459c6..5c7a3b9044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#5691](https://github.com/plotly/plotly.py/pull/5691)], with thanks to @hb1915 for the contribution! - Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution! - Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution! +- Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! ## [7.1.0] - 2026-09-15 From 497b22a4dbcdadaf87f27dcfdb243945ec9cca74 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 08:08:35 +0000 Subject: [PATCH 4/5] Add tests for color export --- plotly/matplotlylib/tests/test_renderer.py | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 15b26bf9b9..25e1c1ea89 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -169,6 +169,51 @@ def test_transparent_text_colors_export(): assert plotly_fig.layout.yaxis.title.font.color == "rgba(0,0,0,0)" +def test_export_color_maps_colors(): + """_export_color maps matplotlib color strings to plotly colors, keeping + or overriding the alpha as requested.""" + from plotly.matplotlylib.mpltools import _export_color + + expected_mappings = { + (None, None): None, + ("none", None): "rgba(0,0,0,0)", + ("#FF0000", None): "#FF0000", + ("#FF0000", 1): "rgba(255, 0, 0, 1)", + ("#FF0000", 0.5): "rgba(255, 0, 0, 0.5)", + ("rgba(255, 0, 0, 0.2)", None): "rgba(255, 0, 0, 0.2)", + ("rgba(255, 0, 0, 0.2)", 1): "rgba(255, 0, 0, 0.2)", + ("rgba(255, 0, 0, 0.2)", 0.5): "rgba(255, 0, 0, 0.5)", + ("rgb(255, 0, 0)", None): "rgb(255, 0, 0)", + ("rgb(255, 0, 0)", 0.5): "rgba(255, 0, 0, 0.5)", + ((1.0, 0.0, 0.0, 0.5), None): "rgba(255, 0, 0, 0.5)", + ((0.0, 1.0, 0.0, 1.0), None): "#00FF00", + } + for (color, opacity), expected in expected_mappings.items(): + result = _export_color(color, opacity) + assert result == expected, ( + f"Input {color!r} with opacity {opacity!r} produced {result!r}, " + f"expected {expected!r}" + ) + + assert _export_color(["#FF0000", "none"]) == ["#FF0000", "rgba(0,0,0,0)"] + assert _export_color(["rgba(255, 0, 0, 0.2)"], 0.5) == ["rgba(255, 0, 0, 0.5)"] + + +def test_scatter_with_multiple_colors_converts(): + """Scatter markers with per-point colors export a list of marker colors.""" + fig, ax = plt.subplots() + ax.scatter([0, 1, 2], [0, 1, 2], c=["red", "green", "blue"]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].mode == "markers" + assert plotly_fig.data[0].marker.color == ( + "rgba(255,0,0,1.0)", + "rgba(0,128,0,1.0)", + "rgba(0,0,255,1.0)", + ) + + def test_eventplot_segments_render(): fig, ax = plt.subplots() ax.eventplot([np.random.randn(20) for _ in range(5)]) From bbf39a863cac714c3ff31848ac65228abc97e397 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 7 Oct 2026 08:41:50 -0600 Subject: [PATCH 5/5] Update changelog order --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 303f2c926a..42622e66bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` drawing step plots with diagonal lines instead of vertical/horizontal step segments by mapping matplotlib step drawstyles (`steps-pre`, `steps-post`, `steps-mid`) to plotly line shapes [[#5697](https://github.com/plotly/plotly.py/pull/5697)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` showing unwanted legends with placeholder or internal label entries (`_nolegend_`, `_childN`) by defaulting `layout.showlegend=False` unless the matplotlib figure explicitly defines a legend [[#5699](https://github.com/plotly/plotly.py/pull/5699)], with thanks to @robertoffmoura for the contribution! -- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution! +- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! ## [7.1.0] - 2026-09-15