markdown: Add support for `HTML` table column `align` attribute (#41163)

Remco Smits created

This PR allows you to define `align="right"` for example to change the
default alignment on **HTML** table columns. This PR also refactors
where we store the alignments in order to make it so you can define it
column based instead of only row based.

See that the `Revenue` column is left aligned instead of the default
`centered`.

**Result**

<img width="1161" height="177" alt="Screenshot 2025-10-25 at 11 01 38"
src="https://github.com/user-attachments/assets/94bda4f0-00c1-4726-a3bd-99b3f2573ef5"
/>


**Code example**

```HTML
<table>
    <tr>
        <th rowspan="2">Region</th>
        <th colspan="2" align="left">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>
```

Release Notes:

- markdown preview: Add support for `HTML` table column `align`
attribute

Change summary

crates/markdown_preview/src/markdown_elements.rs |   2 
crates/markdown_preview/src/markdown_parser.rs   | 192 +++++++++++++++--
crates/markdown_preview/src/markdown_renderer.rs |  18 -
3 files changed, 172 insertions(+), 40 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -106,7 +106,6 @@ pub struct ParsedMarkdownTable {
     pub source_range: Range<usize>,
     pub header: Vec<ParsedMarkdownTableRow>,
     pub body: Vec<ParsedMarkdownTableRow>,
-    pub column_alignments: Vec<ParsedMarkdownTableAlignment>,
 }
 
 #[derive(Debug, Clone, Copy, Default)]
@@ -126,6 +125,7 @@ pub struct ParsedMarkdownTableColumn {
     pub row_span: usize,
     pub is_header: bool,
     pub children: MarkdownParagraph,
+    pub alignment: ParsedMarkdownTableAlignment,
 }
 
 #[derive(Debug)]

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -466,7 +466,10 @@ impl<'a> MarkdownParser<'a> {
         let mut body = vec![];
         let mut row_columns = vec![];
         let mut in_header = true;
-        let column_alignments = alignment.iter().map(Self::convert_alignment).collect();
+        let column_alignments = alignment
+            .iter()
+            .map(Self::convert_alignment)
+            .collect::<Vec<_>>();
 
         loop {
             if self.eof() {
@@ -489,6 +492,10 @@ impl<'a> MarkdownParser<'a> {
                         row_span: 1,
                         is_header: in_header,
                         children: cell_contents,
+                        alignment: column_alignments
+                            .get(row_columns.len())
+                            .copied()
+                            .unwrap_or_default(),
                     });
                 }
                 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
@@ -515,7 +522,6 @@ impl<'a> MarkdownParser<'a> {
             source_range,
             header,
             body,
-            column_alignments,
         }
     }
 
@@ -988,6 +994,8 @@ impl<'a> MarkdownParser<'a> {
                 let mut children = MarkdownParagraph::new();
                 self.consume_paragraph(source_range, node, &mut children);
 
+                let is_header = matches!(name.local, local_name!("th"));
+
                 Some(ParsedMarkdownTableColumn {
                     col_span: std::cmp::max(
                         Self::attr_value(attrs, local_name!("colspan"))
@@ -1001,8 +1009,22 @@ impl<'a> MarkdownParser<'a> {
                             .unwrap_or(1),
                         1,
                     ),
-                    is_header: matches!(name.local, local_name!("th")),
+                    is_header,
                     children,
+                    alignment: Self::attr_value(attrs, local_name!("align"))
+                        .and_then(|align| match align.as_str() {
+                            "left" => Some(ParsedMarkdownTableAlignment::Left),
+                            "center" => Some(ParsedMarkdownTableAlignment::Center),
+                            "right" => Some(ParsedMarkdownTableAlignment::Right),
+                            _ => None,
+                        })
+                        .unwrap_or_else(|| {
+                            if is_header {
+                                ParsedMarkdownTableAlignment::Center
+                            } else {
+                                ParsedMarkdownTableAlignment::default()
+                            }
+                        }),
                 })
             }
             _ => None,
@@ -1155,7 +1177,6 @@ impl<'a> MarkdownParser<'a> {
             Some(ParsedMarkdownTable {
                 source_range,
                 body: body_rows,
-                column_alignments: Vec::default(),
                 header: header_rows,
             })
         } else {
@@ -1653,17 +1674,53 @@ mod tests {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..366,
                     vec![row(vec![
-                        column(1, 1, true, text("Id", 0..366)),
-                        column(1, 1, true, text("Name ", 0..366))
+                        column(
+                            1,
+                            1,
+                            true,
+                            text("Id", 0..366),
+                            ParsedMarkdownTableAlignment::Center
+                        ),
+                        column(
+                            1,
+                            1,
+                            true,
+                            text("Name ", 0..366),
+                            ParsedMarkdownTableAlignment::Center
+                        )
                     ])],
                     vec![
                         row(vec![
-                            column(1, 1, false, text("1", 0..366)),
-                            column(1, 1, false, text("Chris", 0..366))
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("1", 0..366),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Chris", 0..366),
+                                ParsedMarkdownTableAlignment::None
+                            )
                         ]),
                         row(vec![
-                            column(1, 1, false, text("2", 0..366)),
-                            column(1, 1, false, text("Dennis", 0..366))
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("2", 0..366),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Dennis", 0..366),
+                                ParsedMarkdownTableAlignment::None
+                            )
                         ]),
                     ],
                 ))],
