diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 3caf3909b..2e1419dd9 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -85,6 +85,7 @@ quartodoc: - GT.fmt_roman - GT.fmt_date - GT.fmt_time + - GT.fmt_url - GT.fmt_markdown - GT.fmt - title: Modifying columns diff --git a/great_tables/_formats.py b/great_tables/_formats.py index f8323e8ee..4f0d8269e 100644 --- a/great_tables/_formats.py +++ b/great_tables/_formats.py @@ -1,5 +1,6 @@ from __future__ import annotations from decimal import Decimal +from functools import partial from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union, List, cast, Optional, Dict, Literal from typing_extensions import TypeAlias from ._tbl_data import n_rows @@ -1771,6 +1772,273 @@ def fmt_time_fn( return fmt(self, fns=fmt_time_fn, columns=columns, rows=rows) +# fmt_url --------------------------------------------------------------------- + + +def fmt_url( + self: GTSelf, + columns: Union[str, List[str], None] = None, + rows: Union[int, List[int], None] = None, + label: str | None = None, + as_button: bool = False, + color: str = "auto", + show_underline: str | bool = "auto", + button_fill: str = "auto", + button_width: str = "auto", + button_outline: str = "auto", +) -> GTSelf: + """ + Format URLs to generate links. + + Should cells contain URLs, the `fmt_url()` method can be used to make them navigable links. This + should be expressly used on columns that contain *only* URL text (i.e., no URLs as part of a + larger block of text). Should you have such a column of data, there are options for how the + links should be styled. They can be of the conventional style (with underlines and text coloring + that sets it apart from other text), or, they can appear to be button-like (with a surrounding + box that can be filled with a color of your choosing). + + URLs in data cells are detected in two ways. The first is using the simple Markdown notation for + URLs of the form: `[label](URL)`. The second assumes that the text is the URL. In the latter + case the URL is also used as the label but there is the option to use the `label` argument to + modify that text. + + Parameters + ---------- + columns : Union[str, List[str], None] + The columns to target. Can either be a single column name or a series of column names + provided in a list. + rows : Union[int, List[int], None] + In conjunction with `columns`, we can specify which of their rows should undergo formatting. + The default is all rows, resulting in all rows in `columns` being formatted. Alternatively, + we can supply a list of row indices. + label : str | None + The visible 'label' to use for the link. If `None` (the default) the URL will serve as the + label. There are two non-`None` options: (1) using a piece of static text for the label + through provision of a string, and (2) a function can be provided to fashion a label from + every URL. + as_button : bool + An option to style the link as a button. By default, this is `False`. If this option is + chosen then the `button_fill` argument becomes usable. + color : str + The color used for the resulting link and its underline. This is `"auto"` by default; this + allows **Great Tables** to choose an appropriate color based on various factors (such as the + background `button_fill` when `as_button` is `True`). + show_underline : str | bool + Should the link be decorated with an underline? By default this is `"auto"` which means that + **Great Tables** will choose `True` when `as_button=False` and `False` in the other case. + The link underline will be the same color as that set in the `color` option. + button_fill : str + The color to fill the button with. This is ignored if `as_button` is `False`. The default is + `"auto"` which will use the default background color for the button. + button_width : str + The width of the button. This is ignored if `as_button` is `False`. The default is `"auto"` + which will use the default button width for link-as-button. + button_outline : str + Options for styling a link-as-button (and only applies if `as_button=True`). This option is + by default set to `"auto"`, allowing **Great Tables** to choose the appropriate outline + color value. + + Returns + ------- + GT + The GT object is returned. + """ + + if as_button: + # + # All determinations of `color`, `show_underline`, `button_fill` and + # `button_width` for the case where `as_button=True`; each of the + # above arguments are set to "auto" by default + # + + # In the button case, we opt to never show an underline unless it's + # requested by the user (i.e., `show_underline=True`) + if show_underline == "auto": + show_underline = False + + if button_width == "auto": + button_width = None + + button_outline_color = button_outline + button_outline_style = "solid" + button_outline_width = "2px" + + # There are various combinations of "auto" or not with `button_fill` and + # `color` that need to be handled delicately so as to ensure contrast + # between foreground text and background fill is maximized + if button_fill == "auto" and color == "auto": + # Choose a fixed and standard color combination if both options are + # 'auto'; these will be 'steelblue' and 'white' + button_fill = "#4682B4" + color = "#FFFFFF" + + elif button_fill == "auto" and color != "auto": + # If `button_fill` is 'auto' but `color` is not, then we need to + # determine whether the background should be light or dark + bgrnd_bw = "#FFFFFF" + + if bgrnd_bw == "#FFFFFF": + # Background should be light so using 'lightblue' + button_fill = "#ADD8E6" + else: + # Background should be dark so using 'darkblue' + button_fill = "#00008B" + + if button_outline == "auto": + button_outline_color = "#BEBEBE" + button_outline_style = "none" + + elif button_fill != "auto" and color == "auto": + if button_outline == "auto": + button_outline_color = "#DFDFDF" + + if button_fill in [ + "#FFFFFF", + "#FFFFFF", + "#FAF5EF", + "#FAFAFA", + "#FFFEFC", + "#FBFCFA", + "#FBFAF2", + ]: + button_outline_style = "solid" + else: + button_outline_style = "none" + + else: + pass + + else: + button_outline_style = "none" + button_outline_color = "invisible" + button_outline_width = "0px" + + if show_underline == "auto": + show_underline = True + + if color == "auto": + color = "#008B8B" + else: + pass + # TODO: Ensure that the incoming `color` is transformed to hexadecimal form + # color = html_color(colors=color, alpha=None) + + # Generate a function that will operate on single `x` values in the table body + f = partial( + _fmt_url_fn, + label=label, + as_button=as_button, + button_width=button_width, + button_fill=button_fill, + button_outline_style=button_outline_style, + button_outline_color=button_outline_color, + button_outline_width=button_outline_width, + color=color, + show_underline=show_underline, + ) + return fmt(self, fns=f, columns=columns, rows=rows) + + +class FmtUrlBtn: + FILL_DARK = "#4682B4" + FILL_LIGHT = "#FFFFFF" + COLOR_DARK = "#4682B4" + COLOR_LIGHT = "#4682B4" + + @classmethod + def get_fill(cls, fill: str, color: str, outline: str): + if fill != "auto": + return fill + + if color == "auto": + return cls.FILL_DARK + + if cls.is_dark(color): + return cls.FILL_LIGHT + else: + return cls.FILL_DARK + + @classmethod + def get_color(cls, fill: str, color: str, outline: str): + if color != "auto": + return color + + if fill == "auto": + return cls.COLOR_LIGHT + + if cls.is_dark(fill): + return cls.COLOR_LIGHT + + else: + return cls.COLOR_DARK + + # fill = FmtUrlBtn.get_fill(fill="auto", color="auto", outline="auto") + # color = FmtUrlBtn.get_color(fill=fill, color="auto", outline="auto") + + +def _fmt_url_fn( + x: Any, + label, + as_button, + button_width, + button_fill, + button_outline_style, + button_outline_color, + button_outline_width, + color, + show_underline, +) -> str: + # If the `x` value is a Pandas 'NA', then return the same value + if pd.isna(x): + return x + + href_str = x + + if label is not None: + if label is Callable: + label_str = label(x) + else: + label_str = label + + else: + import re + + pattern = r"^rgba\(\s*(?:[0-9]+?\s*,\s*){3}[0-9\.]+?\s*\)$" + matched = bool(re.match(pattern, x)) + + if matched: + # Generate label + if bool(re.match(r"\\[.*?\\]\\(.*?\\)", x)): + label_str = re.sub(r"\\[(.*?)\\]\\(.*?\\)", "\\1", x) + else: + label_str = x + + # Generate href value + if bool(re.match(r"\\[.*?\\]\\(.*?\\)", x)): + href_str = re.sub(r"\\[.*?\\]\\((.*?)\\)", "\\1", x) + else: + href_str = x + + else: + label_str = x + + if as_button: + if button_width is not None: + button_width_str = f"width:{button_width};text-align:center;" + else: + button_width_str = "" + + button_attrs = f"background-color:{button_fill};padding:8px 12px;{button_width_str};outline-style:{button_outline_style};outline-color:{button_outline_color};outline-width:{button_outline_width};" + + else: + button_attrs = "" + + attrs = f'color:{color};text-decoration:{"underline" if show_underline else "none"};{"text-underline-position:under;" if show_underline else ""}display:inline-block;{button_attrs}' + x_formatted = f'{label_str}' + + return x_formatted + + def fmt_markdown( self: GTSelf, columns: Union[str, List[str], None] = None, diff --git a/great_tables/gt.py b/great_tables/gt.py index b49617caa..e4220d6f7 100644 --- a/great_tables/gt.py +++ b/great_tables/gt.py @@ -21,6 +21,7 @@ fmt_roman, fmt_date, fmt_time, + fmt_url, fmt_markdown, ) from great_tables._heading import tab_header @@ -202,6 +203,7 @@ def __init__( fmt_roman = fmt_roman fmt_date = fmt_date fmt_time = fmt_time + fmt_url = fmt_url fmt_markdown = fmt_markdown tab_options = tab_options diff --git a/tests/__snapshots__/test_fmt_url.ambr b/tests/__snapshots__/test_fmt_url.ambr new file mode 100644 index 000000000..ae0efaee8 --- /dev/null +++ b/tests/__snapshots__/test_fmt_url.ambr @@ -0,0 +1,91 @@ +# serializer version: 1 +# name: test_fmt_url_01 + ''' + + + Addington Highlands + https://addingtonhighlands.ca + + + Adelaide Metcalfe + https://adelaidemetcalfe.on.ca + + + Adjala-Tosorontio + https://www.adjtos.ca + + + ''' +# --- +# name: test_fmt_url_02 + ''' + + + Addington Highlands + https://addingtonhighlands.ca + + + Adelaide Metcalfe + https://adelaidemetcalfe.on.ca + + + Adjala-Tosorontio + https://www.adjtos.ca + + + ''' +# --- +# name: test_fmt_url_03 + ''' + + + Addington Highlands + https://addingtonhighlands.ca + + + Adelaide Metcalfe + https://adelaidemetcalfe.on.ca + + + Adjala-Tosorontio + https://www.adjtos.ca + + + ''' +# --- +# name: test_fmt_url_04 + ''' + + + Addington Highlands + https://addingtonhighlands.ca + + + Adelaide Metcalfe + https://adelaidemetcalfe.on.ca + + + Adjala-Tosorontio + https://www.adjtos.ca + + + ''' +# --- +# name: test_fmt_url_05 + ''' + + + Addington Highlands + https://addingtonhighlands.ca + + + Adelaide Metcalfe + https://adelaidemetcalfe.on.ca + + + Adjala-Tosorontio + https://www.adjtos.ca + + + ''' +# --- diff --git a/tests/test_fmt_url.py b/tests/test_fmt_url.py new file mode 100644 index 000000000..b98388c20 --- /dev/null +++ b/tests/test_fmt_url.py @@ -0,0 +1,42 @@ +from great_tables import GT +from great_tables.data import towny +from great_tables._utils_render_html import create_body_component_h + +towny_mini = towny[["name", "website"]].head(3) + + +def assert_rendered_body(snapshot, gt): + built = gt._build_data("html") + body = create_body_component_h(built) + + assert snapshot == body + + +def test_fmt_url_01(snapshot): + new_gt = GT(towny_mini).fmt_url(columns="website") + + assert_rendered_body(snapshot, new_gt) + + +def test_fmt_url_02(snapshot): + new_gt = GT(towny_mini).fmt_url(columns="website", as_button=True) + + assert_rendered_body(snapshot, new_gt) + + +def test_fmt_url_03(snapshot): + new_gt = GT(towny_mini).fmt_url(columns="website", color="red") + + assert_rendered_body(snapshot, new_gt) + + +def test_fmt_url_04(snapshot): + new_gt = GT(towny_mini).fmt_url(columns="website", as_button=True, color="green") + + assert_rendered_body(snapshot, new_gt) + + +def test_fmt_url_05(snapshot): + new_gt = GT(towny_mini).fmt_url(columns="website", show_underline=False, color="green") + + assert_rendered_body(snapshot, new_gt)