diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index d6ad5eb2003ce..aeaa91a4d8517 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -81,6 +81,7 @@ Styler - Naive sparsification is now possible for LaTeX without the multirow package (:issue:`43369`) - :meth:`Styler.to_html` omits CSSStyle rules for hidden table elements (:issue:`43619`) - Custom CSS classes can now be directly specified without string replacement (:issue:`43686`) + - Bug where row trimming failed to reflect hidden rows (:issue:`43703`) Formerly Styler relied on ``display.html.use_mathjax``, which has now been replaced by ``styler.html.mathjax``. diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index ad9b705a3a76d..2c862f2f4ed96 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -324,83 +324,13 @@ def _translate_header(self, sparsify_cols: bool, max_cols: int): head = [] # 1) column headers for r, hide in enumerate(self.hide_columns_): - if hide: + if hide or not clabels: continue else: - # number of index blanks is governed by number of hidden index levels - index_blanks = [ - _element("th", self.css["blank"], self.css["blank_value"], True) - ] * (self.index.nlevels - sum(self.hide_index_) - 1) - - name = self.data.columns.names[r] - column_name = [ - _element( - "th", - ( - f"{self.css['blank']} {self.css['level']}{r}" - if name is None - else f"{self.css['index_name']} {self.css['level']}{r}" - ), - name - if (name is not None and not self.hide_column_names) - else self.css["blank_value"], - not all(self.hide_index_), - ) - ] - - if clabels: - column_headers = [] - for c, value in enumerate(clabels[r]): - header_element_visible = _is_visible(c, r, col_lengths) - header_element = _element( - "th", - ( - f"{self.css['col_heading']} {self.css['level']}{r} " - f"{self.css['col']}{c}" - ), - value, - header_element_visible, - display_value=self._display_funcs_columns[(r, c)](value), - attributes=( - f'colspan="{col_lengths.get((r, c), 0)}"' - if col_lengths.get((r, c), 0) > 1 - else "" - ), - ) - - if self.cell_ids: - header_element[ - "id" - ] = f"{self.css['level']}{r}_{self.css['col']}{c}" - if ( - header_element_visible - and (r, c) in self.ctx_columns - and self.ctx_columns[r, c] - ): - header_element[ - "id" - ] = f"{self.css['level']}{r}_{self.css['col']}{c}" - self.cellstyle_map_columns[ - tuple(self.ctx_columns[r, c]) - ].append(f"{self.css['level']}{r}_{self.css['col']}{c}") - - column_headers.append(header_element) - - if len(self.data.columns) > max_cols: - # add an extra column with `...` value to indicate trimming - column_headers.append( - _element( - "th", - ( - f"{self.css['col_heading']} {self.css['level']}{r} " - f"{self.css['col_trim']}" - ), - "...", - True, - attributes="", - ) - ) - head.append(index_blanks + column_name + column_headers) + header_row = self._generate_col_header_row( + (r, clabels), max_cols, col_lengths + ) + head.append(header_row) # 2) index names if ( @@ -409,35 +339,154 @@ def _translate_header(self, sparsify_cols: bool, max_cols: int): and not all(self.hide_index_) and not self.hide_index_names ): - index_names = [ - _element( - "th", - f"{self.css['index_name']} {self.css['level']}{c}", - self.css["blank_value"] if name is None else name, - not self.hide_index_[c], + index_names_row = self._generate_index_names_row(clabels, max_cols) + head.append(index_names_row) + + return head + + def _generate_col_header_row(self, iter: tuple, max_cols: int, col_lengths: dict): + """ + Generate the row containing column headers: + + +----------------------------+---------------+---------------------------+ + | index_blanks ... | column_name_i | column_headers (level_i) | + +----------------------------+---------------+---------------------------+ + + Parameters + ---------- + iter : tuple + Looping variables from outer scope + max_cols : int + Permissible number of columns + col_lenths : + c + + Returns + ------- + list of elements + """ + + r, clabels = iter + + # number of index blanks is governed by number of hidden index levels + index_blanks = [ + _element("th", self.css["blank"], self.css["blank_value"], True) + ] * (self.index.nlevels - sum(self.hide_index_) - 1) + + name = self.data.columns.names[r] + column_name = [ + _element( + "th", + ( + f"{self.css['blank']} {self.css['level']}{r}" + if name is None + else f"{self.css['index_name']} {self.css['level']}{r}" + ), + name + if (name is not None and not self.hide_column_names) + else self.css["blank_value"], + not all(self.hide_index_), + ) + ] + + column_headers = [] + for c, value in enumerate(clabels[r]): + header_element_visible = _is_visible(c, r, col_lengths) + header_element = _element( + "th", + ( + f"{self.css['col_heading']} {self.css['level']}{r} " + f"{self.css['col']}{c}" + ), + value, + header_element_visible, + display_value=self._display_funcs_columns[(r, c)](value), + attributes=( + f'colspan="{col_lengths.get((r, c), 0)}"' + if col_lengths.get((r, c), 0) > 1 + else "" + ), + ) + + if self.cell_ids: + header_element["id"] = f"{self.css['level']}{r}_{self.css['col']}{c}" + if ( + header_element_visible + and (r, c) in self.ctx_columns + and self.ctx_columns[r, c] + ): + header_element["id"] = f"{self.css['level']}{r}_{self.css['col']}{c}" + self.cellstyle_map_columns[tuple(self.ctx_columns[r, c])].append( + f"{self.css['level']}{r}_{self.css['col']}{c}" ) - for c, name in enumerate(self.data.index.names) - ] - if not clabels: - blank_len = 0 - elif len(self.data.columns) <= max_cols: - blank_len = len(clabels[0]) - else: - blank_len = len(clabels[0]) + 1 # to allow room for `...` trim col + column_headers.append(header_element) - column_blanks = [ + if len(self.data.columns) > max_cols: + # add an extra column with `...` value to indicate trimming + column_headers.append( _element( "th", - f"{self.css['blank']} {self.css['col']}{c}", - self.css["blank_value"], - c not in self.hidden_columns, + ( + f"{self.css['col_heading']} {self.css['level']}{r} " + f"{self.css['col_trim']}" + ), + "...", + True, + attributes="", ) - for c in range(blank_len) - ] - head.append(index_names + column_blanks) + ) + return index_blanks + column_name + column_headers - return head + def _generate_index_names_row(self, iter: tuple, max_cols): + """ + Generate the row containing index names + + +----------------------------+---------------+---------------------------+ + | index_names (level_0 to level_n) ... | column_blanks ... | + +----------------------------+---------------+---------------------------+ + + Parameters + ---------- + iter : tuple + Looping variables from outer scope + max_cols : int + Permissible number of columns + + Returns + ------- + list of elements + """ + + clabels = iter + + index_names = [ + _element( + "th", + f"{self.css['index_name']} {self.css['level']}{c}", + self.css["blank_value"] if name is None else name, + not self.hide_index_[c], + ) + for c, name in enumerate(self.data.index.names) + ] + + if not clabels: + blank_len = 0 + elif len(self.data.columns) <= max_cols: + blank_len = len(clabels[0]) + else: + blank_len = len(clabels[0]) + 1 # to allow room for `...` trim col + + column_blanks = [ + _element( + "th", + f"{self.css['blank']} {self.css['col']}{c}", + self.css["blank_value"], + c not in self.hidden_columns, + ) + for c in range(blank_len) + ] + return index_names + column_blanks def _translate_body(self, sparsify_index: bool, max_rows: int, max_cols: int): """ @@ -445,7 +494,7 @@ def _translate_body(self, sparsify_index: bool, max_rows: int, max_cols: int): Use the following structure: +--------------------------------------------+---------------------------+ - | index_header_0 ... index_header_n | data_by_column | + | index_header_0 ... index_header_n | data_by_column ... | +--------------------------------------------+---------------------------+ Also add elements to the cellstyle_map for more efficient grouped elements in @@ -466,149 +515,194 @@ def _translate_body(self, sparsify_index: bool, max_rows: int, max_cols: int): self.index, sparsify_index, max_rows, self.hidden_rows ) - rlabels = self.data.index.tolist()[:max_rows] # slice to allow trimming + rlabels = self.data.index.tolist() if not isinstance(self.data.index, MultiIndex): rlabels = [[x] for x in rlabels] - body = [] - for r, row_tup in enumerate(self.data.itertuples()): - if r >= max_rows: # used only to add a '...' trimmed row: - index_headers = [ - _element( - "th", - ( - f"{self.css['row_heading']} {self.css['level']}{c} " - f"{self.css['row_trim']}" - ), - "...", - not self.hide_index_[c], - attributes="", - ) - for c in range(self.data.index.nlevels) - ] + body, row_count = [], 0 + for r, row_tup in [ + z for z in enumerate(self.data.itertuples()) if z[0] not in self.hidden_rows + ]: + row_count += 1 + if row_count > max_rows: # used only to add a '...' trimmed row: + trimmed_row = self._generate_trimmed_row(max_cols) + body.append(trimmed_row) + break + body_row = self._generate_body_row( + (r, row_tup, rlabels), max_cols, idx_lengths + ) + body.append(body_row) + return body - data = [ - _element( - "td", - ( - f"{self.css['data']} {self.css['col']}{c} " - f"{self.css['row_trim']}" - ), - "...", - (c not in self.hidden_columns), - attributes="", - ) - for c in range(max_cols) - ] + def _generate_trimmed_row(self, max_cols: int) -> list: + """ + When a render has too many rows we generate a trimming row containing "..." - if len(self.data.columns) > max_cols: - # columns are also trimmed so we add the final element - data.append( - _element( - "td", - ( - f"{self.css['data']} {self.css['row_trim']} " - f"{self.css['col_trim']}" - ), - "...", - True, - attributes="", - ) - ) + Parameters + ---------- + max_cols : int + Number of permissible columns - body.append(index_headers + data) - break + Returns + ------- + list of elements + """ + index_headers = [ + _element( + "th", + ( + f"{self.css['row_heading']} {self.css['level']}{c} " + f"{self.css['row_trim']}" + ), + "...", + not self.hide_index_[c], + attributes="", + ) + for c in range(self.data.index.nlevels) + ] - index_headers = [] - for c, value in enumerate(rlabels[r]): - header_element_visible = ( - _is_visible(r, c, idx_lengths) and not self.hide_index_[c] - ) - header_element = _element( - "th", + data = [ + _element( + "td", + f"{self.css['data']} {self.css['col']}{c} {self.css['row_trim']}", + "...", + (c not in self.hidden_columns), + attributes="", + ) + for c in range(max_cols) + ] + + if len(self.data.columns) > max_cols: + # columns are also trimmed so we add the final element + data.append( + _element( + "td", ( - f"{self.css['row_heading']} {self.css['level']}{c} " - f"{self.css['row']}{r}" - ), - value, - header_element_visible, - display_value=self._display_funcs_index[(r, c)](value), - attributes=( - f'rowspan="{idx_lengths.get((c, r), 0)}"' - if idx_lengths.get((c, r), 0) > 1 - else "" + f"{self.css['data']} {self.css['row_trim']} " + f"{self.css['col_trim']}" ), + "...", + True, + attributes="", ) + ) + return index_headers + data - if self.cell_ids: - header_element[ - "id" - ] = f"{self.css['level']}{c}_{self.css['row']}{r}" # id is given - if ( - header_element_visible - and (r, c) in self.ctx_index - and self.ctx_index[r, c] - ): - # always add id if a style is specified - header_element[ - "id" - ] = f"{self.css['level']}{c}_{self.css['row']}{r}" - self.cellstyle_map_index[tuple(self.ctx_index[r, c])].append( - f"{self.css['level']}{c}_{self.css['row']}{r}" - ) + def _generate_body_row( + self, + iter: tuple, + max_cols: int, + idx_lengths: dict, + ): + """ + Generate a regular row for the body section of appropriate format. - index_headers.append(header_element) - - data = [] - for c, value in enumerate(row_tup[1:]): - if c >= max_cols: - data.append( - _element( - "td", - ( - f"{self.css['data']} {self.css['row']}{r} " - f"{self.css['col_trim']}" - ), - "...", - True, - attributes="", - ) - ) - break + +--------------------------------------------+---------------------------+ + | index_header_0 ... index_header_n | data_by_column ... | + +--------------------------------------------+---------------------------+ - # add custom classes from cell context - cls = "" - if (r, c) in self.cell_context: - cls = " " + self.cell_context[r, c] + Parameters + ---------- + iter : tuple + Iterable from outer scope: row number, row data tuple, row index labels. + max_cols : int + Number of permissible columns. + idx_lengths : dict + A map of the sparsification structure of the index - data_element_visible = ( - c not in self.hidden_columns and r not in self.hidden_rows - ) - data_element = _element( - "td", - ( - f"{self.css['data']} {self.css['row']}{r} " - f"{self.css['col']}{c}{cls}" - ), - value, - data_element_visible, - attributes="", - display_value=self._display_funcs[(r, c)](value), + Returns + ------- + list of elements + """ + r, row_tup, rlabels = iter + + index_headers = [] + for c, value in enumerate(rlabels[r]): + header_element_visible = ( + _is_visible(r, c, idx_lengths) and not self.hide_index_[c] + ) + header_element = _element( + "th", + ( + f"{self.css['row_heading']} {self.css['level']}{c} " + f"{self.css['row']}{r}" + ), + value, + header_element_visible, + display_value=self._display_funcs_index[(r, c)](value), + attributes=( + f'rowspan="{idx_lengths.get((c, r), 0)}"' + if idx_lengths.get((c, r), 0) > 1 + else "" + ), + ) + + if self.cell_ids: + header_element[ + "id" + ] = f"{self.css['level']}{c}_{self.css['row']}{r}" # id is given + if ( + header_element_visible + and (r, c) in self.ctx_index + and self.ctx_index[r, c] + ): + # always add id if a style is specified + header_element["id"] = f"{self.css['level']}{c}_{self.css['row']}{r}" + self.cellstyle_map_index[tuple(self.ctx_index[r, c])].append( + f"{self.css['level']}{c}_{self.css['row']}{r}" ) - if self.cell_ids: - data_element["id"] = f"{self.css['row']}{r}_{self.css['col']}{c}" - if data_element_visible and (r, c) in self.ctx and self.ctx[r, c]: - # always add id if needed due to specified style - data_element["id"] = f"{self.css['row']}{r}_{self.css['col']}{c}" - self.cellstyle_map[tuple(self.ctx[r, c])].append( - f"{self.css['row']}{r}_{self.css['col']}{c}" + index_headers.append(header_element) + + data = [] + for c, value in enumerate(row_tup[1:]): + if c >= max_cols: + data.append( + _element( + "td", + ( + f"{self.css['data']} {self.css['row']}{r} " + f"{self.css['col_trim']}" + ), + "...", + True, + attributes="", ) + ) + break - data.append(data_element) + # add custom classes from cell context + cls = "" + if (r, c) in self.cell_context: + cls = " " + self.cell_context[r, c] - body.append(index_headers + data) - return body + data_element_visible = ( + c not in self.hidden_columns and r not in self.hidden_rows + ) + data_element = _element( + "td", + ( + f"{self.css['data']} {self.css['row']}{r} " + f"{self.css['col']}{c}{cls}" + ), + value, + data_element_visible, + attributes="", + display_value=self._display_funcs[(r, c)](value), + ) + + if self.cell_ids: + data_element["id"] = f"{self.css['row']}{r}_{self.css['col']}{c}" + if data_element_visible and (r, c) in self.ctx and self.ctx[r, c]: + # always add id if needed due to specified style + data_element["id"] = f"{self.css['row']}{r}_{self.css['col']}{c}" + self.cellstyle_map[tuple(self.ctx[r, c])].append( + f"{self.css['row']}{r}_{self.css['col']}{c}" + ) + + data.append(data_element) + + return index_headers + data def _translate_latex(self, d: dict) -> None: r""" @@ -629,7 +723,14 @@ def _translate_latex(self, d: dict) -> None: for r, row in enumerate(d["head"]) ] body = [] - for r, row in enumerate(d["body"]): + index_levels = self.data.index.nlevels + for r, row in zip( + [r for r in range(len(self.data.index)) if r not in self.hidden_rows], + d["body"], + ): + # note: cannot enumerate d["body"] because rows were dropped if hidden + # during _translate_body so must zip to acquire the true r-index associated + # with the ctx obj which contains the cell styles. if all(self.hide_index_): row_body_headers = [] else: @@ -641,13 +742,13 @@ def _translate_latex(self, d: dict) -> None: else "", "cellstyle": self.ctx_index[r, c] if col["is_visible"] else [], } - for c, col in enumerate(row) - if col["type"] == "th" + for c, col in enumerate(row[:index_levels]) + if (col["type"] == "th" and not self.hide_index_[c]) ] row_body_cells = [ - {**col, "cellstyle": self.ctx[r, c - self.data.index.nlevels]} - for c, col in enumerate(row) + {**col, "cellstyle": self.ctx[r, c]} + for c, col in enumerate(row[index_levels:]) if (col["is_visible"] and col["type"] == "td") ] diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index b98a548101877..e21f38c3ff800 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -530,12 +530,6 @@ def test_replaced_css_class_names(styler_mi):
+ | c-1 | +c1 | +d1 | +|
---|---|---|---|---|
+ | c-2 | +c2 | +d2 | +c2 | +
i-0 | +i-2 | ++ | + | + |
i0 | +i2 | +0 | +1 | +2 | +
j2 | +4 | +5 | +6 | +|
j0 | +i2 | +8 | +9 | +10 | +