@@ -1697,12 +1754,36 @@ mod tests {
                     vec![],
                     vec![
                         row(vec![
-                            column(1, 1, false, text("1", 0..240)),
-                            column(1, 1, false, text("Chris", 0..240))
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("1", 0..240),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Chris", 0..240),
+                                ParsedMarkdownTableAlignment::None
+                            )
                         ]),
                         row(vec![
-                            column(1, 1, false, text("2", 0..240)),
-                            column(1, 1, false, text("Dennis", 0..240))
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("2", 0..240),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Dennis", 0..240),
+                                ParsedMarkdownTableAlignment::None
+                            )
                         ]),
                     ],
                 ))],
@@ -1730,8 +1811,20 @@ mod tests {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..150,
                     vec![row(vec![
-                        column(1, 1, true, text("Id", 0..150)),
-                        column(1, 1, true, text("Name", 0..150))
+                        column(
+                            1,
+                            1,
+                            true,
+                            text("Id", 0..150),
+                            ParsedMarkdownTableAlignment::Center
+                        ),
+                        column(
+                            1,
+                            1,
+                            true,
+                            text("Name", 0..150),
+                            ParsedMarkdownTableAlignment::Center
+                        )
                     ])],
                     vec![],
                 ))],
@@ -1915,8 +2008,20 @@ Some other content
         let expected_table = table(
             0..48,
             vec![row(vec![
-                column(1, 1, true, text("Header 1", 1..11)),
-                column(1, 1, true, text("Header 2", 12..22)),
+                column(
+                    1,
+                    1,
+                    true,
+                    text("Header 1", 1..11),
+                    ParsedMarkdownTableAlignment::None,
+                ),
+                column(
+                    1,
+                    1,
+                    true,
+                    text("Header 2", 12..22),
+                    ParsedMarkdownTableAlignment::None,
+                ),
             ])],
             vec![],
         );
@@ -1938,17 +2043,53 @@ Some other content
         let expected_table = table(
             0..95,
             vec![row(vec![
-                column(1, 1, true, text("Header 1", 1..11)),
-                column(1, 1, true, text("Header 2", 12..22)),
+                column(
+                    1,
+                    1,
+                    true,
+                    text("Header 1", 1..11),
+                    ParsedMarkdownTableAlignment::None,
+                ),
+                column(
+                    1,
+                    1,
+                    true,
+                    text("Header 2", 12..22),
+                    ParsedMarkdownTableAlignment::None,
+                ),
             ])],
             vec![
                 row(vec![
-                    column(1, 1, false, text("Cell 1", 49..59)),
-                    column(1, 1, false, text("Cell 2", 60..70)),
+                    column(
+                        1,
+                        1,
+                        false,
+                        text("Cell 1", 49..59),
+                        ParsedMarkdownTableAlignment::None,
+                    ),
+                    column(
+                        1,
+                        1,
+                        false,
+                        text("Cell 2", 60..70),
+                        ParsedMarkdownTableAlignment::None,
+                    ),
                 ]),
                 row(vec![
-                    column(1, 1, false, text("Cell 3", 73..83)),
-                    column(1, 1, false, text("Cell 4", 84..94)),
+                    column(
+                        1,
+                        1,
+                        false,
+                        text("Cell 3", 73..83),
+                        ParsedMarkdownTableAlignment::None,
+                    ),
+                    column(
+                        1,
+                        1,
+                        false,
+                        text("Cell 4", 84..94),
+                        ParsedMarkdownTableAlignment::None,
+                    ),
                 ]),
             ],
         );
@@ -2410,7 +2551,6 @@ fn main() {
         body: Vec<ParsedMarkdownTableRow>,
     ) -> ParsedMarkdownTable {
         ParsedMarkdownTable {
-            column_alignments: Vec::new(),
             source_range,
             header,
             body,
@@ -2426,12 +2566,14 @@ fn main() {
         row_span: usize,
         is_header: bool,
         children: MarkdownParagraph,
+        alignment: ParsedMarkdownTableAlignment,
     ) -> ParsedMarkdownTableColumn {
         ParsedMarkdownTableColumn {
             col_span,
             row_span,
             is_header,
             children,
+            alignment,
         }
     }
 

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -497,7 +497,7 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
     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() {
+        for cell in row.columns.iter() {
             // 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;
@@ -507,19 +507,7 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
                 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 {
+            let container = match cell.alignment {
                 ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
                 ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
                 ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
@@ -917,6 +905,7 @@ mod tests {
             row_span,
             is_header: false,
             children,
+            alignment: ParsedMarkdownTableAlignment::None,
         }
     }
 
@@ -930,6 +919,7 @@ mod tests {
             row_span,
             is_header: false,
             children,
+            alignment: ParsedMarkdownTableAlignment::None,
         }
     }