From 0ac0203ee0998900d0892d7bd11ae6f6ebf3a674 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 16:37:58 -0400 Subject: [PATCH 01/51] Modify FootnoteInfo class --- great_tables/_gt_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py index a5603f523..4c33631e0 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 From b8037edbea3a06da3643ffbdaaeeeb24ae78309c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 16:45:32 -0400 Subject: [PATCH 02/51] Add the tab_footnote() method --- great_tables/_footnotes.py | 100 ++++++++++++++++++++++++++++++++++++- great_tables/gt.py | 2 + 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py index 4dfa4784e..b94d6c3b2 100644 --- a/great_tables/_footnotes.py +++ b/great_tables/_footnotes.py @@ -1,4 +1,102 @@ 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 + **Great Tables** 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 + -------- + See [`GT.tab_footnote()`](`great_tables.GT.tab_footnote`) for examples. + """ + + # Convert footnote to string if it's a Text object + if hasattr(footnote, "__str__"): + footnote_str = str(footnote) + else: + 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.py b/great_tables/gt.py index e38875950..ab3143daa 100644 --- a/great_tables/gt.py +++ b/great_tables/gt.py @@ -63,6 +63,7 @@ from ._stubhead import tab_stubhead from ._substitution import sub_missing, sub_zero from ._tab_create_modify import tab_style +from ._footnotes import tab_footnote from ._tbl_data import _get_cell, n_rows from ._utils import _migrate_unformatted_to_output from ._utils_render_html import ( @@ -267,6 +268,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 From 75d1d6e6da7f2c33083b84baad0c3a2541761c70 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 16:46:25 -0400 Subject: [PATCH 03/51] Implement set_footnote for various location types --- great_tables/_locations.py | 142 ++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) 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 From 44c86ae31649f0d42c4aa4b7c12b124de10865be Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 16:54:29 -0400 Subject: [PATCH 04/51] Remove unused tab_footnote() function and imports --- great_tables/_tab_create_modify.py | 39 +----------------------------- 1 file changed, 1 insertion(+), 38 deletions(-) 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 From 6610a8d57bffad536f1143718ce1d0f53b8adfbe Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 16:56:11 -0400 Subject: [PATCH 05/51] Add footnote mark rendering to HTML table components --- great_tables/_utils_render_html.py | 213 ++++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 3 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 3672d9464..eb9a181c6 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -6,7 +6,7 @@ from htmltools import HTML, TagList, css, tags from . import _locations as loc -from ._gt_data import GroupRowInfo, GTData, Styles +from ._gt_data import FootnoteInfo, 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 @@ -67,6 +67,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)] @@ -183,9 +189,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, @@ -500,6 +511,11 @@ def create_body_component_h(data: GTData) -> str: cell_content: Any = _get_cell(tbl_data, i, colinfo.var) cell_str: str = str(cell_content) + # Add footnote marks to cell content if applicable + cell_str = _add_footnote_marks_to_text( + data, cell_str, "data", colname=colinfo.var, rownum=i + ) + # Determine whether the current cell is the stub cell if has_stub_column: is_stub_cell = colinfo.var == stub_var.var @@ -630,10 +646,201 @@ def create_source_notes_component_h(data: GTData) -> str: def create_footnotes_component_h(data: GTData): + footnotes = data._footnotes + + # If there are no footnotes, return an empty string + if len(footnotes) == 0: + return "" + + # Process footnotes and assign marks + footnotes_with_marks = _process_footnotes_for_display(data, footnotes) + + if len(footnotes_with_marks) == 0: + return "" + # 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)] - return "" + # Get footnote styles + footnote_styles = "" + if styles_footnotes: + footnote_styles = " ".join( + [ + style_attr + for style_info in styles_footnotes + for style in style_info.styles + for style_attr in [str(style)] + if style_attr + ] + ) + + # Get options for footnotes + multiline = True # Default to multiline for now + separator = " " # Default separator + + # Get effective number of columns for colspan + n_cols_total = _get_effective_number_of_columns(data) + + # Create footnote HTML + footnote_items = [] + 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") + footnote_html = f"{footnote_mark_html} {text}" + footnote_items.append(footnote_html) + + if multiline: + # Each footnote gets its own row + footnote_rows = [] + for item in footnote_items: + footnote_rows.append( + f'{item}' + ) + + return f'{"".join(footnote_rows)}' + else: + # All footnotes in a single row + combined_footnotes = separator.join(footnote_items) + return ( + f'' + f'' + f'
{combined_footnotes}
' + f"" + ) + + +def _process_footnotes_for_display( + data: GTData, footnotes: list[FootnoteInfo] +) -> list[dict[str, str]]: + if not footnotes: + return [] + + # Group footnotes by their text to avoid duplicates + footnote_texts: dict[str, int] = {} + footnote_order: list[str] = [] + + for footnote in footnotes: + if footnote.locname == "none": # type: ignore + # Footnotes without marks come first + continue + + if footnote.footnotes: + text = footnote.footnotes[0] if footnote.footnotes else "" + if text not in footnote_texts: + footnote_texts[text] = len(footnote_texts) + 1 + footnote_order.append(text) + + # Add footnotes without marks at the beginning + markless_footnotes = [f for f in footnotes if f.locname == "none"] # type: ignore + result: list[dict[str, str]] = [] + + # Add markless footnotes first + for footnote in markless_footnotes: + if footnote.footnotes: + result.append({"mark": "", "text": footnote.footnotes[0]}) + + # Add footnotes with marks + for text in footnote_order: + mark_number = footnote_texts[text] + result.append({"mark": str(mark_number), "text": text}) + + return result + + +def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: + if not mark: + return "" + + # For now, use simple superscript numbers + if location == "ftr": + # In footer, show mark with period + return f'{mark}.' + else: + # In text, show mark as superscript + return f'{mark}' + + +def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: + """Get the mark number for a footnote based on unique footnote text.""" + if not data._footnotes or not footnote_info.footnotes: + return 1 + + # Get all unique footnote texts in order of first appearance + unique_footnotes = [] + for fn_info in data._footnotes: + if fn_info.footnotes: + footnote_text = fn_info.footnotes[0] # Use first footnote text + if footnote_text not in unique_footnotes: + unique_footnotes.append(footnote_text) + + # Find the mark number for this footnote's text + if footnote_info.footnotes: + footnote_text = footnote_info.footnotes[0] + try: + return unique_footnotes.index(footnote_text) + 1 # 1-based indexing + except ValueError: + return 1 + + return 1 + + +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 = [] + 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_num = _get_footnote_mark_number(data, footnote) + matching_footnotes.append((mark_num, footnote)) + + if not matching_footnotes: + return text + + # Create footnote marks + marks = [] + for mark_num, footnote in matching_footnotes: + mark_html = _create_footnote_mark_html(str(mark_num)) + marks.append(mark_html) + + # Add marks to the text + if marks: + marks_html = "".join(marks) + return f"{text}{marks_html}" + + return text + + +def _get_effective_number_of_columns(data: GTData) -> int: + """Get the effective number of columns for the table.""" + from ._gt_data import ColInfoTypeEnum + + # Count visible columns (default type) and stub columns + visible_cols = len([col for col in data._boxhead if col.type == ColInfoTypeEnum.default]) + stub_cols = len([col for col in data._boxhead if col.type == ColInfoTypeEnum.stub]) + + return visible_cols + stub_cols def rtl_modern_unicode_charset() -> str: From 14e0d352663657136316bea5a45935c186ef3d2d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 17:08:37 -0400 Subject: [PATCH 06/51] Improve footnote numbering to align with display order --- great_tables/_utils_render_html.py | 70 ++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index eb9a181c6..a9599796f 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -763,17 +763,56 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: - """Get the mark number for a footnote based on unique footnote text.""" if not data._footnotes or not footnote_info.footnotes: return 1 - # Get all unique footnote texts in order of first appearance - unique_footnotes = [] + # 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 fn_info.footnotes: - footnote_text = fn_info.footnotes[0] # Use first footnote text - if footnote_text not in unique_footnotes: - unique_footnotes.append(footnote_text) + if not fn_info.footnotes or fn_info.locname == "none": + continue + + footnote_text = fn_info.footnotes[0] + + # Assign locnum (location number) based on R gt table location hierarchy + # Lower numbers appear first in reading order + if fn_info.locname == "title": + locnum = 1 + elif fn_info.locname == "subtitle": + locnum = 2 + elif fn_info.locname == "columns_columns": # Column headers + locnum = 3 + elif fn_info.locname == "data": # Table body + locnum = 4 + elif fn_info.locname == "summary": + locnum = 5 + elif fn_info.locname == "grand_summary": + locnum = 6 + else: + locnum = 999 # Other locations come last + + # Get colnum (column number) - 0-based index of column + 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, colnum, rownum) - this matches R gt behavior + sort_key = (locnum, colnum, rownum) + footnote_positions.append((sort_key, footnote_text)) + + # Sort by (locnum, colnum, rownum) - headers before body, left-to-right, top-to-bottom + 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 number for this footnote's text if footnote_info.footnotes: @@ -786,6 +825,19 @@ def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: return 1 +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 _add_footnote_marks_to_text( data: GTData, text: str, @@ -798,7 +850,7 @@ def _add_footnote_marks_to_text( return text # Find footnotes that match this location - matching_footnotes = [] + matching_footnotes: list[tuple[int, FootnoteInfo]] = [] for footnote in data._footnotes: if footnote.locname == locname: # Check if this footnote targets this specific location @@ -819,7 +871,7 @@ def _add_footnote_marks_to_text( return text # Create footnote marks - marks = [] + marks: list[str] = [] for mark_num, footnote in matching_footnotes: mark_html = _create_footnote_mark_html(str(mark_num)) marks.append(mark_html) From a07f78fdcb05b67ae2e6970ce1373cac66f9dc1f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 17:44:00 -0400 Subject: [PATCH 07/51] Enable the footnotes_marks option in Options class --- great_tables/_gt_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py index 4c33631e0..f1e20fb26 100644 --- a/great_tables/_gt_data.py +++ b/great_tables/_gt_data.py @@ -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") From 75557b41727e4eda730efa44a453dcc12c7492b1 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 17:44:28 -0400 Subject: [PATCH 08/51] Enable the footnotes_marks parameter in tab_options() --- great_tables/_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/great_tables/_options.py b/great_tables/_options.py index f4ab0fc8a..4ce1b28ba 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, From 7673067e4c16746a5779721027da3409e56edb6c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 17:44:43 -0400 Subject: [PATCH 09/51] Add support for custom footnote mark symbols --- great_tables/_utils_render_html.py | 158 ++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 26 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index a9599796f..9bdd84f92 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -717,8 +717,8 @@ def _process_footnotes_for_display( if not footnotes: return [] - # Group footnotes by their text to avoid duplicates - footnote_texts: dict[str, int] = {} + # 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 footnotes: @@ -728,8 +728,9 @@ def _process_footnotes_for_display( if footnote.footnotes: text = footnote.footnotes[0] if footnote.footnotes else "" - if text not in footnote_texts: - footnote_texts[text] = len(footnote_texts) + 1 + if text not in footnote_data: + mark_string = _get_footnote_mark_string(data, footnote) + footnote_data[text] = mark_string footnote_order.append(text) # Add footnotes without marks at the beginning @@ -741,14 +742,90 @@ def _process_footnotes_for_display( if footnote.footnotes: result.append({"mark": "", "text": footnote.footnotes[0]}) - # Add footnotes with marks - for text in footnote_order: - mark_number = footnote_texts[text] - result.append({"mark": str(mark_number), "text": text}) + # Add footnotes with marks - sort by mark order + # For numbers, sort numerically; for symbols, they should already be in order + mark_type = _get_footnote_marks_option(data) + if isinstance(mark_type, str) and mark_type == "numbers": + # Sort by numeric mark value + sorted_texts = sorted( + footnote_order, + key=lambda text: int(footnote_data[text]) + if footnote_data[text].isdigit() + else float("inf"), + ) + else: + # For symbols, maintain the order they appear (visual order) + 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]]: + """Get predefined footnote mark symbol sets.""" + from ._helpers import LETTERS, letters + + return { + "numbers": [], # Special case - handled separately + "letters": letters(), + "LETTERS": LETTERS(), + "standard": ["*", "†", "‡", "§"], + "extended": ["*", "†", "‡", "§", "‖", "¶"], + } + + +def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbers") -> str: + """Generate a footnote mark based on index and mark type. + + Args: + mark_index: 1-based index for the footnote mark + mark_type: Either a string key for preset marks or a list of custom marks + + Returns: + String representation of the footnote mark + """ + 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]: + """Get the footnote marks option from GT data.""" + # 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 "" @@ -762,9 +839,11 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: return f'{mark}' -def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: +def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: + """Get the mark string for a footnote based on R gt sorting and mark type.""" if not data._footnotes or not footnote_info.footnotes: - return 1 + 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]] = [] @@ -814,15 +893,30 @@ def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: if text not in unique_footnotes: unique_footnotes.append(text) - # Find the mark number for this footnote's text + # Find the mark index for this footnote's text if footnote_info.footnotes: footnote_text = footnote_info.footnotes[0] try: - return unique_footnotes.index(footnote_text) + 1 # 1-based indexing + mark_index = unique_footnotes.index(footnote_text) + 1 # 1-based indexing + mark_type = _get_footnote_marks_option(data) + return _generate_footnote_mark(mark_index, mark_type) except ValueError: - return 1 + 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) - return 1 + +def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: + """Legacy function - now wraps _get_footnote_mark_string for backward compatibility.""" + mark_string = _get_footnote_mark_string(data, footnote_info) + # Try to convert to int for numeric marks, otherwise return 1 + try: + return int(mark_string) + except ValueError: + # For symbol marks, we need a different approach in the calling code + return 1 def _get_column_index(data: GTData, colname: str | None) -> int: @@ -850,7 +944,7 @@ def _add_footnote_marks_to_text( return text # Find footnotes that match this location - matching_footnotes: list[tuple[int, FootnoteInfo]] = [] + matching_footnotes: list[tuple[str, FootnoteInfo]] = [] for footnote in data._footnotes: if footnote.locname == locname: # Check if this footnote targets this specific location @@ -864,21 +958,33 @@ def _add_footnote_marks_to_text( match = False if match: - mark_num = _get_footnote_mark_number(data, footnote) - matching_footnotes.append((mark_num, footnote)) + mark_string = _get_footnote_mark_string(data, footnote) + matching_footnotes.append((mark_string, footnote)) if not matching_footnotes: return text - # Create footnote marks - marks: list[str] = [] - for mark_num, footnote in matching_footnotes: - mark_html = _create_footnote_mark_html(str(mark_num)) - marks.append(mark_html) - - # Add marks to the text - if marks: - marks_html = "".join(marks) + # Collect unique mark strings and sort them properly + mark_strings: list[str] = [] + for mark_string, footnote in matching_footnotes: + if mark_string not in mark_strings: + mark_strings.append(mark_string) + + # 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}' return f"{text}{marks_html}" return text From cf770e757aed02a10924fcf3e8a791d6f9042d76 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 21:13:34 -0400 Subject: [PATCH 10/51] Add footnote marks to col labels in HTML rendering --- great_tables/_utils_render_html.py | 33 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 9bdd84f92..e81e9b8e4 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -268,10 +268,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, @@ -334,9 +339,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, @@ -830,13 +843,13 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: if not mark: return "" - # For now, use simple superscript numbers + # Use consistent span structure for both references and footer if location == "ftr": # In footer, show mark with period - return f'{mark}.' + return f'{mark}.' else: - # In text, show mark as superscript - return f'{mark}' + # In text, show mark without period + return f'{mark}' def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: @@ -880,11 +893,11 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: else: rownum = fn_info.rownum if fn_info.rownum is not None else 0 - # Sort key: (locnum, colnum, rownum) - this matches R gt behavior - sort_key = (locnum, colnum, rownum) + # Sort key: (locnum, rownum, colnum) - this matches reading order: top-to-bottom, left-to-right + sort_key = (locnum, rownum, colnum) footnote_positions.append((sort_key, footnote_text)) - # Sort by (locnum, colnum, rownum) - headers before body, left-to-right, top-to-bottom + # Sort by (locnum, rownum, colnum) - headers before body, top-to-bottom, left-to-right footnote_positions.sort(key=lambda x: x[0]) # Get unique footnote texts in sorted order @@ -984,7 +997,7 @@ def _add_footnote_marks_to_text( if mark_strings: # Join mark strings with commas (no spaces) marks_text = ",".join(mark_strings) - marks_html = f'{marks_text}' + marks_html = f'{marks_text}' return f"{text}{marks_html}" return text From b23cfc645abff44006b01f480d253c9db043d779 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 21:13:47 -0400 Subject: [PATCH 11/51] Add several tests --- tests/test_footnotes.py | 332 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/test_footnotes.py diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py new file mode 100644 index 000000000..d69fabf3e --- /dev/null +++ b/tests/test_footnotes.py @@ -0,0 +1,332 @@ +import polars as pl +import re +from great_tables import GT, loc + + +def _create_test_data(): + # Create DataFrame with potential for stub and two row groups + 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(): + # Test basic footnote creation and HTML rendering + 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 + assert re.search(r"10]*>1", html) + + +def test_tab_footnote_numeric_marks(): + # Test numeric footnote marks (default type of 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 + assert re.search(r"10]*>1", html) # First cell + assert re.search(r"200]*>2", html) # Second cell + assert re.search(r"3000]*>3", html) # Third cell + + +def test_tab_footnote_mark_coalescing(): + # Test that multiple footnotes on same location show up as comma-separated 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="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" + assert re.search(r"10]*>1,2", html) + # Second cell should have single mark "3" + assert re.search(r"200]*>3", html) + + +def test_tab_footnote_ordering(): + # Test that footnotes are ordered left-to-right, top-to-bottom + 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) + assert re.search(r">col1]*>1", html) + # First body cell should get mark 2 + assert re.search(r"10]*>2", html) + # Later body cell should get mark 3 + assert re.search(r"200]*>3", html) + + +def test_tab_footnote_all_locations(): + # Test that footnotes can be placed in all major 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(): + # Test "standard" symbol 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])) + .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 + 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(): + # Test "extended" symbol marks + 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) + symbols = ["*", "†", "‡", "§", "‖", "¶"] + values = [10, 100, 1000, 20, 200, 2000] + + for symbol, value in zip(symbols, values): + escaped_symbol = re.escape(symbol) + assert re.search(f"{value}]*>{escaped_symbol}", html) + + +def test_tab_footnote_symbol_marks_letters(): + # Test letter-based 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 + assert re.search(r"10]*>a", html) + assert re.search(r"100]*>b", html) + assert re.search(r"1000]*>c", html) + + +def test_tab_footnote_symbol_marks_uppercase_letters(): + # Test uppercase letter 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 uppercase letter marks appear + assert re.search(r"10]*>A", html) + assert re.search(r"100]*>B", html) + assert re.search(r"1000]*>C", html) + + +def test_tab_footnote_custom_symbol_marks(): + # Test 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) + assert re.search(r"10]*>❶", html) + assert re.search(r"100]*>❷", html) + assert re.search(r"1000]*>❸", html) + + +def test_tab_footnote_symbol_cycling(): + # Test the symbol cycling feature (when there are more footnotes than symbols) + 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 + 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(): + # Test symbol mark coalescing with commas + 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 + assert re.search(r"10]*>\*,†", html) + # The second cell should have a single symbol mark + assert re.search(r"100]*>‡", html) + + +def test_tab_footnote_multiple_rows(): + # Test a single footnote targeting 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 + assert re.search(r"10]*>1", html) + assert re.search(r"20]*>1", html) + assert re.search(r"30]*>1", html) + + +def test_tab_footnote_multiple_columns(): + # Test footnote targeting 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 + assert re.search(r"10]*>1", html) + assert re.search(r"100]*>1", html) + + +def test_tab_footnote_footer_rendering(): + # Test that the footnote footer section is properly rendered + 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 footer section exists + assert re.search(r"]*>.*?", html, re.DOTALL) + + # Check footnotes appear in footer with correct marks + footer_match = re.search(r"]*>.*?", html, re.DOTALL) + 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 (not using a basic string) + from great_tables._text import Text + + 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 + assert re.search(r"10]*>1", html) + # The text object content should appear in footer + assert "Bold text" in html From 298e3f149888afb2df2fa1329ea4c0fdbb0ced34 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 23:30:43 -0400 Subject: [PATCH 12/51] Hide footnotes for hidden columns in HTML rendering --- great_tables/_utils_render_html.py | 38 +++++++++++++++++++----------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index e81e9b8e4..2216eca49 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -724,17 +724,34 @@ def create_footnotes_component_h(data: GTData): ) +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)] + # 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 footnotes: + for footnote in visible_footnotes: if footnote.locname == "none": # type: ignore # Footnotes without marks come first continue @@ -746,8 +763,8 @@ def _process_footnotes_for_display( footnote_data[text] = mark_string footnote_order.append(text) - # Add footnotes without marks at the beginning - markless_footnotes = [f for f in footnotes if f.locname == "none"] # type: ignore + # 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 @@ -778,7 +795,6 @@ def _process_footnotes_for_display( def _get_footnote_mark_symbols() -> dict[str, list[str]]: - """Get predefined footnote mark symbol sets.""" from ._helpers import LETTERS, letters return { @@ -791,15 +807,6 @@ def _get_footnote_mark_symbols() -> dict[str, list[str]]: def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbers") -> str: - """Generate a footnote mark based on index and mark type. - - Args: - mark_index: 1-based index for the footnote mark - mark_type: Either a string key for preset marks or a list of custom marks - - Returns: - String representation of the footnote mark - """ if isinstance(mark_type, str): if mark_type == "numbers": return str(mark_index) @@ -865,6 +872,10 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: 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 = fn_info.footnotes[0] # Assign locnum (location number) based on R gt table location hierarchy @@ -922,7 +933,6 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: - """Legacy function - now wraps _get_footnote_mark_string for backward compatibility.""" mark_string = _get_footnote_mark_string(data, footnote_info) # Try to convert to int for numeric marks, otherwise return 1 try: From d679a163f826f5732d8bd6bfdd9d13ad4310a73c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 23:32:49 -0400 Subject: [PATCH 13/51] Add several tests --- tests/test_footnotes.py | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index d69fabf3e..a7419d832 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -330,3 +330,104 @@ def test_tab_footnote_with_text_object(): assert re.search(r"10]*>1", html) # The text object content should appear in 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")) # Visible + .tab_footnote( + footnote="Note A", locations=loc.column_labels(columns="col2") + ) # Hidden (same text) + .tab_footnote( + footnote="Note A", locations=loc.column_labels(columns="col3") + ) # Visible (same text) + .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col2")) # Hidden only + .tab_footnote( + footnote="Note B", locations=loc.column_labels(columns="col4") + ) # Hidden only (same text) + .tab_footnote(footnote="Note C", locations=loc.column_labels(columns="col1")) # Visible + .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 texts 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 + + # Verify that duplicate footnote text gets same mark number + # Note A appears on both col1 and col3 but should use the same mark (1) + + +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" From fea22711d3535bb5ab9fedee4089d1fb7604c4b9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 23:35:16 -0400 Subject: [PATCH 14/51] Update test_footnotes.py --- tests/test_footnotes.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index a7419d832..7059ae2d3 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -295,7 +295,7 @@ def test_tab_footnote_multiple_columns(): def test_tab_footnote_footer_rendering(): - # Test that the footnote footer section is properly rendered + # Test that the footnotes section is properly rendered gt_table = ( _create_base_gt() .tab_footnote(footnote="First footnote text", locations=loc.body(columns="col1", rows=[0])) @@ -305,11 +305,9 @@ def test_tab_footnote_footer_rendering(): html = gt_table._render_as_html() - # Check footer section exists - assert re.search(r"]*>.*?", html, re.DOTALL) - # 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) @@ -328,7 +326,7 @@ def test_tab_footnote_with_text_object(): # Check that the footnote mark appears assert re.search(r"10]*>1", html) - # The text object content should appear in footer + # Check that the text object content should appear in the footer assert "Bold text" in html From 8eba278f1b7f31be8aae1cfcbe9b82d725585e67 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 11 Aug 2025 23:46:41 -0400 Subject: [PATCH 15/51] Remove period from footnote marks in footer HTML --- great_tables/_utils_render_html.py | 4 ++-- tests/test_footnotes.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 2216eca49..9b028f1b2 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -852,8 +852,8 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: # Use consistent span structure for both references and footer if location == "ftr": - # In footer, show mark with period - return f'{mark}.' + # In footer, show mark without period + return f'{mark}' else: # In text, show mark without period return f'{mark}' diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 7059ae2d3..46505a108 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -310,8 +310,8 @@ def test_tab_footnote_footer_rendering(): 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) + 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(): From f0ac15dfd85a199af39c3d70122ca148b3ebec0d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 09:29:57 -0400 Subject: [PATCH 16/51] Add tab_footnote() to the API reference docs --- docs/_quarto.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index f21c42f58..5bff5e3c0 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 From aca0cb11c4a10f73b2a4e2ea3a2675110be8771e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 15:46:38 -0400 Subject: [PATCH 17/51] Improve footnote handling and ordering in rendered tbl --- great_tables/_footnotes.py | 61 ++++++++++++++-- great_tables/_utils_render_html.py | 113 +++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py index b94d6c3b2..70fab6dbe 100644 --- a/great_tables/_footnotes.py +++ b/great_tables/_footnotes.py @@ -77,14 +77,63 @@ def tab_footnote( Examples -------- - See [`GT.tab_footnote()`](`great_tables.GT.tab_footnote`) for 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') + ) + ``` """ - # Convert footnote to string if it's a Text object - if hasattr(footnote, "__str__"): - footnote_str = str(footnote) - else: - footnote_str = footnote + # Store footnote as-is to preserve Text objects for later processing + footnote_str = footnote # Handle None locations (footnote without mark) if locations is None: diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 9b028f1b2..998907aff 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -524,17 +524,24 @@ def create_body_component_h(data: GTData) -> str: cell_content: Any = _get_cell(tbl_data, i, colinfo.var) cell_str: str = str(cell_content) - # Add footnote marks to cell content if applicable - cell_str = _add_footnote_marks_to_text( - data, cell_str, "data", colname=colinfo.var, rownum=i - ) - # 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 @@ -747,21 +754,58 @@ def _process_footnotes_for_display( # 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 + if fn_info.locname == "title": + locnum = 1 + elif fn_info.locname == "subtitle": + locnum = 2 + elif fn_info.locname == "columns_columns": + locnum = 3 + elif fn_info.locname == "data": + locnum = 4 + elif fn_info.locname == "stub": + locnum = 5 + elif fn_info.locname == "summary": + locnum = 6 + elif fn_info.locname == "grand_summary": + locnum = 7 + else: + locnum = 999 + + 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 visible_footnotes: - if footnote.locname == "none": # type: ignore - # Footnotes without marks come first - continue - + for footnote in sorted_footnotes: if footnote.footnotes: - text = footnote.footnotes[0] if footnote.footnotes else "" - if text not in footnote_data: + 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[text] = mark_string - footnote_order.append(text) + 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 @@ -770,13 +814,15 @@ def _process_footnotes_for_display( # Add markless footnotes first for footnote in markless_footnotes: if footnote.footnotes: - result.append({"mark": "", "text": footnote.footnotes[0]}) + processed_text = _process_text(footnote.footnotes[0]) + result.append({"mark": "", "text": processed_text}) - # Add footnotes with marks - sort by mark order - # For numbers, sort numerically; for symbols, they should already be in order + # Add footnotes with marks - 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": - # Sort by numeric mark value + # For numbers, sort by numeric mark value to handle any edge cases sorted_texts = sorted( footnote_order, key=lambda text: int(footnote_data[text]) @@ -784,7 +830,7 @@ def _process_footnotes_for_display( else float("inf"), ) else: - # For symbols, maintain the order they appear (visual order) + # For letters/symbols, maintain visual order (don't sort alphabetically) sorted_texts = footnote_order for text in sorted_texts: @@ -876,22 +922,24 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: if not _should_display_footnote(data, fn_info): continue - footnote_text = fn_info.footnotes[0] + footnote_text = _process_text(fn_info.footnotes[0]) - # Assign locnum (location number) based on R gt table location hierarchy - # Lower numbers appear first in reading order + # Assign locnum (location number) based on the location hierarchy where + # lower numbers appear first in reading order if fn_info.locname == "title": locnum = 1 elif fn_info.locname == "subtitle": locnum = 2 - elif fn_info.locname == "columns_columns": # Column headers + elif fn_info.locname == "columns_columns": locnum = 3 - elif fn_info.locname == "data": # Table body + elif fn_info.locname == "data": locnum = 4 - elif fn_info.locname == "summary": + elif fn_info.locname == "stub": locnum = 5 - elif fn_info.locname == "grand_summary": + elif fn_info.locname == "summary": locnum = 6 + elif fn_info.locname == "grand_summary": + locnum = 7 else: locnum = 999 # Other locations come last @@ -904,11 +952,12 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: else: rownum = fn_info.rownum if fn_info.rownum is not None else 0 - # Sort key: (locnum, rownum, colnum) - this matches reading order: top-to-bottom, left-to-right + # 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, top-to-bottom, left-to-right + # Sort by (locnum, rownum, colnum): headers before body footnote_positions.sort(key=lambda x: x[0]) # Get unique footnote texts in sorted order @@ -919,9 +968,9 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: # Find the mark index for this footnote's text if footnote_info.footnotes: - footnote_text = footnote_info.footnotes[0] + footnote_text = _process_text(footnote_info.footnotes[0]) try: - mark_index = unique_footnotes.index(footnote_text) + 1 # 1-based indexing + 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: @@ -1000,7 +1049,7 @@ def _add_footnote_marks_to_text( 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 + # since _get_footnote_mark_string() returns them in visual order pass # Create a single footnote mark span with comma-separated marks From 71daaef799c2b423e2ca7746214507af2deebbb6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 15:49:05 -0400 Subject: [PATCH 18/51] Refactor footnote mark HTML generation --- great_tables/_utils_render_html.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 998907aff..faf209708 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -881,7 +881,6 @@ def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbe def _get_footnote_marks_option(data: GTData) -> str | list[str]: - """Get the footnote marks option from GT data.""" # Read from the options system if hasattr(data, "_options") and hasattr(data._options, "footnotes_marks"): marks_value = data._options.footnotes_marks.value @@ -897,12 +896,7 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: return "" # Use consistent span structure for both references and footer - if location == "ftr": - # In footer, show mark without period - return f'{mark}' - else: - # In text, show mark without period - return f'{mark}' + return f'{mark}' def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: From 76727f867a0a34b9a7f74487695eac65a0b8d17c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 16:36:02 -0400 Subject: [PATCH 19/51] Align stub and data cell footnote ordering --- great_tables/_utils_render_html.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index faf209708..7fec4fa84 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -772,15 +772,19 @@ def _process_footnotes_for_display( elif fn_info.locname == "data": locnum = 4 elif fn_info.locname == "stub": - locnum = 5 + locnum = 4 # Same as data since stub and data cells are on the same row level elif fn_info.locname == "summary": - locnum = 6 + locnum = 5 elif fn_info.locname == "grand_summary": - locnum = 7 + locnum = 6 else: locnum = 999 - colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0 + # 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" @@ -929,16 +933,19 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: elif fn_info.locname == "data": locnum = 4 elif fn_info.locname == "stub": - locnum = 5 + locnum = 4 # Same as data since stub and data cells are on the same row level elif fn_info.locname == "summary": - locnum = 6 + locnum = 5 elif fn_info.locname == "grand_summary": - locnum = 7 + locnum = 6 else: locnum = 999 # Other locations come last - # Get colnum (column number) - 0-based index of column - colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0 + # Get colnum (column number) - assign stub 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 # Get rownum - for headers use 0, for body use actual row number if fn_info.locname == "columns_columns": From 80f2ab36731b98db5b725086d4a43c73447bba3d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 16:54:51 -0400 Subject: [PATCH 20/51] Add snapshot test for footnotes in stub and body --- tests/__snapshots__/test_footnotes.ambr | 15 ++++++++++++ tests/test_footnotes.py | 32 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/__snapshots__/test_footnotes.ambr diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr new file mode 100644 index 000000000..7b59371ed --- /dev/null +++ b/tests/__snapshots__/test_footnotes.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_tab_footnote_stub_body_ordering_snapshot + ''' + + + A1 + Y + + + B + Z2 + + + ''' +# --- diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 46505a108..8b2c12037 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1,6 +1,14 @@ import polars as pl import re from great_tables import GT, loc +from great_tables._utils_render_html import create_body_component_h + + +def assert_rendered_body(snapshot, gt): + built = gt._build_data("html") + body = create_body_component_h(built) + + assert snapshot == body def _create_test_data(): @@ -429,3 +437,27 @@ def test_tab_footnote_mixed_locations_hidden(): ) 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) From 7d37e5086484d7deb4d69f024a211ab7a47346ca Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 12 Aug 2025 17:58:42 -0400 Subject: [PATCH 21/51] Support unit notation in HTML/Markdown footnotes --- great_tables/_text.py | 19 +++++++++++++++++-- tests/test_footnotes.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) 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/tests/test_footnotes.py b/tests/test_footnotes.py index 8b2c12037..7ca4f4f0e 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1,6 +1,6 @@ import polars as pl import re -from great_tables import GT, loc +from great_tables import GT, loc, md, html from great_tables._utils_render_html import create_body_component_h @@ -461,3 +461,36 @@ def test_tab_footnote_stub_body_ordering_snapshot(snapshot): # Use assert_rendered_body to create a smaller, focused snapshot assert_rendered_body(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 + ) From 1f28a4a184bc8600f8e9037e2937b14b2b7a4e30 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 10:31:16 -0400 Subject: [PATCH 22/51] Add unified HTML footer for source notes and footnotes --- great_tables/_utils_render_html.py | 93 ++++++++++++++++++++++--- great_tables/css/gt_styles_default.scss | 69 ++++++++++++------ great_tables/gt.py | 11 ++- 3 files changed, 133 insertions(+), 40 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 7fec4fa84..bc77b0268 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -731,6 +731,78 @@ def create_footnotes_component_h(data: GTData): ) +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: @@ -754,8 +826,9 @@ def _process_footnotes_for_display( # 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 + # 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: @@ -821,8 +894,8 @@ def _process_footnotes_for_display( processed_text = _process_text(footnote.footnotes[0]) result.append({"mark": "", "text": processed_text}) - # Add footnotes with marks - maintain visual order (order they appear in table) - # The footnote_order list already contains footnotes in visual order based on how + # 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": @@ -848,7 +921,7 @@ def _get_footnote_mark_symbols() -> dict[str, list[str]]: from ._helpers import LETTERS, letters return { - "numbers": [], # Special case - handled separately + "numbers": [], "letters": letters(), "LETTERS": LETTERS(), "standard": ["*", "†", "‡", "§"], @@ -876,8 +949,8 @@ def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbe 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. + # 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 @@ -941,13 +1014,13 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: else: locnum = 999 # Other locations come last - # Get colnum (column number) - assign stub a lower value than data columns + # 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 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 + # 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: @@ -1043,7 +1116,7 @@ def _add_footnote_marks_to_text( if mark_string not in mark_strings: mark_strings.append(mark_string) - # Sort marks - for numbers, sort numerically; for symbols, sort by their order in symbol set + # 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 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 ab3143daa..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, @@ -63,16 +64,14 @@ from ._stubhead import tab_stubhead from ._substitution import sub_missing, sub_zero from ._tab_create_modify import tab_style -from ._footnotes import tab_footnote from ._tbl_data import _get_cell, n_rows from ._utils import _migrate_unformatted_to_output from ._utils_render_html import ( _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: @@ -366,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) @@ -394,8 +392,7 @@ def _render_as_html( {column_labels_component} {body_component} -{source_notes_component} -{footnotes_component} +{footer_component} """ From 71107cb1a4b43ddac5fbf274ba5b3416523b86f4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 10:31:41 -0400 Subject: [PATCH 23/51] Update tests and snapshots --- tests/__snapshots__/test_export.ambr | 17 +- tests/__snapshots__/test_formats.ambr | 2 - tests/__snapshots__/test_options.ambr | 276 ++++++++++++------ tests/__snapshots__/test_repr.ambr | 35 ++- tests/__snapshots__/test_scss.ambr | 69 +++-- .../__snapshots__/test_utils_render_html.ambr | 9 +- tests/test_footnotes.py | 33 +++ 7 files changed, 289 insertions(+), 152 deletions(-) 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_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 index 7ca4f4f0e..f7f441ddc 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -494,3 +494,36 @@ def test_tab_footnote_html_with_unit_notation(): '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 From d9c44cbe52a868c84422d27dd6832a4e283820f2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 10:37:08 -0400 Subject: [PATCH 24/51] Modify docstring for tab_footnote() --- great_tables/_footnotes.py | 116 ++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 61 deletions(-) diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py index 70fab6dbe..09153156f 100644 --- a/great_tables/_footnotes.py +++ b/great_tables/_footnotes.py @@ -18,66 +18,61 @@ def tab_footnote( """ Add a table footnote. - `tab_footnote()` can make it a painless process to add a footnote to a - **Great Tables** 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. + `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: + 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). + 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. + 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.). + 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. + 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. + 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 @@ -90,44 +85,43 @@ def tab_footnote( 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) + .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') + GT(towny_mini, rowname_col="name") .tab_header( - title=md('The 10 Largest Municipalities in `towny`'), - subtitle='Population values taken from the 2021 census.' + 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' + density_2021="Density", + population_2021="Population" ) .tab_footnote( - footnote='Part of the Greater Toronto Area.', + footnote="Part of the Greater Toronto Area.", locations=loc.stub(rows=[ - 'Toronto', 'Mississauga', 'Brampton', 'Markham', 'Vaughan' + "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') + 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.', + footnote="Census results made public on February 9, 2022.", locations=loc.subtitle() ) .tab_source_note( - source_note=md('Data taken from the `towny` dataset.') + source_note=md("Data taken from the `towny` dataset.") ) - .opt_footnote_marks(marks='letters') + .opt_footnote_marks(marks="letters") ) ``` """ From 7a8d788d70dd77f50f8a9a4011e04042902f5198 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 11:48:03 -0400 Subject: [PATCH 25/51] Ensure that spanners and stubhead label can have footnotes --- great_tables/_utils_render_html.py | 71 +++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index bc77b0268..e2e589abb 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -174,7 +174,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), @@ -237,7 +241,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), @@ -245,9 +253,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] @@ -301,7 +307,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", @@ -391,7 +404,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: @@ -1001,22 +1021,29 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: locnum = 1 elif fn_info.locname == "subtitle": locnum = 2 - elif fn_info.locname == "columns_columns": + elif fn_info.locname == "stubhead": locnum = 3 - elif fn_info.locname == "data": + elif fn_info.locname == "columns_groups": locnum = 4 + elif fn_info.locname == "columns_columns": + locnum = 5 + elif fn_info.locname == "data": + locnum = 6 elif fn_info.locname == "stub": - locnum = 4 # Same as data since stub and data cells are on the same row level + locnum = 6 # Same as data since stub and data cells are on the same row level elif fn_info.locname == "summary": - locnum = 5 + locnum = 7 elif fn_info.locname == "grand_summary": - locnum = 6 + locnum = 8 else: locnum = 999 # Other locations come last # 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 @@ -1078,6 +1105,26 @@ def _get_column_index(data: GTData, colname: str | None) -> int: return 0 +def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None) -> int: + """Get the leftmost column index for a spanner group to enable proper left-to-right ordering.""" + 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, From d8f44fb57723de0d45e41fa8060037cc1813de95 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 11:48:15 -0400 Subject: [PATCH 26/51] Update tests and snapshots --- tests/__snapshots__/test_footnotes.ambr | 41 ++++ tests/test_footnotes.py | 254 +++++++++++++++++++++--- 2 files changed, 262 insertions(+), 33 deletions(-) diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr index 7b59371ed..1196c6bb1 100644 --- a/tests/__snapshots__/test_footnotes.ambr +++ b/tests/__snapshots__/test_footnotes.ambr @@ -1,4 +1,45 @@ # serializer version: 1 +# name: test_tab_footnote_complete_ordering_snapshot + ''' +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title1
Subtitle2
Stubhead3 + Spanner A4 + + Spanner B5 +
col16col27
Row1810920
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 ''' diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index f7f441ddc..c1ad690d4 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -11,8 +11,16 @@ def assert_rendered_body(snapshot, gt): 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(): - # Create DataFrame with potential for stub and two row groups return pl.DataFrame( { "group": ["A", "A", "A", "B", "B", "B"], @@ -34,7 +42,6 @@ def _create_base_gt(): def test_tab_footnote_basic(): - # Test basic footnote creation and HTML rendering gt_table = _create_base_gt().tab_footnote( footnote="Test footnote", locations=loc.body(columns="col1", rows=[0]) ) @@ -48,7 +55,6 @@ def test_tab_footnote_basic(): def test_tab_footnote_numeric_marks(): - # Test numeric footnote marks (default type of marks) gt_table = ( _create_base_gt() .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) @@ -65,7 +71,6 @@ def test_tab_footnote_numeric_marks(): def test_tab_footnote_mark_coalescing(): - # Test that multiple footnotes on same location show up as comma-separated marks gt_table = ( _create_base_gt() .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) @@ -82,7 +87,6 @@ def test_tab_footnote_mark_coalescing(): def test_tab_footnote_ordering(): - # Test that footnotes are ordered left-to-right, top-to-bottom gt_table = ( _create_base_gt() .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0])) @@ -101,7 +105,6 @@ def test_tab_footnote_ordering(): def test_tab_footnote_all_locations(): - # Test that footnotes can be placed in all major locations gt_table = ( _create_base_gt() .tab_footnote(footnote="Title note", locations=loc.title()) @@ -133,7 +136,6 @@ def test_tab_footnote_all_locations(): def test_tab_footnote_symbol_marks_standard(): - # Test "standard" symbol marks gt_table = ( _create_base_gt() .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) @@ -153,7 +155,6 @@ def test_tab_footnote_symbol_marks_standard(): def test_tab_footnote_symbol_marks_extended(): - # Test "extended" symbol marks gt_table = ( _create_base_gt() .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0])) @@ -177,7 +178,6 @@ def test_tab_footnote_symbol_marks_extended(): def test_tab_footnote_symbol_marks_letters(): - # Test letter-based marks ("letters") gt_table = ( _create_base_gt() .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0])) @@ -195,7 +195,6 @@ def test_tab_footnote_symbol_marks_letters(): def test_tab_footnote_symbol_marks_uppercase_letters(): - # Test uppercase letter marks ("LETTERS") gt_table = ( _create_base_gt() .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0])) @@ -213,7 +212,6 @@ def test_tab_footnote_symbol_marks_uppercase_letters(): def test_tab_footnote_custom_symbol_marks(): - # Test custom symbol marks custom_marks = ["❶", "❷", "❸", "❹"] # using circled numbers gt_table = ( _create_base_gt() @@ -232,7 +230,6 @@ def test_tab_footnote_custom_symbol_marks(): def test_tab_footnote_symbol_cycling(): - # Test the symbol cycling feature (when there are more footnotes than symbols) gt_table = ( _create_base_gt() .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0])) @@ -258,7 +255,6 @@ def test_tab_footnote_symbol_cycling(): def test_tab_footnote_symbol_coalescing(): - # Test symbol mark coalescing with commas gt_table = ( _create_base_gt() .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0])) @@ -271,12 +267,12 @@ def test_tab_footnote_symbol_coalescing(): # The first cell should have a coalesced symbol marks assert re.search(r"10]*>\*,†", html) + # The second cell should have a single symbol mark assert re.search(r"100]*>‡", html) def test_tab_footnote_multiple_rows(): - # Test a single footnote targeting multiple rows gt_table = _create_base_gt().tab_footnote( footnote="Multiple rows note", locations=loc.body(columns="col1", rows=[0, 1, 2]) ) @@ -290,7 +286,6 @@ def test_tab_footnote_multiple_rows(): def test_tab_footnote_multiple_columns(): - # Test footnote targeting multiple columns gt_table = _create_base_gt().tab_footnote( footnote="Multiple columns note", locations=loc.body(columns=["col1", "col2"], rows=[0]) ) @@ -303,7 +298,6 @@ def test_tab_footnote_multiple_columns(): def test_tab_footnote_footer_rendering(): - # Test that the footnotes section is properly rendered gt_table = ( _create_base_gt() .tab_footnote(footnote="First footnote text", locations=loc.body(columns="col1", rows=[0])) @@ -316,8 +310,8 @@ def test_tab_footnote_footer_rendering(): # 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) + 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) @@ -334,6 +328,7 @@ def test_tab_footnote_with_text_object(): # Check that the footnote mark appears assert re.search(r"10]*>1", html) + # Check that the text object content should appear in the footer assert "Bold text" in html @@ -350,18 +345,12 @@ def test_tab_footnote_hidden_columns(): gt_table = ( GT(df) - .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col1")) # Visible - .tab_footnote( - footnote="Note A", locations=loc.column_labels(columns="col2") - ) # Hidden (same text) - .tab_footnote( - footnote="Note A", locations=loc.column_labels(columns="col3") - ) # Visible (same text) - .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col2")) # Hidden only - .tab_footnote( - footnote="Note B", locations=loc.column_labels(columns="col4") - ) # Hidden only (same text) - .tab_footnote(footnote="Note C", locations=loc.column_labels(columns="col1")) # Visible + .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"]) ) @@ -398,15 +387,12 @@ def test_tab_footnote_hidden_columns(): # Note B should not appear because it only targets hidden columns assert len(footer_matches) == 2 - # Check footnote texts and marks + # 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 - # Verify that duplicate footnote text gets same mark number - # Note A appears on both col1 and col3 but should use the same mark (1) - def test_tab_footnote_mixed_locations_hidden(): df = pl.DataFrame({"visible_col": [10], "hidden_col": [100]}) @@ -463,6 +449,37 @@ def test_tab_footnote_stub_body_ordering_snapshot(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]}) @@ -527,3 +544,174 @@ def test_footer_structure_combined(): # 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 From da34031afb15d818d401f0e897c999234b3fd2ae Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 12:14:01 -0400 Subject: [PATCH 27/51] Remove unneeded utility function --- great_tables/_utils_render_html.py | 66 ------------------------------ 1 file changed, 66 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index e2e589abb..bc26a2752 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -685,72 +685,6 @@ def create_source_notes_component_h(data: GTData) -> str: return source_notes_component -def create_footnotes_component_h(data: GTData): - footnotes = data._footnotes - - # If there are no footnotes, return an empty string - if len(footnotes) == 0: - return "" - - # Process footnotes and assign marks - footnotes_with_marks = _process_footnotes_for_display(data, footnotes) - - if len(footnotes_with_marks) == 0: - return "" - - # 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)] - - # Get footnote styles - footnote_styles = "" - if styles_footnotes: - footnote_styles = " ".join( - [ - style_attr - for style_info in styles_footnotes - for style in style_info.styles - for style_attr in [str(style)] - if style_attr - ] - ) - - # Get options for footnotes - multiline = True # Default to multiline for now - separator = " " # Default separator - - # Get effective number of columns for colspan - n_cols_total = _get_effective_number_of_columns(data) - - # Create footnote HTML - footnote_items = [] - 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") - footnote_html = f"{footnote_mark_html} {text}" - footnote_items.append(footnote_html) - - if multiline: - # Each footnote gets its own row - footnote_rows = [] - for item in footnote_items: - footnote_rows.append( - f'{item}' - ) - - return f'{"".join(footnote_rows)}' - else: - # All footnotes in a single row - combined_footnotes = separator.join(footnote_items) - return ( - f'' - f'' - f'
{combined_footnotes}
' - f"" - ) - - def create_footer_component_h(data: GTData) -> str: source_notes = data._source_notes footnotes = data._footnotes From cec470c3334c35a55b2c3b5ab130ad8945959da6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 12:26:41 -0400 Subject: [PATCH 28/51] Remove more unneeded code --- great_tables/_utils_render_html.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index bc26a2752..c1bf60138 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -800,10 +800,6 @@ def _process_footnotes_for_display( locnum = 4 elif fn_info.locname == "stub": locnum = 4 # Same as data since stub and data cells are on the same row level - elif fn_info.locname == "summary": - locnum = 5 - elif fn_info.locname == "grand_summary": - locnum = 6 else: locnum = 999 @@ -931,7 +927,6 @@ def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: - """Get the mark string for a footnote based on R gt sorting and mark type.""" if not data._footnotes or not footnote_info.footnotes: mark_type = _get_footnote_marks_option(data) return _generate_footnote_mark(1, mark_type) @@ -965,10 +960,6 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: locnum = 6 elif fn_info.locname == "stub": locnum = 6 # Same as data since stub and data cells are on the same row level - elif fn_info.locname == "summary": - locnum = 7 - elif fn_info.locname == "grand_summary": - locnum = 8 else: locnum = 999 # Other locations come last @@ -1040,7 +1031,6 @@ def _get_column_index(data: GTData, colname: str | None) -> int: def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None) -> int: - """Get the leftmost column index for a spanner group to enable proper left-to-right ordering.""" if not spanner_grpname: return 0 @@ -1117,17 +1107,6 @@ def _add_footnote_marks_to_text( return text -def _get_effective_number_of_columns(data: GTData) -> int: - """Get the effective number of columns for the table.""" - from ._gt_data import ColInfoTypeEnum - - # Count visible columns (default type) and stub columns - visible_cols = len([col for col in data._boxhead if col.type == ColInfoTypeEnum.default]) - stub_cols = len([col for col in data._boxhead if col.type == ColInfoTypeEnum.stub]) - - return visible_cols + stub_cols - - def rtl_modern_unicode_charset() -> str: """ Returns a string containing a regular expression that matches all characters From 832825b2f52c42091dde07bac56720e58a4a65c2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 12:33:34 -0400 Subject: [PATCH 29/51] Refactor footnote location hierarchy mapping --- great_tables/_utils_render_html.py | 47 ++++++++++++------------------ 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index c1bf60138..60106fbdf 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -12,6 +12,23 @@ 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): @@ -790,18 +807,7 @@ def _process_footnotes_for_display( continue # Assign locnum based on visual hierarchy - if fn_info.locname == "title": - locnum = 1 - elif fn_info.locname == "subtitle": - locnum = 2 - elif fn_info.locname == "columns_columns": - locnum = 3 - elif fn_info.locname == "data": - locnum = 4 - elif fn_info.locname == "stub": - locnum = 4 # Same as data since stub and data cells are on the same row level - else: - locnum = 999 + 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": @@ -946,22 +952,7 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: # Assign locnum (location number) based on the location hierarchy where # lower numbers appear first in reading order - if fn_info.locname == "title": - locnum = 1 - elif fn_info.locname == "subtitle": - locnum = 2 - elif fn_info.locname == "stubhead": - locnum = 3 - elif fn_info.locname == "columns_groups": - locnum = 4 - elif fn_info.locname == "columns_columns": - locnum = 5 - elif fn_info.locname == "data": - locnum = 6 - elif fn_info.locname == "stub": - locnum = 6 # Same as data since stub and data cells are on the same row level - else: - locnum = 999 # Other locations come last + 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": From 8c7fcd8e7f3f0311551ebb15417c937b625f8d80 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 13:27:03 -0400 Subject: [PATCH 30/51] Incorporate footnote placement (auto/left/right) --- great_tables/_utils_render_html.py | 63 ++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 60106fbdf..15521bdc7 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -6,7 +6,7 @@ from htmltools import HTML, TagList, css, tags from . import _locations as loc -from ._gt_data import FootnoteInfo, 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 @@ -1074,9 +1074,11 @@ def _add_footnote_marks_to_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) @@ -1085,7 +1087,7 @@ def _add_footnote_marks_to_text( 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 + # since `_get_footnote_mark_string()` returns them in visual order pass # Create a single footnote mark span with comma-separated marks @@ -1093,11 +1095,66 @@ def _add_footnote_marks_to_text( # Join mark strings with commas (no spaces) marks_text = ",".join(mark_strings) marks_html = f'{marks_text}' - return f"{text}{marks_html}" + + # 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 + + # Check if the core is just digits and decimal points + numeric_pattern = r"^[\d.]+$" + return bool(re.match(numeric_pattern, number_core)) + + def rtl_modern_unicode_charset() -> str: """ Returns a string containing a regular expression that matches all characters From 4b0a424e767fd7635e70b023c339d2eeb05439fe Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 13:28:01 -0400 Subject: [PATCH 31/51] Revise tests and snapshots --- tests/__snapshots__/test_footnotes.ambr | 2 +- tests/test_footnotes.py | 121 ++++++++++++++---------- 2 files changed, 70 insertions(+), 53 deletions(-) diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr index 1196c6bb1..b142d063a 100644 --- a/tests/__snapshots__/test_footnotes.ambr +++ b/tests/__snapshots__/test_footnotes.ambr @@ -29,7 +29,7 @@ Row18 - 109 + 9 10 20 diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index c1ad690d4..4699b8a56 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -4,6 +4,22 @@ from great_tables._utils_render_html import create_body_component_h +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 mark "3" + assert re.search(r"]*>3 200", html) + + def assert_rendered_body(snapshot, gt): built = gt._build_data("html") body = create_body_component_h(built) @@ -50,8 +66,8 @@ def test_tab_footnote_basic(): # Check that footnote appears in footer assert "Test footnote" in html - # Check that footnote mark appears in cell - assert re.search(r"10]*>1", 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(): @@ -64,10 +80,10 @@ def test_tab_footnote_numeric_marks(): html = gt_table._render_as_html() - # Check that marks appear in the correct order - assert re.search(r"10]*>1", html) # First cell - assert re.search(r"200]*>2", html) # Second cell - assert re.search(r"3000]*>3", html) # Third cell + # 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(): @@ -80,10 +96,10 @@ def test_tab_footnote_mark_coalescing(): html = gt_table._render_as_html() - # First cell should have coalesced marks "1,2" - assert re.search(r"10]*>1,2", html) - # Second cell should have single mark "3" - assert re.search(r"200]*>3", 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(): @@ -96,12 +112,12 @@ def test_tab_footnote_ordering(): html = gt_table._render_as_html() - # Header should get mark 1 (comes before body) + # 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 - assert re.search(r"10]*>2", html) - # Later body cell should get mark 3 - assert re.search(r"200]*>3", 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(): @@ -147,11 +163,11 @@ def test_tab_footnote_symbol_marks_standard(): html = gt_table._render_as_html() - # Check standard symbols appear in visual reading order - assert re.search(r"10]*>\*", html) - assert re.search(r"20]*>†", html) - assert re.search(r"200]*>‡", html) - assert re.search(r"3000]*>§", 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(): @@ -169,12 +185,13 @@ def test_tab_footnote_symbol_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"{value}]*>{escaped_symbol}", html) + assert re.search(f"]*>{escaped_symbol} {value}", html) def test_tab_footnote_symbol_marks_letters(): @@ -188,10 +205,10 @@ def test_tab_footnote_symbol_marks_letters(): html = gt_table._render_as_html() - # Check that the letter marks appear - assert re.search(r"10]*>a", html) - assert re.search(r"100]*>b", html) - assert re.search(r"1000]*>c", 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(): @@ -205,10 +222,10 @@ def test_tab_footnote_symbol_marks_uppercase_letters(): html = gt_table._render_as_html() - # Check that the uppercase letter marks appear - assert re.search(r"10]*>A", html) - assert re.search(r"100]*>B", html) - assert re.search(r"1000]*>C", 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(): @@ -223,10 +240,10 @@ def test_tab_footnote_custom_symbol_marks(): html = gt_table._render_as_html() - # Check that the custom marks appear (in the right order) - assert re.search(r"10]*>❶", html) - assert re.search(r"100]*>❷", html) - assert re.search(r"1000]*>❸", 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(): @@ -246,12 +263,12 @@ def test_tab_footnote_symbol_cycling(): html = gt_table._render_as_html() - # Check the cycling behavior - 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) + # 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(): @@ -265,11 +282,11 @@ def test_tab_footnote_symbol_coalescing(): html = gt_table._render_as_html() - # The first cell should have a coalesced symbol marks - assert re.search(r"10]*>\*,†", 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 - assert re.search(r"100]*>‡", 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(): @@ -279,10 +296,10 @@ def test_tab_footnote_multiple_rows(): html = gt_table._render_as_html() - # All three cells should have the same footnote mark - assert re.search(r"10]*>1", html) - assert re.search(r"20]*>1", html) - assert re.search(r"30]*>1", 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(): @@ -292,9 +309,9 @@ def test_tab_footnote_multiple_columns(): html = gt_table._render_as_html() - # Both cells in the first row should have the same footnote mark - assert re.search(r"10]*>1", html) - assert re.search(r"100]*>1", 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(): @@ -326,8 +343,8 @@ def test_tab_footnote_with_text_object(): html = gt_table._render_as_html() - # Check that the footnote mark appears - assert re.search(r"10]*>1", 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 From efb6186bdf5a33424860c15e394d99b81d484de8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 13:40:16 -0400 Subject: [PATCH 32/51] Refine numberlike detection --- great_tables/_utils_render_html.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 15521bdc7..fc8a7afbe 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -1150,9 +1150,10 @@ def _is_numeric_content(text: str) -> bool: if not number_core: return False - # Check if the core is just digits and decimal points - numeric_pattern = r"^[\d.]+$" - return bool(re.match(numeric_pattern, number_core)) + # 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: From f82b6efe555221bdb9834894c8228b77d2cfe1b2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 13:40:27 -0400 Subject: [PATCH 33/51] Update tests and snapshots --- tests/__snapshots__/test_footnotes.ambr | 100 ++++++++++++ tests/test_footnotes.py | 197 ++++++++++++++++++++++++ 2 files changed, 297 insertions(+) diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr index b142d063a..5ee4a2db9 100644 --- a/tests/__snapshots__/test_footnotes.ambr +++ b/tests/__snapshots__/test_footnotes.ambr @@ -1,4 +1,104 @@ # 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 '''
diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 4699b8a56..b35ed7e40 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -732,3 +732,200 @@ def test_tab_footnote_spanner_specific_functionality(): r'Group B]*class="gt_footnote_marks"[^>]*>([^<]+)', html ) assert len(group_b_marks) >= 1 + + +# =========================================================================================== +# Tests for utility functions +# =========================================================================================== + + +def test_is_numeric_content(): + from great_tables._utils_render_html import _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(): + """Test the _apply_footnote_placement function with different placement options.""" + from great_tables._utils_render_html import _apply_footnote_placement + from great_tables._gt_data import FootnotePlacement + + 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) From ed6d9adafa3e60514be99564c0f2dcdb425c0fb3 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 13:57:28 -0400 Subject: [PATCH 34/51] Add several tests for single-line source notes --- tests/test_footnotes.py | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index b35ed7e40..00d10a08b 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -929,3 +929,118 @@ def test_footnote_placement_snapshot_right_placement(snapshot): ) 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 From d990b4f8dbf18a50c2c24c45b648453f3d8bc357 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 14:23:53 -0400 Subject: [PATCH 35/51] Remove unneeded utility function --- great_tables/_utils_render_html.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index fc8a7afbe..06f6f9229 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -998,16 +998,6 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: return _generate_footnote_mark(1, mark_type) -def _get_footnote_mark_number(data: GTData, footnote_info: FootnoteInfo) -> int: - mark_string = _get_footnote_mark_string(data, footnote_info) - # Try to convert to int for numeric marks, otherwise return 1 - try: - return int(mark_string) - except ValueError: - # For symbol marks, we need a different approach in the calling code - return 1 - - def _get_column_index(data: GTData, colname: str | None) -> int: if not colname: return 0 From 95a057963900deb56e1b7963eadb84d702ac2255 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 14:36:55 -0400 Subject: [PATCH 36/51] Add tests for some edge cases --- tests/test_footnotes.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 00d10a08b..5a02287d5 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1044,3 +1044,29 @@ def test_footnote_and_source_note_integration(): 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(): + from great_tables._utils_render_html import _create_footnote_mark_html + + # Test that empty mark should return an empty string + result = _create_footnote_mark_html("") + assert result == "" + + +def test_footnote_mark_string_edge_cases(): + from great_tables._utils_render_html import _get_footnote_mark_string + from great_tables._gt_data import FootnoteInfo + + # 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" From 218b379cf73953a2a302858cd549f3bd455ff448 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 14:41:01 -0400 Subject: [PATCH 37/51] Add tests of _get_column_index() --- tests/test_footnotes.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 5a02287d5..8034e2a19 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1070,3 +1070,33 @@ def test_footnote_mark_string_edge_cases(): 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(): + from great_tables._utils_render_html import _get_column_index + + # Create test data + 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 From 84292b7090fbd9142d3006d93725c8b40d993b6b Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 14:47:56 -0400 Subject: [PATCH 38/51] Add tests for _get_spanner_leftmost_column_index() --- tests/test_footnotes.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 8034e2a19..467eb316a 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1,7 +1,11 @@ import polars as pl import re from great_tables import GT, loc, md, html -from great_tables._utils_render_html import create_body_component_h +from great_tables._utils_render_html import ( + create_body_component_h, + _get_column_index, + _get_spanner_leftmost_column_index, +) def test_tab_footnote_mark_coalescing(): @@ -1073,9 +1077,6 @@ def test_footnote_mark_string_edge_cases(): def test_get_column_index_edge_cases(): - from great_tables._utils_render_html import _get_column_index - - # Create test data df = pl.DataFrame({"col1": [1], "col2": [2], "col3": [3]}) gt_table = GT(df) data = gt_table._build_data("test") @@ -1100,3 +1101,26 @@ def test_get_column_index_edge_cases(): 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 From 3cc0da68e1ab2414e05d7ae69e6f83f09f41538f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 14:56:43 -0400 Subject: [PATCH 39/51] Refactor tests for tab_footnote() --- tests/test_footnotes.py | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index 467eb316a..eb5890d85 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1,29 +1,19 @@ 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 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 mark "3" - assert re.search(r"]*>3 200", html) - - def assert_rendered_body(snapshot, gt): built = gt._build_data("html") body = create_body_component_h(built) @@ -338,9 +328,7 @@ def test_tab_footnote_footer_rendering(): def test_tab_footnote_with_text_object(): - # Test a footnote with the Text object (not using a basic string) - from great_tables._text import Text - + # 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]) ) @@ -744,8 +732,6 @@ def test_tab_footnote_spanner_specific_functionality(): def test_is_numeric_content(): - from great_tables._utils_render_html import _is_numeric_content - # Test basic numbers assert _is_numeric_content("123") == True assert _is_numeric_content("123.45") == True @@ -798,10 +784,6 @@ def test_is_numeric_content(): def test_apply_footnote_placement(): - """Test the _apply_footnote_placement function with different placement options.""" - from great_tables._utils_render_html import _apply_footnote_placement - from great_tables._gt_data import FootnotePlacement - text = "123" marks_html = '1' @@ -1051,17 +1033,12 @@ def test_footnote_and_source_note_integration(): def test_create_footnote_mark_html_edge_cases(): - from great_tables._utils_render_html import _create_footnote_mark_html - # Test that empty mark should return an empty string result = _create_footnote_mark_html("") assert result == "" def test_footnote_mark_string_edge_cases(): - from great_tables._utils_render_html import _get_footnote_mark_string - from great_tables._gt_data import FootnoteInfo - # Test with empty GTData (no footnotes) empty_gt = GT(pl.DataFrame({"col": [1]})) footnote_info = FootnoteInfo(locname="body", rownum=0, colname="col", footnotes=["test"]) From 6b5830097e070d40913154ca9a379599b8708017 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 13 Aug 2025 15:55:51 -0400 Subject: [PATCH 40/51] Add opt_footnote_marks to API reference docs --- docs/_quarto.yml | 1 + great_tables/_options.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 5bff5e3c0..d4aac43f4 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -220,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/_options.py b/great_tables/_options.py index 4ce1b28ba..73a10f47c 100644 --- a/great_tables/_options.py +++ b/great_tables/_options.py @@ -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 From 21ca100c6b8b06616e87c5efdc634946b48d7e67 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Sep 2025 21:39:36 -0400 Subject: [PATCH 41/51] Refactor footnote location handling to use Loc objects --- great_tables/_gt_data.py | 2 +- great_tables/_locations.py | 31 +++--- great_tables/_utils_render_html.py | 166 +++++++++++++++++++---------- 3 files changed, 127 insertions(+), 72 deletions(-) diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py index f1e20fb26..05a441746 100644 --- a/great_tables/_gt_data.py +++ b/great_tables/_gt_data.py @@ -875,7 +875,7 @@ class FootnotePlacement(Enum): @dataclass(frozen=True) class FootnoteInfo: - locname: str | None = None + locname: Loc | None = None grpname: str | None = None colname: str | None = None locnum: int | float | None = None diff --git a/great_tables/_locations.py b/great_tables/_locations.py index 02280d87f..7ced0d147 100644 --- a/great_tables/_locations.py +++ b/great_tables/_locations.py @@ -3,7 +3,7 @@ import itertools from dataclasses import dataclass from functools import singledispatch -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Any, Callable, Literal, cast from typing_extensions import TypeAlias @@ -1078,10 +1078,12 @@ def set_footnote(loc: Loc, data: GTData, footnote: str, placement: PlacementOpti raise NotImplementedError(f"Unsupported location type: {type(loc)}") +# Register footnote for `None` location (no footnote mark in the table but the footnote text +# still appears in the footnotes section of the table, before the ordered footnotes) @set_footnote.register(type(None)) def _(loc: None, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: place = FootnotePlacement[placement] - info = FootnoteInfo(locname="none", footnotes=[footnote], placement=place) + info = FootnoteInfo(locname=None, footnotes=[footnote], placement=place) return data._replace(_footnotes=data._footnotes + [info]) @@ -1089,21 +1091,21 @@ def _(loc: None, data: GTData, footnote: str, placement: PlacementOptions) -> GT @set_footnote.register def _(loc: LocTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: place = FootnotePlacement[placement] - info = FootnoteInfo(locname="title", footnotes=[footnote], placement=place, locnum=1) + info = FootnoteInfo(locname=loc, 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) + info = FootnoteInfo(locname=loc, 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) + info = FootnoteInfo(locname=loc, footnotes=[footnote], placement=place, locnum=2.5) return data._replace(_footnotes=data._footnotes + [info]) @@ -1111,13 +1113,14 @@ def _(loc: LocStubhead, data: GTData, footnote: str, placement: PlacementOptions 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) + # Resolve which columns to target; the cast is needed because resolve() + # has a generic return type but we know it returns `list[tuple[str, int]]` for `LocColumnLabels` + name_pos_list = cast(list[tuple[str, int]], resolve(loc, data)) result = data - for name, pos in name_pos_list: + for name, _ in name_pos_list: info = FootnoteInfo( - locname="columns_columns", colname=name, footnotes=[footnote], placement=place, locnum=4 + locname=loc, colname=name, footnotes=[footnote], placement=place, locnum=4 ) result = result._replace(_footnotes=result._footnotes + [info]) @@ -1137,7 +1140,7 @@ def _(loc: LocSpannerLabels, data: GTData, footnote: str, placement: PlacementOp result = data for spanner_id in resolved_loc.ids: info = FootnoteInfo( - locname="columns_groups", + locname=loc, grpname=spanner_id, footnotes=[footnote], placement=place, @@ -1158,7 +1161,7 @@ def _(loc: LocRowGroups, data: GTData, footnote: str, placement: PlacementOption result = data for group_name in group_names: info = FootnoteInfo( - locname="row_groups", + locname=loc, grpname=group_name, footnotes=[footnote], placement=place, @@ -1179,7 +1182,7 @@ def _(loc: LocStub, data: GTData, footnote: str, placement: PlacementOptions) -> result = data for row_pos in row_positions: info = FootnoteInfo( - locname="stub", rownum=row_pos, footnotes=[footnote], placement=place, locnum=5 + locname=loc, rownum=row_pos, footnotes=[footnote], placement=place, locnum=5 ) result = result._replace(_footnotes=result._footnotes + [info]) @@ -1196,7 +1199,7 @@ def _(loc: LocBody, data: GTData, footnote: str, placement: PlacementOptions) -> result = data for pos in positions: info = FootnoteInfo( - locname="data", + locname=loc, colname=pos.colname, rownum=pos.row, footnotes=[footnote], @@ -1218,7 +1221,7 @@ def _(loc: LocSummary, data: GTData, footnote: str, placement: PlacementOptions) result = data for pos in positions: info = FootnoteInfo( - locname="summary_cells", + locname=loc, grpname=getattr(pos, "group_id", None), colname=pos.colname, rownum=pos.row, diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 06f6f9229..adf51538e 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -12,22 +12,31 @@ 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: + +def _get_locnum_for_footnote_location(locname: loc.Loc | None) -> int | float: + """Get the visual hierarchy order for footnote location ordering.""" if locname is None: return 999 - return FOOTNOTE_LOCATION_HIERARCHY.get(locname, 999) # Default to 999 for unknown locations + + # Visual hierarchy mapping for footnote location ordering + if isinstance(locname, loc.LocTitle): + return 1 + elif isinstance(locname, loc.LocSubTitle): + return 2 + elif isinstance(locname, loc.LocStubhead): + return 3 + elif isinstance(locname, loc.LocSpannerLabels): + return 4 + elif isinstance(locname, loc.LocColumnLabels): + return 5 + elif isinstance(locname, (loc.LocBody, loc.LocStub)): + return 6 # Same as data since stub and data cells are on the same row level + elif isinstance(locname, loc.LocRowGroups): + return 5 + elif isinstance(locname, loc.LocSummary): + return 5.5 + else: + return 999 # Default to 999 for unknown locations def _is_loc(loc: str | loc.Loc, cls: type[loc.Loc]): @@ -210,9 +219,16 @@ 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] + # Filter footnotes for column label footnotes + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocColumnLabels) and 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 + column_label_with_footnotes = _apply_footnotes_to_text( + footnotes=footnotes_i, data=data, text=_process_text(info.column_label) ) table_col_headings.append( @@ -800,23 +816,23 @@ def _process_footnotes_for_display( # 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]] = [] + footnote_positions: list[tuple[tuple[int | float, int, int], FootnoteInfo]] = [] for fn_info in visible_footnotes: - if fn_info.locname == "none": + if fn_info.locname is 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": + if isinstance(fn_info.locname, loc.LocStub): 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" + if isinstance(fn_info.locname, loc.LocColumnLabels) else (fn_info.rownum if fn_info.rownum is not None else 0) ) @@ -841,7 +857,7 @@ def _process_footnotes_for_display( 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 + markless_footnotes = [f for f in visible_footnotes if f.locname is None] # type: ignore result: list[dict[str, str]] = [] # Add markless footnotes first @@ -938,10 +954,10 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: 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]] = [] + footnote_positions: list[tuple[tuple[int | float, int, int], str]] = [] for fn_info in data._footnotes: - if not fn_info.footnotes or fn_info.locname == "none": + if not fn_info.footnotes or fn_info.locname is None: continue # Skip footnotes for hidden columns @@ -955,16 +971,16 @@ def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str: 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": + if isinstance(fn_info.locname, loc.LocStub): colnum = -1 # Stub appears before all data columns - elif fn_info.locname == "columns_groups": + elif isinstance(fn_info.locname, loc.LocSpannerLabels): # 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": + if isinstance(fn_info.locname, loc.LocColumnLabels): rownum = 0 # Headers are row 0 else: rownum = fn_info.rownum if fn_info.rownum is not None else 0 @@ -1030,42 +1046,19 @@ def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None 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 +def _apply_footnotes_to_text(footnotes: list[FootnoteInfo], data: GTData, text: str) -> str: + """Apply footnote marks to text for a list of pre-filtered footnotes. - # 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: + This is similar to how _flatten_styles() works for styles. + """ + if not footnotes: return text - # Collect unique mark strings and sort them properly + # Get mark strings for each footnote mark_strings: list[str] = [] footnote_placements: list[FootnoteInfo] = [] - for mark_string, footnote in matching_footnotes: + for footnote in footnotes: + mark_string = _get_footnote_mark_string(data, footnote) if mark_string not in mark_strings: mark_strings.append(mark_string) footnote_placements.append(footnote) @@ -1096,6 +1089,65 @@ def _add_footnote_marks_to_text( return text +def _add_footnote_marks_to_text( + data: GTData, + text: str, + locname: str | loc.Loc, + colname: str | None = None, + rownum: int | None = None, + grpname: str | None = None, +) -> str: + """Legacy function that filters footnotes and applies marks to text. + + This function is kept for backward compatibility but should eventually be replaced + with direct filtering + _apply_footnotes_to_text() calls. + """ + if not data._footnotes: + return text + + # Filter footnotes that match this location - similar to styles filtering + footnotes_i: list[FootnoteInfo] = [] + for footnote in data._footnotes: + # Check if locname matches - handle both string and Loc object cases + locname_matches = False + if isinstance(locname, str): + # For backward compatibility with string-based calls + if locname == "title" and isinstance(footnote.locname, loc.LocTitle): + locname_matches = True + elif locname == "subtitle" and isinstance(footnote.locname, loc.LocSubTitle): + locname_matches = True + elif locname == "stubhead" and isinstance(footnote.locname, loc.LocStubhead): + locname_matches = True + elif locname == "columns_groups" and isinstance(footnote.locname, loc.LocSpannerLabels): + locname_matches = True + elif locname == "columns_columns" and isinstance(footnote.locname, loc.LocColumnLabels): + locname_matches = True + elif locname == "data" and isinstance(footnote.locname, loc.LocBody): + locname_matches = True + elif locname == "stub" and isinstance(footnote.locname, loc.LocStub): + locname_matches = True + elif locname == "summary_cells" and isinstance(footnote.locname, loc.LocSummary): + locname_matches = True + else: + # Direct Loc object comparison + locname_matches = isinstance(footnote.locname, type(locname)) + + if locname_matches: + # 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: + footnotes_i.append(footnote) + + return _apply_footnotes_to_text(footnotes_i, data, text) + + def _apply_footnote_placement( text: str, marks_html: str, placement: FootnotePlacement | None ) -> str: From ee8815496155acb54eca113d6682fa24191f3c7a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Sep 2025 21:53:10 -0400 Subject: [PATCH 42/51] Refactor footnote application logic in HTML rendering --- great_tables/_utils_render_html.py | 159 +++++++++++++---------------- 1 file changed, 70 insertions(+), 89 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index adf51538e..46fd53f5a 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -93,11 +93,15 @@ def create_heading_component_h(data: GTData) -> str: title = _process_text(title) subtitle = _process_text(subtitle) + # Filter footnotes for title and subtitle - similar to styles filtering + footnotes_title = [x for x in data._footnotes if isinstance(x.locname, loc.LocTitle)] + footnotes_subtitle = [x for x in data._footnotes if isinstance(x.locname, loc.LocSubTitle)] + # Add footnote marks to title and subtitle if applicable if has_title: - title = _add_footnote_marks_to_text(data, title, "title") + title = _apply_footnotes_to_text(footnotes_title, data, title) if has_subtitle: - subtitle = _add_footnote_marks_to_text(data, subtitle, "subtitle") + subtitle = _apply_footnotes_to_text(footnotes_subtitle, data, 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)] @@ -193,6 +197,9 @@ def create_columns_component_h(data: GTData) -> str: # Extract the table ID to ensure subsequent IDs are unique table_id = data._options.table_id.value + # Filter footnotes for stubhead - similar to styles filtering + footnotes_stubhead = [x for x in data._footnotes if isinstance(x.locname, loc.LocStubhead)] + # If there are no spanners, then we have to create the cells for the stubhead label # (if present) and for the column headings if spanner_row_count == 0: @@ -201,8 +208,8 @@ def create_columns_component_h(data: GTData) -> str: table_col_headings.append( tags.th( HTML( - _add_footnote_marks_to_text( - data, _process_text(stub_label), locname="stubhead" + _apply_footnotes_to_text( + footnotes_stubhead, data, _process_text(stub_label) ) ), class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}", @@ -275,8 +282,8 @@ def create_columns_component_h(data: GTData) -> str: level_1_spanners.append( tags.th( HTML( - _add_footnote_marks_to_text( - data, _process_text(stub_label), locname="stubhead" + _apply_footnotes_to_text( + footnotes_stubhead, data, _process_text(stub_label) ) ), class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}", @@ -304,12 +311,19 @@ 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 == h_info.var] + # Filter footnotes for this column label - similar to styles filtering + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocColumnLabels) and x.colname == h_info.var + ] + # 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 + column_label_with_footnotes = _apply_footnotes_to_text( + footnotes_i, data, _process_text(h_info.column_label) ) # Creation of tags for column labels with no spanners above them @@ -337,15 +351,23 @@ def create_columns_component_h(data: GTData) -> str: and spanner_ids_level_1_index[ii] in x.grpname ] + # Filter footnotes for this spanner label - similar to styles filtering + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocSpannerLabels) + and spanner_ids_level_1_index[ii] + and x.grpname == spanner_ids_level_1_index[ii] + ] + level_1_spanners.append( tags.th( tags.span( HTML( - _add_footnote_marks_to_text( + _apply_footnotes_to_text( + footnotes_i, data, _process_text(spanner_ids_level_1_index[ii]), - locname="columns_groups", - grpname=spanner_ids_level_1_index[ii], ) ), class_="gt_column_spanner", @@ -381,16 +403,22 @@ def create_columns_component_h(data: GTData) -> str: # TODO check this filter logic styles_i = [x for x in styles_column_label if x.colname == remaining_heading] + # Filter footnotes for this column label - similar to styles filtering + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocColumnLabels) and x.colname == remaining_heading + ] + remaining_alignment = boxhead._get_boxhead_get_alignment_by_var( var=remaining_heading ) # Add footnote marks to column label if any - remaining_headings_label_with_footnotes = _add_footnote_marks_to_text( + remaining_headings_label_with_footnotes = _apply_footnotes_to_text( + footnotes_i, data, _process_text(remaining_headings_label), - "columns_columns", - colname=remaining_heading, ) spanned_column_labels.append( @@ -435,14 +463,22 @@ def create_columns_component_h(data: GTData) -> str: x for x in styles_spanner_label if span_label and span_label in x.grpname ] + # Filter footnotes for this spanner label - similar to styles filtering + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocSpannerLabels) + and span_label + and x.grpname == span_label + ] + if span_label: span = tags.span( HTML( - _add_footnote_marks_to_text( + _apply_footnotes_to_text( + footnotes_i, data, _process_text(span_label), - locname="columns_groups", - grpname=span_label, ) ), class_="gt_column_spanner", @@ -583,17 +619,25 @@ def create_body_component_h(data: GTData) -> str: else: is_stub_cell = False - # Add footnote marks to cell content if applicable - # Use different locname for stub vs data cells + # Filter footnotes for cell content - similar to styles filtering + # Use different locations 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 - ) + # For stub cells, footnotes are stored with colname=None + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocStub) and x.rownum == i + ] + cell_str = _apply_footnotes_to_text(footnotes_i, data, cell_str) else: - cell_str = _add_footnote_marks_to_text( - data, cell_str, "data", colname=colinfo.var, rownum=i - ) + footnotes_i = [ + x + for x in data._footnotes + if isinstance(x.locname, loc.LocBody) + and x.colname == colinfo.var + and x.rownum == i + ] + cell_str = _apply_footnotes_to_text(footnotes_i, data, cell_str) # Get alignment for the current column from the `col_alignment` list # by using the `name` value to obtain the index of the alignment value @@ -1047,10 +1091,6 @@ def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None def _apply_footnotes_to_text(footnotes: list[FootnoteInfo], data: GTData, text: str) -> str: - """Apply footnote marks to text for a list of pre-filtered footnotes. - - This is similar to how _flatten_styles() works for styles. - """ if not footnotes: return text @@ -1089,65 +1129,6 @@ def _apply_footnotes_to_text(footnotes: list[FootnoteInfo], data: GTData, text: return text -def _add_footnote_marks_to_text( - data: GTData, - text: str, - locname: str | loc.Loc, - colname: str | None = None, - rownum: int | None = None, - grpname: str | None = None, -) -> str: - """Legacy function that filters footnotes and applies marks to text. - - This function is kept for backward compatibility but should eventually be replaced - with direct filtering + _apply_footnotes_to_text() calls. - """ - if not data._footnotes: - return text - - # Filter footnotes that match this location - similar to styles filtering - footnotes_i: list[FootnoteInfo] = [] - for footnote in data._footnotes: - # Check if locname matches - handle both string and Loc object cases - locname_matches = False - if isinstance(locname, str): - # For backward compatibility with string-based calls - if locname == "title" and isinstance(footnote.locname, loc.LocTitle): - locname_matches = True - elif locname == "subtitle" and isinstance(footnote.locname, loc.LocSubTitle): - locname_matches = True - elif locname == "stubhead" and isinstance(footnote.locname, loc.LocStubhead): - locname_matches = True - elif locname == "columns_groups" and isinstance(footnote.locname, loc.LocSpannerLabels): - locname_matches = True - elif locname == "columns_columns" and isinstance(footnote.locname, loc.LocColumnLabels): - locname_matches = True - elif locname == "data" and isinstance(footnote.locname, loc.LocBody): - locname_matches = True - elif locname == "stub" and isinstance(footnote.locname, loc.LocStub): - locname_matches = True - elif locname == "summary_cells" and isinstance(footnote.locname, loc.LocSummary): - locname_matches = True - else: - # Direct Loc object comparison - locname_matches = isinstance(footnote.locname, type(locname)) - - if locname_matches: - # 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: - footnotes_i.append(footnote) - - return _apply_footnotes_to_text(footnotes_i, data, text) - - def _apply_footnote_placement( text: str, marks_html: str, placement: FootnotePlacement | None ) -> str: From 5fa1cc04efc88b9f213bee1749cb0bceaf51789f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Sep 2025 21:56:56 -0400 Subject: [PATCH 43/51] Split long HTML string in two for better readability --- great_tables/_utils_render_html.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 46fd53f5a..dce9187bf 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -1117,7 +1117,10 @@ def _apply_footnotes_to_text(footnotes: list[FootnoteInfo], data: GTData, text: if mark_strings: # Join mark strings with commas (no spaces) marks_text = ",".join(mark_strings) - marks_html = f'{marks_text}' + marks_html = ( + '{marks_text}' + ) # Determine placement based on the first footnote's placement setting # (all footnotes for the same location should have the same placement) From 310c9bf425595c9e31dd4b6d262677b4eda03d68 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 9 Sep 2025 11:35:11 -0400 Subject: [PATCH 44/51] Refactor footnote handling to use FootnoteEntry --- great_tables/_footnotes.py | 22 ++- great_tables/_locations.py | 328 ++++++++++++++++--------------------- 2 files changed, 158 insertions(+), 192 deletions(-) diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py index 09153156f..8e62fea8c 100644 --- a/great_tables/_footnotes.py +++ b/great_tables/_footnotes.py @@ -2,8 +2,9 @@ from typing import TYPE_CHECKING -from ._locations import Loc, PlacementOptions, set_footnote -from ._text import Text +from ._gt_data import FootnoteInfo, FootnotePlacement +from ._locations import FootnoteEntry, Loc, PlacementOptions, set_style +from ._text import Text, _process_text if TYPE_CHECKING: from ._types import GTSelf @@ -131,7 +132,11 @@ def tab_footnote( # Handle None locations (footnote without mark) if locations is None: - return set_footnote(None, self, footnote_str, placement) # type: ignore + # For None location, directly add to footnotes + place = FootnotePlacement[placement] + processed_footnote = _process_text(footnote_str) + info = FootnoteInfo(locname=None, footnotes=[processed_footnote], placement=place) + return self._replace(_footnotes=self._footnotes + [info]) # type: ignore # Ensure locations is a list if not isinstance(locations, list): @@ -140,6 +145,15 @@ def tab_footnote( # Apply footnote to each location result = self for loc in locations: - result = set_footnote(loc, result, footnote_str, placement) # type: ignore + if loc is None: + # Handle None in the list + place = FootnotePlacement[placement] + processed_footnote = _process_text(footnote_str) + info = FootnoteInfo(locname=None, footnotes=[processed_footnote], placement=place) + result = result._replace(_footnotes=result._footnotes + [info]) # type: ignore + else: + # Use the new consolidated approach - FootnoteEntry will handle Text conversion internally + footnote_entry = FootnoteEntry(footnote=footnote_str, placement=placement) + result = set_style(loc, result, [footnote_entry]) # type: ignore return result # type: ignore diff --git a/great_tables/_locations.py b/great_tables/_locations.py index 7ced0d147..d1a5d23b5 100644 --- a/great_tables/_locations.py +++ b/great_tables/_locations.py @@ -1,9 +1,9 @@ from __future__ import annotations import itertools -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import singledispatch -from typing import TYPE_CHECKING, Any, Callable, Literal, cast +from typing import TYPE_CHECKING, Any, Callable, Literal from typing_extensions import TypeAlias @@ -20,10 +20,45 @@ ) from ._styles import CellStyle from ._tbl_data import PlDataFrame, PlExpr, eval_select, eval_transform, get_column_names +from ._text import _process_text if TYPE_CHECKING: from ._gt_data import TblData from ._tbl_data import SelectExpr + from ._text import Text + + +@dataclass(frozen=True) +class FootnoteEntry: + """A footnote specification that can be applied to a location along with styles.""" + + footnote: str | Text + placement: PlacementOptions = "auto" + + +def footnotes_split_style_list( + entries: list[CellStyle | FootnoteEntry], +) -> tuple[list[CellStyle], list[FootnoteInfo]]: + """Split a list containing both styles and footnote entries. + + Returns a tuple of (styles, footnote_infos). + """ + styles: list[CellStyle] = [] + footnote_infos: list[FootnoteInfo] = [] + + for entry in entries: + if isinstance(entry, FootnoteEntry): + place = FootnotePlacement[entry.placement] + # Convert Text to string using `_process_text()` + footnote_str = _process_text(entry.footnote) + footnote_info = FootnoteInfo(footnotes=[footnote_str], placement=place) + footnote_infos.append(footnote_info) + else: + # It's a CellStyle + styles.append(entry) + + return styles, footnote_infos + # Misc Types =========================================================================== @@ -962,8 +997,8 @@ def _(loc: LocBody, data: GTData) -> list[CellPos]: @singledispatch -def set_style(loc: Loc, data: GTData, style: list[str]) -> GTData: - """Set style for location.""" +def set_style(loc: Loc, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: + """Set style and footnotes for location.""" raise NotImplementedError(f"Unsupported location type: {type(loc)}") @@ -987,248 +1022,165 @@ def _( | LocSourceNotes ), data: GTData, - style: list[CellStyle], + style: list[CellStyle | FootnoteEntry], ) -> GTData: + styles, new_footnotes = footnotes_split_style_list(style) + # validate ---- - for entry in style: + for entry in styles: entry._raise_if_requires_data(loc) - return data._replace(_styles=data._styles + [StyleInfo(locname=loc, styles=style)]) + # Update footnote infos with location information + updated_footnotes = [] + for footnote_info in new_footnotes: + # Determine locnum based on location type + if isinstance(loc, LocTitle): + locnum = 1 + elif isinstance(loc, LocSubTitle): + locnum = 2 + elif isinstance(loc, LocStubhead) or isinstance(loc, LocStubheadLabel): + locnum = 2.5 + else: + locnum = 6 # Default for footer-area locations + + updated_footnote = replace(footnote_info, locname=loc, locnum=locnum) + updated_footnotes.append(updated_footnote) + + return data._replace( + _styles=data._styles + [StyleInfo(locname=loc, styles=styles)], + _footnotes=data._footnotes + updated_footnotes, + ) @set_style.register -def _(loc: LocColumnLabels, data: GTData, style: list[CellStyle]) -> GTData: +def _(loc: LocColumnLabels, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: + styles, new_footnotes = footnotes_split_style_list(style) + selected = resolve(loc, data) # evaluate any column expressions in styles - styles = [entry._evaluate_expressions(data._tbl_data) for entry in style] + styles_ready = [entry._evaluate_expressions(data._tbl_data) for entry in styles] all_info: list[StyleInfo] = [] + updated_footnotes: list[FootnoteInfo] = [] + for name, pos in selected: + # Add style info crnt_info = StyleInfo( locname=loc, colname=name, - styles=styles, + styles=styles_ready, ) all_info.append(crnt_info) - return data._replace(_styles=data._styles + all_info) + + # Add footnote info for this column + for footnote_info in new_footnotes: + updated_footnote = replace(footnote_info, locname=loc, colname=name, locnum=4) + updated_footnotes.append(updated_footnote) + + return data._replace( + _styles=data._styles + all_info, _footnotes=data._footnotes + updated_footnotes + ) @set_style.register -def _(loc: LocSpannerLabels, data: GTData, style: list[CellStyle]) -> GTData: +def _(loc: LocSpannerLabels, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: + styles, new_footnotes = footnotes_split_style_list(style) + # validate ---- - for entry in style: + for entry in styles: entry._raise_if_requires_data(loc) # TODO resolve new_loc = resolve(loc, data._spanners) + + # Update footnotes with location info + updated_footnotes = [] + for spanner_id in new_loc.ids: + for footnote_info in new_footnotes: + updated_footnote = replace(footnote_info, locname=loc, grpname=spanner_id, locnum=3) + updated_footnotes.append(updated_footnote) + return data._replace( - _styles=data._styles + [StyleInfo(locname=new_loc, grpname=new_loc.ids, styles=style)] + _styles=data._styles + [StyleInfo(locname=new_loc, grpname=new_loc.ids, styles=styles)], + _footnotes=data._footnotes + updated_footnotes, ) @set_style.register -def _(loc: LocRowGroups, data: GTData, style: list[CellStyle]) -> GTData: +def _(loc: LocRowGroups, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: + styles, new_footnotes = footnotes_split_style_list(style) + # validate ---- - for entry in style: + for entry in styles: entry._raise_if_requires_data(loc) row_groups = resolve(loc, data) + + # Update footnotes with location info + updated_footnotes = [] + for group_name in row_groups: + for footnote_info in new_footnotes: + updated_footnote = replace(footnote_info, locname=loc, grpname=group_name, locnum=5) + updated_footnotes.append(updated_footnote) + return data._replace( - _styles=data._styles + [StyleInfo(locname=loc, grpname=row_groups, styles=style)] + _styles=data._styles + [StyleInfo(locname=loc, grpname=row_groups, styles=styles)], + _footnotes=data._footnotes + updated_footnotes, ) @set_style.register -def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData: +def _(loc: LocStub, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: + styles, new_footnotes = footnotes_split_style_list(style) + # validate ---- - for entry in style: + for entry in styles: entry._raise_if_requires_data(loc) # TODO resolve cells = resolve(loc, data) - new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=style) for rownum in cells] - return data._replace(_styles=data._styles + new_styles) + new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=styles) for rownum in cells] + + # Handle footnotes + updated_footnotes = [] + for row_pos in cells: + for footnote_info in new_footnotes: + updated_footnote = replace(footnote_info, locname=loc, rownum=row_pos, locnum=5) + updated_footnotes.append(updated_footnote) + + return data._replace( + _styles=data._styles + new_styles, _footnotes=data._footnotes + updated_footnotes + ) @set_style.register -def _(loc: LocBody, data: GTData, style: list[CellStyle]) -> GTData: +def _(loc: LocBody, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: positions: list[CellPos] = resolve(loc, data) + styles, new_footnotes = footnotes_split_style_list(style) + # evaluate any column expressions in styles - style_ready = [entry._evaluate_expressions(data._tbl_data) for entry in style] + style_ready = [entry._evaluate_expressions(data._tbl_data) for entry in styles] all_info: list[StyleInfo] = [] + updated_footnotes: list[FootnoteInfo] = [] + for col_pos in positions: + # Handle styles row_styles = [entry._from_row(data._tbl_data, col_pos.row) for entry in style_ready] crnt_info = StyleInfo( locname=loc, colname=col_pos.colname, rownum=col_pos.row, styles=row_styles ) all_info.append(crnt_info) - return data._replace(_styles=data._styles + all_info) - - -# Set footnote generic ================================================================= - - -@singledispatch -def set_footnote(loc: Loc, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: - """Set footnote for location.""" - raise NotImplementedError(f"Unsupported location type: {type(loc)}") - - -# Register footnote for `None` location (no footnote mark in the table but the footnote text -# still appears in the footnotes section of the table, before the ordered footnotes) -@set_footnote.register(type(None)) -def _(loc: None, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: - place = FootnotePlacement[placement] - info = FootnoteInfo(locname=None, footnotes=[footnote], placement=place) - - return data._replace(_footnotes=data._footnotes + [info]) - - -@set_footnote.register -def _(loc: LocTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData: - place = FootnotePlacement[placement] - info = FootnoteInfo(locname=loc, 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=loc, 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=loc, 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; the cast is needed because resolve() - # has a generic return type but we know it returns `list[tuple[str, int]]` for `LocColumnLabels` - name_pos_list = cast(list[tuple[str, int]], resolve(loc, data)) - - result = data - for name, _ in name_pos_list: - info = FootnoteInfo( - locname=loc, 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=loc, - 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=loc, - 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=loc, 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=loc, - 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=loc, - 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]) + # Handle footnotes for this position + for footnote_info in new_footnotes: + updated_footnote = replace( + footnote_info, locname=loc, colname=col_pos.colname, rownum=col_pos.row, locnum=5 + ) + updated_footnotes.append(updated_footnote) - return result + return data._replace( + _styles=data._styles + all_info, _footnotes=data._footnotes + updated_footnotes + ) From 7a500913a771ae7c34bb9d505a02a7f4e7e2643c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 9 Sep 2025 12:43:07 -0400 Subject: [PATCH 45/51] Remove unneeded text processing for footnotes --- great_tables/_utils_render_html.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index d81e4203b..597509b03 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -906,12 +906,11 @@ def _process_footnotes_for_display( 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: + footnote_text = footnote.footnotes[0] if footnote.footnotes else "" + if footnote_text not in footnote_data: mark_string = _get_footnote_mark_string(data, footnote) - footnote_data[processed_text] = mark_string - footnote_order.append(processed_text) + footnote_data[footnote_text] = mark_string + footnote_order.append(footnote_text) # Add footnotes without marks at the beginning (also filter for visibility) markless_footnotes = [f for f in visible_footnotes if f.locname is None] # type: ignore @@ -920,8 +919,8 @@ def _process_footnotes_for_display( # 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}) + footnote_text = footnote.footnotes[0] + result.append({"mark": "", "text": footnote_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 From fec1d13540482db45cf89d99af7a17bcc3581281 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 9 Sep 2025 12:53:36 -0400 Subject: [PATCH 46/51] Refactor footnote mark HTML creation --- great_tables/_utils_render_html.py | 10 ++++++---- tests/test_footnotes.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 597509b03..15d080e33 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -827,7 +827,7 @@ def create_footer_component_h(data: GTData) -> str: mark = footnote_data.get("mark", "") text = footnote_data.get("text", "") - footnote_mark_html = _create_footnote_mark_html(mark, location="ftr") + footnote_mark_html = _create_footnote_mark_html(mark=mark) # Wrap footnote text in `gt_from_md` span if it contains HTML markup if "<" in text and ">" in text: @@ -923,8 +923,8 @@ def _process_footnotes_for_display( result.append({"mark": "", "text": footnote_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) + # the `footnote_order` list already contains footnotes in visual order based on how + # `_get_footnote_mark_string()` assigns marks 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 @@ -996,7 +996,9 @@ def _get_footnote_marks_option(data: GTData) -> str | list[str]: return "numbers" -def _create_footnote_mark_html(mark: str, location: str = "ref") -> str: +def _create_footnote_mark_html(mark: str) -> str: + # Handle markless footnotes (footnotes added with `locations=None`) + # These appear in the footer without marks in the table body if not mark: return "" diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py index eb5890d85..2188fc26c 100644 --- a/tests/test_footnotes.py +++ b/tests/test_footnotes.py @@ -1034,7 +1034,7 @@ def test_footnote_and_source_note_integration(): def test_create_footnote_mark_html_edge_cases(): # Test that empty mark should return an empty string - result = _create_footnote_mark_html("") + result = _create_footnote_mark_html(mark="") assert result == "" From 4a7797a8a591fe36490656e3f4fde841df2e0a20 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 9 Sep 2025 12:56:04 -0400 Subject: [PATCH 47/51] Fix footnote mark type handling --- great_tables/_utils_render_html.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py index 15d080e33..6323b1057 100644 --- a/great_tables/_utils_render_html.py +++ b/great_tables/_utils_render_html.py @@ -968,11 +968,8 @@ def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbe 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) + symbols = mark_type if not symbols: return str(mark_index) From 6a39b35f8d63f278e29a65b012463b9bb49f07a8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 9 Sep 2025 13:11:45 -0400 Subject: [PATCH 48/51] Update _locations.py --- great_tables/_locations.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/great_tables/_locations.py b/great_tables/_locations.py index d1a5d23b5..399113d2c 100644 --- a/great_tables/_locations.py +++ b/great_tables/_locations.py @@ -3,7 +3,7 @@ import itertools from dataclasses import dataclass, replace from functools import singledispatch -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Any, Callable, Literal, Union from typing_extensions import TypeAlias @@ -1053,7 +1053,7 @@ def _( @set_style.register -def _(loc: LocColumnLabels, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: +def _(loc: LocColumnLabels, data: GTData, style: list[Union[CellStyle, FootnoteEntry]]) -> GTData: styles, new_footnotes = footnotes_split_style_list(style) selected = resolve(loc, data) @@ -1084,7 +1084,7 @@ def _(loc: LocColumnLabels, data: GTData, style: list[CellStyle | FootnoteEntry] @set_style.register -def _(loc: LocSpannerLabels, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: +def _(loc: LocSpannerLabels, data: GTData, style: list[Union[CellStyle, FootnoteEntry]]) -> GTData: styles, new_footnotes = footnotes_split_style_list(style) # validate ---- @@ -1108,7 +1108,7 @@ def _(loc: LocSpannerLabels, data: GTData, style: list[CellStyle | FootnoteEntry @set_style.register -def _(loc: LocRowGroups, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: +def _(loc: LocRowGroups, data: GTData, style: list[Union[CellStyle, FootnoteEntry]]) -> GTData: styles, new_footnotes = footnotes_split_style_list(style) # validate ---- @@ -1131,7 +1131,7 @@ def _(loc: LocRowGroups, data: GTData, style: list[CellStyle | FootnoteEntry]) - @set_style.register -def _(loc: LocStub, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: +def _(loc: LocStub, data: GTData, style: list[Union[CellStyle, FootnoteEntry]]) -> GTData: styles, new_footnotes = footnotes_split_style_list(style) # validate ---- @@ -1155,7 +1155,7 @@ def _(loc: LocStub, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTD @set_style.register -def _(loc: LocBody, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData: +def _(loc: LocBody, data: GTData, style: list[Union[CellStyle, FootnoteEntry]]) -> GTData: positions: list[CellPos] = resolve(loc, data) styles, new_footnotes = footnotes_split_style_list(style) From 73a89a4fc83edb882343ea2eb428ea2ad0f430dd Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 22 Sep 2025 15:26:43 -0400 Subject: [PATCH 49/51] Throw error if writing a LaTeX tbl with footnotes --- great_tables/_utils_render_latex.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/great_tables/_utils_render_latex.py b/great_tables/_utils_render_latex.py index eb8bef7b6..63d3879af 100644 --- a/great_tables/_utils_render_latex.py +++ b/great_tables/_utils_render_latex.py @@ -553,6 +553,13 @@ def _render_as_latex(data: GTData, use_longtable: bool = False, tbl_pos: str | N if data._styles: _not_implemented("Styles are not yet supported in LaTeX output.") + # Throw exception if footnotes are present in the table + if data._footnotes: + raise NotImplementedError( + "Footnotes are not yet supported in LaTeX output. " + "Consider removing all `.tab_footnote()` calls before using `.as_latex()`." + ) + # Get list representation of stub layout stub_layout = data._stub._get_stub_layout(options=data._options) From 385002f96f886a792c22e7010530c506b4fe32d4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 22 Sep 2025 15:31:13 -0400 Subject: [PATCH 50/51] Update as_latex() docstring with footnotes limitation --- great_tables/_export.py | 1 + 1 file changed, 1 insertion(+) diff --git a/great_tables/_export.py b/great_tables/_export.py index 8c8f4476b..e6395c0e6 100644 --- a/great_tables/_export.py +++ b/great_tables/_export.py @@ -294,6 +294,7 @@ def as_latex(self: GT, use_longtable: bool = False, tbl_pos: str | None = None) functionality that is supported in HTML output tables is not currently supported in LaTeX output tables: + - footnotes (via the `tab_footnote()` method) - the rendering of the stub and row group labels (via the `=rowname_col` and `=groupname_col` args in the `GT()` class) - the use of the `md()` helper function to signal conversion of Markdown text From d88211bb07a09d2a531ebc96c3f70b7f86e0706c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 22 Sep 2025 15:31:37 -0400 Subject: [PATCH 51/51] Add test to check for as_latex() raising w/ footnote --- tests/test_utils_render_latex.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_utils_render_latex.py b/tests/test_utils_render_latex.py index e91203f01..fa0ec58d0 100644 --- a/tests/test_utils_render_latex.py +++ b/tests/test_utils_render_latex.py @@ -3,7 +3,7 @@ import pandas as pd import os -from great_tables import GT, exibble +from great_tables import GT, exibble, loc from great_tables.data import gtcars from great_tables._utils_render_latex import ( @@ -511,3 +511,12 @@ def test_render_as_latex_rowgroup_raises(): _render_as_latex(data=gt_tbl._build_data(context="latex")) assert "Row groups are not yet supported in LaTeX output." in exc_info.value.args[0] + + +def test_render_as_latex_footnotes_raises(): + gt_tbl = GT(exibble).tab_footnote("A footnote", locations=loc.body(columns="num", rows=[0])) + + with pytest.raises(NotImplementedError) as exc_info: + _render_as_latex(data=gt_tbl._build_data(context="latex")) + + assert "Footnotes are not yet supported in LaTeX output." in exc_info.value.args[0]