Skip to content
Open
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
12 changes: 12 additions & 0 deletions lib/matplotlib/backends/backend_mixed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Comment on lines +93 to +98
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MixedModeRenderer.get_image_magnification currently returns self.dpi / self._figdpi unconditionally. When MixedModeRenderer is in raster mode (self._renderer is the Agg raster renderer), callers typically expect magnification to match the active raster renderer (usually 1.0) to avoid unintended extra resampling/memory use for images drawn while rasterizing. Consider returning 1.0 (or delegating to the active renderer) when rasterizing, and only returning dpi/_figdpi when in vector mode.

Suggested change
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.
"""
In vector mode, `.MixedModeRenderer` converts from the figure DPI
(typically 72 for PDF/SVG/PS) to ``self.dpi`` (the user's requested
DPI). When rasterizing, defer to the active raster renderer so image
drawing uses that renderer's native magnification.
"""
if self._raster_renderer is not None and self._renderer is self._raster_renderer:
return self._renderer.get_image_magnification()

Copilot uses AI. Check for mistakes.
return self.dpi / self._figdpi

def stop_rasterizing(self):
"""
Exit "raster" mode. All of the drawing that was done since
Expand Down
48 changes: 43 additions & 5 deletions lib/matplotlib/offsetbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing MixedModeRenderer at module import time pulls in backend code (backend_mixed -> backend_agg) from a core artist module, which can increase import time and risks circular-import issues. Prefer avoiding the hard import (e.g., use a local import inside the rasterization branch, or switch to a capability/attribute check rather than isinstance(renderer, MixedModeRenderer)).

Suggested change
from matplotlib.backends.backend_mixed import MixedModeRenderer

Copilot uses AI. Check for mistakes.


DEBUG = False
Expand Down Expand Up @@ -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],
Expand All @@ -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)
Comment on lines +701 to +716
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scaling branch should be skipped when the renderer is already rasterizing (e.g., this DrawingArea is inside a rasterized parent). In that case points_to_pixels is already in raster-DPI units, so multiplying by mag again will over-scale (dpi factors can effectively be applied twice). Consider guarding with and not renderer._rasterizing (and/or renderer._raster_depth == 0) so the correction only applies when entering rasterization from vector mode.

Copilot uses AI. Check for mistakes.
c.draw(renderer)
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)
Comment on lines +717 to +720
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code mutates dpi_transform and offset_transform and then restores them, but restoration isn’t protected against exceptions. If c.draw(renderer) raises, the transforms remain scaled and can corrupt subsequent draws/layout. Wrap the temporary scaling/restoration in a try/finally to guarantee state restoration.

Suggested change
c.draw(renderer)
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)
try:
c.draw(renderer)
finally:
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)

Copilot uses AI. Check for mistakes.
else:
c.draw(renderer)

_bbox_artist(self, renderer, fill=False, props=dict(pad=0.))
self.stale = False
Expand Down Expand Up @@ -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)
Comment on lines +936 to +938
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in DrawingArea.draw: temporary mutation of offset_transform/ref_offset_transform should be protected with try/finally so state is restored even if c.draw(renderer) errors.

Suggested change
c.draw(renderer)
self.offset_transform.set_matrix(off_mat)
self.ref_offset_transform.set_matrix(ref_mat)
try:
c.draw(renderer)
finally:
self.offset_transform.set_matrix(off_mat)
self.ref_offset_transform.set_matrix(ref_mat)

Copilot uses AI. Check for mistakes.
else:
c.draw(renderer)
_bbox_artist(self, renderer, fill=False, props=dict(pad=0.))
self.stale = False

Expand Down
51 changes: 49 additions & 2 deletions lib/matplotlib/tests/test_offsetbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Loading