diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 00f68dfa..c2eacd79 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -93,6 +93,7 @@ quartodoc:
- fa_icon_repeat
- gt_fa_rank_change
- gt_fa_rating
+ - gt_fmt_img_circle
- img_header
- title: Utilities
diff --git a/docs/examples/index.qmd b/docs/examples/index.qmd
index 2bd0843c..55c803ce 100644
--- a/docs/examples/index.qmd
+++ b/docs/examples/index.qmd
@@ -293,6 +293,11 @@ addEventListener('resize', setTableWidths);
[gt_fa_rating](./with-code.html#gt_fa_rating){.embed-hover-overlay}
:::
+:::{.masonry-item style="--width: 304;"}
+{{< embed with-code.qmd#gt_fmt_img_circle >}}
+[gt_fmt_img_circle](./with-code.html#gt_fmt_img_circle){.embed-hover-overlay}
+:::
+
:::{.masonry-item style="--width: 355;"}
{{< embed with-code.qmd#img_header >}}
[img_header](./with-code.html#img_header){.embed-hover-overlay}
diff --git a/docs/examples/with-code.qmd b/docs/examples/with-code.qmd
index a3a4f5a1..39bd1e2e 100644
--- a/docs/examples/with-code.qmd
+++ b/docs/examples/with-code.qmd
@@ -978,6 +978,29 @@ gt.pipe(gte.gt_fa_rating, columns="rating", name="star")
```
{{< include ./_show-last.qmd >}}
+### [gt_fmt_img_circle](https://posit-dev.github.io/gt-extras/reference/gt_fmt_img_circle.html){title="Click for reference"}
+```{python}
+# | label: gt_fmt_img_circle
+import polars as pl
+from great_tables import GT
+import gt_extras as gte
+
+rich_avatar = "https://avatars.githubusercontent.com/u/5612024?v=4"
+michael_avatar = "https://avatars.githubusercontent.com/u/2574498?v=4"
+jules_avatar = "https://avatars.githubusercontent.com/u/54960783?v=4"
+
+
+df = pl.DataFrame({"@machow": [michael_avatar], "@rich-iannone": [rich_avatar], "@juleswg23": [jules_avatar]})
+
+(
+ GT(df)
+ .cols_align("center")
+ .opt_stylize(color="green", style=6)
+ .pipe(gte.gt_fmt_img_circle, height=80)
+)
+```
+{{< include ./_show-last.qmd >}}
+
### [img_header](https://posit-dev.github.io/gt-extras/reference/img_header.html){title="Click for reference"}
```{python}
# | label: img_header
diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py
index 5671f6e4..45ea3681 100644
--- a/gt_extras/__init__.py
+++ b/gt_extras/__init__.py
@@ -9,7 +9,7 @@
from .formatting import fmt_pct_extra, gt_duplicate_column, gt_two_column_layout
from .html import gt_merge_stack, with_hyperlink, with_tooltip
from .icons import fa_icon_repeat, gt_fa_rank_change, gt_fa_rating
-from .images import add_text_img, img_header
+from .images import add_text_img, gt_fmt_img_circle, img_header
from .plotting import (
gt_plt_bar,
gt_plt_bar_pct,
@@ -68,6 +68,7 @@
"gt_two_column_layout",
"add_text_img",
"img_header",
+ "gt_fmt_img_circle",
"gt_add_divider",
"gt_plt_summary",
]
diff --git a/gt_extras/images.py b/gt_extras/images.py
index 36d388b0..c8877307 100644
--- a/gt_extras/images.py
+++ b/gt_extras/images.py
@@ -1,10 +1,19 @@
from __future__ import annotations
-from great_tables import html
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, ClassVar
+from warnings import warn
+
+from great_tables import GT, html
+from great_tables._gt_data import FormatFns
from great_tables._helpers import px
+from great_tables._tbl_data import Agnostic, DataFrameLike, PlExpr, SelectExpr, is_na
from great_tables._text import Html
+from great_tables._utils import is_valid_http_schema
-__all__ = ["add_text_img", "img_header"]
+__all__ = ["add_text_img", "img_header", "gt_fmt_img_circle"]
def img_header(
@@ -244,3 +253,314 @@ def add_text_img(
""".strip()
return combined_html
+
+
+# Copied from https://github.com/posit-dev/great-tables/pull/676
+def gt_fmt_img_circle(
+ gt: GT,
+ 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,
+) -> GT:
+ """Format image paths to generate circular images within table cells.
+ `gt_fmt_img_circle()` is a utility function similar to [`GT.fmt_image()`](https://posit-dev.github.io/great-tables/reference/GT.fmt_image),
+ but it also accepts additional parameters for customizing the image border:
+ `border_radius=`, `border_width=`, `border_color=`, and `border_style=`.
+
+ When calling `gt_fmt_img_circle()`, **gt-extras** 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
+ --------
+ ```{python}
+ import polars as pl
+ from great_tables import GT
+ import gt_extras as gte
+
+ rich_avatar = "https://avatars.githubusercontent.com/u/5612024?v=4"
+ michael_avatar = "https://avatars.githubusercontent.com/u/2574498?v=4"
+ jules_avatar = "https://avatars.githubusercontent.com/u/54960783?v=4"
+
+
+ df = pl.DataFrame({
+ "@machow": [michael_avatar],
+ "@rich-iannone": [rich_avatar],
+ "@juleswg23": [jules_avatar]
+ })
+
+ (
+ GT(df)
+ .cols_align("center")
+ .opt_stylize(color="green", style=6)
+ .pipe(gte.gt_fmt_img_circle, height=80)
+ )
+ ```
+ """
+ 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()
+
+ expr_cols = [height, width, sep, path, file_pattern, encode]
+
+ if any(isinstance(x, PlExpr) for x in expr_cols):
+ raise NotImplementedError(
+ "gt_fmt_img_circle currently does not support polars expressions for arguments other than"
+ " columns and rows"
+ )
+
+ if height is None and width is None:
+ height = "2em"
+
+ formatter = FmtImage(
+ gt._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 GT.fmt(
+ gt,
+ fns=FormatFns(
+ html=formatter.to_html, latex=formatter.to_latex, default=formatter.to_html
+ ),
+ columns=columns,
+ rows=rows,
+ )
+
+
+@dataclass
+class FmtImage:
+ dispatch_on: DataFrameLike | Agnostic = Agnostic()
+ 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
+ SPAN_TEMPLATE: ClassVar = '{}'
+
+ def to_html(self, val: Any):
+ # TODO: are we assuming val is a string? (or coercing?)
+
+ # otherwise...
+
+ if is_na(self.dispatch_on, val):
+ return val
+
+ if "," in val:
+ files = re.split(r",\s*", val)
+ else:
+ files = [val]
+
+ # TODO: if we allowing height and width to be set based on column values, then
+ # they could end up as bespoke types like np int64, etc..
+ # We should ensure we process those before hitting FmtImage
+ if isinstance(self.height, (int, float)):
+ height = px(self.height)
+ else:
+ height = self.height
+
+ width = self.width
+
+ if self.border_radius is not None:
+ 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)
+
+ out: list[str] = []
+ for file in full_files:
+ # Case 1: from url via `dispatch_on`
+ if self.path is None and is_valid_http_schema(file):
+ uri = file.rstrip().removesuffix("/")
+ # Case 2: from url via `path`
+ elif self.path is not None and is_valid_http_schema(str(self.path)):
+ norm_path = str(self.path).rstrip().removesuffix("/")
+ uri = f"{norm_path}/{file}"
+ # Case 3:
+ else:
+ filename = str((Path(self.path or "") / file).expanduser().absolute())
+
+ if self.encode:
+ uri = self._get_image_uri(filename)
+ else:
+ uri = filename
+
+ # TODO: do we have a way to create tags, that is good at escaping, etc..?
+ 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)
+
+ return span
+
+ def to_latex(self, val: Any):
+ from great_tables._gt_data import FormatterSkipElement
+
+ warn("fmt_image() is not currently implemented in LaTeX output.")
+
+ return FormatterSkipElement()
+
+ @staticmethod
+ def _apply_pattern(file_pattern: str, files: list[str]) -> list[str]:
+ return [file_pattern.format(file) for file in files]
+
+ @classmethod
+ def _get_image_uri(cls, filename: str) -> str:
+ import base64
+
+ with open(filename, "rb") as f:
+ encoded = base64.b64encode(f.read()).decode()
+
+ mime_type = cls._get_mime_type(filename)
+
+ return f"data:{mime_type};base64,{encoded}"
+
+ @staticmethod
+ def _get_mime_type(filename: str) -> str:
+ # note that we strip off the leading "."
+ suffix = Path(filename).suffix[1:]
+
+ if suffix == "svg":
+ return "image/svg+xml"
+ elif suffix == "jpg":
+ return "image/jpeg"
+
+ return f"image/{suffix}"
+
+ @staticmethod
+ 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/gt_extras/tests/__snapshots__/test_images.ambr b/gt_extras/tests/__snapshots__/test_images.ambr
index c70ee354..c02d5da6 100644
--- a/gt_extras/tests/__snapshots__/test_images.ambr
+++ b/gt_extras/tests/__snapshots__/test_images.ambr
@@ -12,6 +12,18 @@
'''
# ---
+# name: test_gt_fmt_img_circle_snapshot
+ '''
+
\n