Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions pygmt/src/paragraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import io
import re
from collections.abc import Sequence
from typing import Literal

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 </techref/justification_codes>`.
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
Expand Down Expand Up @@ -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=[
Expand All @@ -124,18 +145,31 @@ 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,
description="text",
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)
Expand Down
5 changes: 5 additions & 0 deletions pygmt/tests/baseline/test_paragraph_blank_line.png.dvc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
outs:
- md5: c8883bc08455157053c0bb9e2da697da
size: 18202
hash: md5
path: test_paragraph_blank_line.png
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
outs:
- md5: 167d4be24bca4e287b2056ecbfbb629a
size: 29076
- md5: 6c44336fdf613fd9cc04c3d953b7cad5
size: 55114
hash: md5
path: test_paragraph_multiple_paragraphs.png
5 changes: 5 additions & 0 deletions pygmt/tests/baseline/test_paragraph_tab_width.png.dvc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
outs:
- md5: 239ccf5325cd4956246ac4fbbfa358e4
size: 22797
hash: md5
path: test_paragraph_tab_width.png
114 changes: 99 additions & 15 deletions pygmt/tests/test_paragraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from pygmt import Figure
from pygmt.exceptions import GMTValueError


@pytest.mark.mpl_image_compare
Expand All @@ -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
Expand Down Expand Up @@ -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",
)
Loading