diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 82f57b71caebf..0b7279d796464 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -173,15 +173,6 @@ def _translate(self): BLANK_CLASS = "blank" BLANK_VALUE = " " - # mapping variables - ctx = self.ctx # td css styles from apply() and applymap() - cell_context = self.cell_context # td css classes from set_td_classes() - cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict(list) - - # copied attributes - hidden_index = self.hidden_index - hidden_columns = self.hidden_columns - # construct render dict d = { "uuid": self.uuid, @@ -189,165 +180,185 @@ def _translate(self): "caption": self.caption, } + head = self._translate_header( + BLANK_CLASS, BLANK_VALUE, INDEX_NAME_CLASS, COL_HEADING_CLASS + ) + d.update({"head": head}) + + self.cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict( + list + ) + body = self._translate_body(DATA_CLASS, ROW_HEADING_CLASS) + d.update({"body": body}) + + cellstyle: list[dict[str, CSSList | list[str]]] = [ + {"props": list(props), "selectors": selectors} + for props, selectors in self.cellstyle_map.items() + ] + d.update({"cellstyle": cellstyle}) + + table_attr = self.table_attributes + use_mathjax = get_option("display.html.use_mathjax") + if not use_mathjax: + table_attr = table_attr or "" + if 'class="' in table_attr: + table_attr = table_attr.replace('class="', 'class="tex2jax_ignore ') + else: + table_attr += ' class="tex2jax_ignore"' + d.update({"table_attributes": table_attr}) + + if self.tooltips: + d = self.tooltips._translate(self.data, self.uuid, d) + + return d + + def _translate_header( + self, blank_class, blank_value, index_name_class, col_heading_class + ): + """ + Build each within table , using the structure: + +----------------------------+---------------+---------------------------+ + | index_blanks ... | column_name_0 | column_headers (level_0) | + 1) | .. | .. | .. | + | index_blanks ... | column_name_n | column_headers (level_n) | + +----------------------------+---------------+---------------------------+ + 2) | index_names (level_0 to level_n) ... | column_blanks ... | + +----------------------------+---------------+---------------------------+ + """ # for sparsifying a MultiIndex - idx_lengths = _get_level_lengths(self.index) - col_lengths = _get_level_lengths(self.columns, hidden_columns) + col_lengths = _get_level_lengths(self.columns, self.hidden_columns) - n_rlvls = self.data.index.nlevels - n_clvls = self.data.columns.nlevels - rlabels = self.data.index.tolist() clabels = self.data.columns.tolist() - - if n_rlvls == 1: - rlabels = [[x] for x in rlabels] - if n_clvls == 1: + if self.data.columns.nlevels == 1: clabels = [[x] for x in clabels] clabels = list(zip(*clabels)) head = [] - for r in range(n_clvls): - # Blank for Index columns... - row_es = [ - { - "type": "th", - "value": BLANK_VALUE, - "display_value": BLANK_VALUE, - "is_visible": not hidden_index, - "class": " ".join([BLANK_CLASS]), - } - ] * (n_rlvls - 1) - - # ... except maybe the last for columns.names + # 1) column headers + for r in range(self.data.columns.nlevels): + index_blanks = [ + _element("th", blank_class, blank_value, not self.hidden_index) + ] * (self.data.index.nlevels - 1) + name = self.data.columns.names[r] - cs = [ - BLANK_CLASS if name is None else INDEX_NAME_CLASS, - f"level{r}", + column_name = [ + _element( + "th", + f"{blank_class if name is None else index_name_class} level{r}", + name if name is not None else blank_value, + not self.hidden_index, + ) ] - name = BLANK_VALUE if name is None else name - row_es.append( - { - "type": "th", - "value": name, - "display_value": name, - "class": " ".join(cs), - "is_visible": not hidden_index, - } - ) if clabels: - for c, value in enumerate(clabels[r]): - es = { - "type": "th", - "value": value, - "display_value": value, - "class": f"{COL_HEADING_CLASS} level{r} col{c}", - "is_visible": _is_visible(c, r, col_lengths), - } - colspan = col_lengths.get((r, c), 0) - if colspan > 1: - es["attributes"] = f'colspan="{colspan}"' - row_es.append(es) - head.append(row_es) + column_headers = [ + _element( + "th", + f"{col_heading_class} level{r} col{c}", + value, + _is_visible(c, r, col_lengths), + attributes=( + f'colspan="{col_lengths.get((r, c), 0)}"' + if col_lengths.get((r, c), 0) > 1 + else "" + ), + ) + for c, value in enumerate(clabels[r]) + ] + head.append(index_blanks + column_name + column_headers) + # 2) index names if ( self.data.index.names and com.any_not_none(*self.data.index.names) - and not hidden_index + and not self.hidden_index ): - index_header_row = [] + index_names = [ + _element( + "th", + f"{index_name_class} level{c}", + blank_value if name is None else name, + True, + ) + for c, name in enumerate(self.data.index.names) + ] - for c, name in enumerate(self.data.index.names): - cs = [INDEX_NAME_CLASS, f"level{c}"] - name = "" if name is None else name - index_header_row.append( - {"type": "th", "value": name, "class": " ".join(cs)} + column_blanks = [ + _element( + "th", + f"{blank_class} col{c}", + blank_value, + c not in self.hidden_columns, ) + for c in range(len(clabels[0])) + ] + head.append(index_names + column_blanks) - index_header_row.extend( - [ - { - "type": "th", - "value": BLANK_VALUE, - "class": " ".join([BLANK_CLASS, f"col{c}"]), - } - for c in range(len(clabels[0])) - if c not in hidden_columns - ] - ) + return head - head.append(index_header_row) - d.update({"head": head}) + def _translate_body(self, data_class, row_heading_class): + """ + Build each in table in the following format: + +--------------------------------------------+---------------------------+ + | index_header_0 ... index_header_n | data_by_column | + +--------------------------------------------+---------------------------+ + + Also add elements to the cellstyle_map for more efficient grouped elements in + block + """ + # for sparsifying a MultiIndex + idx_lengths = _get_level_lengths(self.index) + + rlabels = self.data.index.tolist() + if self.data.index.nlevels == 1: + rlabels = [[x] for x in rlabels] body = [] for r, row_tup in enumerate(self.data.itertuples()): - row_es = [] - for c, value in enumerate(rlabels[r]): - rid = [ - ROW_HEADING_CLASS, - f"level{c}", - f"row{r}", - ] - es = { - "type": "th", - "is_visible": (_is_visible(r, c, idx_lengths) and not hidden_index), - "value": value, - "display_value": value, - "id": "_".join(rid[1:]), - "class": " ".join(rid), - } - rowspan = idx_lengths.get((c, r), 0) - if rowspan > 1: - es["attributes"] = f'rowspan="{rowspan}"' - row_es.append(es) + index_headers = [ + _element( + "th", + f"{row_heading_class} level{c} row{r}", + value, + (_is_visible(r, c, idx_lengths) and not self.hidden_index), + id=f"level{c}_row{r}", + attributes=( + f'rowspan="{idx_lengths.get((c, r), 0)}"' + if idx_lengths.get((c, r), 0) > 1 + else "" + ), + ) + for c, value in enumerate(rlabels[r]) + ] + data = [] for c, value in enumerate(row_tup[1:]): - formatter = self._display_funcs[(r, c)] - row_dict = { - "type": "td", - "value": value, - "display_value": formatter(value), - "is_visible": (c not in hidden_columns), - "attributes": "", - } - - # only add an id if the cell has a style - props: CSSList = [] - if self.cell_ids or (r, c) in ctx: - row_dict["id"] = f"row{r}_col{c}" - props.extend(ctx[r, c]) - # add custom classes from cell context cls = "" - if (r, c) in cell_context: - cls = " " + cell_context[r, c] - row_dict["class"] = f"{DATA_CLASS} row{r} col{c}{cls}" - - row_es.append(row_dict) - if props: # (), [] won't be in cellstyle_map, cellstyle respectively - cellstyle_map[tuple(props)].append(f"row{r}_col{c}") - body.append(row_es) - d.update({"body": body}) - - cellstyle: list[dict[str, CSSList | list[str]]] = [ - {"props": list(props), "selectors": selectors} - for props, selectors in cellstyle_map.items() - ] - d.update({"cellstyle": cellstyle}) + if (r, c) in self.cell_context: + cls = " " + self.cell_context[r, c] + + data_element = _element( + "td", + f"{data_class} row{r} col{c}{cls}", + value, + (c not in self.hidden_columns), + attributes="", + display_value=self._display_funcs[(r, c)](value), + ) - table_attr = self.table_attributes - use_mathjax = get_option("display.html.use_mathjax") - if not use_mathjax: - table_attr = table_attr or "" - if 'class="' in table_attr: - table_attr = table_attr.replace('class="', 'class="tex2jax_ignore ') - else: - table_attr += ' class="tex2jax_ignore"' - d.update({"table_attributes": table_attr}) + # only add an id if the cell has a style + if self.cell_ids or (r, c) in self.ctx: + data_element["id"] = f"row{r}_col{c}" + if (r, c) in self.ctx and self.ctx[r, c]: # only add if non-empty + self.cellstyle_map[tuple(self.ctx[r, c])].append( + f"row{r}_col{c}" + ) - if self.tooltips: - d = self.tooltips._translate(self.data, self.uuid, d) + data.append(data_element) - return d + body.append(index_headers + data) + return body def format( self, @@ -502,6 +513,27 @@ def format( return self +def _element( + html_element: str, + html_class: str, + value: Any, + is_visible: bool, + **kwargs, +) -> dict: + """ + Template to return container with information for a or element. + """ + if "display_value" not in kwargs: + kwargs["display_value"] = value + return { + "type": html_element, + "value": value, + "class": html_class, + "is_visible": is_visible, + **kwargs, + } + + def _get_level_lengths(index, hidden_elements=None): """ Given an index, find the level length for each element. diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 56e9581f8785a..25a7eb36d6b48 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -273,6 +273,7 @@ def test_empty_index_name_doesnt_display(self): "type": "th", "value": "A", "is_visible": True, + "attributes": "", }, { "class": "col_heading level0 col1", @@ -280,6 +281,7 @@ def test_empty_index_name_doesnt_display(self): "type": "th", "value": "B", "is_visible": True, + "attributes": "", }, { "class": "col_heading level0 col2", @@ -287,6 +289,7 @@ def test_empty_index_name_doesnt_display(self): "type": "th", "value": "C", "is_visible": True, + "attributes": "", }, ] ] @@ -295,6 +298,7 @@ def test_empty_index_name_doesnt_display(self): def test_index_name(self): # https://github.com/pandas-dev/pandas/issues/11655 + # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index("A").style._translate() @@ -313,6 +317,7 @@ def test_index_name(self): "value": "B", "display_value": "B", "is_visible": True, + "attributes": "", }, { "class": "col_heading level0 col1", @@ -320,12 +325,31 @@ def test_index_name(self): "value": "C", "display_value": "C", "is_visible": True, + "attributes": "", }, ], [ - {"class": "index_name level0", "type": "th", "value": "A"}, - {"class": "blank col0", "type": "th", "value": self.blank_value}, - {"class": "blank col1", "type": "th", "value": self.blank_value}, + { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + }, + { + "class": "blank col0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + }, + { + "class": "blank col1", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + }, ], ] @@ -333,6 +357,7 @@ def test_index_name(self): def test_multiindex_name(self): # https://github.com/pandas-dev/pandas/issues/11655 + # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index(["A", "B"]).style._translate() @@ -358,12 +383,31 @@ def test_multiindex_name(self): "value": "C", "display_value": "C", "is_visible": True, + "attributes": "", }, ], [ - {"class": "index_name level0", "type": "th", "value": "A"}, - {"class": "index_name level1", "type": "th", "value": "B"}, - {"class": "blank col0", "type": "th", "value": self.blank_value}, + { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + }, + { + "class": "index_name level1", + "type": "th", + "value": "B", + "is_visible": True, + "display_value": "B", + }, + { + "class": "blank col0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + }, ], ] @@ -838,7 +882,7 @@ def test_mi_sparse(self): "class": "row_heading level0 row0", "id": "level0_row0", } - tm.assert_dict_equal(body_0, expected_0) + assert body_0 == expected_0 body_1 = result["body"][0][1] expected_1 = { @@ -848,8 +892,9 @@ def test_mi_sparse(self): "type": "th", "class": "row_heading level1 row0", "id": "level1_row0", + "attributes": "", } - tm.assert_dict_equal(body_1, expected_1) + assert body_1 == expected_1 body_10 = result["body"][1][0] expected_10 = { @@ -859,8 +904,9 @@ def test_mi_sparse(self): "type": "th", "class": "row_heading level0 row1", "id": "level0_row1", + "attributes": "", } - tm.assert_dict_equal(body_10, expected_10) + assert body_10 == expected_10 head = result["head"][0] expected = [ @@ -884,21 +930,26 @@ def test_mi_sparse(self): "value": "A", "is_visible": True, "display_value": "A", + "attributes": "", }, ] assert head == expected def test_mi_sparse_disabled(self): + df = DataFrame( + {"A": [1, 2]}, index=pd.MultiIndex.from_arrays([["a", "a"], [0, 1]]) + ) + result = df.style._translate()["body"] + assert 'rowspan="2"' in result[0][0]["attributes"] + assert result[1][0]["is_visible"] is False + with pd.option_context("display.multi_sparse", False): - df = DataFrame( - {"A": [1, 2]}, index=pd.MultiIndex.from_arrays([["a", "a"], [0, 1]]) - ) - result = df.style._translate() - body = result["body"] - for row in body: - assert "attributes" not in row[0] + result = df.style._translate()["body"] + assert 'rowspan="2"' not in result[0][0]["attributes"] + assert result[1][0]["is_visible"] is True def test_mi_sparse_index_names(self): + # TODO this test is verbose can be minimised to more directly target test df = DataFrame( {"A": [1, 2]}, index=pd.MultiIndex.from_arrays( @@ -908,14 +959,33 @@ def test_mi_sparse_index_names(self): result = df.style._translate() head = result["head"][1] expected = [ - {"class": "index_name level0", "value": "idx_level_0", "type": "th"}, - {"class": "index_name level1", "value": "idx_level_1", "type": "th"}, - {"class": "blank col0", "value": self.blank_value, "type": "th"}, + { + "class": "index_name level0", + "value": "idx_level_0", + "type": "th", + "is_visible": True, + "display_value": "idx_level_0", + }, + { + "class": "index_name level1", + "value": "idx_level_1", + "type": "th", + "is_visible": True, + "display_value": "idx_level_1", + }, + { + "class": "blank col0", + "value": self.blank_value, + "type": "th", + "is_visible": True, + "display_value": self.blank_value, + }, ] assert head == expected def test_mi_sparse_column_names(self): + # TODO this test is verbose - could be minimised df = DataFrame( np.arange(16).reshape(4, 4), index=pd.MultiIndex.from_arrays( @@ -949,6 +1019,7 @@ def test_mi_sparse_column_names(self): "is_visible": True, "type": "th", "value": 1, + "attributes": "", }, { "class": "col_heading level1 col1", @@ -956,6 +1027,7 @@ def test_mi_sparse_column_names(self): "is_visible": True, "type": "th", "value": 0, + "attributes": "", }, { "class": "col_heading level1 col2", @@ -963,6 +1035,7 @@ def test_mi_sparse_column_names(self): "is_visible": True, "type": "th", "value": 1, + "attributes": "", }, { "class": "col_heading level1 col3", @@ -970,6 +1043,7 @@ def test_mi_sparse_column_names(self): "is_visible": True, "type": "th", "value": 0, + "attributes": "", }, ] assert head == expected