From d5580050582a91bd2af9f7c4355c6a5b917bfa99 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Wed, 22 Oct 2025 19:10:37 +0200 Subject: [PATCH] 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** 499166907-385cc787-fc89-4e6d-bf06-c72c3c0bd775 **After** Screenshot 2025-10-21 at 22 51 55 ```html
Region Revenue Growth
Q2 2024 Q3 2024
North America $2.8M $2.4B +85,614%
Europe $1.2M $1.9B +158,233%
Asia-Pacific $0.5M $1.4B +279,900%
``` **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 Co-authored-by: Anthony Eid --- .../markdown_preview/src/markdown_elements.rs | 23 +- .../markdown_preview/src/markdown_parser.rs | 175 +++++++-- .../markdown_preview/src/markdown_renderer.rs | 334 ++++++++++++------ 3 files changed, 379 insertions(+), 153 deletions(-) diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index d0bde48889143b8ab9f66d9dc2839ebabf7d3541..b0a36a4cf29c386204f6fd1a347a839009e1c357 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -104,25 +104,34 @@ pub enum HeadingLevel { #[derive(Debug)] pub struct ParsedMarkdownTable { pub source_range: Range, - pub header: ParsedMarkdownTableRow, + pub header: Vec, pub body: Vec, pub column_alignments: Vec, } -#[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, + pub columns: Vec, } 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) -> Self { - Self { children } + pub fn with_columns(columns: Vec) -> Self { + Self { columns } } } diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index ad175922daaa20508852f36dd4f62ad90199b7ca..28388923a75f14c601dcafecb2008570e309561f 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -462,9 +462,9 @@ impl<'a> MarkdownParser<'a> { fn parse_table(&mut self, alignment: Vec) -> 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, + node: &Rc, + ) -> Option { + 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, + node: &Rc, + ) -> Option { + 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, @@ -1056,7 +1124,7 @@ impl<'a> MarkdownParser<'a> { node: &Rc, source_range: Range, ) -> Option { - 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, - header: ParsedMarkdownTableRow, + header: Vec, body: Vec, ) -> ParsedMarkdownTable { ParsedMarkdownTable { @@ -2324,8 +2417,22 @@ fn main() { } } - fn row(children: Vec) -> ParsedMarkdownTableRow { - ParsedMarkdownTableRow { children } + fn row(columns: Vec) -> 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 { diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index d3768ca99449e820f6c7b457ce93eb886511340f..0abb12015af317702ff3afd853eab74a40817941 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -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) -> 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::(), + ); + } + actual_column_count } fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { - let mut max_lengths: Vec = 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); - } - - if length > max_lengths[index] { - max_lengths[index] = length; - } - } - } + let mut cells = Vec::with_capacity(total_rows * max_column_count); - let total_max_length: usize = max_lengths.iter().sum(); - let max_column_widths: Vec = max_lengths - .iter() - .map(|&length| length as f32 / total_max_length as f32) - .collect(); + for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() { + let mut col_idx = 0; - let header = render_markdown_table_row( - &parsed.header, - &parsed.column_alignments, - &max_column_widths, - true, - 0, - cx, - ); - - let body: Vec = parsed - .body - .iter() - .enumerate() - .map(|(index, row)| { - render_markdown_table_row( - row, - &parsed.column_alignments, - &max_column_widths, - false, - index, - cx, - ) - }) - .collect(); + 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; + } - div().child(header).children(body).into_any() -} + if col_idx >= max_column_count { + break; + } -fn render_markdown_table_row( - parsed: &ParsedMarkdownTableRow, - alignments: &Vec, - max_column_widths: &Vec, - is_header: bool, - row_index: usize, - cx: &mut RenderContext, -) -> AnyElement { - let mut items = Vec::with_capacity(parsed.children.len()); - let count = parsed.children.len(); + let alignment = parsed + .column_alignments + .get(cell_idx) + .copied() + .unwrap_or_else(|| { + if cell.is_header { + ParsedMarkdownTableAlignment::Center + } else { + ParsedMarkdownTableAlignment::None + } + }); - for (index, cell) in parsed.children.iter().enumerate() { - let alignment = alignments - .get(index) - .copied() - .unwrap_or(ParsedMarkdownTableAlignment::None); + let container = match alignment { + ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), + ParsedMarkdownTableAlignment::Center => v_flex().items_center(), + ParsedMarkdownTableAlignment::Right => v_flex().items_end(), + }; - let contents = render_markdown_text(cell, cx); + 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)); - let container = match alignment { - ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), - ParsedMarkdownTableAlignment::Center => v_flex().items_center(), - ParsedMarkdownTableAlignment::Right => v_flex().items_end(), - }; + cells.push(cell_element); - 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(); - } + // 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; + } + } + } - if is_header { - cell = cell.bg(cx.title_bar_background_color).opacity(0.6) + col_idx += cell.col_span; } - - 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, + ) -> ParsedMarkdownTableColumn { + ParsedMarkdownTableColumn { + col_span, + row_span, + is_header: false, + children, + } + } + + fn column_with_row_span( + col_span: usize, + row_span: usize, + children: Vec, + ) -> 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")]), + ]) + ]) + ); + } +}