From 04be5b7a1677c39d88efe898b0baa7f9c91ffdd0 Mon Sep 17 00:00:00 2001 From: lucasb-eyer Date: Tue, 11 Nov 2025 09:44:56 +0100 Subject: [PATCH] Make FuncFormatter work without FixedLocator. It is impossible for exported plot to ever call the python function, so we need to decide what to do when the user uses a FuncFormatter. I decide that it's most meaningful to convert it to "fixed" ticks at export-time, which is what this code does. However, it still keeps the type as "func" so the consumer of export may choose what to do. mpld3 just treats "func" and "fixed" identically. We could argue about changing to "fixed" here at export-time; I think keeping "func" conveys more information, but I don't feel strongly about it. The current state for FuncFormatter is that it does convert the labels at export-time only if locator is fixed. I argue this is strictly worse. At the same time, I also made the unit-test assert util print much more human interpretable test failure outputs, see comment. --- mplexporter/tests/test_basic.py | 13 +++++++++++-- mplexporter/tests/test_utils.py | 21 +++++++++++++++++++++ mplexporter/utils.py | 17 +++++++++-------- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/mplexporter/tests/test_basic.py b/mplexporter/tests/test_basic.py index b2e1eef..de81a74 100644 --- a/mplexporter/tests/test_basic.py +++ b/mplexporter/tests/test_basic.py @@ -16,9 +16,18 @@ def fake_renderer_output(fig, Renderer): return renderer.output +# Separate function such that pytest fail reports are more readable, +# because they print the values of the args, i.e. lines1, lines2. +def _assert_output_equal_clean(lines1, lines2): + for l1, l2 in zip(lines1, lines2): + assert l1 == l2 + + def _assert_output_equal(text1, text2): - for line1, line2 in zip(text1.strip().split(), text2.strip().split()): - assert line1 == line2 + _assert_output_equal_clean( + [l.strip() for l in text1.strip().split('\n')], + [l.strip() for l in text2.strip().split('\n')], + ) def test_lines(): diff --git a/mplexporter/tests/test_utils.py b/mplexporter/tests/test_utils.py index 51dad80..2a64a8d 100644 --- a/mplexporter/tests/test_utils.py +++ b/mplexporter/tests/test_utils.py @@ -1,4 +1,5 @@ from numpy.testing import assert_allclose, assert_equal +from matplotlib import ticker from . import plt from .. import utils @@ -33,3 +34,23 @@ def test_axis_w_fixed_formatter(): # NOTE: Issue #471 # assert_equal(props['tickformat'], labels) + +def test_axis_w_funcformatter_autolocator(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xlim(0, 1) + + def formatter(value, position): + return f"{position}:{value:.1f}" + + ax.xaxis.set_major_formatter(ticker.FuncFormatter(formatter)) + + props = utils.get_axis_properties(ax.xaxis) + + assert_equal(props['tickformat_formatter'], "fixed") + assert props['tickvalues'] is not None + expected = [formatter(value, i) + for i, value in enumerate(props['tickvalues'])] + assert_equal(props['tickformat'], expected) + + plt.close(fig) diff --git a/mplexporter/utils.py b/mplexporter/utils.py index f9467d7..abc8a6c 100644 --- a/mplexporter/utils.py +++ b/mplexporter/utils.py @@ -206,11 +206,9 @@ def get_axis_properties(axis): # Use tick values if appropriate locator = axis.get_major_locator() - props['nticks'] = len(locator()) - if isinstance(locator, ticker.FixedLocator): - props['tickvalues'] = list(locator()) - else: - props['tickvalues'] = None + tick_locs = list(locator()) # We'll use them later in some cases. + props['nticks'] = len(tick_locs) + props['tickvalues'] = tick_locs if isinstance(locator, ticker.FixedLocator) else None # Find tick formats props['tickformat_formatter'] = "" @@ -235,9 +233,12 @@ def get_axis_properties(axis): elif isinstance(formatter, ticker.FixedFormatter): props['tickformat'] = list(formatter.seq) props['tickformat_formatter'] = "fixed" - elif isinstance(formatter, ticker.FuncFormatter) and props['tickvalues']: - props['tickformat'] = [formatter(value) for value in props['tickvalues']] - props['tickformat_formatter'] = "func" + elif isinstance(formatter, ticker.FuncFormatter): + # It's impossible for JS to re-run our function, so run it now and save as Fixed. + if props['tickvalues'] is None: + props['tickvalues'] = tick_locs + props['tickformat'] = [formatter(value, i) for i, value in enumerate(props['tickvalues'])] + props['tickformat_formatter'] = "fixed" elif not any(label.get_visible() for label in axis.get_ticklabels()): props['tickformat'] = "" else: