Skip to content

Conversation

rich-iannone
Copy link
Member

@rich-iannone rich-iannone commented Aug 12, 2025

This PR adds the tab_footnote() method which allows you to add footnotes for different locations in the table. The method integrates footnote mark placement into the rendering pipeline for table headings, column labels, body cells, etc., and extends the internal data structures and options to support footnote config.

Here's an example of how this works in practice:

import polars as pl
from great_tables import GT, loc, md, html
from great_tables.data import towny

tbl_data = (
    pl.from_pandas(towny)
    .filter(pl.col('csd_type') == 'city')
    .select(['name', 'density_2021', 'population_2021', 'density_2016', 'population_2016'])
    .top_k(8, by='population_2021')
    .sort('population_2021', descending=True)
    .with_columns([
        ((pl.col('population_2021') - pl.col('population_2016')) / pl.col('population_2016') * 100).round(1).alias('pop_change'),
        ((pl.col('density_2021') - pl.col('density_2016')) / pl.col('density_2016') * 100).round(1).alias('density_change')
    ])
)

(
    GT(tbl_data, rowname_col='name')
    .tab_header(
        title=md('Ontario Cities: **2016 vs 2021 Census Data**'),
        subtitle='Comparison of population and density changes over 5 years.'
    )
    .tab_stubhead(label='Municipality')
    .tab_spanner(label='2016 Census', columns=['population_2016', 'density_2016'])
    .tab_spanner(label='2021 Census', columns=['population_2021', 'density_2021'])
    .tab_spanner(label='5-Year Change', columns=['pop_change', 'density_change'])
    .fmt_integer(columns=['population_2016', 'population_2021'])
    .fmt_number(columns=['density_2016', 'density_2021'], decimals=1)
    .fmt_number(columns=['pop_change', 'density_change'], decimals=1)
    .cols_label(
        population_2016='Population',
        density_2016='Density',
        population_2021='Population',
        density_2021='Density',
        pop_change='Population (%)',
        density_change='Density (%)'
    )
    .tab_footnote(
        footnote='Data taken from the census.',
        locations=loc.title()
    )
    .tab_footnote(
        footnote='Municipality names as they appear in the census.',
        locations=loc.stubhead()  # Test stubhead footnote
    )
    .tab_footnote(
        footnote='Population and density figures from the 2016 Census.',
        locations=loc.spanner_labels(ids=['2016 Census'])
    )
    .tab_footnote(
        footnote='Population and density figures from the 2021 Census.',
        locations=loc.spanner_labels(ids=['2021 Census'])
    )
    .tab_footnote(
        footnote='Percentage change calculated as ((2021 - 2016) / 2016) × 100.',
        locations=loc.spanner_labels(ids=['5-Year Change'])
    )
    .tab_footnote(
        footnote='Density measured in persons per square kilometer.',
        locations=loc.column_labels(columns=['density_2016', 'density_2021'])
    )
    .tab_footnote(
        footnote='Part of the Greater Toronto Area.',
        locations=loc.stub(rows=['Toronto', 'Brampton', 'Mississauga'])
    )
    .tab_footnote(
        footnote='Highest population growth in this dataset.',
        locations=loc.body(columns='pop_change', rows=3)
    )
    .tab_source_note(
        source_note = md("Data taken from the `towny` dataset (originally from the **gt** package).")
    )
)

And here is how it looks:

image

Fixes: #164

Copy link

codecov bot commented Aug 12, 2025

Codecov Report

❌ Patch coverage is 91.82561% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.72%. Comparing base (654757a) to head (6a39b35).

Files with missing lines Patch % Lines
great_tables/_utils_render_html.py 92.00% 20 Missing ⚠️
great_tables/_footnotes.py 65.21% 8 Missing ⚠️
great_tables/_locations.py 98.66% 1 Missing ⚠️
great_tables/_text.py 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #763      +/-   ##
==========================================
+ Coverage   91.45%   91.72%   +0.27%     
==========================================
  Files          47       47              
  Lines        5558     5875     +317     
==========================================
+ Hits         5083     5389     +306     
- Misses        475      486      +11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 03:54 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 03:58 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 13:41 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 19:58 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 20:00 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 21:06 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 22:10 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 13, 2025 18:48 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 13, 2025 18:53 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 13, 2025 18:59 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 13, 2025 19:08 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 13, 2025 20:07 Destroyed
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 = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not hard-code any mappings from strings to information. Either add it as an attribute on the class, or use a function / mapping from dataclasses to info.

footnote_positions: list[tuple[tuple[int, int, int], FootnoteInfo]] = []

for fn_info in visible_footnotes:
if fn_info.locname == "none":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems concerning, that there is a string named "none"

@@ -875,10 +875,10 @@ class FootnotePlacement(Enum):

@dataclass(frozen=True)
class FootnoteInfo:
locname: Loc | None = None
locname: str | None = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From pairing, this should be changed back to Loc (which will require fairly extensive refactoring in areas where locname strings are checked)

@@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

locname should just be the loc dataclass instance. (see style loc handling)

Copy link
Collaborator

@machow machow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like currently, the code inserts strings for locations. From pairing, it seems like the footnotes location code is largely the same as that for styling (e.g. targetting footnote locations). Let's explore consolidating the code to set style and footnote data on the GT object.

_locations.py example:

Here is an example modification to set_style() for LocBody() that handles both footnote and style entries. Note the addition of FootnoteEntry.

@set_style.register
def _(loc: LocBody, data: GTData, style: list[CellStyle | FootnoteEntry]) -> GTData:
    positions: list[CellPos] = resolve(loc, data)

    # CHANGE MADE HERE: split styles and footnotes -----
    style, new_footnotes = footnotes_split_style_list(style)
    # ENDCHANGE

    # evaluate any column expressions in styles
    style_ready = [entry._evaluate_expressions(data._tbl_data) for entry in style]
    all_info: list[StyleInfo] = []
    for col_pos in positions:
        row_styles = [entry._from_row(data._tbl_data, col_pos.row) for entry in style_ready]
        crnt_info = StyleInfo(
            locname=loc, colname=col_pos.colname, rownum=col_pos.row, styles=row_styles
        )
        all_info.append(crnt_info)

    # CHANGE MADE HERE: added _footnotes ----
    return data._replace(_styles=data._styles + all_info, _footnotes=data._footnotes + new_footnotes)

Then, basically anywhere styles are handled when rendering HTML, there could be a related footnote handling (I think this currently happens in the PR).

Big changes needed

  • No use of strings for loc (i.e. us isinstance checks or whatever style does)
  • No priority info separate from Loc data (e.g. add attribute to Loc with priority level; OR define function that takes a loc and returns a priority based on instance checks / dispatching, etc.. but not strings.)

@@ -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(
Copy link
Collaborator

@machow machow Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic like this should match styles logic (above) for filtering FootnoteInfo. Notice that on line 317 (style_i = ...), the filtering is done directly. We should match this behavior for footnotes (rather than passing down into a function). If we change it for one, we should change for both.

Recommend pulling filtering out of _add_footnote_marks_to_text() and filtering directly with equivalent styles_i = ... logic (if that's sensible).

return 0


def _add_footnote_marks_to_text(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT it would be helpful if this function did not do any filtering of the FootnoteInfo. That way, similar to how styles are handled, people would see the footnote filtering is very similar, and then a simple function call similar to _flatten_styles() illustrating how footnote text gets inserted or something.

@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 15:11 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 15:46 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 16:55 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 17:05 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 17:08 Destroyed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

epic: Implement footnotes
2 participants