From c60d9df0c32fd47bf04948073420b21f3c19fa8f Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 12:21:25 +0530 Subject: [PATCH] fix(formats): support table cell vertical alignment in rtf, html, and odt --- crates/formats/src/html.rs | 47 +++++++++++++++++++++++++- crates/formats/src/model.rs | 16 +++++++-- crates/formats/src/odt.rs | 67 ++++++++++++++++++++++++++++++------- crates/formats/src/rtf.rs | 26 ++++++++++++++ 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 91d328623..56093f471 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -13,6 +13,7 @@ use std::sync::Arc; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ @@ -1257,6 +1258,26 @@ impl<'r> Builder<'r> { } let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63)); let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background); + let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }); + if let Some(st) = attr(attrs, "style") { + for decl in st.split(';') { + if let Some((k, v)) = decl.split_once(':') { + if k.trim().eq_ignore_ascii_case("vertical-align") { + match v.trim().to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} + } + } + } + } + } self.cells.push(Cell { blocks: Vec::new(), colspan: num("colspan").unwrap_or(1), @@ -1265,6 +1286,7 @@ impl<'r> Builder<'r> { header: name == "th", shading, borders: None, + valign, }); fmt.background = None; self.containers.push(Vec::new()); @@ -1617,7 +1639,12 @@ fn table_html(t: &FTable, out: &mut String, depth: usize) { if c.rowspan > 1 { attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan)); } - let mut css = vec!["padding:4px 6px".to_string(), "vertical-align:top".to_string()]; + let v_css = match c.valign { + Some(VAlign::Center) => "vertical-align:middle", + Some(VAlign::Bottom) => "vertical-align:bottom", + _ => "vertical-align:top", + }; + let mut css = vec!["padding:4px 6px".to_string(), v_css.to_string()]; if !t.borderless { css.insert(0, "border:1px solid #999".to_string()); } @@ -2036,4 +2063,22 @@ mod tests { }; assert_eq!(p.text(), "World"); } + + #[test] + fn table_cell_vertical_alignment() { + let f = parse("
topmidbot
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Top)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() }; + assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let html = export(&doc); + assert!(html.contains("vertical-align:top")); + assert!(html.contains("vertical-align:middle")); + assert!(html.contains("vertical-align:bottom")); + } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c2502f2e4..0a6cfd59c 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for}; use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, Run}; use wordcraft_doc::props::{ - Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VMerge, VertAlign, + Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind}; @@ -242,11 +242,12 @@ pub struct Cell { pub shading: Option, /// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style. pub borders: Option, + pub valign: Option, } impl Default for Cell { fn default() -> Self { - Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None } + Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None } } } @@ -693,7 +694,15 @@ impl<'a> Builder<'a> { VMerge::None }; row.cells.push(wordcraft_doc::Cell { - props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, borders: c.borders, ..Default::default() }, + props: CellProps { + width: Some(w), + span: span as u32, + vmerge, + shading: c.shading, + borders: c.borders, + valign: c.valign, + ..Default::default() + }, blocks, controls: Default::default(), }); @@ -1048,6 +1057,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable header, shading: c.props.shading, borders: c.props.borders, + valign: c.props.valign, }); } rows.push(cells); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 86ee6acee..477da63c2 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -22,6 +22,7 @@ use std::io::{Cursor, Read, Write}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; +use wordcraft_doc::props::VAlign; use wordcraft_doc::section::{Columns, SectionStart}; use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; @@ -103,8 +104,8 @@ struct Writer { last_list: Option, /// The page the block being written starts. master: Option, - /// Cell styles by shading. - cell_styles: Vec>, + /// Cell styles by (shading, vertical-alignment). + cell_styles: Vec<(Option, Option)>, pictures: Vec<(String, std::sync::Arc>, String)>, /// `text:changed-region` elements written so far. regions: Vec, @@ -574,11 +575,12 @@ impl Writer { } } - fn cell_style(&mut self, shading: Option) -> String { - let k = match self.cell_styles.iter().position(|s| *s == shading) { + fn cell_style(&mut self, shading: Option, valign: Option) -> String { + let key = (shading, valign); + let k = match self.cell_styles.iter().position(|s| *s == key) { Some(k) => k, None => { - self.cell_styles.push(shading); + self.cell_styles.push(key); self.cell_styles.len() - 1 } }; @@ -616,7 +618,7 @@ impl Writer { } continue; } - let st = self.cell_style(c.shading); + let st = self.cell_style(c.shading, c.valign); let mut attrs = format!(" table:style-name=\"{st}\" office:value-type=\"string\""); if span > 1 { attrs.push_str(&format!(" table:number-columns-spanned=\"{span}\"")); @@ -699,10 +701,16 @@ impl Writer { )); } } - for (i, sh) in self.cell_styles.iter().enumerate() { + for (i, (sh, va)) in self.cell_styles.iter().enumerate() { let bg = sh.map(|c| format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())).unwrap_or_default(); + let va_str = match va { + Some(VAlign::Top) => " style:vertical-align=\"top\"", + Some(VAlign::Center) => " style:vertical-align=\"middle\"", + Some(VAlign::Bottom) => " style:vertical-align=\"bottom\"", + None => "", + }; s.push_str(&format!( - "", + "", i + 1 )); } @@ -1061,6 +1069,8 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// `style:vertical-align` of a table-cell style's `style:table-cell-properties`. + cell_valign: Option, /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. master_page: Option, @@ -1263,10 +1273,18 @@ impl Styles { } } "table-cell-properties" => { - if let Some((_, s)) = &mut cur - && let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) - { - s.cell_background = Some(c); + if let Some((_, s)) = &mut cur { + if let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) { + s.cell_background = Some(c); + } + if let Some(va) = get(&a, "style:vertical-align") { + s.cell_valign = match va.trim() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }; + } } } "paragraph-properties" => { @@ -1388,6 +1406,11 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.cell_background) } + /// Vertical alignment of a table-cell style (whole chain). + fn cell_valign(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.cell_valign) + } + /// Paragraph kind, alignment, page break, rule, keep-with-next and base formatting. The text formatting of /// every style in the chain applies (automatic and common, ODF 1.3 §16.2 inheritance), except /// the style that gives the paragraph its kind and that style's ancestors: their formatting @@ -1914,10 +1937,11 @@ impl Body<'_> { let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 63); let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1000); let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s)); + let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s)); if let Some(t) = self.tables.last_mut() { t.hcover = span as usize - 1; } - self.cells.push(Cell { colspan: span, rowspan: rows, shading, ..Default::default() }); + self.cells.push(Cell { colspan: span, rowspan: rows, shading, valign, ..Default::default() }); self.containers.push(Vec::new()); self.lists_saved.push((std::mem::take(&mut self.lists), std::mem::take(&mut self.item_used))); } @@ -2793,6 +2817,23 @@ mod tests { assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]); } + #[test] + fn table_cell_style_vertical_alignment() { + let content = format!( + r##"MidBot"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let bytes = export(&doc).unwrap(); + let f2 = parse(&bytes).unwrap(); + let Some(FBlock::Table(t2)) = f2.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table in export") }; + assert_eq!(t2.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t2.rows[0][1].valign, Some(VAlign::Bottom)); + } + /// #1379: three master pages (A4 upright; A4 wide with page numbering restarting at 1; A4 /// upright again with numbering continuing) and a two-column `text:section` in between. fn sections_package() -> Vec { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 3a84c6812..ff6db934d 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document, Rgb}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img}; @@ -598,6 +599,12 @@ impl Writer { } else if c.rowspan > 1 { out.push_str("\\clvmgf"); } + match c.valign { + Some(VAlign::Top) => out.push_str("\\clvertalt"), + Some(VAlign::Center) => out.push_str("\\clvertalc"), + Some(VAlign::Bottom) => out.push_str("\\clvertalb"), + None => {} + } self.cell_borders(c.borders.as_ref(), t.borderless, out); if let Some(s) = c.shading { let i = self.t.color(s); @@ -974,6 +981,7 @@ struct CellDef { /// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw` /// width and `\brdrcf` colour). borders: wordcraft_doc::props::Borders, + valign: Option, } /// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms). @@ -1631,6 +1639,7 @@ impl Reader { c.shading = d.shading; c.borders = d.borders.any_visible().then_some(d.borders); c.header = self.row_header; + c.valign = d.valign; match d.vmerge { 1 => c.rowspan = 2, 2 => c.covered = true, @@ -2042,6 +2051,9 @@ impl Reader { "clvmrg" => self.def_cur.vmerge = 2, "clmgf" => self.def_cur.hmerge = 1, "clmrg" => self.def_cur.hmerge = 2, + "clvertalt" => self.def_cur.valign = Some(VAlign::Top), + "clvertalc" => self.def_cur.valign = Some(VAlign::Center), + "clvertalb" => self.def_cur.valign = Some(VAlign::Bottom), "clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(), "sb" => self.st.sb = Some(pv), "sa" => self.st.sa = Some(pv), @@ -3793,4 +3805,18 @@ mod tests { assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into())); assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into())); } + + #[test] + fn table_cell_vertical_alignment() { + let rtf = br"{\rtf1\ansi\trowd\clvertalt\cellx1000\clvertalc\cellx2000\clvertalb\cellx3000\intbl top\cell center\cell bottom\cell\row}"; + let doc = import(rtf).unwrap(); + let wordcraft_doc::Block::Table(t) = &*doc.body[0] else { panic!() }; + assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(t.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(t.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let exported = export(&doc); + assert!(exported.contains("\\clvertalt")); + assert!(exported.contains("\\clvertalc")); + assert!(exported.contains("\\clvertalb")); + } }