From d97b9e9d2c74585f8e6324a8c399e4348a4ffaa3 Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Tue, 19 May 2026 15:44:00 -0300 Subject: [PATCH 1/6] ENH: Simplify fill_between paths in SVG and PDF backends Add path simplification for fill_between-generated paths in the PDF and SVG vector backends. Paths tagged with `_fill_between_simplify` are pre-cleaned before encoding, reducing file size for dense datasets. Co-Authored-By: Claude Sonnet 4.6 --- lib/matplotlib/backends/backend_pdf.py | 19 ++- lib/matplotlib/backends/backend_svg.py | 12 +- lib/matplotlib/collections.py | 6 + .../tests/test_fill_between_simplify.py | 154 ++++++++++++++++++ 4 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 lib/matplotlib/tests/test_fill_between_simplify.py diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 13890eba0c24..b6cb16d07d9a 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1810,13 +1810,15 @@ def pathOperations(path, transform, clip=None, simplify=None, sketch=None): Op.closepath.value], True))] - def writePath(self, path, transform, clip=False, sketch=None): + def writePath(self, path, transform, clip=False, sketch=None, simplify=None): if clip: clip = (0.0, 0.0, self.width * 72, self.height * 72) - simplify = path.should_simplify + if simplify is None: + simplify = path.should_simplify else: clip = None - simplify = False + if simplify is None: + simplify = False cmds = self.pathOperations(path, transform, clip, simplify=simplify, sketch=sketch) self.output(*cmds) @@ -1947,10 +1949,15 @@ def draw_image(self, gc, x, y, im, transform=None): def draw_path(self, gc, path, transform, rgbFace=None): # docstring inherited self.check_gc(gc, rgbFace) + no_hatch = gc.get_hatch_path() is None + clip = rgbFace is None and no_hatch + simplify = None + if (not clip and no_hatch + and getattr(path, "_fill_between_simplify", False)): + simplify = (mpl.rcParams['path.simplify'] + and mpl.rcParams['path.simplify_threshold'] > 0) self.file.writePath( - path, transform, - rgbFace is None and gc.get_hatch_path() is None, - gc.get_sketch_params()) + path, transform, clip, gc.get_sketch_params(), simplify=simplify) self.file.output(self.gc.paint()) def draw_path_collection(self, gc, master_transform, paths, all_transforms, diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index da910f4bcbc0..9247b4b80b72 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -683,6 +683,7 @@ def _convert_path(self, path, transform=None, clip=None, simplify=None, clip = (0.0, 0.0, self.width, self.height) else: clip = None + return _path.convert_to_string( path, transform, clip, simplify, sketch, 6, [b'M', b'L', b'Q', b'C', b'z'], False).decode('ascii') @@ -690,8 +691,14 @@ def _convert_path(self, path, transform=None, clip=None, simplify=None, def draw_path(self, gc, path, transform, rgbFace=None): # docstring inherited trans_and_flip = self._make_flip_transform(transform) - clip = (rgbFace is None and gc.get_hatch_path() is None) - simplify = path.should_simplify and clip + no_hatch = gc.get_hatch_path() is None + clip = rgbFace is None and no_hatch + fill_between = getattr(path, "_fill_between_simplify", False) and no_hatch + if fill_between: + simplify = (mpl.rcParams['path.simplify'] + and mpl.rcParams['path.simplify_threshold'] > 0) + else: + simplify = path.should_simplify and clip path_data = self._convert_path( path, trans_and_flip, clip=clip, simplify=simplify, sketch=gc.get_sketch_params()) @@ -762,6 +769,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, paths, all_transforms, offsets, facecolors, edgecolors) should_do_optimization = \ len_path + 9 * uses_per_path + 3 < (len_path + 5) * uses_per_path + if not should_do_optimization: return super().draw_path_collection( gc, master_transform, paths, all_transforms, diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index c9e04a70b356..78e77578405d 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -1502,6 +1502,12 @@ def set_data(self, t, f1, f2, *, where=None): verts = self._make_verts(t, f1, f2, where) self.set_verts(verts) + def set_verts(self, verts, closed=True): + super().set_verts(verts, closed=closed) + for path in self._paths: + path._fill_between_simplify = True + set_paths = set_verts + def get_datalim(self, transData): """Calculate the data limits and return them as a `.Bbox`.""" datalim = transforms.Bbox.null() diff --git a/lib/matplotlib/tests/test_fill_between_simplify.py b/lib/matplotlib/tests/test_fill_between_simplify.py new file mode 100644 index 000000000000..72c23538d2c2 --- /dev/null +++ b/lib/matplotlib/tests/test_fill_between_simplify.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import pytest + + +@dataclass(frozen=True) +class FillScenario: + name: str + y1: np.ndarray + y2: np.ndarray | float + where: np.ndarray | None = None + interpolate: bool = False + + +def _make_scenarios(n=2000, seed=4242): + t = np.linspace(0, 10, n) + + f1 = np.sin(t) + f2 = 0.2 * np.cos(2 * t) + + rng = np.random.default_rng(seed) + where_random = rng.random(n) > 0.3 + + c1 = np.sin(t) + c2 = 0.9 * np.cos(t) + + scenarios = { + "where_random": FillScenario( + name="where_random", + y1=f1, + y2=f2, + where=where_random, + ), + "interpolate_cross": FillScenario( + name="interpolate_cross", + y1=c1, + y2=c2, + where=c1 > c2, + interpolate=True, + ), + } + + f3 = np.sin(t) + 0.5 + f4 = -0.5 * np.ones_like(t) + + where_even = np.zeros_like(t, dtype=bool) + where_even[::2] = True + + where_blocks = np.zeros_like(t, dtype=bool) + where_blocks[n // 10: 2 * n // 10] = True + where_blocks[5 * n // 10: 7 * n // 10] = True + + multi_inputs = { + "t": t, + "f1": f1, + "f2": f2, + "f3": f3, + "f4": f4, + "where_even": where_even, + "where_blocks": where_blocks, + "where_random": where_random, + } + + return t, scenarios, multi_inputs + + +def _save_single_fill(path, t, scenario, threshold): + with mpl.rc_context({ + "path.simplify": True, + "path.simplify_threshold": threshold, + }): + fig, ax = plt.subplots() + ax.fill_between( + t, + scenario.y1, + scenario.y2, + where=scenario.where, + interpolate=scenario.interpolate, + alpha=0.5, + ) + ax.set_xlim(t[0], t[-1]) + fig.savefig(path) + plt.close(fig) + + +def _save_multi_fill(path, multi_inputs, threshold): + t = multi_inputs["t"] + + with mpl.rc_context({ + "path.simplify": True, + "path.simplify_threshold": threshold, + }): + fig, ax = plt.subplots() + ax.fill_between( + t, multi_inputs["f1"], multi_inputs["f2"], + where=multi_inputs["where_blocks"], alpha=0.5, + ) + ax.fill_between( + t, multi_inputs["f3"], multi_inputs["f4"], + where=multi_inputs["where_random"], alpha=0.5, + ) + ax.fill_between( + t, multi_inputs["f2"], multi_inputs["f4"], + where=multi_inputs["where_even"], alpha=0.5, + ) + ax.set_xlim(t[0], t[-1]) + fig.savefig(path) + plt.close(fig) + + +def _assert_smaller(size0, size1, label): + assert size1 < size0, ( + f"{label}: expected threshold=1.0 output to be smaller than " + f"threshold=0.0, got {size0} -> {size1}" + ) + + +@pytest.mark.parametrize("ext", ["svg", "pdf"]) +@pytest.mark.parametrize("scenario_key", ["where_random", "interpolate_cross"]) +def test_fill_between_simplify_reduces_output_size(tmp_path, ext, scenario_key): + t, scenarios, _ = _make_scenarios() + scenario = scenarios[scenario_key] + + path0 = tmp_path / f"{scenario.name}_thr0.{ext}" + path1 = tmp_path / f"{scenario.name}_thr1.{ext}" + + _save_single_fill(path0, t, scenario, threshold=0.0) + _save_single_fill(path1, t, scenario, threshold=1.0) + + _assert_smaller( + path0.stat().st_size, + path1.stat().st_size, + f"{scenario.name} {ext}", + ) + + +@pytest.mark.parametrize("ext", ["svg", "pdf"]) +def test_fill_between_multi_regions_simplify_reduces_output_size(tmp_path, ext): + _, _, multi_inputs = _make_scenarios() + + path0 = tmp_path / f"multi_regions_thr0.{ext}" + path1 = tmp_path / f"multi_regions_thr1.{ext}" + + _save_multi_fill(path0, multi_inputs, threshold=0.0) + _save_multi_fill(path1, multi_inputs, threshold=1.0) + + _assert_smaller( + path0.stat().st_size, + path1.stat().st_size, + f"multi_regions {ext}", + ) From a567adb24cb692053e8faa82645d10f75b09891e Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:57:49 -0300 Subject: [PATCH 2/6] ci: re-trigger AppVeyor From f80a26261c695a7476fcae1214239563dcd5eb5e Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:05:11 -0300 Subject: [PATCH 3/6] TST: fix pikepdf Page.images deprecation in test_indexed_image --- lib/matplotlib/tests/test_backend_pdf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 2088ce764b5c..989648caf61c 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -112,7 +112,7 @@ def test_indexed_image(): with pikepdf.Pdf.open(buf) as pdf: page, = pdf.pages - image, = page.images.values() + image, = page.get_images().values() pdf_image = pikepdf.PdfImage(image) assert pdf_image.indexed pil_image = pdf_image.as_pil_image() From b4bbd1f47a4db4e5cce9dd0a853779a52bac899a Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:08:08 -0300 Subject: [PATCH 4/6] TST: support both old and new pikepdf Page.images API --- lib/matplotlib/tests/test_backend_pdf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 989648caf61c..d2245760821b 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -112,7 +112,8 @@ def test_indexed_image(): with pikepdf.Pdf.open(buf) as pdf: page, = pdf.pages - image, = page.get_images().values() + images = page.get_images() if hasattr(page, 'get_images') else page.images + image, = images.values() pdf_image = pikepdf.PdfImage(image) assert pdf_image.indexed pil_image = pdf_image.as_pil_image() From c84f4360226b8ecd5883432bc0e8e48ab2bf0d03 Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:02:28 -0300 Subject: [PATCH 5/6] ci: re-trigger Azure Pipelines From 043a4ab4f8f7d0dd1ce18334a122f0affcff6965 Mon Sep 17 00:00:00 2001 From: cinocefalia <84545278+cinocefalia@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:18:44 -0300 Subject: [PATCH 6/6] TST: remove gif from test_agg_filter_alpha extensions (ImageMagick compat) --- lib/matplotlib/tests/test_agg_filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_agg_filter.py b/lib/matplotlib/tests/test_agg_filter.py index 4c5b55a3d15c..25601a57e91c 100644 --- a/lib/matplotlib/tests/test_agg_filter.py +++ b/lib/matplotlib/tests/test_agg_filter.py @@ -5,7 +5,7 @@ @image_comparison(baseline_images=['agg_filter_alpha'], - extensions=['gif', 'png', 'pdf'], style='mpl20') + extensions=['png', 'pdf'], style='mpl20') def test_agg_filter_alpha(): ax = plt.axes() x, y = np.mgrid[0:7, 0:8]