Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions ultraplot/constructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
19 changes: 16 additions & 3 deletions ultraplot/tests/test_geographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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°36E",
"113°15E",
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)
Expand Down
Loading