diff --git a/pygmt/base_plotting.py b/pygmt/base_plotting.py index c7c85813cdf..fc9b8c9d3b0 100644 --- a/pygmt/base_plotting.py +++ b/pygmt/base_plotting.py @@ -4,7 +4,6 @@ """ import contextlib import csv -import os import numpy as np import pandas as pd @@ -813,7 +812,15 @@ def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwarg lib.call_module("legend", arg_str) @fmt_docstring - @use_alias(R="region", J="projection", B="frame") + @use_alias( + R="region", + J="projection", + B="frame", + C="clearance", + D="offset", + G="fill", + W="pen", + ) @kwargs_to_strings( R="sequence", textfiles="sequence_space", @@ -826,6 +833,7 @@ def text( textfiles=None, x=None, y=None, + position=None, text=None, angle=None, font=None, @@ -833,13 +841,14 @@ def text( **kwargs, ): """ - Plot or typeset text on maps + Plot or typeset text strings of variable size, font type, and + orientation. - Used to be pstext. + Must provide at least one of the following combinations as input: - Takes in textfile(s) or (x,y,text) triples as input. - - Must provide at least *textfiles* or *x*, *y*, and *text*. + - *textfiles* + - *x*, *y*, and *text* + - *position* and *text* Full option list at :gmt-docs:`text.html` @@ -849,70 +858,128 @@ def text( ---------- textfiles : str or list A text data file name, or a list of filenames containing 1 or more - records with (x, y[, font, angle, justify], text). + records with (x, y[, angle, font, justify], text). x/y : float or 1d arrays The x and y coordinates, or an array of x and y coordinates to plot the text + position : str + Sets reference point on the map for the text by using x,y + coordinates extracted from *region* instead of providing them + through *x* and *y*. Specify with a two letter (order independent) + code, chosen from: + + * Horizontal: L(eft), C(entre), R(ight) + * Vertical: T(op), M(iddle), B(ottom) + + For example, position="TL" plots the text at the Upper Left corner + of the map. text : str or 1d array The text string, or an array of strings to plot on the figure - angle: int, float or bool + angle: int, float, str or bool Set the angle measured in degrees counter-clockwise from horizontal. E.g. 30 sets the text at 30 degrees. If no angle is - given then the input textfile(s) must have this as a column. + explicitly given (i.e. angle=True) then the input textfile(s) must + have this as a column. font : str or bool Set the font specification with format "size,font,color" where size is text size in points, font is the font to use, and color sets the font color. E.g. "12p,Helvetica-Bold,red" selects a 12p red - Helvetica-Bold font. If no font info is given then the input - textfile(s) must have this information in one of its columns. - justify: str or bool + Helvetica-Bold font. If no font info is explicitly given (i.e. + font=True), then the input textfile(s) must have this information + in one of its columns. + justify : str or bool Set the alignment which refers to the part of the text string that will be mapped onto the (x,y) point. Choose a 2 character combination of L, C, R (for left, center, or right) and T, M, B for top, middle, or bottom. E.g., BL for lower left. If no - justification is given then the input textfile(s) must have this as - a column. + justification is explicitly given (i.e. justify=True), then the + input textfile(s) must have this as a column. {J} {R} + clearance : str + ``[dx/dy][+to|O|c|C]`` + Adjust the clearance between the text and the surrounding box + [15%]. Only used if *pen* or *fill* are specified. Append the unit + you want ('c' for cm, 'i' for inch, or 'p' for point; if not given + we consult 'PROJ_LENGTH_UNIT') or '%' for a percentage of the + font size. Optionally, use modifier '+t' to set the shape of the + textbox when using *fill* and/or *pen*. Append lower case 'o' to + get a straight rectangle [Default]. Append upper case 'O' to get a + rounded rectangle. In paragraph mode (*paragraph*) you can also + append lower case 'c' to get a concave rectangle or append upper + case 'C' to get a convex rectangle. + fill : str + Sets the shade or color used for filling the text box [Default is + no fill]. + offset : str + ``[j|J]dx[/dy][+v[pen]]`` + Offsets the text from the projected (x,y) point by dx,dy [0/0]. If + dy is not specified then it is set equal to dx. Use offset='j' to + offset the text away from the point instead (i.e., the text + justification will determine the direction of the shift). Using + offset='J' will shorten diagonal offsets at corners by sqrt(2). + Optionally, append '+v' which will draw a line from the original + point to the shifted point; append a pen to change the attributes + for this line. + pen : str + Sets the pen used to draw a rectangle around the text string + (see *clearance*) [Default is width = default, color = black, + style = solid]. """ kwargs = self._preprocess(**kwargs) - kind = data_kind(textfiles, x, y, text) - if kind == "vectors" and text is None: - raise GMTInvalidInput("Must provide text with x and y.") - if kind == "file": - for textfile in textfiles.split(" "): # ensure that textfile(s) exist - if not os.path.exists(textfile): - raise GMTInvalidInput(f"Cannot find the file: {textfile}") + # Ensure inputs are either textfiles, x/y/text, or position/text + if position is None: + kind = data_kind(textfiles, x, y, text) + elif position is not None: + if x is not None or y is not None: + raise GMTInvalidInput( + "Provide either position only, or x/y pairs, not both" + ) + kind = "vectors" - if angle is not None or font is not None or justify is not None: + if kind == "vectors" and text is None: + raise GMTInvalidInput("Must provide text with x/y pairs or position") + + # Build the `-F` argument in gmt text. + if ( + position is not None + or angle is not None + or font is not None + or justify is not None + ): if "F" not in kwargs.keys(): kwargs.update({"F": ""}) - if angle is not None and isinstance(angle, (int, float)): + if angle is not None and isinstance(angle, (int, float, str)): kwargs["F"] += f"+a{str(angle)}" if font is not None and isinstance(font, str): kwargs["F"] += f"+f{font}" if justify is not None and isinstance(justify, str): kwargs["F"] += f"+j{justify}" + if position is not None and isinstance(position, str): + kwargs["F"] += f'+c{position}+t"{text}"' with GMTTempFile(suffix=".txt") as tmpfile: with Session() as lib: if kind == "file": fname = textfiles elif kind == "vectors": - pd.DataFrame.from_dict( - { - "x": np.atleast_1d(x), - "y": np.atleast_1d(y), - "text": np.atleast_1d(text), - } - ).to_csv( - tmpfile.name, - sep="\t", - header=False, - index=False, - quoting=csv.QUOTE_NONE, - ) + if position is not None: + fname = "" + else: + pd.DataFrame.from_dict( + { + "x": np.atleast_1d(x), + "y": np.atleast_1d(y), + "text": np.atleast_1d(text), + } + ).to_csv( + tmpfile.name, + sep="\t", + header=False, + index=False, + quoting=csv.QUOTE_NONE, + ) fname = tmpfile.name arg_str = " ".join([fname, build_arg_string(kwargs)]) diff --git a/pygmt/tests/baseline/test_text_angle_font_justify_from_textfile.png b/pygmt/tests/baseline/test_text_angle_font_justify_from_textfile.png new file mode 100644 index 00000000000..bc244904784 Binary files /dev/null and b/pygmt/tests/baseline/test_text_angle_font_justify_from_textfile.png differ diff --git a/pygmt/tests/baseline/test_text_fill.png b/pygmt/tests/baseline/test_text_fill.png new file mode 100644 index 00000000000..f3f341828aa Binary files /dev/null and b/pygmt/tests/baseline/test_text_fill.png differ diff --git a/pygmt/tests/baseline/test_text_input_remote_filename.png b/pygmt/tests/baseline/test_text_input_remote_filename.png new file mode 100644 index 00000000000..b0c680568ca Binary files /dev/null and b/pygmt/tests/baseline/test_text_input_remote_filename.png differ diff --git a/pygmt/tests/baseline/test_text_pen.png b/pygmt/tests/baseline/test_text_pen.png new file mode 100644 index 00000000000..68ce614a7ea Binary files /dev/null and b/pygmt/tests/baseline/test_text_pen.png differ diff --git a/pygmt/tests/baseline/test_text_position.png b/pygmt/tests/baseline/test_text_position.png new file mode 100644 index 00000000000..165c6c3a157 Binary files /dev/null and b/pygmt/tests/baseline/test_text_position.png differ diff --git a/pygmt/tests/baseline/test_text_position_offset_with_line.png b/pygmt/tests/baseline/test_text_position_offset_with_line.png new file mode 100644 index 00000000000..4e8d438a8f9 Binary files /dev/null and b/pygmt/tests/baseline/test_text_position_offset_with_line.png differ diff --git a/pygmt/tests/baseline/test_text_round_clearance.png b/pygmt/tests/baseline/test_text_round_clearance.png new file mode 100644 index 00000000000..c35e44a5d74 Binary files /dev/null and b/pygmt/tests/baseline/test_text_round_clearance.png differ diff --git a/pygmt/tests/test_text.py b/pygmt/tests/test_text.py index fb3f54d9789..26e45f8f4fc 100644 --- a/pygmt/tests/test_text.py +++ b/pygmt/tests/test_text.py @@ -7,7 +7,8 @@ import pytest from .. import Figure -from ..exceptions import GMTInvalidInput +from ..exceptions import GMTCLibError, GMTInvalidInput +from ..helpers import GMTTempFile TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") POINTS_DATA = os.path.join(TEST_DATA_DIR, "points.txt") @@ -77,6 +78,16 @@ def test_text_input_single_filename(): return fig +@pytest.mark.mpl_image_compare +def test_text_input_remote_filename(): + """ + Run text by passing in a remote filename to textfiles + """ + fig = Figure() + fig.text(region=[0, 6.5, 0, 6.5], textfiles="@Table_5_11.txt") + return fig + + @pytest.mark.mpl_image_compare def test_text_input_multiple_filenames(): """ @@ -92,10 +103,48 @@ def test_text_nonexistent_filename(): Run text by passing in a list of filenames with one that does not exist """ fig = Figure() - with pytest.raises(GMTInvalidInput): + with pytest.raises(GMTCLibError): fig.text(region=[10, 70, -5, 10], textfiles=[POINTS_DATA, "notexist.txt"]) +@pytest.mark.mpl_image_compare +def test_text_position(region): + """ + Print text at center middle (CM) and eight other positions + (Top/Middle/Bottom x Left/Centre/Right). + """ + fig = Figure() + fig.text(region=region, projection="x1c", frame="a", position="CM", text="C M") + for position in ("TL", "TC", "TR", "ML", "MR", "BL", "BC", "BR"): + fig.text(position=position, text=position) + return fig + + +def test_text_xy_with_position_fails(region): + """ + Run text by providing both x/y pairs and position arguments. + """ + fig = Figure() + with pytest.raises(GMTInvalidInput): + fig.text( + region=region, projection="x1c", x=1.2, y=2.4, position="MC", text="text" + ) + + +@pytest.mark.mpl_image_compare +def test_text_position_offset_with_line(region): + """ + Print text at centre middle (CM) and eight other positions + (Top/Middle/Bottom x Left/Centre/Right), offset by 0.5 cm, with a line + drawn from the original to the shifted point. + """ + fig = Figure() + fig.text(region=region, projection="x1c", frame="a", position="CM", text="C M") + for position in ("TL", "TC", "TR", "ML", "MR", "BL", "BC", "BR"): + fig.text(position=position, text=position, offset="j0.5c+v") + return fig + + @pytest.mark.mpl_image_compare def test_text_angle_30(region, projection): """ @@ -130,6 +179,58 @@ def test_text_font_bold(region, projection): return fig +@pytest.mark.mpl_image_compare +def test_text_fill(region, projection): + """ + Print text with blue color fill + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=1.2, + text="blue fill around text", + fill="blue", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_pen(region, projection): + """ + Print text with thick green dashed pen + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=1.2, + text="green pen around text", + pen="thick,green,dashed", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_round_clearance(region, projection): + """ + Print text with round rectangle box clearance + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=1.2, + text="clearance around text", + clearance="90%+tO", + pen="default,black,dashed", + ) + return fig + + @pytest.mark.mpl_image_compare def test_text_justify_bottom_right_and_top_left(region, projection): """ @@ -172,3 +273,25 @@ def test_text_justify_parsed_from_textfile(): D="j0.45/0+vred", # draw red-line from xy point to text label (city name) ) return fig + + +@pytest.mark.mpl_image_compare +def test_text_angle_font_justify_from_textfile(): + """ + Print text with x, y, angle, font, justify, and text arguments parsed from + the textfile. + """ + fig = Figure() + with GMTTempFile(suffix=".txt") as tempfile: + with open(tempfile.name, "w") as tmpfile: + tmpfile.write("114 0.5 30 22p,Helvetica-Bold,black LM BORNEO") + fig.text( + region=[113, 117.5, -0.5, 3], + projection="M5c", + frame="a", + textfiles=tempfile.name, + angle=True, + font=True, + justify=True, + ) + return fig