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 0 - - - - - - """ @@ -605,3 +599,72 @@ def test_include_css_style_rules_only_for_visible_column_labels(styler_mi): """ ) assert expected_styles in result + + +def test_hiding_index_columns_multiindex_alignment(): + # gh 43644 + midx = MultiIndex.from_product( + [["i0", "j0"], ["i1"], ["i2", "j2"]], names=["i-0", "i-1", "i-2"] + ) + cidx = MultiIndex.from_product( + [["c0"], ["c1", "d1"], ["c2", "d2"]], names=["c-0", "c-1", "c-2"] + ) + df = DataFrame(np.arange(16).reshape(4, 4), index=midx, columns=cidx) + styler = Styler(df, uuid_len=0) + styler.hide_index(level=1).hide_columns(level=0) + styler.hide_index([("j0", "i1", "j2")]) + styler.hide_columns([("c0", "d1", "d2")]) + result = styler.to_html() + expected = dedent( + """\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 c-1c1d1
 c-2c2d2c2
i-0i-2   
i0i2012
j2456
j0i28910
+ """ + ) + assert result == expected diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 99c454b4e5ab6..9cc75f71bfc4b 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -1226,17 +1226,15 @@ def test_hide_columns_index_mult_levels(self): assert ctx["body"][1][2]["is_visible"] assert ctx["body"][1][2]["display_value"] == "3" - # hide top row level, which hides both rows + # hide top row level, which hides both rows so body empty ctx = df.style.hide_index("a")._translate(True, True) - for i in [0, 1, 2, 3]: - assert not ctx["body"][0][i]["is_visible"] - assert not ctx["body"][1][i]["is_visible"] + assert ctx["body"] == [] # hide first row only ctx = df.style.hide_index(("a", 0))._translate(True, True) for i in [0, 1, 2, 3]: - assert not ctx["body"][0][i]["is_visible"] - assert ctx["body"][1][i]["is_visible"] + assert "row1" in ctx["body"][0][i]["class"] # row0 not included in body + assert ctx["body"][0][i]["is_visible"] def test_pipe(self): def set_caption_from_template(styler, a, b): @@ -1493,25 +1491,27 @@ def test_caption_raises(mi_styler, caption): mi_styler.set_caption(caption) -@pytest.mark.parametrize("axis", ["index", "columns"]) -def test_hiding_headers_over_axis_no_sparsify(axis): +def test_hiding_headers_over_index_no_sparsify(): # GH 43464 midx = MultiIndex.from_product([[1, 2], ["a", "a", "b"]]) - df = DataFrame( - 9, - index=midx if axis == "index" else [0], - columns=midx if axis == "columns" else [0], - ) + df = DataFrame(9, index=midx, columns=[0]) + ctx = df.style._translate(False, False) + assert len(ctx["body"]) == 6 + ctx = df.style.hide_index((1, "a"))._translate(False, False) + assert len(ctx["body"]) == 4 + assert "row2" in ctx["body"][0][0]["class"] - styler = getattr(df.style, f"hide_{axis}")((1, "a")) - ctx = styler._translate(False, False) - if axis == "columns": # test column headers - for ix in [(0, 1), (0, 2), (1, 1), (1, 2)]: - assert ctx["head"][ix[0]][ix[1]]["is_visible"] is False - if axis == "index": # test row headers - for ix in [(0, 0), (0, 1), (1, 0), (1, 1)]: - assert ctx["body"][ix[0]][ix[1]]["is_visible"] is False +def test_hiding_headers_over_columns_no_sparsify(): + # GH 43464 + midx = MultiIndex.from_product([[1, 2], ["a", "a", "b"]]) + df = DataFrame(9, columns=midx, index=[0]) + ctx = df.style._translate(False, False) + for ix in [(0, 1), (0, 2), (1, 1), (1, 2)]: + assert ctx["head"][ix[0]][ix[1]]["is_visible"] is True + ctx = df.style.hide_columns((1, "a"))._translate(False, False) + for ix in [(0, 1), (0, 2), (1, 1), (1, 2)]: + assert ctx["head"][ix[0]][ix[1]]["is_visible"] is False def test_get_level_lengths_mi_hidden(): @@ -1534,3 +1534,13 @@ def test_get_level_lengths_mi_hidden(): hidden_elements=[0, 1, 0, 1], # hidden element can repeat if duplicated index ) tm.assert_dict_equal(result, expected) + + +def test_row_trimming_hide_index(): + # gh 43703 + df = DataFrame([[1], [2], [3], [4], [5]]) + with pd.option_context("styler.render.max_rows", 2): + ctx = df.style.hide_index([0, 1])._translate(True, True) + assert len(ctx["body"]) == 3 + for r, val in enumerate(["3", "4", "..."]): + assert ctx["body"][r][1]["display_value"] == val diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index a2e42efdacd89..73c9ddb26e1f8 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -1,5 +1,6 @@ from textwrap import dedent +import numpy as np import pytest from pandas import ( @@ -789,3 +790,49 @@ def test_css_convert_apply_index(styler, axis): styler.applymap_index(lambda x: "font-weight: bold;", axis=axis) for label in getattr(styler, axis): assert f"\\bfseries {label}" in styler.to_latex(convert_css=True) + + +def test_hide_index_latex(styler): + # GH 43637 + styler.hide_index([0]) + result = styler.to_latex() + expected = dedent( + """\ + \\begin{tabular}{lrrl} + & A & B & C \\\\ + 1 & 1 & -1.22 & cd \\\\ + \\end{tabular} + """ + ) + assert expected == result + + +def test_latex_hiding_index_columns_multiindex_alignment(): + # gh 43644 + midx = MultiIndex.from_product( + [["i0", "j0"], ["i1"], ["i2", "j2"]], names=["i-0", "i-1", "i-2"] + ) + cidx = MultiIndex.from_product( + [["c0"], ["c1", "d1"], ["c2", "d2"]], names=["c-0", "c-1", "c-2"] + ) + df = DataFrame(np.arange(16).reshape(4, 4), index=midx, columns=cidx) + styler = Styler(df, uuid_len=0) + styler.hide_index(level=1).hide_columns(level=0) + styler.hide_index([("i0", "i1", "i2")]) + styler.hide_columns([("c0", "c1", "c2")]) + styler.applymap(lambda x: "color:{red};" if x == 5 else "") + styler.applymap_index(lambda x: "color:{blue};" if "j" in x else "") + result = styler.to_latex() + expected = dedent( + """\ + \\begin{tabular}{llrrr} + & c-1 & c1 & \\multicolumn{2}{r}{d1} \\\\ + & c-2 & d2 & c2 & d2 \\\\ + i-0 & i-2 & & & \\\\ + i0 & \\color{blue} j2 & \\color{red} 5 & 6 & 7 \\\\ + \\multirow[c]{2}{*}{\\color{blue} j0} & i2 & 9 & 10 & 11 \\\\ + & \\color{blue} j2 & 13 & 14 & 15 \\\\ + \\end{tabular} + """ + ) + assert result == expected