@@ -29,12 +29,76 @@ def _is_dtype_numeric(dtype) -> bool:
2929 return pandas .api .types .is_numeric_dtype (dtype )
3030
3131
32+ def _calculate_rowspans (dataframe : pd .DataFrame ) -> list [list [int ]]:
33+ """Calculates the rowspan for each cell in a MultiIndex DataFrame.
34+
35+ Args:
36+ dataframe (pd.DataFrame):
37+ The DataFrame for which to calculate index rowspans.
38+
39+ Returns:
40+ list[list[int]]:
41+ A list of lists, where each inner list corresponds to an index level
42+ and contains the rowspan for each row at that level. A value of 0
43+ indicates that the cell should not be rendered (it's covered by a
44+ previous rowspan).
45+ """
46+ if not isinstance (dataframe .index , pd .MultiIndex ):
47+ # If not a MultiIndex, no rowspans are needed for the index itself.
48+ # Return a structure that indicates each index cell should be rendered once.
49+ return [[1 ] * len (dataframe .index )] if dataframe .index .nlevels > 0 else []
50+
51+ rowspans : list [list [int ]] = []
52+ for level_idx in range (dataframe .index .nlevels ):
53+ current_level_spans : list [int ] = []
54+ current_value = None
55+ current_span = 0
56+
57+ for i in range (len (dataframe .index )):
58+ value = dataframe .index .get_level_values (level_idx )[i ]
59+
60+ if value == current_value :
61+ current_span += 1
62+ current_level_spans .append (0 ) # Mark as covered by previous rowspan
63+ else :
64+ # If new value, finalize previous span and start a new one
65+ if current_span > 0 :
66+ # Update the rowspan for the start of the previous span
67+ current_level_spans [i - current_span ] = current_span
68+ current_value = value
69+ current_span = 1
70+ current_level_spans .append (0 ) # Placeholder, will be updated later
71+
72+ # Finalize the last span
73+ if current_span > 0 :
74+ current_level_spans [len (dataframe .index ) - current_span ] = current_span
75+
76+ rowspans .append (current_level_spans )
77+
78+ return rowspans
79+
80+
3281def render_html (
3382 * ,
3483 dataframe : pd .DataFrame ,
3584 table_id : str ,
3685) -> str :
37- """Render a pandas DataFrame to HTML with specific styling."""
86+ """Renders a pandas DataFrame to an HTML table with specific styling.
87+
88+ This function generates an HTML table representation of a pandas DataFrame,
89+ including special handling for MultiIndex to create a nested, rowspan-based
90+ display similar to the BigQuery UI.
91+
92+ Args:
93+ dataframe (pd.DataFrame):
94+ The DataFrame to render.
95+ table_id (str):
96+ A unique ID to assign to the HTML table element.
97+
98+ Returns:
99+ str:
100+ An HTML string representing the rendered DataFrame.
101+ """
38102 classes = "dataframe table table-striped table-hover"
39103 table_html = [f'<table border="1" class="{ classes } " id="{ table_id } ">' ]
40104 precision = options .display .precision
@@ -46,33 +110,46 @@ def render_html(
46110 # Add index headers
47111 for name in dataframe .index .names :
48112 table_html .append (
49- f' <th style="text-align: left;">'
50- f'<div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">'
51- f"{ html .escape (str (name ))} </div></th>"
113+ (
114+ f' <th style="text-align: left;">'
115+ f'<div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">'
116+ f"{ html .escape (str (name ))} </div></th>"
117+ )
52118 )
53119
54120 for col in dataframe .columns :
55121 table_html .append (
56- f' <th style="text-align: left;">'
57- f'<div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">'
58- f"{ html .escape (str (col ))} </div></th>"
122+ (
123+ f' <th style="text-align: left;">'
124+ f'<div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">'
125+ f"{ html .escape (str (col ))} </div></th>"
126+ )
59127 )
60128 table_html .append (" </tr>" )
61129 table_html .append (" </thead>" )
62130
63131 # Render table body
64132 table_html .append (" <tbody>" )
65- for row_tuple in dataframe .itertuples ():
133+
134+ rowspans = _calculate_rowspans (dataframe )
135+
136+ for row_idx , row_tuple in enumerate (dataframe .itertuples ()):
66137 table_html .append (" <tr>" )
67138 # First item in itertuples is the index, which can be a tuple for MultiIndex
68139 index_values = row_tuple [0 ]
69140 if not isinstance (index_values , tuple ):
70141 index_values = (index_values ,)
71142
72- for value in index_values :
73- table_html .append (' <td style="text-align: left; padding: 0.5em;">' )
74- table_html .append (f" { html .escape (str (value ))} " )
75- table_html .append (" </td>" )
143+ for level_idx , value in enumerate (index_values ):
144+ span = rowspans [level_idx ][row_idx ]
145+ if span > 0 :
146+ # Only render the <th> if it's the start of a new span
147+ rowspan_attr = f' rowspan="{ span } "' if span > 1 else ""
148+ table_html .append (
149+ f' <th{ rowspan_attr } style="text-align: left; vertical-align: top; padding: 0.5em;">'
150+ f" { html .escape (str (value ))} "
151+ f" </th>"
152+ )
76153
77154 # The rest are the column values
78155 for i , value in enumerate (row_tuple [1 :]):
0 commit comments