"""
``geom_richtext``: text labels with rich formatting and box
styling, ported from R's ``ggtext::geom_richtext``.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plotnine._utils import to_rgba
from plotnine.doctools import document
from plotnine.geoms.geom_text import geom_text
if TYPE_CHECKING:
from typing import Any
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.offsetbox import DrawingArea
from plotnine.coords.coord import coord
from plotnine.iapi import panel_view
from plotnine.layer import layer
_aes = geom_text.DEFAULT_AES.copy()
_aes.update(
{
"fill": "white",
}
)
_params = geom_text.DEFAULT_PARAMS.copy()
_params.update(
{
"boxstyle": "round",
"boxcolor": None,
"label_padding": 0.25,
"label_r": 0.15,
"label_size": 0.7,
"tooth_size": None,
"text_color": None,
"fill_alpha": None,
}
)
def _parse_markdown(text: str) -> tuple[str, dict[str, Any]]:
"""
Parse minimal markdown in *text* and return the cleaned
string together with any text-property overrides.
Supported syntax
----------------
- ``**bold**``: fontweight="bold"
- ``*italic*``: fontstyle="italic"
- ``<br>`` / ``<br/>``: newline
- ``<sup>...</sup>``: superscript (via mathtext ``$^{...}$``)
- ``<sub>...</sub>``: subscript (via mathtext ``$_{...}$``)
Returns the cleaned text and a dict of matplotlib text
property overrides (``fontweight``, ``fontstyle``).
"""
props: dict[str, Any] = {}
# <br> / <br/>: newline
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
# <sup>...</sup>: mathtext superscript
text = re.sub(
r"<sup>(.*?)</sup>",
r"$^{\1}$",
text,
flags=re.IGNORECASE,
)
# <sub>...</sub>: mathtext subscript
text = re.sub(
r"<sub>(.*?)</sub>",
r"$_{\1}$",
text,
flags=re.IGNORECASE,
)
# **bold**: set weight and strip markers
if re.search(r"\*\*(.+?)\*\*", text):
# If the entire text is bold, set the property
m = re.fullmatch(r"\*\*(.+)\*\*", text.strip())
if m:
text = m.group(1)
props["fontweight"] = "bold"
else:
# Partial bold cannot be drawn inline in plain matplotlib,
# so just strip the markers
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
# *italic*: same approach
if re.search(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", text):
m = re.fullmatch(r"\*(.+)\*", text.strip())
if m:
text = m.group(1)
props["fontstyle"] = "italic"
else:
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", text)
return text, props
[docs]
@document
class geom_richtext(geom_text):
"""
Text labels with a background box and basic rich-text
formatting
An enhanced version of :class:`~plotnine.geom_label` that
supports a subset of markdown and HTML formatting in label
text:
* ``**bold**`` → bold text
* ``*italic*`` → italic text
* ``<br>`` → line break
* ``<sup>x</sup>`` → superscript
* ``<sub>x</sub>`` → subscript
{usage}
Parameters
----------
{common_parameters}
boxstyle : str
Matplotlib box style (``"round"``, ``"square"``, etc.).
boxcolor : str | None
Border colour of the label box. ``None`` uses the text
colour.
label_padding : float
Padding inside the box.
label_r : float
Corner-rounding radius (for round box styles).
label_size : float
Border line width of the box.
text_color : str | None
Override text colour independently of the box colour.
fill_alpha : float | None
Alpha for the fill colour (separate from text alpha).
See Also
--------
plotnine.geom_label
plotnine.geom_text
"""
DEFAULT_AES = _aes
DEFAULT_PARAMS = _params
[docs]
@staticmethod
def draw_group(
data: pd.DataFrame,
panel_params: panel_view,
coord: coord,
ax: Axes,
params: dict[str, Any],
):
data = coord.transform(data, panel_params)
zorder = params["zorder"]
# Text and fill colours
text_alpha = data["alpha"]
color = to_rgba(data["color"], text_alpha)
fill_alpha = params.get("fill_alpha")
if fill_alpha is not None:
fill = to_rgba(data["fill"], fill_alpha)
else:
fill = to_rgba(data["fill"], text_alpha)
if isinstance(fill, tuple):
fill = [list(fill)] * len(data["x"])
# Build box style string
tokens = [
params["boxstyle"],
f"pad={params['label_padding']}",
]
if params["boxstyle"] in {"round", "round4"}:
tokens.append(f"rounding_size={params['label_r']}")
elif params["boxstyle"] in ("roundtooth", "sawtooth"):
tooth = params.get("tooth_size")
if tooth is not None:
tokens.append(f"tooth_size={tooth}")
boxstyle = ",".join(tokens)
bbox_base = {
"linewidth": params["label_size"],
"boxstyle": boxstyle,
}
text_color_override = params.get("text_color")
for i in range(len(data)):
row = data.iloc[i]
label = str(row["label"])
# Parse simple markdown
label, md_props = _parse_markdown(label)
kw: dict[str, Any] = {
"x": row["x"],
"y": row["y"],
"s": label,
"color": (
text_color_override
if text_color_override
else color
if isinstance(color, tuple)
else color[i]
),
"size": row["size"],
"rotation": row["angle"],
"linespacing": row["lineheight"],
"ha": row["ha"],
"va": row["va"],
"fontweight": md_props.get("fontweight", row["fontweight"]),
"fontstyle": md_props.get("fontstyle", row["fontstyle"]),
"zorder": zorder,
"clip_on": True,
"rasterized": params.get("raster", False),
}
if row.get("family") is not None:
kw["family"] = row["family"]
bbox = dict(bbox_base)
bbox["edgecolor"] = (
params["boxcolor"]
if params["boxcolor"] is not None
else kw["color"]
)
bbox["facecolor"] = fill if isinstance(fill, tuple) else fill[i]
kw["bbox"] = bbox
txt = ax.text(**kw)
if params.get("path_effects"):
txt.set_path_effects(params["path_effects"])
[docs]
@staticmethod
def draw_legend(
data: pd.Series[Any],
da: DrawingArea,
lyr: layer,
) -> DrawingArea:
from matplotlib.patches import Rectangle
fill = to_rgba(data["fill"], data["alpha"])
if data["fill"]:
rect = Rectangle(
(0, 0),
width=da.width,
height=da.height,
linewidth=0,
facecolor=fill,
capstyle="projecting",
)
da.add_artist(rect)
return geom_text.draw_legend(data, da, lyr)