| is escaped in table cells for plain text, emphasis, and URLs, but not for code spans. push_code_span() never receives the InlineContext, so it emits raw pipes, which split the GFM table row and silently drop every column to the right of the code cell.
Expected
The GFM spec (§4.10 Tables extension, Example 200) is explicit that this applies inside inline spans:
Include a pipe in a cell's content by escaping it, including inside other inline spans:
→ <td>b <code>|</code> az</td>
The repo already treats this as settled for the other three paths — escape.rs:82 escapes '|' if ctx == InlineContext::TableCell for plain and styled text, and format_url percent-encodes it under a comment that states the rule outright:
// src/render/markdown/escape.rs:151-152
// Raw pipes split GFM table cells.
'|' => escaped.push_str("%7C"),
with the existing test url_pipes_cannot_split_table_cells (tests.rs:289) and the plain-text case in table_basic (tests.rs:232, Ann | Bob → Ann \| Bob). Only the code-span path is missing.
Actual
Minimal .epub with a two-column table:
<table>
<thead><tr><th>Operator</th><th>Meaning</th></tr></thead>
<tbody>
<tr><td><code>a | b</code></td><td>bitwise or</td></tr>
<tr><td><pre>ls | wc -l</pre></td><td>shell pipeline</td></tr>
<tr><td>plain a | b</td><td>escaped correctly</td></tr>
</tbody>
</table>
cargo run --example convert -- pipes.epub on v0.1.8 (4e3089b):
| Operator | Meaning |
| --- | --- |
| `a | b` | bitwise or |
| `ls | wc -l` | shell pipeline |
| plain a \| b | escaped correctly |
The delimiter row declares two columns, but the first two body rows emit three cells each. GFM ignores cells past the header width, so feeding that output to a GFM renderer (marked 18, gfm: true) loses the Meaning value and tears the code span in half:
<tr><th>Operator</th><th>Meaning</th></tr>
<tr><td>`a</td><td>b`</td></tr> <!-- "bitwise or" gone -->
<tr><td>`ls</td><td>wc -l`</td></tr> <!-- "shell pipeline" gone -->
<tr><td>plain a | b</td><td>escaped correctly</td></tr> <!-- correct -->
Expected first body row is | a | b | bitwise or |, which renders as <td><code>a | b</code></td><td>bitwise or</td> with both columns intact.
Failing tests
In the style of the existing url_pipes_cannot_split_table_cells:
#[test]
fn code_span_pipes_cannot_split_table_cells() {
let md = doc(vec![table_from(
vec![
vec![
Cell::from_inlines(vec![Inline::plain("Operator")]),
Cell::from_inlines(vec![Inline::plain("Meaning")]),
],
vec![
Cell::from_inlines(vec![styled("a | b", Style { code: true, ..Style::PLAIN })]),
Cell::from_inlines(vec![Inline::plain("bitwise or")]),
],
],
1,
)]);
assert_eq!(md, "| Operator | Meaning |\n| --- | --- |\n| `a \\| b` | bitwise or |\n");
}
#[test]
fn code_block_pipes_cannot_split_table_cells() {
let cell = Cell::new(vec![Block::CodeBlock { lang: None, text: "ls | wc -l".into() }]);
let md = doc(vec![table_from(vec![vec![cell]], 0)]);
assert_eq!(md, "| |\n| --- |\n| `ls \\| wc -l` |\n");
}
Both fail on 4e3089b, while url_pipes_cannot_split_table_cells passes:
test render::markdown::tests::url_pipes_cannot_split_table_cells ... ok
test render::markdown::tests::code_block_pipes_cannot_split_table_cells ... FAILED
test render::markdown::tests::code_span_pipes_cannot_split_table_cells ... FAILED
left: "| Operator | Meaning |\n| --- | --- |\n| `a | b` | bitwise or |\n"
right: "| Operator | Meaning |\n| --- | --- |\n| `a \| b` | bitwise or |\n"
Root cause
Pipe escaping lives in escape_text(), keyed on InlineContext::TableCell (escape.rs:82). Code spans are a bypass around it: inline.rs:207 routes code-styled runs to push_code_span(), which takes no InlineContext and only handles newlines and the backtick fence. table.rs:169 sends cell-level Block::CodeBlock down the same function. Because GFM splits table rows before inline parsing, backticks do not protect a pipe — the row is already cut by the time the code span would exist.
Affected inputs
- EPUB —
<code>, <kbd>, <samp>, <tt> (shared/html.rs:690) and <pre> (shared/html.rs:423).
- docx, rtf, odt, ods, odp — the shared
Source Code / HTML Preformatted / Preformatted Text paragraph styles (shared/blockstyle.rs:22). The existing rtf/table.rs:219 and odf/mod.rs:275 tests both assert a CodeBlock landing inside a table cell, so this path is reachable there.
- CSV and XLSX are unaffected — they only emit
Inline::plain.
Trigger conditions are ordinary for technical documents: operator tables (a | b), shell pipelines (ls | wc -l), union types (string | null), regex alternation, and Markdown table examples themselves. The snapshot corpus happens to contain no table cell with code and a pipe, which is why CI stayed green.
Severity feels moderate rather than cosmetic: for a converter positioned as producing LLM-ready Markdown, the failure mode is silent loss of a neighbouring column rather than visibly broken output.
Suggested fix
Give push_code_span the context and escape pipes in table cells (two call sites: inline.rs:208, table.rs:169):
-pub(crate) fn push_code_span(text: &str, out: &mut String) {
+pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) {
let text = text.replace('\n', " ");
+ // A raw pipe splits a GFM table cell even inside a code span.
+ let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };
let fence = backtick_fence(&text, 1);
With that patch cargo test is fully green locally (210 unit tests including the two above, plus the integration and snapshot suites) with no snapshot changes. Happy to open a PR if that's welcome.
This report was produced with AI assistance; I reproduced it locally and reviewed every conclusion myself.
|is escaped in table cells for plain text, emphasis, and URLs, but not for code spans.push_code_span()never receives theInlineContext, so it emits raw pipes, which split the GFM table row and silently drop every column to the right of the code cell.Expected
The GFM spec (§4.10 Tables extension, Example 200) is explicit that this applies inside inline spans:
The repo already treats this as settled for the other three paths —
escape.rs:82escapes'|' if ctx == InlineContext::TableCellfor plain and styled text, andformat_urlpercent-encodes it under a comment that states the rule outright:with the existing test
url_pipes_cannot_split_table_cells(tests.rs:289) and the plain-text case intable_basic(tests.rs:232,Ann | Bob→Ann \| Bob). Only the code-span path is missing.Actual
Minimal
.epubwith a two-column table:cargo run --example convert -- pipes.epubon v0.1.8 (4e3089b):The delimiter row declares two columns, but the first two body rows emit three cells each. GFM ignores cells past the header width, so feeding that output to a GFM renderer (marked 18,
gfm: true) loses theMeaningvalue and tears the code span in half:Expected first body row is
|a | b| bitwise or |, which renders as<td><code>a | b</code></td><td>bitwise or</td>with both columns intact.Failing tests
In the style of the existing
url_pipes_cannot_split_table_cells:Both fail on
4e3089b, whileurl_pipes_cannot_split_table_cellspasses:Root cause
Pipe escaping lives in
escape_text(), keyed onInlineContext::TableCell(escape.rs:82). Code spans are a bypass around it:inline.rs:207routes code-styled runs topush_code_span(), which takes noInlineContextand only handles newlines and the backtick fence.table.rs:169sends cell-levelBlock::CodeBlockdown the same function. Because GFM splits table rows before inline parsing, backticks do not protect a pipe — the row is already cut by the time the code span would exist.Affected inputs
<code>,<kbd>,<samp>,<tt>(shared/html.rs:690) and<pre>(shared/html.rs:423).Source Code/HTML Preformatted/Preformatted Textparagraph styles (shared/blockstyle.rs:22). The existingrtf/table.rs:219andodf/mod.rs:275tests both assert aCodeBlocklanding inside a table cell, so this path is reachable there.Inline::plain.Trigger conditions are ordinary for technical documents: operator tables (
a | b), shell pipelines (ls | wc -l), union types (string | null), regex alternation, and Markdown table examples themselves. The snapshot corpus happens to contain no table cell with code and a pipe, which is why CI stayed green.Severity feels moderate rather than cosmetic: for a converter positioned as producing LLM-ready Markdown, the failure mode is silent loss of a neighbouring column rather than visibly broken output.
Suggested fix
Give
push_code_spanthe context and escape pipes in table cells (two call sites:inline.rs:208,table.rs:169):With that patch
cargo testis fully green locally (210 unit tests including the two above, plus the integration and snapshot suites) with no snapshot changes. Happy to open a PR if that's welcome.This report was produced with AI assistance; I reproduced it locally and reviewed every conclusion myself.