2828
2929import bigframes
3030from bigframes ._config import display_options , options
31- from bigframes .display import plaintext
31+ from bigframes .display import _flatten , plaintext
3232import bigframes .formatting_helpers as formatter
3333
3434if typing .TYPE_CHECKING :
@@ -48,13 +48,17 @@ def render_html(
4848 orderable_columns : list [str ] | None = None ,
4949 max_columns : int | None = None ,
5050) -> str :
51- """Render a pandas DataFrame to HTML with specific styling."""
51+ """Render a pandas DataFrame to HTML with specific styling and nested data support."""
52+ # Flatten nested data first
53+ flatten_result = _flatten .flatten_nested_data (dataframe )
54+ flat_df = flatten_result .dataframe
55+
5256 orderable_columns = orderable_columns or []
5357 classes = "dataframe table table-striped table-hover"
5458 table_html_parts = [f'<table border="1" class="{ classes } " id="{ table_id } ">' ]
5559
5660 # Handle column truncation
57- columns = list (dataframe .columns )
61+ columns = list (flat_df .columns )
5862 if max_columns is not None and max_columns > 0 and len (columns ) > max_columns :
5963 half = max_columns // 2
6064 left_columns = columns [:half ]
@@ -70,11 +74,20 @@ def render_html(
7074
7175 table_html_parts .append (
7276 _render_table_header (
73- dataframe , orderable_columns , left_columns , right_columns , show_ellipsis
77+ flat_df , orderable_columns , left_columns , right_columns , show_ellipsis
7478 )
7579 )
7680 table_html_parts .append (
77- _render_table_body (dataframe , left_columns , right_columns , show_ellipsis )
81+ _render_table_body (
82+ flat_df ,
83+ flatten_result .row_labels ,
84+ flatten_result .continuation_rows ,
85+ flatten_result .cleared_on_continuation ,
86+ flatten_result .nested_columns ,
87+ left_columns ,
88+ right_columns ,
89+ show_ellipsis ,
90+ )
7891 )
7992 table_html_parts .append ("</table>" )
8093 return "" .join (table_html_parts )
@@ -117,39 +130,66 @@ def render_col_header(col):
117130
118131def _render_table_body (
119132 dataframe : pd .DataFrame ,
133+ row_labels : list [str ] | None ,
134+ continuation_rows : set [int ] | None ,
135+ clear_on_continuation : list [str ],
136+ nested_originated_columns : set [str ],
120137 left_columns : list [Any ],
121138 right_columns : list [Any ],
122139 show_ellipsis : bool ,
123140) -> str :
124- """Render the body of the HTML table."""
141+ """Render the table body.
142+
143+ Args:
144+ dataframe: The flattened dataframe to render.
145+ row_labels: Optional labels for each row, used for visual grouping of exploded rows.
146+ See `bigframes.display._flatten.FlattenResult` for details.
147+ continuation_rows: Indices of rows that are continuations of array explosion.
148+ See `bigframes.display._flatten.FlattenResult` for details.
149+ clear_on_continuation: Columns to render as empty in continuation rows.
150+ See `bigframes.display._flatten.FlattenResult` for details.
151+ nested_originated_columns: Columns created from nested data, used for alignment.
152+ left_columns: Columns to display on the left.
153+ right_columns: Columns to display on the right.
154+ show_ellipsis: Whether to show an ellipsis row.
155+ """
125156 body_parts = [" <tbody>" ]
126157 precision = options .display .precision
127158
128159 for i in range (len (dataframe )):
129- body_parts .append (" <tr>" )
160+ row_class = ""
161+ orig_row_idx = None
162+ is_continuation = False
163+
164+ if row_labels :
165+ orig_row_idx = row_labels [i ]
166+
167+ if continuation_rows and i in continuation_rows :
168+ is_continuation = True
169+ row_class = "array-continuation"
170+
171+ if orig_row_idx is not None :
172+ body_parts .append (
173+ f' <tr class="{ row_class } " data-orig-row="{ orig_row_idx } ">'
174+ )
175+ else :
176+ body_parts .append (" <tr>" )
177+
130178 row = dataframe .iloc [i ]
131179
132180 def render_col_cell (col_name ):
133181 value = row [col_name ]
134182 dtype = dataframe .dtypes .loc [col_name ] # type: ignore
135- align = "right" if _is_dtype_numeric (dtype ) else "left"
136-
137- # TODO(b/438181139): Consider semi-exploding ARRAY/STRUCT columns
138- # into multiple rows/columns like the BQ UI does.
139- if pandas .api .types .is_scalar (value ) and pd .isna (value ):
140- body_parts .append (
141- f' <td class="cell-align-{ align } ">'
142- '<em class="null-value"><NA></em></td>'
143- )
144- else :
145- if isinstance (value , float ):
146- cell_content = f"{ value :.{precision }f} "
147- else :
148- cell_content = str (value )
149- body_parts .append (
150- f' <td class="cell-align-{ align } ">'
151- f"{ html .escape (cell_content )} </td>"
152- )
183+ cell_html = _render_cell (
184+ value ,
185+ dtype ,
186+ is_continuation ,
187+ str (col_name ),
188+ clear_on_continuation ,
189+ nested_originated_columns ,
190+ precision ,
191+ )
192+ body_parts .append (cell_html )
153193
154194 for col in left_columns :
155195 render_col_cell (col )
@@ -166,6 +206,43 @@ def render_col_cell(col_name):
166206 return "\n " .join (body_parts )
167207
168208
209+ def _render_cell (
210+ value : Any ,
211+ dtype : Any ,
212+ is_continuation : bool ,
213+ col_name_str : str ,
214+ clear_on_continuation : list [str ],
215+ nested_originated_columns : set [str ],
216+ precision : int ,
217+ ) -> str :
218+ """Render a single cell of the HTML table."""
219+ if is_continuation and col_name_str in clear_on_continuation :
220+ return " <td></td>"
221+
222+ if col_name_str in nested_originated_columns :
223+ align = "left"
224+ else :
225+ align = "right" if _is_dtype_numeric (dtype ) else "left"
226+
227+ if pandas .api .types .is_scalar (value ) and pd .isna (value ):
228+ if is_continuation :
229+ # For padding nulls in continuation rows, show empty cell
230+ return f' <td class="cell-align-{ align } "></td>'
231+ else :
232+ # For primary nulls, keep showing the <NA> indicator but maybe styled
233+ return (
234+ f' <td class="cell-align-{ align } ">'
235+ '<em class="null-value"><NA></em></td>'
236+ )
237+
238+ if isinstance (value , float ):
239+ cell_content = f"{ value :.{precision }f} "
240+ else :
241+ cell_content = str (value )
242+
243+ return f' <td class="cell-align-{ align } ">' f"{ html .escape (cell_content )} </td>"
244+
245+
169246def _obj_ref_rt_to_html (obj_ref_rt : str ) -> str :
170247 obj_ref_rt_json = json .loads (obj_ref_rt )
171248 obj_ref_details = obj_ref_rt_json ["objectref" ]["details" ]
@@ -252,8 +329,8 @@ def _get_obj_metadata(
252329
253330def get_anywidget_bundle (
254331 obj : Union [bigframes .dataframe .DataFrame , bigframes .series .Series ],
255- include = None ,
256- exclude = None ,
332+ include : typing . Container [ str ] | None = None ,
333+ exclude : typing . Container [ str ] | None = None ,
257334) -> tuple [dict [str , Any ], dict [str , Any ]]:
258335 """
259336 Helper method to create and return the anywidget mimebundle.
@@ -350,9 +427,9 @@ def repr_mimebundle_head(
350427
351428def repr_mimebundle (
352429 obj : Union [bigframes .dataframe .DataFrame , bigframes .series .Series ],
353- include = None ,
354- exclude = None ,
355- ):
430+ include : typing . Container [ str ] | None = None ,
431+ exclude : typing . Container [ str ] | None = None ,
432+ ) -> dict [ str , str ] | tuple [ dict [ str , Any ], dict [ str , Any ]] | None :
356433 """Custom display method for IPython/Jupyter environments."""
357434 # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and
358435 # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed.
0 commit comments