Skip to content

markdown: a pipe inside a code span in a table cell is not escaped, splitting the GFM row and dropping later columns #84

Description

@codeAnqiang-ma

| 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:

| b `\|` az |

<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 | BobAnn \| 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions