diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index 94416b7ac7c..467aae04f0c 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -3,6 +3,7 @@ """ import io +import re from collections.abc import Sequence from typing import Literal @@ -35,6 +36,8 @@ def paragraph( # noqa: PLR0913 fill: str | None = None, pen: str | None = None, alignment: Literal["left", "center", "right", "justified"] = "left", + tab_width: int = 4, + blank_line: bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, panel: int | Sequence[int] | bool = False, @@ -43,14 +46,20 @@ def paragraph( # noqa: PLR0913 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. + This method typesets one or multiple paragraphs of text at a given position. The + text is flowed within a given paragraph width and with a specified line spacing, and + 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. + The text string is typeset following the What You Type Is What You Get principle, + meaning that the text is rendered exactly as it appears in the input string. This + allows for precise control over the formatting of the text, including the use of + multiple spaces and tabs. By default, a tab is replaced with four spaces, but this + can be changed by setting the ``tab_width``. + Full GMT docs at :gmt-docs:`text.html`. Parameters @@ -71,14 +80,20 @@ def paragraph( # noqa: PLR0913 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"``. + fill + Set color for filling the paragraph box [Default is no fill]. + pen + Set the pen for the paragraph box [Default is ``"0.25p,black,solid"``]. + tab_width + Number of spaces used to expand tab characters in ``text`` when typesetting. + Must be a non-negative integer. Use ``0`` to remove tab characters instead of + replacing them with spaces. + blank_line + If ``True``, use a blank line between paragraphs. [Default is ``False``, i.e., + no blank line between paragraphs.] $verbose $panel $transparency @@ -108,6 +123,12 @@ def paragraph( # noqa: PLR0913 description="value for parameter 'alignment'", choices=_valid_alignments, ) + if tab_width < 0: + raise GMTValueError( + tab_width, + description="value for parameter 'tab_width'", + reason="Must be a non-negative integer.", + ) aliasdict = AliasSystem( F=[ @@ -124,11 +145,23 @@ def paragraph( # noqa: PLR0913 ) 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) - + # Separator for multiple paragraphs. + # "\n\n": the default separator, which results in no blank line between paragraphs. + # " \n\n": add a blank line between paragraphs. + sep = " \n\n" if blank_line else "\n\n" + # Convert a single string into a list of paragraphs for consistent handling. + # Split the single string on blank lines, allowing for whitespaces in between. + if not is_nonstr_iter(text): + text = re.split(r"\n\s*\n", text) # type: ignore[arg-type] + # Join multiple paragraphs with a blank line. Remove trailing whitespaces and + # newlines in each paragraph, but keep leading whitespaces and tabs for now. + # _textstr = sep.join(t.rstrip().replace("\n", "") for t in text) + _textstr = sep.join(t.rstrip().replace("\n", "") for t in text) + # Replace two or more consecutive spaces with \040 (octal for space), and replace + # tabs with the appropriate number of \040. + _textstr = re.sub(r" {2,}", lambda m: r"\040" * len(m.group()), _textstr) + _textstr = _textstr.replace("\t", r"\040" * tab_width) if _textstr == "": raise GMTValueError( text, @@ -136,6 +169,7 @@ def paragraph( # noqa: PLR0913 reason="'text' must be a non-empty string or sequence of strings.", ) + confdict = {} # 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) diff --git a/pygmt/tests/baseline/test_paragraph_blank_line.png.dvc b/pygmt/tests/baseline/test_paragraph_blank_line.png.dvc new file mode 100644 index 00000000000..e66f69240fa --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph_blank_line.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: c8883bc08455157053c0bb9e2da697da + size: 18202 + hash: md5 + path: test_paragraph_blank_line.png diff --git a/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc b/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc index 664e741540b..555e99359c7 100644 --- a/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc +++ b/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc @@ -1,5 +1,5 @@ outs: -- md5: 167d4be24bca4e287b2056ecbfbb629a - size: 29076 +- md5: 6c44336fdf613fd9cc04c3d953b7cad5 + size: 55114 hash: md5 path: test_paragraph_multiple_paragraphs.png diff --git a/pygmt/tests/baseline/test_paragraph_tab_width.png.dvc b/pygmt/tests/baseline/test_paragraph_tab_width.png.dvc new file mode 100644 index 00000000000..1f7a5f4bc2e --- /dev/null +++ b/pygmt/tests/baseline/test_paragraph_tab_width.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 239ccf5325cd4956246ac4fbbfa358e4 + size: 22797 + hash: md5 + path: test_paragraph_tab_width.png diff --git a/pygmt/tests/test_paragraph.py b/pygmt/tests/test_paragraph.py index ed25f0d2e58..a1427619a3f 100644 --- a/pygmt/tests/test_paragraph.py +++ b/pygmt/tests/test_paragraph.py @@ -4,6 +4,7 @@ import pytest from pygmt import Figure +from pygmt.exceptions import GMTValueError @pytest.mark.mpl_image_compare @@ -29,25 +30,29 @@ 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 - ) + text = [ + " Paragraph 1: Two leading whitespaces. Three inline whitespaces. Two trailing whitespaces. ", + " Paragraph 2: One leading tab results in one indentation (four whitespaces by default).", + " Paragraph 3: Two leading tabs results in two indentation (eight whitespaces by default).", + "Paragraph 4: Multiple inline tabs are converted to multiple spaces. Trailing tabs have not effects. ", + "Paragraph 5: Mixing tabs and spaces. 2T3STST( ).", + "\nParagraph 6: Leading newline is converted to a space. Trailing newlines are converted to spaces.\n\n", + "\n\nParagraph 7: Multiple leading newline are converted to multiple spaces. xxx yyy zzz.", + "Paragraph 8: Newlines insiden a paragraph\nare converted to spaces.", + "Paragraph 9: This is the last paragraph.", + ] + if inputtype == "string": + text = "\n\n".join(text) fig = Figure() - fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + fig.basemap(region=[0, 17, 0, 8], projection="x1c", frame=True) fig.paragraph( - x=4, - y=4, + x=1, + y=1, text=text, - parwidth="5c", + font="Courier", + justify="BL", + parwidth="15c", linespacing="12p", ) return fig @@ -95,3 +100,82 @@ def test_paragraph_font_angle_justify(): justify="TL", ) return fig + + +@pytest.mark.mpl_image_compare +def test_paragraph_blank_line(): + """ + Test typesetting a single paragraph with blank_line option. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 4], projection="X10c/4c", frame=True) + text = ( + "This is a long paragraph. " * 5 + + "\n\n" + + "This is another long paragraph. " * 5 + ) + fig.paragraph( + x=5, y=2, text=text, parwidth="8c", linespacing="12p", blank_line=True + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_paragraph_tab_width(): + """ + Test typesetting a single paragraph with tab_width option. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 4], projection="X10c/6c", frame=True) + text = "A paragraph with tabs\tinside. " * 3 + fig.paragraph(x=5, y=3, text=text, parwidth="8c", linespacing="12p") + fig.paragraph(x=5, y=2, text=text, parwidth="8c", linespacing="12p", tab_width=0) + fig.paragraph(x=5, y=1, text=text, parwidth="8c", linespacing="12p", tab_width=8) + return fig + + +def test_paragraph_invalid_alignment(): + """ + Test that providing an invalid alignment raises a GMTValueError. + """ + fig = Figure() + with pytest.raises(GMTValueError, match="value for parameter 'alignment'"): + fig.paragraph( + x=1, + y=1, + text="This is a long paragraph.", + parwidth="8c", + linespacing="12p", + alignment="invalid", + ) + + +def test_paragraph_invalid_tab_width(): + """ + Test that providing an invalid tab_width raises a GMTValueError. + """ + fig = Figure() + with pytest.raises(GMTValueError, match="value for parameter 'tab_width'"): + fig.paragraph( + x=1, + y=1, + text="This is a long paragraph.", + parwidth="8c", + linespacing="12p", + tab_width=-1, + ) + + +def test_paragraph_empty_text(): + """ + Test that providing an invalid text type raises a GMTValueError. + """ + fig = Figure() + with pytest.raises(GMTValueError): + fig.paragraph( + x=1, + y=1, + text="", + parwidth="8c", + linespacing="12p", + )