From 4104dd5aed28b1d289d08fc175e63b6b145ca124 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Wed, 12 Nov 2025 08:49:04 +0000 Subject: [PATCH 1/5] Fix PDF bloat for off-axis scatter with per-point colors Skip emitting markers outside canvas bounds in draw_path_collection to reduce PDF file size when scatter points are off-axis. Fixes #2488 --- lib/matplotlib/backends/backend_pdf.py | 6 ++ lib/matplotlib/tests/test_backend_pdf.py | 109 +++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index d63808eb3925..109cd9339208 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2118,6 +2118,12 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position, hatchcolors=hatchcolors): + # Skip markers outside visible canvas bounds to reduce PDF size + # (same optimization as in draw_markers). + if not (0 <= xo <= self.file.width * 72 + and 0 <= yo <= self.file.height * 72): + continue + self.check_gc(gc0, rgbFace) dx, dy = xo - lastx, yo - lasty output(1, 0, 0, 1, dx, dy, Op.concat_matrix, path_id, diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index f126fb543e78..032326f24a6a 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -478,3 +478,112 @@ def test_font_bitstream_charter(): ax.text(0.1, 0.3, r"fi ffl 1234", usetex=True, fontsize=50) ax.set_xticks([]) ax.set_yticks([]) + + +def test_scatter_offaxis_colored_pdf_size(): + """ + Test that off-axis scatter plots with per-point colors don't bloat PDFs. + + Regression test for issue #2488. When scatter points with per-point colors + are completely outside the visible axes, the PDF backend should skip + writing those markers to significantly reduce file size. + """ + # Use John Hunter's birthday as random seed for reproducibility + rng = np.random.default_rng(19680801) + + n_points = 1000 + x = rng.random(n_points) * 10 + y = rng.random(n_points) * 10 + c = rng.random(n_points) + + # Test 1: Scatter with per-point colors, all points OFF-AXIS + fig1, ax1 = plt.subplots() + ax1.scatter(x, y, c=c) + ax1.set_xlim(20, 30) # Move view completely away from data (x is 0-10) + ax1.set_ylim(20, 30) # Move view completely away from data (y is 0-10) + + buf1 = io.BytesIO() + fig1.savefig(buf1, format='pdf') + size_offaxis_colored = buf1.tell() + plt.close(fig1) + + # Test 2: Empty scatter (baseline - smallest possible) + fig2, ax2 = plt.subplots() + ax2.set_xlim(20, 30) + ax2.set_ylim(20, 30) + + buf2 = io.BytesIO() + fig2.savefig(buf2, format='pdf') + size_empty = buf2.tell() + plt.close(fig2) + + # The off-axis colored scatter should be close to empty size + # Allow up to 50KB overhead for axes/metadata, but should be much smaller + # than if all 1000 markers were written (which would add ~200-400KB) + assert size_offaxis_colored < size_empty + 50_000, ( + f"Off-axis colored scatter PDF ({size_offaxis_colored} bytes) is too large. " + f"Expected close to empty figure size ({size_empty} bytes). " + f"Markers may not be properly skipped." + ) + + +@check_figures_equal(extensions=["pdf"]) +def test_scatter_offaxis_colored_visual(fig_test, fig_ref): + """ + Test that on-axis scatter with per-point colors still renders correctly. + + Ensures the optimization for off-axis markers doesn't break normal + scatter rendering. + """ + rng = np.random.default_rng(19680801) + + n_points = 100 + x = rng.random(n_points) * 5 + y = rng.random(n_points) * 5 + c = rng.random(n_points) + + # Test figure: scatter with clipping optimization + ax_test = fig_test.subplots() + ax_test.scatter(x, y, c=c, s=50) + ax_test.set_xlim(0, 10) + ax_test.set_ylim(0, 10) + + # Reference figure: should look identical + ax_ref = fig_ref.subplots() + ax_ref.scatter(x, y, c=c, s=50) + ax_ref.set_xlim(0, 10) + ax_ref.set_ylim(0, 10) + + +@check_figures_equal(extensions=["pdf"]) +def test_scatter_mixed_onoff_axis(fig_test, fig_ref): + """ + Test scatter with some points on-axis and some off-axis. + + Ensures the optimization correctly handles the common case where only + some markers are outside the visible area. + """ + rng = np.random.default_rng(19680801) + + # Create points: half on-axis (0-5), half off-axis (15-20) + n_points = 50 + x_on = rng.random(n_points) * 5 + y_on = rng.random(n_points) * 5 + x_off = rng.random(n_points) * 5 + 15 + y_off = rng.random(n_points) * 5 + 15 + + x = np.concatenate([x_on, x_off]) + y = np.concatenate([y_on, y_off]) + c = rng.random(2 * n_points) + + # Test figure: scatter with mixed points + ax_test = fig_test.subplots() + ax_test.scatter(x, y, c=c, s=50) + ax_test.set_xlim(0, 10) + ax_test.set_ylim(0, 10) + + # Reference figure: only the on-axis points should be visible + ax_ref = fig_ref.subplots() + ax_ref.scatter(x_on, y_on, c=c[:n_points], s=50) + ax_ref.set_xlim(0, 10) + ax_ref.set_ylim(0, 10) From f95d7611f9932a68a099ca698fe25709b07a16fd Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Wed, 12 Nov 2025 20:47:58 +0530 Subject: [PATCH 2/5] Skip off-canvas markers in PDF backend, accounting for marker size --- lib/matplotlib/backends/backend_pdf.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 109cd9339208..219ef2d79c29 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2104,12 +2104,26 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, padding = np.max(linewidths) path_codes = [] + + # Calculate maximum marker extent for conservative bounds checking. + # We need to account for marker size, not just position. + max_marker_extent = 0 for i, (path, transform) in enumerate(self._iter_collection_raw_paths( master_transform, paths, all_transforms)): + if len(path.vertices): + # Get the bounding box of the transformed marker path. + # Use get_extents() which is more efficient than transforming + # all vertices, and add padding for stroke width. + bbox = path.get_extents(transform) + max_marker_extent = max(max_marker_extent, + bbox.width / 2, bbox.height / 2) name = self.file.pathCollectionObject( gc, path, transform, padding, filled, stroked) path_codes.append(name) + # Add padding for stroke width. + max_marker_extent += padding + output = self.file.output output(*self.gc.push()) lastx, lasty = 0, 0 @@ -2118,10 +2132,13 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position, hatchcolors=hatchcolors): - # Skip markers outside visible canvas bounds to reduce PDF size - # (same optimization as in draw_markers). - if not (0 <= xo <= self.file.width * 72 - and 0 <= yo <= self.file.height * 72): + # Skip markers outside visible canvas bounds to reduce PDF size. + # Add max_marker_extent margin to account for marker size - a marker + # may be partially visible even if its center is outside the canvas. + canvas_width = self.file.width * 72 + canvas_height = self.file.height * 72 + if not (-max_marker_extent <= xo <= canvas_width + max_marker_extent + and -max_marker_extent <= yo <= canvas_height + max_marker_extent): continue self.check_gc(gc0, rgbFace) From 1f3b3c344772b58a1bda0f5eac2233a83a3de19f Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 13 Nov 2025 14:34:32 +0530 Subject: [PATCH 3/5] Fix PDF bloat for off-axis scatter with per-point colors --- lib/matplotlib/backends/backend_pdf.py | 43 ++++++++++++++------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 219ef2d79c29..65a15f393f57 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2104,25 +2104,28 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, padding = np.max(linewidths) path_codes = [] - - # Calculate maximum marker extent for conservative bounds checking. - # We need to account for marker size, not just position. - max_marker_extent = 0 + path_extents = [] for i, (path, transform) in enumerate(self._iter_collection_raw_paths( master_transform, paths, all_transforms)): - if len(path.vertices): - # Get the bounding box of the transformed marker path. - # Use get_extents() which is more efficient than transforming - # all vertices, and add padding for stroke width. - bbox = path.get_extents(transform) - max_marker_extent = max(max_marker_extent, - bbox.width / 2, bbox.height / 2) name = self.file.pathCollectionObject( gc, path, transform, padding, filled, stroked) path_codes.append(name) + # Compute the extent of each marker path to enable per-marker + # bounds checking. This allows us to skip markers that are + # completely outside the visible canvas while preserving markers + # that are partially visible. + if len(path.vertices): + bbox = path.get_extents(transform) + # Store half-width and half-height for efficient bounds checking + path_extents.append((bbox.width / 2, bbox.height / 2)) + else: + path_extents.append((0, 0)) + + # Create a mapping from path_id to extent for efficient lookup + path_extent_map = dict(zip(path_codes, path_extents)) - # Add padding for stroke width. - max_marker_extent += padding + canvas_width = self.file.width * 72 + canvas_height = self.file.height * 72 output = self.file.output output(*self.gc.push()) @@ -2132,13 +2135,13 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position, hatchcolors=hatchcolors): - # Skip markers outside visible canvas bounds to reduce PDF size. - # Add max_marker_extent margin to account for marker size - a marker - # may be partially visible even if its center is outside the canvas. - canvas_width = self.file.width * 72 - canvas_height = self.file.height * 72 - if not (-max_marker_extent <= xo <= canvas_width + max_marker_extent - and -max_marker_extent <= yo <= canvas_height + max_marker_extent): + # Skip markers completely outside visible canvas bounds to reduce + # PDF file size. Use per-marker extents to handle large markers + # correctly: only skip if the marker's bounding box doesn't + # intersect the canvas at all. + extent_x, extent_y = path_extent_map[path_id] + if not (-extent_x <= xo <= canvas_width + extent_x + and -extent_y <= yo <= canvas_height + extent_y): continue self.check_gc(gc0, rgbFace) From 1c3e605395ef889b4fb71741eed3b65ec8c2b3a8 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 13 Nov 2025 14:35:18 +0530 Subject: [PATCH 4/5] Add tests for scatter PDF optimization with colored markers --- lib/matplotlib/tests/test_backend_pdf.py | 121 +++++++++++++++++++++-- 1 file changed, 115 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 032326f24a6a..9e34745a9774 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -507,8 +507,9 @@ def test_scatter_offaxis_colored_pdf_size(): size_offaxis_colored = buf1.tell() plt.close(fig1) - # Test 2: Empty scatter (baseline - smallest possible) + # Test 2: Empty scatter (baseline - accounts for scatter call overhead) fig2, ax2 = plt.subplots() + ax2.scatter([], []) # Empty scatter to match the axes structure ax2.set_xlim(20, 30) ax2.set_ylim(20, 30) @@ -517,15 +518,38 @@ def test_scatter_offaxis_colored_pdf_size(): size_empty = buf2.tell() plt.close(fig2) - # The off-axis colored scatter should be close to empty size - # Allow up to 50KB overhead for axes/metadata, but should be much smaller - # than if all 1000 markers were written (which would add ~200-400KB) - assert size_offaxis_colored < size_empty + 50_000, ( + # Test 3: Scatter with visible markers (should be much larger) + fig3, ax3 = plt.subplots() + ax3.scatter(x + 20, y + 20, c=c) # Shift points to be visible + ax3.set_xlim(20, 30) + ax3.set_ylim(20, 30) + + buf3 = io.BytesIO() + fig3.savefig(buf3, format='pdf') + size_visible = buf3.tell() + plt.close(fig3) + + # The off-axis colored scatter should be close to empty size. + # Since the axes are identical, the difference should be minimal + # (just the scatter collection setup, no actual marker data). + # Use a tight tolerance since axes output is identical. + assert size_offaxis_colored < size_empty + 5_000, ( f"Off-axis colored scatter PDF ({size_offaxis_colored} bytes) is too large. " - f"Expected close to empty figure size ({size_empty} bytes). " + f"Expected close to empty scatter size ({size_empty} bytes). " f"Markers may not be properly skipped." ) + # The visible scatter should be significantly larger than both empty and + # off-axis, demonstrating the optimization is working. + assert size_visible > size_empty + 15_000, ( + f"Visible scatter PDF ({size_visible} bytes) should be much larger " + f"than empty ({size_empty} bytes) to validate the test." + ) + assert size_visible > size_offaxis_colored + 15_000, ( + f"Visible scatter PDF ({size_visible} bytes) should be much larger " + f"than off-axis ({size_offaxis_colored} bytes) to validate optimization." + ) + @check_figures_equal(extensions=["pdf"]) def test_scatter_offaxis_colored_visual(fig_test, fig_ref): @@ -587,3 +611,88 @@ def test_scatter_mixed_onoff_axis(fig_test, fig_ref): ax_ref.scatter(x_on, y_on, c=c[:n_points], s=50) ax_ref.set_xlim(0, 10) ax_ref.set_ylim(0, 10) + + +@check_figures_equal(extensions=["pdf"]) +def test_scatter_large_markers_partial_clip(fig_test, fig_ref): + """ + Test that large markers are rendered when partially visible. + + Addresses reviewer concern: markers with centers outside the canvas but + with edges extending into the visible area should still be rendered. + """ + # Create markers just outside the visible area + # Canvas is 0-10, markers at x=-0.5 and x=10.5 + x = np.array([-0.5, 10.5, 5]) # left edge, right edge, center + y = np.array([5, 5, -0.5]) # center, center, bottom edge + c = np.array([0.2, 0.5, 0.8]) + + # Test figure: large markers (s=500 ≈ 11 points radius) + # Centers are outside, but marker edges extend into visible area + ax_test = fig_test.subplots() + ax_test.scatter(x, y, c=c, s=500) + ax_test.set_xlim(0, 10) + ax_test.set_ylim(0, 10) + + # Reference figure: same plot (should render identically) + ax_ref = fig_ref.subplots() + ax_ref.scatter(x, y, c=c, s=500) + ax_ref.set_xlim(0, 10) + ax_ref.set_ylim(0, 10) + + +@check_figures_equal(extensions=["pdf"]) +def test_scatter_logscale(fig_test, fig_ref): + """ + Test scatter optimization with logarithmic scales. + + Ensures bounds checking works correctly in log-transformed coordinates. + """ + rng = np.random.default_rng(19680801) + + # Create points across several orders of magnitude + n_points = 50 + x = 10 ** (rng.random(n_points) * 4) # 1 to 10000 + y = 10 ** (rng.random(n_points) * 4) + c = rng.random(n_points) + + # Test figure: log scale with points mostly outside view + ax_test = fig_test.subplots() + ax_test.scatter(x, y, c=c, s=50) + ax_test.set_xscale('log') + ax_test.set_yscale('log') + ax_test.set_xlim(100, 1000) # Only show middle range + ax_test.set_ylim(100, 1000) + + # Reference figure: should render identically + ax_ref = fig_ref.subplots() + ax_ref.scatter(x, y, c=c, s=50) + ax_ref.set_xscale('log') + ax_ref.set_yscale('log') + ax_ref.set_xlim(100, 1000) + ax_ref.set_ylim(100, 1000) + + +@check_figures_equal(extensions=["pdf"]) +def test_scatter_polar(fig_test, fig_ref): + """ + Test scatter optimization with polar coordinates. + + Ensures bounds checking works correctly in polar projections. + """ + rng = np.random.default_rng(19680801) + + n_points = 50 + theta = rng.random(n_points) * 2 * np.pi + r = rng.random(n_points) * 3 + c = rng.random(n_points) + + # Test figure: polar projection + ax_test = fig_test.subplots(subplot_kw={'projection': 'polar'}) + ax_test.scatter(theta, r, c=c, s=50) + ax_test.set_ylim(0, 2) # Limit radial range + + # Reference figure: should render identically + ax_ref = fig_ref.subplots(subplot_kw={'projection': 'polar'}) + ax_ref.scatter(theta, r, c=c, s=50) + ax_ref.set_ylim(0, 2) From 3ef8b23735d481881eee2023ec58f510cd5310d2 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 14 Nov 2025 17:42:52 +0530 Subject: [PATCH 5/5] implement hybrid approach --- lib/matplotlib/backends/backend_pdf.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 65a15f393f57..b9f3c71835c4 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2135,6 +2135,19 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position, hatchcolors=hatchcolors): + # Optimization: Fast path for markers with centers inside canvas. + # This avoids the dictionary lookup for the common case where + # markers are visible, improving performance for large scatter plots. + if 0 <= xo <= canvas_width and 0 <= yo <= canvas_height: + # Marker center is inside canvas - definitely render it + self.check_gc(gc0, rgbFace) + dx, dy = xo - lastx, yo - lasty + output(1, 0, 0, 1, dx, dy, Op.concat_matrix, path_id, + Op.use_xobject) + lastx, lasty = xo, yo + continue + + # Marker center is outside canvas - check if partially visible. # Skip markers completely outside visible canvas bounds to reduce # PDF file size. Use per-marker extents to handle large markers # correctly: only skip if the marker's bounding box doesn't