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 + ''' + + + + + + + + + ''' +# --- # 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..66a3575e 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": ["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") + 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