diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index f21c42f58..d4aac43f4 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -117,6 +117,7 @@ quartodoc:
- GT.tab_spanner_delim
- GT.tab_stub
- GT.tab_stubhead
+ - GT.tab_footnote
- GT.tab_source_note
- GT.tab_style
- GT.tab_options
@@ -219,6 +220,7 @@ quartodoc:
- GT.opt_table_outline
- GT.opt_table_font
- GT.opt_stylize
+ - GT.opt_footnote_marks
- title: Export
desc: >
There may come a day when you need to export a table to some specific format. A great method
diff --git a/great_tables/_footnotes.py b/great_tables/_footnotes.py
index 4dfa4784e..09153156f 100644
--- a/great_tables/_footnotes.py
+++ b/great_tables/_footnotes.py
@@ -1,4 +1,145 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
-# TODO: create the `tab_footnote()` function
+from ._locations import Loc, PlacementOptions, set_footnote
+from ._text import Text
+
+if TYPE_CHECKING:
+ from ._types import GTSelf
+
+
+def tab_footnote(
+ self: GTSelf,
+ footnote: str | Text,
+ locations: Loc | None | list[Loc | None] = None,
+ placement: PlacementOptions = "auto",
+) -> GTSelf:
+ """
+ Add a table footnote.
+
+ `tab_footnote()` can make it a painless process to add a footnote to a table. There are commonly
+ two components to a footnote: (1) a footnote mark that is attached to the targeted cell content,
+ and (2) the footnote text itself that is placed in the table's footer area. Each unit of
+ footnote text in the footer is linked to an element of text or otherwise through the footnote
+ mark.
+
+ The footnote system in **Great Tables** presents footnotes in a way that matches the usual
+ expectations, where:
+
+ 1. footnote marks have a sequence, whether they are symbols, numbers, or letters
+ 2. multiple footnotes can be applied to the same content (and marks are always presented in an
+ ordered fashion)
+ 3. footnote text in the footer is never exactly repeated, **Great Tables** reuses footnote marks
+ where needed throughout the table
+ 4. footnote marks are ordered across the table in a consistent manner (left to right, top to
+ bottom)
+
+ Each call of `tab_footnote()` will either add a different footnote to the footer or reuse
+ existing footnote text therein. One or more cells outside of the footer are targeted using
+ location classes from the `loc` module (e.g., `loc.body()`, `loc.column_labels()`, etc.). You
+ can choose to *not* attach a footnote mark by simply not specifying anything in the `locations`
+ argument.
+
+ By default, **Great Tables** will choose which side of the text to place the footnote mark via
+ the `placement="auto"` option. You are, however, always free to choose the placement of the
+ footnote mark (either to the `"left"` or `"right"` of the targeted cell content).
+
+ Parameters
+ ----------
+ footnote
+ The text to be used in the footnote. We can optionally use [`md()`](`great_tables.md`) or
+ [`html()`](`great_tables.html`) to style the text as Markdown or to retain HTML elements in
+ the footnote text.
+ locations
+ The cell or set of cells to be associated with the footnote. Supplying any of the location
+ classes from the `loc` module is a useful way to target the location cells that are
+ associated with the footnote text. These location classes are: `loc.title`, `loc.stubhead`,
+ `loc.spanner_labels`, `loc.column_labels`, `loc.row_groups`, `loc.stub`, `loc.body`, etc.
+ Additionally, we can enclose several location calls within a `list()` if we wish to link the
+ footnote text to different types of locations (e.g., body cells, row group labels, the table
+ title, etc.).
+ placement
+ Where to affix footnote marks to the table content. Two options for this are `"left"` or
+ `"right"`, where the placement is either to the absolute left or right of the cell content.
+ By default, however, this option is set to `"auto"` whereby **Great Tables** will choose a
+ preferred left-or-right placement depending on the alignment of the cell content.
+
+ Returns
+ -------
+ GT
+ The GT object is returned. This is the same object that the method is called on so that we
+ can facilitate method chaining.
+
+ Examples
+ --------
+ This example table will be based on the `towny` dataset. We have a header part, with a title and
+ a subtitle. We can choose which of these could be associated with a footnote and in this case it
+ is the `"subtitle"`. This table has a stub with row labels and some of those labels are
+ associated with a footnote. So long as row labels are unique, they can be easily used as row
+ identifiers in `loc.stub()`. The third footnote is placed on the `"Density"` column label. Here,
+ changing the order of the `tab_footnote()` calls has no effect on the final table rendering.
+
+ ```{python}
+ import polars as pl
+ from great_tables import GT, loc, md
+ from great_tables.data import towny
+
+ towny_mini = (
+ pl.from_pandas(towny)
+ .filter(pl.col("csd_type") == "city")
+ .select(["name", "density_2021", "population_2021"])
+ .top_k(10, by="population_2021")
+ .sort("population_2021", descending=True)
+ )
+
+ (
+ GT(towny_mini, rowname_col="name")
+ .tab_header(
+ title=md("The 10 Largest Municipalities in `towny`"),
+ subtitle="Population values taken from the 2021 census."
+ )
+ .fmt_integer()
+ .cols_label(
+ density_2021="Density",
+ population_2021="Population"
+ )
+ .tab_footnote(
+ footnote="Part of the Greater Toronto Area.",
+ locations=loc.stub(rows=[
+ "Toronto", "Mississauga", "Brampton", "Markham", "Vaughan"
+ ])
+ )
+ .tab_footnote(
+ footnote=md("Density is in terms of persons per {{km^2}}."),
+ locations=loc.column_labels(columns="density_2021")
+ )
+ .tab_footnote(
+ footnote="Census results made public on February 9, 2022.",
+ locations=loc.subtitle()
+ )
+ .tab_source_note(
+ source_note=md("Data taken from the `towny` dataset.")
+ )
+ .opt_footnote_marks(marks="letters")
+ )
+ ```
+ """
+
+ # Store footnote as-is to preserve Text objects for later processing
+ footnote_str = footnote
+
+ # Handle None locations (footnote without mark)
+ if locations is None:
+ return set_footnote(None, self, footnote_str, placement) # type: ignore
+
+ # Ensure locations is a list
+ if not isinstance(locations, list):
+ locations = [locations]
+
+ # Apply footnote to each location
+ result = self
+ for loc in locations:
+ result = set_footnote(loc, result, footnote_str, placement) # type: ignore
+
+ return result # type: ignore
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index a5603f523..f1e20fb26 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -875,10 +875,10 @@ class FootnotePlacement(Enum):
@dataclass(frozen=True)
class FootnoteInfo:
- locname: Loc | None = None
+ locname: str | None = None
grpname: str | None = None
colname: str | None = None
- locnum: int | None = None
+ locnum: int | float | None = None
rownum: int | None = None
colnum: int | None = None
footnotes: list[str] | None = None
@@ -1160,7 +1160,7 @@ class Options:
# footnotes_border_lr_style: OptionsInfo = OptionsInfo(True, "footnotes", "value", "none")
# footnotes_border_lr_width: OptionsInfo = OptionsInfo(True, "footnotes", "px", "2px")
# footnotes_border_lr_color: OptionsInfo = OptionsInfo(True, "footnotes", "value", "#D3D3D3")
- # footnotes_marks: OptionsInfo = OptionsInfo(False, "footnotes", "values", "numbers")
+ footnotes_marks: OptionsInfo = OptionsInfo(False, "footnotes", "values", "numbers")
# footnotes_multiline: OptionsInfo = OptionsInfo(False, "footnotes", "boolean", True)
# footnotes_sep: OptionsInfo = OptionsInfo(False, "footnotes", "value", " ")
source_notes_padding: OptionsInfo = OptionsInfo(True, "source_notes", "px", "4px")
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 246966303..02280d87f 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -1088,4 +1088,144 @@ def _(loc: None, data: GTData, footnote: str, placement: PlacementOptions) -> GT
@set_footnote.register
def _(loc: LocTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
- raise NotImplementedError()
+ place = FootnotePlacement[placement]
+ info = FootnoteInfo(locname="title", footnotes=[footnote], placement=place, locnum=1)
+ return data._replace(_footnotes=data._footnotes + [info])
+
+
+@set_footnote.register
+def _(loc: LocSubTitle, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+ info = FootnoteInfo(locname="subtitle", footnotes=[footnote], placement=place, locnum=2)
+ return data._replace(_footnotes=data._footnotes + [info])
+
+
+@set_footnote.register
+def _(loc: LocStubhead, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+ info = FootnoteInfo(locname="stubhead", footnotes=[footnote], placement=place, locnum=2.5)
+ return data._replace(_footnotes=data._footnotes + [info])
+
+
+@set_footnote.register
+def _(loc: LocColumnLabels, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Resolve which columns to target - returns list[tuple[str, int]]
+ name_pos_list = resolve(loc, data)
+
+ result = data
+ for name, pos in name_pos_list:
+ info = FootnoteInfo(
+ locname="columns_columns", colname=name, footnotes=[footnote], placement=place, locnum=4
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
+
+
+@set_footnote.register
+def _(loc: LocSpannerLabels, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Get spanners from data
+ spanners = data._spanners if hasattr(data, "_spanners") else []
+
+ # Resolve which spanners to target
+ resolved_loc = resolve(loc, spanners)
+
+ result = data
+ for spanner_id in resolved_loc.ids:
+ info = FootnoteInfo(
+ locname="columns_groups",
+ grpname=spanner_id,
+ footnotes=[footnote],
+ placement=place,
+ locnum=3,
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
+
+
+@set_footnote.register
+def _(loc: LocRowGroups, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Resolve which row groups to target - returns set[str]
+ group_names = resolve(loc, data)
+
+ result = data
+ for group_name in group_names:
+ info = FootnoteInfo(
+ locname="row_groups",
+ grpname=group_name,
+ footnotes=[footnote],
+ placement=place,
+ locnum=5,
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
+
+
+@set_footnote.register
+def _(loc: LocStub, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Resolve which stub rows to target - returns set[int]
+ row_positions = resolve(loc, data)
+
+ result = data
+ for row_pos in row_positions:
+ info = FootnoteInfo(
+ locname="stub", rownum=row_pos, footnotes=[footnote], placement=place, locnum=5
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
+
+
+@set_footnote.register
+def _(loc: LocBody, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Resolve which body cells to target
+ positions = resolve(loc, data)
+
+ result = data
+ for pos in positions:
+ info = FootnoteInfo(
+ locname="data",
+ colname=pos.colname,
+ rownum=pos.row,
+ footnotes=[footnote],
+ placement=place,
+ locnum=5,
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
+
+
+@set_footnote.register
+def _(loc: LocSummary, data: GTData, footnote: str, placement: PlacementOptions) -> GTData:
+ place = FootnotePlacement[placement]
+
+ # Resolve which summary cells to target
+ positions = resolve(loc, data)
+
+ result = data
+ for pos in positions:
+ info = FootnoteInfo(
+ locname="summary_cells",
+ grpname=getattr(pos, "group_id", None),
+ colname=pos.colname,
+ rownum=pos.row,
+ footnotes=[footnote],
+ placement=place,
+ locnum=5.5,
+ )
+ result = result._replace(_footnotes=result._footnotes + [info])
+
+ return result
diff --git a/great_tables/_options.py b/great_tables/_options.py
index f4ab0fc8a..73a10f47c 100644
--- a/great_tables/_options.py
+++ b/great_tables/_options.py
@@ -145,7 +145,7 @@ def tab_options(
# footnotes_border_lr_style: str | None = None,
# footnotes_border_lr_width: str | None = None,
# footnotes_border_lr_color: str | None = None,
- # footnotes_marks: str | list[str] | None = None,
+ footnotes_marks: str | list[str] | None = None,
# footnotes_multiline: bool | None = None,
# footnotes_sep: str | None = None,
source_notes_background_color: str | None = None,
@@ -563,7 +563,8 @@ def tab_options(
def opt_footnote_marks(self: GTSelf, marks: str | list[str] = "numbers") -> GTSelf:
"""
- Option to modify the set of footnote marks
+ Option to modify the set of footnote marks.
+
Alter the footnote marks for any footnotes that may be present in the table. Either a list
of marks can be provided (including Unicode characters), or, a specific keyword could be
used to signify a preset sequence. This method serves as a shortcut for using
diff --git a/great_tables/_tab_create_modify.py b/great_tables/_tab_create_modify.py
index 61c9e2a28..1dafa1970 100644
--- a/great_tables/_tab_create_modify.py
+++ b/great_tables/_tab_create_modify.py
@@ -3,7 +3,7 @@
from typing import TYPE_CHECKING
from ._helpers import GoogleFont
-from ._locations import Loc, PlacementOptions, set_footnote, set_style
+from ._locations import Loc, set_style
from ._styles import CellStyle
if TYPE_CHECKING:
@@ -146,40 +146,3 @@ def tab_style(
new_data = set_style(loc, new_data, style)
return new_data
-
-
-# TODO: note that this function does not yet render, and rendering
-# will likely be implemented down the road (e.g. after basic styling).
-# this is just all the machinery to set data in GT._footnotes
-def tab_footnote(
- self: GTSelf,
- footnote: str | list[str],
- locations: Loc | None | list[Loc | None],
- placement: PlacementOptions = "auto",
-) -> GTSelf:
- """Add a footnote to a table
-
- Parameters
- ----------
- footnote
- The footnote text.
- locations
- The location to place the footnote. If None, then a footnote is created without
- a corresponding marker on the table (TODO: double check this).
- placement
- Where to affix the footnote marks to the table content.
-
- """
-
- if isinstance(footnote, list):
- raise NotImplementedError("Currently, only a single string is supported for footnote.")
-
- if not isinstance(locations, list):
- locations = [locations]
-
- new_data = self
- if isinstance(locations, list):
- for loc in locations:
- new_data = set_footnote(loc, self, footnote, placement)
-
- return new_data
diff --git a/great_tables/_text.py b/great_tables/_text.py
index cd895ec70..bde866b58 100644
--- a/great_tables/_text.py
+++ b/great_tables/_text.py
@@ -45,6 +45,11 @@ class Html(Text):
"""HTML text"""
def to_html(self) -> str:
+ if "{{" in self.text and "}}" in self.text:
+ from great_tables._helpers import UnitStr
+
+ unit_str = UnitStr.from_str(self.text)
+ return unit_str.to_html()
return self.text
def to_latex(self) -> str:
@@ -58,8 +63,18 @@ def to_latex(self) -> str:
def _md_html(x: str) -> str:
- str = commonmark.commonmark(x)
- return re.sub(r"^
|
\n$", "", str)
+ if "{{" in x and "}}" in x:
+ from great_tables._helpers import UnitStr
+
+ unit_str = UnitStr.from_str(x)
+ processed_text = unit_str.to_html()
+ else:
+ processed_text = x
+
+ str_result = commonmark.commonmark(processed_text)
+ if str_result is None:
+ return processed_text
+ return re.sub(r"^|
\n$", "", str_result)
def _md_latex(x: str) -> str:
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 3672d9464..06f6f9229 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -6,12 +6,29 @@
from htmltools import HTML, TagList, css, tags
from . import _locations as loc
-from ._gt_data import GroupRowInfo, GTData, Styles
+from ._gt_data import FootnoteInfo, FootnotePlacement, GroupRowInfo, GTData, Styles
from ._spanners import spanners_print_matrix
from ._tbl_data import _get_cell, cast_frame_to_string, replace_null_frame
from ._text import BaseText, _process_text, _process_text_id
from ._utils import heading_has_subtitle, heading_has_title, seq_groups
+# Visual hierarchy mapping for footnote location ordering
+FOOTNOTE_LOCATION_HIERARCHY = {
+ "title": 1,
+ "subtitle": 2,
+ "stubhead": 3,
+ "columns_groups": 4,
+ "columns_columns": 5,
+ "data": 6,
+ "stub": 6, # Same as data since stub and data cells are on the same row level
+}
+
+
+def _get_locnum_for_footnote_location(locname: str | None) -> int:
+ if locname is None:
+ return 999
+ return FOOTNOTE_LOCATION_HIERARCHY.get(locname, 999) # Default to 999 for unknown locations
+
def _is_loc(loc: str | loc.Loc, cls: type[loc.Loc]):
if isinstance(loc, str):
@@ -67,6 +84,12 @@ def create_heading_component_h(data: GTData) -> str:
title = _process_text(title)
subtitle = _process_text(subtitle)
+ # Add footnote marks to title and subtitle if applicable
+ if has_title:
+ title = _add_footnote_marks_to_text(data, title, "title")
+ if has_subtitle:
+ subtitle = _add_footnote_marks_to_text(data, subtitle, "subtitle")
+
# Filter list of StyleInfo for the various header components
styles_header = [x for x in data._styles if _is_loc(x.locname, loc.LocHeader)]
styles_title = [x for x in data._styles if _is_loc(x.locname, loc.LocTitle)]
@@ -168,7 +191,11 @@ def create_columns_component_h(data: GTData) -> str:
if stub_layout:
table_col_headings.append(
tags.th(
- HTML(_process_text(stub_label)),
+ HTML(
+ _add_footnote_marks_to_text(
+ data, _process_text(stub_label), locname="stubhead"
+ )
+ ),
class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}",
rowspan="1",
colspan=len(stub_layout),
@@ -183,9 +210,14 @@ def create_columns_component_h(data: GTData) -> str:
# Filter by column label / id, join with overall column labels style
styles_i = [x for x in styles_column_label if x.colname == info.var]
+ # Add footnote marks to column label if any
+ column_label_with_footnotes = _add_footnote_marks_to_text(
+ data, _process_text(info.column_label), "columns_columns", colname=info.var
+ )
+
table_col_headings.append(
tags.th(
- HTML(_process_text(info.column_label)),
+ HTML(column_label_with_footnotes),
class_=f"gt_col_heading gt_columns_bottom_border gt_{info.defaulted_align}",
rowspan=1,
colspan=1,
@@ -226,7 +258,11 @@ def create_columns_component_h(data: GTData) -> str:
if stub_layout:
level_1_spanners.append(
tags.th(
- HTML(_process_text(stub_label)),
+ HTML(
+ _add_footnote_marks_to_text(
+ data, _process_text(stub_label), locname="stubhead"
+ )
+ ),
class_=f"gt_col_heading gt_columns_bottom_border gt_{stubhead_label_alignment}",
rowspan=2,
colspan=len(stub_layout),
@@ -234,9 +270,7 @@ def create_columns_component_h(data: GTData) -> str:
scope="colgroup" if len(stub_layout) > 1 else "col",
id=_create_element_id(table_id, stub_label),
)
- )
-
- # NOTE: Run-length encoding treats missing values as distinct from each other; in other
+ ) # NOTE: Run-length encoding treats missing values as distinct from each other; in other
# words, each missing value starts a new run of length 1
spanner_ids_level_1 = spanner_ids[level_1_index]
@@ -257,10 +291,15 @@ def create_columns_component_h(data: GTData) -> str:
# Get the alignment values for the first set of column labels
first_set_alignment = h_info.defaulted_align
+ # Add footnote marks to column label if any
+ column_label_with_footnotes = _add_footnote_marks_to_text(
+ data, _process_text(h_info.column_label), "columns_columns", colname=h_info.var
+ )
+
# Creation of tags for column labels with no spanners above them
level_1_spanners.append(
tags.th(
- HTML(_process_text(h_info.column_label)),
+ HTML(column_label_with_footnotes),
class_=f"gt_col_heading gt_columns_bottom_border gt_{first_set_alignment}",
rowspan=2,
colspan=1,
@@ -285,7 +324,14 @@ def create_columns_component_h(data: GTData) -> str:
level_1_spanners.append(
tags.th(
tags.span(
- HTML(_process_text(spanner_ids_level_1_index[ii])),
+ HTML(
+ _add_footnote_marks_to_text(
+ data,
+ _process_text(spanner_ids_level_1_index[ii]),
+ locname="columns_groups",
+ grpname=spanner_ids_level_1_index[ii],
+ )
+ ),
class_="gt_column_spanner",
),
class_="gt_center gt_columns_top_border gt_column_spanner_outer",
@@ -323,9 +369,17 @@ def create_columns_component_h(data: GTData) -> str:
var=remaining_heading
)
+ # Add footnote marks to column label if any
+ remaining_headings_label_with_footnotes = _add_footnote_marks_to_text(
+ data,
+ _process_text(remaining_headings_label),
+ "columns_columns",
+ colname=remaining_heading,
+ )
+
spanned_column_labels.append(
tags.th(
- HTML(_process_text(remaining_headings_label)),
+ HTML(remaining_headings_label_with_footnotes),
class_=f"gt_col_heading gt_columns_bottom_border gt_{remaining_alignment}",
rowspan=1,
colspan=1,
@@ -367,7 +421,14 @@ def create_columns_component_h(data: GTData) -> str:
if span_label:
span = tags.span(
- HTML(_process_text(span_label)),
+ HTML(
+ _add_footnote_marks_to_text(
+ data,
+ _process_text(span_label),
+ locname="columns_groups",
+ grpname=span_label,
+ )
+ ),
class_="gt_column_spanner",
)
else:
@@ -501,11 +562,23 @@ def create_body_component_h(data: GTData) -> str:
cell_str: str = str(cell_content)
# Determine whether the current cell is the stub cell
- if has_stub_column:
+ if has_stub_column and stub_var is not None:
is_stub_cell = colinfo.var == stub_var.var
else:
is_stub_cell = False
+ # Add footnote marks to cell content if applicable
+ # Use different locname for stub vs data cells
+ if is_stub_cell:
+ # For stub cells, don't pass colname since stub footnotes are stored with colname=None
+ cell_str = _add_footnote_marks_to_text(
+ data, cell_str, "stub", colname=None, rownum=i
+ )
+ else:
+ cell_str = _add_footnote_marks_to_text(
+ data, cell_str, "data", colname=colinfo.var, rownum=i
+ )
+
# Get alignment for the current column from the `col_alignment` list
# by using the `name` value to obtain the index of the alignment value
cell_alignment = colinfo.defaulted_align
@@ -629,11 +702,448 @@ def create_source_notes_component_h(data: GTData) -> str:
return source_notes_component
-def create_footnotes_component_h(data: GTData):
- # Filter list of StyleInfo to only those that apply to the footnotes
- styles_footnotes = [x for x in data._styles if _is_loc(x.locname, loc.LocFootnotes)]
+def create_footer_component_h(data: GTData) -> str:
+ source_notes = data._source_notes
+ footnotes = data._footnotes
+
+ # Get the effective number of columns for colspan
+ n_cols_total = data._boxhead._get_effective_number_of_columns(
+ stub=data._stub, options=data._options
+ )
+
+ footer_rows = []
+
+ # Add source notes if they exist
+ if source_notes:
+ # Filter list of StyleInfo to only those that apply to the source notes
+ styles_footer = [x for x in data._styles if _is_loc(x.locname, loc.LocFooter)]
+ styles_source_notes = [x for x in data._styles if _is_loc(x.locname, loc.LocSourceNotes)]
+
+ # Obtain the `multiline` and `separator` options from `_options`
+ multiline = data._options.source_notes_multiline.value
+ separator = cast(str, data._options.source_notes_sep.value)
+
+ if multiline:
+ # Each source note gets its own row with gt_sourcenotes class on the tr
+ _styles = _flatten_styles(styles_footer + styles_source_notes, wrap=True)
+ for note in source_notes:
+ note_str = _process_text(note)
+ footer_rows.append(
+ f' | {note_str} |
'
+ )
+ else:
+ # All source notes in a single row with gt_sourcenotes class on the tr
+ source_note_list = []
+ for note in source_notes:
+ note_str = _process_text(note)
+ source_note_list.append(note_str)
+
+ source_notes_str_joined = separator.join(source_note_list)
+ footer_rows.append(
+ f'{source_notes_str_joined} |
'
+ )
+
+ # Add footnotes if they exist
+ if footnotes:
+ # Process footnotes and assign marks
+ footnotes_with_marks = _process_footnotes_for_display(data, footnotes)
+
+ if footnotes_with_marks:
+ # Each footnote gets its own row
+ for footnote_data in footnotes_with_marks:
+ mark = footnote_data.get("mark", "")
+ text = footnote_data.get("text", "")
+
+ footnote_mark_html = _create_footnote_mark_html(mark, location="ftr")
+
+ # Wrap footnote text in `gt_from_md` span if it contains HTML markup
+ if "<" in text and ">" in text:
+ footnote_text = f'{text}'
+ else:
+ footnote_text = text
+
+ footnote_html = f"{footnote_mark_html} {footnote_text}"
+ footer_rows.append(
+ f''
+ )
+
+ # If no footer content, return empty string
+ if not footer_rows:
+ return ""
+
+ return f'{"".join(footer_rows)}'
+
+
+def _should_display_footnote(data: GTData, footnote: FootnoteInfo) -> bool:
+ # If footnote targets a specific column, check if it's hidden
+ if footnote.colname is not None:
+ # Get column info from boxhead to check if it's hidden
+ for col_info in data._boxhead._d:
+ if col_info.var == footnote.colname:
+ return col_info.visible
+ # If column not found in boxhead, assume it should be displayed
+ return True
+
+ # For footnotes that don't target specific columns (e.g., title, subtitle), always display
+ return True
+
+
+def _process_footnotes_for_display(
+ data: GTData, footnotes: list[FootnoteInfo]
+) -> list[dict[str, str]]:
+ if not footnotes:
+ return []
+
+ # Filter out footnotes for hidden columns
+ visible_footnotes = [f for f in footnotes if _should_display_footnote(data, f)]
+
+ # Sort footnotes by visual order (same logic as in _get_footnote_mark_string);
+ # this ensures footnotes appear in the footnotes section in the same order as their
+ # marks in the table
+ footnote_positions: list[tuple[tuple[int, int, int], FootnoteInfo]] = []
+
+ for fn_info in visible_footnotes:
+ if fn_info.locname == "none":
+ continue
+
+ # Assign locnum based on visual hierarchy
+ locnum = _get_locnum_for_footnote_location(fn_info.locname)
+
+ # Assign column number, with stub getting a lower value than data columns
+ if fn_info.locname == "stub":
+ colnum = -1 # Stub appears before all data columns
+ else:
+ colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0
+ rownum = (
+ 0
+ if fn_info.locname == "columns_columns"
+ else (fn_info.rownum if fn_info.rownum is not None else 0)
+ )
+
+ sort_key = (locnum, rownum, colnum)
+ footnote_positions.append((sort_key, fn_info))
+
+ # Sort by visual order
+ footnote_positions.sort(key=lambda x: x[0])
+ sorted_footnotes = [fn_info for _, fn_info in footnote_positions]
+
+ # Group footnotes by their text to avoid duplicates and get their marks
+ footnote_data: dict[str, str] = {} # text -> mark_string
+ footnote_order: list[str] = []
+
+ for footnote in sorted_footnotes:
+ if footnote.footnotes:
+ raw_text = footnote.footnotes[0] if footnote.footnotes else ""
+ processed_text = _process_text(raw_text) # Process to get comparable string
+ if processed_text not in footnote_data:
+ mark_string = _get_footnote_mark_string(data, footnote)
+ footnote_data[processed_text] = mark_string
+ footnote_order.append(processed_text)
+
+ # Add footnotes without marks at the beginning (also filter for visibility)
+ markless_footnotes = [f for f in visible_footnotes if f.locname == "none"] # type: ignore
+ result: list[dict[str, str]] = []
+
+ # Add markless footnotes first
+ for footnote in markless_footnotes:
+ if footnote.footnotes:
+ processed_text = _process_text(footnote.footnotes[0])
+ result.append({"mark": "", "text": processed_text})
+
+ # Add footnotes with marks and maintain visual order (order they appear in table);
+ # the footnote_order list already contains footnotes in visual order based on how
+ # _get_footnote_mark_string assigns marks (top-to-bottom, left-to-right)
+ mark_type = _get_footnote_marks_option(data)
+ if isinstance(mark_type, str) and mark_type == "numbers":
+ # For numbers, sort by numeric mark value to handle any edge cases
+ sorted_texts = sorted(
+ footnote_order,
+ key=lambda text: int(footnote_data[text])
+ if footnote_data[text].isdigit()
+ else float("inf"),
+ )
+ else:
+ # For letters/symbols, maintain visual order (don't sort alphabetically)
+ sorted_texts = footnote_order
+
+ for text in sorted_texts:
+ mark_string = footnote_data[text]
+ result.append({"mark": mark_string, "text": text})
+
+ return result
+
+
+def _get_footnote_mark_symbols() -> dict[str, list[str]]:
+ from ._helpers import LETTERS, letters
+
+ return {
+ "numbers": [],
+ "letters": letters(),
+ "LETTERS": LETTERS(),
+ "standard": ["*", "†", "‡", "§"],
+ "extended": ["*", "†", "‡", "§", "‖", "¶"],
+ }
+
+
+def _generate_footnote_mark(mark_index: int, mark_type: str | list[str] = "numbers") -> str:
+ if isinstance(mark_type, str):
+ if mark_type == "numbers":
+ return str(mark_index)
+
+ symbol_sets = _get_footnote_mark_symbols()
+ if mark_type in symbol_sets:
+ symbols = symbol_sets[mark_type]
+ else:
+ # Default to numbers if unknown type
+ return str(mark_index)
+ elif isinstance(mark_type, list):
+ symbols = mark_type
+ else:
+ # Default to numbers
+ return str(mark_index)
+
+ if not symbols:
+ return str(mark_index)
+
+ # Calculate symbol and repetition for cycling behavior;
+ # e.g., for 4 symbols: index 1-4 -> symbol once, 5-8 -> symbol twice, etc.
+ symbol_index = (mark_index - 1) % len(symbols)
+ repetitions = (mark_index - 1) // len(symbols) + 1
+
+ return symbols[symbol_index] * repetitions
+
+
+def _get_footnote_marks_option(data: GTData) -> str | list[str]:
+ # Read from the options system
+ if hasattr(data, "_options") and hasattr(data._options, "footnotes_marks"):
+ marks_value = data._options.footnotes_marks.value
+ if marks_value is not None:
+ return marks_value
+
+ # Default to numbers
+ return "numbers"
+
+
+def _create_footnote_mark_html(mark: str, location: str = "ref") -> str:
+ if not mark:
+ return ""
+
+ # Use consistent span structure for both references and footer
+ return f''
+
+
+def _get_footnote_mark_string(data: GTData, footnote_info: FootnoteInfo) -> str:
+ if not data._footnotes or not footnote_info.footnotes:
+ mark_type = _get_footnote_marks_option(data)
+ return _generate_footnote_mark(1, mark_type)
+
+ # Create a list of all footnote positions with their text, following R gt approach
+ footnote_positions: list[tuple[tuple[int, int, int], str]] = []
+
+ for fn_info in data._footnotes:
+ if not fn_info.footnotes or fn_info.locname == "none":
+ continue
+
+ # Skip footnotes for hidden columns
+ if not _should_display_footnote(data, fn_info):
+ continue
+
+ footnote_text = _process_text(fn_info.footnotes[0])
+
+ # Assign locnum (location number) based on the location hierarchy where
+ # lower numbers appear first in reading order
+ locnum = _get_locnum_for_footnote_location(fn_info.locname)
+
+ # Get colnum (column number) and assign stub a lower value than data columns
+ if fn_info.locname == "stub":
+ colnum = -1 # Stub appears before all data columns
+ elif fn_info.locname == "columns_groups":
+ # For spanners, use the leftmost column index to ensure left-to-right ordering
+ colnum = _get_spanner_leftmost_column_index(data, fn_info.grpname)
+ else:
+ colnum = _get_column_index(data, fn_info.colname) if fn_info.colname else 0
+
+ # Get rownum; for headers use 0, for body use actual row number
+ if fn_info.locname == "columns_columns":
+ rownum = 0 # Headers are row 0
+ else:
+ rownum = fn_info.rownum if fn_info.rownum is not None else 0
+
+ # Sort key: (locnum, rownum, colnum); this should match reading order
+ # of top-to-bottom, left-to-right
+ sort_key = (locnum, rownum, colnum)
+ footnote_positions.append((sort_key, footnote_text))
+
+ # Sort by (locnum, rownum, colnum): headers before body
+ footnote_positions.sort(key=lambda x: x[0])
+
+ # Get unique footnote texts in sorted order
+ unique_footnotes: list[str] = []
+ for _, text in footnote_positions:
+ if text not in unique_footnotes:
+ unique_footnotes.append(text)
+
+ # Find the mark index for this footnote's text
+ if footnote_info.footnotes:
+ footnote_text = _process_text(footnote_info.footnotes[0])
+ try:
+ mark_index = unique_footnotes.index(footnote_text) + 1 # Use 1-based indexing
+ mark_type = _get_footnote_marks_option(data)
+ return _generate_footnote_mark(mark_index, mark_type)
+ except ValueError:
+ mark_type = _get_footnote_marks_option(data)
+ return _generate_footnote_mark(1, mark_type)
+
+ mark_type = _get_footnote_marks_option(data)
+ return _generate_footnote_mark(1, mark_type)
+
+
+def _get_column_index(data: GTData, colname: str | None) -> int:
+ if not colname:
+ return 0
+
+ # Get the column order from boxhead
+ columns = data._boxhead._get_default_columns()
+ for i, col_info in enumerate(columns):
+ if col_info.var == colname:
+ return i
+
+ return 0
+
+
+def _get_spanner_leftmost_column_index(data: GTData, spanner_grpname: str | None) -> int:
+ if not spanner_grpname:
+ return 0
+
+ # Find the spanner with this group name
+ for spanner in data._spanners:
+ if spanner.spanner_label == spanner_grpname:
+ # Get the column indices for all columns in this spanner
+ column_indices = []
+ for col_var in spanner.vars:
+ col_index = _get_column_index(data, col_var)
+ column_indices.append(col_index)
+
+ # Return the minimum (leftmost) column index
+ return min(column_indices) if column_indices else 0
+
+ return 0
+
+
+def _add_footnote_marks_to_text(
+ data: GTData,
+ text: str,
+ locname: str,
+ colname: str | None = None,
+ rownum: int | None = None,
+ grpname: str | None = None,
+) -> str:
+ if not data._footnotes:
+ return text
+
+ # Find footnotes that match this location
+ matching_footnotes: list[tuple[str, FootnoteInfo]] = []
+ for footnote in data._footnotes:
+ if footnote.locname == locname:
+ # Check if this footnote targets this specific location
+ match = True
+
+ if colname is not None and footnote.colname != colname:
+ match = False
+ if rownum is not None and footnote.rownum != rownum:
+ match = False
+ if grpname is not None and footnote.grpname != grpname:
+ match = False
+
+ if match:
+ mark_string = _get_footnote_mark_string(data, footnote)
+ matching_footnotes.append((mark_string, footnote))
+
+ if not matching_footnotes:
+ return text
+
+ # Collect unique mark strings and sort them properly
+ mark_strings: list[str] = []
+ footnote_placements: list[FootnoteInfo] = []
+ for mark_string, footnote in matching_footnotes:
+ if mark_string not in mark_strings:
+ mark_strings.append(mark_string)
+ footnote_placements.append(footnote)
+
+ # Sort marks: for numbers, sort numerically; for symbols, sort by their order in symbol set
+ mark_type = _get_footnote_marks_option(data)
+ if isinstance(mark_type, str) and mark_type == "numbers":
+ # Sort numerically for numbers
+ mark_strings.sort(key=lambda x: int(x) if x.isdigit() else float("inf"))
+ else:
+ # For symbols, maintain the order they appear (which should already be correct)
+ # since `_get_footnote_mark_string()` returns them in visual order
+ pass
+
+ # Create a single footnote mark span with comma-separated marks
+ if mark_strings:
+ # Join mark strings with commas (no spaces)
+ marks_text = ",".join(mark_strings)
+ marks_html = f''
+
+ # Determine placement based on the first footnote's placement setting
+ # (all footnotes for the same location should have the same placement)
+ placement = footnote_placements[0].placement if footnote_placements else None
+
+ # Apply placement logic
+ return _apply_footnote_placement(text, marks_html, placement)
+
+ return text
+
+
+def _apply_footnote_placement(
+ text: str, marks_html: str, placement: FootnotePlacement | None
+) -> str:
+ # Default to auto if no placement specified
+ if placement is None:
+ placement_setting = FootnotePlacement.auto
+ else:
+ placement_setting = placement
+
+ if placement_setting == FootnotePlacement.left:
+ # Left placement: footnote marks + space + text
+ return f"{marks_html} {text}"
+ elif placement_setting == FootnotePlacement.right:
+ # Right placement: text + footnote marks
+ return f"{text}{marks_html}"
+ else:
+ # Auto placement: left for numbers, right for everything else
+ if _is_numeric_content(text):
+ # For numbers, place marks on the left so alignment is preserved
+ return f"{marks_html} {text}"
+ else:
+ # For text, place marks on the right
+ return f"{text}{marks_html}"
+
+
+def _is_numeric_content(text: str) -> bool:
+ import re
+
+ # Strip HTML tags for analysis
+ clean_text = re.sub(r"<[^>]+>", "", text).strip()
+
+ if not clean_text:
+ return False
+
+ # Remove common formatting characters to get to the core content
+ # This handles formatted numbers with commas, currency symbols, percent signs, etc.
+ # Include a wide range of currency symbols and formatting characters
+ formatting_chars = r"[,\s$%€£¥₹₽₩₪₱₡₴₦₨₵₸₲₩\(\)−\-+]"
+ number_core = re.sub(formatting_chars, "", clean_text)
+
+ # Check if what remains is primarily numeric (including decimals)
+ if not number_core:
+ return False
- return ""
+ # Check if the core is a valid number: must have at least one digit,
+ # and can have at most one decimal point
+ numeric_pattern = r"^\d*\.?\d+$|^\d+\.?\d*$"
+ return bool(re.match(numeric_pattern, number_core)) and number_core != "."
def rtl_modern_unicode_charset() -> str:
diff --git a/great_tables/css/gt_styles_default.scss b/great_tables/css/gt_styles_default.scss
index 1fba67e43..2b949db0c 100644
--- a/great_tables/css/gt_styles_default.scss
+++ b/great_tables/css/gt_styles_default.scss
@@ -276,29 +276,6 @@ p {
border-bottom-color: $table_body_border_bottom_color;
}
-.gt_sourcenotes {
- color: $font_color_source_notes_background_color;
- background-color: $source_notes_background_color;
- border-bottom-style: $source_notes_border_bottom_style;
- border-bottom-width: $source_notes_border_bottom_width;
- border-bottom-color: $source_notes_border_bottom_color;
- border-left-style: $source_notes_border_lr_style;
- border-left-width: $source_notes_border_lr_width;
- border-left-color: $source_notes_border_lr_color;
- border-right-style: $source_notes_border_lr_style;
- border-right-width: $source_notes_border_lr_width;
- border-right-color: $source_notes_border_lr_color;
-}
-
-.gt_sourcenote {
- font-size: $source_notes_font_size;
- padding-top: $source_notes_padding;
- padding-bottom: $source_notes_padding;
- padding-left: $source_notes_padding_horizontal;
- padding-right: $source_notes_padding_horizontal;
- text-align: left;
-}
-
.gt_left {
text-align: left;
}
@@ -328,6 +305,52 @@ p {
font-size: 65%;
}
+.gt_footnotes {
+ color: font-color($footnotes_background_color);
+ background-color: $footnotes_background_color;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+}
+
+.gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.gt_sourcenotes {
+ color: $font_color_source_notes_background_color;
+ background-color: $source_notes_background_color;
+ border-bottom-style: $source_notes_border_bottom_style;
+ border-bottom-width: $source_notes_border_bottom_width;
+ border-bottom-color: $source_notes_border_bottom_color;
+ border-left-style: $source_notes_border_lr_style;
+ border-left-width: $source_notes_border_lr_width;
+ border-left-color: $source_notes_border_lr_color;
+ border-right-style: $source_notes_border_lr_style;
+ border-right-width: $source_notes_border_lr_width;
+ border-right-color: $source_notes_border_lr_color;
+}
+
+.gt_sourcenote {
+ font-size: $source_notes_font_size;
+ padding-top: $source_notes_padding;
+ padding-bottom: $source_notes_padding;
+ padding-left: $source_notes_padding_horizontal;
+ padding-right: $source_notes_padding_horizontal;
+ text-align: left;
+}
+
.gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
diff --git a/great_tables/gt.py b/great_tables/gt.py
index e38875950..aa2230629 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -9,6 +9,7 @@
from ._boxhead import cols_align, cols_label, cols_label_rotate
from ._data_color import data_color
from ._export import as_latex, as_raw_html, save, show, write_raw_html
+from ._footnotes import tab_footnote
from ._formats import (
fmt,
fmt_bytes,
@@ -69,9 +70,8 @@
_get_table_defs,
create_body_component_h,
create_columns_component_h,
- create_footnotes_component_h,
+ create_footer_component_h,
create_heading_component_h,
- create_source_notes_component_h,
)
if TYPE_CHECKING:
@@ -267,6 +267,7 @@ def __init__(
tab_header = tab_header
tab_source_note = tab_source_note
+ tab_footnote = tab_footnote
tab_spanner = tab_spanner
tab_spanner_delim = tab_spanner_delim
tab_stubhead = tab_stubhead
@@ -364,8 +365,7 @@ def _render_as_html(
heading_component = create_heading_component_h(data=self)
column_labels_component = create_columns_component_h(data=self)
body_component = create_body_component_h(data=self)
- source_notes_component = create_source_notes_component_h(data=self)
- footnotes_component = create_footnotes_component_h(data=self)
+ footer_component = create_footer_component_h(data=self)
# Get attributes for the table
table_defs = _get_table_defs(data=self)
@@ -392,8 +392,7 @@ def _render_as_html(
{column_labels_component}
{body_component}
-{source_notes_component}
-{footnotes_component}
+{footer_component}
"""
diff --git a/tests/__snapshots__/test_export.ambr b/tests/__snapshots__/test_export.ambr
index b6fe21790..4f7b79eeb 100644
--- a/tests/__snapshots__/test_export.ambr
+++ b/tests/__snapshots__/test_export.ambr
@@ -36,8 +36,6 @@
#test_table .gt_row_group_first th { border-top-width: 2px; }
#test_table .gt_striped { background-color: rgba(128,128,128,0.05); }
#test_table .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
- #test_table .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
- #test_table .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test_table .gt_left { text-align: left; }
#test_table .gt_center { text-align: center; }
#test_table .gt_right { text-align: right; font-variant-numeric: tabular-nums; }
@@ -45,6 +43,10 @@
#test_table .gt_font_bold { font-weight: bold; }
#test_table .gt_font_italic { font-style: italic; }
#test_table .gt_super { font-size: 65%; }
+ #test_table .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test_table .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; }
+ #test_table .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test_table .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test_table .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; }
#test_table .gt_asterisk { font-size: 100%; vertical-align: 0; }
@@ -121,14 +123,7 @@
$0.44 |
-
-
-
- This is only a subset of the dataset. |
-
-
-
-
+ This is only a subset of the dataset. |
@@ -158,7 +153,6 @@
-
@@ -191,7 +185,6 @@
-
diff --git a/tests/__snapshots__/test_footnotes.ambr b/tests/__snapshots__/test_footnotes.ambr
new file mode 100644
index 000000000..5ee4a2db9
--- /dev/null
+++ b/tests/__snapshots__/test_footnotes.ambr
@@ -0,0 +1,156 @@
+# serializer version: 1
+# name: test_footnote_placement_snapshot_different_types
+ '''
+
+
+
+
+
+
+ Auto Placement Test |
+
+
+ integers |
+ floats |
+ currency |
+ percentages |
+ text |
+ mixed |
+ formatted_num |
+ scientific |
+
+
+
+
+ 42 |
+ 123.45 |
+ $1,234.56 |
+ 85.5% |
+ Hello |
+ ABC123 |
+ (1,000) |
+ 1.23e-4 |
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_footnote_placement_snapshot_left_placement
+ '''
+
+
+
+
+
+
+ Left Placement Test |
+
+
+ integers |
+ text |
+ currency |
+
+
+
+
+ 42 |
+ Hello |
+ $1,234.56 |
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_footnote_placement_snapshot_right_placement
+ '''
+
+
+
+
+
+
+ Right Placement Test |
+
+
+ integers |
+ text |
+ currency |
+
+
+
+
+ 42 |
+ Hello |
+ $1,234.56 |
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_tab_footnote_complete_ordering_snapshot
+ '''
+
+
+ '''
+# ---
+# name: test_tab_footnote_stub_body_ordering_snapshot
+ '''
+
+
+ A |
+ Y |
+
+
+ B |
+ Z |
+
+
+ '''
+# ---
diff --git a/tests/__snapshots__/test_formats.ambr b/tests/__snapshots__/test_formats.ambr
index 7246dc93f..a49adb4c7 100644
--- a/tests/__snapshots__/test_formats.ambr
+++ b/tests/__snapshots__/test_formats.ambr
@@ -51,7 +51,6 @@
-
'''
# ---
# name: test_format_repr_snap
@@ -163,7 +162,6 @@
-
'''
# ---
# name: test_format_snap
diff --git a/tests/__snapshots__/test_options.ambr b/tests/__snapshots__/test_options.ambr
index d4661e920..718ce20c9 100644
--- a/tests/__snapshots__/test_options.ambr
+++ b/tests/__snapshots__/test_options.ambr
@@ -1023,29 +1023,6 @@
border-bottom-color: #0076BA;
}
- #abc .gt_sourcenotes {
- color: #333333;
- background-color: #FFFFFF;
- border-bottom-style: none;
- border-bottom-width: 2px;
- border-bottom-color: #D3D3D3;
- border-left-style: none;
- border-left-width: 2px;
- border-left-color: #D3D3D3;
- border-right-style: none;
- border-right-width: 2px;
- border-right-color: #D3D3D3;
- }
-
- #abc .gt_sourcenote {
- font-size: 90%;
- padding-top: 4px;
- padding-bottom: 4px;
- padding-left: 5px;
- padding-right: 5px;
- text-align: left;
- }
-
#abc .gt_left {
text-align: left;
}
@@ -1075,6 +1052,52 @@
font-size: 65%;
}
+ #abc .gt_footnotes {
+ color: font-color(#FFFFFF);
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_sourcenotes {
+ color: #333333;
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_sourcenote {
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ text-align: left;
+ }
+
#abc .gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
@@ -1374,29 +1397,6 @@
border-bottom-color: #0076BA;
}
- #abc .gt_sourcenotes {
- color: #333333;
- background-color: #FFFFFF;
- border-bottom-style: none;
- border-bottom-width: 2px;
- border-bottom-color: #D3D3D3;
- border-left-style: none;
- border-left-width: 2px;
- border-left-color: #D3D3D3;
- border-right-style: none;
- border-right-width: 2px;
- border-right-color: #D3D3D3;
- }
-
- #abc .gt_sourcenote {
- font-size: 90%;
- padding-top: 4px;
- padding-bottom: 4px;
- padding-left: 5px;
- padding-right: 5px;
- text-align: left;
- }
-
#abc .gt_left {
text-align: left;
}
@@ -1426,6 +1426,52 @@
font-size: 65%;
}
+ #abc .gt_footnotes {
+ color: font-color(#FFFFFF);
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_sourcenotes {
+ color: #333333;
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_sourcenote {
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ text-align: left;
+ }
+
#abc .gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
@@ -1833,29 +1879,6 @@
border-bottom-color: red;
}
- #abc .gt_sourcenotes {
- color: #000000;
- background-color: red;
- border-bottom-style: solid;
- border-bottom-width: 5px;
- border-bottom-color: red;
- border-left-style: solid;
- border-left-width: 5px;
- border-left-color: red;
- border-right-style: solid;
- border-right-width: 5px;
- border-right-color: red;
- }
-
- #abc .gt_sourcenote {
- font-size: 12px;
- padding-top: 5px;
- padding-bottom: 5px;
- padding-left: 5px;
- padding-right: 5px;
- text-align: left;
- }
-
#abc .gt_left {
text-align: left;
}
@@ -1885,6 +1908,52 @@
font-size: 65%;
}
+ #abc .gt_footnotes {
+ color: font-color(red);
+ background-color: red;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_sourcenotes {
+ color: #000000;
+ background-color: red;
+ border-bottom-style: solid;
+ border-bottom-width: 5px;
+ border-bottom-color: red;
+ border-left-style: solid;
+ border-left-width: 5px;
+ border-left-color: red;
+ border-right-style: solid;
+ border-right-width: 5px;
+ border-right-color: red;
+ }
+
+ #abc .gt_sourcenote {
+ font-size: 12px;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ padding-left: 5px;
+ padding-right: 5px;
+ text-align: left;
+ }
+
#abc .gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
@@ -2184,29 +2253,6 @@
border-bottom-color: #D3D3D3;
}
- #abc .gt_sourcenotes {
- color: #333333;
- background-color: #FFFFFF;
- border-bottom-style: none;
- border-bottom-width: 2px;
- border-bottom-color: #D3D3D3;
- border-left-style: none;
- border-left-width: 2px;
- border-left-color: #D3D3D3;
- border-right-style: none;
- border-right-width: 2px;
- border-right-color: #D3D3D3;
- }
-
- #abc .gt_sourcenote {
- font-size: 90%;
- padding-top: 4px;
- padding-bottom: 4px;
- padding-left: 5px;
- padding-right: 5px;
- text-align: left;
- }
-
#abc .gt_left {
text-align: left;
}
@@ -2236,6 +2282,52 @@
font-size: 65%;
}
+ #abc .gt_footnotes {
+ color: font-color(#FFFFFF);
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_sourcenotes {
+ color: #333333;
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_sourcenote {
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ text-align: left;
+ }
+
#abc .gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
diff --git a/tests/__snapshots__/test_repr.ambr b/tests/__snapshots__/test_repr.ambr
index 129c5ec8c..8baff9a0c 100644
--- a/tests/__snapshots__/test_repr.ambr
+++ b/tests/__snapshots__/test_repr.ambr
@@ -36,8 +36,6 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
- #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
- #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
#test .gt_center { text-align: center; }
#test .gt_right { text-align: right; font-variant-numeric: tabular-nums; }
@@ -45,6 +43,10 @@
#test .gt_font_bold { font-weight: bold; }
#test .gt_font_italic { font-style: italic; }
#test .gt_super { font-size: 65%; }
+ #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; }
#test .gt_asterisk { font-size: 100%; vertical-align: 0; }
@@ -68,7 +70,6 @@
-
@@ -112,8 +113,6 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
- #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
- #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
#test .gt_center { text-align: center; }
#test .gt_right { text-align: right; font-variant-numeric: tabular-nums; }
@@ -121,6 +120,10 @@
#test .gt_font_bold { font-weight: bold; }
#test .gt_font_italic { font-style: italic; }
#test .gt_super { font-size: 65%; }
+ #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; }
#test .gt_asterisk { font-size: 100%; vertical-align: 0; }
@@ -144,7 +147,6 @@
-
@@ -194,8 +196,6 @@
#test .gt_row_group_first th { border-top-width: 2px !important; }
#test .gt_striped { background-color: rgba(128,128,128,0.05) !important; }
#test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; }
- #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
- #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_left { text-align: left !important; }
#test .gt_center { text-align: center !important; }
#test .gt_right { text-align: right !important; font-variant-numeric: tabular-nums !important; }
@@ -203,6 +203,10 @@
#test .gt_font_bold { font-weight: bold !important; }
#test .gt_font_italic { font-style: italic !important; }
#test .gt_super { font-size: 65% !important; }
+ #test .gt_footnotes { color: font-color(#FFFFFF) !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
+ #test .gt_footnote { margin: 0px !important; font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; }
+ #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
+ #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_footnote_marks { font-size: 75% !important; vertical-align: 0.4em !important; position: initial !important; }
#test .gt_asterisk { font-size: 100% !important; vertical-align: 0 !important; }
@@ -226,7 +230,6 @@
-
@@ -273,8 +276,6 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
- #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
- #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
#test .gt_center { text-align: center; }
#test .gt_right { text-align: right; font-variant-numeric: tabular-nums; }
@@ -282,6 +283,10 @@
#test .gt_font_bold { font-weight: bold; }
#test .gt_font_italic { font-style: italic; }
#test .gt_super { font-size: 65%; }
+ #test .gt_footnotes { color: font-color(#FFFFFF); background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_footnote { margin: 0px; font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
+ #test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_footnote_marks { font-size: 75%; vertical-align: 0.4em; position: initial; }
#test .gt_asterisk { font-size: 100%; vertical-align: 0; }
@@ -305,7 +310,6 @@
-
@@ -349,8 +353,6 @@
#test .gt_row_group_first th { border-top-width: 2px !important; }
#test .gt_striped { background-color: rgba(128,128,128,0.05) !important; }
#test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; }
- #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
- #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_left { text-align: left !important; }
#test .gt_center { text-align: center !important; }
#test .gt_right { text-align: right !important; font-variant-numeric: tabular-nums !important; }
@@ -358,6 +360,10 @@
#test .gt_font_bold { font-weight: bold !important; }
#test .gt_font_italic { font-style: italic !important; }
#test .gt_super { font-size: 65% !important; }
+ #test .gt_footnotes { color: font-color(#FFFFFF) !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
+ #test .gt_footnote { margin: 0px !important; font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; }
+ #test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
+ #test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_footnote_marks { font-size: 75% !important; vertical-align: 0.4em !important; position: initial !important; }
#test .gt_asterisk { font-size: 100% !important; vertical-align: 0 !important; }
@@ -381,7 +387,6 @@
-
diff --git a/tests/__snapshots__/test_scss.ambr b/tests/__snapshots__/test_scss.ambr
index 00b3549db..6ab91e79b 100644
--- a/tests/__snapshots__/test_scss.ambr
+++ b/tests/__snapshots__/test_scss.ambr
@@ -285,29 +285,6 @@
border-bottom-color: #D3D3D3;
}
- #abc .gt_sourcenotes {
- color: #333333;
- background-color: #FFFFFF;
- border-bottom-style: none;
- border-bottom-width: 2px;
- border-bottom-color: #D3D3D3;
- border-left-style: none;
- border-left-width: 2px;
- border-left-color: #D3D3D3;
- border-right-style: none;
- border-right-width: 2px;
- border-right-color: #D3D3D3;
- }
-
- #abc .gt_sourcenote {
- font-size: 90%;
- padding-top: 4px;
- padding-bottom: 4px;
- padding-left: 5px;
- padding-right: 5px;
- text-align: left;
- }
-
#abc .gt_left {
text-align: left;
}
@@ -337,6 +314,52 @@
font-size: 65%;
}
+ #abc .gt_footnotes {
+ color: font-color(#FFFFFF);
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_footnote {
+ margin: 0px;
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_sourcenotes {
+ color: #333333;
+ background-color: #FFFFFF;
+ border-bottom-style: none;
+ border-bottom-width: 2px;
+ border-bottom-color: #D3D3D3;
+ border-left-style: none;
+ border-left-width: 2px;
+ border-left-color: #D3D3D3;
+ border-right-style: none;
+ border-right-width: 2px;
+ border-right-color: #D3D3D3;
+ }
+
+ #abc .gt_sourcenote {
+ font-size: 90%;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ padding-left: 5px;
+ padding-right: 5px;
+ text-align: left;
+ }
+
#abc .gt_footnote_marks {
font-size: 75%;
vertical-align: 0.4em;
diff --git a/tests/__snapshots__/test_utils_render_html.ambr b/tests/__snapshots__/test_utils_render_html.ambr
index a29289f38..d94345740 100644
--- a/tests/__snapshots__/test_utils_render_html.ambr
+++ b/tests/__snapshots__/test_utils_render_html.ambr
@@ -69,14 +69,7 @@
one |
-
-
-
- yo |
-
-
-
-
+ yo |
diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py
new file mode 100644
index 000000000..eb5890d85
--- /dev/null
+++ b/tests/test_footnotes.py
@@ -0,0 +1,1103 @@
+import polars as pl
+import re
+from great_tables import GT, loc, md, html
+from great_tables._gt_data import FootnotePlacement, FootnoteInfo
+from great_tables._text import Text
+from great_tables._utils_render_html import (
+ create_body_component_h,
+ _apply_footnote_placement,
+ _create_footnote_mark_html,
+ _get_column_index,
+ _get_footnote_mark_string,
+ _get_spanner_leftmost_column_index,
+ _is_numeric_content,
+)
+
+
+def assert_rendered_body(snapshot, gt):
+ built = gt._build_data("html")
+ body = create_body_component_h(built)
+
+ assert snapshot == body
+
+
+def assert_complete_html_without_style(snapshot, gt):
+ import re
+
+ html = gt.as_raw_html()
+ html_without_style = re.sub(r"", "", html, flags=re.DOTALL)
+
+ assert snapshot == html_without_style
+
+
+def _create_test_data():
+ return pl.DataFrame(
+ {
+ "group": ["A", "A", "A", "B", "B", "B"],
+ "row_id": ["r1", "r2", "r3", "r4", "r5", "r6"],
+ "col1": [10, 20, 30, 40, 50, 60],
+ "col2": [100, 200, 300, 400, 500, 600],
+ "col3": [1000, 2000, 3000, 4000, 5000, 6000],
+ }
+ )
+
+
+def _create_base_gt():
+ df = _create_test_data()
+ return (
+ GT(df, rowname_col="row_id", groupname_col="group")
+ .tab_header(title="Test Title", subtitle="Test Subtitle")
+ .tab_spanner(label="Spanner", columns=["col1", "col2"])
+ )
+
+
+def test_tab_footnote_basic():
+ gt_table = _create_base_gt().tab_footnote(
+ footnote="Test footnote", locations=loc.body(columns="col1", rows=[0])
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that footnote appears in footer
+ assert "Test footnote" in html
+ # Check that footnote mark appears in cell (left placement for numbers with auto)
+ assert re.search(r"]*>1 10", html)
+
+
+def test_tab_footnote_numeric_marks():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Second note", locations=loc.body(columns="col2", rows=[1]))
+ .tab_footnote(footnote="Third note", locations=loc.body(columns="col3", rows=[2]))
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that marks appear in the correct order (left placement for numbers with auto)
+ assert re.search(r"]*>1 10", html) # First cell
+ assert re.search(r"]*>2 200", html) # Second cell
+ assert re.search(r"]*>3 3000", html) # Third cell
+
+
+def test_tab_footnote_mark_coalescing():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Second note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Third note", locations=loc.body(columns="col2", rows=[1]))
+ )
+
+ html = gt_table._render_as_html()
+
+ # First cell should have coalesced marks "1,2" (left placement for numbers with auto)
+ assert re.search(r"]*>1,2 10", html)
+ # Second cell should have single mark "3" (left placement for numbers with auto)
+ assert re.search(r"]*>3 200", html)
+
+
+def test_tab_footnote_ordering():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Header note", locations=loc.column_labels(columns="col1"))
+ .tab_footnote(footnote="Later body note", locations=loc.body(columns="col2", rows=[1]))
+ )
+
+ html = gt_table._render_as_html()
+
+ # Header should get mark 1 (comes before body); text gets right placement
+ assert re.search(r">col1]*>1", html)
+ # First body cell should get mark 2; numbers get left placement with auto
+ assert re.search(r"]*>2 10", html)
+ # Later body cell should get mark 3; numbers get left placement with auto
+ assert re.search(r"]*>3 200", html)
+
+
+def test_tab_footnote_all_locations():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Title note", locations=loc.title())
+ .tab_footnote(footnote="Subtitle note", locations=loc.subtitle())
+ .tab_footnote(footnote="Spanner note", locations=loc.spanner_labels(ids=["Spanner"]))
+ .tab_footnote(footnote="Column note", locations=loc.column_labels(columns="col1"))
+ .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Stub note", locations=loc.stub(rows=[0]))
+ .tab_footnote(footnote="Row group note", locations=loc.row_groups(rows=[0]))
+ )
+
+ html = gt_table._render_as_html()
+
+ # All footnotes should appear in footer
+ for note in [
+ "Title note",
+ "Subtitle note",
+ "Spanner note",
+ "Column note",
+ "Body note",
+ "Stub note",
+ "Row group note",
+ ]:
+ assert note in html
+
+ # Check that the footnote marks in the title and subtitle appear
+ assert re.search(r"Test Title]*>1", html) # Title
+ assert re.search(r"Test Subtitle]*>2", html) # Subtitle
+
+
+def test_tab_footnote_symbol_marks_standard():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Second note", locations=loc.body(columns="col2", rows=[1]))
+ .tab_footnote(footnote="Third note", locations=loc.body(columns="col3", rows=[2]))
+ .tab_footnote(footnote="Fourth note", locations=loc.body(columns="col1", rows=[1]))
+ .opt_footnote_marks("standard")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check standard symbols appear in visual reading order (left placement for numbers with auto)
+ assert re.search(r"]*>\* 10", html)
+ assert re.search(r"]*>† 20", html)
+ assert re.search(r"]*>‡ 200", html)
+ assert re.search(r"]*>§ 3000", html)
+
+
+def test_tab_footnote_symbol_marks_extended():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0]))
+ .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0]))
+ .tab_footnote(footnote="Note 4", locations=loc.body(columns="col1", rows=[1]))
+ .tab_footnote(footnote="Note 5", locations=loc.body(columns="col2", rows=[1]))
+ .tab_footnote(footnote="Note 6", locations=loc.body(columns="col3", rows=[1]))
+ .opt_footnote_marks("extended")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check extended symbols appear in reading order (left-to-right, top-to-bottom)
+ # Numbers get left placement with auto
+ symbols = ["*", "†", "‡", "§", "‖", "¶"]
+ values = [10, 100, 1000, 20, 200, 2000]
+
+ for symbol, value in zip(symbols, values):
+ escaped_symbol = re.escape(symbol)
+ assert re.search(f"]*>{escaped_symbol} {value}", html)
+
+
+def test_tab_footnote_symbol_marks_letters():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Note B", locations=loc.body(columns="col2", rows=[0]))
+ .tab_footnote(footnote="Note C", locations=loc.body(columns="col3", rows=[0]))
+ .opt_footnote_marks("letters")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that the letter marks appear (left placement for numbers with auto)
+ assert re.search(r"]*>a 10", html)
+ assert re.search(r"]*>b 100", html)
+ assert re.search(r"]*>c 1000", html)
+
+
+def test_tab_footnote_symbol_marks_uppercase_letters():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Note A", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Note B", locations=loc.body(columns="col2", rows=[0]))
+ .tab_footnote(footnote="Note C", locations=loc.body(columns="col3", rows=[0]))
+ .opt_footnote_marks("LETTERS")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that the uppercase letter marks appear (left placement for numbers with auto)
+ assert re.search(r"]*>A 10", html)
+ assert re.search(r"]*>B 100", html)
+ assert re.search(r"]*>C 1000", html)
+
+
+def test_tab_footnote_custom_symbol_marks():
+ custom_marks = ["❶", "❷", "❸", "❹"] # using circled numbers
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0]))
+ .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0]))
+ .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0]))
+ .opt_footnote_marks(custom_marks)
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that the custom marks appear (in the right order, left placement for numbers with auto)
+ assert re.search(r"]*>❶ 10", html)
+ assert re.search(r"]*>❷ 100", html)
+ assert re.search(r"]*>❸ 1000", html)
+
+
+def test_tab_footnote_symbol_cycling():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="Note 1", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Note 2", locations=loc.body(columns="col2", rows=[0]))
+ .tab_footnote(footnote="Note 3", locations=loc.body(columns="col3", rows=[0]))
+ .tab_footnote(
+ footnote="Note 4", locations=loc.body(columns="col1", rows=[1])
+ ) # Should cycle to **
+ .tab_footnote(
+ footnote="Note 5", locations=loc.body(columns="col2", rows=[1])
+ ) # Should cycle to ††
+ .opt_footnote_marks("standard")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check the cycling behavior (left placement for numbers with auto)
+ assert re.search(r"]*>\* 10", html)
+ assert re.search(r"]*>† 100", html)
+ assert re.search(r"]*>‡ 1000", html)
+ assert re.search(r"]*>§ 20", html)
+ assert re.search(r"]*>\*\* 200", html)
+
+
+def test_tab_footnote_symbol_coalescing():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="First note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Second note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Third note", locations=loc.body(columns="col2", rows=[0]))
+ .opt_footnote_marks("standard")
+ )
+
+ html = gt_table._render_as_html()
+
+ # The first cell should have a coalesced symbol marks (left placement for numbers with auto)
+ assert re.search(r"]*>\*,† 10", html)
+
+ # The second cell should have a single symbol mark (left placement for numbers with auto)
+ assert re.search(r"]*>‡ 100", html)
+
+
+def test_tab_footnote_multiple_rows():
+ gt_table = _create_base_gt().tab_footnote(
+ footnote="Multiple rows note", locations=loc.body(columns="col1", rows=[0, 1, 2])
+ )
+
+ html = gt_table._render_as_html()
+
+ # All three cells should have the same footnote mark (left placement for numbers with auto)
+ assert re.search(r"]*>1 10", html)
+ assert re.search(r"]*>1 20", html)
+ assert re.search(r"]*>1 30", html)
+
+
+def test_tab_footnote_multiple_columns():
+ gt_table = _create_base_gt().tab_footnote(
+ footnote="Multiple columns note", locations=loc.body(columns=["col1", "col2"], rows=[0])
+ )
+
+ html = gt_table._render_as_html()
+
+ # Both cells in the first row should have the same footnote mark (left placement for numbers with auto)
+ assert re.search(r"]*>1 10", html)
+ assert re.search(r"]*>1 100", html)
+
+
+def test_tab_footnote_footer_rendering():
+ gt_table = (
+ _create_base_gt()
+ .tab_footnote(footnote="First footnote text", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Second footnote text", locations=loc.body(columns="col2", rows=[1]))
+ .opt_footnote_marks("standard")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check footnotes appear in footer with correct marks
+ footer_match = re.search(r"]*>.*?", html, re.DOTALL)
+ assert footer_match is not None
+
+ footer_html = footer_match.group(0)
+ assert re.search(r"]*>\*\s*First footnote text", footer_html)
+ assert re.search(r"]*>†\s*Second footnote text", footer_html)
+
+
+def test_tab_footnote_with_text_object():
+ # Test a footnote with the Text object
+ gt_table = _create_base_gt().tab_footnote(
+ footnote=Text("Bold text"), locations=loc.body(columns="col1", rows=[0])
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that the footnote mark appears (left placement for numbers with auto)
+ assert re.search(r"]*>1 10", html)
+
+ # Check that the text object content should appear in the footer
+ assert "Bold text" in html
+
+
+def test_tab_footnote_hidden_columns():
+ df = pl.DataFrame(
+ {
+ "col1": [10],
+ "col2": [100], # Will be hidden
+ "col3": [1000],
+ "col4": [10000], # Will be hidden
+ }
+ )
+
+ gt_table = (
+ GT(df)
+ .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col1"))
+ .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col2"))
+ .tab_footnote(footnote="Note A", locations=loc.column_labels(columns="col3"))
+ .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col2"))
+ .tab_footnote(footnote="Note B", locations=loc.column_labels(columns="col4"))
+ .tab_footnote(footnote="Note C", locations=loc.column_labels(columns="col1"))
+ .cols_hide(columns=["col2", "col4"])
+ )
+
+ html = gt_table._render_as_html()
+
+ # Extract footnote marks from visible column headers
+ col1_match = re.search(r'id="col1"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html)
+ col3_match = re.search(r'id="col3"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html)
+
+ assert col1_match is not None
+ assert col3_match is not None
+
+ col1_marks_match = re.search(
+ r'', col1_match.group(1)
+ )
+ col3_marks_match = re.search(
+ r'', col3_match.group(1)
+ )
+
+ # col1 should have marks 1,2 (Note A and Note C)
+ assert col1_marks_match is not None
+ assert col1_marks_match.group(1) == "1,2"
+
+ # col3 should have mark 1 only (Note A)
+ assert col3_marks_match is not None
+ assert col3_marks_match.group(1) == "1"
+
+ # Extract footer footnotes
+ footer_matches = re.findall(
+ r'\s*([^<]+?)(?=)', html
+ )
+
+ # Should only show 2 footnotes in footer (Note A and Note C)
+ # Note B should not appear because it only targets hidden columns
+ assert len(footer_matches) == 2
+
+ # Check footnote text and marks
+ footnote_dict = {mark.rstrip("."): text.strip() for mark, text in footer_matches}
+ assert footnote_dict["1"] == "Note A" # Appears on visible columns
+ assert footnote_dict["2"] == "Note C" # Appears on visible column
+ assert "Note B" not in html # Should not appear anywhere since only targets hidden columns
+
+
+def test_tab_footnote_mixed_locations_hidden():
+ df = pl.DataFrame({"visible_col": [10], "hidden_col": [100]})
+
+ gt_table = (
+ GT(df)
+ .tab_footnote(
+ footnote="Mixed location note",
+ locations=[
+ loc.column_labels(columns="visible_col"),
+ loc.column_labels(columns="hidden_col"),
+ ],
+ )
+ .cols_hide(columns="hidden_col")
+ )
+
+ html = gt_table._render_as_html()
+
+ # Footnote should appear because it targets at least one visible location
+ assert "Mixed location note" in html
+
+ # Mark should appear on visible column
+ visible_match = re.search(r'id="visible_col"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)', html)
+ assert visible_match is not None
+
+ marks_match = re.search(
+ r'', visible_match.group(1)
+ )
+ assert marks_match is not None
+ assert marks_match.group(1) == "1"
+
+
+def test_tab_footnote_stub_body_ordering_snapshot(snapshot):
+ df = pl.DataFrame(
+ {
+ "name": ["A", "B"],
+ "value": ["Y", "Z"],
+ }
+ )
+
+ gt_table = (
+ GT(df, rowname_col="name", id="test_stub_body_footnotes")
+ .tab_footnote(
+ footnote="Body note.",
+ locations=loc.body(columns="value", rows=[1]),
+ )
+ .tab_footnote(
+ footnote="Stub note.",
+ locations=loc.stub(rows=[0]),
+ )
+ )
+
+ # Use assert_rendered_body to create a smaller, focused snapshot
+ assert_rendered_body(snapshot, gt_table)
+
+
+def test_tab_footnote_complete_ordering_snapshot(snapshot):
+ df = pl.DataFrame(
+ {
+ "name": ["Row1"],
+ "col1": [10],
+ "col2": [20],
+ }
+ )
+
+ gt_table = (
+ GT(df, rowname_col="name", id="test_complete_footnote_ordering")
+ .tab_header(title="Title", subtitle="Subtitle")
+ .tab_stubhead(label="Stubhead")
+ .tab_spanner(label="Spanner A", columns=["col1"])
+ .tab_spanner(label="Spanner B", columns=["col2"])
+ .tab_footnote(footnote="Subtitle note", locations=loc.subtitle())
+ .tab_footnote(footnote="Spanner B note", locations=loc.spanner_labels(ids=["Spanner B"]))
+ .tab_footnote(footnote="Spanner A note", locations=loc.spanner_labels(ids=["Spanner A"]))
+ .tab_footnote(footnote="Title note", locations=loc.title())
+ .tab_footnote(footnote="Col2 note", locations=loc.column_labels(columns="col2"))
+ .tab_footnote(footnote="Col1 note", locations=loc.column_labels(columns="col1"))
+ .tab_footnote(footnote="Body note", locations=loc.body(columns="col1", rows=[0]))
+ .tab_footnote(footnote="Stub note", locations=loc.stub(rows=[0]))
+ .tab_footnote(footnote="Stubhead note", locations=loc.stubhead())
+ )
+
+ # Use `assert_complete_html_without_style()` to capture all footnote marks in the table (and
+ # the footnotes in the footer section)
+ assert_complete_html_without_style(snapshot, gt_table)
+
+
+def test_tab_footnote_md_with_unit_notation():
+ df = pl.DataFrame({"area": [100, 200], "value": [10, 20]})
+
+ gt_table = GT(df).tab_footnote(
+ footnote=md("**Area** is measured in {{km^2}}."),
+ locations=loc.body(columns="area", rows=[0]),
+ )
+
+ html_output = gt_table._render_as_html()
+
+ assert (
+ 'Area is measured in km2'
+ in html_output
+ )
+
+
+def test_tab_footnote_html_with_unit_notation():
+ # Test that html() footnotes also support unit notation like {{km^2}}
+ df = pl.DataFrame({"area": [100, 200], "value": [10, 20]})
+
+ gt_table = GT(df).tab_footnote(
+ footnote=html("Area is measured in {{km^2}}."),
+ locations=loc.body(columns="area", rows=[0]),
+ )
+
+ html_output = gt_table._render_as_html()
+
+ assert (
+ 'Area is measured in km2'
+ in html_output
+ )
+
+
+def test_footer_structure_combined():
+ df = pl.DataFrame({"area": [100, 200], "value": [10, 20]})
+
+ gt_table = (
+ GT(df)
+ .tab_source_note("Source: Test data.")
+ .tab_footnote(
+ footnote="Area footnote.",
+ locations=loc.body(columns="area", rows=[0]),
+ )
+ .tab_footnote(
+ footnote="Value footnote.",
+ locations=loc.body(columns="value", rows=[1]),
+ )
+ )
+
+ html_output = gt_table._render_as_html()
+
+ # Check that there is only a single container
+ assert html_output.count("") == 1
+
+ # Check that both source notes and footnotes are present
+ assert "gt_sourcenote" in html_output
+ assert html_output.count("gt_footnote") >= 2
+
+ # Check proper class structure (should use the `gt_footnotes` class)
+ assert 'class="gt_footnotes"' in html_output
+
+ # Check that footnote marks are present
+ assert "gt_footnote_marks" in html_output
+
+
+def test_tab_footnote_complex_spanner_ordering():
+ df = pl.DataFrame(
+ {
+ "region": ["North", "South", "East", "West"],
+ "q1_sales": [100, 110, 95, 105],
+ "q1_profit": [20, 25, 18, 22],
+ "q2_sales": [120, 130, 115, 125],
+ "q2_profit": [25, 30, 22, 28],
+ "q3_sales": [140, 150, 135, 145],
+ "q3_profit": [30, 35, 27, 32],
+ }
+ )
+
+ gt_table = (
+ GT(df, rowname_col="region")
+ .tab_header(title="Quarterly Performance", subtitle="By Region")
+ .tab_stubhead(label="Region")
+ .tab_spanner(label="Q1 Performance", columns=["q1_sales", "q1_profit"])
+ .tab_spanner(label="Q2 Performance", columns=["q2_sales", "q2_profit"])
+ .tab_spanner(label="Q3 Performance", columns=["q3_sales", "q3_profit"])
+ .tab_spanner(label="Sales Data", columns=["q1_sales", "q2_sales", "q3_sales"])
+ .tab_spanner(label="Profit Data", columns=["q1_profit", "q2_profit", "q3_profit"])
+ .cols_label(
+ q1_sales="Sales",
+ q1_profit="Profit",
+ q2_sales="Sales",
+ q2_profit="Profit",
+ q3_sales="Sales",
+ q3_profit="Profit",
+ )
+ .tab_footnote(footnote="Title footnote", locations=loc.title())
+ .tab_footnote(footnote="Subtitle footnote", locations=loc.subtitle())
+ .tab_footnote(footnote="Stubhead footnote", locations=loc.stubhead())
+ .tab_footnote(
+ footnote="Sales Data spanner footnote", locations=loc.spanner_labels(ids=["Sales Data"])
+ )
+ .tab_footnote(
+ footnote="Profit Data spanner footnote",
+ locations=loc.spanner_labels(ids=["Profit Data"]),
+ )
+ .tab_footnote(
+ footnote="Q1 Performance spanner footnote",
+ locations=loc.spanner_labels(ids=["Q1 Performance"]),
+ )
+ .tab_footnote(
+ footnote="Q2 Performance spanner footnote",
+ locations=loc.spanner_labels(ids=["Q2 Performance"]),
+ )
+ .tab_footnote(
+ footnote="Q3 Performance spanner footnote",
+ locations=loc.spanner_labels(ids=["Q3 Performance"]),
+ )
+ .tab_footnote(
+ footnote="Q1 Sales column footnote", locations=loc.column_labels(columns="q1_sales")
+ )
+ .tab_footnote(
+ footnote="Q1 Profit column footnote", locations=loc.column_labels(columns="q1_profit")
+ )
+ .tab_footnote(footnote="North region footnote", locations=loc.stub(rows=[0]))
+ .tab_footnote(footnote="South region footnote", locations=loc.stub(rows=[1]))
+ .tab_footnote(
+ footnote="Cell footnote (North Q1 Sales)",
+ locations=loc.body(columns="q1_sales", rows=[0]),
+ )
+ .tab_footnote(
+ footnote="Cell footnote (South Q2 Profit)",
+ locations=loc.body(columns="q2_profit", rows=[1]),
+ )
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that all footnotes appear in footer
+ expected_footnotes = [
+ "Title footnote",
+ "Subtitle footnote",
+ "Stubhead footnote",
+ "Sales Data spanner footnote",
+ "Profit Data spanner footnote",
+ "Q1 Performance spanner footnote",
+ "Q2 Performance spanner footnote",
+ "Q3 Performance spanner footnote",
+ "Q1 Sales column footnote",
+ "Q1 Profit column footnote",
+ "North region footnote",
+ "South region footnote",
+ "Cell footnote (North Q1 Sales)",
+ "Cell footnote (South Q2 Profit)",
+ ]
+
+ for footnote in expected_footnotes:
+ assert footnote in html
+
+ #
+ # Check that footnote marks appear in the expected locations
+ #
+
+ # Title should have mark 1
+ assert re.search(r"Quarterly Performance]*>1", html)
+
+ # Subtitle should have mark 2
+ assert re.search(r"By Region]*>2", html)
+
+ # Stubhead should have mark 3
+ assert re.search(r"Region]*>3", html)
+
+ # Test that spanner marks are present by looking for any spanner with footnote marks
+ spanner_marks = re.findall(
+ r'[^<]*]*class="gt_footnote_marks"[^>]*>([^<]+)',
+ html,
+ )
+ assert len(spanner_marks) > 0
+
+ #
+ # Test that marks are ordered sequentially (1, 2, 3, ...)
+ #
+
+ # Extract all footnote marks from the HTML
+ mark_pattern = r']*class="gt_footnote_marks"[^>]*>([^<]+)'
+ all_marks = re.findall(mark_pattern, html)
+
+ # Convert marks to individual numbers (handle comma-separated marks like "1,2")
+ mark_numbers = []
+ for mark in all_marks:
+ for single_mark in mark.split(","):
+ if single_mark.strip().isdigit():
+ mark_numbers.append(int(single_mark.strip()))
+
+ # Check they include sequential numbers starting from 1
+ if mark_numbers:
+ unique_marks = sorted(list(set(mark_numbers)))
+ assert 1 in unique_marks
+ assert len(unique_marks) >= 3
+
+
+def test_tab_footnote_spanner_specific_functionality():
+ df = pl.DataFrame({"col1": [1, 2], "col2": [3, 4], "col3": [5, 6], "col4": [7, 8]})
+
+ gt_table = (
+ GT(df)
+ .tab_spanner(label="Group A", columns=["col1", "col2"])
+ .tab_spanner(label="Group B", columns=["col3", "col4"])
+ .tab_footnote(footnote="First spanner note", locations=loc.spanner_labels(ids=["Group A"]))
+ .tab_footnote(footnote="Second spanner note", locations=loc.spanner_labels(ids=["Group A"]))
+ .tab_footnote(footnote="Group B note", locations=loc.spanner_labels(ids=["Group B"]))
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that all spanner footnotes appear
+ assert "First spanner note" in html
+ assert "Second spanner note" in html
+ assert "Group B note" in html
+
+ #
+ # Check that spanner labels get footnote marks
+ #
+
+ # Group A should have marks for both footnotes
+ group_a_marks = re.findall(
+ r'Group A]*class="gt_footnote_marks"[^>]*>([^<]+)', html
+ )
+ assert len(group_a_marks) >= 1
+
+ # Group B should have its own mark
+ group_b_marks = re.findall(
+ r'Group B]*class="gt_footnote_marks"[^>]*>([^<]+)', html
+ )
+ assert len(group_b_marks) >= 1
+
+
+# ===========================================================================================
+# Tests for utility functions
+# ===========================================================================================
+
+
+def test_is_numeric_content():
+ # Test basic numbers
+ assert _is_numeric_content("123") == True
+ assert _is_numeric_content("123.45") == True
+ assert _is_numeric_content("0") == True
+ assert _is_numeric_content("0.0") == True
+
+ # Test formatted numbers
+ assert _is_numeric_content("1,234") == True
+ assert _is_numeric_content("1,234.56") == True
+ assert _is_numeric_content("$123") == True
+ assert _is_numeric_content("$1,234.56") == True
+ assert _is_numeric_content("123%") == True
+ assert _is_numeric_content("(123)") == True
+ assert _is_numeric_content("€1,234.56") == True
+ assert _is_numeric_content("£1,234.56") == True
+ assert _is_numeric_content("¥1,234") == True
+
+ # Test numbers with various formatting
+ assert _is_numeric_content(" 123 ") == True
+ assert _is_numeric_content("+123") == True
+ assert _is_numeric_content("-123") == True
+ assert _is_numeric_content("−123") == True
+
+ # Test with HTML tags
+ assert _is_numeric_content("123") == True
+ assert _is_numeric_content("$1,234.56") == True
+ assert _is_numeric_content('123.45
') == True
+
+ # Test non-numeric content
+ assert _is_numeric_content("Hello") == False
+ assert _is_numeric_content("Text123") == False
+ assert _is_numeric_content("123Text") == False
+ assert _is_numeric_content("A") == False
+ assert _is_numeric_content("NA") == False
+ assert _is_numeric_content("NULL") == False
+ assert _is_numeric_content("") == False
+ assert _is_numeric_content(" ") == False
+
+ # Test mixed content with HTML
+ assert _is_numeric_content("Hello") == False
+ assert _is_numeric_content("Text Content") == False
+
+ # Test edge cases
+ assert _is_numeric_content("$") == False
+ assert _is_numeric_content("%") == False
+ assert _is_numeric_content("()") == False
+ assert _is_numeric_content(",") == False
+ assert _is_numeric_content(".") == False
+ assert _is_numeric_content("..") == False
+
+
+def test_apply_footnote_placement():
+ text = "123"
+ marks_html = ''
+
+ # Test left placement
+ result = _apply_footnote_placement(text, marks_html, FootnotePlacement.left)
+ expected = ' 123'
+ assert result == expected
+
+ # Test right placement
+ result = _apply_footnote_placement(text, marks_html, FootnotePlacement.right)
+ expected = '123'
+ assert result == expected
+
+ # Test auto placement with numeric content
+ result = _apply_footnote_placement("123", marks_html, FootnotePlacement.auto)
+ expected = ' 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 = 'Hello' # 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,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 = ' 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 = (
+ ' 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 = (
+ 'Hello' # Should go right for text in HTML
+ )
+ assert result == expected
+
+
+def test_footnote_placement_snapshot_different_types(snapshot):
+ import pandas as pd
+
+ # Create test data with different value types
+ df = pd.DataFrame(
+ {
+ "integers": [42],
+ "floats": [123.45],
+ "currency": ["$1,234.56"],
+ "percentages": ["85.5%"],
+ "text": ["Hello"],
+ "mixed": ["ABC123"],
+ "formatted_num": ["(1,000)"],
+ "scientific": ["1.23e-4"],
+ }
+ )
+
+ # Test with auto placement (default)
+ gt_auto = (
+ GT(df, id="test_auto_placement")
+ .tab_header(title="Auto Placement Test")
+ .tab_footnote("Integer footnote", locations=loc.body(columns="integers", rows=[0]))
+ .tab_footnote("Float footnote", locations=loc.body(columns="floats", rows=[0]))
+ .tab_footnote("Currency footnote", locations=loc.body(columns="currency", rows=[0]))
+ .tab_footnote("Percentage footnote", locations=loc.body(columns="percentages", rows=[0]))
+ .tab_footnote("Text footnote", locations=loc.body(columns="text", rows=[0]))
+ .tab_footnote("Mixed footnote", locations=loc.body(columns="mixed", rows=[0]))
+ .tab_footnote(
+ "Formatted number footnote", locations=loc.body(columns="formatted_num", rows=[0])
+ )
+ .tab_footnote("Scientific footnote", locations=loc.body(columns="scientific", rows=[0]))
+ )
+
+ assert_complete_html_without_style(snapshot, gt_auto)
+
+
+def test_footnote_placement_snapshot_left_placement(snapshot):
+ import pandas as pd
+
+ df = pd.DataFrame({"integers": [42], "text": ["Hello"], "currency": ["$1,234.56"]})
+
+ # Test with explicit left placement
+ gt_left = (
+ GT(df, id="test_left_placement")
+ .tab_header(title="Left Placement Test")
+ .tab_footnote(
+ "Integer footnote", locations=loc.body(columns="integers", rows=[0]), placement="left"
+ )
+ .tab_footnote(
+ "Text footnote", locations=loc.body(columns="text", rows=[0]), placement="left"
+ )
+ .tab_footnote(
+ "Currency footnote", locations=loc.body(columns="currency", rows=[0]), placement="left"
+ )
+ )
+
+ assert_complete_html_without_style(snapshot, gt_left)
+
+
+def test_footnote_placement_snapshot_right_placement(snapshot):
+ import pandas as pd
+
+ df = pd.DataFrame({"integers": [42], "text": ["Hello"], "currency": ["$1,234.56"]})
+
+ # Test with explicit right placement
+ gt_right = (
+ GT(df, id="test_right_placement")
+ .tab_header(title="Right Placement Test")
+ .tab_footnote(
+ "Integer footnote", locations=loc.body(columns="integers", rows=[0]), placement="right"
+ )
+ .tab_footnote(
+ "Text footnote", locations=loc.body(columns="text", rows=[0]), placement="right"
+ )
+ .tab_footnote(
+ "Currency footnote", locations=loc.body(columns="currency", rows=[0]), placement="right"
+ )
+ )
+
+ assert_complete_html_without_style(snapshot, gt_right)
+
+
+def test_source_notes_single_line_with_footnotes():
+ import pandas as pd
+
+ df = pd.DataFrame({"values": [42, 123]})
+
+ # Create a table with source notes in single-line mode and footnotes
+ gt_table = (
+ GT(df)
+ .tab_header(title="Table with Source Notes and Footnotes")
+ .tab_source_note("First source note")
+ .tab_source_note("Second source note")
+ .tab_source_note("Third source note")
+ .tab_footnote("Value footnote", locations=loc.body(columns="values", rows=[0]))
+ .tab_options(source_notes_multiline=False)
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that source notes are in single line (joined by separator)
+ # The default separator should be used to join the notes
+ assert "First source note" in html
+ assert "Second source note" in html
+ assert "Third source note" in html
+
+ # Check that footnotes are also present
+ assert "Value footnote" in html
+
+ # Verify the HTML structure: source notes should be in a single row
+ import re
+
+ # Look for source notes in a single with the `gt_sourcenote` class
+ source_note_pattern = r' | ]*>[^<]*First source note[^<]*Second source note[^<]*Third source note[^<]* |
'
+ assert re.search(source_note_pattern, html)
+
+
+def test_source_notes_multiline_with_footnotes():
+ import pandas as pd
+
+ df = pd.DataFrame({"values": [42, 123]})
+
+ # Create a table with source notes in multiline mode and footnotes
+ gt_table = (
+ GT(df)
+ .tab_header(title="Table with Multiline Source Notes and Footnotes")
+ .tab_source_note("First source note")
+ .tab_source_note("Second source note")
+ .tab_source_note("Third source note")
+ .tab_footnote("Value footnote", locations=loc.body(columns="values", rows=[0]))
+ .tab_options(source_notes_multiline=True)
+ )
+
+ html = gt_table._render_as_html()
+
+ # Check that source notes are present
+ assert "First source note" in html
+ assert "Second source note" in html
+ assert "Third source note" in html
+
+ # Check that footnotes are also present
+ assert "Value footnote" in html
+
+ # Verify the HTML structure: each source note should be in its own row
+ import re
+
+ # Look for multiple source note rows
+ source_note_rows = re.findall(
+ r']*>', html
+ )
+ assert len(source_note_rows) >= 3
+
+
+def test_footnote_and_source_note_integration():
+ import pandas as pd
+
+ df = pd.DataFrame({"numbers": [100, 200], "text": ["Alpha", "Beta"]})
+
+ # Create a comprehensive table with both footnotes and source notes
+ gt_table = (
+ GT(df)
+ .tab_header(title="Integration Test: Footnotes and Source Notes")
+ .tab_footnote("Number footnote", locations=loc.body(columns="numbers", rows=[0]))
+ .tab_footnote("Text footnote", locations=loc.body(columns="text", rows=[1]))
+ .tab_source_note("Data source: Example dataset")
+ .tab_source_note("Analysis performed in 2025")
+ .tab_options(source_notes_multiline=False)
+ )
+
+ html = gt_table._render_as_html()
+
+ # Verify footnotes are applied with correct placement
+ import re
+
+ # Numbers should get left placement, text should get right placement
+ assert re.search(r"]*>1 100", html), "Number footnote should be left-placed"
+ assert re.search(r"Beta]*>2", html), "Text footnote should be right-placed"
+
+ # Verify footnotes appear in footer
+ assert "Number footnote" in html
+ assert "Text footnote" in html
+
+ # Verify source notes appear in footer in single line
+ assert "Data source: Example dataset" in html
+ assert "Analysis performed in 2025" in html
+
+ # Check that both footnotes and source notes are in the footer section
+ footer_match = re.search(r" |
(.*?)", html, re.DOTALL)
+ assert footer_match
+
+ # Footer should contain both source notes and footnotes
+ footer_content = footer_match.group(1)
+ assert "Data source: Example dataset" in footer_content
+ assert "Number footnote" in footer_content
+ assert "Text footnote" in footer_content
+
+
+def test_create_footnote_mark_html_edge_cases():
+ # Test that empty mark should return an empty string
+ result = _create_footnote_mark_html("")
+ assert result == ""
+
+
+def test_footnote_mark_string_edge_cases():
+ # Test with empty GTData (no footnotes)
+ empty_gt = GT(pl.DataFrame({"col": [1]}))
+ footnote_info = FootnoteInfo(locname="body", rownum=0, colname="col", footnotes=["test"])
+
+ # Should return mark "1" when no existing footnotes
+ result = _get_footnote_mark_string(empty_gt._build_data("footnote_test"), footnote_info)
+ assert result == "1"
+
+ # Test with footnote_info having no footnotes
+ footnote_info_empty = FootnoteInfo(locname="body", rownum=0, colname="col", footnotes=[])
+ result = _get_footnote_mark_string(empty_gt._build_data("footnote_test"), footnote_info_empty)
+ assert result == "1"
+
+
+def test_get_column_index_edge_cases():
+ df = pl.DataFrame({"col1": [1], "col2": [2], "col3": [3]})
+ gt_table = GT(df)
+ data = gt_table._build_data("test")
+
+ # Test situation where colname is None or empty
+ result = _get_column_index(data, None)
+ assert result == 0
+
+ result = _get_column_index(data, "")
+ assert result == 0
+
+ # Test situation where the column name is not found
+ result = _get_column_index(data, "nonexistent_column")
+ assert result == 0
+
+ # Tests of normal cases where the column provided exists
+ result = _get_column_index(data, "col1")
+ assert result == 0
+
+ result = _get_column_index(data, "col2")
+ assert result == 1
+
+ result = _get_column_index(data, "col3")
+ assert result == 2
+
+
+def test_get_spanner_leftmost_column_index_edge_cases():
+ df = pl.DataFrame({"col1": [1], "col2": [2], "col3": [3]})
+ gt_table = GT(df).tab_spanner(label="Test Spanner", columns=["col2", "col3"])
+ data = gt_table._build_data("test")
+
+ # Test case 1: `spanner_grpname` is None
+ result = _get_spanner_leftmost_column_index(data, None)
+ assert result == 0
+
+ # Test case 2: `spanner_grpname` is empty string
+ result = _get_spanner_leftmost_column_index(data, "")
+ assert result == 0
+
+ # Test case 3: `spanner_grpname` doesn't exist
+ result = _get_spanner_leftmost_column_index(data, "Nonexistent Spanner")
+ assert result == 0
+
+ # Test normal case: existing spanner should return leftmost column index;
+ # col2 is at index 1, col3 at index 2, so leftmost is 1
+ result = _get_spanner_leftmost_column_index(data, "Test Spanner")
+ assert result == 1