diff --git a/src/maxplotlib/backends/tikzfigure/convert.py b/src/maxplotlib/backends/tikzfigure/convert.py index 804edfd..163e228 100644 --- a/src/maxplotlib/backends/tikzfigure/convert.py +++ b/src/maxplotlib/backends/tikzfigure/convert.py @@ -1003,6 +1003,34 @@ def _draw(self, data, options, cycle) -> str: parts.append("-- cycle") return f"\\draw[{', '.join(keep)}] {' '.join(parts)};" + def _draw_loops(self, loops, options) -> str: + """One ``\\draw`` path of several closed loops, filled by the even-odd rule. + + A compound patch (a shape with holes, such as an annulus or a plate with an aperture) + is one path in Matplotlib; drawn as one path in TikZ its holes stay empty, where one + filled plot per loop would fill them. + """ + precision = self.options["precision"] + keep = [ + option + for option in options + if not option.startswith( + ("mark", "only marks", "forget plot", "area legend") + ) + ] + keep.append("even odd rule") + parts = [] + for loop in loops: + points = [ + f"(axis cs:{format_number(float(x), precision)}," + f"{format_number(float(y), precision)})" + for x, y in loop + if np.isfinite(x) and np.isfinite(y) + ] + if len(points) > 2: + parts.append(" -- ".join(points) + " -- cycle") + return f"\\draw[{', '.join(keep)}] {' '.join(parts)};" + def _add(self, data, options, label="", cycle=False, clip=True): data = np.asarray(data, dtype=float) if not clip: @@ -1119,6 +1147,14 @@ def _patch(self, patch, clip=None): ) if options is None: continue + closed_polygons = [polygon for polygon in polygons if len(polygon) > 2] + if can_fill and len(polygons) > 1 and len(closed_polygons) == len(polygons): + # one filled path of several loops: holes stay holes (even-odd rule) + loops = [self._clip_polygon(polygon) for polygon in polygons] + loops = [self._to_data(loop) for loop in loops if len(loop)] + if loops: + pieces.append((loops, options, "compound")) + continue for polygon in polygons: closed = can_fill and len(polygon) > 2 polygon = ( @@ -1130,6 +1166,9 @@ def _patch(self, patch, clip=None): def emit(): for index, (data, options, closed) in enumerate(pieces): + if closed == "compound": + self.axis.add_raw(self._draw_loops(data, options)) + continue self._add( data, options, label if index == 0 else "", cycle=closed, clip=clip ) diff --git a/src/maxplotlib/tests/test_tikzfigure_backend.py b/src/maxplotlib/tests/test_tikzfigure_backend.py index 5f1d396..22e7f5c 100644 --- a/src/maxplotlib/tests/test_tikzfigure_backend.py +++ b/src/maxplotlib/tests/test_tikzfigure_backend.py @@ -134,6 +134,24 @@ def test_hatches_become_tikz_patterns(hatch, pattern): assert "patterns" in figure._tikz_libraries +def test_a_patch_with_a_hole_is_one_even_odd_path(): + from matplotlib.patches import Annulus, Rectangle + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + ax.add_patch(Annulus((0, 0), 1.5, 0.5, facecolor="C0", hatch="//", label="ring")) + ax.add_patch(Rectangle((1.6, 1.6), 0.2, 0.2)) # one loop: a plot as before + ax.legend() + tikz = figure_to_tikz(fig).generate_tikz() + ring = [line for line in tikz.splitlines() if "even odd rule" in line] + assert len(ring) == 1 + assert ring[0].count("-- cycle") == 2 # the outer circle and the hole, one path + assert "pattern=north east lines" in ring[0] + assert "\\addlegendentry{ring}" in tikz + assert tikz.count("\\addplot[") == 1 # the rectangle + + def test_a_hatched_legend_entry_keeps_its_pattern(): from matplotlib.patches import Rectangle