From 8e74a5bde0a4715b752398630c810ee3dc29b997 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Jul 2026 12:25:06 +1000 Subject: [PATCH 1/2] add defaults for missing symbols --- ultraplot/constructor.py | 10 +++++++--- ultraplot/tests/test_geographic.py | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ultraplot/constructor.py b/ultraplot/constructor.py index ea5909d54..b9036cf27 100644 --- a/ultraplot/constructor.py +++ b/ultraplot/constructor.py @@ -227,9 +227,13 @@ def _build_formatter_registry() -> dict[str, object]: if hasattr(mdates, "ConciseDateFormatter"): registry["concise"] = mdates.ConciseDateFormatter if _version_cartopy >= "0.18": - registry["dms"] = partial(pticker.DegreeFormatter, dms=True) - registry["dmslon"] = partial(pticker.LongitudeFormatter, dms=True) - registry["dmslat"] = partial(pticker.LatitudeFormatter, dms=True) + # Cartopy defaults to U+2032/U+2033 prime glyphs for minutes/seconds. + # TeX Gyre Heros, UltraPlot's default sans-serif font, does not contain + # those glyphs, so use ASCII symbols for the built-in DMS presets. + dms_kwargs = {"dms": True, "minute_symbol": "'", "second_symbol": '"'} + registry["dms"] = partial(pticker.DegreeFormatter, **dms_kwargs) + registry["dmslon"] = partial(pticker.LongitudeFormatter, **dms_kwargs) + registry["dmslat"] = partial(pticker.LatitudeFormatter, **dms_kwargs) return registry diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 7a3c27844..ea1edd671 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -306,6 +306,15 @@ def test_lon0_shifts(): uplt.close(fig) +def test_dms_formatter_symbols_are_default_font_safe(): + formatter = uplt.Formatter("dmslon") + label = formatter(1 + 1 / 60 + 1 / 3600) + assert "\N{PRIME}" not in label + assert "\N{DOUBLE PRIME}" not in label + assert "'" in label + assert '"' in label + + @pytest.mark.parametrize( "layout, expectations", [ From 21513fcc68e58f75f44b7a0d1bfd095ec9f60118 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 10 Jul 2026 18:46:17 +1000 Subject: [PATCH 2/2] Update logic to default back --- ultraplot/constructor.py | 19 ++++++++++++++++--- ultraplot/tests/test_geographic.py | 18 +++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/ultraplot/constructor.py b/ultraplot/constructor.py index b9036cf27..1cb9e6600 100644 --- a/ultraplot/constructor.py +++ b/ultraplot/constructor.py @@ -21,9 +21,11 @@ import cycler import matplotlib.colors as mcolors import matplotlib.dates as mdates +import matplotlib.font_manager as mfonts import matplotlib.projections.polar as mpolar import matplotlib.scale as mscale import matplotlib.ticker as mticker +from matplotlib.ft2font import FT2Font import numpy as np from . import colors as pcolors @@ -184,6 +186,17 @@ def _build_locator_registry() -> dict[str, object]: return registry +def _get_dms_symbol_kwargs() -> dict[str, str]: + """Return ASCII DMS symbols when the active font lacks prime glyphs.""" + try: + path = mfonts.findfont(mfonts.FontProperties()) + font = FT2Font(path) + has_primes = all(font.get_char_index(ord(symbol)) for symbol in "′″") + except Exception: # pragma: no cover - font lookup should be reliable + has_primes = False + return {} if has_primes else {"minute_symbol": "'", "second_symbol": '"'} + + def _build_formatter_registry() -> dict[str, object]: registry = { # note default LogFormatter uses ugly e+00 notation "none": mticker.NullFormatter, @@ -228,9 +241,9 @@ def _build_formatter_registry() -> dict[str, object]: registry["concise"] = mdates.ConciseDateFormatter if _version_cartopy >= "0.18": # Cartopy defaults to U+2032/U+2033 prime glyphs for minutes/seconds. - # TeX Gyre Heros, UltraPlot's default sans-serif font, does not contain - # those glyphs, so use ASCII symbols for the built-in DMS presets. - dms_kwargs = {"dms": True, "minute_symbol": "'", "second_symbol": '"'} + # Use them whenever the active font supports them, otherwise use ASCII + # fallbacks so that the built-in DMS presets never render missing glyphs. + dms_kwargs = {"dms": True, **_get_dms_symbol_kwargs()} registry["dms"] = partial(pticker.DegreeFormatter, **dms_kwargs) registry["dmslon"] = partial(pticker.LongitudeFormatter, **dms_kwargs) registry["dmslat"] = partial(pticker.LatitudeFormatter, **dms_kwargs) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index ea1edd671..59651d868 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -307,12 +307,16 @@ def test_lon0_shifts(): def test_dms_formatter_symbols_are_default_font_safe(): + pytest.importorskip("cartopy") formatter = uplt.Formatter("dmslon") label = formatter(1 + 1 / 60 + 1 / 3600) - assert "\N{PRIME}" not in label - assert "\N{DOUBLE PRIME}" not in label - assert "'" in label - assert '"' in label + assert formatter._minute_symbol in ("\N{PRIME}", "'") + assert formatter._second_symbol in ("\N{DOUBLE PRIME}", '"') + assert formatter._minute_symbol in label + assert formatter._second_symbol in label + + formatter = uplt.Formatter("dmslon", minute_symbol="m", second_symbol="s") + assert formatter(1 + 1 / 60 + 1 / 3600).endswith("1m1sE") @pytest.mark.parametrize( @@ -1484,11 +1488,11 @@ def test_dms_used_for_mercator(): ax.format(land=True, labels=True, lonlocator=limit) import matplotlib.ticker as mticker + minute_symbol = ax[0].gridlines_major.xformatter._minute_symbol expectations = ( - "0°36′E", - "113°15′E", + f"0°36{minute_symbol}E", + f"113°15{minute_symbol}E", ) - for expectation, tick in zip(expectations, limit): a = ax[0].gridlines_major.xformatter(tick) b = ax[1].gridlines_major.xformatter(tick)