Skip to content

Commit a3bff52

Browse files
committed
Fix #28549
1 parent f8707b2 commit a3bff52

3 files changed

Lines changed: 140 additions & 35 deletions

File tree

lib/matplotlib/backends/backend_mixed.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ def start_rasterizing(self):
8686
self._raster_renderer)
8787
self._bbox_inches_restore = r
8888

89+
def get_image_magnification(self):
90+
"""
91+
Return the magnification factor for rasterized content.
92+
93+
When using `.MixedModeRenderer`, the figure DPI is set to the vector
94+
renderer's DPI (typically 72 for PDF/SVG/PS) but rasterized content
95+
is drawn at ``self.dpi`` (the user's requested DPI). This factor
96+
converts from vector-space display coordinates to raster-space pixel
97+
coordinates.
98+
"""
99+
return self.dpi / self._figdpi
100+
89101
def stop_rasterizing(self):
90102
"""
91103
Exit "raster" mode. All of the drawing that was done since

lib/matplotlib/offsetbox.py

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,12 @@
3737
from matplotlib.patches import (
3838
FancyBboxPatch, FancyArrowPatch, bbox_artist as mbbox_artist)
3939
from matplotlib.transforms import Bbox, BboxBase, TransformedBbox
40+
from matplotlib.backends.backend_mixed import MixedModeRenderer
4041

4142

4243
DEBUG = False
4344

4445

45-
def _is_vector_renderer(renderer):
46-
# 1. Check the renderer name
47-
vector_bases = {'RendererPdf', 'RendererSVG', 'RendererPS'}
48-
# If it's MixedModeRenderer, get the actual renderer recursively
49-
actual_renderer = renderer
50-
while type(actual_renderer).__name__ == 'MixedModeRenderer':
51-
actual_renderer = getattr(actual_renderer, '_renderer', actual_renderer)
52-
renderer_name = type(actual_renderer).__name__
53-
if renderer_name in vector_bases:
54-
return True
55-
# 3. For unknown renderers, assume they are not vector
56-
return False
57-
58-
5946
def _compat_get_offset(meth):
6047
"""
6148
Decorator for the get_offset method of OffsetBox and subclasses, that
@@ -709,26 +696,29 @@ def draw(self, renderer):
709696
[self.width, 0]]),
710697
self.get_transform())
711698
for c in self._children:
712-
is_rasterized = getattr(c, 'get_rasterized', lambda: False)()
713-
if is_rasterized and _is_vector_renderer(renderer):
714-
# PDF/SVG backend uses 72 dpi in display units,
715-
# so we need to scale the rasterized content accordingly
716-
target_dpi = getattr(renderer, 'dpi', 72) or 72
717-
dpi_correction = target_dpi / 72
718-
# New transform to apply the dpi correction
719-
corrected_transform = self.get_transform() \
720-
+ mtransforms.Affine2D().scale(dpi_correction)
721-
orig_transform = c.get_transform() \
722-
if hasattr(c, 'get_transform') else None
723-
c.set_transform(corrected_transform)
724-
if self._clip_children and not (c.clipbox or c._clippath):
725-
c.set_clip_path(tpath)
699+
if self._clip_children and not (c.clipbox or c._clippath):
700+
c.set_clip_path(tpath)
701+
if c.get_rasterized() and isinstance(renderer, MixedModeRenderer):
702+
# When using MixedModeRenderer (PDF/SVG/PS), the figure DPI is
703+
# set to the vector renderer's DPI (typically 72 pt/inch), so
704+
# dpi_cor ≈ 1.0. But rasterized children are drawn into a
705+
# RendererAgg buffer at renderer.dpi (the user's requested DPI).
706+
# The child's transform is dpi_transform + offset_transform,
707+
# where offset_transform is a translation in vector-DPI display
708+
# units. Both must be scaled to raster-DPI pixel coordinates.
709+
mag = renderer.get_image_magnification()
710+
raster_dpi_cor = dpi_cor * mag
711+
off_mat = self.offset_transform.get_matrix().copy()
712+
scaled_off = off_mat.copy()
713+
scaled_off[:2, 2] *= mag # scale tx and ty to raster DPI
714+
self.dpi_transform.clear()
715+
self.dpi_transform.scale(raster_dpi_cor)
716+
self.offset_transform.set_matrix(scaled_off)
726717
c.draw(renderer)
727-
if orig_transform is not None:
728-
c.set_transform(orig_transform)
718+
self.dpi_transform.clear()
719+
self.dpi_transform.scale(dpi_cor)
720+
self.offset_transform.set_matrix(off_mat)
729721
else:
730-
if self._clip_children and not (c.clipbox or c._clippath):
731-
c.set_clip_path(tpath)
732722
c.draw(renderer)
733723

734724
_bbox_artist(self, renderer, fill=False, props=dict(pad=0.))
@@ -930,7 +920,26 @@ def get_bbox(self, renderer):
930920
def draw(self, renderer):
931921
# docstring inherited
932922
for c in self._children:
933-
c.draw(renderer)
923+
if c.get_rasterized() and isinstance(renderer, MixedModeRenderer):
924+
# offset_transform and ref_offset_transform store display-unit
925+
# translations computed at vector DPI (typically 72 pt/inch for
926+
# PDF/SVG/PS). When rasterizing, the RendererAgg buffer uses
927+
# renderer.dpi (user DPI). Scale the translation components so
928+
# the child draws at the correct position in the raster buffer.
929+
mag = renderer.get_image_magnification()
930+
off_mat = self.offset_transform.get_matrix().copy()
931+
ref_mat = self.ref_offset_transform.get_matrix().copy()
932+
scaled_off = off_mat.copy()
933+
scaled_off[:2, 2] *= mag # scale tx and ty only
934+
scaled_ref = ref_mat.copy()
935+
scaled_ref[:2, 2] *= mag
936+
self.offset_transform.set_matrix(scaled_off)
937+
self.ref_offset_transform.set_matrix(scaled_ref)
938+
c.draw(renderer)
939+
self.offset_transform.set_matrix(off_mat)
940+
self.ref_offset_transform.set_matrix(ref_mat)
941+
else:
942+
c.draw(renderer)
934943
_bbox_artist(self, renderer, fill=False, props=dict(pad=0.))
935944
self.stale = False
936945

