From d8d4b6de38155ec663672c2c81d5e78f28896a0f Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 2 Sep 2026 20:44:14 +0000 Subject: [PATCH 1/6] Convert axhspan and axvspan to layout shapes --- plotly/matplotlylib/renderer.py | 58 ++++++++++++++++++++ plotly/matplotlylib/tests/test_renderer.py | 61 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..39231b443e 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -56,6 +56,21 @@ def _convert_collection_dash(linestyle): return ",".join("{0:g}px".format(d) for d in dashes) +def _has_blended_transform(transform): + """Check whether a matplotlib transform contains a blended transform.""" + if isinstance( + transform, (transforms.BlendedGenericTransform, transforms.BlendedAffine2D) + ): + return True + if hasattr(transform, "_a") and hasattr(transform, "_b"): + return _has_blended_transform(transform._a) or _has_blended_transform( + transform._b + ) + if hasattr(transform, "_transform"): + return _has_blended_transform(transform._transform) + return False + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -683,6 +698,39 @@ def _draw_axes_line(self, props): self.msg += " Heck yeah, I drew that reference line\n" return True + def _draw_axes_span(self, props): + """Draw a reference span (axhspan/axvspan) as a layout shape spanning + the region in data coordinates.""" + ax = self.current_mpl_ax + px_points = props["data"] + data_points = [ax.transData.inverted().transform(pt) for pt in px_points] + xs = [pt[0] for pt in data_points] + ys = [pt[1] for pt in data_points] + x0, x1 = float(min(xs)), float(max(xs)) + y0, y1 = float(min(ys)), float(max(ys)) + style = props["style"] + fillcolor = _export_color(style["facecolor"]) + edgecolor = _export_color(style["edgecolor"]) + linewidth = style["edgewidth"] if style["edgecolor"] != "none" else 0 + shape = go.layout.Shape( + type="rect", + x0=x0, + y0=y0, + x1=x1, + y1=y1, + xref="x{0}".format(self.axis_ct), + yref="y{0}".format(self.axis_ct), + fillcolor=fillcolor, + line=go.layout.shape.Line( + color=edgecolor, + width=linewidth, + dash=mpltools.convert_dash(style["dasharray"]), + ), + layer="below" if style.get("zorder", 1) < 2 else "above", + ) + self.plotly_fig["layout"]["shapes"] += (shape,) + self.msg += " Heck yeah, I drew that reference span\n" + def draw_image(self, **props): """Draw image. @@ -908,6 +956,16 @@ def draw_path(self, **props): elif isinstance(props["mplobj"], mpatches.StepPatch): self.msg += " Drawing a step path\n" self._draw_step_path(props) + elif ( + isinstance(props["mplobj"], (mpatches.Rectangle, mpatches.Polygon)) + and props["coordinates"] == "display" + and _has_blended_transform(props["mplobj"].get_transform()) + ): + if self._processing_legend: + self.msg += " Using native legend\n" + else: + self.msg += " Drawing an axes span\n" + self._draw_axes_span(props) else: self.msg += " This path isn't a bar, not drawing\n" warnings.warn( diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 90f73bff36..a53e104f22 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1392,3 +1392,64 @@ def test_mixed_style_lines_group_consecutive_matches(): else: # older matplotlib emits one collection per level assert dashes == ["5.55px,2.4px"] * 2 + ["solid"] * 2 + + +def test_axhspan_converts(): + """axhspan converts to a layout shape spanning the axes width.""" + fig, ax = plt.subplots() + ax.axhspan(0.25, 0.75) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 1 + shape = plotly_fig.layout.shapes[0] + assert shape.type == "rect" + x0, x1 = ax.get_xlim() + assert abs(shape.x0 - x0) < 1e-9 + assert abs(shape.x1 - x1) < 1e-9 + assert abs(shape.y0 - 0.25) < 1e-9 + assert abs(shape.y1 - 0.75) < 1e-9 + assert shape.xref == "x" + assert shape.yref == "y" + assert shape.fillcolor == "#1F77B4" + assert shape.layer == "below" + + +def test_axvspan_converts(): + """axvspan converts to a layout shape spanning the axes height.""" + fig, ax = plt.subplots() + ax.axvspan(0.25, 0.75) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 1 + shape = plotly_fig.layout.shapes[0] + assert shape.type == "rect" + y0, y1 = ax.get_ylim() + assert abs(shape.x0 - 0.25) < 1e-9 + assert abs(shape.x1 - 0.75) < 1e-9 + assert abs(shape.y0 - y0) < 1e-9 + assert abs(shape.y1 - y1) < 1e-9 + assert shape.xref == "x" + assert shape.yref == "y" + assert shape.fillcolor == "#1F77B4" + assert shape.layer == "below" + + +def test_axhspan_with_custom_style_converts(): + """axhspan with custom colors and alpha converts to a styled layout shape.""" + fig, ax = plt.subplots() + ax.axhspan(0.25, 0.75, facecolor="red", edgecolor="blue", alpha=0.5, linestyle="--") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 1 + shape = plotly_fig.layout.shapes[0] + assert shape.type == "rect" + assert shape.fillcolor == "rgba(255, 0, 0, 0.5)" + assert shape.line.color == "rgba(0, 0, 255, 0.5)" + assert shape.line.dash == "dash" + assert shape.line.width == 1.0 From 8a0df73f56b2cfa78eea4fbc4d5cddefe7e298b4 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 26 Oct 2026 14:53:53 +0000 Subject: [PATCH 2/6] Reference the axis domain for axes-fraction sides of spans --- plotly/matplotlylib/renderer.py | 51 +++++++++++++++------- plotly/matplotlylib/tests/test_renderer.py | 49 ++++++++++++++++----- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 39231b443e..cf932641f1 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -56,19 +56,26 @@ def _convert_collection_dash(linestyle): return ",".join("{0:g}px".format(d) for d in dashes) -def _has_blended_transform(transform): - """Check whether a matplotlib transform contains a blended transform.""" +def _find_blended_transform(transform): + """Return the first blended transform within a matplotlib transform, or + None when it contains no blended transform.""" if isinstance( transform, (transforms.BlendedGenericTransform, transforms.BlendedAffine2D) ): - return True + return transform if hasattr(transform, "_a") and hasattr(transform, "_b"): - return _has_blended_transform(transform._a) or _has_blended_transform( - transform._b - ) + blended = _find_blended_transform(transform._a) + if blended is None: + blended = _find_blended_transform(transform._b) + return blended if hasattr(transform, "_transform"): - return _has_blended_transform(transform._transform) - return False + return _find_blended_transform(transform._transform) + return None + + +def _has_blended_transform(transform): + """Check whether a matplotlib transform contains a blended transform.""" + return _find_blended_transform(transform) is not None class PlotlyRenderer(Renderer): @@ -699,15 +706,29 @@ def _draw_axes_line(self, props): return True def _draw_axes_span(self, props): - """Draw a reference span (axhspan/axvspan) as a layout shape spanning - the region in data coordinates.""" + """Draw a reference span (axhspan/axvspan) as a layout shape. + + Sides given in axes fractions (e.g. axhspan xmin/xmax) map onto the + axis domain, so they stay fixed to the axes on pan/zoom; sides given + in data coordinates map onto the data axis.""" ax = self.current_mpl_ax px_points = props["data"] - data_points = [ax.transData.inverted().transform(pt) for pt in px_points] - xs = [pt[0] for pt in data_points] - ys = [pt[1] for pt in data_points] + blended = _find_blended_transform(props["mplobj"].get_transform()) + x_in_axes = blended._x == ax.transAxes + y_in_axes = blended._y == ax.transAxes + axes_points = ax.transAxes.inverted().transform(px_points) + data_points = ax.transData.inverted().transform(px_points) + xs = (axes_points if x_in_axes else data_points)[:, 0] + ys = (axes_points if y_in_axes else data_points)[:, 1] x0, x1 = float(min(xs)), float(max(xs)) y0, y1 = float(min(ys)), float(max(ys)) + axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else "" + xref = "x{0}".format(axis_suffix) + yref = "y{0}".format(axis_suffix) + if x_in_axes: + xref += " domain" + if y_in_axes: + yref += " domain" style = props["style"] fillcolor = _export_color(style["facecolor"]) edgecolor = _export_color(style["edgecolor"]) @@ -718,8 +739,8 @@ def _draw_axes_span(self, props): y0=y0, x1=x1, y1=y1, - xref="x{0}".format(self.axis_ct), - yref="y{0}".format(self.axis_ct), + xref=xref, + yref=yref, fillcolor=fillcolor, line=go.layout.shape.Line( color=edgecolor, diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index a53e104f22..21a06f538e 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1395,7 +1395,7 @@ def test_mixed_style_lines_group_consecutive_matches(): def test_axhspan_converts(): - """axhspan converts to a layout shape spanning the axes width.""" + """axhspan converts to a layout shape spanning the x axis domain.""" fig, ax = plt.subplots() ax.axhspan(0.25, 0.75) @@ -1405,19 +1405,18 @@ def test_axhspan_converts(): assert len(plotly_fig.layout.shapes) == 1 shape = plotly_fig.layout.shapes[0] assert shape.type == "rect" - x0, x1 = ax.get_xlim() - assert abs(shape.x0 - x0) < 1e-9 - assert abs(shape.x1 - x1) < 1e-9 + assert abs(shape.x0 - 0) < 1e-9 + assert abs(shape.x1 - 1) < 1e-9 assert abs(shape.y0 - 0.25) < 1e-9 assert abs(shape.y1 - 0.75) < 1e-9 - assert shape.xref == "x" + assert shape.xref == "x domain" assert shape.yref == "y" assert shape.fillcolor == "#1F77B4" assert shape.layer == "below" def test_axvspan_converts(): - """axvspan converts to a layout shape spanning the axes height.""" + """axvspan converts to a layout shape spanning the y axis domain.""" fig, ax = plt.subplots() ax.axvspan(0.25, 0.75) @@ -1427,17 +1426,47 @@ def test_axvspan_converts(): assert len(plotly_fig.layout.shapes) == 1 shape = plotly_fig.layout.shapes[0] assert shape.type == "rect" - y0, y1 = ax.get_ylim() assert abs(shape.x0 - 0.25) < 1e-9 assert abs(shape.x1 - 0.75) < 1e-9 - assert abs(shape.y0 - y0) < 1e-9 - assert abs(shape.y1 - y1) < 1e-9 + assert abs(shape.y0 - 0) < 1e-9 + assert abs(shape.y1 - 1) < 1e-9 assert shape.xref == "x" - assert shape.yref == "y" + assert shape.yref == "y domain" assert shape.fillcolor == "#1F77B4" assert shape.layer == "below" +def test_axhspan_axes_fractions_convert(): + """axhspan xmin/xmax convert to x axis domain fractions and axvspan + ymin/ymax convert to y axis domain fractions.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10]) + ax.axhspan(2, 4, xmin=0.2, xmax=0.6) + ax.axvspan(6, 8, ymin=0.1, ymax=0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + hspan, vspan = plotly_fig.layout.shapes + assert hspan.xref == "x domain" + assert hspan.yref == "y" + assert np.allclose([hspan.x0, hspan.x1, hspan.y0, hspan.y1], [0.2, 0.6, 2, 4]) + assert vspan.xref == "x" + assert vspan.yref == "y domain" + assert np.allclose([vspan.x0, vspan.x1, vspan.y0, vspan.y1], [6, 8, 0.1, 0.5]) + + +def test_axhspan_on_second_subplot_references_its_domain(): + """Spans on a second subplot reference that subplot's axis domain.""" + fig, (ax1, ax2) = plt.subplots(1, 2) + ax2.axhspan(2, 4) + + plotly_fig = tls.mpl_to_plotly(fig) + + (shape,) = plotly_fig.layout.shapes + assert shape.xref == "x2 domain" + assert shape.yref == "y2" + + def test_axhspan_with_custom_style_converts(): """axhspan with custom colors and alpha converts to a styled layout shape.""" fig, ax = plt.subplots() From 21d293564345e8233be3111a808a3782ee6b211c Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 26 Oct 2026 14:57:19 +0000 Subject: [PATCH 3/6] Convert only axis-aligned rectangles to span shapes --- plotly/matplotlylib/renderer.py | 40 +++++++++++++++++++--- plotly/matplotlylib/tests/test_renderer.py | 25 ++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index cf932641f1..9788a7df67 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -78,6 +78,27 @@ def _has_blended_transform(transform): return _find_blended_transform(transform) is not None +def _is_axis_aligned_rectangle(points): + """Check whether polygon vertices trace an axis-aligned rectangle: every + vertex is a corner of the bounding box and all four corners appear.""" + xs = [float(x) for x, _ in points] + ys = [float(y) for _, y in points] + if not xs: + return False + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + corners = set() + for x, y in zip(xs, ys): + at_x_min = math.isclose(x, x_min, abs_tol=1e-9) + at_x_max = math.isclose(x, x_max, abs_tol=1e-9) + at_y_min = math.isclose(y, y_min, abs_tol=1e-9) + at_y_max = math.isclose(y, y_max, abs_tol=1e-9) + if not (at_x_min or at_x_max) or not (at_y_min or at_y_max): + return False + corners.add((at_x_max, at_y_max)) + return len(corners) == 4 + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -705,6 +726,19 @@ def _draw_axes_line(self, props): self.msg += " Heck yeah, I drew that reference line\n" return True + def _is_axes_span(self, props): + """Check whether a path qualifies as a reference span (e.g. axhspan, + axvspan): an axis-aligned rectangle in a blended axes/data + transform.""" + if not isinstance(props["mplobj"], (mpatches.Rectangle, mpatches.Polygon)): + return False + if not ( + props["coordinates"] == "display" + and _has_blended_transform(props["mplobj"].get_transform()) + ): + return False + return _is_axis_aligned_rectangle(props["data"]) + def _draw_axes_span(self, props): """Draw a reference span (axhspan/axvspan) as a layout shape. @@ -977,11 +1011,7 @@ def draw_path(self, **props): elif isinstance(props["mplobj"], mpatches.StepPatch): self.msg += " Drawing a step path\n" self._draw_step_path(props) - elif ( - isinstance(props["mplobj"], (mpatches.Rectangle, mpatches.Polygon)) - and props["coordinates"] == "display" - and _has_blended_transform(props["mplobj"].get_transform()) - ): + elif self._is_axes_span(props): if self._processing_legend: self.msg += " Using native legend\n" else: diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 21a06f538e..7c14e169de 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -5,6 +5,7 @@ import matplotlib import matplotlib.pyplot as plt import matplotlib.dates as mdates +import matplotlib.patches as mpatches from packaging.version import Version from matplotlib import transforms import plotly.tools as tls @@ -1482,3 +1483,27 @@ def test_axhspan_with_custom_style_converts(): assert shape.line.color == "rgba(0, 0, 255, 0.5)" assert shape.line.dash == "dash" assert shape.line.width == 1.0 + + +def test_blended_unaligned_shapes_do_not_convert_to_spans(): + """A triangle and a rotated rectangle in a blended axes/data transform + warn and leave the layout shapes empty.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10]) + ax.add_patch( + mpatches.Polygon( + [[5, 0.1], [9, 0.1], [7, 0.5]], + transform=ax.get_xaxis_transform(), + color="b", + ) + ) + ax.add_patch( + mpatches.Rectangle( + (6, 0.6), 2, 0.2, angle=30, transform=ax.get_xaxis_transform(), color="g" + ) + ) + + with pytest.warns(UserWarning, match="path object"): + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.shapes) == 0 From d823d7eb035b44e9171b8ca7ffe3afac52a24198 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 26 Oct 2026 14:58:42 +0000 Subject: [PATCH 4/6] Convert date x-values of axvspan shapes --- plotly/matplotlylib/renderer.py | 2 ++ plotly/matplotlylib/tests/test_renderer.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 9788a7df67..b0b826a565 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -756,6 +756,8 @@ def _draw_axes_span(self, props): ys = (axes_points if y_in_axes else data_points)[:, 1] x0, x1 = float(min(xs)), float(max(xs)) y0, y1 = float(min(ys)), float(max(ys)) + if self.x_is_mpl_date and not x_in_axes: + x0, x1 = self._convert_x_dates([x0, x1]) axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else "" xref = "x{0}".format(axis_suffix) yref = "y{0}".format(axis_suffix) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 7c14e169de..dfc15b56df 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1507,3 +1507,21 @@ def test_blended_unaligned_shapes_do_not_convert_to_spans(): plotly_fig = tls.mpl_to_plotly(fig) assert len(plotly_fig.layout.shapes) == 0 + + +def test_axvspan_on_date_xaxis_converts_to_dates(): + """axvspan on a date x axis exports its x extent as date strings.""" + dates = [datetime.datetime(2023, 1, i) for i in range(1, 10)] + fig, ax = plt.subplots() + ax.plot(dates, range(len(dates))) + ax.axvspan(dates[2], dates[5]) + ax.axhspan(2, 4) + + plotly_fig = tls.mpl_to_plotly(fig) + + vspan, hspan = plotly_fig.layout.shapes + assert vspan.xref == "x" + assert vspan.x0.startswith("2023-01-03") + assert vspan.x1.startswith("2023-01-06") + assert hspan.xref == "x domain" + assert np.allclose([hspan.x0, hspan.x1], [0, 1]) From b7c89669c6b4e9736f2d7d6345d2fa0f88ea92f1 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 26 Oct 2026 14:59:36 +0000 Subject: [PATCH 5/6] Show labeled spans in the legend --- plotly/matplotlylib/renderer.py | 5 +++++ plotly/matplotlylib/tests/test_renderer.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index b0b826a565..9bb1945999 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -769,6 +769,9 @@ def _draw_axes_span(self, props): fillcolor = _export_color(style["facecolor"]) edgecolor = _export_color(style["edgecolor"]) linewidth = style["edgewidth"] if style["edgecolor"] != "none" else 0 + label = props["mplobj"].get_label() + if not label or (isinstance(label, str) and label.startswith("_")): + label = None shape = go.layout.Shape( type="rect", x0=x0, @@ -784,6 +787,8 @@ def _draw_axes_span(self, props): dash=mpltools.convert_dash(style["dasharray"]), ), layer="below" if style.get("zorder", 1) < 2 else "above", + name=label, + showlegend=True if label is not None else None, ) self.plotly_fig["layout"]["shapes"] += (shape,) self.msg += " Heck yeah, I drew that reference span\n" diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index dfc15b56df..5c816aca76 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1525,3 +1525,19 @@ def test_axvspan_on_date_xaxis_converts_to_dates(): assert vspan.x1.startswith("2023-01-06") assert hspan.xref == "x domain" assert np.allclose([hspan.x0, hspan.x1], [0, 1]) + + +def test_labeled_axvspan_shows_in_legend(): + """A labeled span exports its label as the shape legend entry.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10], label="line") + ax.axvspan(2, 4, label="span") + ax.axhspan(6, 8) + ax.legend() + + plotly_fig = tls.mpl_to_plotly(fig) + + labeled, unlabeled = plotly_fig.layout.shapes + assert labeled.name == "span" + assert labeled.showlegend is True + assert unlabeled.showlegend is None From 344760d43cd032aef079f5e468d3c2a4c97f0005 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 26 Oct 2026 15:01:26 +0000 Subject: [PATCH 6/6] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..abf88523cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution! +- Add support for converting matplotlib reference spans (`axhspan` and `axvspan`) into Plotly layout shapes in `mpl_to_plotly`, with axes-fraction extents (`xmin`/`xmax`, `ymin`/`ymax`) on the axis domain, support for date and log axes, and legend entries for labeled spans, with thanks to @robertoffmoura for the contribution! ### Fixed - Fix `mpl_to_plotly` tick marker mirroring, axis positioning, and trace visibility for twinned and overlaid subplots (such as `twinx` and `twiny`) [[#5310](https://github.com/plotly/plotly.py/pull/5310)], with thanks to @robertoffmoura for the contribution!