diff --git a/crates/editor/src/content/buffer_tests.rs b/crates/editor/src/content/buffer_tests.rs
index 4489a6b22ba..4d2155eae2e 100644
--- a/crates/editor/src/content/buffer_tests.rs
+++ b/crates/editor/src/content/buffer_tests.rs
@@ -13019,6 +13019,53 @@ fn test_clipboard_table_copy_uses_source_offsets_for_later_formatted_cells() {
});
}
+#[test]
+fn test_clipboard_table_copy_emits_real_newline_for_in_cell_break() {
+ App::test((), |mut app| async move {
+ // Product decision (#13732): the two clipboard flavors carry a `
` differently.
+ //
+ // - The plain-text flavor is *rendered text*: it already strips styling, so an
+ // authored hard break emits a real `\n`, never the literal HTML string "
".
+ // Injecting HTML the user didn't select would violate that rendered-text contract.
+ // - The HTML flavor (see serialize_table_cell_inline / selected_text_as_html) carries
+ // the break as a real `
` element, so Warp-to-Warp paste round-trips faithfully.
+ //
+ // Accepted cost: a plain-text-only re-paste of this row into a Warp table may split it,
+ // because the in-cell newline collides with the tab/newline row delimiter. That is a
+ // standard TSV-class limitation we consciously accept — it is NOT an oversight. Do not
+ // "fix" it by re-emitting "
" here; that regresses the plain-text contract above.
+ let table_source = "a
b\tc";
+ let markdown = format!("```{TABLE_BLOCK_MARKDOWN_LANG}\n{table_source}\n```\n");
+ let (buffer, _selection) = Buffer::mock_from_markdown(
+ &markdown,
+ None,
+ Box::new(|_, _| IndentBehavior::Ignore),
+ &mut app,
+ );
+
+ buffer.update(&mut app, |buffer, _ctx| {
+ let block_start = buffer.containing_block_start(CharOffset::from(1));
+ let max_offset = buffer.max_charoffset();
+
+ let copied = buffer.clipboard_table_text_in_range(
+ block_start,
+ block_start..max_offset,
+ LineEnding::LF,
+ );
+
+ assert!(
+ !copied.contains("
"),
+ "plain-text flavor must not contain literal HTML the user didn't select, got {copied:?}"
+ );
+ // The in-cell break renders as a real newline; the trailing `\n` terminates the row.
+ assert_eq!(
+ copied, "a\nb\tc\n",
+ "in-cell break should emit a real newline in the plain-text flavor"
+ );
+ });
+ });
+}
+
#[test]
fn test_multiselect_text_styling() {
App::test((), |mut app| async move {
diff --git a/crates/editor/src/content/edit.rs b/crates/editor/src/content/edit.rs
index 93130d07fed..ee15e2c06e2 100644
--- a/crates/editor/src/content/edit.rs
+++ b/crates/editor/src/content/edit.rs
@@ -295,7 +295,15 @@ fn measure_table_cells(
let mut line = LayOutArgs::new();
line.highlighted_urls = highlight_urls(&runs);
for run in &runs {
- line.layout_run(layout, run, ¶graph_style);
+ // `layout_run` strips a trailing `\n` (from a raw HTML `
`) out of
+ // `line.text`; an interior `\n` is left in place. Reinsert the break at each run
+ // boundary where it occurred — a `
` can land at a style-run boundary (e.g.
+ // `**bold**
**plain**`), so the break may terminate a non-final run. Tracking
+ // only the final run's flag would drop such interior breaks and silently collapse
+ // the cell to one line.
+ if line.layout_run(layout, run, ¶graph_style) {
+ line.text.push('\n');
+ }
}
let text_layout = InlineTextLayoutInput {
text: line.text,
diff --git a/crates/editor/src/content/edit_tests.rs b/crates/editor/src/content/edit_tests.rs
index a94a3b2dd21..ec2b0bdef8d 100644
--- a/crates/editor/src/content/edit_tests.rs
+++ b/crates/editor/src/content/edit_tests.rs
@@ -14,6 +14,7 @@ use warpui_core::{App, SingletonEntity};
use super::{
BlockLocation, LayOutArgs, layout_mermaid_diagram_block, layout_table_block, layout_text_block,
+ measure_table_cells,
};
use crate::content::buffer::{StyledBufferRun, StyledTextBlock};
use crate::content::edit::{
@@ -843,6 +844,156 @@ fn test_layout_table_block_caches_cell_text_frames() {
})
}
+/// Lays out the given internal-format table string and returns the measured cell text strings
+/// (header row first, then body rows). The returned `String`s are the exact inputs handed to the
+/// text layout system, so a trailing `\n` here means the cell will render a trailing empty line.
+///
+/// This asserts on `measure_table_cells`' output rather than laid-out line counts because
+/// `App::test`'s text layout backend is a stub (`platform::test::delegate`'s `layout_text` ignores
+/// its input and always returns a single empty line), so `line_heights.len()` cannot observe
+/// newline handling.
+fn measured_cell_texts(text_layout: &TextLayout<'_>, content: &str) -> Vec> {
+ let table =
+ crate::content::text::table_from_internal_format_with_inline_markdown(content, Vec::new());
+ let table_style = text_layout.rich_text_styles().table_style;
+ let mut header_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
+ header_style.font_weight = Weight::Bold;
+ let body_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
+
+ let (_widths, cell_text_layouts) =
+ measure_table_cells(&table, text_layout, &table_style, header_style, body_style);
+ cell_text_layouts
+ .into_iter()
+ .map(|row| row.into_iter().map(|cell| cell.text_layout.text).collect())
+ .collect()
+}
+
+/// A trailing `
` at the end of a table cell must preserve the trailing newline in the cell's
+/// measured text so it renders an extra empty line, matching GitHub's rendering of a trailing hard
+/// break inside a GFM pipe-table cell.
+#[test]
+fn test_measure_table_cells_trailing_br_keeps_newline() {
+ App::test((), |app| async move {
+ app.read(|ctx| {
+ let layout_cache = LayoutCache::new();
+ let text_layout = TextLayout::new(
+ &layout_cache,
+ ctx.font_cache().text_layout_system(),
+ &TEST_STYLES,
+ f32::MAX,
+ );
+ // Body cell 0 ends in `
`; control cell 1 is a plain single-line cell.
+ let cells = measured_cell_texts(&text_layout, "a\tb\none
\ttwo\n");
+
+ assert_eq!(
+ cells[1][0], "one\n",
+ "cell ending in
must keep its trailing newline (extra empty line)"
+ );
+ assert_eq!(
+ cells[1][1], "two",
+ "control cell without
must be unchanged"
+ );
+ });
+ })
+}
+
+/// A cell containing only `
` parses to a lone `\n` fragment; its measured text must be a lone
+/// newline (an empty content line plus a trailing empty line), not the empty string.
+#[test]
+fn test_measure_table_cells_lone_br_keeps_newline() {
+ App::test((), |app| async move {
+ app.read(|ctx| {
+ let layout_cache = LayoutCache::new();
+ let text_layout = TextLayout::new(
+ &layout_cache,
+ ctx.font_cache().text_layout_system(),
+ &TEST_STYLES,
+ f32::MAX,
+ );
+ let cells = measured_cell_texts(&text_layout, "a\tb\n
\ttwo\n");
+
+ assert_eq!(cells[1][0], "\n", "a lone
cell must keep its newline");
+ });
+ })
+}
+
+/// Embedded `
` (text on both sides) is the control path: the interior newline was never
+/// stripped, so it must remain untouched by the trailing-break fix.
+#[test]
+fn test_measure_table_cells_embedded_br_unchanged() {
+ App::test((), |app| async move {
+ app.read(|ctx| {
+ let layout_cache = LayoutCache::new();
+ let text_layout = TextLayout::new(
+ &layout_cache,
+ ctx.font_cache().text_layout_system(),
+ &TEST_STYLES,
+ f32::MAX,
+ );
+ let cells = measured_cell_texts(&text_layout, "a\tb\none
two\tthree\n");
+
+ assert_eq!(
+ cells[1][0], "one\ntwo",
+ "embedded
must keep its single interior newline"
+ );
+ });
+ })
+}
+
+/// A `
` that falls between two styled runs (e.g. `**bold**
**plain**`) parses to a
+/// standalone `\n` fragment sitting in a non-final run position. `layout_run` strips that run's
+/// trailing `\n` and reports the break, but the following run reports no break — so tracking only
+/// the final run's flag would drop the interior newline and collapse the two lines into one. The
+/// break must be reinserted per-run, at the run boundary.
+#[test]
+fn test_measure_table_cells_style_boundary_br_keeps_newline() {
+ App::test((), |app| async move {
+ app.read(|ctx| {
+ let layout_cache = LayoutCache::new();
+ let text_layout = TextLayout::new(
+ &layout_cache,
+ ctx.font_cache().text_layout_system(),
+ &TEST_STYLES,
+ f32::MAX,
+ );
+ // Body cell 0: bold "bold", a
break, bold "plain". The
becomes a standalone
+ // `\n` fragment between the two bold runs — a non-final run that ends in the break.
+ let cells = measured_cell_texts(&text_layout, "a\tb\n**bold**
**plain**\ttwo\n");
+
+ assert_eq!(
+ cells[1][0], "bold\nplain",
+ "a
at a style-run boundary must keep its newline between the runs"
+ );
+ });
+ })
+}
+
+/// A cell with breaks in both an interior style-boundary position and a trailing position must
+/// preserve every newline, proving the per-run reinsertion handles multiple breaks rather than
+/// only the last one.
+#[test]
+fn test_measure_table_cells_style_boundary_and_trailing_br() {
+ App::test((), |app| async move {
+ app.read(|ctx| {
+ let layout_cache = LayoutCache::new();
+ let text_layout = TextLayout::new(
+ &layout_cache,
+ ctx.font_cache().text_layout_system(),
+ &TEST_STYLES,
+ f32::MAX,
+ );
+ // Body cell 0: an interior style-boundary break (between the two bold runs) plus a
+ // trailing break at the end of the cell.
+ let cells = measured_cell_texts(&text_layout, "a\tb\n**bold**
**plain**
\ttwo\n");
+
+ assert_eq!(
+ cells[1][0], "bold\nplain\n",
+ "breaks at a style boundary and at the trailing position must both be preserved"
+ );
+ });
+ })
+}
+
#[test]
fn test_layout_table_block_clamps_cell_width_to_max() {
App::test((), |app| async move {
diff --git a/crates/editor/src/content/markdown.rs b/crates/editor/src/content/markdown.rs
index 7c3276f4867..803ebd5ba11 100644
--- a/crates/editor/src/content/markdown.rs
+++ b/crates/editor/src/content/markdown.rs
@@ -1172,7 +1172,25 @@ where
serializer.start_elem(QualName::new(None, ns!(html), "code".into()), iter::empty())?;
}
- serializer.write_text(&fragment.text)?;
+ // An authored hard break (raw HTML `
`) is stored as an embedded newline. In an
+ // HTML table cell a raw newline collapses to a space, silently dropping the break, so
+ // emit a real `
` element between the split parts instead. Inline code cannot
+ // contain such a newline (the `.md` inline parser never produces one), so it is written
+ // verbatim. Mirrors the guard in `inline_to_markdown`.
+ if !styles.is_inline_code() && fragment.text.contains('\n') {
+ let mut parts = fragment.text.split('\n');
+ if let Some(first) = parts.next() {
+ serializer.write_text(first)?;
+ }
+ for part in parts {
+ let br_tag = QualName::new(None, ns!(html), "br".into());
+ serializer.start_elem(br_tag.clone(), iter::empty())?;
+ serializer.end_elem(br_tag)?;
+ serializer.write_text(part)?;
+ }
+ } else {
+ serializer.write_text(&fragment.text)?;
+ }
if styles.is_inline_code() {
serializer.end_elem(QualName::new(None, ns!(html), "code".into()))?;
@@ -1249,10 +1267,20 @@ fn inline_to_markdown(inline: &FormattedTextInline) -> String {
let mut previous_styles = TextStylesWithMetadata::default();
for fragment in inline {
let next_styles = TextStylesWithMetadata::from(fragment.styles.clone());
+ // An authored hard break (raw HTML `
`) is stored as an embedded newline. Emit it as
+ // literal `
` so it survives the GFM pipe-table serialization without splitting the
+ // cell into a spurious extra row. Inline code cannot contain a newline from the `.md`
+ // inline parser, so it is left untouched. Mirrors the guard in
+ // `markdown_parser::inline_to_markdown`.
+ let fragment_text = if !next_styles.is_inline_code() && fragment.text.contains('\n') {
+ fragment.text.replace('\n', "
")
+ } else {
+ fragment.text.clone()
+ };
let content = BufferMarkdownParser::append_formatting(
&previous_styles,
&next_styles,
- fragment.text.as_str(),
+ fragment_text.as_str(),
&mut markdown,
);
previous_styles = next_styles;
diff --git a/crates/editor/src/content/markdown_tests.rs b/crates/editor/src/content/markdown_tests.rs
index f8e01a683c0..587195dec4f 100644
--- a/crates/editor/src/content/markdown_tests.rs
+++ b/crates/editor/src/content/markdown_tests.rs
@@ -178,6 +178,40 @@ fn test_gfm_table_html_serialization() {
});
}
+#[test]
+fn test_gfm_table_html_serialization_preserves_br_in_cell() {
+ App::test((), |mut app| async move {
+ let _flag = warp_core::features::FeatureFlag::MarkdownTables.override_enabled(true);
+ let markdown = "\
+| header 1 | header 2 |\n\
+| --- | --- |\n\
+| line one
line two | value 2 |\n";
+ let (buffer, _selection) = Buffer::mock_from_markdown(
+ markdown,
+ None,
+ Box::new(|_, _| IndentBehavior::Ignore),
+ &mut app,
+ );
+
+ let html = app.read_model(&buffer, |buffer, ctx| {
+ let range = CharOffset::from(1)..buffer.max_charoffset();
+ buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
+ });
+
+ let html = html.expect("table should export HTML");
+ // The in-cell break must serialize as a real
element, not a raw newline (which
+ // HTML collapses to a space, silently dropping the break).
+ assert!(
+ html.contains("line one line two | "),
+ "cell break should become a
element, got {html}"
+ );
+ assert!(
+ !html.contains("line one\nline two"),
+ "cell HTML must not contain a raw newline, got {html:?}"
+ );
+ });
+}
+
#[test]
fn test_apply_formatted_text_delta_append() {
App::test((), |mut app| async move {
@@ -357,6 +391,35 @@ fn test_table_markdown_export_escapes_pipe_characters() {
});
}
+#[test]
+fn test_table_markdown_export_preserves_br_line_breaks() {
+ App::test((), |mut app| async move {
+ // A cell whose text contains a `
` hard break is stored internally as an
+ // embedded newline. The GFM export must emit it back as literal `
` so the
+ // newline does not split the pipe-table row into a spurious extra row.
+ let markdown = format!(
+ "```{}\nheader 1\theader 2\nline one
line two\tvalue 2\n```\n",
+ TABLE_BLOCK_MARKDOWN_LANG
+ );
+ let (buffer, _selection) = Buffer::mock_from_markdown(
+ &markdown,
+ None,
+ Box::new(|_, _| IndentBehavior::Ignore),
+ &mut app,
+ );
+
+ let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
+ assert_eq!(
+ exported_markdown,
+ "| header 1 | header 2 |\n| --- | --- |\n| line one
line two | value 2 |\n"
+ );
+ assert!(
+ !exported_markdown.contains("line one\nline two"),
+ "in-cell hard break must not emit a raw newline that splits the table row: {exported_markdown:?}"
+ );
+ });
+}
+
#[test]
fn test_url_link_display_text_round_trip_is_stable() {
App::test((), |mut app| async move {
diff --git a/crates/editor/src/render/model/table_offset_map.rs b/crates/editor/src/render/model/table_offset_map.rs
index 3af29f39f8d..73c171bc7c6 100644
--- a/crates/editor/src/render/model/table_offset_map.rs
+++ b/crates/editor/src/render/model/table_offset_map.rs
@@ -283,6 +283,37 @@ impl TableOffsetMap {
// per-cell offsets can be derived by seeking to boundaries instead of re-parsing the
// whole table on every edit. The current embedded-text-plus-cached-parse model is
// sufficient for read-only tables; see PR #24326 discussion for context.
+/// If a raw HTML `
` line-break token begins at `idx` in `chars`, return its length in
+/// characters. Accepts the same forms as the Markdown parser (`markdown_parser::parse_inline_token_br`):
+/// `
`, `
`, and `
` with a case-insensitive `br` and any run of ASCII whitespace
+/// before an optional self-closing slash. Attributes (e.g. `
`) are not accepted,
+/// matching the parser, so such input is walked as ordinary source text.
+fn br_token_len_at(chars: &[char], idx: usize) -> Option {
+ let mut i = idx;
+ if chars.get(i)? != &'<' {
+ return None;
+ }
+ i += 1;
+ if !chars.get(i)?.eq_ignore_ascii_case(&'b') {
+ return None;
+ }
+ i += 1;
+ if !chars.get(i)?.eq_ignore_ascii_case(&'r') {
+ return None;
+ }
+ i += 1;
+ while chars.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
+ i += 1;
+ }
+ if chars.get(i) == Some(&'/') {
+ i += 1;
+ }
+ if chars.get(i)? != &'>' {
+ return None;
+ }
+ Some(i + 1 - idx)
+}
+
impl TableCellOffsetMap {
/// Build a cell offset map from the raw cell `source` text and the parsed `inline`
/// fragments produced by the Markdown parser.
@@ -306,52 +337,78 @@ impl TableCellOffsetMap {
continue;
}
- let first_rendered = rendered_chars[0];
- while source_idx < total_source_chars {
- let sc = source_chars[source_idx];
- if sc == '\\'
- && source_idx + 1 < total_source_chars
- && source_chars[source_idx + 1] == first_rendered
- {
- source_idx += 1;
- break;
- }
- if sc == first_rendered {
- break;
+ // A fragment may embed authored hard breaks (rendered `\n`, spelled `
` in
+ // source). Walk it in newline-delimited segments and emit one range per segment so
+ // each range keeps a linear rendered↔source correspondence; the `
` token span
+ // between segments becomes a gap, handled the same way as Markdown markers between
+ // fragments (see the `source_end` back-fill below).
+ for (segment_index, segment) in rendered_chars.split(|&c| c == '\n').enumerate() {
+ if segment_index > 0 {
+ // Scan forward to the `
` token that produced this break (skipping any
+ // trailing Markdown markers, e.g. the closing `**` of a bold run) and
+ // consume it.
+ while source_idx < total_source_chars {
+ if let Some(token_len) = br_token_len_at(&source_chars, source_idx) {
+ source_idx += token_len;
+ break;
+ }
+ source_idx += 1;
+ }
+ // Account for the rendered `\n` we split on.
+ rendered_offset += CharOffset::from(1usize);
}
- source_idx += 1;
- }
-
- let visible_source_start = CharOffset::from(source_idx);
- for &rendered_char in &rendered_chars {
- if source_idx >= total_source_chars {
- break;
+ if segment.is_empty() {
+ continue;
}
- let sc = source_chars[source_idx];
- if sc == '\\'
- && source_idx + 1 < total_source_chars
- && source_chars[source_idx + 1] == rendered_char
- {
- source_idx += 2;
- } else {
+
+ let first_rendered = segment[0];
+ while source_idx < total_source_chars {
+ let sc = source_chars[source_idx];
+ if sc == '\\'
+ && source_idx + 1 < total_source_chars
+ && source_chars[source_idx + 1] == first_rendered
+ {
+ source_idx += 1;
+ break;
+ }
+ if sc == first_rendered {
+ break;
+ }
source_idx += 1;
}
- }
- let visible_source_end = CharOffset::from(source_idx);
- let rendered_start = rendered_offset;
- let rendered_end = rendered_start + CharOffset::from(rendered_chars.len());
+ let visible_source_start = CharOffset::from(source_idx);
+
+ for &rendered_char in segment {
+ if source_idx >= total_source_chars {
+ break;
+ }
+ let sc = source_chars[source_idx];
+ if sc == '\\'
+ && source_idx + 1 < total_source_chars
+ && source_chars[source_idx + 1] == rendered_char
+ {
+ source_idx += 2;
+ } else {
+ source_idx += 1;
+ }
+ }
+
+ let visible_source_end = CharOffset::from(source_idx);
+ let rendered_start = rendered_offset;
+ let rendered_end = rendered_start + CharOffset::from(segment.len());
- fragment_ranges.push(TableCellFragmentRange {
- rendered_start,
- rendered_end,
- source_end: visible_source_end,
- visible_source_start,
- visible_source_end,
- });
+ fragment_ranges.push(TableCellFragmentRange {
+ rendered_start,
+ rendered_end,
+ source_end: visible_source_end,
+ visible_source_start,
+ visible_source_end,
+ });
- rendered_offset = rendered_end;
+ rendered_offset = rendered_end;
+ }
}
let total_source_offset = CharOffset::from(total_source_chars);
@@ -389,10 +446,18 @@ impl TableCellOffsetMap {
.unwrap_or(self.source_length);
}
+ let mut previous_visible_source_end = CharOffset::zero();
for fragment in &self.fragment_ranges {
+ // A rendered `\n` (authored `
`) sits in the gap before this range's
+ // `rendered_start`. Map it to the end of the previous range's visible source (the
+ // start of the `
` token) rather than interpolating into this range.
+ if rendered_offset < fragment.rendered_start {
+ return previous_visible_source_end;
+ }
if rendered_offset < fragment.rendered_end {
return fragment.visible_source_start + (rendered_offset - fragment.rendered_start);
}
+ previous_visible_source_end = fragment.visible_source_end;
}
self.source_length
diff --git a/crates/editor/src/render/model/table_offset_map_tests.rs b/crates/editor/src/render/model/table_offset_map_tests.rs
index 5232d241681..65698648c27 100644
--- a/crates/editor/src/render/model/table_offset_map_tests.rs
+++ b/crates/editor/src/render/model/table_offset_map_tests.rs
@@ -231,6 +231,115 @@ fn test_table_cell_offset_map_handles_backslash_escaped_punctuation() {
);
}
+#[test]
+fn test_table_cell_offset_map_maps_br_source_offsets() {
+ // `
` is 4 source chars but renders as a single `\n`. The map must map offsets in
+ // the post-break region ("two") to their true source positions, not collapse them to
+ // the break.
+ let source = "one
two";
+ let inline = parse_inline_markdown(source);
+ let rendered_text: String = inline
+ .iter()
+ .map(|fragment| fragment.text.as_str())
+ .collect();
+ assert_eq!(rendered_text, "one\ntwo");
+
+ let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
+ assert_eq!(
+ map.source_length(),
+ CharOffset::from(source.chars().count())
+ );
+ assert_eq!(
+ map.rendered_length(),
+ CharOffset::from(rendered_text.chars().count())
+ );
+
+ // Rendered "two" begins at rendered offset 4 and source offset 7 (after `one
`).
+ assert_eq!(
+ map.rendered_to_source(CharOffset::from(4)),
+ CharOffset::from(7),
+ "rendered 't' should map to source 't' after the
token"
+ );
+ assert_eq!(
+ map.rendered_to_source(CharOffset::from(6)),
+ CharOffset::from(9),
+ );
+ assert_eq!(
+ map.source_to_rendered(CharOffset::from(7)),
+ CharOffset::from(4),
+ "source 't' should map to rendered 't'"
+ );
+ assert_eq!(
+ map.source_to_rendered(CharOffset::from(9)),
+ CharOffset::from(6),
+ );
+
+ // Every rendered char (including the break) should map back to a source position whose
+ // char matches, except the break which corresponds to the `<` of `
`.
+ for (rendered_idx, rendered_char) in rendered_text.chars().enumerate() {
+ if rendered_char == '\n' {
+ continue;
+ }
+ let source_pos = map.rendered_to_source(CharOffset::from(rendered_idx));
+ assert_eq!(
+ source.chars().nth(source_pos.as_usize()),
+ Some(rendered_char),
+ "rendered {rendered_idx} ({rendered_char:?}) should map to same char in source",
+ );
+ }
+}
+
+#[test]
+fn test_table_cell_offset_map_maps_self_closing_br_source_offsets() {
+ // `
` is 5 source chars, still a single rendered `\n`.
+ let source = "one
two";
+ let inline = parse_inline_markdown(source);
+ let rendered_text: String = inline
+ .iter()
+ .map(|fragment| fragment.text.as_str())
+ .collect();
+ assert_eq!(rendered_text, "one\ntwo");
+
+ let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
+ // Rendered "two" begins at rendered offset 4 and source offset 8 (after `one
`).
+ assert_eq!(
+ map.rendered_to_source(CharOffset::from(4)),
+ CharOffset::from(8),
+ );
+ assert_eq!(
+ map.source_to_rendered(CharOffset::from(8)),
+ CharOffset::from(4),
+ );
+}
+
+#[test]
+fn test_table_cell_offset_map_maps_br_at_style_boundary() {
+ // The break falls between a bold fragment ("a") and a plain one that begins with the
+ // break ("\nb"), so the walk must handle a `\n` as the *first* rendered char too.
+ let source = "**a**
b";
+ let inline = parse_inline_markdown(source);
+ let rendered_text: String = inline
+ .iter()
+ .map(|fragment| fragment.text.as_str())
+ .collect();
+ assert_eq!(rendered_text, "a\nb");
+
+ let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
+ assert_eq!(
+ map.source_length(),
+ CharOffset::from(source.chars().count())
+ );
+ // Rendered 'b' is at rendered offset 2 and source offset 9 (after `**a**
`).
+ assert_eq!(
+ map.rendered_to_source(CharOffset::from(2)),
+ CharOffset::from(9),
+ );
+ assert_eq!(
+ map.source_to_rendered(CharOffset::from(9)),
+ CharOffset::from(2),
+ );
+}
+
#[test]
fn test_table_cell_offset_map_handles_nested_styles() {
let source = "**a *b* c**";
diff --git a/crates/markdown_parser/src/lib.rs b/crates/markdown_parser/src/lib.rs
index e9cf95c66ef..3084237e744 100644
--- a/crates/markdown_parser/src/lib.rs
+++ b/crates/markdown_parser/src/lib.rs
@@ -431,7 +431,13 @@ impl FormattedTable {
/// Serialize to GFM pipe-table markdown.
pub fn to_plain_text(&self) -> String {
fn inline_to_text(inline: &FormattedTextInline) -> String {
- inline.iter().map(|f| f.text.as_str()).collect()
+ // Translate authored hard breaks (embedded newlines, from raw HTML `
`) back
+ // to `
` so an in-cell line break does not split the pipe-table row. A cell's
+ // text never contains a newline for any other reason.
+ inline
+ .iter()
+ .map(|f| f.text.replace('\n', "
"))
+ .collect()
}
let mut lines = Vec::new();
@@ -464,6 +470,14 @@ fn inline_to_markdown(inline: &FormattedTextInline) -> String {
continue;
}
+ // An authored hard break (raw HTML `
`) is stored as an embedded newline. Emit it
+ // as literal `
` so it survives the newline/tab/pipe-delimited table serialization
+ // formats without splitting a cell into a spurious extra row. Inline code spans are
+ // handled below (a newline cannot appear in one from the `.md` inline parser).
+ if !fragment.styles.inline_code && text.contains('\n') {
+ text = text.replace('\n', "
");
+ }
+
if fragment.styles.inline_code {
result.push('`');
result.push_str(&text);
diff --git a/crates/markdown_parser/src/markdown_parser.rs b/crates/markdown_parser/src/markdown_parser.rs
index 1e67e7f43da..c17593d5595 100644
--- a/crates/markdown_parser/src/markdown_parser.rs
+++ b/crates/markdown_parser/src/markdown_parser.rs
@@ -1035,6 +1035,14 @@ fn parse_inline<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
InlineToken::UnderlineEnd => {
input = parse_underline(&mut state, remaining);
}
+ InlineToken::LineBreak => {
+ // Emit the break as a newline fragment. Both the paragraph and table-cell
+ // render paths turn an embedded newline into a real visual break. The
+ // fragment may later coalesce into adjacent plain text (the newline is
+ // preserved, so rendering is unaffected); serialization paths translate any
+ // embedded newline back to `
`. See `HARD_LINE_BREAK`.
+ state.push_closed_node(FormattedTextFragment::plain_text(HARD_LINE_BREAK));
+ }
}
}
@@ -1549,6 +1557,7 @@ fn parse_inline_token<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
parse_inline_token_autolink,
parse_inline_token_underline_start,
parse_inline_token_underline_end,
+ parse_inline_token_br,
whitespace,
text,
// This _must_ be the last parser in the chain. It unconditionally consumes a single
@@ -1654,6 +1663,33 @@ fn parse_inline_token_underline_end<'a, E: ContextError<&'a str> + ParseError<&'
)(input)
}
+/// Parse a raw HTML `
` line break.
+///
+/// Accepts the case-insensitive forms `
`, `
`, and `
` (any run of ASCII
+/// whitespace is allowed before an optional self-closing `/`). Attributes are intentionally
+/// *not* accepted: a tag such as `
` falls through to literal text rather than
+/// being silently reinterpreted, keeping malformed/unsupported input deterministic. A stray
+/// `
` also fails here and is emitted as literal text by the fallback
+/// `text`/`unmatched_char` parsers, matching the ``/`` precedent.
+fn parse_inline_token_br<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
+ input: &'a str,
+) -> IResult<&'a str, InlineToken<'a>, E> {
+ context(
+ "line_break",
+ value(
+ InlineToken::LineBreak,
+ tuple((
+ tag("<"),
+ tag_no_case("br"),
+ space0,
+ // Optional self-closing slash: `
` or `
`.
+ map(take_while_m_n(0, 1, |c| c == '/'), |_| ()),
+ tag(">"),
+ )),
+ ),
+ )(input)
+}
+
/// Helper to parse a run of delimiters.
fn parse_delimiter_run<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
kind: DelimiterKind,
@@ -1700,7 +1736,19 @@ enum InlineToken<'a> {
LinkEnd,
/// A closing , which triggers underline parsing.
UnderlineEnd,
-}
+ /// A raw HTML `
` line break (including the `
`, `
`, and uppercase
+ /// variants). Rendered as a forced line break within the current paragraph or table
+ /// cell. See [`HARD_LINE_BREAK`].
+ LineBreak,
+}
+
+/// The text a hard line break (raw HTML `
`) is represented as within a
+/// [`FormattedTextFragment`]. Both the paragraph and table-cell render paths already turn an
+/// embedded newline into a real visual break, so the break is stored as a plain-text `"\n"`
+/// fragment. Serialization paths that flatten fragments into newline/tab/pipe-delimited
+/// formats (e.g. [`inline_to_markdown`], [`FormattedTable::to_plain_text`]) must translate
+/// this sentinel back to `
` so it does not corrupt row/column structure.
+pub(crate) const HARD_LINE_BREAK: &str = "\n";
/// An entry in the [delimiter stack](https://spec.commonmark.org/0.30/#delimiter-stack)
#[derive(Debug, Clone)]
diff --git a/crates/markdown_parser/src/markdown_parser_tests.rs b/crates/markdown_parser/src/markdown_parser_tests.rs
index d347c01c981..3dde1a489d4 100644
--- a/crates/markdown_parser/src/markdown_parser_tests.rs
+++ b/crates/markdown_parser/src/markdown_parser_tests.rs
@@ -2912,3 +2912,219 @@ fn test_parse_table_with_strikethrough() {
panic!("Expected table");
}
}
+
+// --- Raw HTML `
` line breaks (GH13732) ---------------------------------
+//
+// A `
` is parsed into a bare `"\n"` fragment; both the paragraph and table-cell render
+// paths turn an embedded newline into a real visual break. These tests assert the parse-level
+// shape, the malformed-input fallbacks, and that serialization round-trips the break as
+// literal `
` rather than corrupting the delimited table formats.
+
+/// Collect the fragments of the single paragraph line produced by `source`.
+fn parse_single_line_fragments(source: &str) -> Vec {
+ let result = test_parse_markdown(source);
+ assert_eq!(result.len(), 1, "expected a single line, got {result:?}");
+ match result.into_iter().next().unwrap() {
+ FormattedTextLine::Line(fragments) => fragments,
+ other => panic!("expected a paragraph Line, got {other:?}"),
+ }
+}
+
+/// The joined text of the single paragraph line produced by `source`. A `
` renders as
+/// an embedded `"\n"`; fragments with identical styles coalesce, so the break lives inside a
+/// merged fragment rather than as a standalone one — the render path breaks on the newline
+/// either way.
+fn parse_single_line_text(source: &str) -> String {
+ parse_single_line_fragments(source)
+ .iter()
+ .map(|f| f.text.as_str())
+ .collect()
+}
+
+#[test]
+fn test_br_standalone_becomes_line_break() {
+ assert_eq!(
+ parse_single_line_text("First half
second half"),
+ "First half\nsecond half"
+ );
+}
+
+#[test]
+fn test_br_self_closing_variants() {
+ // `
`, `
`, and `
` are all recognized identically.
+ for source in ["a
b", "a
b", "a
b", "a
b"] {
+ assert_eq!(
+ parse_single_line_text(source),
+ "a\nb",
+ "failed for {source:?}"
+ );
+ }
+}
+
+#[test]
+fn test_br_case_insensitive() {
+ for source in ["a
b", "a
b", "a
b", "a
b"] {
+ assert_eq!(
+ parse_single_line_text(source),
+ "a\nb",
+ "failed for {source:?}"
+ );
+ }
+}
+
+#[test]
+fn test_br_consecutive_produce_two_breaks() {
+ // `
` is two forced breaks (one visually blank line), not a paragraph boundary.
+ assert_eq!(parse_single_line_text("a
b"), "a\n\nb");
+}
+
+#[test]
+fn test_br_malformed_stays_literal() {
+ // Each of these fails the `
` shape and must survive as literal text, not a break.
+ // - `
`
+ // - `< br>` : whitespace before the tag name
+ // - `` : end tag, not a void element
+ // - `
` : attributes are intentionally rejected (deterministic fallback)
+ // - `` : not the `br` tag name
+ for source in [
+ "a
b",
+ "ab",
+ "a
b",
+ "ab",
+ ] {
+ let fragments = parse_single_line_fragments(source);
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert!(
+ !joined.contains('\n'),
+ "expected no line break for malformed {source:?}, got {fragments:?}"
+ );
+ // The literal tag text survives.
+ let literal = source.trim_start_matches('a').trim_end_matches('b');
+ assert!(
+ joined.contains(literal),
+ "expected literal {literal:?} to survive for {source:?}, got {joined:?}"
+ );
+ }
+}
+
+#[test]
+fn test_br_inside_code_span_is_literal() {
+ // Code spans are consumed atomically before the `
` parser sees their contents.
+ let fragments = parse_single_line_fragments("before `no
here` after");
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert!(
+ !joined.contains('\n'),
+ "expected no break inside a code span, got {fragments:?}"
+ );
+ assert!(
+ joined.contains("
"),
+ "expected literal
preserved inside code span, got {joined:?}"
+ );
+}
+
+#[test]
+fn test_kbd_tag_still_literal_control() {
+ // Control: an unrelated raw tag is NOT special-cased and stays literal text.
+ let fragments = parse_single_line_fragments("Press K now");
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert!(!joined.contains('\n'), "kbd must not produce a break");
+ assert!(
+ joined.contains("") && joined.contains(""),
+ "expected literal tags preserved, got {joined:?}"
+ );
+}
+
+#[test]
+fn test_br_inside_gfm_table_cell_becomes_line_break() {
+ let source = "| Feature | Notes |\n| --- | --- |\n| Export | Supports CSV.
Also JSON. |\n";
+ let result = test_parse_markdown_with_gfm_tables(source);
+ assert_eq!(result.len(), 1);
+
+ let FormattedTextLine::Table(table) = &result[0] else {
+ panic!("expected a table, got {result:?}");
+ };
+ let cell_text: String = table.rows[0][1].iter().map(|f| f.text.as_str()).collect();
+ assert_eq!(
+ cell_text, "Supports CSV.\nAlso JSON.",
+ "expected the cell to contain an embedded newline break, got {:?}",
+ table.rows[0][1]
+ );
+}
+
+#[test]
+fn test_br_in_table_cell_round_trips_as_literal_br() {
+ // A break inside a cell must serialize back to `
` in both the internal tab/newline
+ // format and the GFM pipe-table format, never as a raw `\n` that would split the row.
+ let source = "| A | B |\n| --- | --- |\n| one
two | plain |\n";
+ let result = test_parse_markdown_with_gfm_tables(source);
+ let FormattedTextLine::Table(table) = &result[0] else {
+ panic!("expected a table, got {result:?}");
+ };
+
+ let internal = table.to_internal_format();
+ // Exactly two lines (header + one data row), no spurious row from the in-cell break.
+ assert_eq!(
+ internal.lines().count(),
+ 2,
+ "in-cell break must not add a table row, got {internal:?}"
+ );
+ assert!(
+ internal.contains("one
two"),
+ "expected
in internal format, got {internal:?}"
+ );
+
+ let plain = table.to_plain_text();
+ assert!(
+ plain.contains("| one
two |"),
+ "expected
in GFM plain text, got {plain:?}"
+ );
+ // Header + separator + one data row.
+ assert_eq!(
+ plain.lines().count(),
+ 3,
+ "in-cell break must not add a pipe-table row, got {plain:?}"
+ );
+}
+
+// Regression guards for `
` combined with inline style delimiters. A `
` inside a
+// styled run keeps the surrounding style on both sides of the embedded newline; fragments with
+// identical styles coalesce, so the break lives inside a single styled fragment.
+
+#[test]
+fn test_br_inside_bold_keeps_bold_across_break() {
+ let fragments = parse_single_line_fragments("**a
b**");
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}");
+ assert!(
+ fragments
+ .iter()
+ .all(|f| f.styles.weight == Some(CustomWeight::Bold)),
+ "both sides of the break should stay bold, got {fragments:?}"
+ );
+}
+
+#[test]
+fn test_br_inside_underline_keeps_underline_across_break() {
+ let fragments = parse_single_line_fragments("a
b");
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}");
+ assert!(
+ fragments.iter().all(|f| f.styles.underline),
+ "both sides of the break should stay underlined, got {fragments:?}"
+ );
+}
+
+#[test]
+fn test_br_inside_link_keeps_hyperlink_across_break() {
+ let fragments = parse_single_line_fragments("[a
b](https://x.com)");
+ let joined: String = fragments.iter().map(|f| f.text.as_str()).collect();
+ assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}");
+ assert!(
+ fragments.iter().all(|f| matches!(
+ &f.styles.hyperlink,
+ Some(Hyperlink::Url(url)) if url == "https://x.com"
+ )),
+ "both sides of the break should keep the hyperlink, got {fragments:?}"
+ );
+}