diff --git a/src/maxplotlib/backends/tikzfigure/convert.py b/src/maxplotlib/backends/tikzfigure/convert.py index 9a1515f..d86b238 100644 --- a/src/maxplotlib/backends/tikzfigure/convert.py +++ b/src/maxplotlib/backends/tikzfigure/convert.py @@ -290,6 +290,7 @@ def convert(self) -> TikzFigure: figure._layout_engine = engine self.tikz.add_package("lmodern") self.tikz.add_package("amsmath") + self.tikz.add_package("amssymb") definitions = self.colors.definitions() if definitions: self.tikz.add_raw(definitions) @@ -410,6 +411,18 @@ def figure_to_tikz( return converter.convert() +#: The TikZ tips (arrows.meta, which tikzfigure loads) of Matplotlib's straight arrow styles. +_ARROW_TIPS = { + "-": ("", ""), + "->": ("", "Straight Barb"), + "<-": ("Straight Barb", ""), + "<->": ("Straight Barb", "Straight Barb"), + "-|>": ("", "Triangle"), + "<|-": ("Triangle", ""), + "<|-|>": ("Triangle", "Triangle"), +} + + def _dash(pattern) -> str | None: """A Matplotlib ``(offset, [on, off, ...])`` dash pattern in points as TikZ.""" if pattern is None: @@ -968,9 +981,55 @@ def _line(self, line): clip = line.get_clip_on() return lambda: self._add(data, options, label, clip=clip) + def _arrow(self, patch): + """A straight ``FancyArrowPatch`` as a ``\\draw`` with TikZ arrow tips, or ``None``. + + The tips of ``->``, ``<-``, ``<->`` and their filled forms (``-|>``, ``<|-``, + ``<|-|>``) become ``Straight Barb`` and ``Triangle`` tips of the size Matplotlib + gives them (the arrow style's head sizes times ``mutation_scale``); ``shrinkA`` and + ``shrinkB`` shorten the line. Other arrow styles and curved connections are drawn as + the paths Matplotlib renders. + """ + style = patch.get_arrowstyle() + arrow = getattr(style, "arrow", None) + ends = getattr(patch, "_posA_posB", None) + if arrow not in _ARROW_TIPS or ends is None or ends[0] is None: + return None + connection = type(patch.get_connectionstyle()).__name__ + if connection != "Arc3" or getattr(patch.get_connectionstyle(), "rad", 0.0): + return None # a curved connection + options = self._stroke( + patch.get_edgecolor(), patch.get_linewidth(), _dash(patch._dash_pattern) + ) + if options is None: + return lambda: None + scale = float(patch.get_mutation_scale()) + length = _pt(getattr(style, "head_length", 0.4) * scale) + width = _pt(2 * getattr(style, "head_width", 0.2) * scale) + begin, end = _ARROW_TIPS[arrow] + tips = "-".join( + f"{{{tip}[length={length}, width={width}]}}" if tip else "" + for tip in (begin, end) + ) + options = [tips, *options] + for key, shrink in (("<", patch.shrinkA), (">", patch.shrinkB)): + if shrink: + options.append(f"shorten {key}={_pt(shrink)}") + display = patch.get_transform().transform(np.asarray(ends, dtype=float)) + data = self._to_data(display) + + def emit(): + self.axis.add_raw(self._draw(data, options, False)) + + return emit + def _patch(self, patch, clip=None): if clip is None: clip = patch.get_clip_on() + if isinstance(patch, FancyArrowPatch): + emit = self._arrow(patch) + if emit is not None: + return emit transform = patch.get_transform() path = patch.get_path() fillable = None diff --git a/src/maxplotlib/backends/tikzfigure/text.py b/src/maxplotlib/backends/tikzfigure/text.py index 616fb9c..2f5cd2f 100644 --- a/src/maxplotlib/backends/tikzfigure/text.py +++ b/src/maxplotlib/backends/tikzfigure/text.py @@ -11,6 +11,7 @@ # Unicode characters pdflatex does not typeset by default, as math. _UNICODE_MATH = { + "⌀": r"\varnothing", "α": r"\alpha", "β": r"\beta", "γ": r"\gamma", diff --git a/src/maxplotlib/tests/test_tikzfigure_backend.py b/src/maxplotlib/tests/test_tikzfigure_backend.py index 1ccb674..f48e9c3 100644 --- a/src/maxplotlib/tests/test_tikzfigure_backend.py +++ b/src/maxplotlib/tests/test_tikzfigure_backend.py @@ -107,6 +107,37 @@ def test_text_and_annotation_arrows_are_not_clipped(): assert re.search(r"\\draw\[.*\] \(axis cs:", tikz), "the arrow is a \\draw" +def test_straight_arrows_get_tikz_tips(): + from matplotlib.patches import FancyArrowPatch + + fig, ax = plt.subplots() + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.add_patch( + FancyArrowPatch( + (0.1, 0.2), (0.9, 0.2), arrowstyle="<->", mutation_scale=10, shrinkA=0 + ) + ) + ax.add_patch(FancyArrowPatch((0.1, 0.5), (0.9, 0.5), arrowstyle="-|>")) + ax.add_patch( # a curved arrow stays a path + FancyArrowPatch( + (0.1, 0.8), (0.9, 0.8), arrowstyle="->", connectionstyle="arc3,rad=0.3" + ) + ) + ax.text(0.5, 0.9, "⌀ 2 mm") + tikz = figure_to_tikz(fig).generate_tikz() + double = re.search( + r"\\draw\[\{Straight Barb\[length=4pt, width=4pt\]\}-\{Straight Barb\[" + r"length=4pt, width=4pt\]\}, .*shorten >=2pt\] \(axis cs:0\.1,0\.2\) -- " + r"\(axis cs:0\.9,0\.2\);", + tikz, + ) + assert double, tikz + assert re.search(r"\\draw\[-\{Triangle\[length=\d", tikz) + assert tikz.count("\\draw[") == 2 and tikz.count("\\addplot") >= 2 # the curve + assert "{$\\varnothing$ 2 mm}" in tikz + + def test_an_annotation_arrow_without_text_is_kept(): fig, ax = plt.subplots() ax.set_xlim(0, 1)