Skip to content

feat: add the tab_footnote() method #763

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 40 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0ac0203
Modify FootnoteInfo class
rich-iannone Aug 11, 2025
b8037ed
Add the tab_footnote() method
rich-iannone Aug 11, 2025
75d1d6e
Implement set_footnote for various location types
rich-iannone Aug 11, 2025
44c86ae
Remove unused tab_footnote() function and imports
rich-iannone Aug 11, 2025
6610a8d
Add footnote mark rendering to HTML table components
rich-iannone Aug 11, 2025
14e0d35
Improve footnote numbering to align with display order
rich-iannone Aug 11, 2025
a07f78f
Enable the footnotes_marks option in Options class
rich-iannone Aug 11, 2025
75557b4
Enable the footnotes_marks parameter in tab_options()
rich-iannone Aug 11, 2025
7673067
Add support for custom footnote mark symbols
rich-iannone Aug 11, 2025
cf770e7
Add footnote marks to col labels in HTML rendering
rich-iannone Aug 12, 2025
b23cfc6
Add several tests
rich-iannone Aug 12, 2025
298e3f1
Hide footnotes for hidden columns in HTML rendering
rich-iannone Aug 12, 2025
d679a16
Add several tests
rich-iannone Aug 12, 2025
fea2271
Update test_footnotes.py
rich-iannone Aug 12, 2025
8eba278
Remove period from footnote marks in footer HTML
rich-iannone Aug 12, 2025
f0ac15d
Add tab_footnote() to the API reference docs
rich-iannone Aug 12, 2025
aca0cb1
Improve footnote handling and ordering in rendered tbl
rich-iannone Aug 12, 2025
71daaef
Refactor footnote mark HTML generation
rich-iannone Aug 12, 2025
76727f8
Align stub and data cell footnote ordering
rich-iannone Aug 12, 2025
80f2ab3
Add snapshot test for footnotes in stub and body
rich-iannone Aug 12, 2025
7d37e50
Support unit notation in HTML/Markdown footnotes
rich-iannone Aug 12, 2025
1f28a4a
Add unified HTML footer for source notes and footnotes
rich-iannone Aug 13, 2025
71107cb
Update tests and snapshots
rich-iannone Aug 13, 2025
d9c44cb
Modify docstring for tab_footnote()
rich-iannone Aug 13, 2025
7a8d788
Ensure that spanners and stubhead label can have footnotes
rich-iannone Aug 13, 2025
d8f44fb
Update tests and snapshots
rich-iannone Aug 13, 2025
da34031
Remove unneeded utility function
rich-iannone Aug 13, 2025
cec470c
Remove more unneeded code
rich-iannone Aug 13, 2025
832825b
Refactor footnote location hierarchy mapping
rich-iannone Aug 13, 2025
8c7fcd8
Incorporate footnote placement (auto/left/right)
rich-iannone Aug 13, 2025
4b0a424
Revise tests and snapshots
rich-iannone Aug 13, 2025
efb6186
Refine numberlike detection
rich-iannone Aug 13, 2025
f82b6ef
Update tests and snapshots
rich-iannone Aug 13, 2025
ed6d9ad
Add several tests for single-line source notes
rich-iannone Aug 13, 2025
d990b4f
Remove unneeded utility function
rich-iannone Aug 13, 2025
95a0579
Add tests for some edge cases
rich-iannone Aug 13, 2025
218b379
Add tests of _get_column_index()
rich-iannone Aug 13, 2025
84292b7
Add tests for _get_spanner_leftmost_column_index()
rich-iannone Aug 13, 2025
3cc0da6
Refactor tests for tab_footnote()
rich-iannone Aug 13, 2025
6b58300
Add opt_footnote_marks to API reference docs
rich-iannone Aug 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
143 changes: 142 additions & 1 deletion great_tables/_footnotes.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions great_tables/_gt_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
142 changes: 141 additions & 1 deletion great_tables/_locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions great_tables/_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 1 addition & 38 deletions great_tables/_tab_create_modify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading
Loading