From e7ff8b16afd7bff0f8401c4edd6802e52a4d0ee5 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:10:11 -0400 Subject: [PATCH 01/10] initial setup --- docs/_quarto.yml | 1 + gt_extras/__init__.py | 3 ++- gt_extras/images.py | 20 ++++++++++++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 00f68dfa..cc7eb05b 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -91,6 +91,7 @@ quartodoc: contents: - add_text_img - fa_icon_repeat + - fmt_image_circle - gt_fa_rank_change - gt_fa_rating - img_header diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py index 5671f6e4..aa77a973 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, fmt_image_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", + "fmt_image_circle", "gt_add_divider", "gt_plt_summary", ] diff --git a/gt_extras/images.py b/gt_extras/images.py index 36d388b0..03aace78 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -1,10 +1,11 @@ from __future__ import annotations -from great_tables import html +from great_tables import GT, html from great_tables._helpers import px +from great_tables._tbl_data import SelectExpr from great_tables._text import Html -__all__ = ["add_text_img", "img_header"] +__all__ = ["add_text_img", "img_header", "fmt_image_circle"] def img_header( @@ -244,3 +245,18 @@ def add_text_img( """.strip() return combined_html + + +def fmt_image_circle( + gt: GT, + columns: SelectExpr, + color: str = "grey", + height: str | int | None = None, + width: str | None = None, + border_radius: str | None = "50%", + border_width: int = 3, + border_color: str = "black", + border_style: str = "solid", +) -> GT: + res = gt + return res From d949c898db539530cc63451f45577301d3a6f531 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:17:26 -0400 Subject: [PATCH 02/10] rename to fmt_img_circle --- docs/_quarto.yml | 2 +- gt_extras/__init__.py | 4 ++-- gt_extras/images.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index cc7eb05b..c4b36adc 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -91,7 +91,7 @@ quartodoc: contents: - add_text_img - fa_icon_repeat - - fmt_image_circle + - fmt_img_circle - gt_fa_rank_change - gt_fa_rating - img_header diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py index aa77a973..2f233602 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, fmt_image_circle, img_header +from .images import add_text_img, fmt_img_circle, img_header from .plotting import ( gt_plt_bar, gt_plt_bar_pct, @@ -68,7 +68,7 @@ "gt_two_column_layout", "add_text_img", "img_header", - "fmt_image_circle", + "fmt_img_circle", "gt_add_divider", "gt_plt_summary", ] diff --git a/gt_extras/images.py b/gt_extras/images.py index 03aace78..6a8d541a 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -5,7 +5,7 @@ from great_tables._tbl_data import SelectExpr from great_tables._text import Html -__all__ = ["add_text_img", "img_header", "fmt_image_circle"] +__all__ = ["add_text_img", "img_header", "fmt_img_circle"] def img_header( @@ -247,7 +247,7 @@ def add_text_img( return combined_html -def fmt_image_circle( +def fmt_img_circle( gt: GT, columns: SelectExpr, color: str = "grey", From 366436b3f593259d858e962426877f7bc79f32f7 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:41:40 -0400 Subject: [PATCH 03/10] add jerry's code --- gt_extras/images.py | 350 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 342 insertions(+), 8 deletions(-) diff --git a/gt_extras/images.py b/gt_extras/images.py index 6a8d541a..b1c4151b 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -1,9 +1,16 @@ from __future__ import annotations +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, ClassVar + 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 SelectExpr +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", "fmt_img_circle"] @@ -249,14 +256,341 @@ def add_text_img( def fmt_img_circle( gt: GT, - columns: SelectExpr, - color: str = "grey", + 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: int = 3, - border_color: str = "black", - border_style: str = "solid", + 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: - res = gt - return res + """Format image paths to generate circular images within table cells. + `fmt_img_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_img_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 + -------- + ```{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( + gt, + 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( + gt: GT, + 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, +) -> GT: + # 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] + + if any(isinstance(x, PlExpr) for x in expr_cols): + raise NotImplementedError( + "fmt_image 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 + + # 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." + ) + 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) + + 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 warnings import warn + + from ._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'' From f7f556f164811af8232c7729a6d02869a4549422 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:02:57 -0400 Subject: [PATCH 04/10] update fn name and example fns --- docs/_quarto.yml | 2 +- docs/examples/with-code.qmd | 24 ++++++++++++++++++++++++ gt_extras/__init__.py | 4 ++-- gt_extras/images.py | 19 ++++++++++--------- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index c4b36adc..c2eacd79 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -91,9 +91,9 @@ quartodoc: contents: - add_text_img - fa_icon_repeat - - fmt_img_circle - gt_fa_rank_change - gt_fa_rating + - gt_fmt_img_circle - img_header - title: Utilities diff --git a/docs/examples/with-code.qmd b/docs/examples/with-code.qmd index a3a4f5a1..8a6825ec 100644 --- a/docs/examples/with-code.qmd +++ b/docs/examples/with-code.qmd @@ -921,6 +921,30 @@ df = pd.DataFrame({ ``` {{< 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, vals, html +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 >}} + ### [gt_fa_rank_change](https://posit-dev.github.io/gt-extras/reference/gt_fa_rank_change.html){title="Click for reference"} ```{python} # | label: gt_fa_rank_change diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py index 2f233602..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, fmt_img_circle, 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,7 +68,7 @@ "gt_two_column_layout", "add_text_img", "img_header", - "fmt_img_circle", + "gt_fmt_img_circle", "gt_add_divider", "gt_plt_summary", ] diff --git a/gt_extras/images.py b/gt_extras/images.py index b1c4151b..f130b3ca 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -12,7 +12,7 @@ from great_tables._text import Html from great_tables._utils import is_valid_http_schema -__all__ = ["add_text_img", "img_header", "fmt_img_circle"] +__all__ = ["add_text_img", "img_header", "gt_fmt_img_circle"] def img_header( @@ -254,7 +254,8 @@ def add_text_img( return combined_html -def fmt_img_circle( +# 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, @@ -270,11 +271,11 @@ def fmt_img_circle( encode: bool = True, ) -> GT: """Format image paths to generate circular images within table cells. - `fmt_img_circle()` is a utility function similar to [`fmt_image()`](`great_tables.fmt_image`), + `gt_fmt_img_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_img_circle()`, **Great Tables** automatically sets `border_radius="50%"` to + When calling `gt_fmt_img_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 @@ -327,20 +328,20 @@ def fmt_img_circle( ```{python} import polars as pl from great_tables import GT, vals, html + import gt_extras as gte - 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" + jules_avatar = "https://avatars.githubusercontent.com/u/54960783?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]}) + df = pl.DataFrame({"@machow": [michael_avatar], "@rich-iannone": [rich_avatar], "@juleswg23": [jules_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) + .pipe(gte.gt_fmt_img_circle, height=80) ) ``` """ From 924354628934b4da4f7802332bc31aea27a204b3 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:06:40 -0400 Subject: [PATCH 05/10] re-order with-code. Add examples --- docs/examples/index.qmd | 5 ++++ docs/examples/with-code.qmd | 47 ++++++++++++++++++------------------- gt_extras/images.py | 2 +- 3 files changed, 29 insertions(+), 25 deletions(-) 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 8a6825ec..39bd1e2e 100644 --- a/docs/examples/with-code.qmd +++ b/docs/examples/with-code.qmd @@ -921,30 +921,6 @@ df = pd.DataFrame({ ``` {{< 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, vals, html -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 >}} - ### [gt_fa_rank_change](https://posit-dev.github.io/gt-extras/reference/gt_fa_rank_change.html){title="Click for reference"} ```{python} # | label: gt_fa_rank_change @@ -1002,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/images.py b/gt_extras/images.py index f130b3ca..e43db45c 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -327,7 +327,7 @@ def gt_fmt_img_circle( -------- ```{python} import polars as pl - from great_tables import GT, vals, html + from great_tables import GT import gt_extras as gte rich_avatar = "https://avatars.githubusercontent.com/u/5612024?v=4" From 69e6589fcc281190f5cfe93a97f94a27108277cf Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:06:43 -0400 Subject: [PATCH 06/10] refactor --- gt_extras/images.py | 54 ++++----------------------------------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/gt_extras/images.py b/gt_extras/images.py index e43db45c..f2f2072d 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -4,6 +4,7 @@ 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 @@ -366,45 +367,11 @@ def gt_fmt_img_circle( border_width, border_color, border_style = border_props.values() - return _fmt_image( - gt, - 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( - gt: GT, - 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, -) -> GT: - # 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] if any(isinstance(x, PlExpr) for x in expr_cols): raise NotImplementedError( - "fmt_image currently does not support polars expressions for arguments other than" + "gt_fmt_img_circle currently does not support polars expressions for arguments other than" " columns and rows" ) @@ -470,20 +437,9 @@ def to_html(self, val: Any): else: height = self.height - # 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." - ) - else: - width = self.width + 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 "%"' @@ -538,9 +494,7 @@ def to_html(self, val: Any): return span def to_latex(self, val: Any): - from warnings import warn - - from ._gt_data import FormatterSkipElement + from great_tables._gt_data import FormatterSkipElement warn("fmt_image() is not currently implemented in LaTeX output.") From 729bbcb14483b173e257aa52853a6ff81f03a3e6 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:07:00 -0400 Subject: [PATCH 07/10] tests! --- .../tests/__snapshots__/test_images.ambr | 12 + gt_extras/tests/test_images.py | 221 +++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/gt_extras/tests/__snapshots__/test_images.ambr b/gt_extras/tests/__snapshots__/test_images.ambr index c70ee354..6bbe0913 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 + ''' + + + + + + + + + ''' +# --- # name: test_img_header_snapshot Html(text='
\n \n
\n Test Label\n
\n
') # --- diff --git a/gt_extras/tests/test_images.py b/gt_extras/tests/test_images.py index c14c41db..9066658c 100644 --- a/gt_extras/tests/test_images.py +++ b/gt_extras/tests/test_images.py @@ -1,6 +1,14 @@ +from base64 import b64encode +from pathlib import Path + +import pandas as pd +import polars as pl +import pytest +from conftest import assert_rendered_body +from great_tables import GT from great_tables._text import Html -from gt_extras.images import add_text_img, img_header +from gt_extras.images import FmtImage, add_text_img, gt_fmt_img_circle, img_header def test_img_header_snapshot(snapshot): @@ -172,3 +180,214 @@ def test_add_text_img_empty_url(): assert isinstance(result, str) assert "Empty URL Test" in result assert "src=''" in result + + +def test_gt_fmt_img_circle_snapshot(snapshot): + df = pd.DataFrame({"img": ["avatar1.png", "avatar2.png"]}) + gt_test = GT(df) + + res = gt_fmt_img_circle(gt_test, columns="img", encode=False, border_width="2px") + assert_rendered_body(snapshot, gt=res) + + +def test_gt_fmt_img_circle_basic(): + df = pd.DataFrame({"img": ["test.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle(gt_test, columns="img", encode=False, border_width="3px") + html = result.as_raw_html() + + assert "border-radius: 50%;" in html + assert "border-width: 3px;" in html + assert "border-color: #0A0A0A;" in html + assert "border-style: solid;" in html + assert "test.png" in html + + +def test_gt_fmt_img_circle_custom_border(): + df = pd.DataFrame({"img": ["custom.jpg"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle( + gt_test, + columns="img", + encode=False, + border_width=5, + border_color="red", + border_style="dashed", + ) + html = result.as_raw_html() + + assert "border-width: 5px;" in html + assert "border-color: red;" in html + assert "border-style: dashed;" in html + assert "border-radius: 50%;" in html + + +def test_gt_fmt_img_circle_custom_size(): + df = pd.DataFrame({"img": ["size_test.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle( + gt_test, + columns="img", + encode=False, + height=80, + width="80px", + border_color="blue", + ) + html = result.as_raw_html() + + assert "height: 80px;" in html + assert "width: 80px;" in html + assert "border-color: blue;" in html + + +def test_gt_fmt_img_circle_multiple_images(): + df = pd.DataFrame({"img": ["img1.png,img2.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle( + gt_test, + columns="img", + sep=" | ", + encode=False, + border_width="2px", + ) + html = result.as_raw_html() + + assert "img1.png" in html + assert "img2.png" in html + assert " | " in html + assert html.count("border-radius: 50%;") == 2 + + +def test_gt_fmt_img_circle_no_border_defaults(): + """Test that without any border properties, no border styles are applied""" + df = pd.DataFrame({"img": ["no_border.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle(gt_test, columns="img", encode=False) + html = result.as_raw_html() + + assert "border-radius: 50%;" in html + assert "border-width:" not in html + assert "border-color:" not in html + + +def test_gt_fmt_img_circle_partial_border_props(): + """Test that providing only border_color applies default width and style""" + df = pd.DataFrame({"img": ["partial.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle( + gt_test, columns="img", encode=False, border_color="green" + ) + html = result.as_raw_html() + + assert "border-color: green;" in html + assert "border-width: 3px;" in html + assert "border-style: solid;" in html + + +def test_gt_fmt_img_circle_custom_border_radius(): + df = pd.DataFrame({"img": ["radius_test.png"]}) + gt_test = GT(df) + + result = gt_fmt_img_circle( + gt_test, + columns="img", + encode=False, + border_radius="25%", + ) + html = result.as_raw_html() + + assert "border-radius: 25%;" in html + + +def test_gt_fmt_img_circle_invalid_border_radius(): + df = pl.DataFrame({"img": ["test.png"], "height_col": ["80px"]}) + gt_test = GT(df) + + with pytest.raises(NotImplementedError): + gt_fmt_img_circle(gt_test, columns="img", border_radius="4").as_raw_html() + + +def test_gt_fmt_img_circle_polars_expr_error(): + df = pl.DataFrame({"img": ["test.png"], "height_col": ["80px"]}) + gt_test = GT(df) + + with pytest.raises(NotImplementedError): + gt_fmt_img_circle( + gt_test, + columns="img", + height=pl.col("height_col"), + border_width="2px", + ) + + +def test_gt_fmt_img_circle_as_latex(): + df = pl.DataFrame({"img": ["test.png"], "height_col": ["80px"]}) + gt_test = GT(df) + + with pytest.warns(): + gt_fmt_img_circle(gt_test, columns="img").as_latex() + + +def test_gt_fmt_img_circle_with_na_values(): + df = pd.DataFrame({"img": ["test.png", None, "another.jpg", pd.NA]}) + gt_test = GT(df) + + result = gt_fmt_img_circle(gt_test, columns="img", encode=False, border_width="2px") + html = result.as_raw_html() + + assert "test.png" in html + assert "another.jpg" in html + assert isinstance(result, GT) + + +def strip_windows_drive(x): + # this is a hacky approach to ensuring fmt_image path tests succeed + # on our windows build. On linux root is just "/". On windows its a + # drive name. Assumes our windows runner uses D:\ + return x.replace('src="D:\\', 'src="/') + + +@pytest.mark.parametrize( + "ext,mime_type", + [ + ("svg", "image/svg+xml"), + ("jpg", "image/jpeg"), + ("png", "image/png"), + ], +) +def test_fmt_image_encode_param(tmpdir, ext, mime_type): + content = "abc" + p_img = Path(tmpdir) / f"some.{ext}" + p_img.write_text(content) + + formatter = FmtImage(sep=" ", file_pattern=f"{{}}.{ext}", encode=True) + res = formatter.to_html(f"{tmpdir}/some") + + b64_content = b64encode(content.encode()).decode() + img_src = f"data:{mime_type};base64,{b64_content}" + dst = formatter.SPAN_TEMPLATE.format( + f'' + ) + + assert strip_windows_drive(res) == dst + + +@pytest.mark.parametrize( + "url", + ["http://posit.co/", "http://posit.co", "https://posit.co/", "https://posit.co"], +) +def test_fmt_image_path_http(url: str): + formatter = FmtImage(encode=False, height=30, path=url, border_radius="50%") + res = formatter.to_html("c") + dst_img = ''.format( + url.removesuffix("/") + ) + dst = formatter.SPAN_TEMPLATE.format(dst_img) + + assert strip_windows_drive(res) == dst From 91bbe8e1ab53907a9bfd313b89aa72400ceed05b Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:10:56 -0400 Subject: [PATCH 08/10] update snap test --- gt_extras/tests/__snapshots__/test_images.ambr | 4 ++-- gt_extras/tests/test_images.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gt_extras/tests/__snapshots__/test_images.ambr b/gt_extras/tests/__snapshots__/test_images.ambr index 6bbe0913..c02d5da6 100644 --- a/gt_extras/tests/__snapshots__/test_images.ambr +++ b/gt_extras/tests/__snapshots__/test_images.ambr @@ -16,10 +16,10 @@ ''' - + - + ''' diff --git a/gt_extras/tests/test_images.py b/gt_extras/tests/test_images.py index 9066658c..66a3575e 100644 --- a/gt_extras/tests/test_images.py +++ b/gt_extras/tests/test_images.py @@ -183,7 +183,7 @@ def test_add_text_img_empty_url(): def test_gt_fmt_img_circle_snapshot(snapshot): - df = pd.DataFrame({"img": ["avatar1.png", "avatar2.png"]}) + df = pd.DataFrame({"img": ["https://www.avatar1.png", "https://www.avatar2.png"]}) gt_test = GT(df) res = gt_fmt_img_circle(gt_test, columns="img", encode=False, border_width="2px") From c619b7079c8344de816f55c77c8e6eaddf84e1bb Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:13:14 -0400 Subject: [PATCH 09/10] update docstring --- gt_extras/images.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/gt_extras/images.py b/gt_extras/images.py index f2f2072d..8da1c864 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -272,11 +272,11 @@ def gt_fmt_img_circle( encode: bool = True, ) -> GT: """Format image paths to generate circular images within table cells. - `gt_fmt_img_circle()` is a utility function similar to [`fmt_image()`](`great_tables.fmt_image`), + `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()`, **Great Tables** automatically sets `border_radius="50%"` to + 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 @@ -287,33 +287,44 @@ def gt_fmt_img_circle( 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). + 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`. @@ -321,7 +332,7 @@ def gt_fmt_img_circle( Returns ------- GT - The GT object is returned. This is the same object that the method is called on so that we + The `GT` object is returned. This is the same object that the method is called on so that we can facilitate method chaining. Examples From b50b3f824703f78148b6bef2485672e05315da21 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:27:34 -0400 Subject: [PATCH 10/10] edit example formatting --- gt_extras/images.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gt_extras/images.py b/gt_extras/images.py index 8da1c864..c8877307 100644 --- a/gt_extras/images.py +++ b/gt_extras/images.py @@ -347,7 +347,11 @@ def gt_fmt_img_circle( jules_avatar = "https://avatars.githubusercontent.com/u/54960783?v=4" - df = pl.DataFrame({"@machow": [michael_avatar], "@rich-iannone": [rich_avatar], "@juleswg23": [jules_avatar]}) + df = pl.DataFrame({ + "@machow": [michael_avatar], + "@rich-iannone": [rich_avatar], + "@juleswg23": [jules_avatar] + }) ( GT(df)