diff --git a/src/maxplotlib/backends/matplotlib/utils.py b/src/maxplotlib/backends/matplotlib/utils.py index 60786b5..bb3331d 100644 --- a/src/maxplotlib/backends/matplotlib/utils.py +++ b/src/maxplotlib/backends/matplotlib/utils.py @@ -6,7 +6,7 @@ import pint -def setup_tex_fonts(fontsize=14, usetex=False): +def setup_tex_fonts(fontsize=10, usetex=False): """ Sets up LaTeX fonts for plotting. """ @@ -15,6 +15,8 @@ def setup_tex_fonts(fontsize=14, usetex=False): "font.family": "serif", "pgf.rcfonts": False, "axes.labelsize": fontsize, + "axes.titlesize": fontsize, + "figure.titlesize": fontsize, "font.size": fontsize, "legend.fontsize": fontsize, "xtick.labelsize": fontsize, @@ -58,13 +60,13 @@ def convert_to_inches(length_str): def _2pt(width, dpi=300, verbose: bool = False): if verbose: - print(f"Converting width: {width} to points with dpi={dpi}") + print(f"Converting width: {width} to points") if isinstance(width, (int, float)): return width elif isinstance(width, str): length_in = convert_to_inches(width) - length_pt = length_in * dpi + length_pt = length_in * 72.27 if verbose: print(f"Converted length: {length_in} inches = {length_pt} points") return length_pt diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 1ad74f1..a321776 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -155,7 +155,7 @@ def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None): node.x, node.y, node.content, - fontsize=10, + fontsize=self._fontsize, ha="center", va="center", wrap=True, @@ -183,9 +183,9 @@ def __init__( caption: str | None = None, description: str | None = None, label: str | None = None, - fontsize: int = 14, - dpi: int = 300, - width: str = "5cm", + fontsize: int = 10, + dpi: int | None = None, + width: str | None = None, ratio: str = "golden", # TODO Add literal usetex: bool | None = None, subplot_spacing: SubplotSpacing | None = None, @@ -201,9 +201,9 @@ def __init__( caption (str): Caption for the figure. description (str): Description for the figure. label (str): Label for the figure. - fontsize (int): Font size. Default is 14. - dpi (int): DPI for the figure. Default is 300. - width (str): Width of the figure. Default is "17cm". + fontsize (int): Font size. Default is 10. + dpi (int | None): Optional export/render DPI override. + width (str | None): Optional figure width, e.g. "7cm". ratio (str): Aspect ratio. Default is "golden". usetex (bool | None): Default text.usetex behavior for this canvas. If None, read from MAXPLOTLIB_USETEX environment variable. @@ -762,7 +762,8 @@ def savefig( layers=layers, ) _fn = f"{filename_no_extension}_{layers}.{extension}" - fig.savefig(_fn) + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + fig.savefig(_fn, **savefig_kwargs) print(f"Saved {_fn}") else: if layers is None: @@ -772,14 +773,16 @@ def savefig( full_filepath = f"{filename_no_extension}_{layers}.{extension}" if self._plotted: - self._matplotlib_fig.savefig(full_filepath) + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + self._matplotlib_fig.savefig(full_filepath, **savefig_kwargs) else: fig, axs = self.plot( backend="matplotlib", savefig=True, layers=layers, ) - fig.savefig(full_filepath) + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + fig.savefig(full_filepath, **savefig_kwargs) if verbose: print(f"Saved {full_filepath}") elif backend == "plotext": @@ -841,8 +844,8 @@ def savefig( def plot( self, backend: Backends = "matplotlib", - savefig=False, - layers=None, + savefig: bool = False, + layers: list | None = None, usetex: bool | None = None, verbose: bool = False, ): @@ -884,17 +887,22 @@ def show( verbose: bool = False, ): if verbose: - print(f"Showing figure using backend: {backend}") + print(f"Showing canvas using backend: {backend}") if backend == "matplotlib": - self.plot( + if verbose: + print("Generating Matplotlib figure for display...") + fig, axes = self.plot( backend="matplotlib", savefig=False, layers=layers, usetex=usetex, verbose=verbose, ) - # self._matplotlib_fig.show() + if verbose: + print("Displaying Matplotlib figure...") + plt.show() + return fig, axes elif backend == "plotly": resolved_usetex = self._usetex if usetex is None else usetex fig = self.plot_plotly( @@ -936,6 +944,7 @@ def plot_matplotlib( resolved_usetex = self._usetex if usetex is None else usetex tex_fonts = setup_tex_fonts(fontsize=self.fontsize, usetex=resolved_usetex) + render_dpi = self.dpi if savefig else None setup_plotstyle( tex_fonts=tex_fonts, @@ -947,27 +956,40 @@ def plot_matplotlib( if verbose: print("Plot style set up.") print(f"{self._figsize = } {self._width = } {self._ratio = }") + subplot_kwargs = { + "squeeze": False, + "gridspec_kw": self._gridspec_kw, + } if self._figsize is not None: - fig_width, fig_height = self._figsize - else: + subplot_kwargs["figsize"] = self._figsize + elif self._width is not None: fig_width, fig_height = set_size( width=self._width, ratio=self._ratio, - dpi=self.dpi, + dpi=render_dpi, verbose=verbose, ) + subplot_kwargs["figsize"] = (fig_width, fig_height) if verbose: - print(f"Figure size: {fig_width} x {fig_height} points") + if "figsize" in subplot_kwargs: + fig_width, fig_height = subplot_kwargs["figsize"] + print(f"Figure size: {fig_width} x {fig_height} inches") + else: + print("Figure size: Matplotlib default") + print(f"Render DPI override: {render_dpi} (export DPI: {self.dpi})") + + if render_dpi is not None: + subplot_kwargs["dpi"] = render_dpi fig, axes = plt.subplots( self.nrows, self.ncols, - figsize=(fig_width, fig_height), - squeeze=False, - dpi=self.dpi, - gridspec_kw=self._gridspec_kw, + **subplot_kwargs, ) + if verbose: + print(f"Created Matplotlib figure and axes with shape {axes.shape}") + for (row, col), subplot in self._subplot_dict.items(): ax = axes[row][col] if isinstance(subplot, TikzFigure): @@ -977,8 +999,16 @@ def plot_matplotlib( # ax.set_title(f"Subplot ({row}, {col})") ax.grid() + if verbose: + print("Finished plotting subplots.") + if self._suptitle: - fig.suptitle(self._suptitle, **self._suptitle_kwargs) + suptitle_kwargs = dict(self._suptitle_kwargs) + suptitle_kwargs.setdefault("fontsize", self.fontsize) + fig.suptitle(self._suptitle, **suptitle_kwargs) + + if verbose: + print("Set suptitle.") # Set caption, labels, etc., if needed self._plotted = True @@ -1019,6 +1049,7 @@ def plot_tikzfigure( "No subplots to plot. Call add_subplot() or Canvas.subplots() first." ) + axis_width, axis_height = self._get_tikzfigure_axis_dimensions() fig = TikzFigure() # Add each subplot as a subfigure axis @@ -1041,8 +1072,10 @@ def plot_tikzfigure( else None ), grid=line_plot._grid, - caption=line_plot._title or f"Subplot {col + 1}", + title=line_plot._title or f"Subplot {col + 1}", width=0.45, + axis_width=axis_width, + height=axis_height, ) # Add each plot line to the subfigure @@ -1069,6 +1102,28 @@ def plot_tikzfigure( return fig + def _get_tikzfigure_axis_dimensions(self) -> tuple[str | None, str | None]: + if self._width is None: + return None, None + + total_width_in, total_height_in = set_size( + width=self._width, + ratio=self._ratio, + dpi=self._dpi if self._dpi is not None else 300, + ) + total_width_cm = total_width_in * 2.54 + total_height_cm = total_height_in * 2.54 + horizontal_sep_cm = getattr(TikzFigure, "GROUPPLOT_HORIZONTAL_SEP_CM", 1.5) + available_width_cm = total_width_cm - horizontal_sep_cm * (self.ncols - 1) + if available_width_cm <= 0: + raise ValueError( + f'Canvas width "{self._width}" is too small for {self.ncols} ' + "tikzfigure subplot(s)." + ) + + axis_width_cm = available_width_cm / self.ncols + return f"{axis_width_cm:.6g}cm", f"{total_height_cm:.6g}cm" + def plot_plotext( self, savefig: bool = False, @@ -1124,15 +1179,6 @@ def plot_plotly( usetex=resolved_usetex, ) # adjust or redefine for Plotly if needed - # Set default width and height if not specified - if self._figsize is not None: - fig_width, fig_height = self._figsize - else: - fig_width, fig_height = set_size( - width=self._width, - ratio=self._ratio, - ) - # print(self._width, fig_width, fig_height) # Create subplot titles in row-major order (Plotly expects rows*cols entries) subplot_titles = [""] * (self.nrows * self.ncols) for (row, col), sp in self._subplot_dict.items(): diff --git a/src/maxplotlib/tests/test_canvas.py b/src/maxplotlib/tests/test_canvas.py index 48f416e..b72dbb7 100644 --- a/src/maxplotlib/tests/test_canvas.py +++ b/src/maxplotlib/tests/test_canvas.py @@ -56,6 +56,23 @@ def test_canvas_plot_tikzfigure_three_subplots(): assert "\\subfigure" in result or "subfigure" in result +def test_canvas_plot_tikzfigure_respects_width_and_ratio(): + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(0, 1, 5) + canvas, ax = Canvas.subplots(width="10cm", ratio=2) + ax.plot(x, x**2, color="black") + ax.set_title("Parabola") + + tikz = canvas.plot_tikzfigure().generate_tikz() + + assert "width=10cm" in tikz + assert "height=20cm" in tikz + assert "title=Parabola" in tikz + + def test_canvas_plot_tikzfigure_vertical_not_supported(): """Test that vertical layouts raise NotImplementedError.""" import numpy as np @@ -294,7 +311,7 @@ def test_canvas_plot_usetex_precedence(monkeypatch): captured: list[bool] = [] - def fake_setup_tex_fonts(fontsize=14, usetex=False): + def fake_setup_tex_fonts(fontsize=10, usetex=False): captured.append(usetex) return {} @@ -313,5 +330,142 @@ def fake_setup_tex_fonts(fontsize=14, usetex=False): assert captured == [True, False] +def test_canvas_show_uses_matplotlib_show(monkeypatch): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + calls = [] + + monkeypatch.setattr( + plt, "show", lambda *args, **kwargs: calls.append((args, kwargs)) + ) + + canvas = Canvas() + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, axes = canvas.show() + + assert calls == [((), {})] + assert fig is not None + assert axes is not None + + +def test_show_canvas_script_invokes_canvas_show(monkeypatch): + import maxplotlib + + calls = [] + + def fake_show(self, *args, **kwargs): + calls.append((args, kwargs)) + return object(), object() + + monkeypatch.setattr(maxplotlib.Canvas, "show", fake_show) + + canvas = maxplotlib.Canvas(width="10cm", ratio=0.4) + canvas.add_line(x=[1, 2, 3], y=[4, 5, 6]) + canvas.show(backend="tikzfigure", verbose=True) + + assert calls == [((), {"backend": "tikzfigure", "verbose": True})] + + +def test_canvas_plot_uses_screen_dpi_when_not_saving(): + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + default_fig = plt.figure() + default_dpi = default_fig.dpi + plt.close(default_fig) + + canvas = Canvas(dpi=300) + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, _ = canvas.plot() + + assert fig.dpi == pytest.approx(default_dpi) + + +def test_canvas_without_explicit_size_uses_matplotlib_defaults(): + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + default_fig = plt.figure() + default_size = default_fig.get_size_inches().copy() + default_dpi = default_fig.dpi + plt.close(default_fig) + + canvas = Canvas() + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, _ = canvas.plot() + + assert fig.get_size_inches()[0] == pytest.approx(default_size[0]) + assert fig.get_size_inches()[1] == pytest.approx(default_size[1]) + assert fig.dpi == pytest.approx(default_dpi) + + +def test_canvas_savefig_uses_configured_export_dpi(monkeypatch, tmp_path): + from matplotlib.figure import Figure + + from maxplotlib import Canvas + + savefig_calls = [] + original_savefig = Figure.savefig + + def wrapped_savefig(self, *args, **kwargs): + savefig_calls.append(kwargs.get("dpi")) + return original_savefig(self, *args, **kwargs) + + monkeypatch.setattr(Figure, "savefig", wrapped_savefig) + + canvas = Canvas(dpi=300) + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + canvas.plot() + canvas.savefig(tmp_path / "figure.png") + + assert savefig_calls[-1] == 300 + + +def test_canvas_width_in_centimeters_is_preserved(): + import pytest + + from maxplotlib import Canvas + + canvas, _ = Canvas.subplots(width="7cm") + fig, _ = canvas.plot() + + assert fig.get_size_inches()[0] == pytest.approx(7 / 2.54, abs=0.01) + + +def test_canvas_fontsize_controls_axes_text(): + import pytest + + from maxplotlib import Canvas + + canvas = Canvas(fontsize=10) + canvas.add_subplot() + canvas.set_title("Title") + canvas.set_xlabel("X") + canvas.set_ylabel("Y") + canvas.suptitle("Figure title") + + fig, axes = canvas.plot() + + ax = axes[0, 0] + assert ax.title.get_fontsize() == pytest.approx(10) + assert ax.xaxis.label.get_fontsize() == pytest.approx(10) + assert ax.yaxis.label.get_fontsize() == pytest.approx(10) + assert fig._suptitle is not None + assert fig._suptitle.get_fontsize() == pytest.approx(10) + + if __name__ == "__main__": test() diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index 3892904..078e321 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -126,6 +126,39 @@ "cell_type": "markdown", "id": "9", "metadata": {}, + "source": [ + "### 1.2.1 Checking explicit width and height\n", + "\n", + "When you set both `width=` and `ratio=`, the TikZ export now writes explicit pgfplots dimensions.\n", + "This is useful when you want a tall figure for a column-sized layout in LaTeX." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "canvas_ratio2, ax_ratio2 = Canvas.subplots(width=\"10cm\", ratio=2)\n", + "ax_ratio2.plot(x, np.exp(-x / np.pi), color=\"purple\", line_width=1.5)\n", + "ax_ratio2.set_title(\"ratio = 2 export\")\n", + "\n", + "tikz_ratio2 = canvas_ratio2.plot(backend=\"tikzfigure\")\n", + "ratio2_code = tikz_ratio2.generate_tikz()\n", + "\n", + "for line in ratio2_code.splitlines():\n", + " if \"nextgroupplot\" in line:\n", + " print(line.strip())\n", + " break\n", + "\n", + "# Expected: width=10cm and height=20cm in the \\nextgroupplot options" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, "source": [ "### 1.3 TikZ-specific kwargs\n", "\n", @@ -136,7 +169,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -153,7 +186,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "13", "metadata": {}, "source": [ "### 1.4 Layer-aware TikZ output\n", @@ -165,7 +198,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -192,7 +225,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "15", "metadata": {}, "source": [ "### 1.5 Saving TikZ code to a file\n", @@ -203,7 +236,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -226,7 +259,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "### 1.6 Rendering the figure (requires `pdflatex`)\n", @@ -237,7 +270,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -247,7 +280,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "19", "metadata": {}, "source": [ "### 1.7 Canvas → TikZ limitations\n", @@ -267,7 +300,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "20", "metadata": {}, "source": [ "---\n", @@ -285,7 +318,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "21", "metadata": {}, "source": [ "### 2.1 Drawing paths with `draw()`\n", @@ -296,7 +329,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -314,7 +347,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "23", "metadata": {}, "source": [ "### 2.2 Straight line segments with `line()`\n", @@ -326,7 +359,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -347,7 +380,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "25", "metadata": {}, "source": [ "### 2.3 Rectangles, circles, and arcs" @@ -356,7 +389,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +422,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "27", "metadata": {}, "source": [ "### 2.4 Nodes — text labels and markers\n", @@ -400,7 +433,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -431,7 +464,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "29", "metadata": {}, "source": [ "### 2.5 Custom colours with `colorlet()`\n", @@ -442,7 +475,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -466,7 +499,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "31", "metadata": {}, "source": [ "### 2.6 Filled paths and patterns\n", @@ -477,7 +510,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -506,7 +539,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "33", "metadata": {}, "source": [ "### 2.7 Layers in `TikzFigure`\n", @@ -518,7 +551,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -540,7 +573,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "35", "metadata": {}, "source": [ "### 2.8 Escaping to raw TikZ code\n", @@ -551,7 +584,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -571,7 +604,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "37", "metadata": {}, "source": [ "### 2.9 Putting it all together — a complete figure\n", @@ -582,7 +615,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -634,7 +667,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -644,7 +677,7 @@ }, { "cell_type": "markdown", - "id": "38", + "id": "40", "metadata": {}, "source": [ "### 2.10 Embedding in a LaTeX document\n", @@ -675,7 +708,7 @@ }, { "cell_type": "markdown", - "id": "39", + "id": "41", "metadata": {}, "source": [ "---\n", diff --git a/tutorials/tutorial_tikzfigure_subplots.ipynb b/tutorials/tutorial_tikzfigure_subplots.ipynb index b12de55..faedb7a 100644 --- a/tutorials/tutorial_tikzfigure_subplots.ipynb +++ b/tutorials/tutorial_tikzfigure_subplots.ipynb @@ -61,6 +61,34 @@ "cell_type": "markdown", "id": "3", "metadata": {}, + "source": [ + "## Inspecting the generated subplot dimensions\n", + "\n", + "The exported TikZ splits the available width across the columns and keeps the requested overall height.\n", + "Printing the `\\\\nextgroupplot[...]` lines makes that explicit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "tikz = canvas.plot(backend=\"tikzfigure\")\n", + "subplot_code = tikz.generate_tikz()\n", + "\n", + "for line in subplot_code.splitlines():\n", + " if \"nextgroupplot\" in line:\n", + " print(line.strip())\n", + "\n", + "# With width=\"10cm\" and ratio=0.3, each subplot gets its own width entry." + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, "source": [ "## 1×3 Layout: Multiple Functions\n", "\n", @@ -70,7 +98,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -100,7 +128,7 @@ }, { "cell_type": "markdown", - "id": "5", + "id": "7", "metadata": {}, "source": [ "## Important Notes\n", @@ -108,7 +136,7 @@ "- 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", - "- Each subplot's title becomes a subfigure caption in the LaTeX output" + "- Each subplot's title becomes a pgfplots `title=` entry in the generated LaTeX output" ] } ],