From 078bc1b5eff1e156077c0bba273a97f083f2b5fd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Feb 2026 19:31:39 +1000 Subject: [PATCH 01/11] Add explicit scale bar raster controls for sharp exports --- matplotlib_map_utils/core/scale_bar.py | 37 ++++++++++++++++---- matplotlib_map_utils/defaults/scale_bar.py | 37 ++++++++++++++++---- matplotlib_map_utils/validation/scale_bar.py | 12 ++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index 0e91aac..c2142de 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -205,9 +205,14 @@ def copy(self): # THANK YOU to matplotlib-scalebar for figuring this out # Note that we never specify the renderer - the axis takes care of it! def draw(self, renderer, *args, **kwargs): + # Prefer renderer dpi for class-based artists so exports stay sharp + # when savefig(dpi=...) differs from the figure construction dpi. + _bar = copy.deepcopy(self._bar) + if _bar.get("raster_dpi", None) is None: + _bar["raster_dpi"] = renderer.dpi # Can re-use the drawing function we already established, but return the object instead sb_artist = scale_bar(ax=self.axes, style=self._style, location=self._location, draw=False, - bar=self._bar, units=self._units, + bar=_bar, units=self._units, labels=self._labels, text=self._text, aob=self._aob, zorder=self._zorder) # This handles the actual drawing @@ -272,6 +277,14 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", _text = sbf._validate_dict(text, copy.deepcopy(_DEFAULT_TEXT), sbt._VALIDATE_TEXT, return_clean=True) # this one has to be a deepcopy due to dictionary immutability _aob = sbf._validate_dict(aob, _DEFAULT_AOB, sbt._VALIDATE_AOB, return_clean=True) + # Raster controls for the temporary rendered image. + # These are kept explicit so output quality is not coupled to external rc state. + _fig = ax.get_figure() + _raster_dpi = _bar.get("raster_dpi", None) + if _raster_dpi is None: + _raster_dpi = _fig.dpi + _raster_dpi = _raster_dpi * _bar.get("raster_dpi_scale", 1) + ##### CONFIGURING TEXT ##### # First need to convert each string font size (if any) to a point size for d in [_text, _labels, _units]: @@ -290,7 +303,7 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", # First, ensuring matplotlib knows the correct dimensions for everything # as we need it to be accurate to calculate out the plots! if draw: - ax.get_figure().draw_without_rendering() + _fig.draw_without_rendering() # Getting the config for the bar (length, text, divs, etc.) bar_max, bar_length, units_label, major_div, minor_div = _config_bar(ax, _bar) @@ -308,7 +321,7 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", units_label = _units["label"] # Creating a temporary figure and axis for rendering later - fig_temp, ax_temp = _temp_figure(ax) + fig_temp, ax_temp = _temp_figure(ax, dpi=_raster_dpi) ##### BAR CONSTRUCTION ##### @@ -472,7 +485,14 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", # Placing the image in an OffsetBox, while rotating if desired # We have to set the zoom level to be relative to the DPI as well (image is in pixels) - offset_img = matplotlib.offsetbox.OffsetImage(img_scale_bar, origin="upper", zoom=72/fig_temp.dpi) + offset_img = matplotlib.offsetbox.OffsetImage( + img_scale_bar, + origin="upper", + zoom=72/fig_temp.dpi, + interpolation=_bar.get("interpolation", "none"), + dpi_cor=_bar.get("dpi_cor", True), + resample=_bar.get("resample", False), + ) # If desired, we can just return the rendered image in the final OffsetImage # This will override any aob or draw selections! Only the OffsetImage is returned! if return_aob==False: @@ -1087,15 +1107,18 @@ def _format_numeric(val, fmt, integer_override=True): return f"{val:{fmt}}" # A small function for creating a temporary figure based on a provided axis -def _temp_figure(ax, axis=False, visible=False): +def _temp_figure(ax, axis=False, visible=False, dpi=None): # Getting the figure of the provided axis fig = ax.get_figure() + # If no dpi is passed, fall back to the figure dpi + if dpi is None: + dpi = fig.dpi # Getting the dimensions of the axis ax_bbox = ax.patch.get_window_extent() # Converting to inches and rounding up ax_dim = math.ceil(max(ax_bbox.height, ax_bbox.width) / fig.dpi) # Creating a new temporary figure - fig_temp, ax_temp = matplotlib.pyplot.subplots(1,1, figsize=(ax_dim*1.5, ax_dim*1.5), dpi=fig.dpi) + fig_temp, ax_temp = matplotlib.pyplot.subplots(1,1, figsize=(ax_dim*1.5, ax_dim*1.5), dpi=dpi) # Turning off the x and y labels if desired if axis == False: ax_temp.axis("off") @@ -1291,4 +1314,4 @@ def _render_as_image(fig, ax, artist, rotation, add=True, remove=True, close=Tru if close == True: matplotlib.pyplot.close(fig) # Returning the image - return img \ No newline at end of file + return img diff --git a/matplotlib_map_utils/defaults/scale_bar.py b/matplotlib_map_utils/defaults/scale_bar.py index c132896..2bd4fea 100644 --- a/matplotlib_map_utils/defaults/scale_bar.py +++ b/matplotlib_map_utils/defaults/scale_bar.py @@ -35,7 +35,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":0.5 # changed + "tickwidth":0.5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -107,7 +112,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":0.75 # changed + "tickwidth":0.75, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -179,7 +189,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":1.5 # changed + "tickwidth":1.5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -251,7 +266,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":3 # changed + "tickwidth":3, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -323,7 +343,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":5 # changed + "tickwidth":5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -379,4 +404,4 @@ "md":[_BAR_MD, _LABELS_MD, _UNITS_MD, _TEXT_MD, _AOB_MD], "lg":[_BAR_LG, _LABELS_LG, _UNITS_LG, _TEXT_LG, _AOB_LG], "xl":[_BAR_XL, _LABELS_XL, _UNITS_XL, _TEXT_XL, _AOB_XL], -} \ No newline at end of file +} diff --git a/matplotlib_map_utils/validation/scale_bar.py b/matplotlib_map_utils/validation/scale_bar.py index ee7c120..cec559e 100644 --- a/matplotlib_map_utils/validation/scale_bar.py +++ b/matplotlib_map_utils/validation/scale_bar.py @@ -99,6 +99,11 @@ class _TYPE_BAR(TypedDict, total=False): basecolors: list | tuple | str # a color or list of colors to use for the bottom bar tickcolors: list | tuple | str # a color or list of colors to use for the ticks tickwidth: float | int # the line thickness of the bottom bar and ticks + interpolation: str | None # interpolation method used by OffsetImage; e.g. "none", "nearest", "bilinear" + dpi_cor: bool # whether OffsetImage should be corrected for renderer dpi (matplotlib default behavior) + resample: bool # whether OffsetImage should use image resampling during scaling + raster_dpi: float | int | None # explicit dpi for temporary rasterization step, None uses figure/renderer dpi + raster_dpi_scale: float | int # multiplier applied to raster_dpi for supersampling class _TYPE_LABELS(TypedDict, total=False): @@ -195,6 +200,11 @@ class _TYPE_AOB(TypedDict, total=False): "basecolors":{"func":vf._validate_iterable, "kwargs":{"func":matplotlib.rcsetup.validate_color}}, # ticks only: any color value for matplotlib "tickcolors":{"func":vf._validate_iterable, "kwargs":{"func":matplotlib.rcsetup.validate_color}}, # ticks only: any color value for matplotlib "tickwidth":{"func":vf._validate_range, "kwargs":{"min":0, "max":None, "none_ok":True}}, # ticks only: between 0 and inf + "interpolation":{"func":vf._validate_type, "kwargs":{"match":str, "none_ok":True}}, + "dpi_cor":{"func":vf._validate_type, "kwargs":{"match":bool}}, + "resample":{"func":vf._validate_type, "kwargs":{"match":bool}}, + "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, + "raster_dpi_scale":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, } _VALID_LABELS_STYLE = get_args(_TYPE_LABELS.__annotations__["style"]) @@ -272,4 +282,4 @@ class _TYPE_AOB(TypedDict, total=False): "frameon":{"func":vf._validate_type, "kwargs":{"match":bool}}, # any bool "bbox_to_anchor":{"func":vf._skip_validation}, # NOTE: currently unvalidated, use at your own risk! "bbox_transform":{"func":vf._skip_validation}, # NOTE: currently unvalidated, use at your own risk! -} \ No newline at end of file +} From 4018f261f61bf1114e3320cf974bbd77b31d8150 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Feb 2026 19:32:05 +1000 Subject: [PATCH 02/11] Require raster_dpi >= 1 in scale bar validation --- matplotlib_map_utils/validation/scale_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotlib_map_utils/validation/scale_bar.py b/matplotlib_map_utils/validation/scale_bar.py index cec559e..cd3fcf7 100644 --- a/matplotlib_map_utils/validation/scale_bar.py +++ b/matplotlib_map_utils/validation/scale_bar.py @@ -203,7 +203,7 @@ class _TYPE_AOB(TypedDict, total=False): "interpolation":{"func":vf._validate_type, "kwargs":{"match":str, "none_ok":True}}, "dpi_cor":{"func":vf._validate_type, "kwargs":{"match":bool}}, "resample":{"func":vf._validate_type, "kwargs":{"match":bool}}, - "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, + "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":1, "max":None, "none_ok":True}}, "raster_dpi_scale":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, } From c4bb0a132f9307cb20ccb2501740e36cb8827939 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Feb 2026 19:42:06 +1000 Subject: [PATCH 03/11] Bypass rasterization for unrotated scale bars --- matplotlib_map_utils/core/scale_bar.py | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index c2142de..651da45 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -320,9 +320,6 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", if _units["label"] is not None: units_label = _units["label"] - # Creating a temporary figure and axis for rendering later - fig_temp, ax_temp = _temp_figure(ax, dpi=_raster_dpi) - ##### BAR CONSTRUCTION ##### # Defining the height and width of the bar @@ -472,6 +469,30 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", major_pack = matplotlib.offsetbox.VPacker(children=units_elements, sep=_units["sep"], pad=_units["pad"], align=units_align) ##### RENDERING ##### + # If no rotation is requested and an AnchoredOffsetBox is expected, + # keep the packed artists vectorized for crisp text/edges at any export DPI. + _rotation = _bar.get("rotation", 0) or 0 + if return_aob==True and math.isclose(_rotation % 360, 0, abs_tol=1e-9): + aob_img = matplotlib.offsetbox.AnchoredOffsetbox(loc=_location, child=major_pack, **_del_keys(_aob, ["facecolor","edgecolor","alpha"])) + if _aob["facecolor"] is not None: + aob_img.patch.set_facecolor(_aob["facecolor"]) + aob_img.patch.set_visible(True) + if _aob["edgecolor"] is not None: + aob_img.patch.set_edgecolor(_aob["edgecolor"]) + aob_img.patch.set_visible(True) + if _aob["alpha"]: + aob_img.patch.set_alpha(_aob["alpha"]) + aob_img.patch.set_visible(True) + aob_img.set_zorder(_zorder) + if draw == True: + _ = ax.add_artist(aob_img) + return + else: + return aob_img + + # For rotated bars (or explicit OffsetImage return), render to raster. + fig_temp, ax_temp = _temp_figure(ax, dpi=_raster_dpi) + # Here, we have to render the scale bar as an image on the temporary fig and ax we made # This is because it is honestly too difficult to keep the image as-is and apply our rotations # Mainly because Matplotlib doesn't let you place a nested OffsetBox inside of an AuxTransformBox with a rotation applied From d278aef094d9b896458e856d4f8a1d3465a70684 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Feb 2026 19:31:39 +1000 Subject: [PATCH 04/11] Add explicit scale bar raster controls for sharp exports --- matplotlib_map_utils/core/scale_bar.py | 37 ++++++++++++++++---- matplotlib_map_utils/defaults/scale_bar.py | 37 ++++++++++++++++---- matplotlib_map_utils/validation/scale_bar.py | 12 ++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index 0e91aac..c2142de 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -205,9 +205,14 @@ def copy(self): # THANK YOU to matplotlib-scalebar for figuring this out # Note that we never specify the renderer - the axis takes care of it! def draw(self, renderer, *args, **kwargs): + # Prefer renderer dpi for class-based artists so exports stay sharp + # when savefig(dpi=...) differs from the figure construction dpi. + _bar = copy.deepcopy(self._bar) + if _bar.get("raster_dpi", None) is None: + _bar["raster_dpi"] = renderer.dpi # Can re-use the drawing function we already established, but return the object instead sb_artist = scale_bar(ax=self.axes, style=self._style, location=self._location, draw=False, - bar=self._bar, units=self._units, + bar=_bar, units=self._units, labels=self._labels, text=self._text, aob=self._aob, zorder=self._zorder) # This handles the actual drawing @@ -272,6 +277,14 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", _text = sbf._validate_dict(text, copy.deepcopy(_DEFAULT_TEXT), sbt._VALIDATE_TEXT, return_clean=True) # this one has to be a deepcopy due to dictionary immutability _aob = sbf._validate_dict(aob, _DEFAULT_AOB, sbt._VALIDATE_AOB, return_clean=True) + # Raster controls for the temporary rendered image. + # These are kept explicit so output quality is not coupled to external rc state. + _fig = ax.get_figure() + _raster_dpi = _bar.get("raster_dpi", None) + if _raster_dpi is None: + _raster_dpi = _fig.dpi + _raster_dpi = _raster_dpi * _bar.get("raster_dpi_scale", 1) + ##### CONFIGURING TEXT ##### # First need to convert each string font size (if any) to a point size for d in [_text, _labels, _units]: @@ -290,7 +303,7 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", # First, ensuring matplotlib knows the correct dimensions for everything # as we need it to be accurate to calculate out the plots! if draw: - ax.get_figure().draw_without_rendering() + _fig.draw_without_rendering() # Getting the config for the bar (length, text, divs, etc.) bar_max, bar_length, units_label, major_div, minor_div = _config_bar(ax, _bar) @@ -308,7 +321,7 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", units_label = _units["label"] # Creating a temporary figure and axis for rendering later - fig_temp, ax_temp = _temp_figure(ax) + fig_temp, ax_temp = _temp_figure(ax, dpi=_raster_dpi) ##### BAR CONSTRUCTION ##### @@ -472,7 +485,14 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", # Placing the image in an OffsetBox, while rotating if desired # We have to set the zoom level to be relative to the DPI as well (image is in pixels) - offset_img = matplotlib.offsetbox.OffsetImage(img_scale_bar, origin="upper", zoom=72/fig_temp.dpi) + offset_img = matplotlib.offsetbox.OffsetImage( + img_scale_bar, + origin="upper", + zoom=72/fig_temp.dpi, + interpolation=_bar.get("interpolation", "none"), + dpi_cor=_bar.get("dpi_cor", True), + resample=_bar.get("resample", False), + ) # If desired, we can just return the rendered image in the final OffsetImage # This will override any aob or draw selections! Only the OffsetImage is returned! if return_aob==False: @@ -1087,15 +1107,18 @@ def _format_numeric(val, fmt, integer_override=True): return f"{val:{fmt}}" # A small function for creating a temporary figure based on a provided axis -def _temp_figure(ax, axis=False, visible=False): +def _temp_figure(ax, axis=False, visible=False, dpi=None): # Getting the figure of the provided axis fig = ax.get_figure() + # If no dpi is passed, fall back to the figure dpi + if dpi is None: + dpi = fig.dpi # Getting the dimensions of the axis ax_bbox = ax.patch.get_window_extent() # Converting to inches and rounding up ax_dim = math.ceil(max(ax_bbox.height, ax_bbox.width) / fig.dpi) # Creating a new temporary figure - fig_temp, ax_temp = matplotlib.pyplot.subplots(1,1, figsize=(ax_dim*1.5, ax_dim*1.5), dpi=fig.dpi) + fig_temp, ax_temp = matplotlib.pyplot.subplots(1,1, figsize=(ax_dim*1.5, ax_dim*1.5), dpi=dpi) # Turning off the x and y labels if desired if axis == False: ax_temp.axis("off") @@ -1291,4 +1314,4 @@ def _render_as_image(fig, ax, artist, rotation, add=True, remove=True, close=Tru if close == True: matplotlib.pyplot.close(fig) # Returning the image - return img \ No newline at end of file + return img diff --git a/matplotlib_map_utils/defaults/scale_bar.py b/matplotlib_map_utils/defaults/scale_bar.py index c132896..2bd4fea 100644 --- a/matplotlib_map_utils/defaults/scale_bar.py +++ b/matplotlib_map_utils/defaults/scale_bar.py @@ -35,7 +35,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":0.5 # changed + "tickwidth":0.5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -107,7 +112,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":0.75 # changed + "tickwidth":0.75, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -179,7 +189,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":1.5 # changed + "tickwidth":1.5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -251,7 +266,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":3 # changed + "tickwidth":3, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -323,7 +343,12 @@ "tick_loc":"above", "basecolors":["black"], "tickcolors":["black"], - "tickwidth":5 # changed + "tickwidth":5, # changed + "interpolation":"none", + "dpi_cor":True, + "resample":False, + "raster_dpi":None, + "raster_dpi_scale":1, } # Labels @@ -379,4 +404,4 @@ "md":[_BAR_MD, _LABELS_MD, _UNITS_MD, _TEXT_MD, _AOB_MD], "lg":[_BAR_LG, _LABELS_LG, _UNITS_LG, _TEXT_LG, _AOB_LG], "xl":[_BAR_XL, _LABELS_XL, _UNITS_XL, _TEXT_XL, _AOB_XL], -} \ No newline at end of file +} diff --git a/matplotlib_map_utils/validation/scale_bar.py b/matplotlib_map_utils/validation/scale_bar.py index ee7c120..cec559e 100644 --- a/matplotlib_map_utils/validation/scale_bar.py +++ b/matplotlib_map_utils/validation/scale_bar.py @@ -99,6 +99,11 @@ class _TYPE_BAR(TypedDict, total=False): basecolors: list | tuple | str # a color or list of colors to use for the bottom bar tickcolors: list | tuple | str # a color or list of colors to use for the ticks tickwidth: float | int # the line thickness of the bottom bar and ticks + interpolation: str | None # interpolation method used by OffsetImage; e.g. "none", "nearest", "bilinear" + dpi_cor: bool # whether OffsetImage should be corrected for renderer dpi (matplotlib default behavior) + resample: bool # whether OffsetImage should use image resampling during scaling + raster_dpi: float | int | None # explicit dpi for temporary rasterization step, None uses figure/renderer dpi + raster_dpi_scale: float | int # multiplier applied to raster_dpi for supersampling class _TYPE_LABELS(TypedDict, total=False): @@ -195,6 +200,11 @@ class _TYPE_AOB(TypedDict, total=False): "basecolors":{"func":vf._validate_iterable, "kwargs":{"func":matplotlib.rcsetup.validate_color}}, # ticks only: any color value for matplotlib "tickcolors":{"func":vf._validate_iterable, "kwargs":{"func":matplotlib.rcsetup.validate_color}}, # ticks only: any color value for matplotlib "tickwidth":{"func":vf._validate_range, "kwargs":{"min":0, "max":None, "none_ok":True}}, # ticks only: between 0 and inf + "interpolation":{"func":vf._validate_type, "kwargs":{"match":str, "none_ok":True}}, + "dpi_cor":{"func":vf._validate_type, "kwargs":{"match":bool}}, + "resample":{"func":vf._validate_type, "kwargs":{"match":bool}}, + "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, + "raster_dpi_scale":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, } _VALID_LABELS_STYLE = get_args(_TYPE_LABELS.__annotations__["style"]) @@ -272,4 +282,4 @@ class _TYPE_AOB(TypedDict, total=False): "frameon":{"func":vf._validate_type, "kwargs":{"match":bool}}, # any bool "bbox_to_anchor":{"func":vf._skip_validation}, # NOTE: currently unvalidated, use at your own risk! "bbox_transform":{"func":vf._skip_validation}, # NOTE: currently unvalidated, use at your own risk! -} \ No newline at end of file +} From ac13dc6a33ffc966ad0d751cbf930265425b9308 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Feb 2026 19:32:05 +1000 Subject: [PATCH 05/11] Require raster_dpi >= 1 in scale bar validation --- matplotlib_map_utils/validation/scale_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotlib_map_utils/validation/scale_bar.py b/matplotlib_map_utils/validation/scale_bar.py index cec559e..cd3fcf7 100644 --- a/matplotlib_map_utils/validation/scale_bar.py +++ b/matplotlib_map_utils/validation/scale_bar.py @@ -203,7 +203,7 @@ class _TYPE_AOB(TypedDict, total=False): "interpolation":{"func":vf._validate_type, "kwargs":{"match":str, "none_ok":True}}, "dpi_cor":{"func":vf._validate_type, "kwargs":{"match":bool}}, "resample":{"func":vf._validate_type, "kwargs":{"match":bool}}, - "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, + "raster_dpi":{"func":vf._validate_range, "kwargs":{"min":1, "max":None, "none_ok":True}}, "raster_dpi_scale":{"func":vf._validate_range, "kwargs":{"min":0.0001, "max":None, "none_ok":True}}, } From 13093972e1cb43bef609de0493fb819d4bb852f2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 21 Feb 2026 15:44:45 +1000 Subject: [PATCH 06/11] Dispatch default scale_bar drawing through ScaleBar artist --- matplotlib_map_utils/core/scale_bar.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index c2142de..baecfe3 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -256,6 +256,22 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", aob: None | bool | sbt._TYPE_AOB=None, zorder: int=99, return_aob: bool=True,): + # For the default function mode, dispatch to the Artist class so final + # rasterization happens at draw-time with the active renderer dpi. + if draw == True and return_aob == True: + _ = ax.add_artist( + ScaleBar( + style=style, + location=location, + bar=bar, + units=units, + labels=labels, + text=text, + aob=aob, + zorder=zorder, + ) + ) + return ##### VALIDATION ##### _style = sbf._validate(sbt._VALIDATE_PRIMARY, "style", style) From 8aba1bb2d6b3a0d92ec70e23535671f79f0a8aec Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 22 Feb 2026 19:19:05 +1000 Subject: [PATCH 07/11] Fix raster_dpi handling under UltraPlot Agg render path --- matplotlib_map_utils/core/scale_bar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index baecfe3..398851a 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -1309,9 +1309,9 @@ def _render_as_image(fig, ax, artist, rotation, add=True, remove=True, close=Tru # If needed, adding the artist to the axis if add == True: ax.add_artist(artist) - # Draw the figure, but without showing it, to place all the elements - fig.draw_without_rendering() - # Sets the canvas for the figure to AGG (Anti-Grain Geometry) + # Render directly with Agg; a prior draw_without_rendering() can override + # temporary figure DPI in some wrappers (e.g., UltraPlot), which makes + # raster_dpi ineffective. canvas = FigureCanvasAgg(fig) # Draws the figure onto the canvas canvas.draw() From b278f44e9847778f74c5d3163339a1f722140118 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 24 Feb 2026 18:47:13 +1000 Subject: [PATCH 08/11] Refactor scale bar raster dpi resolution --- matplotlib_map_utils/core/scale_bar.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index 398851a..9b7e133 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -35,6 +35,18 @@ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["md"] + +def _resolve_raster_dpi(bar, fig, renderer=None): + """ + Resolve base raster DPI and raster scale for temporary rendering. + """ + raster_dpi = bar.get("raster_dpi", None) + if raster_dpi is None: + raster_dpi = renderer.dpi if renderer is not None else fig.dpi + raster_dpi_scale = bar.get("raster_dpi_scale", 1) + return raster_dpi, raster_dpi_scale + + ### CLASSES ### class ScaleBar(matplotlib.artist.Artist): @@ -209,7 +221,10 @@ def draw(self, renderer, *args, **kwargs): # when savefig(dpi=...) differs from the figure construction dpi. _bar = copy.deepcopy(self._bar) if _bar.get("raster_dpi", None) is None: - _bar["raster_dpi"] = renderer.dpi + _raster_dpi, _ = _resolve_raster_dpi( + _bar, self.axes.get_figure(), renderer=renderer + ) + _bar["raster_dpi"] = _raster_dpi # Can re-use the drawing function we already established, but return the object instead sb_artist = scale_bar(ax=self.axes, style=self._style, location=self._location, draw=False, bar=_bar, units=self._units, @@ -296,10 +311,8 @@ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes", # Raster controls for the temporary rendered image. # These are kept explicit so output quality is not coupled to external rc state. _fig = ax.get_figure() - _raster_dpi = _bar.get("raster_dpi", None) - if _raster_dpi is None: - _raster_dpi = _fig.dpi - _raster_dpi = _raster_dpi * _bar.get("raster_dpi_scale", 1) + _raster_dpi, _raster_dpi_scale = _resolve_raster_dpi(_bar, _fig) + _raster_dpi = _raster_dpi * _raster_dpi_scale ##### CONFIGURING TEXT ##### # First need to convert each string font size (if any) to a point size From c064f3c70365f6db71c556e86922bc63701232c0 Mon Sep 17 00:00:00 2001 From: moss-xyz <43250672+moss-xyz@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:48:59 +1300 Subject: [PATCH 09/11] Incrementing versioning in pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de5b662..f9b6fb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "matplotlib-map-utils" -version = "3.1.1" +version = "3.1.2" authors = [ { name="David Moss", email="davidmoss1221@gmail.com" }, ] From 1ebd7c3690be86bc9ffb03016452c7e6f5cc9a4c Mon Sep 17 00:00:00 2001 From: moss-xyz <43250672+moss-xyz@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:51:59 +1300 Subject: [PATCH 10/11] Minor adjustment to internal function placement to match prior --- .gitignore | 3 ++- matplotlib_map_utils/core/scale_bar.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 03ad553..4baa508 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ scratch/ dist/ *.egg-info/ -.venv/ \ No newline at end of file +.venv/ +uv.lock \ No newline at end of file diff --git a/matplotlib_map_utils/core/scale_bar.py b/matplotlib_map_utils/core/scale_bar.py index 9b7e133..e22ba29 100644 --- a/matplotlib_map_utils/core/scale_bar.py +++ b/matplotlib_map_utils/core/scale_bar.py @@ -36,17 +36,6 @@ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["md"] -def _resolve_raster_dpi(bar, fig, renderer=None): - """ - Resolve base raster DPI and raster scale for temporary rendering. - """ - raster_dpi = bar.get("raster_dpi", None) - if raster_dpi is None: - raster_dpi = renderer.dpi if renderer is not None else fig.dpi - raster_dpi_scale = bar.get("raster_dpi_scale", 1) - return raster_dpi, raster_dpi_scale - - ### CLASSES ### class ScaleBar(matplotlib.artist.Artist): @@ -1344,3 +1333,14 @@ def _render_as_image(fig, ax, artist, rotation, add=True, remove=True, close=Tru matplotlib.pyplot.close(fig) # Returning the image return img + + +def _resolve_raster_dpi(bar, fig, renderer=None): + """ + Resolve base raster DPI and raster scale for temporary rendering. + """ + raster_dpi = bar.get("raster_dpi", None) + if raster_dpi is None: + raster_dpi = renderer.dpi if renderer is not None else fig.dpi + raster_dpi_scale = bar.get("raster_dpi_scale", 1) + return raster_dpi, raster_dpi_scale \ No newline at end of file From 5b46ec5406a767a8bea8d4a973bacadd9273b091 Mon Sep 17 00:00:00 2001 From: moss-xyz <43250672+moss-xyz@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:53:59 +1300 Subject: [PATCH 11/11] Updating readme to 3.1.2 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cdf900b..d174325 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,8 @@ Two more projects assisted with the creation of this script: - `v3.1.1`: Fixed a bug that led to errors when creating a `scale_bar` at resolutions below 5km or 1 mile, due to a bug in the backend configuration functions (namely, `_config_bar_dim()`), which was fixed by correctly instantiating the necessary variable `ax_units` in other cases via an `else` statement (see [here](https://github.com/moss-xyz/matplotlib-map-utils/issues/14) for details). +- `v3.1.2`: Fixed a compatibility issue with [Ultraplot](https://github.com/Ultraplot/UltraPlot), primarily affecting the `ScaleBar` element, where text would rasterize at a low resolution (see [here](https://github.com/moss-xyz/matplotlib-map-utils/issues/16) and [here](https://github.com/moss-xyz/matplotlib-map-utils/pull/17) for details). A big thank you to cvanelteren on the Ultraplot team for identifying and implementing the necessary fixes, as well as making adjustments to the Ultraplot package to improve compatibility! + #### Future Roadmap With the release of `v3.x`, this project has achieved full coverage of the "main" map elements I think are necessary.