Skip to content

fix(html2text): keep a pipe in a table cell from adding a column - #2275

Open
L4XB wants to merge 2 commits into
unclecode:mainfrom
L4XB:fix/table-cell-pipe
Open

L4XB wants to merge 2 commits into
unclecode:mainfrom
L4XB:fix/table-cell-pipe

Conversation

@L4XB

@L4XB L4XB commented Sep 16, 2026

Copy link
Copy Markdown

The bug

A GFM table row is split on every unescaped pipe, and html2text writes a cell's text through unchanged. A cell that holds a pipe therefore adds a column.

Measured on main (862f6bc):

input:   <table><tr><th>Name</th><th>Modes</th></tr>
                <tr><td>codec</td><td>a|b|c</td></tr></table>

output:  | Name | Modes |
         | --- | --- |
         | codec | a|b|c |

Read back, that last row has four cells against a two-column header. Everything after it is one column out of step, and a reader assigns b and c to columns that do not exist.

It is not an exotic cell value for a crawler. A pipe turns up in regex alternations, shell commands (grep -E 'a|b'), part numbers, "either/or" notes, and anything pasted out of a terminal — which is to say, in exactly the documentation and reference pages people crawl.

The fix

escape_table_cell_pipes escapes the pipes while the converter is inside a <td>/<th>, tracked with a single flag set in the existing table branch of handle_tag.

Two details worth calling out:

  • The run of backslashes in front of the pipe is matched, not the pipe alone. escape_md_section leaves a backslash before a pipe untouched, because | is not in RE_SLASH_CHARS. A cell holding a\|b would otherwise come out as a\\|b, where the cell's own backslash escapes the one we added and the pipe splits the row anyway. Doubling the run first gives a\\\|b, which reads back as the literal a\|b.
  • CustomHTML2Text calls it too. Its handle_data returns early for inside_pre and inside_code and writes straight to self.o, so a pipe inside a code span in a cell never reached the base escaping. A code span does not protect a pipe — GFM splits the row before it looks at backticks — so both early returns escape as well.

ignore_tables and bypass_tables return from the branch above, so the flag never gets set for them and their output is byte-identical. A pipe outside a table is untouched.

The table padder had to agree. pad_tables reformats the finished table by splitting each row on every "|", which turned the escaped pipe straight back into a column boundary:

| codec | a\    |b\ |c |

reformat_table now splits on the unescaped pipes, through one split_table_row helper used at all three of its split sites. PAD_TABLES defaults to False, so this only shows up for callers that turn it on — but leaving it would have traded one broken table for another.

Tests

tests/unit/test_html2text_table_cell_pipe.py, 18 cases — every one parameterised over both HTML2Text and CustomHTML2Text. The helper reads the produced table back the way a reader does (split on unescaped pipes, then resolve the CommonMark escapes) and asserts on the cells, not on a fixed string, so the test pins the property rather than the formatting.

Covered: a|b|c, a part number, a cell with its own a\|b, a header cell, a pipe inside inline code, the same table under pad_tables=True, plus guards for C:\path, a plain cell, a pipe in a paragraph outside any table, bypass_tables, and a plain table under pad_tables=True.

Measured:

result
with the change 22 passed
source changes reverted to main, tests kept 12 failed, 10 passed

tests/unit + the markdown suites: 111 passed with the change against 89 passed without it, and the same 28 failed on both sides — those are pre-existing failures in test_sitemap_namespace_parsing.py and friends, unrelated to this change.

A GFM row is split on every unescaped pipe, and the cell's text reached the
output unchanged. A regex alternation, a shell command or a part number in a
cell therefore pushed the rest of the row into columns the header does not
have:

    | Name | Modes |
    | --- | --- |
    | codec | a|b|c |

Read back, that row has four cells against a two-column header, and every row
after it is out of step.

Escape the pipes in a cell. The run of backslashes in front of the pipe is
matched rather than the pipe alone, because escape_md_section leaves a
backslash before a pipe untouched (`|` is not in RE_SLASH_CHARS), so a cell
holding `a\|b` would otherwise have its own backslash consume the escape.

CustomHTML2Text returns early for inline code and pre without going through
the base escaping, so both of those call it too: a code span does not protect
a pipe, GFM splits the row first.
`pad_tables` reformats the finished table by splitting each row on every "|",
so the pipe a cell escapes was turned straight back into a column boundary and
the padded table came out wider than its header.
@Ryan-cw

Ryan-cw commented Sep 17, 2026

Copy link
Copy Markdown

ok

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants