From 2b303aba4a3dde03bf333d93dae988121b205726 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 28 Apr 2026 16:00:03 +0800 Subject: [PATCH 1/9] Figure.paragraph: What you see is what you type for spaces and tabs. --- pygmt/src/paragraph.py | 46 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index 94416b7ac7c..e5f1abc6ca2 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, + blankline_between_paragraphs: bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, panel: int | Sequence[int] | bool = False, @@ -79,6 +82,13 @@ def paragraph( # noqa: PLR0913 alignment Set the alignment of the text. Valid values are ``"left"``, ``"center"``, ``"right"``, and ``"justified"``. + 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. + blankline_between_paragraphs + If ``True``, use a blank line between paragraphs. [Default is ``False``, i.e., + no blank line between paragraphs.] $verbose $panel $transparency @@ -108,6 +118,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 +140,32 @@ 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) - + # + # The GMT's behavior: + # - Leading and trailing spaces are ignored. + # - Multiple spaces inside a paragraph are combined into one single space. + # - Leading tabs are combined into one tab that results in a 4-space indentation. + # - Trailing tabs are ignored. + # - Multiple tabs inside a paragraph are converted to multiple spaces. + # - Mixing tabs and spaces inside a paragraph has a complicated behavior. + # - Newline characters are always converted into spaces. + + # 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 blankline_between_paragraphs else "\n\n" + # Convert a single string into a list of paragraphs for consistent handling. + # Split the single string on black lines, allowing for whitespaces in between. + if not is_nonstr_iter(text): + text = re.split(r"\n\s*\n", text) + # 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) + # 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 +173,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) From c1de4f4f51ed0fbff1d1ba7c9154909360c5d93f Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 25 May 2026 12:18:30 +0800 Subject: [PATCH 2/9] Remove unneeded comments --- pygmt/src/paragraph.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index e5f1abc6ca2..3465dc9b6d0 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -141,16 +141,6 @@ def paragraph( # noqa: PLR0913 aliasdict.merge({"M": True}) # Prepare the text string that will be passed to an io.StringIO object. - # - # The GMT's behavior: - # - Leading and trailing spaces are ignored. - # - Multiple spaces inside a paragraph are combined into one single space. - # - Leading tabs are combined into one tab that results in a 4-space indentation. - # - Trailing tabs are ignored. - # - Multiple tabs inside a paragraph are converted to multiple spaces. - # - Mixing tabs and spaces inside a paragraph has a complicated behavior. - # - Newline characters are always converted into spaces. - # 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. From 1aca8f1f744902fb7b5e42a9f0e998ed57b08550 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 25 May 2026 23:52:06 +0800 Subject: [PATCH 3/9] Ignore a mypy type error --- pygmt/src/paragraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index 3465dc9b6d0..a6dfe4b547c 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -148,7 +148,7 @@ def paragraph( # noqa: PLR0913 # Convert a single string into a list of paragraphs for consistent handling. # Split the single string on black lines, allowing for whitespaces in between. if not is_nonstr_iter(text): - text = re.split(r"\n\s*\n", 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) From 8e6c05da1769bfcc50bc98c6dcc67999d449f283 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 26 May 2026 00:24:13 +0800 Subject: [PATCH 4/9] Update existing tests --- pygmt/src/paragraph.py | 3 +-- pygmt/tests/test_paragraph.py | 34 ++++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index a6dfe4b547c..e00e6cb6597 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -77,8 +77,7 @@ def paragraph( # noqa: PLR0913 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"``]. + Set the pen for the paragraph box [Default is ``"0.25p,black,solid"``]. alignment Set the alignment of the text. Valid values are ``"left"``, ``"center"``, ``"right"``, and ``"justified"``. diff --git a/pygmt/tests/test_paragraph.py b/pygmt/tests/test_paragraph.py index ed25f0d2e58..08015b9a982 100644 --- a/pygmt/tests/test_paragraph.py +++ b/pygmt/tests/test_paragraph.py @@ -29,25 +29,31 @@ def test_paragraph_multiple_paragraphs(inputtype): """ Test typesetting multiple paragraphs. """ + 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\n\nare converted to spaces.", + "Paragraph 9: This is the last paragraph.", + ] + if inputtype == "list": - text = [ - "This is the first paragraph. " * 5, - "This is the second paragraph. " * 5, - ] + pass else: - text = ( - "This is the first paragraph. " * 5 - + "\n\n" # Separate the paragraphs with a blank line. - + "This is the second paragraph. " * 5 - ) - + 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, 12], 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 From fdec96411820de039bde9343d3d87ace92f625a3 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 15 Jun 2026 00:41:05 +0800 Subject: [PATCH 5/9] Rename blankline_between_paragraphs to blank_line --- pygmt/src/paragraph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index e00e6cb6597..bd03af97e95 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -37,7 +37,7 @@ def paragraph( # noqa: PLR0913 pen: str | None = None, alignment: Literal["left", "center", "right", "justified"] = "left", tab_width: int = 4, - blankline_between_paragraphs: bool = False, + blank_line: bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, panel: int | Sequence[int] | bool = False, @@ -85,7 +85,7 @@ def paragraph( # noqa: PLR0913 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. - blankline_between_paragraphs + blank_line If ``True``, use a blank line between paragraphs. [Default is ``False``, i.e., no blank line between paragraphs.] $verbose @@ -143,7 +143,7 @@ def paragraph( # noqa: PLR0913 # 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 blankline_between_paragraphs else "\n\n" + 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 black lines, allowing for whitespaces in between. if not is_nonstr_iter(text): From e03118025bb6b601b6a33e0aa4217b5231012db8 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 21 Jun 2026 12:02:32 +0800 Subject: [PATCH 6/9] Update baseline images --- .../tests/baseline/test_paragraph_multiple_paragraphs.png.dvc | 4 ++-- pygmt/tests/test_paragraph.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc b/pygmt/tests/baseline/test_paragraph_multiple_paragraphs.png.dvc index 664e741540b..2d844140dc6 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: ebf9e854384e7cbdd66b0f9d97d69620 + size: 63143 hash: md5 path: test_paragraph_multiple_paragraphs.png diff --git a/pygmt/tests/test_paragraph.py b/pygmt/tests/test_paragraph.py index 08015b9a982..42431aa4077 100644 --- a/pygmt/tests/test_paragraph.py +++ b/pygmt/tests/test_paragraph.py @@ -37,7 +37,7 @@ def test_paragraph_multiple_paragraphs(inputtype): "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\n\nare converted to spaces.", + "Paragraph 8: Newlines insiden a paragraph\nare converted to spaces.", "Paragraph 9: This is the last paragraph.", ] From f1a63a6ef8f80ad539f27c2c7bc6c460a59a2fea Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 21 Jun 2026 13:33:06 +0800 Subject: [PATCH 7/9] Improve docstrings --- pygmt/src/paragraph.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pygmt/src/paragraph.py b/pygmt/src/paragraph.py index bd03af97e95..467aae04f0c 100644 --- a/pygmt/src/paragraph.py +++ b/pygmt/src/paragraph.py @@ -46,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 @@ -74,13 +80,13 @@ 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 `. + 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"``]. - alignment - Set the alignment of the text. Valid values are ``"left"``, ``"center"``, - ``"right"``, and ``"justified"``. 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 @@ -145,11 +151,12 @@ def paragraph( # noqa: PLR0913 # " \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 black lines, allowing for whitespaces in between. + # 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. From 3efc33983789dded69d1d3a09d936744e4df675d Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 21 Jun 2026 13:59:14 +0800 Subject: [PATCH 8/9] Add more tests --- .../test_paragraph_blank_line.png.dvc | 5 +++ ...test_paragraph_multiple_paragraphs.png.dvc | 4 +- .../baseline/test_paragraph_tab_width.png.dvc | 5 +++ pygmt/tests/test_paragraph.py | 40 ++++++++++++++++--- 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 pygmt/tests/baseline/test_paragraph_blank_line.png.dvc create mode 100644 pygmt/tests/baseline/test_paragraph_tab_width.png.dvc 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 2d844140dc6..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: ebf9e854384e7cbdd66b0f9d97d69620 - size: 63143 +- 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 42431aa4077..d446c5c4006 100644 --- a/pygmt/tests/test_paragraph.py +++ b/pygmt/tests/test_paragraph.py @@ -40,13 +40,11 @@ def test_paragraph_multiple_paragraphs(inputtype): "Paragraph 8: Newlines insiden a paragraph\nare converted to spaces.", "Paragraph 9: This is the last paragraph.", ] - - if inputtype == "list": - pass - else: + if inputtype == "string": text = "\n\n".join(text) + fig = Figure() - fig.basemap(region=[0, 17, 0, 12], projection="x1c", frame=True) + fig.basemap(region=[0, 17, 0, 8], projection="x1c", frame=True) fig.paragraph( x=1, y=1, @@ -101,3 +99,35 @@ 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 From 7ef9359fecdbcd9c2ffbf94f08ab38c4a927b273 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 21 Jun 2026 14:11:28 +0800 Subject: [PATCH 9/9] Add more tests to increase code coverage --- pygmt/tests/test_paragraph.py | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pygmt/tests/test_paragraph.py b/pygmt/tests/test_paragraph.py index d446c5c4006..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 @@ -131,3 +132,50 @@ def test_paragraph_tab_width(): 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", + )