lib/matplotlib/tests/test_offsetbox.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from matplotlib.backend_bases import MouseButton, MouseEvent
1313

1414
from matplotlib.offsetbox import (
15-
AnchoredOffsetbox, AnnotationBbox, AnchoredText, DrawingArea, HPacker,
16-
OffsetBox, OffsetImage, PaddedBox, TextArea, VPacker, _get_packed_offsets)
15+
AnchoredOffsetbox, AnnotationBbox, AnchoredText, AuxTransformBox,
16+
DrawingArea, HPacker, OffsetBox, OffsetImage, PaddedBox, TextArea, VPacker,
17+
_get_packed_offsets)
1718

1819

1920
@image_comparison(['offsetbox_clipping'], remove_text=True)
@@ -470,3 +471,86 @@ def test_draggable_in_subfigure():
470471
bbox = ann.get_window_extent()
471472
MouseEvent("button_press_event", fig.canvas, bbox.x1+2, bbox.y1+2)._process()
472473
assert not ann._draggable.got_artist
474+
475+
476+
def test_anchored_offsetbox_tuple_and_float_borderpad():
477+
"""
478+
Test AnchoredOffsetbox correctly handles both float and tuple for borderpad.
479+
"""
480+
481+
fig, ax = plt.subplots()
482+
483+
# Case 1: Establish a baseline with float value
484+
text_float = AnchoredText("float", loc='lower left', borderpad=5)
485+
ax.add_artist(text_float)
486+
487+
# Case 2: Test that a symmetric tuple gives the exact same result.
488+
text_tuple_equal = AnchoredText("tuple", loc='lower left', borderpad=(5, 5))
489+
ax.add_artist(text_tuple_equal)
490+
491+
# Case 3: Test that an asymmetric tuple with different values works as expected.
492+
text_tuple_asym = AnchoredText("tuple_asym", loc='lower left', borderpad=(10, 4))
493+
ax.add_artist(text_tuple_asym)
494+
495+
# Draw the canvas to calculate final positions
496+
fig.canvas.draw()
497+
498+
pos_float = text_float.get_window_extent()
499+
pos_tuple_equal = text_tuple_equal.get_window_extent()
500+
pos_tuple_asym = text_tuple_asym.get_window_extent()
501+
502+
# Assertion 1: Prove that borderpad=5 is identical to borderpad=(5, 5).
503+
assert pos_tuple_equal.x0 == pos_float.x0
504+
assert pos_tuple_equal.y0 == pos_float.y0
505+
506+
# Assertion 2: Prove that the asymmetric padding moved the box
507+
# further from the origin than the baseline in the x-direction and less far
508+
# in the y-direction.
509+
assert pos_tuple_asym.x0 > pos_float.x0
510+
assert pos_tuple_asym.y0 < pos_float.y0
511+
512+
513+
@check_figures_equal(extensions=["pdf", "svg", "png"], tol=5)
514+
def test_rasterized_artist_in_drawing_area(fig_test, fig_ref):
515+
"""Rasterized artists in DrawingArea must be correctly positioned in
516+
vector backends. Regression test for
517+
https://github.com/matplotlib/matplotlib/issues/28549
518+
"""
519+
from matplotlib.collections import PatchCollection
520+
from matplotlib.patches import Rectangle
521+
522+
def make_plot(fig, rasterized):
523+
ax = fig.subplots()
524+
box = DrawingArea(10, 10)
525+
rects = PatchCollection(
526+
[Rectangle((0, i), width=10, height=1) for i in range(10)]
527+
)
528+
rects.set_rasterized(rasterized)
529+
box.add_artist(rects)
530+
ax.add_artist(AnchoredOffsetbox(child=box, loc="center", pad=0))
531+
532+
make_plot(fig_test, rasterized=True)
533+
make_plot(fig_ref, rasterized=False)
534+
535+
536+
@check_figures_equal(extensions=["pdf", "svg", "png"], tol=8)
537+
def test_rasterized_artist_in_aux_transform_box(fig_test, fig_ref):
538+
"""Rasterized artists in AuxTransformBox must be correctly positioned in
539+
vector backends. Regression test for
540+
https://github.com/matplotlib/matplotlib/issues/28549
541+
"""
542+
from matplotlib.patches import Ellipse
543+
544+
def make_plot(fig, rasterized):
545+
ax = fig.subplots()
546+
ax.set_xlim(0, 10)
547+
ax.set_ylim(0, 10)
548+
# Use ax.transData as the typical real-world use of AuxTransformBox
549+
box = AuxTransformBox(ax.transData)
550+
el = Ellipse((5, 5), width=4, height=2)
551+
el.set_rasterized(rasterized)
552+
box.add_artist(el)
553+
ax.add_artist(AnchoredOffsetbox(child=box, loc="center", pad=0))
554+
555+
make_plot(fig_test, rasterized=True)
556+
make_plot(fig_ref, rasterized=False)

0 commit comments

Comments
 (0)