Skip to content

Commit d4a006c

Browse files
authored
Add Figure.paragraph to typeset one or multiple paragraph of text strings (#3709)
1 parent 871bb8e commit d4a006c

File tree

10 files changed

+275
-0
lines changed

10 files changed

+275
-0
lines changed

doc/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Plotting map elements
3434
Figure.legend
3535
Figure.logo
3636
Figure.magnetic_rose
37+
Figure.paragraph
3738
Figure.scalebar
3839
Figure.solar
3940
Figure.text

pygmt/figure.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ def _repr_html_(self) -> str:
426426
logo,
427427
magnetic_rose,
428428
meca,
429+
paragraph,
429430
plot,
430431
plot3d,
431432
psconvert,

pygmt/src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from pygmt.src.makecpt import makecpt
4444
from pygmt.src.meca import meca
4545
from pygmt.src.nearneighbor import nearneighbor
46+
from pygmt.src.paragraph import paragraph
4647
from pygmt.src.plot import plot
4748
from pygmt.src.plot3d import plot3d
4849
from pygmt.src.project import project

pygmt/src/paragraph.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""
2+
paragraph - Typeset one or multiple paragraphs.
3+
"""
4+
5+
import io
6+
from collections.abc import Sequence
7+
from typing import Literal
8+
9+
from pygmt._typing import AnchorCode
10+
from pygmt.alias import Alias, AliasSystem
11+
from pygmt.clib import Session
12+
from pygmt.exceptions import GMTValueError
13+
from pygmt.helpers import (
14+
_check_encoding,
15+
build_arg_list,
16+
fmt_docstring,
17+
is_nonstr_iter,
18+
non_ascii_to_octal,
19+
)
20+
21+
__doctest_skip__ = ["paragraph"]
22+
23+
24+
@fmt_docstring
25+
def paragraph( # noqa: PLR0913
26+
self,
27+
x: float | str,
28+
y: float | str,
29+
text: str | Sequence[str],
30+
parwidth: float | str,
31+
linespacing: float | str,
32+
font: str | None = None,
33+
angle: float | None = None,
34+
justify: AnchorCode | None = None,
35+
fill: str | None = None,
36+
pen: str | None = None,
37+
alignment: Literal["left", "center", "right", "justified"] = "left",
38+
verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"]
39+
| bool = False,
40+
panel: int | Sequence[int] | bool = False,
41+
transparency: float | Sequence[float] | bool | None = None,
42+
):
43+
r"""
44+
Typeset one or multiple paragraphs.
45+
46+
This method typesets one or multiple paragraphs of text at a given position on the
47+
figure. The text is flowed within a given paragraph width and with a specified line
48+
spacing. The text can be aligned left, center, right, or justified.
49+
50+
Multiple paragraphs can be provided as a sequence of strings, where each string
51+
represents a separate paragraph, or as a single string with a blank line (``\n\n``)
52+
separating the paragraphs.
53+
54+
Full GMT docs at :gmt-docs:`text.html`.
55+
56+
Parameters
57+
----------
58+
x/y
59+
The x, y coordinates of the paragraph.
60+
text
61+
The paragraph text to typeset. If a sequence of strings is provided, each string
62+
is treated as a separate paragraph.
63+
parwidth
64+
The width of the paragraph.
65+
linespacing
66+
The spacing between lines.
67+
font
68+
The font of the text.
69+
angle
70+
The angle of the text.
71+
justify
72+
Set the alignment of the block of text, relative to the given x, y position.
73+
Choose a :doc:`2-character justification code </techref/justification_codes>`.
74+
fill
75+
Set color for filling the paragraph box [Default is no fill].
76+
pen
77+
Set the pen used to draw a rectangle around the paragraph [Default is
78+
``"0.25p,black,solid"``].
79+
alignment
80+
Set the alignment of the text. Valid values are ``"left"``, ``"center"``,
81+
``"right"``, and ``"justified"``.
82+
$verbose
83+
$panel
84+
$transparency
85+
86+
Examples
87+
--------
88+
>>> import pygmt
89+
>>>
90+
>>> fig = pygmt.Figure()
91+
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True)
92+
>>> fig.paragraph(
93+
... x=4,
94+
... y=4,
95+
... text="This is a long paragraph. " * 10,
96+
... parwidth="5c",
97+
... linespacing="12p",
98+
... font="12p",
99+
... )
100+
>>> fig.show()
101+
"""
102+
self._activate_figure()
103+
104+
_valid_alignments = ("left", "center", "right", "justified")
105+
if alignment not in _valid_alignments:
106+
raise GMTValueError(
107+
alignment,
108+
description="value for parameter 'alignment'",
109+
choices=_valid_alignments,
110+
)
111+
112+
aliasdict = AliasSystem(
113+
F=[
114+
Alias(font, name="font", prefix="+f"),
115+
Alias(angle, name="angle", prefix="+a"),
116+
Alias(justify, name="justify", prefix="+j"),
117+
],
118+
G=Alias(fill, name="fill"),
119+
W=Alias(pen, name="pen"),
120+
).add_common(
121+
V=verbose,
122+
c=panel,
123+
t=transparency,
124+
)
125+
aliasdict.merge({"M": True})
126+
127+
confdict = {}
128+
# Prepare the text string that will be passed to an io.StringIO object.
129+
# Multiple paragraphs are separated by a blank line "\n\n".
130+
_textstr: str = "\n\n".join(text) if is_nonstr_iter(text) else str(text)
131+
132+
if _textstr == "":
133+
raise GMTValueError(
134+
text,
135+
description="text",
136+
reason="'text' must be a non-empty string or sequence of strings.",
137+
)
138+
139+
# Check the encoding of the text string and convert it to octal if necessary.
140+
if (encoding := _check_encoding(_textstr)) != "ascii":
141+
_textstr = non_ascii_to_octal(_textstr, encoding=encoding)
142+
confdict["PS_CHAR_ENCODING"] = encoding
143+
144+
with Session() as lib:
145+
with io.StringIO() as buffer: # Prepare the StringIO input.
146+
buffer.write(f"> {x} {y} {linespacing} {parwidth} {alignment[0]}\n")
147+
buffer.write(_textstr)
148+
with lib.virtualfile_in(data=buffer) as vfile:
149+
lib.call_module(
150+
"text",
151+
args=build_arg_list(aliasdict, infile=vfile, confdict=confdict),
152+
)

pygmt/src/text.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def text_( # noqa: PLR0912, PLR0913
6969
ZapfDingbats and ISO-8859-x (x can be 1-11, 13-16) encodings. Refer to
7070
:doc:`/techref/encodings` for the full list of supported non-ASCII characters.
7171
72+
For typesetting one or more paragraphs of text, see
73+
:meth:`pygmt.Figure.paragraph`.
74+
7275
Full GMT docs at :gmt-docs:`text.html`.
7376
7477
$aliases
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: c5b1df47e811475defb0db79e49cab3d
3+
size: 27632
4+
hash: md5
5+
path: test_paragraph.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: a0ef6e989b11a252ec2a7ef497f3c789
3+
size: 36274
4+
hash: md5
5+
path: test_paragraph_alignment.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: 6f55167eb6bc626b2bfee89ffe73faad
3+
size: 48604
4+
hash: md5
5+
path: test_paragraph_font_angle_justify.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: 167d4be24bca4e287b2056ecbfbb629a
3+
size: 29076
4+
hash: md5
5+
path: test_paragraph_multiple_paragraphs.png

pygmt/tests/test_paragraph.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Tests for Figure.paragraph.
3+
"""
4+
5+
import pytest
6+
from pygmt import Figure
7+
8+
9+
@pytest.mark.mpl_image_compare
10+
def test_paragraph():
11+
"""
12+
Test typesetting a single paragraph.
13+
"""
14+
fig = Figure()
15+
fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True)
16+
fig.paragraph(
17+
x=4,
18+
y=4,
19+
text="This is a long paragraph. " * 10,
20+
parwidth="5c",
21+
linespacing="12p",
22+
)
23+
return fig
24+
25+
26+
@pytest.mark.mpl_image_compare(filename="test_paragraph_multiple_paragraphs.png")
27+
@pytest.mark.parametrize("inputtype", ["list", "string"])
28+
def test_paragraph_multiple_paragraphs(inputtype):
29+
"""
30+
Test typesetting multiple paragraphs.
31+
"""
32+
if inputtype == "list":
33+
text = [
34+
"This is the first paragraph. " * 5,
35+
"This is the second paragraph. " * 5,
36+
]
37+
else:
38+
text = (
39+
"This is the first paragraph. " * 5
40+
+ "\n\n" # Separate the paragraphs with a blank line.
41+
+ "This is the second paragraph. " * 5
42+
)
43+
44+
fig = Figure()
45+
fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True)
46+
fig.paragraph(
47+
x=4,
48+
y=4,
49+
text=text,
50+
parwidth="5c",
51+
linespacing="12p",
52+
)
53+
return fig
54+
55+
56+
@pytest.mark.mpl_image_compare
57+
def test_paragraph_alignment():
58+
"""
59+
Test typesetting a single paragraph with different alignments.
60+
"""
61+
fig = Figure()
62+
fig.basemap(region=[0, 10, 0, 8], projection="X10c/8c", frame=True)
63+
for x, y, alignment in [
64+
(5, 1, "left"),
65+
(5, 3, "right"),
66+
(5, 5, "center"),
67+
(5, 7, "justified"),
68+
]:
69+
fig.paragraph(
70+
x=x,
71+
y=y,
72+
text=alignment.upper() + " : " + "This is a long paragraph. " * 5,
73+
parwidth="8c",
74+
linespacing="12p",
75+
alignment=alignment,
76+
)
77+
return fig
78+
79+
80+
@pytest.mark.mpl_image_compare
81+
def test_paragraph_font_angle_justify():
82+
"""
83+
Test typesetting a single paragraph with font, angle, and justify options.
84+
"""
85+
fig = Figure()
86+
fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True)
87+
fig.paragraph(
88+
x=1,
89+
y=4,
90+
text="This is a long paragraph. " * 10,
91+
parwidth="8c",
92+
linespacing="12p",
93+
font="10p,Helvetica-Bold,red",
94+
angle=45,
95+
justify="TL",
96+
)
97+
return fig

0 commit comments

Comments
 (0)