diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 00869acd..1c8d51e6 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -11,6 +11,7 @@ import scipp as sc from matplotlib.colorbar import ColorbarBase from matplotlib.colors import Colormap, LinearSegmentedColormap, LogNorm, Normalize +from matplotlib.ticker import FuncFormatter from ..backends.matplotlib.utils import fig_to_bytes from ..core.limits import find_limits, fix_empty_range @@ -186,6 +187,7 @@ def __init__( fig = plt.Figure(figsize=(height_inches * 0.2, height_inches)) self.cax = fig.add_axes([0.05, 0.02, 0.2, 0.98]) self.colorbar = ColorbarBase(self.cax, cmap=self.cmap, norm=self.normalizer) + self._update_colorbar_formatter() self.cax.yaxis.set_label_coords(-0.9, 0.5) if self._clabel is not None: self.cax.set_ylabel(self._clabel) @@ -276,6 +278,9 @@ def apply_limits(self): self.normalizer.vmax = self._cmax["data"] if self.colorbar is not None: + # Re-apply the formatter after updating normalizer limits, + # as matplotlib may have reset it when the normalizer changed + self._update_colorbar_formatter() self._update_colorbar_widget() self.notify_artists() @@ -378,6 +383,33 @@ def ylabel(self) -> str | None: def ylabel(self, lab: str): self.clabel = lab + def _update_colorbar_formatter(self): + """ + Update the colorbar tick formatter based on the current normalization. + For log scale, use a plain text formatter to avoid mathtext parsing errors + during tight_layout() calculations. + """ + if self.colorbar is not None and self.cax is not None: + if self._logc: + # Use a custom formatter that produces plain text labels, + # avoiding mathtext notation that can cause parsing errors + # during tight_layout() when matplotlib measures text extent + def log_formatter(x, pos): + if x <= 0: + return '' + exponent = int(np.round(np.log10(x))) + mantissa = x / (10**exponent) + # For powers of 10, show as "10^N" without math mode + if abs(mantissa - 1.0) < 0.05: + return f'1e{exponent:+d}' + # For other values, use exponential notation + return f'{x:.1e}' + + self.cax.yaxis.set_major_formatter(FuncFormatter(log_formatter)) + else: + # Reset to default formatter for linear scale + self.cax.yaxis.set_major_formatter(mpl.ticker.ScalarFormatter()) + def toggle_norm(self): """ Toggle the norm flag, between `linear` and `log`. @@ -388,6 +420,7 @@ def toggle_norm(self): self._cmax["data"] = -np.inf if self.colorbar is not None: self.colorbar.mappable.norm = self.normalizer + self._update_colorbar_formatter() self.autoscale() if self._canvas is not None: self._canvas.draw() diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py index c6b9105c..980f3090 100644 --- a/tests/graphics/colormapper_test.py +++ b/tests/graphics/colormapper_test.py @@ -398,3 +398,43 @@ def test_toolbar_log_norm_button_toggles_colormapper_norm( assert fig.view.colormapper.norm == 'linear' fig.toolbar['lognorm'].value = True assert fig.view.colormapper.norm == 'log' + + +def test_logc_tight_layout_does_not_raise_parse_error(): + r""" + Test that calling tight_layout() on a figure with logc colorbar does not raise + a ValueError from matplotlib's mathtext parser. This reproduces the issue + where LogNorm tick labels like "$\mathdefault{10^{-33}}$" cause parsing errors. + """ + import warnings + + # Create data - the specific range from the user's report + da = sc.DataArray( + data=sc.array(dims=['y', 'x'], values=np.arange(1, 801).reshape(20, 40)), + ) + mapper = ColorMapper(cbar=True, logc=True) + artist = DummyChild(data=da, colormapper=mapper) + mapper.add_artist('data', artist) + + mapper.autoscale() + + # Call tight_layout() on the colorbar figure + # This is what causes the error in the user's report + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + try: + # Draw the figure first to ensure rendering is done + mapper.cax.get_figure().canvas.draw() + # Now call tight_layout which can trigger the mathtext parsing issue + mapper.cax.get_figure().tight_layout() + except ValueError as e: + # Check if this is the mathtext parsing error we're trying to fix + error_str = str(e) + if 'ParseException' in error_str and ( + '$' in error_str or 'mathdefault' in error_str + ): + pytest.fail( + f"tight_layout() raised mathtext parsing error with logc=True: {e}" + ) + # Re-raise if it's a different ValueError + raise