diff --git a/docs/_quarto.yml b/docs/_quarto.yml index f21c42f58..d4aac43f4 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -117,6 +117,7 @@ quartodoc: - GT.tab_spanner_delim - GT.tab_stub - GT.tab_stubhead + - GT.tab_footnote - GT.tab_source_note - GT.tab_style - GT.tab_options @@ -219,6 +220,7 @@ quartodoc: - GT.opt_table_outline - GT.opt_table_font - GT.opt_stylize + - GT.opt_footnote_marks - title: Export desc: > There may come a day when you need to export a table to some specific format. A great method diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py index 4dfa4784e..09153156f 100644 --- a/great_tables/_footnotes.py +++ b/great_tables/_footnotes.py @@ -1,4 +1,145 @@ from __future__ import annotations +from typing import TYPE_CHECKING -# TODO: create the `tab_footnote()` function +from ._locations import Loc, PlacementOptions, set_footnote +from ._text import Text + +if TYPE_CHECKING: + from ._types import GTSelf + + +def tab_footnote( + self: GTSelf, + footnote: str | Text, + locations: Loc | None | list[Loc | None] = None, + placement: PlacementOptions = "auto", +) -> GTSelf: + """ + Add a table footnote. + + `tab_footnote()` can make it a painless process to add a footnote to a table. There are commonly + two components to a footnote: (1) a footnote mark that is attached to the targeted cell content, + and (2) the footnote text itself that is placed in the table's footer area. Each unit of + footnote text in the footer is linked to an element of text or otherwise through the footnote + mark. + + The footnote system in **Great Tables** presents footnotes in a way that matches the usual + expectations, where: + + 1. footnote marks have a sequence, whether they are symbols, numbers, or letters + 2. multiple footnotes can be applied to the same content (and marks are always presented in an + ordered fashion) + 3. footnote text in the footer is never exactly repeated, **Great Tables** reuses footnote marks + where needed throughout the table + 4. footnote marks are ordered across the table in a consistent manner (left to right, top to + bottom) + + Each call of `tab_footnote()` will either add a different footnote to the footer or reuse + existing footnote text therein. One or more cells outside of the footer are targeted using + location classes from the `loc` module (e.g., `loc.body()`, `loc.column_labels()`, etc.). You + can choose to *not* attach a footnote mark by simply not specifying anything in the `locations` + argument. + + By default, **Great Tables** will choose which side of the text to place the footnote mark via + the `placement="auto"` option. You are, however, always free to choose the placement of the + footnote mark (either to the `"left"` or `"right"` of the targeted cell content). + + Parameters + ---------- + footnote + The text to be used in the footnote. We can optionally use [`md()`](`great_tables.md`) or + [`html()`](`great_tables.html`) to style the text as Markdown or to retain HTML elements in + the footnote text. + locations + The cell or set of cells to be associated with the footnote. Supplying any of the location + classes from the `loc` module is a useful way to target the location cells that are + associated with the footnote text. These location classes are: `loc.title`, `loc.stubhead`, + `loc.spanner_labels`, `loc.column_labels`, `loc.row_groups`, `loc.stub`, `loc.body`, etc. + Additionally, we can enclose several location calls within a `list()` if we wish to link the + footnote text to different types of locations (e.g., body cells, row group labels, the table + title, etc.). + placement + Where to affix footnote marks to the table content. Two options for this are `"left"` or + `"right"`, where the placement is either to the absolute left or right of the cell content. + By default, however, this option is set to `"auto"` whereby **Great Tables** will choose a + preferred left-or-right placement depending on the alignment of the cell content. + + 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 table will be based on the `towny` dataset. We have a header part, with a title and + a subtitle. We can choose which of these could be associated with a footnote and in this case it + is the `"subtitle"`. This table has a stub with row labels and some of those labels are + associated with a footnote. So long as row labels are unique, they can be easily used as row + identifiers in `loc.stub()`. The third footnote is placed on the `"Density"` column label. Here, + changing the order of the `tab_footnote()` calls has no effect on the final table rendering. + + ```{python} + import polars as pl + from great_tables import GT, loc, md + from great_tables.data import towny + + towny_mini = ( + pl.from_pandas(towny) + .filter(pl.col("csd_type") == "city") + .select(["name", "density_2021", "population_2021"]) + .top_k(10, by="population_2021") + .sort("population_2021", descending=True) + ) + + ( + GT(towny_mini, rowname_col="name") + .tab_header( + title=md("The 10 Largest Municipalities in `towny`"), + subtitle="Population values taken from the 2021 census." + ) + .fmt_integer() + .cols_label( + density_2021="Density", + population_2021="Population" + ) + .tab_footnote( + footnote="Part of the Greater Toronto Area.", + locations=loc.stub(rows=[ + "Toronto", "Mississauga", "Brampton", "Markham", "Vaughan" + ]) + ) + .tab_footnote( + footnote=md("Density is in terms of persons per {{km^2}}."), + locations=loc.column_labels(columns="density_2021") + ) + .tab_footnote( + footnote="Census results made public on February 9, 2022.", + locations=loc.subtitle() + ) + .tab_source_note( + source_note=md("Data taken from the `towny` dataset.") + ) + .opt_footnote_marks(marks="letters") + ) + ``` + """ + + # Store footnote as-is to preserve Text objects for later processing + footnote_str = footnote + + # Handle None locations (footnote without mark) + if locations is None: + return set_footnote(None, self, footnote_str, placement) # type: ignore + + # Ensure locations is a list + if not isinstance(locations, list): + locations = [locations] + + # Apply footnote to each location + result = self + for loc in locations: + result = set_footnote(loc, result, footnote_str, placement) # type: ignore + + return result # type: ignore diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py index a5603f523..f1e20fb26 100644 --- a/great_tables/_gt_data.py +++ b/great_tables/_gt_data.py @@ -875,10 +875,10 @@ class FootnotePlacement(Enum): @dataclass(frozen=True) class FootnoteInfo: - locname: Loc | None = None + locname: str | None = None grpname: str | None = None colname: str | None = None - locnum: int | None = None + locnum: int | float | None = None rownum: int | None = None colnum: int | None = None footnotes: list[str] | None = None @@ -1160,7 +1160,7 @@ class Options: # footnotes_border_lr_style: OptionsInfo = OptionsInfo(True, "footnotes", "value", "none") # footnotes_border_lr_width: OptionsInfo = OptionsInfo(True, "footnotes", "px", "2px") # footnotes_border_lr_color: OptionsInfo = OptionsInfo(True, "footnotes", "value", "#D3D3D3") - # footnotes_marks: OptionsInfo = OptionsInfo(False, "footnotes", "values", "numbers") + footnotes_marks: OptionsInfo = OptionsInfo(False, "footnotes", "values", "numbers") # footnotes_multiline: OptionsInfo = OptionsInfo(False, "footnotes", "boolean", True) # footnotes_sep: OptionsInfo = OptionsInfo(False, "footnotes", "value", " ") source_notes_padding: OptionsInfo = OptionsInfo(True, "source_notes", "px", "4px") diff --git a/great_tables/_locations.py b/great_tables/_locations.py index 246966303..02280d87f 100644 --- a/great_tables/_locations.py +++ b/great_tables/_locations.py @@ -1088,4 +1088,144 @@ def _(loc: None, data: GTData, footnote: str, placement: PlacementOptions) -> GT @set_footnote.register def _(loc: LocTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: - raise NotImplementedError() + place = FootnotePlacement[placement] + info = FootnoteInfo(locname="title", footnotes=[footnote], placement=place, locnum=1) + return data._replace(_footnotes=data._footnotes + [info]) + + +@set_footnote.register +def _(loc: LocSubTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + info = FootnoteInfo(locname="subtitle", footnotes=[footnote], placement=place, locnum=2) + return data._replace(_footnotes=data._footnotes + [info]) + + +@set_footnote.register +def _(loc: LocStubhead, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + info = FootnoteInfo(locname="stubhead", footnotes=[footnote], placement=place, locnum=2.5) + return data._replace(_footnotes=data._footnotes + [info]) + + +@set_footnote.register +def _(loc: LocColumnLabels, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Resolve which columns to target - returns list[tuple[str, int]] + name_pos_list = resolve(loc, data) + + result = data + for name, pos in name_pos_list: + info = FootnoteInfo( + locname="columns_columns", colname=name, footnotes=[footnote], placement=place, locnum=4 + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result + + +@set_footnote.register +def _(loc: LocSpannerLabels, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Get spanners from data + spanners = data._spanners if hasattr(data, "_spanners") else [] + + # Resolve which spanners to target + resolved_loc = resolve(loc, spanners) + + result = data + for spanner_id in resolved_loc.ids: + info = FootnoteInfo( + locname="columns_groups", + grpname=spanner_id, + footnotes=[footnote], + placement=place, + locnum=3, + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result + + +@set_footnote.register +def _(loc: LocRowGroups, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Resolve which row groups to target - returns set[str] + group_names = resolve(loc, data) + + result = data + for group_name in group_names: + info = FootnoteInfo( + locname="row_groups", + grpname=group_name, + footnotes=[footnote], + placement=place, + locnum=5, + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result + + +@set_footnote.register +def _(loc: LocStub, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Resolve which stub rows to target - returns set[int] + row_positions = resolve(loc, data) + + result = data + for row_pos in row_positions: + info = FootnoteInfo( + locname="stub", rownum=row_pos, footnotes=[footnote], placement=place, locnum=5 + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result + + +@set_footnote.register +def _(loc: LocBody, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Resolve which body cells to target + positions = resolve(loc, data) + + result = data + for pos in positions: + info = FootnoteInfo( + locname="data", + colname=pos.colname, + rownum=pos.row, + footnotes=[footnote], + placement=place, + locnum=5, + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result + + +@set_footnote.register +def _(loc: LocSummary, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: + place = FootnotePlacement[placement] + + # Resolve which summary cells to target + positions = resolve(loc, data) + + result = data + for pos in positions: + info = FootnoteInfo( + locname="summary_cells", + grpname=getattr(pos, "group_id", None), + colname=pos.colname, + rownum=pos.row, + footnotes=[footnote], + placement=place, + locnum=5.5, + ) + result = result._replace(_footnotes=result._footnotes + [info]) + + return result diff --git a/great_tables/_options.py b/great_tables/_options.py index f4ab0fc8a..73a10f47c 100644 --- a/great_tables/_options.py +++ b/great_tables/_options.py @@ -145,7 +145,7 @@ def tab_options( # footnotes_border_lr_style: str | None = None, # footnotes_border_lr_width: str | None = None, # footnotes_border_lr_color: str | None = None, - # footnotes_marks: str | list[str] | None = None, + footnotes_marks: str | list[str] | None = None, # footnotes_multiline: bool | None = None, # footnotes_sep: str | None = None, source_notes_background_color: str | None = None, @@ -563,7 +563,8 @@ def tab_options( def opt_footnote_marks(self: GTSelf, marks: str | list[str] = "numbers") -> GTSelf: """ - Option to modify the set of footnote marks + Option to modify the set of footnote marks. + Alter the footnote marks for any footnotes that may be present in the table. Either a list of marks can be provided (including Unicode characters), or, a specific keyword could be used to signify a preset sequence. This method serves as a shortcut for using diff --git a/great_tables/_tab_create_modify.py b/great_tables/_tab_create_modify.py index 61c9e2a28..1dafa1970 100644 --- a/great_tables/_tab_create_modify.py +++ b/great_tables/_tab_create_modify.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from ._helpers import GoogleFont -from ._locations import Loc, PlacementOptions, set_footnote, set_style +from ._locations import Loc, set_style from ._styles import CellStyle if TYPE_CHECKING: @@ -146,40 +146,3 @@ def tab_style( new_data = set_style(loc, new_data, style) return new_data - - -# TODO: note that this function does not yet render, and rendering -# will likely be implemented down the road (e.g. after basic styling). -# this is just all the machinery to set data in GT._footnotes -def tab_footnote( - self: GTSelf, - footnote: str | list[str], - locations: Loc | None | list[Loc | None], - placement: PlacementOptions = "auto", -) -> GTSelf: - """Add a footnote to a table - - Parameters - ---------- - footnote - The footnote text. - locations - The location to place the footnote. If None, then a footnote is created without - a corresponding marker on the table (TODO: double check this). - placement - Where to affix the footnote marks to the table content. - - """ - - if isinstance(footnote, list): - raise NotImplementedError("Currently, only a single string is supported for footnote.") - - if not isinstance(locations, list): - locations = [locations] - - new_data = self - if isinstance(locations, list): - for loc in locations: - new_data = set_footnote(loc, self, footnote, placement) - - return new_data diff --git a/great_tables/_text.py b/great_tables/_text.py index cd895ec70..bde866b58 100644 --- a/great_tables/_text.py +++ b/great_tables/_text.py @@ -45,6 +45,11 @@ class Html(Text): """HTML text""" def to_html(self) -> str: + if "{{" in self.text and "}}" in self.text: + from great_tables._helpers import UnitStr + + unit_str = UnitStr.from_str(self.text) + return unit_str.to_html() return self.text def to_latex(self) -> str: @@ -58,8 +63,18 @@ def to_latex(self) -> str: def _md_html(x: str) -> str: - str = commonmark.commonmark(x) - return re.sub(r"^

