Repository: matplotlib/matplotlib

Issue:
[Bug]: Nondeterminism in SVG clipPath element id attributes
### Bug summary

Hello - I'm trying to make the [`astroplan`](https://github.com/astropy/astroplan) documentation build [reproducibly](https://reproducible-builds.org/) in Debian, and have found a snag: despite configuring the [`svg.hashsalt`](https://matplotlib.org/stable/users/explain/customizing.html?highlight=svg.hashsalt#matplotlibrc-sample) to successfully make `path` identifiers in some SVG plots generated by `matplotlib` deterministic, there is a remaining problem that `clipPath` identifiers are nondeterministic.

The cause appears to be the use of Python object ID (via [`id(...)` calls here](https://github.com/matplotlib/matplotlib/blob/c23ccdde6f0f8c071b09a88770e24452f2859e99/lib/matplotlib/backends/backend_svg.py#L622)) when [generating `clippath` IDs](https://github.com/matplotlib/matplotlib/blob/c23ccdde6f0f8c071b09a88770e24452f2859e99/lib/matplotlib/backends/backend_svg.py#L631).

### Code for reproduction

```Python
import matplotlib as mpl
import matplotlib.pyplot as plt
import sys

mpl.rcParams["svg.hashsalt"] = "fixed-salt"

fig = plt.gcf()
fig.add_subplot(projection="polar")

plt.savefig(sys.stdout.buffer, format="svg")
```


### Actual outcome

Differences appear in the `clipPath` identifiers and their references from elsewhere in the SVG output.

### Expected outcome

When an `svg.hashsalt` value is configured, the SVG output should be deterministic.

### Additional information

This could arguably be an enhancement request rather than a bug.

Configuring a static `PYTHONHASHSEED` value does not help to produce deterministic results.

### Operating system

Debian GNU/Linux (trixie)

### Matplotlib Version

3.6.3

### Matplotlib Backend

TkAgg

### Python version

Python 3.11.8

### Jupyter version

N/A

### Installation

Linux package manager


Discussion:
### Good first issue - notes for new contributors

This issue is suited to new contributors because it does not require understanding of the
Matplotlib internals. To get started, please see our [contributing
guide](https://matplotlib.org/stable/devel/index).

**We do not assign issues**. Check the *Development* section in the sidebar for linked pull
requests (PRs). If there are none, feel free to start working on it. If there is an open PR, please
collaborate on the work by reviewing it rather than duplicating it in a competing PR.

If something is unclear, please reach out on any of our [communication
channels](https://matplotlib.org/stable/devel/contributing.html#get-connected).



Patch:
diff --git a/doc/api/next_api_changes/behavior/27833-JA.rst b/doc/api/next_api_changes/behavior/27833-JA.rst
new file mode 100644
index 000000000000..59323f56108f
--- /dev/null
+++ b/doc/api/next_api_changes/behavior/27833-JA.rst
@@ -0,0 +1,8 @@
+SVG output: improved reproducibility
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Some SVG-format plots `produced different output on each render <https://github.com/matplotlib/matplotlib/issues/27831>`__, even with a static ``svg.hashsalt`` value configured.
+
+The problem was a non-deterministic ID-generation scheme for clip paths; the fix introduces a repeatable, monotonically increasing integer ID scheme as a replacement.
+
+Provided that plots add clip paths themselves in deterministic order, this enables repeatable (a.k.a. reproducible, deterministic) SVG output.
diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py
index 72354b81862b..51eee57a6a84 100644
--- a/lib/matplotlib/backends/backend_svg.py
+++ b/lib/matplotlib/backends/backend_svg.py
@@ -302,6 +302,7 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72,
 
         self._groupd = {}
         self._image_counter = itertools.count()
+        self._clip_path_ids = {}
         self._clipd = {}
         self._markers = {}
         self._path_collection_id = 0
@@ -325,6 +326,20 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72,
         self._write_metadata(metadata)
         self._write_default_style()
 
+    def _get_clippath_id(self, clippath):
+        """
+        Returns a stable and unique identifier for the *clippath* argument
+        object within the current rendering context.
+
+        This allows plots that include custom clip paths to produce identical
+        SVG output on each render, provided that the :rc:`svg.hashsalt` config
+        setting and the ``SOURCE_DATE_EPOCH`` build-time environment variable
+        are set to fixed values.
+        """
+        if clippath not in self._clip_path_ids:
+            self._clip_path_ids[clippath] = len(self._clip_path_ids)
+        return self._clip_path_ids[clippath]
+
     def finalize(self):
         self._write_clips()
         self._write_hatches()
@@ -590,7 +605,7 @@ def _get_clip_attrs(self, gc):
         clippath, clippath_trans = gc.get_clip_path()
         if clippath is not None:
             clippath_trans = self._make_flip_transform(clippath_trans)
-            dictkey = (id(clippath), str(clippath_trans))
+            dictkey = (self._get_clippath_id(clippath), str(clippath_trans))
         elif cliprect is not None:
             x, y, w, h = cliprect.bounds
             y = self.height-(y+h)
@@ -605,7 +620,7 @@ def _get_clip_attrs(self, gc):
             else:
                 self._clipd[dictkey] = (dictkey, oid)
         else:
-            clip, oid = clip
+            _, oid = clip
         return {'clip-path': f'url(#{oid})'}
 
     def _write_clips(self):


Test patch:
diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py
index 01edbf870fb4..b694bb297912 100644
--- a/lib/matplotlib/tests/test_backend_svg.py
+++ b/lib/matplotlib/tests/test_backend_svg.py
@@ -10,6 +10,7 @@
 
 import matplotlib as mpl
 from matplotlib.figure import Figure
+from matplotlib.patches import Circle
 from matplotlib.text import Text
 import matplotlib.pyplot as plt
 from matplotlib.testing.decorators import check_figures_equal, image_comparison
@@ -299,6 +300,33 @@ def include(gid, obj):
             assert gid in buf
 
 
+def test_clip_path_ids_reuse():
+    fig, circle = Figure(), Circle((0, 0), radius=10)
+    for i in range(5):
+        ax = fig.add_subplot()
+        aimg = ax.imshow([[i]])
+        aimg.set_clip_path(circle)
+
+    inner_circle = Circle((0, 0), radius=1)
+    ax = fig.add_subplot()
+    aimg = ax.imshow([[0]])
+    aimg.set_clip_path(inner_circle)
+
+    with BytesIO() as fd:
+        fig.savefig(fd, format='svg')
+        buf = fd.getvalue()
+
+    tree = xml.etree.ElementTree.fromstring(buf)
+    ns = 'http://www.w3.org/2000/svg'
+
+    clip_path_ids = set()
+    for node in tree.findall(f'.//{{{ns}}}clipPath[@id]'):
+        node_id = node.attrib['id']
+        assert node_id not in clip_path_ids  # assert ID uniqueness
+        clip_path_ids.add(node_id)
+    assert len(clip_path_ids) == 2  # only two clipPaths despite reuse in multiple axes
+
+
 def test_savefig_tight():
     # Check that the draw-disabled renderer correctly disables open/close_group
     # as well.
diff --git a/lib/matplotlib/tests/test_determinism.py b/lib/matplotlib/tests/test_determinism.py
index 3865dbc7fa43..2ecc40dbd3c0 100644
--- a/lib/matplotlib/tests/test_determinism.py
+++ b/lib/matplotlib/tests/test_determinism.py
@@ -8,13 +8,21 @@
 import pytest
 
 import matplotlib as mpl
-import matplotlib.testing.compare
 from matplotlib import pyplot as plt
-from matplotlib.testing._markers import needs_ghostscript, needs_usetex
+from matplotlib.cbook import get_sample_data
+from matplotlib.collections import PathCollection
+from matplotlib.image import BboxImage
+from matplotlib.offsetbox import AnchoredOffsetbox, AuxTransformBox
+from matplotlib.patches import Circle, PathPatch
+from matplotlib.path import Path
 from matplotlib.testing import subprocess_run_for_testing
+from matplotlib.testing._markers import needs_ghostscript, needs_usetex
+import matplotlib.testing.compare
+from matplotlib.text import TextPath
+from matplotlib.transforms import IdentityTransform
 
 
-def _save_figure(objects='mhi', fmt="pdf", usetex=False):
+def _save_figure(objects='mhip', fmt="pdf", usetex=False):
     mpl.use(fmt)
     mpl.rcParams.update({'svg.hashsalt': 'asdf', 'text.usetex': usetex})
 
@@ -50,6 +58,76 @@ def _save_figure(objects='mhi', fmt="pdf", usetex=False):
         A = [[2, 3, 1], [1, 2, 3], [2, 1, 3]]
         fig.add_subplot(1, 6, 5).imshow(A, interpolation='bicubic')
 
+    if 'p' in objects:
+
+        # clipping support class, copied from demo_text_path.py gallery example
+        class PathClippedImagePatch(PathPatch):
+            """
+            The given image is used to draw the face of the patch. Internally,
+            it uses BboxImage whose clippath set to the path of the patch.
+
+            FIXME : The result is currently dpi dependent.
+            """
+
+            def __init__(self, path, bbox_image, **kwargs):
+                super().__init__(path, **kwargs)
+                self.bbox_image = BboxImage(
+                    self.get_window_extent, norm=None, origin=None)
+                self.bbox_image.set_data(bbox_image)
+
+            def set_facecolor(self, color):
+                """Simply ignore facecolor."""
+                super().set_facecolor("none")
+
+            def draw(self, renderer=None):
+                # the clip path must be updated every draw. any solution? -JJ
+                self.bbox_image.set_clip_path(self._path, self.get_transform())
+                self.bbox_image.draw(renderer)
+                super().draw(renderer)
+
+        # add a polar projection
+        px = fig.add_subplot(projection="polar")
+        pimg = px.imshow([[2]])
+        pimg.set_clip_path(Circle((0, 1), radius=0.3333))
+
+        # add a text-based clipping path (origin: demo_text_path.py)
+        (ax1, ax2) = fig.subplots(2)
+        arr = plt.imread(get_sample_data("grace_hopper.jpg"))
+        text_path = TextPath((0, 0), "!?", size=150)
+        p = PathClippedImagePatch(text_path, arr, ec="k")
+        offsetbox = AuxTransformBox(IdentityTransform())
+        offsetbox.add_artist(p)
+        ao = AnchoredOffsetbox(loc='upper left', child=offsetbox, frameon=True,
+                               borderpad=0.2)
+        ax1.add_artist(ao)
+
+        # add a 2x2 grid of path-clipped axes (origin: test_artist.py)
+        exterior = Path.unit_rectangle().deepcopy()
+        exterior.vertices *= 4
+        exterior.vertices -= 2
+        interior = Path.unit_circle().deepcopy()
+        interior.vertices = interior.vertices[::-1]
+        clip_path = Path.make_compound_path(exterior, interior)
+
+        star = Path.unit_regular_star(6).deepcopy()
+        star.vertices *= 2.6
+
+        (row1, row2) = fig.subplots(2, 2, sharex=True, sharey=True)
+        for row in (row1, row2):
+            ax1, ax2 = row
+            collection = PathCollection([star], lw=5, edgecolor='blue',
+                                        facecolor='red', alpha=0.7, hatch='*')
+            collection.set_clip_path(clip_path, ax1.transData)
+            ax1.add_collection(collection)
+
+            patch = PathPatch(star, lw=5, edgecolor='blue', facecolor='red',
+                              alpha=0.7, hatch='*')
+            patch.set_clip_path(clip_path, ax2.transData)
+            ax2.add_patch(patch)
+
+            ax1.set_xlim([-3, 3])
+            ax1.set_ylim([-3, 3])
+
     x = range(5)
     ax = fig.add_subplot(1, 6, 6)
     ax.plot(x, x)
@@ -67,12 +145,13 @@ def _save_figure(objects='mhi', fmt="pdf", usetex=False):
         ("m", "pdf", False),
         ("h", "pdf", False),
         ("i", "pdf", False),
-        ("mhi", "pdf", False),
-        ("mhi", "ps", False),
+        ("mhip", "pdf", False),
+        ("mhip", "ps", False),
         pytest.param(
-            "mhi", "ps", True, marks=[needs_usetex, needs_ghostscript]),
-        ("mhi", "svg", False),
-        pytest.param("mhi", "svg", True, marks=needs_usetex),
+            "mhip", "ps", True, marks=[needs_usetex, needs_ghostscript]),
+        ("p", "svg", False),
+        ("mhip", "svg", False),
+        pytest.param("mhip", "svg", True, marks=needs_usetex),
     ]
 )
 def test_determinism_check(objects, fmt, usetex):
@@ -84,7 +163,7 @@ def test_determinism_check(objects, fmt, usetex):
     ----------
     objects : str
         Objects to be included in the test document: 'm' for markers, 'h' for
-        hatch patterns, 'i' for images.
+        hatch patterns, 'i' for images, and 'p' for paths.
     fmt : {"pdf", "ps", "svg"}
         Output format.
     """