diff --git a/doc/api/index.rst b/doc/api/index.rst index d168e73a696..747c1fb8a9a 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -32,6 +32,7 @@ Plotting data and laying out the map: Figure.logo Figure.image Figure.shift_origin + Figure.text Color palette table generation: diff --git a/pygmt/base_plotting.py b/pygmt/base_plotting.py index 09082fe271e..8d43c88c162 100644 --- a/pygmt/base_plotting.py +++ b/pygmt/base_plotting.py @@ -2,6 +2,11 @@ Base class with plot generating commands. Does not define any special non-GMT methods (savefig, show, etc). """ +import csv +import os +import numpy as np +import pandas as pd + from .clib import Session from .exceptions import GMTInvalidInput from .helpers import ( @@ -9,6 +14,7 @@ dummy_context, data_kind, fmt_docstring, + GMTTempFile, use_alias, kwargs_to_strings, ) @@ -574,3 +580,107 @@ def legend(self, spec=None, **kwargs): raise GMTInvalidInput("Unrecognized data type: {}".format(type(spec))) arg_str = " ".join([specfile, build_arg_string(kwargs)]) lib.call_module("legend", arg_str) + + @fmt_docstring + @use_alias(R="region", J="projection") + @kwargs_to_strings( + R="sequence", + textfiles="sequence_space", + angle="sequence_comma", + font="sequence_comma", + justify="sequence_comma", + ) + def text( + self, + textfiles=None, + x=None, + y=None, + text=None, + angle=None, + font=None, + justify=None, + **kwargs, + ): + """ + Plot or typeset text on maps + + Used to be pstext. + + Takes in textfile(s) or (x,y,text) triples as input. + + Must provide at least *textfiles* or *x*, *y*, and *text*. + + Full option list at :gmt-docs:`text.html` + + {aliases} + + Parameters + ---------- + 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). + x, y : float or 1d arrays + The x and y coordinates, or an array of x and y coordinates to plot the text + text : str or 1d array + The text string, or an array of strings to plot on the figure + angle: int/float 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. + 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 + 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. + {J} + {R} + """ + 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}") + + if 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)): + 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}" + + 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, + ) + fname = tmpfile.name + + arg_str = " ".join([fname, build_arg_string(kwargs)]) + lib.call_module("text", arg_str) diff --git a/pygmt/tests/baseline/test_text_angle_30.png b/pygmt/tests/baseline/test_text_angle_30.png new file mode 100644 index 00000000000..a859ddd371c Binary files /dev/null and b/pygmt/tests/baseline/test_text_angle_30.png differ diff --git a/pygmt/tests/baseline/test_text_font_bold.png b/pygmt/tests/baseline/test_text_font_bold.png new file mode 100644 index 00000000000..957056ba43c Binary files /dev/null and b/pygmt/tests/baseline/test_text_font_bold.png differ diff --git a/pygmt/tests/baseline/test_text_input_multiple_filenames.png b/pygmt/tests/baseline/test_text_input_multiple_filenames.png new file mode 100644 index 00000000000..3589de0bc49 Binary files /dev/null and b/pygmt/tests/baseline/test_text_input_multiple_filenames.png differ diff --git a/pygmt/tests/baseline/test_text_input_single_filename.png b/pygmt/tests/baseline/test_text_input_single_filename.png new file mode 100644 index 00000000000..65ab36c1221 Binary files /dev/null and b/pygmt/tests/baseline/test_text_input_single_filename.png differ diff --git a/pygmt/tests/baseline/test_text_justify_bottom_right_and_top_left.png b/pygmt/tests/baseline/test_text_justify_bottom_right_and_top_left.png new file mode 100644 index 00000000000..5f10262a4d5 Binary files /dev/null and b/pygmt/tests/baseline/test_text_justify_bottom_right_and_top_left.png differ diff --git a/pygmt/tests/baseline/test_text_justify_parsed_from_textfile.png b/pygmt/tests/baseline/test_text_justify_parsed_from_textfile.png new file mode 100644 index 00000000000..34b89b39bad Binary files /dev/null and b/pygmt/tests/baseline/test_text_justify_parsed_from_textfile.png differ diff --git a/pygmt/tests/baseline/test_text_multiple_lines_of_text.png b/pygmt/tests/baseline/test_text_multiple_lines_of_text.png new file mode 100644 index 00000000000..0bb344d2c04 Binary files /dev/null and b/pygmt/tests/baseline/test_text_multiple_lines_of_text.png differ diff --git a/pygmt/tests/baseline/test_text_single_line_of_text.png b/pygmt/tests/baseline/test_text_single_line_of_text.png new file mode 100644 index 00000000000..932ac478cfc Binary files /dev/null and b/pygmt/tests/baseline/test_text_single_line_of_text.png differ diff --git a/pygmt/tests/data/cities.txt b/pygmt/tests/data/cities.txt new file mode 100644 index 00000000000..895ab6beda3 --- /dev/null +++ b/pygmt/tests/data/cities.txt @@ -0,0 +1,5 @@ +105.87 21.02 LM HANOI +282.95 -12.1 LM LIMA +178.42 -18.13 LM SUVA +237.67 47.58 RM SEATTLE +28.20 -25.75 LM PRETORIA diff --git a/pygmt/tests/test_text.py b/pygmt/tests/test_text.py new file mode 100644 index 00000000000..4eb4c41ed4c --- /dev/null +++ b/pygmt/tests/test_text.py @@ -0,0 +1,173 @@ +# pylint: disable=redefined-outer-name +""" +Tests text +""" +import os + +import pytest + +from .. import Figure +from ..exceptions import GMTInvalidInput + +TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +POINTS_DATA = os.path.join(TEST_DATA_DIR, "points.txt") +CITIES_DATA = os.path.join(TEST_DATA_DIR, "cities.txt") + + +@pytest.fixture(scope="module") +def projection(): + "The projection system" + return "x4i" + + +@pytest.fixture(scope="module") +def region(): + "The data region" + return [0, 5, 0, 2.5] + + +@pytest.mark.mpl_image_compare +def test_text_single_line_of_text(region, projection): + """ + Place a single line text of text at some x, y location + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=2.4, + text="This is a line of text", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_multiple_lines_of_text(region, projection): + """ + Place multiple lines of text at their respective x, y locations + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=[1.2, 1.6], + y=[0.6, 0.3], + text=["This is a line of text", "This is another line of text"], + ) + return fig + + +def test_text_without_text_input(region, projection): + """ + Run text by passing in x and y, but no text + """ + fig = Figure() + with pytest.raises(GMTInvalidInput): + fig.text(region=region, projection=projection, x=1.2, y=2.4) + + +@pytest.mark.mpl_image_compare +def test_text_input_single_filename(): + """ + Run text by passing in one filename to textfiles + """ + fig = Figure() + fig.text(region=[10, 70, -5, 10], textfiles=POINTS_DATA) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_input_multiple_filenames(): + """ + Run text by passing in multiple filenames to textfiles + """ + fig = Figure() + fig.text(region=[10, 70, -30, 10], textfiles=[POINTS_DATA, CITIES_DATA]) + return fig + + +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): + fig.text(region=[10, 70, -5, 10], textfiles=[POINTS_DATA, "notexist.txt"]) + + +@pytest.mark.mpl_image_compare +def test_text_angle_30(region, projection): + """ + Print text at 30 degrees counter-clockwise from horizontal + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=2.4, + text="text angle 30 degrees", + angle=30, + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_font_bold(region, projection): + """ + Print text with a bold font + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=2.4, + text="text in bold", + font="Helvetica-Bold", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_justify_bottom_right_and_top_left(region, projection): + """ + Print text justified at bottom right and top left + """ + fig = Figure() + fig.text( + region=region, + projection=projection, + x=1.2, + y=0.2, + text="text justified bottom right", + justify="BR", + ) + fig.text( + region=region, + projection=projection, + x=1.2, + y=0.2, + text="text justified top left", + justify="TL", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_text_justify_parsed_from_textfile(): + """ + Print text justified based on a column from textfile, using justify=True boolean + operation. Loosely based on "All great-circle paths lead to Rome" gallery example at + https://gmt.soest.hawaii.edu/doc/latest/gallery/ex23.html + """ + fig = Figure() + fig.text( + region="g", + projection="H90/9i", + justify=True, + textfiles=CITIES_DATA, + D="j0.45/0+vred", # draw red-line from xy point to text label (city name) + ) + return fig