From 87c3d97846677e6e5784463b10e5967bf407dd83 Mon Sep 17 00:00:00 2001 From: IMGillusion Date: Wed, 9 Sep 2026 15:32:18 +0800 Subject: [PATCH] Fix column width for escaped cells (issue #244) Formats with per-character escape rules (latex, github) escaped special characters at render time, i.e. after padding, so escaped cells were wider than the measured column widths and rows misaligned. Apply the escaping before widths are measured, then drop the escape rules from the row formatters to avoid escaping twice. --- tabulate/__init__.py | 29 ++++++++++++++++++++++++++++- test/test_output.py | 4 ++-- test/test_regression.py | 27 +++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..e1abad2 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2385,11 +2385,38 @@ def tabulate( break elif align != "global": aligns[idx] = align + # Formats that escape special characters (latex, github) used to escape at + # render time, i.e. after padding, so escaped cells were wider than the + # measured column widths and rows misaligned (issue #244). Escape before + # widths are measured, then drop the escape rules from the row formatters + # to avoid escaping twice. + _is_colon_grid = tablefmt == "colon_grid" + if not isinstance(tablefmt, TableFormat): + tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) + escape_map = None + for _rowfmt in (tablefmt.headerrow, tablefmt.datarow): + if isinstance(_rowfmt, DataRow) and _rowfmt.escape_map: + escape_map = _rowfmt.escape_map + break + if escape_map: + + def _escape_text(s): + return "".join(escape_map.get(c, c) for c in s) if isinstance(s, str) else s + + cols = [[_escape_text(v) for v in c] for c in cols] + headers = [_escape_text(h) for h in headers] + _repl = {} + for _name in ("headerrow", "datarow"): + _rowfmt = getattr(tablefmt, _name) + if isinstance(_rowfmt, DataRow) and _rowfmt.escape_map: + _repl[_name] = DataRow(_rowfmt.begin, _rowfmt.sep, _rowfmt.end) + if _repl: + tablefmt = tablefmt._replace(**_repl) minwidths = [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) aligns_copy = aligns.copy() # Reset alignments in copy of alignments list to "left" for 'colon_grid' format, # which enforces left alignment in the text output of the data. - if tablefmt == "colon_grid": + if _is_colon_grid: aligns_copy = ["left"] * len(cols) cols = [ _align_column( diff --git a/test/test_output.py b/test/test_output.py index ea3da87..1277041 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2684,8 +2684,8 @@ def test_latex(): r"\hline", r" strings & numbers (\$N\_0\$) \\", r"\hline", - r" spam & 41.9999 \\", - r" eggs & 451 \\", + r" spam & 41.9999 \\", + r" eggs & 451 \\", r"\hline", r"\end{tabular}", ] diff --git a/test/test_regression.py b/test/test_regression.py index 9555676..ca5e64f 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -211,13 +211,36 @@ def test_column_with_mixed_value_types(): assert_equal(table, expected) +def test_latex_escaped_cell_width(): + "Regression: escaped chars widen cells; column widths must account for it (issue #244)" + data = {"col1": ["first", "a_long_second_row", "third_row"], "col2": [1, 2, 3]} + result = tabulate(data, tablefmt="latex", headers="keys") + expected = "\n".join( + [ + r"\begin{tabular}{lr}", + r"\hline", + r" col1 & col2 \\", + r"\hline", + r" first & 1 \\", + r" a\_long\_second\_row & 2 \\", + r" third\_row & 3 \\", + r"\hline", + r"\end{tabular}", + ] + ) + assert_equal(expected, result) + # the & separator must sit at the same column on every row + amp_cols = {line.index("&") for line in result.splitlines() if "&" in line} + assert len(amp_cols) == 1 + + def test_latex_escape_special_chars(): "Regression: escape special characters in LaTeX output (issue #32)" expected = "\n".join( [ r"\begin{tabular}{l}", r"\hline", - r" foo\^{}bar \\", + r" foo\^{}bar \\", r"\hline", r" \&\%\^{}\_\$\#\{\}\ensuremath{<}\ensuremath{>}\textasciitilde{} \\", r"\hline", @@ -596,5 +619,5 @@ def test_asciidoc_without_trailing_whitespace(): def test_github_escape_pipe_character(): "Regression: github format must escape pipe character with a backslash (issue #241)" result = tabulate([["foo|bar"]], headers=("spam|eggs",), tablefmt="github") - expected = "| spam\\|eggs |\n|:------------|\n| foo\\|bar |" + expected = "| spam\\|eggs |\n|:-------------|\n| foo\\|bar |" assert_equal(expected, result)