diff --git a/ultraplot/constructor.py b/ultraplot/constructor.py index ea5909d54..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, @@ -227,9 +240,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. + # 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) return registry diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 7a3c27844..59651d868 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -306,6 +306,19 @@ def test_lon0_shifts(): uplt.close(fig) +def test_dms_formatter_symbols_are_default_font_safe(): + pytest.importorskip("cartopy") + formatter = uplt.Formatter("dmslon") + label = formatter(1 + 1 / 60 + 1 / 3600) + 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( "layout, expectations", [ @@ -1475,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)