From ba5ba7f0bb4822e165879e94131764a150189be6 Mon Sep 17 00:00:00 2001 From: jrycw Date: Sat, 26 Apr 2025 17:56:01 +0800 Subject: [PATCH] Add `fmt_image_circle()` and `vals.fmt_image_circle()` functions --- docs/_quarto.yml | 2 + great_tables/_formats.py | 232 ++++++++++++++++++++++++++++++++-- great_tables/_formats_vals.py | 88 ++++++++++++- great_tables/gt.py | 2 + great_tables/vals.py | 1 + tests/test_formats.py | 46 ++++++- tests/test_formats_vals.py | 26 ++++ 7 files changed, 386 insertions(+), 11 deletions(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index a652ceaf2..24dc86446 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -135,6 +135,7 @@ quartodoc: - GT.fmt_markdown - GT.fmt_units - GT.fmt_image + - GT.fmt_image_circle - GT.fmt_icon - GT.fmt_flag - GT.fmt_nanoplot @@ -246,6 +247,7 @@ quartodoc: - vals.fmt_time - vals.fmt_markdown - vals.fmt_image + - vals.fmt_image_circle - title: Built in datasets desc: > The **Great Tables** package is equipped with sixteen datasets that come in all shapes and diff --git a/great_tables/_formats.py b/great_tables/_formats.py index 66833e4b1..a6d775d27 100644 --- a/great_tables/_formats.py +++ b/great_tables/_formats.py @@ -3657,7 +3657,7 @@ def fmt_image( columns: SelectExpr = None, rows: int | list[int] | None = None, height: str | int | None = None, - width: str | int | None = None, + width: str | None = None, sep: str = " ", path: str | Path | None = None, file_pattern: str = "{}", @@ -3740,7 +3740,165 @@ def fmt_image( ) ``` """ + return _fmt_image( + self, + columns=columns, + rows=rows, + height=height, + width=width, + sep=sep, + path=path, + file_pattern=file_pattern, + encode=encode, + ) + + +def fmt_image_circle( + self: GTSelf, + columns: SelectExpr = None, + rows: int | list[int] | None = None, + height: str | int | None = None, + width: str | None = None, + border_radius: str | None = "50%", + border_width: str | int | None = None, + border_color: str | None = None, + border_style: str | None = None, + sep: str = " ", + path: str | Path | None = None, + file_pattern: str = "{}", + encode: bool = True, +) -> GTSelf: + """Format image paths to generate circular images within table cells. + `fmt_image_circle()` is a utility function similar to [`fmt_image()`](`great_tables.fmt_image`), + but it also accepts additional parameters for customizing the image border: + `border_radius=`, `border_width=`, `border_color=`, and `border_style=`. + + When calling `fmt_image_circle()`, **Great Tables** automatically sets `border_radius="50%"` to + create a full circle. However, we can't assume whether you want the border to be visible. + Therefore, you should supply at least one of the following: `border_width=`, `border_color=`, + or `border_style=`. Based on your input, sensible defaults will be applied for any unset border + properties. + + Parameters + ---------- + columns + The columns to target. Can either be a single column name or a series of column names + provided in a list. + rows + In conjunction with `columns=`, we can specify which of their rows should undergo + formatting. The default is all rows, resulting in all rows in targeted columns being + formatted. Alternatively, we can supply a list of row indices. + height + The height of the rendered images. + width + The width of the rendered images. + border_radius + The radius of the image border. Accepts values in pixels (`px`) or percentages (`%`). + Defaults to `50%` to create a circular image. + border_width + The width of the image border. + border_color + The color of the image border. + border_style + The style of the image border (e.g., solid, dashed, dotted). + sep + In the output of images within a body cell, `sep=` provides the separator between each + image. + path + An optional path to local image files or an HTTP/HTTPS URL. + This is combined with the filenames to form the complete image paths. + file_pattern + The pattern to use for mapping input values in the body cells to the names of the graphics + files. The string supplied should use `"{}"` in the pattern to map filename fragments to + input strings. + encode + The option to always use Base64 encoding for image paths that are determined to be local. By + default, this is `True`. + + Returns + ------- + GT + The GT object is returned. This is the same object that the method is called on so that we + can facilitate method chaining. + + Examples + -------- + This example demonstrates how to use `fmt_image_circle()` to create circular images in table cells, + along with its counterpart [`vals.fmt_image_circle`](`great_tables.vals.fmt_image_circle`) to render + a circular image in the header. + ```{python} + import polars as pl + from great_tables import GT, vals, html + + posit_avatar = "https://avatars.githubusercontent.com/u/107264312?s=200&v=4" + rich_avatar = "https://avatars.githubusercontent.com/u/5612024?v=4" + michael_avatar = "https://avatars.githubusercontent.com/u/2574498?v=4" + + title_img = vals.fmt_image_circle(posit_avatar, height=100, border_color="#D3D3D3")[0] + df = pl.DataFrame({"@rich-iannone": [rich_avatar], "@machow": [michael_avatar]}) + + ( + GT(df) + .fmt_image_circle(height=150, border_width=5) + .tab_header(html(title_img)) + .cols_align("center") + .opt_stylize(color="green", style=6) + ) + ``` + """ + default_border_props = { + "border-width": "3px", + "border-color": "#0A0A0A", + "border-style": "solid", + } + + border_props = { + "border-width": border_width, + "border-color": border_color, + "border-style": border_style, + } + + # This block assigns default values to `border-width`, `border-color`, and `border-style` + # if the user specifies at least one of them but leaves others unset. + if any(border_props.values()): + for k, v in default_border_props.items(): + if border_props[k] is None: + border_props[k] = v + + border_width, border_color, border_style = border_props.values() + + return _fmt_image( + self, + columns=columns, + rows=rows, + height=height, + width=width, + border_radius=border_radius, + border_width=border_width, + border_color=border_color, + border_style=border_style, + sep=sep, + path=path, + file_pattern=file_pattern, + encode=encode, + ) + +def _fmt_image( + self: GTSelf, + columns: SelectExpr = None, + rows: int | list[int] | None = None, + height: str | int | None = None, + width: str | None = None, + border_radius: str | None = None, + border_width: str | int | None = None, + border_color: str | None = None, + border_style: str | None = None, + sep: str = " ", + path: str | Path | None = None, + file_pattern: str = "{}", + encode: bool = True, +) -> GTSelf: # TODO: most parameter options should allow a polars expression (or from_column) ---- # can other fmt functions do this kind of thing? expr_cols = [height, width, sep, path, file_pattern, encode] @@ -3754,7 +3912,19 @@ def fmt_image( if height is None and width is None: height = "2em" - formatter = FmtImage(self._tbl_data, height, width, sep, path, file_pattern, encode) + formatter = FmtImage( + self._tbl_data, + height=height, + width=width, + border_radius=border_radius, + border_width=border_width, + border_color=border_color, + border_style=border_style, + sep=sep, + path=path, + file_pattern=file_pattern, + encode=encode, + ) return fmt( self, fns=FormatFns(html=formatter.to_html, latex=formatter.to_latex, default=formatter.to_html), @@ -3767,12 +3937,15 @@ def fmt_image( class FmtImage: dispatch_on: DataFrameLike | Agnostic = Agnostic() height: str | int | None = None - width: str | int | None = None + width: str | None = None + border_radius: str | None = None + border_width: str | int | None = None + border_color: str | None = None + border_style: str | None = None sep: str = " " path: str | Path | None = None file_pattern: str = "{}" encode: bool = True - SPAN_TEMPLATE: ClassVar = '{}' def to_html(self, val: Any): @@ -3799,7 +3972,29 @@ def to_html(self, val: Any): # TODO: note that only height can be numeric in the R program. Is this on purpose? # In any event, raising explicitly for numeric width below. if isinstance(self.width, (int, float)): - raise NotImplementedError("The width argument must be specified as a string.") + raise NotImplementedError("The `width=` argument must be specified as a string.") + else: + width = self.width + + if self.border_radius is not None: + if not isinstance(self.border_radius, str): + raise NotImplementedError( + "The `border_radius=` argument must be specified as a string." + ) + if not any(self.border_radius.endswith(suffix) for suffix in {"px", "%"}): + raise NotImplementedError( + 'The `border_radius=` argument must end with either "px" or "%"' + ) + + border_radius = self.border_radius + + if isinstance(self.border_width, (int, float)): + border_width = px(self.border_width) + else: + border_width = self.border_width + + border_color = self.border_color + border_style = self.border_style full_files = self._apply_pattern(self.file_pattern, files) @@ -3822,7 +4017,17 @@ def to_html(self, val: Any): uri = filename # TODO: do we have a way to create tags, that is good at escaping, etc..? - out.append(self._build_img_tag(uri, height, self.width)) + out.append( + self._build_img_tag( + uri=uri, + height=height, + width=width, + border_radius=border_radius, + border_width=border_width, + border_color=border_color, + border_style=border_style, + ) + ) img_tags = self.sep.join(out) span = self.SPAN_TEMPLATE.format(img_tags) @@ -3866,15 +4071,26 @@ def _get_mime_type(filename: str) -> str: return f"image/{suffix}" @staticmethod - def _build_img_tag(uri: str, height: str | None = None, width: str | None = None) -> str: + def _build_img_tag( + uri: str, + height: str | None = None, + width: str | None = None, + border_radius: str | None = None, + border_width: str | None = None, + border_color: str | None = None, + border_style: str | None = None, + ) -> str: style_string = "".join( [ f"height: {height};" if height is not None else "", f"width: {width};" if width is not None else "", + f"border-radius: {border_radius};" if border_radius is not None else "", + f"border-width: {border_width};" if border_width is not None else "", + f"border-color: {border_color};" if border_color is not None else "", + f"border-style: {border_style};" if border_style is not None else "", "vertical-align: middle;", ] ) - return f'' diff --git a/great_tables/_formats_vals.py b/great_tables/_formats_vals.py index 4d16f6b29..2150da209 100644 --- a/great_tables/_formats_vals.py +++ b/great_tables/_formats_vals.py @@ -1051,7 +1051,7 @@ def val_fmt_markdown( def val_fmt_image( x: X, height: str | int | None = None, - width: str | int | None = None, + width: str | None = None, sep: str = " ", path: str | Path | None = None, file_pattern: str = "{}", @@ -1116,3 +1116,89 @@ def val_fmt_image( vals_fmt = _get_column_of_values(gt=gt_obj_fmt, column_name="x", context="html") return vals_fmt + + +def val_fmt_image_circle( + x: X, + height: str | int | None = None, + width: str | None = None, + border_radius: str | None = "50%", + border_width: str | int | None = None, + border_color: str | None = None, + border_style: str | None = None, + sep: str = " ", + path: str | Path | None = None, + file_pattern: str = "{}", + encode: bool = True, +) -> list[str]: + """Format image paths to generate circular images within table cells. + `fmt_image_circle()` is a utility function similar to [`fmt_image()`](`great_tables.fmt_image`), + but it also accepts additional parameters for customizing the image border: + `border_radius=`, `border_width=`, `border_color=`, and `border_style=`. + + When calling `fmt_image_circle()`, **Great Tables** automatically sets `border_radius="50%"` to + create a full circle. However, we can't assume whether you want the border to be visible. + Therefore, you should supply at least one of the following: `border_width=`, `border_color=`, + or `border_style=`. Based on your input, sensible defaults will be applied for any unset border + properties. + + Parameters + ---------- + x + A list of values to be formatted. + height + The height of the rendered images. + width + The width of the rendered images. + border_radius + The radius of the image border. Accepts values in pixels (`px`) or percentages (`%`). + Defaults to `50%` to create a circular image. + border_width + The width of the image border. + border_color + The color of the image border. + border_style + The style of the image border (e.g., solid, dashed, dotted). + sep + In the output of images within a body cell, `sep=` provides the separator between each + image. + path + An optional path to local image files or an HTTP/HTTPS URL. + This is combined with the filenames to form the complete image paths. + file_pattern + The pattern to use for mapping input values in the body cells to the names of the graphics + files. The string supplied should use `"{}"` in the pattern to map filename fragments to + input strings. + encode + The option to always use Base64 encoding for image paths that are determined to be local. By + default, this is `True`. + + Returns + ------- + list[str] + A list of formatted values is returned. + + See Also + -------- + Check out our blog post, [Rendering images anywhere in Great Tables](https://posit-dev.github.io/great-tables/blog/rendering-images/), + which walks through how to use `vals.fmt_image()`. + """ + gt_obj: GTData = _make_one_col_table(vals=x) + + gt_obj_fmt = gt_obj.fmt_image_circle( + columns="x", + height=height, + width=width, + border_radius=border_radius, + border_width=border_width, + border_color=border_color, + border_style=border_style, + sep=sep, + path=path, + file_pattern=file_pattern, + encode=encode, + ) + + vals_fmt = _get_column_of_values(gt=gt_obj_fmt, column_name="x", context="html") + + return vals_fmt diff --git a/great_tables/gt.py b/great_tables/gt.py index 82b43fd99..fb75df089 100644 --- a/great_tables/gt.py +++ b/great_tables/gt.py @@ -18,6 +18,7 @@ fmt_flag, fmt_icon, fmt_image, + fmt_image_circle, fmt_integer, fmt_markdown, fmt_nanoplot, @@ -233,6 +234,7 @@ def __init__( fmt_datetime = fmt_datetime fmt_markdown = fmt_markdown fmt_image = fmt_image + fmt_image_circle = fmt_image_circle fmt_icon = fmt_icon fmt_flag = fmt_flag fmt_units = fmt_units diff --git a/great_tables/vals.py b/great_tables/vals.py index 81f79329b..b81ca3dbe 100644 --- a/great_tables/vals.py +++ b/great_tables/vals.py @@ -14,4 +14,5 @@ val_fmt_time as fmt_time, val_fmt_markdown as fmt_markdown, val_fmt_image as fmt_image, + val_fmt_image_circle as fmt_image_circle, ) diff --git a/tests/test_formats.py b/tests/test_formats.py index 12e5fcf7b..7967cff0a 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -1554,12 +1554,14 @@ def test_fmt_image_height_int(): assert strip_windows_drive(res) == dst -def test_fmt_image_width_int(): +def test_fmt_image_width_int_raises(): formatter = FmtImage(encode=False, width=20) - with pytest.raises(NotImplementedError): + with pytest.raises(NotImplementedError) as exc_info: formatter.to_html("/a") + assert "The `width=` argument must be specified as a string." in exc_info.value.args[0] + @pytest.mark.skipif(sys.platform == "win32", reason="uses linux specific paths") def test_fmt_image_path(): @@ -1582,6 +1584,46 @@ def test_fmt_image_path_http(url: str): assert strip_windows_drive(res) == dst +def test_fmt_image_circle_single(): + formatter = FmtImage(border_radius="50%", sep=" ", file_pattern="{}.svg", encode=False) + res = formatter.to_html("/a") + dst = formatter.SPAN_TEMPLATE.format( + '' + ) + + assert strip_windows_drive(res) == dst + + +def test_fmt_image_circle_multiple(): + formatter = FmtImage(border_radius="50%", sep="---", file_pattern="{}.svg", encode=False) + res = formatter.to_html("/a,/b") + dst = formatter.SPAN_TEMPLATE.format( + '' + "---" + '' + ) + + assert strip_windows_drive(res) == dst + + +def test_fmt_image_circle_border_radius_raises1(): + formatter = FmtImage(encode=False, border_radius=20) + + with pytest.raises(NotImplementedError) as exc_info: + formatter.to_html("/a") + assert "The `border_radius=` argument must be specified as a string." in exc_info.value.args[0] + + +def test_fmt_image_circle_border_radius_raises2(): + formatter = FmtImage(encode=False, border_radius="20") + + with pytest.raises(NotImplementedError) as exc_info: + formatter.to_html("/a") + assert ( + 'The `border_radius=` argument must end with either "px" or "%"' in exc_info.value.args[0] + ) + + def test_fmt_icon_one_per_cell(): df = pd.DataFrame({"x": ["hippo", "burger", "pizza-slice"]}) diff --git a/tests/test_formats_vals.py b/tests/test_formats_vals.py index 660426a56..80a87a81b 100644 --- a/tests/test_formats_vals.py +++ b/tests/test_formats_vals.py @@ -29,3 +29,29 @@ def test_val_fmt_image_multiple(img_paths: Path): assert 'img src="data:image/svg+xml;base64' in img1 assert 'img src="data:image/svg+xml;base64' in img2 + + +def test_locate_val_fmt_image_circle(img_paths: Path): + imgs = vals.fmt_image_circle( + "1", border_style="solid", path=img_paths, file_pattern="metro_{}.svg" + ) + with open(img_paths / "metro_1.svg", "rb") as f: + encoded = base64.b64encode(f.read()).decode() + + assert encoded in imgs[0] + + +def test_val_fmt_image_circle_single(img_paths: Path): + imgs = vals.fmt_image_circle( + "1", border_style="solid", path=img_paths, file_pattern="metro_{}.svg" + ) + assert 'img src="data:image/svg+xml;base64' in imgs[0] + + +def test_val_fmt_image_circle_multiple(img_paths: Path): + img1, img2 = vals.fmt_image_circle( + ["1", "2"], border_style="solid", path=img_paths, file_pattern="metro_{}.svg" + ) + + assert 'img src="data:image/svg+xml;base64' in img1 + assert 'img src="data:image/svg+xml;base64' in img2