diff --git a/lib/matplotlib/backends/backend_mixed.py b/lib/matplotlib/backends/backend_mixed.py index 36c0896f3097..187d08aab27c 100644 --- a/lib/matplotlib/backends/backend_mixed.py +++ b/lib/matplotlib/backends/backend_mixed.py @@ -86,6 +86,18 @@ def start_rasterizing(self): self._raster_renderer) self._bbox_inches_restore = r + def get_image_magnification(self): + """ + Return the magnification factor for rasterized content. + + When using `.MixedModeRenderer`, the figure DPI is set to the vector + renderer's DPI (typically 72 for PDF/SVG/PS) but rasterized content + is drawn at ``self.dpi`` (the user's requested DPI). This factor + converts from vector-space display coordinates to raster-space pixel + coordinates. + """ + return self.dpi / self._figdpi + def stop_rasterizing(self): """ Exit "raster" mode. All of the drawing that was done since diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 9b9c7a69f35f..cb8fc7e21991 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -37,6 +37,7 @@ from matplotlib.patches import ( FancyBboxPatch, FancyArrowPatch, bbox_artist as mbbox_artist) from matplotlib.transforms import Bbox, BboxBase, TransformedBbox +from matplotlib.backends.backend_mixed import MixedModeRenderer DEBUG = False @@ -689,9 +690,6 @@ def draw(self, renderer): self.dpi_transform.clear() self.dpi_transform.scale(dpi_cor) - # At this point the DrawingArea has a transform - # to the display space so the path created is - # good for clipping children tpath = mtransforms.TransformedPath( mpath.Path([[0, 0], [0, self.height], [self.width, self.height], @@ -700,7 +698,28 @@ def draw(self, renderer): for c in self._children: if self._clip_children and not (c.clipbox or c._clippath): c.set_clip_path(tpath) - c.draw(renderer) + if c.get_rasterized() and isinstance(renderer, MixedModeRenderer): + # When using MixedModeRenderer (PDF/SVG/PS), the figure DPI is + # set to the vector renderer's DPI (typically 72 pt/inch), so + # dpi_cor ≈ 1.0. But rasterized children are drawn into a + # RendererAgg buffer at renderer.dpi (the user's requested DPI). + # The child's transform is dpi_transform + offset_transform, + # where offset_transform is a translation in vector-DPI display + # units. Both must be scaled to raster-DPI pixel coordinates. + mag = renderer.get_image_magnification() + raster_dpi_cor = dpi_cor * mag + off_mat = self.offset_transform.get_matrix().copy() + scaled_off = off_mat.copy() + scaled_off[:2, 2] *= mag # scale tx and ty to raster DPI + self.dpi_transform.clear() + self.dpi_transform.scale(raster_dpi_cor) + self.offset_transform.set_matrix(scaled_off) + c.draw(renderer) + self.dpi_transform.clear() + self.dpi_transform.scale(dpi_cor) + self.offset_transform.set_matrix(off_mat) + else: + c.draw(renderer) _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False @@ -899,7 +918,26 @@ def get_bbox(self, renderer): def draw(self, renderer): # docstring inherited for c in self._children: - c.draw(renderer) + if c.get_rasterized() and isinstance(renderer, MixedModeRenderer): + # offset_transform and ref_offset_transform store display-unit + # translations computed at vector DPI (typically 72 pt/inch for + # PDF/SVG/PS). When rasterizing, the RendererAgg buffer uses + # renderer.dpi (user DPI). Scale the translation components so + # the child draws at the correct position in the raster buffer. + mag = renderer.get_image_magnification() + off_mat = self.offset_transform.get_matrix().copy() + ref_mat = self.ref_offset_transform.get_matrix().copy() + scaled_off = off_mat.copy() + scaled_off[:2, 2] *= mag # scale tx and ty only + scaled_ref = ref_mat.copy() + scaled_ref[:2, 2] *= mag + self.offset_transform.set_matrix(scaled_off) + self.ref_offset_transform.set_matrix(scaled_ref) + c.draw(renderer) + self.offset_transform.set_matrix(off_mat) + self.ref_offset_transform.set_matrix(ref_mat) + else: + c.draw(renderer) _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py index f126b1cbb466..cae8a4988bfd 100644 --- a/lib/matplotlib/tests/test_offsetbox.py +++ b/lib/matplotlib/tests/test_offsetbox.py @@ -12,8 +12,9 @@ from matplotlib.backend_bases import MouseButton, MouseEvent from matplotlib.offsetbox import ( - AnchoredOffsetbox, AnnotationBbox, AnchoredText, DrawingArea, HPacker, - OffsetBox, OffsetImage, PaddedBox, TextArea, VPacker, _get_packed_offsets) + AnchoredOffsetbox, AnnotationBbox, AnchoredText, AuxTransformBox, + DrawingArea, HPacker, OffsetBox, OffsetImage, PaddedBox, TextArea, VPacker, + _get_packed_offsets) @image_comparison(['offsetbox_clipping'], remove_text=True) @@ -507,3 +508,49 @@ def test_anchored_offsetbox_tuple_and_float_borderpad(): # in the y-direction. assert pos_tuple_asym.x0 > pos_float.x0 assert pos_tuple_asym.y0 < pos_float.y0 + + +@check_figures_equal(extensions=["pdf", "svg", "png"], tol=5) +def test_rasterized_artist_in_drawing_area(fig_test, fig_ref): + """Rasterized artists in DrawingArea must be correctly positioned in + vector backends. Regression test for + https://github.com/matplotlib/matplotlib/issues/28549 + """ + from matplotlib.collections import PatchCollection + from matplotlib.patches import Rectangle + + def make_plot(fig, rasterized): + ax = fig.subplots() + box = DrawingArea(10, 10) + rects = PatchCollection( + [Rectangle((0, i), width=10, height=1) for i in range(10)] + ) + rects.set_rasterized(rasterized) + box.add_artist(rects) + ax.add_artist(AnchoredOffsetbox(child=box, loc="center", pad=0)) + + make_plot(fig_test, rasterized=True) + make_plot(fig_ref, rasterized=False) + + +@check_figures_equal(extensions=["pdf", "svg", "png"], tol=8) +def test_rasterized_artist_in_aux_transform_box(fig_test, fig_ref): + """Rasterized artists in AuxTransformBox must be correctly positioned in + vector backends. Regression test for + https://github.com/matplotlib/matplotlib/issues/28549 + """ + from matplotlib.patches import Ellipse + + def make_plot(fig, rasterized): + ax = fig.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + # Use ax.transData as the typical real-world use of AuxTransformBox + box = AuxTransformBox(ax.transData) + el = Ellipse((5, 5), width=4, height=2) + el.set_rasterized(rasterized) + box.add_artist(el) + ax.add_artist(AnchoredOffsetbox(child=box, loc="center", pad=0)) + + make_plot(fig_test, rasterized=True) + make_plot(fig_ref, rasterized=False)