From 2b303aba4a3dde03bf333d93dae988121b205726 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 28 Apr 2026 16:00:03 +0800 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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