diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..7a9a7563 --- /dev/null +++ b/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup + +setup( + name='tikzplotlib', + packages=['tikzplotlib'], + package_dir={'':'src'} +) \ No newline at end of file diff --git a/src/tikzplotlib/_axes.py b/src/tikzplotlib/_axes.py index fa84db2f..b662b77a 100644 --- a/src/tikzplotlib/_axes.py +++ b/src/tikzplotlib/_axes.py @@ -1,15 +1,29 @@ import matplotlib as mpl +import math import numpy as np -from matplotlib.backends.backend_pgf import ( - common_texification as mpl_common_texification, -) +import re +from matplotlib.backends.backend_pgf import _tex_escape as mpl_tex_escape from . import _color -def _common_texification(string): +def _tex_escape(string): # Work around - return mpl_common_texification(string).replace("&", "\\&") + return mpl_tex_escape(string).replace("&", "\\&") + +def _siunitx_texification(string: str) -> str: + string = re.sub(r"\smm", r" \\si{\\mm}", string) + string = re.sub(r"\s°C", r" \\si{\\celsius}", string) + string = re.sub(r"\sA/s", r" \\si{\\angstrom\\per\\second}", string) + string = re.sub(r"\sAngstrom", r" \\si{\\angstrom}", string) + string = re.sub(r"\sg/s", r" \\si{\\gram\\per\\second}", string) + string = re.sub(r"\shour", r" \\si{\\hour}", string) + string = re.sub(r"(\d+(\.\d+)?)\s?cc", r"\\SI{\1}{\\cc}", string) + string = re.sub(r"\scc", r" \\si{\\cc}", string) + string = re.sub(r"\s\\%", r" \\si{\\percent}", string) + string = re.sub(r"\sg/um", r" \\si{\\g\\per\\um}", string) + string = re.sub(r"(\d+(\.\d+)?)\s?deg", r"\\SI{\1}{\\degree}", string) + return string class Axes: @@ -42,15 +56,21 @@ def __init__(self, data, obj): # noqa: C901 title = obj.get_title() data["current axis title"] = title if title: - title = _common_texification(title) + title = _tex_escape(title) + title = _siunitx_texification(title) self.axis_options.append(f"title={{{title}}}") # get axes titles xlabel = obj.get_xlabel() if xlabel: - xlabel = _common_texification(xlabel) + xlabel = _tex_escape(xlabel) + xlabel = _siunitx_texification(xlabel) labelcolor = obj.xaxis.label.get_c() + xlabel_spl = xlabel.split(",") + if len(xlabel_spl) == 2: + xlabel = ",".join(["$" + xlabel_spl[0].replace(" ", "\\ ") + "$", + xlabel_spl[1]]) if labelcolor != "black": data, col, _ = _color.mpl_color2xcolor(data, labelcolor) @@ -64,7 +84,15 @@ def __init__(self, data, obj): # noqa: C901 ylabel = obj.get_ylabel() if ylabel: - ylabel = _common_texification(ylabel) + ylabel = _tex_escape(ylabel) + ylabel = _siunitx_texification(ylabel) + + ylabel_spl = ylabel.split(",") + if len(ylabel_spl) == 2: + ylabel = ",".join(["$" + ylabel_spl[0].replace(" ", + "\\ ").replace("+-", r"\pm").replace("-", + r"\mhyphen ") + "$", + ylabel_spl[1]]) labelcolor = obj.yaxis.label.get_c() if labelcolor != "black": @@ -212,6 +240,8 @@ def _set_axis_dimensions(self, data, aspect_num, xlim, ylim): else: # TODO keep an eye on https://tex.stackexchange.com/q/480058/13262 pass + if data["axis_equal"]: + self.axis_options.append("axis equal") def _ticks(self, data, obj): # get ticks @@ -329,6 +359,8 @@ def _grid(self, obj, data): self.axis_options.append("xmajorgrids") if has_minor_xgrid: self.axis_options.append("xminorgrids") + # No way to check from the axis the actual style of the minor grid + self.axis_options.append("minor x grid style={gray!20}") xlines = obj.get_xgridlines() if xlines: @@ -341,6 +373,8 @@ def _grid(self, obj, data): self.axis_options.append("ymajorgrids") if has_minor_ygrid: self.axis_options.append("yminorgrids") + # No way to check from the axis the actual style of the minor grid + self.axis_options.append("minor y grid style={gray!20}") ylines = obj.get_ygridlines() if ylines: @@ -594,17 +628,23 @@ def _get_ticks(data, xy, ticks, ticklabels): label = ticklabel.get_text() if "," in label: label = "{" + label + "}" - pgfplots_ticklabels.append(_common_texification(label)) + pgfplots_ticklabels.append(_tex_escape(label)) # note: ticks may be present even if labels are not, keep them for grid lines for tick in ticks: pgfplots_ticks.append(tick) # if the labels are all missing, then we need to output an empty set of labels - if len(ticklabels) == 0 and len(ticks) != 0: + data[f"nticks_{xy}"] = len(ticks) + if len(ticklabels) == 0 and len(ticks) != 0 and "minor" not in xy: axis_options.append(f"{xy}ticklabels={{}}") # remove the multiplier too - axis_options.append(f"scaled {xy} ticks=" + r"manual:{}{\pgfmathparse{#1}}") + elif "minor" in xy and len(ticks) != 0: + xy_ = xy.split()[1] + if data[f"nticks_{xy_}"] != 0: + multiplier = 5 * math.ceil(len(ticks)/data[f"nticks_{xy_}"]/5) + axis_options.append(f"minor {xy_} tick num={multiplier}") + axis_options.append(f"% {data[f'nticks_{xy_}']}; {len(ticks)}") # Leave the ticks to PGFPlots if not in STRICT mode and if there are no explicit # labels. @@ -616,9 +656,8 @@ def _get_ticks(data, xy, ticks, ticklabels): xy, ",".join([f"{el:{ff}}" for el in pgfplots_ticks]) ) ) - else: - val = "{}" if "minor" in xy else "\\empty" - axis_options.append(f"{xy}tick={val}") + elif "minor" not in xy: + axis_options.append(f"{xy}tick=\\empty") if is_label_required: length = sum(len(label) for label in pgfplots_ticklabels) diff --git a/src/tikzplotlib/_legend.py b/src/tikzplotlib/_legend.py index 29d8e635..0e42bd1a 100644 --- a/src/tikzplotlib/_legend.py +++ b/src/tikzplotlib/_legend.py @@ -78,8 +78,11 @@ def draw_legend(data, obj): if alignment: data["current axes"].axis_options.append(f"legend cell align={{{alignment}}}") - if obj._ncol != 1: - data["current axes"].axis_options.append(f"legend columns={obj._ncol}") + try: + if obj._ncol != 1: + data["current axes"].axis_options.append(f"legend columns={obj._ncol}") + except AttributeError: + warnings.warn("Unable to interrogate the number of columns in legend. Using 1 as default.") # Write styles to data if legend_style: diff --git a/src/tikzplotlib/_line2d.py b/src/tikzplotlib/_line2d.py index b431b869..72ea0e6e 100644 --- a/src/tikzplotlib/_line2d.py +++ b/src/tikzplotlib/_line2d.py @@ -7,6 +7,7 @@ from . import _files from . import _path as mypath from ._markers import _mpl_marker2pgfp_marker +from ._text import escape_text from ._util import get_legend_text, has_legend, transform_to_data_coordinates @@ -100,7 +101,7 @@ def draw_line2d(data, obj): content += c if legend_text is not None: - content.append(f"\\addlegendentry{{{legend_text}}}\n") + content.append(f"\\addlegendentry{{{escape_text(legend_text)}}}\n") return data, content @@ -272,9 +273,15 @@ def _table(obj, data): # noqa: C901 if "unbounded coords=jump" not in data["current axes"].axis_options: data["current axes"].axis_options.append("unbounded coords=jump") - plot_table = [ - f"{x:{xformat}}{col_sep}{y:{ff}}{table_row_sep}" for x, y in zip(xdata, ydata) - ] + if len(xdata) > data["every n dot"]: + plot_table = [ + f"{x:{xformat}}{col_sep}{y:{ff}}{table_row_sep}" for x, y in zip( + xdata[::data["every n dot"]], + ydata[::data["every n dot"]])] + else: + plot_table = [ + f"{x:{xformat}}{col_sep}{y:{ff}}{table_row_sep}" for x, y in zip(xdata, ydata)] + min_extern_length = 3 diff --git a/src/tikzplotlib/_path.py b/src/tikzplotlib/_path.py index 11381d59..1d017d23 100644 --- a/src/tikzplotlib/_path.py +++ b/src/tikzplotlib/_path.py @@ -231,7 +231,7 @@ def draw_pathcollection(data, obj): is_contour = len(dd) == 1 if is_contour: - draw_options = ["draw=none"] + draw_options = ["thick"] if marker0 is not None: data, pgfplots_marker, marker_options = _mpl_marker2pgfp_marker( @@ -303,7 +303,7 @@ def draw_pathcollection(data, obj): plot_table = [] plot_table.append(" ".join(labels) + "\n") - for row in dd_strings: + for row in dd_strings[::data["every n dot"]]: plot_table.append(" ".join(row) + "\n") if data["externalize tables"]: @@ -468,9 +468,13 @@ def mpl_linestyle2pgfplots_linestyle(data, line_style, line=None): # get defaults default_dashOffset, default_dashSeq = mpl.lines._get_dash_pattern(line_style) - # get dash format of line under test - dashSeq = line._us_dashSeq - dashOffset = line._us_dashOffset + try: + # get dash format of line under test + dashSeq = line._us_dashSeq + dashOffset = line._us_dashOffset + except AttributeError: + dashSeq = default_dashSeq + dashOffset = default_dashOffset lst = list() if dashSeq != default_dashSeq: diff --git a/src/tikzplotlib/_save.py b/src/tikzplotlib/_save.py index d89cadfa..3e76b00f 100644 --- a/src/tikzplotlib/_save.py +++ b/src/tikzplotlib/_save.py @@ -40,6 +40,8 @@ def get_tikz_code( float_format: str = ".15g", table_row_sep: str = "\n", flavor: str = "latex", + every_n_dot: int = 1, + axis_equal: bool = False, ): """Main function. Here, the recursion into the image starts and the contents are picked up. The actual file gets written in this routine. @@ -136,6 +138,12 @@ def get_tikz_code( Default is ``"latex"``. :type flavor: str + :param every_n_dot: if path is encountered, only draw every Nth dot + :type every_n_dot: int + + :param axis_equal: if true, have equal axis ratio + :type axis_equal: bool + :returns: None The following optional attributes of matplotlib's objects are recognized @@ -176,6 +184,8 @@ def get_tikz_code( data["legend colors"] = [] data["add axis environment"] = add_axis_environment data["show_info"] = show_info + data["every n dot"] = every_n_dot + data["axis_equal"] = axis_equal # rectangle_legends is used to keep track of which rectangles have already # had \addlegendimage added. There should be only one \addlegenimage per # bar chart data series. diff --git a/src/tikzplotlib/_text.py b/src/tikzplotlib/_text.py index 34975d3b..2be3e974 100644 --- a/src/tikzplotlib/_text.py +++ b/src/tikzplotlib/_text.py @@ -1,8 +1,45 @@ import matplotlib as mpl +import re from matplotlib.patches import ArrowStyle from . import _color +def escape_text(text): + """ + Escapes certain patterns in a given text to make them compatible with + LaTeX formatting, especially for SI units. + + Parameters: + - text (str): The input string that needs to be processed for LaTeX- + compatible escapes. + + Returns: + - str: The text with escaped patterns suitable for LaTeX rendering. + + The function primarily performs the following conversions: + 1. Converts percentages, e.g., "45%", "45.5 %", to the LaTeX SI + unit format: "\\SI{45}{\\percent}". + 2. Fixes potential issues with the "\\SI" unit for the plus-minus + notation. + 3. Corrects ", s" patterns to ", \\SI{\\s}". + + Note: + This function assumes that the input text is likely to contain numeric + values that need to be presented using SI notation in LaTeX. For + correct rendering, the siunitx LaTeX package should be included in + the document. + + Example: + Given the input: "The efficiency is 45% and the tolerance is + +-\\SI{2}{\\degree}, s" + The output will be: "The efficiency is \\SI{45}{\\percent} and + the tolerance is \\SI{+-2}{\\degree}, \\SI{\s}" + """ + res_text = re.sub(r"(\d+(\.\d+)?)\s?%", r"\\SI{\1}{\\percent}", text) + res_text = re.sub(r"(\d+(\.\d+)?)\s?mm", r"\\SI{\1}{\\mm}", res_text) + res_text = re.sub(r"(\d+(\.\d+)?)\s?deg", r"\\SI{\1}{\\degree}", res_text) + return res_text.replace("+-\\SI{", "\\SI{+-").replace(", s", ", \\SI{\\s}") + def draw_text(data, obj): """Paints text on the graph.""" @@ -23,7 +60,8 @@ def draw_text(data, obj): if obj.axes: # If the coordinates are relative to an axis, use `axis cs`. - tikz_pos = f"(axis cs:{pos[0]:{ff}},{pos[1]:{ff}})" + rel = "rel " if abs(pos[0]) < 1 and abs(pos[1]) < 1 else "" + tikz_pos = f"({rel}axis cs:{pos[0]:{ff}},{pos[1]:{ff}})" else: # relative to the entire figure, it's a getting a littler harder. See # for a solution to the @@ -115,7 +153,7 @@ def draw_text(data, obj): text = text.replace("\n ", "\\\\") props = ",\n ".join(properties) - text = " ".join(style + [text]) + text = escape_text(" ".join(style + [text])).replace("\n", "\\\\") content.append(f"\\draw {tikz_pos} node[\n {props}\n]{{{text}}};\n") return data, content