From baa338c3fc9b6d3842f63b95bc3a94a8e2afd302 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Aug 2026 15:40:53 +0200 Subject: [PATCH 1/3] Added more tikz primitives --- src/maxplotlib/canvas/canvas.py | 182 ++++++++++++++++++- src/maxplotlib/subfigure/line_plot.py | 246 +++++++++++++++++++++++++- src/maxplotlib/tests/test_canvas.py | 56 ++++++ tutorials/tutorial_07_tikz.ipynb | 117 +++++++++++- 4 files changed, 588 insertions(+), 13 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index fbcda42..56944cf 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -18,7 +18,13 @@ from maxplotlib.backends.plotext import PlotextFigure, create_plotext_figure from maxplotlib.colors.colors import Color from maxplotlib.linestyle.linestyle import Linestyle -from maxplotlib.subfigure.line_plot import LinePlot +from maxplotlib.subfigure.line_plot import ( + LinePlot, + _TIKZ_SUPPORTED_PLOT_TYPES, + _tikz_error_bounds, + _tikz_step_coordinates, + _tikz_style_kwargs, +) from maxplotlib.utils.options import Backends @@ -2269,7 +2275,12 @@ def plot_tikzfigure( # Add each plot line to the subfigure for line_data in line_plot.line_data: - if line_data.get("plot_type") == "plot": + plot_type = line_data.get("plot_type") + if plot_type not in _TIKZ_SUPPORTED_PLOT_TYPES: + raise NotImplementedError( + f"{plot_type} is not supported by the tikzfigure backend" + ) + if plot_type == "plot": # Extract and transform x, y data x = (line_data["x"] + line_plot._xshift) * line_plot._xscale y = (line_data["y"] + line_plot._yshift) * line_plot._yscale @@ -2280,11 +2291,170 @@ def plot_tikzfigure( ax.add_plot( x=x, y=y, - # label=kwargs.get("label", ""), - color=kwargs.get("color", "black"), - line_width=kwargs.get("linewidth", 1.0), + **_tikz_style_kwargs(kwargs), + ) + elif plot_type == "scatter": + x = (line_data["x"] + line_plot._xshift) * line_plot._xscale + y = (line_data["y"] + line_plot._yshift) * line_plot._yscale + kwargs = _tikz_style_kwargs(line_data.get("kwargs", {})) + kwargs.setdefault("mark", "*") + kwargs["line_width"] = 0 + ax.add_plot(x=x, y=y, **kwargs) + elif plot_type in {"bar", "barh"}: + source_kwargs = line_data.get("kwargs", {}) + kwargs = _tikz_style_kwargs(source_kwargs) + kwargs["fill"] = source_kwargs.get("color", "blue") + kwargs["fill_opacity"] = source_kwargs.get("alpha", 1.0) + kwargs["line_width"] = source_kwargs.get("linewidth", 0) + if plot_type == "bar": + width = source_kwargs.get("width", 0.8) + for x, height in zip(line_data["x"], line_data["height"]): + ax.add_plot( + x=[x - width / 2, x + width / 2, x + width / 2, x - width / 2], + y=[0, 0, height, height], + cycle=True, + **kwargs, + ) + else: + height = source_kwargs.get("height", 0.8) + for y, width in zip(line_data["y"], line_data["width"]): + ax.add_plot( + x=[0, width, width, 0], + y=[y - height / 2, y - height / 2, y + height / 2, y + height / 2], + cycle=True, + **kwargs, + ) + elif plot_type == "fill_between": + x = line_data["x"] + y1 = np.asarray(line_data["y1"]) + y2 = np.broadcast_to(line_data["y2"], y1.shape) + source_kwargs = line_data.get("kwargs", {}) + kwargs = _tikz_style_kwargs(source_kwargs) + kwargs["fill"] = source_kwargs.get("color", "blue") + kwargs["fill_opacity"] = source_kwargs.get("alpha", 0.25) + ax.add_plot( + x=list(x) + list(x[::-1]), + y=list(y1) + list(y2[::-1]), + cycle=True, + **kwargs, + ) + elif plot_type == "errorbar": + x = line_data["x"] + y = line_data["y"] + kwargs = _tikz_style_kwargs(line_data.get("kwargs", {})) + ax.add_plot(x=x, y=y, **kwargs) + y_bounds = _tikz_error_bounds(line_data["yerr"], y) + if y_bounds is not None: + lower, upper = y_bounds + for xi, low, high in zip(x, y - lower, y + upper): + ax.add_plot(x=[xi, xi], y=[low, high], **kwargs) + x_bounds = _tikz_error_bounds(line_data["xerr"], x) + if x_bounds is not None: + lower, upper = x_bounds + for yi, low, high in zip(y, x - lower, x + upper): + ax.add_plot(x=[low, high], y=[yi, yi], **kwargs) + elif plot_type in {"step", "stairs"}: + source_kwargs = line_data.get("kwargs", {}) + if plot_type == "step": + x = line_data["x"] + y = line_data["y"] + where = source_kwargs.get("where", "pre") + else: + values = line_data["values"] + edges = line_data["edges"] + if edges is None: + edges = np.arange(len(values) + 1) + x = edges + y = np.r_[values, values[-1]] + where = "post" + x, y = _tikz_step_coordinates(x, y, where=where) + ax.add_plot( + x=x, + y=y, + **_tikz_style_kwargs(source_kwargs), ) - elif line_data.get("plot_type") == "gantt": + elif plot_type == "stem": + x = line_data["x"] + y = line_data["y"] + source_kwargs = line_data.get("kwargs", {}) + style = _tikz_style_kwargs(source_kwargs) + marker_style = dict(style) + marker_style.update(mark=source_kwargs.get("marker", "*"), line_width=0) + ax.add_plot(x=x, y=y, **marker_style) + for xi, yi in zip(x, y): + ax.add_plot(x=[xi, xi], y=[0, yi], **style) + elif plot_type in {"hlines", "vlines"}: + style = _tikz_style_kwargs(line_data.get("kwargs", {})) + if plot_type == "hlines": + for yi, left, right in zip( + np.atleast_1d(line_data["y"]), + np.atleast_1d(line_data["xmin"]), + np.atleast_1d(line_data["xmax"]), + ): + ax.add_plot(x=[left, right], y=[yi, yi], **style) + else: + for xi, bottom, top in zip( + np.atleast_1d(line_data["x"]), + np.atleast_1d(line_data["ymin"]), + np.atleast_1d(line_data["ymax"]), + ): + ax.add_plot(x=[xi, xi], y=[bottom, top], **style) + elif plot_type in {"axvspan", "axhspan"}: + source_kwargs = line_data.get("kwargs", {}) + style = _tikz_style_kwargs(source_kwargs) + style["fill"] = source_kwargs.get("color", "blue") + style["fill_opacity"] = source_kwargs.get("alpha", 0.2) + if plot_type == "axvspan": + xmin, xmax = line_data["xmin"], line_data["xmax"] + ymin, ymax = line_plot._ymin or 0, line_plot._ymax or 1 + x = [xmin, xmax, xmax, xmin] + y = [ymin, ymin, ymax, ymax] + else: + ymin, ymax = line_data["ymin"], line_data["ymax"] + xmin, xmax = line_plot._xmin or 0, line_plot._xmax or 1 + x = [xmin, xmax, xmax, xmin] + y = [ymin, ymin, ymax, ymax] + ax.add_plot(x=x, y=y, cycle=True, **style) + elif plot_type == "fill": + if len(line_data["args"]) < 2: + raise ValueError("tikzfigure fill requires x and y coordinates") + x, y = line_data["args"][:2] + source_kwargs = line_data.get("kwargs", {}) + style = _tikz_style_kwargs(source_kwargs) + style["fill"] = source_kwargs.get("color", "blue") + style["fill_opacity"] = source_kwargs.get("alpha", 0.25) + ax.add_plot(x=x, y=y, cycle=True, **style) + elif plot_type == "flame_chart": + labels = line_data["labels"] + parents = line_data["parents"] + values = line_data["values"] * line_plot._xscale + start_times = line_data["start_times"] + depths = np.zeros(len(labels), dtype=int) + if start_times is None: + start_times = np.zeros(len(labels)) + else: + start_times = ( + start_times + line_plot._xshift + ) * line_plot._xscale + for index, parent in enumerate(parents): + if parent is not None: + parent_index = ( + parent + if isinstance(parent, int) + else labels.index(parent) + ) + depths[index] = depths[parent_index] + 1 + colors = ["red", "blue", "green", "orange", "purple", "cyan"] + for index, (start, value) in enumerate(zip(start_times, values)): + y = depths[index] + ax.add_plot( + x=[start, start + value, start + value, start], + y=[y - 0.4, y - 0.4, y + 0.4, y + 0.4], + cycle=True, + fill=colors[y % len(colors)], + line_width=0, + ) + elif plot_type == "gantt": tasks = line_data["tasks"] start_times = ( line_data["start_times"] + line_plot._xshift diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index fb43e5b..8f34c0d 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -5,6 +5,84 @@ from tikzfigure import TikzFigure +_TIKZ_SUPPORTED_PLOT_TYPES = { + "plot", + "scatter", + "bar", + "barh", + "fill_between", + "errorbar", + "step", + "stairs", + "stem", + "hlines", + "vlines", + "axvspan", + "axhspan", + "fill", + "gantt", + "flame_chart", +} + + +def _tikz_style_kwargs(kwargs, *, default_color="black"): + """Translate common Matplotlib-style options to pgfplots/TikZ options.""" + kwargs = dict(kwargs) + style = {} + if kwargs.get("color") is not None: + style["color"] = kwargs["color"] + else: + style["color"] = default_color + if kwargs.get("linewidth") is not None: + style["line_width"] = kwargs["linewidth"] + if kwargs.get("alpha") is not None: + style["opacity"] = kwargs["alpha"] + if kwargs.get("linestyle") in {"--", "dashed"}: + style["dash_pattern"] = "on 4pt off 2pt" + elif kwargs.get("linestyle") in {":", "dotted"}: + style["dash_pattern"] = "on 1pt off 2pt" + elif kwargs.get("linestyle") == "-.": + style["dash_pattern"] = "on 4pt off 2pt on 1pt off 2pt" + if kwargs.get("marker") is not None: + style["mark"] = kwargs["marker"] + if kwargs.get("markersize") is not None: + style["mark_size"] = f"{kwargs['markersize']}pt" + return style + + +def _tikz_error_bounds(error, values): + """Return lower and upper error arrays in Matplotlib's common formats.""" + if error is None: + return None + error = np.asarray(error, dtype=float) + values = np.asarray(values, dtype=float) + if error.ndim == 0: + error = np.full(values.shape, error.item()) + if error.ndim == 2 and error.shape[0] == 2: + return error[0], error[1] + return error, error + + +def _tikz_step_coordinates(x, y, where="pre"): + """Expand line data into explicit coordinates for a stepped path.""" + x = np.asarray(x) + y = np.asarray(y) + if len(x) < 2: + return x, y + if where == "post": + step_x = np.repeat(x, 2)[1:] + step_y = np.repeat(y, 2)[:-1] + elif where == "mid": + mids = (x[:-1] + x[1:]) / 2 + step_x = np.ravel(np.column_stack((x[:-1], mids, mids, x[1:]))) + step_y = np.ravel(np.column_stack((y[:-1], y[:-1], y[1:], y[1:]))) + return step_x, step_y + else: + step_x = np.repeat(x, 2)[:-1] + step_y = np.repeat(y, 2)[1:] + return step_x, step_y + + class Node: def __init__(self, x, y, label="", content="", layer=0, **kwargs): self.x = x @@ -1620,12 +1698,176 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: if layers and layer_name not in layers: continue for line in layer_lines: - if line["plot_type"] == "plot": + plot_type = line["plot_type"] + if plot_type not in _TIKZ_SUPPORTED_PLOT_TYPES: + raise NotImplementedError( + f"{plot_type} is not supported by the tikzfigure backend" + ) + if plot_type == "plot": x = (line["x"] + self._xshift) * self._xscale y = (line["y"] + self._yshift) * self._yscale nodes = [[xi, yi] for xi, yi in zip(x, y)] - tikz_figure.draw(nodes=nodes, **line["kwargs"]) + tikz_figure.draw( + nodes=nodes, + **_tikz_style_kwargs(line["kwargs"]), + ) + elif plot_type == "scatter": + x = (line["x"] + self._xshift) * self._xscale + y = (line["y"] + self._yshift) * self._yscale + style = _tikz_style_kwargs(line["kwargs"]) + style.setdefault("mark", "*") + style["line_width"] = 0 + tikz_figure.draw( + nodes=[[xi, yi] for xi, yi in zip(x, y)], + **style, + ) + elif plot_type in {"bar", "barh"}: + kwargs = line["kwargs"] + style = _tikz_style_kwargs(kwargs) + style["fill"] = kwargs.get("color", "blue") + style["fill_opacity"] = kwargs.get("alpha", 1.0) + style["line_width"] = kwargs.get("linewidth", 0) + if plot_type == "bar": + width = kwargs.get("width", 0.8) + for x, height in zip(line["x"], line["height"]): + x = (x + self._xshift) * self._xscale + height = height * self._yscale + tikz_figure.draw( + nodes=[ + [x - width / 2, 0], + [x + width / 2, 0], + [x + width / 2, height], + [x - width / 2, height], + ], + cycle=True, + **style, + ) + else: + height = kwargs.get("height", 0.8) + for y, width in zip(line["y"], line["width"]): + y = (y + self._yshift) * self._yscale + width = width * self._xscale + tikz_figure.draw( + nodes=[ + [0, y - height / 2], + [width, y - height / 2], + [width, y + height / 2], + [0, y + height / 2], + ], + cycle=True, + **style, + ) + elif plot_type == "fill_between": + x = (line["x"] + self._xshift) * self._xscale + y1 = np.asarray(line["y1"]) + y2 = np.broadcast_to(line["y2"], y1.shape) + nodes = [[xi, yi] for xi, yi in zip(x, y1)] + nodes.extend([[xi, yi] for xi, yi in zip(x[::-1], y2[::-1])]) + kwargs = line["kwargs"] + style = _tikz_style_kwargs(kwargs) + style["fill"] = kwargs.get("color", "blue") + style["fill_opacity"] = kwargs.get("alpha", 0.25) + tikz_figure.draw(nodes=nodes, cycle=True, **style) + elif plot_type == "errorbar": + x = (line["x"] + self._xshift) * self._xscale + y = (line["y"] + self._yshift) * self._yscale + style = _tikz_style_kwargs(line["kwargs"]) + tikz_figure.draw(nodes=[[xi, yi] for xi, yi in zip(x, y)], **style) + y_bounds = _tikz_error_bounds(line["yerr"], y) + if y_bounds is not None: + lower, upper = y_bounds + for xi, low, high in zip(x, y - lower, y + upper): + tikz_figure.draw(nodes=[[xi, low], [xi, high]], **style) + x_bounds = _tikz_error_bounds(line["xerr"], x) + if x_bounds is not None: + lower, upper = x_bounds + for yi, low, high in zip(y, x - lower, x + upper): + tikz_figure.draw(nodes=[[low, yi], [high, yi]], **style) + elif plot_type in {"step", "stairs"}: + kwargs = line["kwargs"] + if plot_type == "step": + x = line["x"] + y = line["y"] + where = kwargs.get("where", "pre") + else: + values = line["values"] + edges = line["edges"] + if edges is None: + edges = np.arange(len(values) + 1) + x = edges + y = np.r_[values, values[-1]] + where = "post" + x, y = _tikz_step_coordinates(x, y, where=where) + x = (x + self._xshift) * self._xscale + y = (y + self._yshift) * self._yscale + tikz_figure.draw( + nodes=[[xi, yi] for xi, yi in zip(x, y)], + **_tikz_style_kwargs(kwargs), + ) + elif plot_type == "stem": + x = (line["x"] + self._xshift) * self._xscale + y = (line["y"] + self._yshift) * self._yscale + kwargs = line["kwargs"] + style = _tikz_style_kwargs(kwargs) + marker_style = dict(style) + marker_style.update(mark=kwargs.get("marker", "*"), line_width=0) + tikz_figure.draw( + nodes=[[xi, yi] for xi, yi in zip(x, y)], **marker_style + ) + for xi, yi in zip(x, y): + tikz_figure.draw(nodes=[[xi, 0], [xi, yi]], **style) + elif plot_type in {"hlines", "vlines"}: + kwargs = _tikz_style_kwargs(line["kwargs"]) + if plot_type == "hlines": + for yi, left, right in zip( + np.atleast_1d(line["y"]), + np.atleast_1d(line["xmin"]), + np.atleast_1d(line["xmax"]), + ): + tikz_figure.draw(nodes=[[left, yi], [right, yi]], **kwargs) + else: + for xi, bottom, top in zip( + np.atleast_1d(line["x"]), + np.atleast_1d(line["ymin"]), + np.atleast_1d(line["ymax"]), + ): + tikz_figure.draw(nodes=[[xi, bottom], [xi, top]], **kwargs) + elif plot_type in {"axvspan", "axhspan"}: + kwargs = line["kwargs"] + style = _tikz_style_kwargs(kwargs) + style["fill"] = kwargs.get("color", "blue") + style["fill_opacity"] = kwargs.get("alpha", 0.2) + if plot_type == "axvspan": + ymin, ymax = self._ymin or 0, self._ymax or 1 + nodes = [ + [line["xmin"], ymin], + [line["xmax"], ymin], + [line["xmax"], ymax], + [line["xmin"], ymax], + ] + else: + xmin, xmax = self._xmin or 0, self._xmax or 1 + nodes = [ + [xmin, line["ymin"]], + [xmax, line["ymin"]], + [xmax, line["ymax"]], + [xmin, line["ymax"]], + ] + tikz_figure.draw(nodes=nodes, cycle=True, **style) + elif plot_type == "fill": + if len(line["args"]) < 2: + raise ValueError("tikzfigure fill requires x and y coordinates") + x, y = line["args"][:2] + kwargs = line["kwargs"] + style = _tikz_style_kwargs(kwargs) + style["fill"] = kwargs.get("color", "blue") + style["fill_opacity"] = kwargs.get("alpha", 0.25) + tikz_figure.draw( + nodes=[[xi, yi] for xi, yi in zip(x, y)], + cycle=True, + **style, + ) elif line["plot_type"] == "gantt": tasks = line["tasks"] start_times = (line["start_times"] + self._xshift) * self._xscale diff --git a/src/maxplotlib/tests/test_canvas.py b/src/maxplotlib/tests/test_canvas.py index 8a6de64..0af1f92 100644 --- a/src/maxplotlib/tests/test_canvas.py +++ b/src/maxplotlib/tests/test_canvas.py @@ -94,6 +94,62 @@ def test_canvas_plot_tikzfigure_vertical_not_supported(): assert "nrows > 1" in str(exc_info.value) +def test_tikzfigure_supports_scatter_bars_fills_and_errorbars(): + import numpy as np + + from maxplotlib import Canvas + + x = np.arange(3) + canvas = Canvas() + canvas.scatter(x, [1, 2, 1], color="red") + canvas.bar(x, [1, 2, 1], color="blue") + canvas.fill_between(x, [1, 2, 1], 0, color="green", alpha=0.2) + canvas.errorbar(x, [1, 2, 1], yerr=0.1, color="black") + + tikz = canvas.render(backend="tikzfigure").generate_tikz() + + assert "mark=*" in tikz + assert "fill=blue" in tikz + assert "fill=green" in tikz + assert tikz.count("coordinates") >= 4 + + +def test_tikzfigure_rejects_unsupported_plot_types_explicitly(): + import numpy as np + import pytest + + from maxplotlib import Canvas + + canvas = Canvas() + canvas.imshow(np.ones((2, 2))) + + with pytest.raises(NotImplementedError, match="imshow"): + canvas.render(backend="tikzfigure") + + +def test_tikzfigure_supports_step_stem_reference_lines_spans_and_fill(): + import numpy as np + + from maxplotlib import Canvas + + x = np.arange(4) + canvas = Canvas() + canvas.step(x, [1, 2, 1, 3], color="black") + canvas.stem(x, [1, 2, 1, 3], color="purple") + canvas.hlines([1, 2], 0, 3, color="gray") + canvas.vlines([1, 2], 0, 3, color="gray") + canvas.axvspan(1, 2, color="orange", alpha=0.2) + canvas.axhspan(1, 2, color="green", alpha=0.2) + canvas.fill(x, [0, 1, 0, 1], color="cyan", alpha=0.2) + + tikz = canvas.render(backend="tikzfigure").generate_tikz() + + assert "mark=*" in tikz + assert "fill=orange" in tikz + assert "fill=cyan" in tikz + assert tikz.count("coordinates") >= 10 + + def test_canvas_matplotlib_gridspec_kw_affects_row_spacing(): """Test that hspace changes the vertical spacing between rows.""" import matplotlib.pyplot as plt diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index cb48c04..df7aba3 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -290,12 +290,12 @@ "| Line plots (`canvas.plot`) | ✅ |\n", "| Layer filtering | ✅ |\n", "| `line_width=` kwarg | ✅ |\n", - "| Multiple subplots | ❌ (raises `NotImplementedError`) |\n", - "| `canvas.scatter`, `canvas.bar` | ❌ (silently ignored) |\n", - "| `canvas.fill_between` | ❌ |\n", - "| Axis labels / titles | ❌ (TikZ has no axis frame by default) |\n", + "| Horizontal subplots (1×n) | ✅ |\n", + "| `canvas.scatter`, `canvas.bar`, `canvas.barh` | ✅ |\n", + "| `canvas.fill_between`, `canvas.errorbar` | ✅ |\n", + "| Axis labels / titles | ✅ |\n", "\n", - "For anything beyond line plots, use the `tikzfigure` API directly (Part 2 below)." + "For unsupported primitives, the Canvas API raises `NotImplementedError`; use the direct `tikzfigure` API for advanced TikZ shapes (Part 2 below)." ] }, { @@ -747,6 +747,113 @@ "| `'blue!50!red'` | 50% blend |\n", "| `'gray!20'` | 20% gray (80% white) |" ] + }, + { + "cell_type": "markdown", + "id": "42", + "metadata": {}, + "source": [ + "## Part 1.8 — Canvas primitives supported by TikZ\n", + "\n", + "The Canvas TikZ backend supports line, scatter, bar, horizontal-bar, filled-region,\n", + "and error-bar plots. The following example renders the same canvas as a Matplotlib\n", + "preview and as TikZ source." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from maxplotlib import Canvas\n", + "\n", + "x = np.arange(5)\n", + "y = np.array([1.0, 2.2, 1.4, 3.0, 2.5])\n", + "\n", + "primitive_canvas = Canvas()\n", + "primitive_canvas.plot(x, y, color=\"black\", linewidth=1.5)\n", + "primitive_canvas.scatter(x, y + 0.35, color=\"crimson\")\n", + "primitive_canvas.bar(x, y * 0.35, color=\"steelblue\", alpha=0.7)\n", + "primitive_canvas.fill_between(x, y, 0, color=\"gold\", alpha=0.2)\n", + "primitive_canvas.errorbar(x, y, yerr=0.15, color=\"darkgreen\")\n", + "primitive_canvas.configure(\n", + " title=\"TikZ-supported Canvas primitives\",\n", + " xlabel=\"Sample\",\n", + " ylabel=\"Value\",\n", + " grid=True,\n", + ")\n", + "\n", + "preview_fig, preview_axes = primitive_canvas.render(backend=\"matplotlib\")\n", + "preview_fig\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": {}, + "outputs": [], + "source": [ + "tikz_primitive_figure = primitive_canvas.render(backend=\"tikzfigure\")\n", + "tikz_primitive_source = tikz_primitive_figure.generate_tikz()\n", + "print(tikz_primitive_source[:2000])\n" + ] + }, + { + "cell_type": "markdown", + "id": "45", + "metadata": {}, + "source": [ + "The preview above uses the regular Matplotlib renderer, while the next cell\n", + "shows the TikZ generated from the same canvas. Unsupported primitives now raise\n", + "`NotImplementedError` instead of being silently omitted." + ] + }, + { + "cell_type": "markdown", + "id": "46", + "metadata": {}, + "source": [ + "## Part 1.9 — More TikZ-supported primitives\n", + "\n", + "Step and stairs plots, stems, reference lines, spans, and polygon fills are also\n", + "translated to TikZ. This preview uses the same Canvas for both backends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "more_canvas = Canvas()\n", + "more_canvas.step(x, y, color=\"black\", where=\"post\")\n", + "more_canvas.stem(x, y, linefmt=\"m-\", markerfmt=\"mo\")\n", + "more_canvas.hlines([1.5, 2.5], 0, 4, color=\"gray\", linestyle=\"--\")\n", + "more_canvas.vlines([1, 3], 0, 3.5, color=\"gray\")\n", + "more_canvas.axvspan(1, 2, color=\"orange\", alpha=0.2)\n", + "more_canvas.axhspan(1, 2, color=\"green\", alpha=0.2)\n", + "more_canvas.fill(x, [0.2, 0.8, 0.4, 1.0, 0.2], color=\"cyan\", alpha=0.2)\n", + "more_canvas.configure(title=\"Additional TikZ primitives\", xlabel=\"Sample\", ylabel=\"Value\", grid=True)\n", + "more_preview, _ = more_canvas.render(backend=\"matplotlib\")\n", + "more_preview" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "more_tikz = more_canvas.render(backend=\"tikzfigure\")\n", + "print(more_tikz.generate_tikz()[:2000])" + ] } ], "metadata": { From c61522f3539ac5f5675eef9148af68f4fbc8b5f8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Aug 2026 15:43:05 +0200 Subject: [PATCH 2/3] formatting --- src/maxplotlib/canvas/canvas.py | 20 ++++++++++++++++---- src/maxplotlib/subfigure/line_plot.py | 1 - tutorials/tutorial_07_tikz.ipynb | 8 +++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 56944cf..9c79628 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -19,8 +19,8 @@ from maxplotlib.colors.colors import Color from maxplotlib.linestyle.linestyle import Linestyle from maxplotlib.subfigure.line_plot import ( - LinePlot, _TIKZ_SUPPORTED_PLOT_TYPES, + LinePlot, _tikz_error_bounds, _tikz_step_coordinates, _tikz_style_kwargs, @@ -2310,7 +2310,12 @@ def plot_tikzfigure( width = source_kwargs.get("width", 0.8) for x, height in zip(line_data["x"], line_data["height"]): ax.add_plot( - x=[x - width / 2, x + width / 2, x + width / 2, x - width / 2], + x=[ + x - width / 2, + x + width / 2, + x + width / 2, + x - width / 2, + ], y=[0, 0, height, height], cycle=True, **kwargs, @@ -2320,7 +2325,12 @@ def plot_tikzfigure( for y, width in zip(line_data["y"], line_data["width"]): ax.add_plot( x=[0, width, width, 0], - y=[y - height / 2, y - height / 2, y + height / 2, y + height / 2], + y=[ + y - height / 2, + y - height / 2, + y + height / 2, + y + height / 2, + ], cycle=True, **kwargs, ) @@ -2379,7 +2389,9 @@ def plot_tikzfigure( source_kwargs = line_data.get("kwargs", {}) style = _tikz_style_kwargs(source_kwargs) marker_style = dict(style) - marker_style.update(mark=source_kwargs.get("marker", "*"), line_width=0) + marker_style.update( + mark=source_kwargs.get("marker", "*"), line_width=0 + ) ax.add_plot(x=x, y=y, **marker_style) for xi, yi in zip(x, y): ax.add_plot(x=[xi, xi], y=[0, yi], **style) diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 8f34c0d..ca09c72 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -4,7 +4,6 @@ from mpl_toolkits.axes_grid1 import make_axes_locatable from tikzfigure import TikzFigure - _TIKZ_SUPPORTED_PLOT_TYPES = { "plot", "scatter", diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index df7aba3..f7f1f8e 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -788,7 +788,7 @@ ")\n", "\n", "preview_fig, preview_axes = primitive_canvas.render(backend=\"matplotlib\")\n", - "preview_fig\n" + "preview_fig" ] }, { @@ -800,7 +800,7 @@ "source": [ "tikz_primitive_figure = primitive_canvas.render(backend=\"tikzfigure\")\n", "tikz_primitive_source = tikz_primitive_figure.generate_tikz()\n", - "print(tikz_primitive_source[:2000])\n" + "print(tikz_primitive_source[:2000])" ] }, { @@ -839,7 +839,9 @@ "more_canvas.axvspan(1, 2, color=\"orange\", alpha=0.2)\n", "more_canvas.axhspan(1, 2, color=\"green\", alpha=0.2)\n", "more_canvas.fill(x, [0.2, 0.8, 0.4, 1.0, 0.2], color=\"cyan\", alpha=0.2)\n", - "more_canvas.configure(title=\"Additional TikZ primitives\", xlabel=\"Sample\", ylabel=\"Value\", grid=True)\n", + "more_canvas.configure(\n", + " title=\"Additional TikZ primitives\", xlabel=\"Sample\", ylabel=\"Value\", grid=True\n", + ")\n", "more_preview, _ = more_canvas.render(backend=\"matplotlib\")\n", "more_preview" ] From 44fca0108b5d647fbe26f11e2bdbfd9cdca7ec8d Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Aug 2026 20:57:37 +0200 Subject: [PATCH 3/3] Do not return anything when running show with plotly --- src/maxplotlib/canvas/canvas.py | 4 +- src/maxplotlib/tests/test_canvas.py | 21 ++++ tutorials/tutorial_07_tikz.ipynb | 122 +++++++++---------- tutorials/tutorial_tikzfigure_subplots.ipynb | 2 +- 4 files changed, 86 insertions(+), 63 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 9c79628..28a2e8f 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -2059,7 +2059,9 @@ def show( allow_unsupported=allow_unsupported, ) fig.show() - return fig + # Plotly has already displayed the figure. Returning it from a + # notebook cell would trigger a second implicit rich display. + return None if _running_in_jupyter() else fig elif backend == "plotext": figure = self.plot_plotext( savefig=False, diff --git a/src/maxplotlib/tests/test_canvas.py b/src/maxplotlib/tests/test_canvas.py index 0af1f92..b8ef8d5 100644 --- a/src/maxplotlib/tests/test_canvas.py +++ b/src/maxplotlib/tests/test_canvas.py @@ -411,6 +411,27 @@ def test_canvas_show_uses_matplotlib_show(monkeypatch): assert axes is not None +def test_canvas_show_plotly_does_not_return_displayed_figure_in_jupyter(monkeypatch): + import maxplotlib.canvas.canvas as canvas_module + from maxplotlib import Canvas + + class FakePlotlyFigure: + def __init__(self): + self.show_calls = 0 + + def show(self): + self.show_calls += 1 + + figure = FakePlotlyFigure() + monkeypatch.setattr(Canvas, "plot_plotly", lambda *args, **kwargs: figure) + monkeypatch.setattr(canvas_module, "_running_in_jupyter", lambda: True) + + result = Canvas().show(backend="plotly") + + assert result is None + assert figure.show_calls == 1 + + def test_canvas_show_uses_ipython_display_in_jupyter(monkeypatch): import sys import types diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index f7f1f8e..6fdf89b 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -101,30 +101,39 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "7", "metadata": {}, + "outputs": [], + "source": [ + "canvas.show(backend=\"tikzfigure\")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, "source": [ "### 1.2 Inspecting the generated LaTeX\n", "\n", - "`tikz.generate_tikz()` returns the raw LaTeX source string. \n", + "`str(tikz)` returns the raw LaTeX source string (and `generate_tikz()` remains available explicitly). \n", "Each data line becomes a `\\draw` command connecting coordinate pairs." ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ - "code = tikz.generate_tikz()\n", - "print(code)" + "print(tikz)" ] }, { "cell_type": "markdown", - "id": "9", + "id": "10", "metadata": {}, "source": [ "### 1.2.1 Checking explicit width and height\n", @@ -136,7 +145,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -157,7 +166,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "12", "metadata": {}, "source": [ "### 1.3 TikZ-specific kwargs\n", @@ -169,7 +178,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -186,7 +195,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "### 1.4 Layer-aware TikZ output\n", @@ -198,7 +207,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -225,7 +234,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "### 1.5 Saving TikZ code to a file\n", @@ -236,7 +245,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -259,7 +268,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "### 1.6 Rendering the figure (requires `pdflatex`)\n", @@ -270,7 +279,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -280,7 +289,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "### 1.7 Canvas → TikZ limitations\n", @@ -300,7 +309,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "21", "metadata": {}, "source": [ "---\n", @@ -318,7 +327,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "22", "metadata": {}, "source": [ "### 2.1 Drawing paths with `draw()`\n", @@ -329,7 +338,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -347,7 +356,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "24", "metadata": {}, "source": [ "### 2.2 Straight line segments with `line()`\n", @@ -359,7 +368,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -380,7 +389,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "### 2.3 Rectangles, circles, and arcs" @@ -389,7 +398,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -422,7 +431,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "28", "metadata": {}, "source": [ "### 2.4 Nodes — text labels and markers\n", @@ -433,7 +442,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -464,7 +473,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "30", "metadata": {}, "source": [ "### 2.5 Custom colours with `colorlet()`\n", @@ -475,7 +484,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -499,7 +508,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "32", "metadata": {}, "source": [ "### 2.6 Filled paths and patterns\n", @@ -510,7 +519,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -539,7 +548,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "34", "metadata": {}, "source": [ "### 2.7 Layers in `TikzFigure`\n", @@ -551,7 +560,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -573,7 +582,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "36", "metadata": {}, "source": [ "### 2.8 Escaping to raw TikZ code\n", @@ -584,7 +593,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -604,7 +613,7 @@ }, { "cell_type": "markdown", - "id": "37", + "id": "38", "metadata": {}, "source": [ "### 2.9 Putting it all together — a complete figure\n", @@ -615,7 +624,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -667,7 +676,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -677,7 +686,7 @@ }, { "cell_type": "markdown", - "id": "40", + "id": "41", "metadata": {}, "source": [ "### 2.10 Embedding in a LaTeX document\n", @@ -708,7 +717,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "42", "metadata": {}, "source": [ "---\n", @@ -750,25 +759,23 @@ }, { "cell_type": "markdown", - "id": "42", + "id": "43", "metadata": {}, "source": [ "## Part 1.8 — Canvas primitives supported by TikZ\n", "\n", "The Canvas TikZ backend supports line, scatter, bar, horizontal-bar, filled-region,\n", - "and error-bar plots. The following example renders the same canvas as a Matplotlib\n", - "preview and as TikZ source." + "and error-bar plots. The following example renders the canvas as TikZ source." ] }, { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "44", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", - "import matplotlib.pyplot as plt\n", "from maxplotlib import Canvas\n", "\n", "x = np.arange(5)\n", @@ -785,49 +792,44 @@ " xlabel=\"Sample\",\n", " ylabel=\"Value\",\n", " grid=True,\n", - ")\n", - "\n", - "preview_fig, preview_axes = primitive_canvas.render(backend=\"matplotlib\")\n", - "preview_fig" + ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "45", "metadata": {}, "outputs": [], "source": [ "tikz_primitive_figure = primitive_canvas.render(backend=\"tikzfigure\")\n", - "tikz_primitive_source = tikz_primitive_figure.generate_tikz()\n", - "print(tikz_primitive_source[:2000])" + "print(str(tikz_primitive_figure)[:2000])" ] }, { "cell_type": "markdown", - "id": "45", + "id": "46", "metadata": {}, "source": [ - "The preview above uses the regular Matplotlib renderer, while the next cell\n", - "shows the TikZ generated from the same canvas. Unsupported primitives now raise\n", + "The next cell shows the TikZ generated from the canvas. Unsupported primitives now raise\n", "`NotImplementedError` instead of being silently omitted." ] }, { "cell_type": "markdown", - "id": "46", + "id": "47", "metadata": {}, "source": [ "## Part 1.9 — More TikZ-supported primitives\n", "\n", "Step and stairs plots, stems, reference lines, spans, and polygon fills are also\n", - "translated to TikZ. This preview uses the same Canvas for both backends." + "translated to TikZ. The generated TikZ is printed below." ] }, { "cell_type": "code", "execution_count": null, - "id": "47", + "id": "48", "metadata": {}, "outputs": [], "source": [ @@ -841,20 +843,18 @@ "more_canvas.fill(x, [0.2, 0.8, 0.4, 1.0, 0.2], color=\"cyan\", alpha=0.2)\n", "more_canvas.configure(\n", " title=\"Additional TikZ primitives\", xlabel=\"Sample\", ylabel=\"Value\", grid=True\n", - ")\n", - "more_preview, _ = more_canvas.render(backend=\"matplotlib\")\n", - "more_preview" + ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "48", + "id": "49", "metadata": {}, "outputs": [], "source": [ "more_tikz = more_canvas.render(backend=\"tikzfigure\")\n", - "print(more_tikz.generate_tikz()[:2000])" + "print(str(more_tikz)[:2000])" ] } ], diff --git a/tutorials/tutorial_tikzfigure_subplots.ipynb b/tutorials/tutorial_tikzfigure_subplots.ipynb index 1b61846..e558329 100644 --- a/tutorials/tutorial_tikzfigure_subplots.ipynb +++ b/tutorials/tutorial_tikzfigure_subplots.ipynb @@ -135,7 +135,7 @@ "\n", "- Only **horizontal layouts (1×n)** are supported with tikzfigure backend\n", "- Vertical/grid layouts (nrows > 1) will raise an error\n", - "- Use matplotlib backend for complex layouts or grids\n", + "- Use the direct tikzfigure API for complex layouts or grids\n", "- Each subplot's title becomes a pgfplots `title=` entry in the generated LaTeX output" ] }