Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/maxplotlib/backends/tikzfigure/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/maxplotlib/tests/test_tikzfigure_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading