diff --git a/doc/api/index.rst b/doc/api/index.rst index 5824dc328e1..b0fc25798f3 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -34,6 +34,7 @@ Plotting map elements Figure.legend Figure.logo Figure.magnetic_rose + Figure.paragraph Figure.scalebar Figure.solar Figure.text diff --git a/pygmt/figure.py b/pygmt/figure.py index 136f0008b9b..1434b0d3779 100644 --- a/pygmt/figure.py +++ b/pygmt/figure.py @@ -426,6 +426,7 @@ def _repr_html_(self) -> str: logo, magnetic_rose, meca, + paragraph, plot, plot3d, psconvert, diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py index 3c3fb2c39e3..8ac65a3a301 100644 --- a/pygmt/src/__init__.py +++ b/pygmt/src/__init__.py @@ -42,6 +42,7 @@ from pygmt.src.makecpt import makecpt from pygmt.src.meca import meca from pygmt.src.nearneighbor import nearneighbor +from pygmt.src.paragraph import paragraph from pygmt.src.plot import plot from pygmt.src.plot3d import plot3d from pygmt.src.project import project diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py new file mode 100644 index 00000000000..94416b7ac7c --- /dev/null +++ b/pygmt/src/paragraph.py @@ -0,0 +1,152 @@ +""" +paragraph - Typeset one or multiple paragraphs. +""" + +import io +from collections.abc import Sequence +from typing import Literal + +from pygmt._typing import AnchorCode +from pygmt.alias import Alias, AliasSystem +from pygmt.clib import Session +from pygmt.exceptions import GMTValueError +from pygmt.helpers import ( + _check_encoding, + build_arg_list, + fmt_docstring, + is_nonstr_iter, + non_ascii_to_octal, +) + +__doctest_skip__ = ["paragraph"] + + +@fmt_docstring +def paragraph( # noqa: PLR0913 + self, + x: float | str, + y: float | str, + text: str | Sequence[str], + parwidth: float | str, + linespacing: float | str, + font: str | None = None, + angle: float | None = None, + justify: AnchorCode | None = None, + fill: str | None = None, + pen: str | None = None, + alignment: Literal["left", "center", "right", "justified"] = "left", + verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] + | bool = False, + panel: int | Sequence[int] | bool = False, + transparency: float | Sequence[float] | bool | None = None, +): + r""" + Typeset one or multiple paragraphs. + + This method typesets one or multiple paragraphs of text at a given position on the + figure. The text is flowed within a given paragraph width and with a specified line + spacing. The text can be aligned left, center, right, or justified. + + Multiple paragraphs can be provided as a sequence of strings, where each string + represents a separate paragraph, or as a single string with a blank line (``\n\n``) + separating the paragraphs. + + Full GMT docs at :gmt-docs:`text.html`. + + Parameters + ---------- + x/y + The x, y coordinates of the paragraph. + text + The paragraph text to typeset. If a sequence of strings is provided, each string + is treated as a separate paragraph. + parwidth + The width of the paragraph. + linespacing + The spacing between lines. + font + The font of the text. + angle + The angle of the text. + justify + Set the alignment of the block of text, relative to the given x, y position. + Choose a :doc:`2-character justification code `. + fill + Set color for filling the paragraph box [Default is no fill]. + pen + Set the pen used to draw a rectangle around the paragraph [Default is + ``"0.25p,black,solid"``]. + alignment + Set the alignment of the text. Valid values are ``"left"``, ``"center"``, + ``"right"``, and ``"justified"``. + $verbose + $panel + $transparency + + Examples + -------- + >>> import pygmt + >>> + >>> fig = pygmt.Figure() + >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + >>> fig.paragraph( + ... x=4, + ... y=4, + ... text="This is a long paragraph. " * 10, + ... parwidth="5c", + ... linespacing="12p", + ... font="12p", + ... ) + >>> fig.show() + """ + self._activate_figure() + + _valid_alignments = ("left", "center", "right", "justified") + if alignment not in _valid_alignments: + raise GMTValueError( + alignment, + description="value for parameter 'alignment'", + choices=_valid_alignments, + ) + + aliasdict = AliasSystem( + F=[ + Alias(font, name="font", prefix="+f"), + Alias(angle, name="angle", prefix="+a"), + Alias(justify, name="justify", prefix="+j"), + ], + G=Alias(fill, name="fill"), + W=Alias(pen, name="pen"), + ).add_common( + V=verbose, + c=panel, + t=transparency, + ) + aliasdict.merge({"M": True}) + + confdict = {} + # Prepare the text string that will be passed to an io.StringIO object. + # Multiple paragraphs are separated by a blank line "\n\n". + _textstr: str = "\n\n".join(text) if is_nonstr_iter(text) else str(text) + + if _textstr == "": + raise GMTValueError( + text, + description="text", + reason="'text' must be a non-empty string or sequence of strings.", + ) + + # Check the encoding of the text string and convert it to octal if necessary. + if (encoding := _check_encoding(_textstr)) != "ascii": + _textstr = non_ascii_to_octal(_textstr, encoding=encoding) + confdict["PS_CHAR_ENCODING"] = encoding + + with Session() as lib: + with io.StringIO() as buffer: # Prepare the StringIO input. + buffer.write(f"> {x} {y} {linespacing} {parwidth} {alignment[0]}\n") + buffer.write(_textstr) + with lib.virtualfile_in(data=buffer) as vfile: + lib.call_module( + "text", + args=build_arg_list(aliasdict, infile=vfile, confdict=confdict), + ) diff --git a/pygmt/src/text.py b/pygmt/src/text.py index 9adb6e2911f..d9863d88d8d 100644 --- a/pygmt/src/text.py +++ b/pygmt/src/text.py @@ -69,6 +69,9 @@ def text_( # noqa: PLR0912, PLR0913 ZapfDingbats and ISO-8859-x (x can be 1-11, 13-16) encodings. Refer to :doc:`/techref/encodings` for the full list of supported non-ASCII characters. + For typesetting one or more paragraphs of text, see + :meth:`pygmt.Figure.paragraph`. + Full GMT docs at :gmt-docs:`text.html`. $aliases diff --git a/pygmt/tests/baseline/test_paragraph.png.dvc b/pygmt/tests/baseline/test_paragraph.png.dvc new file mode 100644 index 00000000000..82906933e1d --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: c5b1df47e811475defb0db79e49cab3d + size: 27632 + hash: md5 + path: test_paragraph.png diff --git a/pygmt/tests/baseline/test_paragraph_alignment.png.dvc b/pygmt/tests/baseline/test_paragraph_alignment.png.dvc new file mode 100644 index 00000000000..4a148fac914 --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph_alignment.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: a0ef6e989b11a252ec2a7ef497f3c789 + size: 36274 + hash: md5 + path: test_paragraph_alignment.png diff --git a/pygmt/tests/baseline/test_paragraph_font_angle_justify.png.dvc b/pygmt/tests/baseline/test_paragraph_font_angle_justify.png.dvc new file mode 100644 index 00000000000..483021faf2d --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph_font_angle_justify.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 6f55167eb6bc626b2bfee89ffe73faad + size: 48604 + hash: md5 + path: test_paragraph_font_angle_justify.png diff --git a/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc b/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc new file mode 100644 index 00000000000..664e741540b --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 167d4be24bca4e287b2056ecbfbb629a + size: 29076 + hash: md5 + path: test_paragraph_multiple_paragraphs.png diff --git a/pygmt/tests/test_paragraph.py b/pygmt/tests/test_paragraph.py new file mode 100644 index 00000000000..ed25f0d2e58 --- /dev/null +++ b/pygmt/tests/test_paragraph.py @@ -0,0 +1,97 @@ +""" +Tests for Figure.paragraph. +""" + +import pytest +from pygmt import Figure + + +@pytest.mark.mpl_image_compare +def test_paragraph(): + """ + Test typesetting a single paragraph. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + fig.paragraph( + x=4, + y=4, + text="This is a long paragraph. " * 10, + parwidth="5c", + linespacing="12p", + ) + return fig + + +@pytest.mark.mpl_image_compare(filename="test_paragraph_multiple_paragraphs.png") +@pytest.mark.parametrize("inputtype", ["list", "string"]) +def test_paragraph_multiple_paragraphs(inputtype): + """ + Test typesetting multiple paragraphs. + """ + if inputtype == "list": + text = [ + "This is the first paragraph. " * 5, + "This is the second paragraph. " * 5, + ] + else: + text = ( + "This is the first paragraph. " * 5 + + "\n\n" # Separate the paragraphs with a blank line. + + "This is the second paragraph. " * 5 + ) + + fig = Figure() + fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + fig.paragraph( + x=4, + y=4, + text=text, + parwidth="5c", + linespacing="12p", + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_paragraph_alignment(): + """ + Test typesetting a single paragraph with different alignments. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 8], projection="X10c/8c", frame=True) + for x, y, alignment in [ + (5, 1, "left"), + (5, 3, "right"), + (5, 5, "center"), + (5, 7, "justified"), + ]: + fig.paragraph( + x=x, + y=y, + text=alignment.upper() + " : " + "This is a long paragraph. " * 5, + parwidth="8c", + linespacing="12p", + alignment=alignment, + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_paragraph_font_angle_justify(): + """ + Test typesetting a single paragraph with font, angle, and justify options. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + fig.paragraph( + x=1, + y=4, + text="This is a long paragraph. " * 10, + parwidth="8c", + linespacing="12p", + font="10p,Helvetica-Bold,red", + angle=45, + justify="TL", + ) + return fig