|

\n$", "", str) + if "{{" in x and "}}" in x: + from great_tables._helpers import UnitStr + + unit_str = UnitStr.from_str(x) + processed_text = unit_str.to_html() + else: + processed_text = x + + str_result = commonmark.commonmark(processed_text) + if str_result is None: + return processed_text + return re.sub(r"^

|

\n$", "", str_result) def _md_latex(x: str) -> str: diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 3672d9464..06f6f9229 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -6,12 +6,29 @@ from htmltools import HTML, TagList, css, tags from . import _locations as loc -from ._gt_data import GroupRowInfo, GTData, Styles +from ._gt_data import FootnoteInfo, FootnotePlacement, GroupRowInfo, GTData, Styles from ._spanners import spanners_print_matrix from ._tbl_data import _get_cell, cast_frame_to_string, replace_null_frame from ._text import BaseText, _process_text, _process_text_id from ._utils import heading_has_subtitle, heading_has_title, seq_groups +# Visual hierarchy mapping for footnote location ordering +FOOTNOTE_LOCATION_HIERARCHY = { + "title": 1, + "subtitle": 2, + "stubhead": 3, + "columns_groups": 4, + "columns_columns": 5, + "data": 6, + "stub": 6, # Same as data since stub and data cells are on the same row level +} + + +def _get_locnum_for_footnote_location(locname: str | None) -> int: + if locname is None: + return 999 + return FOOTNOTE_LOCATION_HIERARCHY.get(locname, 999) # Default to 999 for unknown locations + def _is_loc(loc: str | loc.Loc, cls: type[loc.Loc]): if isinstance(loc, str): @@ -67,6 +84,12 @@ def create_heading_component_h(data: GTData) -> str: title = _process_text(title) subtitle = _process_text(subtitle) + # Add footnote marks to title and subtitle if applicable + if has_title: + title = _add_footnote_marks_to_text(data, title, "title") + if has_subtitle: + subtitle = _add_footnote_marks_to_text(data, subtitle, "subtitle") + # Filter list of StyleInfo for the various header components styles_header = [x for x in data._styles if _is_loc(x.locname, loc.LocHeader)] styles_title = [x for x in data._styles if _is_loc(x.locname, loc.LocTitle)] @@ -168,7 +191,11 @@ def create_columns_component_h(data: GTData) -> str: if stub_layout: table_col_headings.append( tags.th( - HTML(_process_text(stub_label)), + HTML( + _add_footnote_marks_to_text( + data, _process_text(stub_label), locname="stubhead" + ) + ), class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}", rowspan="1", colspan=len(stub_layout), @@ -183,9 +210,14 @@ def create_columns_component_h(data: GTData) -> str: # Filter by column label / id, join with overall column labels style styles_i = [x for x in styles_column_label if x.colname == info.var] + # Add footnote marks to column label if any + column_label_with_footnotes = _add_footnote_marks_to_text( + data, _process_text(info.column_label), "columns_columns", colname=info.var + ) + table_col_headings.append( tags.th( - HTML(_process_text(info.column_label)), + HTML(column_label_with_footnotes), class_=f"gt_col_heading gt_columns_bottom_border gt_{info.defaulted_align}", rowspan=1, colspan=1, @@ -226,7 +258,11 @@ def create_columns_component_h(data: GTData) -> str: if stub_layout: level_1_spanners.append( tags.th( - HTML(_process_text(stub_label)), + HTML( + _add_footnote_marks_to_text( + data, _process_text(stub_label), locname="stubhead" + ) + ), class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}", rowspan=2, colspan=len(stub_layout), @@ -234,9 +270,7 @@ def create_columns_component_h(data: GTData) -> str: scope="colgroup" if len(stub_layout) > 1 else "col", id=_create_element_id(table_id, stub_label), ) - ) - - # NOTE: Run-length encoding treats missing values as distinct from each other; in other + ) # NOTE: Run-length encoding treats missing values as distinct from each other; in other # words, each missing value starts a new run of length 1 spanner_ids_level_1 = spanner_ids[level_1_index] @@ -257,10 +291,15 @@ def create_columns_component_h(data: GTData) -> str: # Get the alignment values for the first set of column labels first_set_alignment = h_info.defaulted_align + # Add footnote marks to column label if any + column_label_with_footnotes = _add_footnote_marks_to_text( + data, _process_text(h_info.column_label), "columns_columns", colname=h_info.var + ) + # Creation of tags for column labels with no spanners above them level_1_spanners.append( tags.th( - HTML(_process_text(h_info.column_label)), + HTML(column_label_with_footnotes), class_=f"gt_col_heading gt_columns_bottom_border gt_{first_set_alignment}", rowspan=2, colspan=1, @@ -285,7 +324,14 @@ def create_columns_component_h(data: GTData) -> str: level_1_spanners.append( tags.th( tags.span( - HTML(_process_text(spanner_ids_level_1_index[ii])), + HTML( + _add_footnote_marks_to_text( + data, + _process_text(spanner_ids_level_1_index[ii]), + locname="columns_groups", + grpname=spanner_ids_level_1_index[ii], + ) + ), class_="gt_column_spanner", ), class_="gt_center gt_columns_top_border gt_column_spanner_outer", @@ -323,9 +369,17 @@ def create_columns_component_h(data: GTData) -> str: var=remaining_heading ) + # Add footnote marks to column label if any + remaining_headings_label_with_footnotes = _add_footnote_marks_to_text( + data, + _process_text(remaining_headings_label), + "columns_columns", + colname=remaining_heading, + ) + spanned_column_labels.append( tags.th( - HTML(_process_text(remaining_headings_label)), + HTML(remaining_headings_label_with_footnotes), class_=f"gt_col_heading gt_columns_bottom_border gt_{remaining_alignment}", rowspan=1, colspan=1, @@ -367,7 +421,14 @@ def create_columns_component_h(data: GTData) -> str: if span_label: span = tags.span( - HTML(_process_text(span_label)), + HTML( + _add_footnote_marks_to_text( + data, + _process_text(span_label), + locname="columns_groups", + grpname=span_label, + ) + ), class_="gt_column_spanner", ) else: @@ -501,11 +562,23 @@ def create_body_component_h(data: GTData) -> str: cell_str: str = str(cell_content) # Determine whether the current cell is the stub cell - if has_stub_column: + if has_stub_column and stub_var is not None: is_stub_cell = colinfo.var == stub_var.var else: is_stub_cell = False + # Add footnote marks to cell content if applicable + # Use different locname for stub vs data cells + if is_stub_cell: + # For stub cells, don't pass colname since stub footnotes are stored with colname=None + cell_str = _add_footnote_marks_to_text( + data, cell_str, "stub", colname=None, rownum=i + ) + else: + cell_str = _add_footnote_marks_to_text( + data, cell_str, "data", colname=colinfo.var, rownum=i + ) + # Get alignment for the current column from the `col_alignment` list # by using the `name` value to obtain the index of the alignment value cell_alignment = colinfo.defaulted_align @@ -629,11 +702,448 @@ def create_source_notes_component_h(data: GTData) -> str: return source_notes_component -def create_footnotes_component_h(data: GTData): - # Filter list of StyleInfo to only those that apply to the footnotes - styles_footnotes = [x for x in data._styles if _is_loc(x.locname, loc.LocFootnotes)] +def create_footer_component_h(data: GTData) -> str: + source_notes = data._source_notes + footnotes = data._footnotes + + # Get the effective number of columns for colspan + n_cols_total = data._boxhead._get_effective_number_of_columns( + stub=data._stub, options=data._options + ) + + footer_rows = [] + + # Add source notes if they exist + if source_notes: + # Filter list of StyleInfo to only those that apply to the source notes + styles_footer = [x for x in data._styles if _is_loc(x.locname, loc.LocFooter)] + styles_source_notes = [x for x in data._styles if _is_loc(x.locname, loc.LocSourceNotes)] + + # Obtain the `multiline` and `separator` options from `_options` + multiline = data._options.source_notes_multiline.value + separator = cast(str, data._options.source_notes_sep.value) + + if multiline: + # Each source note gets its own row with gt_sourcenotes class on the tr + _styles = _flatten_styles(styles_footer + styles_source_notes, wrap=True) + for note in source_notes: + note_str = _process_text(note) + footer_rows.append( + f'{note_str}' + ) + else: + # All source notes in a single row with gt_sourcenotes class on the tr + source_note_list = [] + for note in source_notes: + note_str = _process_text(note) + source_note_list.append(note_str) + + source_notes_str_joined = separator.join(source_note_list) + footer_rows.append( + f'{source_notes_str_joined}' + ) + + # Add footnotes if they exist + if footnotes: + # Process footnotes and assign marks + footnotes_with_marks = _process_footnotes_for_display(data, footnotes) + + if footnotes_with_marks: + # Each footnote gets its own row + for footnote_data in footnotes_with_marks: + mark = footnote_data.get("mark", "") + text = footnote_data.get("text", "") + + footnote_mark_html = _create_footnote_mark_html(mark, location="ftr") + + # Wrap footnote text in `gt_from_md` span if it contains HTML markup + if "<" in text and ">" in text: + footnote_text = f'{text}' + else: + footnote_text = text + + footnote_html = f"{footnote_mark_html} {footnote_text}" + footer_rows.append( + f'{footnote_html}' + ) + + # If no footer content, return empty string + if not footer_rows: + return "" + + return f'{"".join(footer_rows)}' + + +def _should_display_footnote(data: GTData, footnote: FootnoteInfo) -> bool: + # If footnote targets a specific column, check if it's hidden + if footnote.colname is not None: + # Get column info from boxhead to check if it's hidden + for col_info in data._boxhead._d: + if col_info.var == footnote.colname: + return col_info.visible + # If column not found in boxhead, assume it should be displayed + return True + + # For footnotes that don't target specific columns (e.g., title, subtitle), always display + return True + + +def _process_footnotes_for_display( + data: GTData, footnotes: list[FootnoteInfo] +) -> list[dict[str, str]]: + if not footnotes: + return [] + + # Filter out footnotes for hidden columns + visible_footnotes = [f for f in footnotes if _should_display_footnote(data, f)] + + # Sort footnotes by visual order (same logic as in _get_footnote_mark_string); + # this ensures footnotes appear in the footnotes section in the same order as their + # marks in the table + footnote_positions: list[tuple[tuple[int, int, int], FootnoteInfo]] = [] + + for fn_info in visible_footnotes: + if fn_info.locname == "none": + continue + + # Assign locnum based on visual hierarchy + locnum = _get_locnum_for_footnote_location(fn_info.locname) + + # Assign column number, with stub getting a lower value than data columns + if fn_info.locname == "stub": + colnum = -1 # Stub appears before all data columns + else: + colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0 + rownum = ( + 0 + if fn_info.locname == "columns_columns" + else (fn_info.rownum if fn_info.rownum is not None else 0) + ) + + sort_key = (locnum, rownum, colnum) + footnote_positions.append((sort_key, fn_info)) + + # Sort by visual order + footnote_positions.sort(key=lambda x: x[0]) + sorted_footnotes = [fn_info for _, fn_info in footnote_positions] + + # Group footnotes by their text to avoid duplicates and get their marks + footnote_data: dict[str, str] = {} # text -> mark_string + footnote_order: list[str] = [] + + for footnote in sorted_footnotes: + if footnote.footnotes: + raw_text = footnote.footnotes[0] if footnote.footnotes else "" + processed_text = _process_text(raw_text) # Process to get comparable string + if processed_text not in footnote_data: + mark_string = _get_footnote_mark_string(data, footnote) + footnote_data[processed_text] = mark_string + footnote_order.append(processed_text) + + # Add footnotes without marks at the beginning (also filter for visibility) + markless_footnotes = [f for f in visible_footnotes if f.locname == "none"] # type: ignore + result: list[dict[str, str]] = [] + + # Add markless footnotes first + for footnote in markless_footnotes: + if footnote.footnotes: + processed_text = _process_text(footnote.footnotes[0]) + result.append({"mark": "", "text": processed_text}) + + # Add footnotes with marks and maintain visual order (order they appear in table); + # the footnote_order list already contains footnotes in visual order based on how + # _get_footnote_mark_string assigns marks (top-to-bottom, left-to-right) + mark_type = _get_footnote_marks_option(data) + if isinstance(mark_type, str) and mark_type == "numbers": + # For numbers, sort by numeric mark value to handle any edge cases + sorted_texts = sorted( + footnote_order, + key=lambda text: int(footnote_data[text]) + if footnote_data[text].isdigit() + else float("inf"), + ) + else: + # For letters/symbols, maintain visual order (don't sort alphabetically) + sorted_texts = footnote_order + + for text in sorted_texts: + mark_string = footnote_data[text] + result.append({"mark": mark_string, "text": text}) + + return result + + +def _get_footnote_mark_symbols() -> dict[str, list[str]]: + from ._helpers import LETTERS, letters + + return { + "numbers": [], + "letters": letters(), + "LETTERS": LETTERS(), + "standard": ["*", "†", "‡", "§"], + "extended": ["*", "†", "‡", "§", "‖", "¶"], + } + + +def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbers") -> str: + if isinstance(mark_type, str): + if mark_type == "numbers": + return str(mark_index) + + symbol_sets = _get_footnote_mark_symbols() + if mark_type in symbol_sets: + symbols = symbol_sets[mark_type] + else: + # Default to numbers if unknown type + return str(mark_index) + elif isinstance(mark_type, list): + symbols = mark_type + else: + # Default to numbers + return str(mark_index) + + if not symbols: + return str(mark_index) + + # Calculate symbol and repetition for cycling behavior; + # e.g., for 4 symbols: index 1-4 -> symbol once, 5-8 -> symbol twice, etc. + symbol_index = (mark_index - 1) % len(symbols) + repetitions = (mark_index - 1) // len(symbols) + 1 + + return symbols[symbol_index] * repetitions + + +def _get_footnote_marks_option(data: GTData) -> str | list[str]: + # Read from the options system + if hasattr(data, "_options") and hasattr(data._options, "footnotes_marks"): + marks_value = data._options.footnotes_marks.value + if marks_value is not None: + return marks_value + + # Default to numbers + return "numbers" + + +def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: + if not mark: + return "" + + # Use consistent span structure for both references and footer + return f'{mark}' + + +def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: + if not data._footnotes or not footnote_info.footnotes: + mark_type = _get_footnote_marks_option(data) + return _generate_footnote_mark(1, mark_type) + + # Create a list of all footnote positions with their text, following R gt approach + footnote_positions: list[tuple[tuple[int, int, int], str]] = [] + + for fn_info in data._footnotes: + if not fn_info.footnotes or fn_info.locname == "none": + continue + + # Skip footnotes for hidden columns + if not _should_display_footnote(data, fn_info): + continue + + footnote_text = _process_text(fn_info.footnotes[0]) + + # Assign locnum (location number) based on the location hierarchy where + # lower numbers appear first in reading order + locnum = _get_locnum_for_footnote_location(fn_info.locname) + + # Get colnum (column number) and assign stub a lower value than data columns + if fn_info.locname == "stub": + colnum = -1 # Stub appears before all data columns + elif fn_info.locname == "columns_groups": + # For spanners, use the leftmost column index to ensure left-to-right ordering + colnum = _get_spanner_leftmost_column_index(data, fn_info.grpname) + else: + colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0 + + # Get rownum; for headers use 0, for body use actual row number + if fn_info.locname == "columns_columns": + rownum = 0 # Headers are row 0 + else: + rownum = fn_info.rownum if fn_info.rownum is not None else 0 + + # Sort key: (locnum, rownum, colnum); this should match reading order + # of top-to-bottom, left-to-right + sort_key = (locnum, rownum, colnum) + footnote_positions.append((sort_key, footnote_text)) + + # Sort by (locnum, rownum, colnum): headers before body + footnote_positions.sort(key=lambda x: x[0]) + + # Get unique footnote texts in sorted order + unique_footnotes: list[str] = [] + for _, text in footnote_positions: + if text not in unique_footnotes: + unique_footnotes.append(text) + + # Find the mark index for this footnote's text + if footnote_info.footnotes: + footnote_text = _process_text(footnote_info.footnotes[0]) + try: + mark_index = unique_footnotes.index(footnote_text) + 1 # Use 1-based indexing + mark_type = _get_footnote_marks_option(data) + return _generate_footnote_mark(mark_index, mark_type) + except ValueError: + mark_type = _get_footnote_marks_option(data) + return _generate_footnote_mark(1, mark_type) + + mark_type = _get_footnote_marks_option(data) + return _generate_footnote_mark(1, mark_type) + + +def _get_column_index(data: GTData, colname: str | None) -> int: + if not colname: + return 0 + + # Get the column order from boxhead + columns = data._boxhead._get_default_columns() + for i, col_info in enumerate(columns): + if col_info.var == colname: + return i + + return 0 + + +def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None) -> int: + if not spanner_grpname: + return 0 + + # Find the spanner with this group name + for spanner in data._spanners: + if spanner.spanner_label == spanner_grpname: + # Get the column indices for all columns in this spanner + column_indices = [] + for col_var in spanner.vars: + col_index = _get_column_index(data, col_var) + column_indices.append(col_index) + + # Return the minimum (leftmost) column index + return min(column_indices) if column_indices else 0 + + return 0 + + +def _add_footnote_marks_to_text( + data: GTData, + text: str, + locname: str, + colname: str | None = None, + rownum: int | None = None, + grpname: str | None = None, +) -> str: + if not data._footnotes: + return text + + # Find footnotes that match this location + matching_footnotes: list[tuple[str, FootnoteInfo]] = [] + for footnote in data._footnotes: + if footnote.locname == locname: + # Check if this footnote targets this specific location + match = True + + if colname is not None and footnote.colname != colname: + match = False + if rownum is not None and footnote.rownum != rownum: + match = False + if grpname is not None and footnote.grpname != grpname: + match = False + + if match: + mark_string = _get_footnote_mark_string(data, footnote) + matching_footnotes.append((mark_string, footnote)) + + if not matching_footnotes: + return text + + # Collect unique mark strings and sort them properly + mark_strings: list[str] = [] + footnote_placements: list[FootnoteInfo] = [] + for mark_string, footnote in matching_footnotes: + if mark_string not in mark_strings: + mark_strings.append(mark_string) + footnote_placements.append(footnote) + + # Sort marks: for numbers, sort numerically; for symbols, sort by their order in symbol set + mark_type = _get_footnote_marks_option(data) + if isinstance(mark_type, str) and mark_type == "numbers": + # Sort numerically for numbers + mark_strings.sort(key=lambda x: int(x) if x.isdigit() else float("inf")) + else: + # For symbols, maintain the order they appear (which should already be correct) + # since `_get_footnote_mark_string()` returns them in visual order + pass + + # Create a single footnote mark span with comma-separated marks + if mark_strings: + # Join mark strings with commas (no spaces) + marks_text = ",".join(mark_strings) + marks_html = f'{marks_text}' + + # Determine placement based on the first footnote's placement setting + # (all footnotes for the same location should have the same placement) + placement = footnote_placements[0].placement if footnote_placements else None + + # Apply placement logic + return _apply_footnote_placement(text, marks_html, placement) + + return text + + +def _apply_footnote_placement( + text: str, marks_html: str, placement: FootnotePlacement | None +) -> str: + # Default to auto if no placement specified + if placement is None: + placement_setting = FootnotePlacement.auto + else: + placement_setting = placement + + if placement_setting == FootnotePlacement.left: + # Left placement: footnote marks + space + text + return f"{marks_html} {text}" + elif placement_setting == FootnotePlacement.right: + # Right placement: text + footnote marks + return f"{text}{marks_html}" + else: + # Auto placement: left for numbers, right for everything else + if _is_numeric_content(text): + # For numbers, place marks on the left so alignment is preserved + return f"{marks_html} {text}" + else: + # For text, place marks on the right + return f"{text}{marks_html}" + + +def _is_numeric_content(text: str) -> bool: + import re + + # Strip HTML tags for analysis + clean_text = re.sub(r"<[^>]+>", "", text).strip() + + if not clean_text: + return False + + # Remove common formatting characters to get to the core content + # This handles formatted numbers with commas, currency symbols, percent signs, etc. + # Include a wide range of currency symbols and formatting characters + formatting_chars = r"[,\s$%€£¥₹₽₩₪₱₡₴₦₨₵₸₲₩\(\)−\-+]" + number_core = re.sub(formatting_chars, "", clean_text) + + # Check if what remains is primarily numeric (including decimals) + if not number_core: + return False - return "" + # Check if the core is a valid number: must have at least one digit, + # and can have at most one decimal point + numeric_pattern = r"^\d*\.?\d+$|^\d+\.?\d*$" + return bool(re.match(numeric_pattern, number_core)) and number_core != "." def rtl_modern_unicode_charset() -> str: diff --git a/great_tables/css/gt_styles_default.scss b/great_tables/css/gt_styles_default.scss index 1fba67e43..2b949db0c 100644 --- a/great_tables/css/gt_styles_default.scss +++ b/great_tables/css/gt_styles_default.scss @@ -276,29 +276,6 @@ p { border-bottom-color: $table_body_border_bottom_color; } -.gt_sourcenotes { - color: $font_color_source_notes_background_color; - background-color: $source_notes_background_color; - border-bottom-style: $source_notes_border_bottom_style; - border-bottom-width: $source_notes_border_bottom_width; - border-bottom-color: $source_notes_border_bottom_color; - border-left-style: $source_notes_border_lr_style; - border-left-width: $source_notes_border_lr_width; - border-left-color: $source_notes_border_lr_color; - border-right-style: $source_notes_border_lr_style; - border-right-width: $source_notes_border_lr_width; - border-right-color: $source_notes_border_lr_color; -} - -.gt_sourcenote { - font-size: $source_notes_font_size; - padding-top: $source_notes_padding; - padding-bottom: $source_notes_padding; - padding-left: $source_notes_padding_horizontal; - padding-right: $source_notes_padding_horizontal; - text-align: left; -} - .gt_left { text-align: left; } @@ -328,6 +305,52 @@ p { font-size: 65%; } +.gt_footnotes { + color: font-color($footnotes_background_color); + background-color: $footnotes_background_color; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; +} + +.gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; +} + +.gt_sourcenotes { + color: $font_color_source_notes_background_color; + background-color: $source_notes_background_color; + border-bottom-style: $source_notes_border_bottom_style; + border-bottom-width: $source_notes_border_bottom_width; + border-bottom-color: $source_notes_border_bottom_color; + border-left-style: $source_notes_border_lr_style; + border-left-width: $source_notes_border_lr_width; + border-left-color: $source_notes_border_lr_color; + border-right-style: $source_notes_border_lr_style; + border-right-width: $source_notes_border_lr_width; + border-right-color: $source_notes_border_lr_color; +} + +.gt_sourcenote { + font-size: $source_notes_font_size; + padding-top: $source_notes_padding; + padding-bottom: $source_notes_padding; + padding-left: $source_notes_padding_horizontal; + padding-right: $source_notes_padding_horizontal; + text-align: left; +} + .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; diff --git a/great_tables/gt.py b/great_tables/gt.py index e38875950..aa2230629 100644 --- a/great_tables/gt.py +++ b/great_tables/gt.py @@ -9,6 +9,7 @@ from ._boxhead import cols_align, cols_label, cols_label_rotate from ._data_color import data_color from ._export import as_latex, as_raw_html, save, show, write_raw_html +from ._footnotes import tab_footnote from ._formats import ( fmt, fmt_bytes, @@ -69,9 +70,8 @@ _get_table_defs, create_body_component_h, create_columns_component_h, - create_footnotes_component_h, + create_footer_component_h, create_heading_component_h, - create_source_notes_component_h, ) if TYPE_CHECKING: @@ -267,6 +267,7 @@ def __init__( tab_header = tab_header tab_source_note = tab_source_note + tab_footnote = tab_footnote tab_spanner = tab_spanner tab_spanner_delim = tab_spanner_delim tab_stubhead = tab_stubhead @@ -364,8 +365,7 @@ def _render_as_html( heading_component = create_heading_component_h(data=self) column_labels_component = create_columns_component_h(data=self) body_component = create_body_component_h(data=self) - source_notes_component = create_source_notes_component_h(data=self) - footnotes_component = create_footnotes_component_h(data=self) + footer_component = create_footer_component_h(data=self) # Get attributes for the table table_defs = _get_table_defs(data=self) @@ -392,8 +392,7 @@ def _render_as_html( {column_labels_component} {body_component} -{source_notes_component} -{footnotes_component} +{footer_component} """ diff --git a/tests/__snapshots__/test_export.ambr b/tests/__snapshots__/test_export.ambr index b6fe21790..4f7b79eeb 100644 --- a/tests/__snapshots__/test_export.ambr +++ b/tests/__snapshots__/test_export.ambr @@ -36,8 +36,6 @@ #test_table .gt_row_group_first th { border-top-width: 2px; } #test_table .gt_striped { background-color: rgba(128,128,128,0.05); } #test_table .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; } - #test_table .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } - #test_table .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test_table .gt_left { text-align: left; } #test_table .gt_center { text-align: center; } #test_table .gt_right { text-align: right; font-variant-numeric: tabular-nums; } @@ -45,6 +43,10 @@ #test_table .gt_font_bold { font-weight: bold; } #test_table .gt_font_italic { font-style: italic; } #test_table .gt_super { font-size: 65%; } + #test_table .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test_table .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; } + #test_table .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test_table .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test_table .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; } #test_table .gt_asterisk { font-size: 100%; vertical-align: 0; } @@ -121,14 +123,7 @@ $0.44 - - - - This is only a subset of the dataset. - - - - + This is only a subset of the dataset. @@ -158,7 +153,6 @@ - @@ -191,7 +185,6 @@ - diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr new file mode 100644 index 000000000..5ee4a2db9 --- /dev/null +++ b/tests/__snapshots__/test_footnotes.ambr @@ -0,0 +1,156 @@ +# serializer version: 1 +# name: test_footnote_placement_snapshot_different_types + ''' +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Auto Placement Test
integersfloatscurrencypercentagestextmixedformatted_numscientific
1 422 123.453 $1,234.564 85.5%Hello5ABC12367 (1,000)1.23e-48
1 Integer footnote
2 Float footnote
3 Currency footnote
4 Percentage footnote
5 Text footnote
6 Mixed footnote
7 Formatted number footnote
8 Scientific footnote
+ +
+ + ''' +# --- +# name: test_footnote_placement_snapshot_left_placement + ''' +
+ + + + + + + + + + + + + + + + + + + + + +
Left Placement Test
integerstextcurrency
1 422 Hello3 $1,234.56
1 Integer footnote
2 Text footnote
3 Currency footnote
+ +
+ + ''' +# --- +# name: test_footnote_placement_snapshot_right_placement + ''' +
+ + + + + + + + + + + + + + + + + + + + + +
Right Placement Test
integerstextcurrency
421Hello2$1,234.563
1 Integer footnote
2 Text footnote
3 Currency footnote
+ +
+ + ''' +# --- +# name: test_tab_footnote_complete_ordering_snapshot + ''' +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title1
Subtitle2
Stubhead3 + Spanner A4 + + Spanner B5 +
col16col27
Row189 1020
1 Title note
2 Subtitle note
3 Stubhead note
4 Spanner A note
5 Spanner B note
6 Col1 note
7 Col2 note
8 Stub note
9 Body note
+ +
+ + ''' +# --- +# name: test_tab_footnote_stub_body_ordering_snapshot + ''' + + + A1 + Y + + + B + Z2 + + + ''' +# --- diff --git a/tests/__snapshots__/test_formats.ambr b/tests/__snapshots__/test_formats.ambr index 7246dc93f..a49adb4c7 100644 --- a/tests/__snapshots__/test_formats.ambr +++ b/tests/__snapshots__/test_formats.ambr @@ -51,7 +51,6 @@ - ''' # --- # name: test_format_repr_snap @@ -163,7 +162,6 @@ - ''' # --- # name: test_format_snap diff --git a/tests/__snapshots__/test_options.ambr b/tests/__snapshots__/test_options.ambr index d4661e920..718ce20c9 100644 --- a/tests/__snapshots__/test_options.ambr +++ b/tests/__snapshots__/test_options.ambr @@ -1023,29 +1023,6 @@ border-bottom-color: #0076BA; } - #abc .gt_sourcenotes { - color: #333333; - background-color: #FFFFFF; - border-bottom-style: none; - border-bottom-width: 2px; - border-bottom-color: #D3D3D3; - border-left-style: none; - border-left-width: 2px; - border-left-color: #D3D3D3; - border-right-style: none; - border-right-width: 2px; - border-right-color: #D3D3D3; - } - - #abc .gt_sourcenote { - font-size: 90%; - padding-top: 4px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - text-align: left; - } - #abc .gt_left { text-align: left; } @@ -1075,6 +1052,52 @@ font-size: 65%; } + #abc .gt_footnotes { + color: font-color(#FFFFFF); + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + } + + #abc .gt_sourcenotes { + color: #333333; + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_sourcenote { + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + } + #abc .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; @@ -1374,29 +1397,6 @@ border-bottom-color: #0076BA; } - #abc .gt_sourcenotes { - color: #333333; - background-color: #FFFFFF; - border-bottom-style: none; - border-bottom-width: 2px; - border-bottom-color: #D3D3D3; - border-left-style: none; - border-left-width: 2px; - border-left-color: #D3D3D3; - border-right-style: none; - border-right-width: 2px; - border-right-color: #D3D3D3; - } - - #abc .gt_sourcenote { - font-size: 90%; - padding-top: 4px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - text-align: left; - } - #abc .gt_left { text-align: left; } @@ -1426,6 +1426,52 @@ font-size: 65%; } + #abc .gt_footnotes { + color: font-color(#FFFFFF); + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + } + + #abc .gt_sourcenotes { + color: #333333; + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_sourcenote { + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + } + #abc .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; @@ -1833,29 +1879,6 @@ border-bottom-color: red; } - #abc .gt_sourcenotes { - color: #000000; - background-color: red; - border-bottom-style: solid; - border-bottom-width: 5px; - border-bottom-color: red; - border-left-style: solid; - border-left-width: 5px; - border-left-color: red; - border-right-style: solid; - border-right-width: 5px; - border-right-color: red; - } - - #abc .gt_sourcenote { - font-size: 12px; - padding-top: 5px; - padding-bottom: 5px; - padding-left: 5px; - padding-right: 5px; - text-align: left; - } - #abc .gt_left { text-align: left; } @@ -1885,6 +1908,52 @@ font-size: 65%; } + #abc .gt_footnotes { + color: font-color(red); + background-color: red; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + } + + #abc .gt_sourcenotes { + color: #000000; + background-color: red; + border-bottom-style: solid; + border-bottom-width: 5px; + border-bottom-color: red; + border-left-style: solid; + border-left-width: 5px; + border-left-color: red; + border-right-style: solid; + border-right-width: 5px; + border-right-color: red; + } + + #abc .gt_sourcenote { + font-size: 12px; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + } + #abc .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; @@ -2184,29 +2253,6 @@ border-bottom-color: #D3D3D3; } - #abc .gt_sourcenotes { - color: #333333; - background-color: #FFFFFF; - border-bottom-style: none; - border-bottom-width: 2px; - border-bottom-color: #D3D3D3; - border-left-style: none; - border-left-width: 2px; - border-left-color: #D3D3D3; - border-right-style: none; - border-right-width: 2px; - border-right-color: #D3D3D3; - } - - #abc .gt_sourcenote { - font-size: 90%; - padding-top: 4px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - text-align: left; - } - #abc .gt_left { text-align: left; } @@ -2236,6 +2282,52 @@ font-size: 65%; } + #abc .gt_footnotes { + color: font-color(#FFFFFF); + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + } + + #abc .gt_sourcenotes { + color: #333333; + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_sourcenote { + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + } + #abc .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; diff --git a/tests/__snapshots__/test_repr.ambr b/tests/__snapshots__/test_repr.ambr index 129c5ec8c..8baff9a0c 100644 --- a/tests/__snapshots__/test_repr.ambr +++ b/tests/__snapshots__/test_repr.ambr @@ -36,8 +36,6 @@ #test .gt_row_group_first th { border-top-width: 2px; } #test .gt_striped { background-color: rgba(128,128,128,0.05); } #test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; } - #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } - #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_left { text-align: left; } #test .gt_center { text-align: center; } #test .gt_right { text-align: right; font-variant-numeric: tabular-nums; } @@ -45,6 +43,10 @@ #test .gt_font_bold { font-weight: bold; } #test .gt_font_italic { font-style: italic; } #test .gt_super { font-size: 65%; } + #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; } + #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; } #test .gt_asterisk { font-size: 100%; vertical-align: 0; } @@ -68,7 +70,6 @@ - @@ -112,8 +113,6 @@ #test .gt_row_group_first th { border-top-width: 2px; } #test .gt_striped { background-color: rgba(128,128,128,0.05); } #test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; } - #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } - #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_left { text-align: left; } #test .gt_center { text-align: center; } #test .gt_right { text-align: right; font-variant-numeric: tabular-nums; } @@ -121,6 +120,10 @@ #test .gt_font_bold { font-weight: bold; } #test .gt_font_italic { font-style: italic; } #test .gt_super { font-size: 65%; } + #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; } + #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; } #test .gt_asterisk { font-size: 100%; vertical-align: 0; } @@ -144,7 +147,6 @@ - @@ -194,8 +196,6 @@ #test .gt_row_group_first th { border-top-width: 2px !important; } #test .gt_striped { background-color: rgba(128,128,128,0.05) !important; } #test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; } - #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } - #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; } #test .gt_left { text-align: left !important; } #test .gt_center { text-align: center !important; } #test .gt_right { text-align: right !important; font-variant-numeric: tabular-nums !important; } @@ -203,6 +203,10 @@ #test .gt_font_bold { font-weight: bold !important; } #test .gt_font_italic { font-style: italic !important; } #test .gt_super { font-size: 65% !important; } + #test .gt_footnotes { color: font-color(#FFFFFF) !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } + #test .gt_footnote { margin: 0px !important; font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; } + #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } + #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; } #test .gt_footnote_marks { font-size: 75% !important; vertical-align: 0.4em !important; position: initial !important; } #test .gt_asterisk { font-size: 100% !important; vertical-align: 0 !important; } @@ -226,7 +230,6 @@ - @@ -273,8 +276,6 @@ #test .gt_row_group_first th { border-top-width: 2px; } #test .gt_striped { background-color: rgba(128,128,128,0.05); } #test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; } - #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } - #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_left { text-align: left; } #test .gt_center { text-align: center; } #test .gt_right { text-align: right; font-variant-numeric: tabular-nums; } @@ -282,6 +283,10 @@ #test .gt_font_bold { font-weight: bold; } #test .gt_font_italic { font-style: italic; } #test .gt_super { font-size: 65%; } + #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; } + #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; } + #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; } #test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; } #test .gt_asterisk { font-size: 100%; vertical-align: 0; } @@ -305,7 +310,6 @@ - @@ -349,8 +353,6 @@ #test .gt_row_group_first th { border-top-width: 2px !important; } #test .gt_striped { background-color: rgba(128,128,128,0.05) !important; } #test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; } - #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } - #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; } #test .gt_left { text-align: left !important; } #test .gt_center { text-align: center !important; } #test .gt_right { text-align: right !important; font-variant-numeric: tabular-nums !important; } @@ -358,6 +360,10 @@ #test .gt_font_bold { font-weight: bold !important; } #test .gt_font_italic { font-style: italic !important; } #test .gt_super { font-size: 65% !important; } + #test .gt_footnotes { color: font-color(#FFFFFF) !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } + #test .gt_footnote { margin: 0px !important; font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; } + #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; } + #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; } #test .gt_footnote_marks { font-size: 75% !important; vertical-align: 0.4em !important; position: initial !important; } #test .gt_asterisk { font-size: 100% !important; vertical-align: 0 !important; } @@ -381,7 +387,6 @@ - diff --git a/tests/__snapshots__/test_scss.ambr b/tests/__snapshots__/test_scss.ambr index 00b3549db..6ab91e79b 100644 --- a/tests/__snapshots__/test_scss.ambr +++ b/tests/__snapshots__/test_scss.ambr @@ -285,29 +285,6 @@ border-bottom-color: #D3D3D3; } - #abc .gt_sourcenotes { - color: #333333; - background-color: #FFFFFF; - border-bottom-style: none; - border-bottom-width: 2px; - border-bottom-color: #D3D3D3; - border-left-style: none; - border-left-width: 2px; - border-left-color: #D3D3D3; - border-right-style: none; - border-right-width: 2px; - border-right-color: #D3D3D3; - } - - #abc .gt_sourcenote { - font-size: 90%; - padding-top: 4px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - text-align: left; - } - #abc .gt_left { text-align: left; } @@ -337,6 +314,52 @@ font-size: 65%; } + #abc .gt_footnotes { + color: font-color(#FFFFFF); + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_footnote { + margin: 0px; + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + } + + #abc .gt_sourcenotes { + color: #333333; + background-color: #FFFFFF; + border-bottom-style: none; + border-bottom-width: 2px; + border-bottom-color: #D3D3D3; + border-left-style: none; + border-left-width: 2px; + border-left-color: #D3D3D3; + border-right-style: none; + border-right-width: 2px; + border-right-color: #D3D3D3; + } + + #abc .gt_sourcenote { + font-size: 90%; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + } + #abc .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; diff --git a/tests/__snapshots__/test_utils_render_html.ambr b/tests/__snapshots__/test_utils_render_html.ambr index a29289f38..d94345740 100644 --- a/tests/__snapshots__/test_utils_render_html.ambr +++ b/tests/__snapshots__/test_utils_render_html.ambr @@ -69,14 +69,7 @@ one - - - - yo - - - - + yo diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py new file mode 100644 index 000000000..eb5890d85 --- /dev/null +++ b/tests/test_footnotes.py @@ -0,0 +1,1103 @@ +import polars as pl +import re +from great_tables import GT, loc, md, html +from great_tables._gt_data import FootnotePlacement, FootnoteInfo +from great_tables._text import Text +from great_tables._utils_render_html import ( + create_body_component_h, + _apply_footnote_placement, + _create_footnote_mark_html, + _get_column_index, + _get_footnote_mark_string, + _get_spanner_leftmost_column_index, + _is_numeric_content, +) + + +def assert_rendered_body(snapshot, gt): + built = gt._build_data("html") + body = create_body_component_h(built) + + assert snapshot == body + + +def assert_complete_html_without_style(snapshot, gt): + import re + + html = gt.as_raw_html() + html_without_style = re.sub(r"", "", html, flags=re.DOTALL) + + assert snapshot == html_without_style + + +def _create_test_data(): + return pl.DataFrame( + { + "group": ["A", "A", "A", "B", "B", "B"], + "row_id": ["r1", "r2", "r3", "r4", "r5", "r6"], + "col1": [10, 20, 30, 40, 50, 60], + "col2": [100, 200, 300, 400, 500, 600], + "col3": [1000, 2000, 3000, 4000, 5000, 6000], + } + ) + + +def _create_base_gt(): + df = _create_test_data() + return ( + GT(df, rowname_col="row_id", groupname_col="group") + .tab_header(title="Test Title", subtitle="Test Subtitle") + .tab_spanner(label="Spanner", columns=["col1", "col2"]) + ) + + +def test_tab_footnote_basic(): + gt_table = _create_base_gt().tab_footnote( + footnote="Test footnote", locations=loc.body(columns="col1", rows=[0]) + ) + + html = gt_table._render_as_html() + + # Check that footnote appears in footer + assert "Test footnote" in html + # Check that footnote mark appears in cell (left placement for numbers with auto) + assert re.search(r"]*>1 10", html) + + +def test_tab_footnote_numeric_marks(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Second note", locations=loc.body(columns="col2", rows=[1])) + .tab_footnote(footnote="Third note", locations=loc.body(columns="col3", rows=[2])) + ) + + html = gt_table._render_as_html() + + # Check that marks appear in the correct order (left placement for numbers with auto) + assert re.search(r"]*>1 10", html) # First cell + assert re.search(r"]*>2 200", html) # Second cell + assert re.search(r"]*>3 3000", html) # Third cell + + +def test_tab_footnote_mark_coalescing(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Second note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Third note", locations=loc.body(columns="col2", rows=[1])) + ) + + html = gt_table._render_as_html() + + # First cell should have coalesced marks "1,2" (left placement for numbers with auto) + assert re.search(r"]*>1,2 10", html) + # Second cell should have single mark "3" (left placement for numbers with auto) + assert re.search(r"]*>3 200", html) + + +def test_tab_footnote_ordering(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Header note", locations=loc.column_labels(columns="col1")) + .tab_footnote(footnote="Later body note", locations=loc.body(columns="col2", rows=[1])) + ) + + html = gt_table._render_as_html() + + # Header should get mark 1 (comes before body); text gets right placement + assert re.search(r">col1]*>1", html) + # First body cell should get mark 2; numbers get left placement with auto + assert re.search(r"]*>2 10", html) + # Later body cell should get mark 3; numbers get left placement with auto + assert re.search(r"]*>3 200", html) + + +def test_tab_footnote_all_locations(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Title note", locations=loc.title()) + .tab_footnote(footnote="Subtitle note", locations=loc.subtitle()) + .tab_footnote(footnote="Spanner note", locations=loc.spanner_labels(ids=["Spanner"])) + .tab_footnote(footnote="Column note", locations=loc.column_labels(columns="col1")) + .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Stub note", locations=loc.stub(rows=[0])) + .tab_footnote(footnote="Row group note", locations=loc.row_groups(rows=[0])) + ) + + html = gt_table._render_as_html() + + # All footnotes should appear in footer + for note in [ + "Title note", + "Subtitle note", + "Spanner note", + "Column note", + "Body note", + "Stub note", + "Row group note", + ]: + assert note in html + + # Check that the footnote marks in the title and subtitle appear + assert re.search(r"Test Title]*>1", html) # Title + assert re.search(r"Test Subtitle]*>2", html) # Subtitle + + +def test_tab_footnote_symbol_marks_standard(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Second note", locations=loc.body(columns="col2", rows=[1])) + .tab_footnote(footnote="Third note", locations=loc.body(columns="col3", rows=[2])) + .tab_footnote(footnote="Fourth note", locations=loc.body(columns="col1", rows=[1])) + .opt_footnote_marks("standard") + ) + + html = gt_table._render_as_html() + + # Check standard symbols appear in visual reading order (left placement for numbers with auto) + assert re.search(r"]*>\* 10", html) + assert re.search(r"]*>† 20", html) + assert re.search(r"]*>‡ 200", html) + assert re.search(r"]*>§ 3000", html) + + +def test_tab_footnote_symbol_marks_extended(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0])) + .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0])) + .tab_footnote(footnote="Note 4", locations=loc.body(columns="col1", rows=[1])) + .tab_footnote(footnote="Note 5", locations=loc.body(columns="col2", rows=[1])) + .tab_footnote(footnote="Note 6", locations=loc.body(columns="col3", rows=[1])) + .opt_footnote_marks("extended") + ) + + html = gt_table._render_as_html() + + # Check extended symbols appear in reading order (left-to-right, top-to-bottom) + # Numbers get left placement with auto + symbols = ["*", "†", "‡", "§", "‖", "¶"] + values = [10, 100, 1000, 20, 200, 2000] + + for symbol, value in zip(symbols, values): + escaped_symbol = re.escape(symbol) + assert re.search(f"]*>{escaped_symbol} {value}", html) + + +def test_tab_footnote_symbol_marks_letters(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Note B", locations=loc.body(columns="col2", rows=[0])) + .tab_footnote(footnote="Note C", locations=loc.body(columns="col3", rows=[0])) + .opt_footnote_marks("letters") + ) + + html = gt_table._render_as_html() + + # Check that the letter marks appear (left placement for numbers with auto) + assert re.search(r"]*>a 10", html) + assert re.search(r"]*>b 100", html) + assert re.search(r"]*>c 1000", html) + + +def test_tab_footnote_symbol_marks_uppercase_letters(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Note B", locations=loc.body(columns="col2", rows=[0])) + .tab_footnote(footnote="Note C", locations=loc.body(columns="col3", rows=[0])) + .opt_footnote_marks("LETTERS") + ) + + html = gt_table._render_as_html() + + # Check that the uppercase letter marks appear (left placement for numbers with auto) + assert re.search(r"]*>A 10", html) + assert re.search(r"]*>B 100", html) + assert re.search(r"]*>C 1000", html) + + +def test_tab_footnote_custom_symbol_marks(): + custom_marks = ["❶", "❷", "❸", "❹"] # using circled numbers + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0])) + .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0])) + .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0])) + .opt_footnote_marks(custom_marks) + ) + + html = gt_table._render_as_html() + + # Check that the custom marks appear (in the right order, left placement for numbers with auto) + assert re.search(r"]*>❶ 10", html) + assert re.search(r"]*>❷ 100", html) + assert re.search(r"]*>❸ 1000", html) + + +def test_tab_footnote_symbol_cycling(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0])) + .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0])) + .tab_footnote( + footnote="Note 4", locations=loc.body(columns="col1", rows=[1]) + ) # Should cycle to ** + .tab_footnote( + footnote="Note 5", locations=loc.body(columns="col2", rows=[1]) + ) # Should cycle to †† + .opt_footnote_marks("standard") + ) + + html = gt_table._render_as_html() + + # Check the cycling behavior (left placement for numbers with auto) + assert re.search(r"]*>\* 10", html) + assert re.search(r"]*>† 100", html) + assert re.search(r"]*>‡ 1000", html) + assert re.search(r"]*>§ 20", html) + assert re.search(r"]*>\*\* 200", html) + + +def test_tab_footnote_symbol_coalescing(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Second note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Third note", locations=loc.body(columns="col2", rows=[0])) + .opt_footnote_marks("standard") + ) + + html = gt_table._render_as_html() + + # The first cell should have a coalesced symbol marks (left placement for numbers with auto) + assert re.search(r"]*>\*,† 10", html) + + # The second cell should have a single symbol mark (left placement for numbers with auto) + assert re.search(r"]*>‡ 100", html) + + +def test_tab_footnote_multiple_rows(): + gt_table = _create_base_gt().tab_footnote( + footnote="Multiple rows note", locations=loc.body(columns="col1", rows=[0, 1, 2]) + ) + + html = gt_table._render_as_html() + + # All three cells should have the same footnote mark (left placement for numbers with auto) + assert re.search(r"]*>1 10", html) + assert re.search(r"]*>1 20", html) + assert re.search(r"]*>1 30", html) + + +def test_tab_footnote_multiple_columns(): + gt_table = _create_base_gt().tab_footnote( + footnote="Multiple columns note", locations=loc.body(columns=["col1", "col2"], rows=[0]) + ) + + html = gt_table._render_as_html() + + # Both cells in the first row should have the same footnote mark (left placement for numbers with auto) + assert re.search(r"]*>1 10", html) + assert re.search(r"]*>1 100", html) + + +def test_tab_footnote_footer_rendering(): + gt_table = ( + _create_base_gt() + .tab_footnote(footnote="First footnote text", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Second footnote text", locations=loc.body(columns="col2", rows=[1])) + .opt_footnote_marks("standard") + ) + + html = gt_table._render_as_html() + + # Check footnotes appear in footer with correct marks + footer_match = re.search(r"]*>.*?", html, re.DOTALL) + assert footer_match is not None + + footer_html = footer_match.group(0) + assert re.search(r"]*>\*\s*First footnote text", footer_html) + assert re.search(r"]*>†\s*Second footnote text", footer_html) + + +def test_tab_footnote_with_text_object(): + # Test a footnote with the Text object + gt_table = _create_base_gt().tab_footnote( + footnote=Text("Bold text"), locations=loc.body(columns="col1", rows=[0]) + ) + + html = gt_table._render_as_html() + + # Check that the footnote mark appears (left placement for numbers with auto) + assert re.search(r"]*>1 10", html) + + # Check that the text object content should appear in the footer + assert "Bold text" in html + + +def test_tab_footnote_hidden_columns(): + df = pl.DataFrame( + { + "col1": [10], + "col2": [100], # Will be hidden + "col3": [1000], + "col4": [10000], # Will be hidden + } + ) + + gt_table = ( + GT(df) + .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col1")) + .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col2")) + .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col3")) + .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col2")) + .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col4")) + .tab_footnote(footnote="Note C", locations=loc.column_labels(columns="col1")) + .cols_hide(columns=["col2", "col4"]) + ) + + html = gt_table._render_as_html() + + # Extract footnote marks from visible column headers + col1_match = re.search(r'id="col1"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html) + col3_match = re.search(r'id="col3"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html) + + assert col1_match is not None + assert col3_match is not None + + col1_marks_match = re.search( + r']*>([^<]*)', col1_match.group(1) + ) + col3_marks_match = re.search( + r']*>([^<]*)', col3_match.group(1) + ) + + # col1 should have marks 1,2 (Note A and Note C) + assert col1_marks_match is not None + assert col1_marks_match.group(1) == "1,2" + + # col3 should have mark 1 only (Note A) + assert col3_marks_match is not None + assert col3_marks_match.group(1) == "1" + + # Extract footer footnotes + footer_matches = re.findall( + r']*>([^<]*)\s*([^<]+?)(?=)', html + ) + + # Should only show 2 footnotes in footer (Note A and Note C) + # Note B should not appear because it only targets hidden columns + assert len(footer_matches) == 2 + + # Check footnote text and marks + footnote_dict = {mark.rstrip("."): text.strip() for mark, text in footer_matches} + assert footnote_dict["1"] == "Note A" # Appears on visible columns + assert footnote_dict["2"] == "Note C" # Appears on visible column + assert "Note B" not in html # Should not appear anywhere since only targets hidden columns + + +def test_tab_footnote_mixed_locations_hidden(): + df = pl.DataFrame({"visible_col": [10], "hidden_col": [100]}) + + gt_table = ( + GT(df) + .tab_footnote( + footnote="Mixed location note", + locations=[ + loc.column_labels(columns="visible_col"), + loc.column_labels(columns="hidden_col"), + ], + ) + .cols_hide(columns="hidden_col") + ) + + html = gt_table._render_as_html() + + # Footnote should appear because it targets at least one visible location + assert "Mixed location note" in html + + # Mark should appear on visible column + visible_match = re.search(r'id="visible_col"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html) + assert visible_match is not None + + marks_match = re.search( + r']*>([^<]*)', visible_match.group(1) + ) + assert marks_match is not None + assert marks_match.group(1) == "1" + + +def test_tab_footnote_stub_body_ordering_snapshot(snapshot): + df = pl.DataFrame( + { + "name": ["A", "B"], + "value": ["Y", "Z"], + } + ) + + gt_table = ( + GT(df, rowname_col="name", id="test_stub_body_footnotes") + .tab_footnote( + footnote="Body note.", + locations=loc.body(columns="value", rows=[1]), + ) + .tab_footnote( + footnote="Stub note.", + locations=loc.stub(rows=[0]), + ) + ) + + # Use assert_rendered_body to create a smaller, focused snapshot + assert_rendered_body(snapshot, gt_table) + + +def test_tab_footnote_complete_ordering_snapshot(snapshot): + df = pl.DataFrame( + { + "name": ["Row1"], + "col1": [10], + "col2": [20], + } + ) + + gt_table = ( + GT(df, rowname_col="name", id="test_complete_footnote_ordering") + .tab_header(title="Title", subtitle="Subtitle") + .tab_stubhead(label="Stubhead") + .tab_spanner(label="Spanner A", columns=["col1"]) + .tab_spanner(label="Spanner B", columns=["col2"]) + .tab_footnote(footnote="Subtitle note", locations=loc.subtitle()) + .tab_footnote(footnote="Spanner B note", locations=loc.spanner_labels(ids=["Spanner B"])) + .tab_footnote(footnote="Spanner A note", locations=loc.spanner_labels(ids=["Spanner A"])) + .tab_footnote(footnote="Title note", locations=loc.title()) + .tab_footnote(footnote="Col2 note", locations=loc.column_labels(columns="col2")) + .tab_footnote(footnote="Col1 note", locations=loc.column_labels(columns="col1")) + .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0])) + .tab_footnote(footnote="Stub note", locations=loc.stub(rows=[0])) + .tab_footnote(footnote="Stubhead note", locations=loc.stubhead()) + ) + + # Use `assert_complete_html_without_style()` to capture all footnote marks in the table (and + # the footnotes in the footer section) + assert_complete_html_without_style(snapshot, gt_table) + + +def test_tab_footnote_md_with_unit_notation(): + df = pl.DataFrame({"area": [100, 200], "value": [10, 20]}) + + gt_table = GT(df).tab_footnote( + footnote=md("**Area** is measured in {{km^2}}."), + locations=loc.body(columns="area", rows=[0]), + ) + + html_output = gt_table._render_as_html() + + assert ( + 'Area is measured in km2' + in html_output + ) + + +def test_tab_footnote_html_with_unit_notation(): + # Test that html() footnotes also support unit notation like {{km^2}} + df = pl.DataFrame({"area": [100, 200], "value": [10, 20]}) + + gt_table = GT(df).tab_footnote( + footnote=html("Area is measured in {{km^2}}."), + locations=loc.body(columns="area", rows=[0]), + ) + + html_output = gt_table._render_as_html() + + assert ( + 'Area is measured in km2' + in html_output + ) + + +def test_footer_structure_combined(): + df = pl.DataFrame({"area": [100, 200], "value": [10, 20]}) + + gt_table = ( + GT(df) + .tab_source_note("Source: Test data.") + .tab_footnote( + footnote="Area footnote.", + locations=loc.body(columns="area", rows=[0]), + ) + .tab_footnote( + footnote="Value footnote.", + locations=loc.body(columns="value", rows=[1]), + ) + ) + + html_output = gt_table._render_as_html() + + # Check that there is only a single container + assert html_output.count("") == 1 + + # Check that both source notes and footnotes are present + assert "gt_sourcenote" in html_output + assert html_output.count("gt_footnote") >= 2 + + # Check proper class structure (should use the `gt_footnotes` class) + assert 'class="gt_footnotes"' in html_output + + # Check that footnote marks are present + assert "gt_footnote_marks" in html_output + + +def test_tab_footnote_complex_spanner_ordering(): + df = pl.DataFrame( + { + "region": ["North", "South", "East", "West"], + "q1_sales": [100, 110, 95, 105], + "q1_profit": [20, 25, 18, 22], + "q2_sales": [120, 130, 115, 125], + "q2_profit": [25, 30, 22, 28], + "q3_sales": [140, 150, 135, 145], + "q3_profit": [30, 35, 27, 32], + } + ) + + gt_table = ( + GT(df, rowname_col="region") + .tab_header(title="Quarterly Performance", subtitle="By Region") + .tab_stubhead(label="Region") + .tab_spanner(label="Q1 Performance", columns=["q1_sales", "q1_profit"]) + .tab_spanner(label="Q2 Performance", columns=["q2_sales", "q2_profit"]) + .tab_spanner(label="Q3 Performance", columns=["q3_sales", "q3_profit"]) + .tab_spanner(label="Sales Data", columns=["q1_sales", "q2_sales", "q3_sales"]) + .tab_spanner(label="Profit Data", columns=["q1_profit", "q2_profit", "q3_profit"]) + .cols_label( + q1_sales="Sales", + q1_profit="Profit", + q2_sales="Sales", + q2_profit="Profit", + q3_sales="Sales", + q3_profit="Profit", + ) + .tab_footnote(footnote="Title footnote", locations=loc.title()) + .tab_footnote(footnote="Subtitle footnote", locations=loc.subtitle()) + .tab_footnote(footnote="Stubhead footnote", locations=loc.stubhead()) + .tab_footnote( + footnote="Sales Data spanner footnote", locations=loc.spanner_labels(ids=["Sales Data"]) + ) + .tab_footnote( + footnote="Profit Data spanner footnote", + locations=loc.spanner_labels(ids=["Profit Data"]), + ) + .tab_footnote( + footnote="Q1 Performance spanner footnote", + locations=loc.spanner_labels(ids=["Q1 Performance"]), + ) + .tab_footnote( + footnote="Q2 Performance spanner footnote", + locations=loc.spanner_labels(ids=["Q2 Performance"]), + ) + .tab_footnote( + footnote="Q3 Performance spanner footnote", + locations=loc.spanner_labels(ids=["Q3 Performance"]), + ) + .tab_footnote( + footnote="Q1 Sales column footnote", locations=loc.column_labels(columns="q1_sales") + ) + .tab_footnote( + footnote="Q1 Profit column footnote", locations=loc.column_labels(columns="q1_profit") + ) + .tab_footnote(footnote="North region footnote", locations=loc.stub(rows=[0])) + .tab_footnote(footnote="South region footnote", locations=loc.stub(rows=[1])) + .tab_footnote( + footnote="Cell footnote (North Q1 Sales)", + locations=loc.body(columns="q1_sales", rows=[0]), + ) + .tab_footnote( + footnote="Cell footnote (South Q2 Profit)", + locations=loc.body(columns="q2_profit", rows=[1]), + ) + ) + + html = gt_table._render_as_html() + + # Check that all footnotes appear in footer + expected_footnotes = [ + "Title footnote", + "Subtitle footnote", + "Stubhead footnote", + "Sales Data spanner footnote", + "Profit Data spanner footnote", + "Q1 Performance spanner footnote", + "Q2 Performance spanner footnote", + "Q3 Performance spanner footnote", + "Q1 Sales column footnote", + "Q1 Profit column footnote", + "North region footnote", + "South region footnote", + "Cell footnote (North Q1 Sales)", + "Cell footnote (South Q2 Profit)", + ] + + for footnote in expected_footnotes: + assert footnote in html + + # + # Check that footnote marks appear in the expected locations + # + + # Title should have mark 1 + assert re.search(r"Quarterly Performance]*>1", html) + + # Subtitle should have mark 2 + assert re.search(r"By Region]*>2", html) + + # Stubhead should have mark 3 + assert re.search(r"Region]*>3", html) + + # Test that spanner marks are present by looking for any spanner with footnote marks + spanner_marks = re.findall( + r'[^<]*]*class="gt_footnote_marks"[^>]*>([^<]+)', + html, + ) + assert len(spanner_marks) > 0 + + # + # Test that marks are ordered sequentially (1, 2, 3, ...) + # + + # Extract all footnote marks from the HTML + mark_pattern = r']*class="gt_footnote_marks"[^>]*>([^<]+)' + all_marks = re.findall(mark_pattern, html) + + # Convert marks to individual numbers (handle comma-separated marks like "1,2") + mark_numbers = [] + for mark in all_marks: + for single_mark in mark.split(","): + if single_mark.strip().isdigit(): + mark_numbers.append(int(single_mark.strip())) + + # Check they include sequential numbers starting from 1 + if mark_numbers: + unique_marks = sorted(list(set(mark_numbers))) + assert 1 in unique_marks + assert len(unique_marks) >= 3 + + +def test_tab_footnote_spanner_specific_functionality(): + df = pl.DataFrame({"col1": [1, 2], "col2": [3, 4], "col3": [5, 6], "col4": [7, 8]}) + + gt_table = ( + GT(df) + .tab_spanner(label="Group A", columns=["col1", "col2"]) + .tab_spanner(label="Group B", columns=["col3", "col4"]) + .tab_footnote(footnote="First spanner note", locations=loc.spanner_labels(ids=["Group A"])) + .tab_footnote(footnote="Second spanner note", locations=loc.spanner_labels(ids=["Group A"])) + .tab_footnote(footnote="Group B note", locations=loc.spanner_labels(ids=["Group B"])) + ) + + html = gt_table._render_as_html() + + # Check that all spanner footnotes appear + assert "First spanner note" in html + assert "Second spanner note" in html + assert "Group B note" in html + + # + # Check that spanner labels get footnote marks + # + + # Group A should have marks for both footnotes + group_a_marks = re.findall( + r'Group A]*class="gt_footnote_marks"[^>]*>([^<]+)', html + ) + assert len(group_a_marks) >= 1 + + # Group B should have its own mark + group_b_marks = re.findall( + r'Group B]*class="gt_footnote_marks"[^>]*>([^<]+)', html + ) + assert len(group_b_marks) >= 1 + + +# =========================================================================================== +# Tests for utility functions +# =========================================================================================== + + +def test_is_numeric_content(): + # Test basic numbers + assert _is_numeric_content("123") == True + assert _is_numeric_content("123.45") == True + assert _is_numeric_content("0") == True + assert _is_numeric_content("0.0") == True + + # Test formatted numbers + assert _is_numeric_content("1,234") == True + assert _is_numeric_content("1,234.56") == True + assert _is_numeric_content("$123") == True + assert _is_numeric_content("$1,234.56") == True + assert _is_numeric_content("123%") == True + assert _is_numeric_content("(123)") == True + assert _is_numeric_content("€1,234.56") == True + assert _is_numeric_content("£1,234.56") == True + assert _is_numeric_content("¥1,234") == True + + # Test numbers with various formatting + assert _is_numeric_content(" 123 ") == True + assert _is_numeric_content("+123") == True + assert _is_numeric_content("-123") == True + assert _is_numeric_content("−123") == True + + # Test with HTML tags + assert _is_numeric_content("123") == True + assert _is_numeric_content("$1,234.56") == True + assert _is_numeric_content('
123.45
') == True + + # Test non-numeric content + assert _is_numeric_content("Hello") == False + assert _is_numeric_content("Text123") == False + assert _is_numeric_content("123Text") == False + assert _is_numeric_content("A") == False + assert _is_numeric_content("NA") == False + assert _is_numeric_content("NULL") == False + assert _is_numeric_content("") == False + assert _is_numeric_content(" ") == False + + # Test mixed content with HTML + assert _is_numeric_content("Hello") == False + assert _is_numeric_content("Text Content") == False + + # Test edge cases + assert _is_numeric_content("$") == False + assert _is_numeric_content("%") == False + assert _is_numeric_content("()") == False + assert _is_numeric_content(",") == False + assert _is_numeric_content(".") == False + assert _is_numeric_content("..") == False + + +def test_apply_footnote_placement(): + text = "123" + marks_html = '1' + + # Test left placement + result = _apply_footnote_placement(text, marks_html, FootnotePlacement.left) + expected = '1 123' + assert result == expected + + # Test right placement + result = _apply_footnote_placement(text, marks_html, FootnotePlacement.right) + expected = '1231' + assert result == expected + + # Test auto placement with numeric content + result = _apply_footnote_placement("123", marks_html, FootnotePlacement.auto) + expected = '1 123' # Should go left for numbers + assert result == expected + + # Test auto placement with text content + result = _apply_footnote_placement("Hello", marks_html, FootnotePlacement.auto) + expected = 'Hello1' # Should go right for text + assert result == expected + + # Test auto placement with formatted numbers + result = _apply_footnote_placement("$1,234.56", marks_html, FootnotePlacement.auto) + expected = ( + '1 $1,234.56' # Should go left for formatted numbers + ) + assert result == expected + + # Test None placement (should default to auto) + result = _apply_footnote_placement("123", marks_html, None) + expected = '1 123' # Should go left for numbers + assert result == expected + + # Test with HTML content + html_text = "456" + result = _apply_footnote_placement(html_text, marks_html, FootnotePlacement.auto) + expected = ( + '1 456' # Should go left for numbers in HTML + ) + assert result == expected + + html_text = "Hello" + result = _apply_footnote_placement(html_text, marks_html, FootnotePlacement.auto) + expected = ( + 'Hello1' # Should go right for text in HTML + ) + assert result == expected + + +def test_footnote_placement_snapshot_different_types(snapshot): + import pandas as pd + + # Create test data with different value types + df = pd.DataFrame( + { + "integers": [42], + "floats": [123.45], + "currency": ["$1,234.56"], + "percentages": ["85.5%"], + "text": ["Hello"], + "mixed": ["ABC123"], + "formatted_num": ["(1,000)"], + "scientific": ["1.23e-4"], + } + ) + + # Test with auto placement (default) + gt_auto = ( + GT(df, id="test_auto_placement") + .tab_header(title="Auto Placement Test") + .tab_footnote("Integer footnote", locations=loc.body(columns="integers", rows=[0])) + .tab_footnote("Float footnote", locations=loc.body(columns="floats", rows=[0])) + .tab_footnote("Currency footnote", locations=loc.body(columns="currency", rows=[0])) + .tab_footnote("Percentage footnote", locations=loc.body(columns="percentages", rows=[0])) + .tab_footnote("Text footnote", locations=loc.body(columns="text", rows=[0])) + .tab_footnote("Mixed footnote", locations=loc.body(columns="mixed", rows=[0])) + .tab_footnote( + "Formatted number footnote", locations=loc.body(columns="formatted_num", rows=[0]) + ) + .tab_footnote("Scientific footnote", locations=loc.body(columns="scientific", rows=[0])) + ) + + assert_complete_html_without_style(snapshot, gt_auto) + + +def test_footnote_placement_snapshot_left_placement(snapshot): + import pandas as pd + + df = pd.DataFrame({"integers": [42], "text": ["Hello"], "currency": ["$1,234.56"]}) + + # Test with explicit left placement + gt_left = ( + GT(df, id="test_left_placement") + .tab_header(title="Left Placement Test") + .tab_footnote( + "Integer footnote", locations=loc.body(columns="integers", rows=[0]), placement="left" + ) + .tab_footnote( + "Text footnote", locations=loc.body(columns="text", rows=[0]), placement="left" + ) + .tab_footnote( + "Currency footnote", locations=loc.body(columns="currency", rows=[0]), placement="left" + ) + ) + + assert_complete_html_without_style(snapshot, gt_left) + + +def test_footnote_placement_snapshot_right_placement(snapshot): + import pandas as pd + + df = pd.DataFrame({"integers": [42], "text": ["Hello"], "currency": ["$1,234.56"]}) + + # Test with explicit right placement + gt_right = ( + GT(df, id="test_right_placement") + .tab_header(title="Right Placement Test") + .tab_footnote( + "Integer footnote", locations=loc.body(columns="integers", rows=[0]), placement="right" + ) + .tab_footnote( + "Text footnote", locations=loc.body(columns="text", rows=[0]), placement="right" + ) + .tab_footnote( + "Currency footnote", locations=loc.body(columns="currency", rows=[0]), placement="right" + ) + ) + + assert_complete_html_without_style(snapshot, gt_right) + + +def test_source_notes_single_line_with_footnotes(): + import pandas as pd + + df = pd.DataFrame({"values": [42, 123]}) + + # Create a table with source notes in single-line mode and footnotes + gt_table = ( + GT(df) + .tab_header(title="Table with Source Notes and Footnotes") + .tab_source_note("First source note") + .tab_source_note("Second source note") + .tab_source_note("Third source note") + .tab_footnote("Value footnote", locations=loc.body(columns="values", rows=[0])) + .tab_options(source_notes_multiline=False) + ) + + html = gt_table._render_as_html() + + # Check that source notes are in single line (joined by separator) + # The default separator should be used to join the notes + assert "First source note" in html + assert "Second source note" in html + assert "Third source note" in html + + # Check that footnotes are also present + assert "Value footnote" in html + + # Verify the HTML structure: source notes should be in a single row + import re + + # Look for source notes in a single with the `gt_sourcenote` class + source_note_pattern = r']*>[^<]*First source note[^<]*Second source note[^<]*Third source note[^<]*' + assert re.search(source_note_pattern, html) + + +def test_source_notes_multiline_with_footnotes(): + import pandas as pd + + df = pd.DataFrame({"values": [42, 123]}) + + # Create a table with source notes in multiline mode and footnotes + gt_table = ( + GT(df) + .tab_header(title="Table with Multiline Source Notes and Footnotes") + .tab_source_note("First source note") + .tab_source_note("Second source note") + .tab_source_note("Third source note") + .tab_footnote("Value footnote", locations=loc.body(columns="values", rows=[0])) + .tab_options(source_notes_multiline=True) + ) + + html = gt_table._render_as_html() + + # Check that source notes are present + assert "First source note" in html + assert "Second source note" in html + assert "Third source note" in html + + # Check that footnotes are also present + assert "Value footnote" in html + + # Verify the HTML structure: each source note should be in its own row + import re + + # Look for multiple source note rows + source_note_rows = re.findall( + r']*>', html + ) + assert len(source_note_rows) >= 3 + + +def test_footnote_and_source_note_integration(): + import pandas as pd + + df = pd.DataFrame({"numbers": [100, 200], "text": ["Alpha", "Beta"]}) + + # Create a comprehensive table with both footnotes and source notes + gt_table = ( + GT(df) + .tab_header(title="Integration Test: Footnotes and Source Notes") + .tab_footnote("Number footnote", locations=loc.body(columns="numbers", rows=[0])) + .tab_footnote("Text footnote", locations=loc.body(columns="text", rows=[1])) + .tab_source_note("Data source: Example dataset") + .tab_source_note("Analysis performed in 2025") + .tab_options(source_notes_multiline=False) + ) + + html = gt_table._render_as_html() + + # Verify footnotes are applied with correct placement + import re + + # Numbers should get left placement, text should get right placement + assert re.search(r"]*>1 100", html), "Number footnote should be left-placed" + assert re.search(r"Beta]*>2", html), "Text footnote should be right-placed" + + # Verify footnotes appear in footer + assert "Number footnote" in html + assert "Text footnote" in html + + # Verify source notes appear in footer in single line + assert "Data source: Example dataset" in html + assert "Analysis performed in 2025" in html + + # Check that both footnotes and source notes are in the footer section + footer_match = re.search(r"(.*?)", html, re.DOTALL) + assert footer_match + + # Footer should contain both source notes and footnotes + footer_content = footer_match.group(1) + assert "Data source: Example dataset" in footer_content + assert "Number footnote" in footer_content + assert "Text footnote" in footer_content + + +def test_create_footnote_mark_html_edge_cases(): + # Test that empty mark should return an empty string + result = _create_footnote_mark_html("") + assert result == "" + + +def test_footnote_mark_string_edge_cases(): + # Test with empty GTData (no footnotes) + empty_gt = GT(pl.DataFrame({"col": [1]})) + footnote_info = FootnoteInfo(locname="body", rownum=0, colname="col", footnotes=["test"]) + + # Should return mark "1" when no existing footnotes + result = _get_footnote_mark_string(empty_gt._build_data("footnote_test"), footnote_info) + assert result == "1" + + # Test with footnote_info having no footnotes + footnote_info_empty = FootnoteInfo(locname="body", rownum=0, colname="col", footnotes=[]) + result = _get_footnote_mark_string(empty_gt._build_data("footnote_test"), footnote_info_empty) + assert result == "1" + + +def test_get_column_index_edge_cases(): + df = pl.DataFrame({"col1": [1], "col2": [2], "col3": [3]}) + gt_table = GT(df) + data = gt_table._build_data("test") + + # Test situation where colname is None or empty + result = _get_column_index(data, None) + assert result == 0 + + result = _get_column_index(data, "") + assert result == 0 + + # Test situation where the column name is not found + result = _get_column_index(data, "nonexistent_column") + assert result == 0 + + # Tests of normal cases where the column provided exists + result = _get_column_index(data, "col1") + assert result == 0 + + result = _get_column_index(data, "col2") + assert result == 1 + + result = _get_column_index(data, "col3") + assert result == 2 + + +def test_get_spanner_leftmost_column_index_edge_cases(): + df = pl.DataFrame({"col1": [1], "col2": [2], "col3": [3]}) + gt_table = GT(df).tab_spanner(label="Test Spanner", columns=["col2", "col3"]) + data = gt_table._build_data("test") + + # Test case 1: `spanner_grpname` is None + result = _get_spanner_leftmost_column_index(data, None) + assert result == 0 + + # Test case 2: `spanner_grpname` is empty string + result = _get_spanner_leftmost_column_index(data, "") + assert result == 0 + + # Test case 3: `spanner_grpname` doesn't exist + result = _get_spanner_leftmost_column_index(data, "Nonexistent Spanner") + assert result == 0 + + # Test normal case: existing spanner should return leftmost column index; + # col2 is at index 1, col3 at index 2, so leftmost is 1 + result = _get_spanner_leftmost_column_index(data, "Test Spanner") + assert result == 1