markdown: Add support for colspan and rowspan for HTML tables (#39898)

Closes https://github.com/zed-industries/zed/issues/39837

This PR adds support for `colspan` feature that is only supported for
HTML tables. I also fixed an edge case where the right side border was
not applied because it didn't match the total column count.

**Before**
<img width="725" height="179"
alt="499166907-385cc787-fc89-4e6d-bf06-c72c3c0bd775"
src="https://github.com/user-attachments/assets/69586053-9893-4c92-aa89-7830d2bc7a6d"
/>

**After**
<img width="1165" height="180" alt="Screenshot 2025-10-21 at 22 51 55"
src="https://github.com/user-attachments/assets/f40686e7-d95b-45a6-be42-e226e2f77483"
/>

```html
<table>
    <tr>
        <th rowspan="2">Region</th>
        <th colspan="2">Revenue</th>
        <th rowspan="2">Growth</th>
    </tr>
    <tr>
        <th>Q2 2024</th>
        <th>Q3 2024</th>
    </tr>
    <tr>
        <td>North America</td>
        <td>$2.8M</td>
        <td>$2.4B</td>
        <td>+85,614%</td>
    </tr>
    <tr>
        <td>Europe</td>
        <td>$1.2M</td>
        <td>$1.9B</td>
        <td>+158,233%</td>
    </tr>
    <tr>
        <td>Asia-Pacific</td>
        <td>$0.5M</td>
        <td>$1.4B</td>
        <td>+279,900%</td>
    </tr>
</table>
```

**TODO**:
- [x] Add tests for rending logic
- [x] Test all the tables again

cc @bennetbo

Release Notes:

- Markdown: Added support for `colspan` and `rowspan` for HTML tables

---------

Co-authored-by: Zed AI <ai@zed.nl>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
This commit is contained in:
Remco Smits 2025-10-22 19:10:37 +02:00 committed by GitHub
parent 96a0db24d9
commit d558005058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 385 additions and 159 deletions

View file

@ -104,25 +104,34 @@ pub enum HeadingLevel {
#[derive(Debug)]
pub struct ParsedMarkdownTable {
pub source_range: Range<usize>,
pub header: ParsedMarkdownTableRow,
pub header: Vec<ParsedMarkdownTableRow>,
pub body: Vec<ParsedMarkdownTableRow>,
pub column_alignments: Vec<ParsedMarkdownTableAlignment>,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Default)]
#[cfg_attr(test, derive(PartialEq))]
pub enum ParsedMarkdownTableAlignment {
/// Default text alignment.
#[default]
None,
Left,
Center,
Right,
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ParsedMarkdownTableColumn {
pub col_span: usize,
pub row_span: usize,
pub is_header: bool,
pub children: MarkdownParagraph,
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ParsedMarkdownTableRow {
pub children: Vec<MarkdownParagraph>,
pub columns: Vec<ParsedMarkdownTableColumn>,
}
impl Default for ParsedMarkdownTableRow {
@ -134,12 +143,12 @@ impl Default for ParsedMarkdownTableRow {
impl ParsedMarkdownTableRow {
pub fn new() -> Self {
Self {
children: Vec::new(),
columns: Vec::new(),
}
}
pub fn with_children(children: Vec<MarkdownParagraph>) -> Self {
Self { children }
pub fn with_columns(columns: Vec<ParsedMarkdownTableColumn>) -> Self {
Self { columns }
}
}

View file

@ -462,9 +462,9 @@ impl<'a> MarkdownParser<'a> {
fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
let (_event, source_range) = self.previous().unwrap();
let source_range = source_range.clone();
let mut header = ParsedMarkdownTableRow::new();
let mut header = vec![];
let mut body = vec![];
let mut current_row = vec![];
let mut row_columns = vec![];
let mut in_header = true;
let column_alignments = alignment.iter().map(Self::convert_alignment).collect();
@ -484,17 +484,21 @@ impl<'a> MarkdownParser<'a> {
Event::Start(Tag::TableCell) => {
self.cursor += 1;
let cell_contents = self.parse_text(false, Some(source_range));
current_row.push(cell_contents);
row_columns.push(ParsedMarkdownTableColumn {
col_span: 1,
row_span: 1,
is_header: in_header,
children: cell_contents,
});
}
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
self.cursor += 1;
let new_row = std::mem::take(&mut current_row);
let columns = std::mem::take(&mut row_columns);
if in_header {
header.children = new_row;
header.push(ParsedMarkdownTableRow { columns: columns });
in_header = false;
} else {
let row = ParsedMarkdownTableRow::with_children(new_row);
body.push(row);
body.push(ParsedMarkdownTableRow::with_columns(columns));
}
}
Event::End(TagEnd::Table) => {
@ -941,6 +945,70 @@ impl<'a> MarkdownParser<'a> {
}
}
fn parse_table_row(
&self,
source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>,
) -> Option<ParsedMarkdownTableRow> {
let mut columns = Vec::new();
match &node.data {
markup5ever_rcdom::NodeData::Element { name, .. } => {
if local_name!("tr") != name.local {
return None;
}
for node in node.children.borrow().iter() {
if let Some(column) = self.parse_table_column(source_range.clone(), node) {
columns.push(column);
}
}
}
_ => {}
}
if columns.is_empty() {
None
} else {
Some(ParsedMarkdownTableRow { columns })
}
}
fn parse_table_column(
&self,
source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>,
) -> Option<ParsedMarkdownTableColumn> {
match &node.data {
markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
if !matches!(name.local, local_name!("th") | local_name!("td")) {
return None;
}
let mut children = MarkdownParagraph::new();
self.consume_paragraph(source_range, node, &mut children);
Some(ParsedMarkdownTableColumn {
col_span: std::cmp::max(
Self::attr_value(attrs, local_name!("colspan"))
.and_then(|span| span.parse().ok())
.unwrap_or(1),
1,
),
row_span: std::cmp::max(
Self::attr_value(attrs, local_name!("rowspan"))
.and_then(|span| span.parse().ok())
.unwrap_or(1),
1,
),
is_header: matches!(name.local, local_name!("th")),
children,
})
}
_ => None,
}
}
fn consume_children(
&self,
source_range: Range<usize>,
@ -1056,7 +1124,7 @@ impl<'a> MarkdownParser<'a> {
node: &Rc<markup5ever_rcdom::Node>,
source_range: Range<usize>,
) -> Option<ParsedMarkdownTable> {
let mut header_columns = Vec::new();
let mut header_rows = Vec::new();
let mut body_rows = Vec::new();
// node should be a thead or tbody element
@ -1066,21 +1134,16 @@ impl<'a> MarkdownParser<'a> {
if local_name!("thead") == name.local {
// node should be a tr element
for node in node.children.borrow().iter() {
let mut paragraph = MarkdownParagraph::new();
self.consume_paragraph(source_range.clone(), node, &mut paragraph);
for paragraph in paragraph.into_iter() {
header_columns.push(vec![paragraph]);
if let Some(row) = self.parse_table_row(source_range.clone(), node) {
header_rows.push(row);
}
}
} else if local_name!("tbody") == name.local {
// node should be a tr element
for node in node.children.borrow().iter() {
let mut row = MarkdownParagraph::new();
self.consume_paragraph(source_range.clone(), node, &mut row);
body_rows.push(ParsedMarkdownTableRow::with_children(
row.into_iter().map(|column| vec![column]).collect(),
));
if let Some(row) = self.parse_table_row(source_range.clone(), node) {
body_rows.push(row);
}
}
}
}
@ -1088,12 +1151,12 @@ impl<'a> MarkdownParser<'a> {
}
}
if !header_columns.is_empty() || !body_rows.is_empty() {
if !header_rows.is_empty() || !body_rows.is_empty() {
Some(ParsedMarkdownTable {
source_range,
body: body_rows,
column_alignments: Vec::default(),
header: ParsedMarkdownTableRow::with_children(header_columns),
header: header_rows,
})
} else {
None
@ -1589,10 +1652,19 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..366,
row(vec![text("Id", 0..366), text("Name ", 0..366)]),
vec![row(vec![
column(1, 1, true, text("Id", 0..366)),
column(1, 1, true, text("Name ", 0..366))
])],
vec![
row(vec![text("1", 0..366), text("Chris", 0..366)]),
row(vec![text("2", 0..366), text("Dennis", 0..366)]),
row(vec![
column(1, 1, false, text("1", 0..366)),
column(1, 1, false, text("Chris", 0..366))
]),
row(vec![
column(1, 1, false, text("2", 0..366)),
column(1, 1, false, text("Dennis", 0..366))
]),
],
))],
},
@ -1622,10 +1694,16 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..240,
row(vec![]),
vec![],
vec![
row(vec![text("1", 0..240), text("Chris", 0..240)]),
row(vec![text("2", 0..240), text("Dennis", 0..240)]),
row(vec![
column(1, 1, false, text("1", 0..240)),
column(1, 1, false, text("Chris", 0..240))
]),
row(vec![
column(1, 1, false, text("2", 0..240)),
column(1, 1, false, text("Dennis", 0..240))
]),
],
))],
},
@ -1651,7 +1729,10 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..150,
row(vec![text("Id", 0..150), text("Name", 0..150)]),
vec![row(vec![
column(1, 1, true, text("Id", 0..150)),
column(1, 1, true, text("Name", 0..150))
])],
vec![],
))],
},
@ -1833,7 +1914,10 @@ Some other content
let expected_table = table(
0..48,
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
vec![row(vec![
column(1, 1, true, text("Header 1", 1..11)),
column(1, 1, true, text("Header 2", 12..22)),
])],
vec![],
);
@ -1853,10 +1937,19 @@ Some other content
let expected_table = table(
0..95,
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
vec![row(vec![
column(1, 1, true, text("Header 1", 1..11)),
column(1, 1, true, text("Header 2", 12..22)),
])],
vec![
row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]),
row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]),
row(vec![
column(1, 1, false, text("Cell 1", 49..59)),
column(1, 1, false, text("Cell 2", 60..70)),
]),
row(vec![
column(1, 1, false, text("Cell 3", 73..83)),
column(1, 1, false, text("Cell 4", 84..94)),
]),
],
);
@ -2313,7 +2406,7 @@ fn main() {
fn table(
source_range: Range<usize>,
header: ParsedMarkdownTableRow,
header: Vec<ParsedMarkdownTableRow>,
body: Vec<ParsedMarkdownTableRow>,
) -> ParsedMarkdownTable {
ParsedMarkdownTable {
@ -2324,8 +2417,22 @@ fn main() {
}
}
fn row(children: Vec<MarkdownParagraph>) -> ParsedMarkdownTableRow {
ParsedMarkdownTableRow { children }
fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
ParsedMarkdownTableRow { columns }
}
fn column(
col_span: usize,
row_span: usize,
is_header: bool,
children: MarkdownParagraph,
) -> ParsedMarkdownTableColumn {
ParsedMarkdownTableColumn {
col_span,
row_span,
is_header,
children,
}
}
impl PartialEq for ParsedMarkdownTable {

View file

@ -8,8 +8,8 @@ use fs::normalize_path;
use gpui::{
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
StyledText, TextStyle, WeakEntity, Window, div, img, rems,
Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText,
TextStyle, WeakEntity, Window, div, img, rems,
};
use settings::Settings;
use std::{
@ -19,8 +19,10 @@ use std::{
};
use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
use ui::{
Clickable, FluentBuilder, LinkPreview, StatefulInteractiveElement, StyledExt, StyledImage,
ToggleState, Tooltip, VisibleOnHover, prelude::*, tooltip_container,
ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
h_flex, tooltip_container, v_flex,
};
use workspace::{OpenOptions, OpenVisible, Workspace};
@ -467,132 +469,100 @@ impl gpui::RenderOnce for MarkdownCheckbox {
}
}
fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
paragraphs
.iter()
.map(|paragraph| match paragraph {
MarkdownParagraphChunk::Text(text) => text.contents.len(),
// TODO: Scale column width based on image size
MarkdownParagraphChunk::Image(_) => 1,
})
.sum()
fn calculate_table_columns_count(rows: &Vec<ParsedMarkdownTableRow>) -> usize {
let mut actual_column_count = 0;
for row in rows {
actual_column_count = actual_column_count.max(
row.columns
.iter()
.map(|column| column.col_span)
.sum::<usize>(),
);
}
actual_column_count
}
fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
let actual_header_column_count = calculate_table_columns_count(&parsed.header);
let actual_body_column_count = calculate_table_columns_count(&parsed.body);
let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count);
for (index, cell) in parsed.header.children.iter().enumerate() {
let length = paragraph_len(cell);
max_lengths[index] = length;
}
let total_rows = parsed.header.len() + parsed.body.len();
for row in &parsed.body {
for (index, cell) in row.children.iter().enumerate() {
let length = paragraph_len(cell);
// Track which grid cells are occupied by spanning cells
let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
if index >= max_lengths.len() {
max_lengths.resize(index + 1, length);
let mut cells = Vec::with_capacity(total_rows * max_column_count);
for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() {
let mut col_idx = 0;
for (cell_idx, cell) in row.columns.iter().enumerate() {
// Skip columns occupied by row-spanning cells from previous rows
while col_idx < max_column_count && grid_occupied[row_idx][col_idx] {
col_idx += 1;
}
if length > max_lengths[index] {
max_lengths[index] = length;
if col_idx >= max_column_count {
break;
}
let alignment = parsed
.column_alignments
.get(cell_idx)
.copied()
.unwrap_or_else(|| {
if cell.is_header {
ParsedMarkdownTableAlignment::Center
} else {
ParsedMarkdownTableAlignment::None
}
});
let container = match alignment {
ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
};
let cell_element = container
.col_span(cell.col_span.min(max_column_count - col_idx) as u16)
.row_span(cell.row_span.min(total_rows - row_idx) as u16)
.children(render_markdown_text(&cell.children, cx))
.px_2()
.py_1()
.border_1()
.size_full()
.border_color(cx.border_color)
.when(cell.is_header, |this| {
this.bg(cx.title_bar_background_color)
})
.when(cell.row_span > 1, |this| this.justify_center())
.when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
cells.push(cell_element);
// Mark grid positions as occupied for row-spanning cells
for r in 0..cell.row_span {
for c in 0..cell.col_span {
if row_idx + r < total_rows && col_idx + c < max_column_count {
grid_occupied[row_idx + r][col_idx + c] = true;
}
}
}
col_idx += cell.col_span;
}
}
let total_max_length: usize = max_lengths.iter().sum();
let max_column_widths: Vec<f32> = max_lengths
.iter()
.map(|&length| length as f32 / total_max_length as f32)
.collect();
let header = render_markdown_table_row(
&parsed.header,
&parsed.column_alignments,
&max_column_widths,
true,
0,
cx,
);
let body: Vec<AnyElement> = parsed
.body
.iter()
.enumerate()
.map(|(index, row)| {
render_markdown_table_row(
row,
&parsed.column_alignments,
&max_column_widths,
false,
index,
cx,
)
})
.collect();
div().child(header).children(body).into_any()
}
fn render_markdown_table_row(
parsed: &ParsedMarkdownTableRow,
alignments: &Vec<ParsedMarkdownTableAlignment>,
max_column_widths: &Vec<f32>,
is_header: bool,
row_index: usize,
cx: &mut RenderContext,
) -> AnyElement {
let mut items = Vec::with_capacity(parsed.children.len());
let count = parsed.children.len();
for (index, cell) in parsed.children.iter().enumerate() {
let alignment = alignments
.get(index)
.copied()
.unwrap_or(ParsedMarkdownTableAlignment::None);
let contents = render_markdown_text(cell, cx);
let container = match alignment {
ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
};
let max_width = max_column_widths.get(index).unwrap_or(&0.0);
let mut cell = container
.w(Length::Definite(relative(*max_width)))
.h_full()
.children(contents)
.px_2()
.py_1()
.border_color(cx.border_color)
.border_l_1();
if count == index + 1 {
cell = cell.border_r_1();
}
if is_header {
cell = cell.bg(cx.title_bar_background_color).opacity(0.6)
}
items.push(cell);
}
let mut row = h_flex().border_color(cx.border_color);
if is_header {
row = row.border_y_1();
} else {
row = row.border_b_1();
}
if row_index % 2 == 1 {
row = row.bg(cx.panel_background_color)
}
row.children(items).into_any_element()
cx.with_common_p(div())
.grid()
.size_full()
.grid_cols(max_column_count as u16)
.border_1()
.border_color(cx.border_color)
.children(cells)
.into_any()
}
fn render_markdown_block_quote(
@ -903,3 +873,143 @@ impl Render for InteractiveMarkdownElementTooltip {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown_elements::ParsedMarkdownTableColumn;
use crate::markdown_elements::ParsedMarkdownText;
fn text(text: &str) -> MarkdownParagraphChunk {
MarkdownParagraphChunk::Text(ParsedMarkdownText {
source_range: 0..text.len(),
contents: SharedString::new(text),
highlights: Default::default(),
region_ranges: Default::default(),
regions: Default::default(),
})
}
fn column(
col_span: usize,
row_span: usize,
children: Vec<MarkdownParagraphChunk>,
) -> ParsedMarkdownTableColumn {
ParsedMarkdownTableColumn {
col_span,
row_span,
is_header: false,
children,
}
}
fn column_with_row_span(
col_span: usize,
row_span: usize,
children: Vec<MarkdownParagraphChunk>,
) -> ParsedMarkdownTableColumn {
ParsedMarkdownTableColumn {
col_span,
row_span,
is_header: false,
children,
}
}
#[test]
fn test_calculate_table_columns_count() {
assert_eq!(0, calculate_table_columns_count(&vec![]));
assert_eq!(
1,
calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
column(1, 1, vec![text("column1")])
])])
);
assert_eq!(
2,
calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
column(1, 1, vec![text("column1")]),
column(1, 1, vec![text("column2")]),
])])
);
assert_eq!(
2,
calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
column(2, 1, vec![text("column1")])
])])
);
assert_eq!(
3,
calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
column(1, 1, vec![text("column1")]),
column(2, 1, vec![text("column2")]),
])])
);
assert_eq!(
2,
calculate_table_columns_count(&vec![
ParsedMarkdownTableRow::with_columns(vec![
column(1, 1, vec![text("column1")]),
column(1, 1, vec![text("column2")]),
]),
ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
])
);
assert_eq!(
3,
calculate_table_columns_count(&vec![
ParsedMarkdownTableRow::with_columns(vec![
column(1, 1, vec![text("column1")]),
column(1, 1, vec![text("column2")]),
]),
ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
])
);
}
#[test]
fn test_row_span_support() {
assert_eq!(
3,
calculate_table_columns_count(&vec![
ParsedMarkdownTableRow::with_columns(vec![
column_with_row_span(1, 2, vec![text("spans 2 rows")]),
column(1, 1, vec![text("column2")]),
column(1, 1, vec![text("column3")]),
]),
ParsedMarkdownTableRow::with_columns(vec![
// First column is covered by row span from above
column(1, 1, vec![text("column2 row2")]),
column(1, 1, vec![text("column3 row2")]),
])
])
);
assert_eq!(
4,
calculate_table_columns_count(&vec![
ParsedMarkdownTableRow::with_columns(vec![
column_with_row_span(1, 3, vec![text("spans 3 rows")]),
column_with_row_span(2, 1, vec![text("spans 2 cols")]),
column(1, 1, vec![text("column4")]),
]),
ParsedMarkdownTableRow::with_columns(vec![
// First column covered by row span
column(1, 1, vec![text("column2")]),
column(1, 1, vec![text("column3")]),
column(1, 1, vec![text("column4")]),
]),
ParsedMarkdownTableRow::with_columns(vec![
// First column still covered by row span
column(3, 1, vec![text("spans 3 cols")]),
])
])
);
}
}