diff --git a/src/maxplotlib/backends/tikzfigure/convert.py b/src/maxplotlib/backends/tikzfigure/convert.py index d86b238..804edfd 100644 --- a/src/maxplotlib/backends/tikzfigure/convert.py +++ b/src/maxplotlib/backends/tikzfigure/convert.py @@ -423,6 +423,45 @@ def figure_to_tikz( } +def _hatch_pattern(hatch) -> str | None: + """The TikZ pattern (patterns library) of a Matplotlib hatch string such as ``"///"``. + + Both diagonals (``"/\\"``, ``"x"``) are a cross-hatch, both straight directions + (``"+"``, ``"-|"``) a grid, one direction its lines, and ``.``, ``o``, ``O``, ``*`` + dots; ``None`` for no hatch. The density of the hatch (repeated characters) is not + kept. + """ + if not hatch: + return None + has = set(hatch) + if {"/", "\\"} <= has or has & {"x", "X"}: + return "crosshatch" + if "+" in has or {"-", "|"} <= has: + return "grid" + for character, pattern in ( + ("/", "north east lines"), + ("\\", "north west lines"), + ("-", "horizontal lines"), + ("|", "vertical lines"), + ): + if character in has: + return pattern + if has & {".", "o", "O", "*"}: + return "dots" + return None + + +def _patch_hatch(patch): + """``(hatch string, color)`` of a patch, or ``None`` without a hatch.""" + if not patch.get_hatch(): + return None + if hasattr(patch, "get_hatchcolor"): # Matplotlib >= 3.10 + color = patch.get_hatchcolor() + else: # pragma: no cover - older Matplotlib + color = getattr(patch, "_hatch_color", patch.get_edgecolor()) + return patch.get_hatch(), color + + def _dash(pattern) -> str | None: """A Matplotlib ``(offset, [on, off, ...])`` dash pattern in points as TikZ.""" if pattern is None: @@ -762,13 +801,31 @@ def _line_options(self, line) -> list[str] | None: options.append("mark=none") return options - def _area_options(self, face, edge, width, dash, alpha=None): + def _hatch(self, hatch) -> list[str] | None: + """The pattern options of a hatch ``(hatch string, color)``, drawn over the fill. + + The pattern is a ``postaction``, so it covers the fill instead of replacing it, and + loads TikZ's patterns library (a ``.tikz`` file needs ``\\usetikzlibrary{patterns}``). + """ + if hatch is None: + return None + hatch, color = hatch + pattern = _hatch_pattern(hatch) + name, _ = self.colors(color) + if pattern is None or name is None: + return None + self.parent.tikz.usetikzlibrary("patterns") + return [f"postaction={{pattern={pattern}, pattern color={name}}}"] + + def _area_options(self, face, edge, width, dash, alpha=None, hatch=None): fill = self._fill(face, alpha) stroke = self._stroke(edge, width, dash, alpha) - if fill is None and stroke is None: + pattern = self._hatch(hatch) + if fill is None and stroke is None and pattern is None: return None options = list(fill or ["fill=none"]) options.extend(stroke or ["draw=none"]) + options.extend(pattern or []) options.extend(["mark=none", "area legend"]) return options @@ -785,6 +842,7 @@ def _legend_image(self, handle) -> list[str]: handle.get_edgecolor(), handle.get_linewidth(), _dash(handle._dash_pattern), + hatch=_patch_hatch(handle), ) if options: return options @@ -1046,6 +1104,7 @@ def _patch(self, patch, clip=None): # the patch's colors include its alpha face = patch.get_facecolor() if patch.get_fill() else "none" dash = _dash(patch._dash_pattern) + hatch = _patch_hatch(patch) pieces = [] for sub_path, can_fill in zip(paths, fillable): if transform is not None: @@ -1056,6 +1115,7 @@ def _patch(self, patch, clip=None): patch.get_edgecolor(), patch.get_linewidth(), dash, + hatch=hatch if can_fill else None, ) if options is None: continue diff --git a/src/maxplotlib/tests/test_tikzfigure_backend.py b/src/maxplotlib/tests/test_tikzfigure_backend.py index f48e9c3..5f1d396 100644 --- a/src/maxplotlib/tests/test_tikzfigure_backend.py +++ b/src/maxplotlib/tests/test_tikzfigure_backend.py @@ -107,6 +107,43 @@ def test_text_and_annotation_arrows_are_not_clipped(): assert re.search(r"\\draw\[.*\] \(axis cs:", tikz), "the arrow is a \\draw" +@pytest.mark.parametrize( + "hatch, pattern", + [ + ("///", "north east lines"), + ("\\\\", "north west lines"), + ("x", "crosshatch"), + ("/\\", "crosshatch"), + ("+", "grid"), + ("-", "horizontal lines"), + ("|", "vertical lines"), + ("..", "dots"), + ], +) +def test_hatches_become_tikz_patterns(hatch, pattern): + from matplotlib.patches import Rectangle + + fig, ax = plt.subplots() + ax.add_patch( + Rectangle((0.1, 0.1), 0.5, 0.5, facecolor="0.9", edgecolor="k", hatch=hatch) + ) + ax.add_patch(Rectangle((0.7, 0.7), 0.2, 0.2, facecolor="C0")) # no hatch + figure = figure_to_tikz(fig) + tikz = figure.generate_tikz() + assert tikz.count(f"postaction={{pattern={pattern}, pattern color=mpl000000}}") == 1 + assert "patterns" in figure._tikz_libraries + + +def test_a_hatched_legend_entry_keeps_its_pattern(): + from matplotlib.patches import Rectangle + + fig, ax = plt.subplots() + ax.add_patch(Rectangle((0.1, 0.1), 0.5, 0.5, fill=False, hatch="//", label="wall")) + ax.legend() + tikz = figure_to_tikz(fig).generate_tikz() + assert re.search(r"\\addlegendimage\{[^}]*pattern=north east lines", tikz), tikz + + def test_straight_arrows_get_tikz_tips(): from matplotlib.patches import FancyArrowPatch