fix(formats): support table cell vertical alignment in rtf, html, and odt

This commit is contained in:
Divyanshu Gupta
2026-10-21 18:35:08 +05:30
parent d588e0b873
commit c60d9df0c3
4 changed files with 139 additions and 17 deletions
+46 -1
View File
@@ -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::<u32>().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("<table><tr><td valign=top>top</td><td valign=\"center\">mid</td><td style=\"vertical-align: bottom\">bot</td></tr></table>");
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"));
}
}
+13 -3
View File
@@ -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<Rgb>,
/// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style.
pub borders: Option<wordcraft_doc::props::Borders>,
pub valign: Option<VAlign>,
}
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);
+54 -13
View File
@@ -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<String>,
/// The page the block being written starts.
master: Option<SecStart>,
/// Cell styles by shading.
cell_styles: Vec<Option<Rgb>>,
/// Cell styles by (shading, vertical-alignment).
cell_styles: Vec<(Option<Rgb>, Option<VAlign>)>,
pictures: Vec<(String, std::sync::Arc<Vec<u8>>, String)>,
/// `text:changed-region` elements written so far.
regions: Vec<String>,
@@ -574,11 +575,12 @@ impl Writer {
}
}
fn cell_style(&mut self, shading: Option<Rgb>) -> String {
let k = match self.cell_styles.iter().position(|s| *s == shading) {
fn cell_style(&mut self, shading: Option<Rgb>, valign: Option<VAlign>) -> 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!(
"<style:style style:name=\"Cell{}\" style:family=\"table-cell\"><style:table-cell-properties fo:padding=\"4pt\" fo:border=\"0.5pt solid #000000\"{bg}/></style:style>",
"<style:style style:name=\"Cell{}\" style:family=\"table-cell\"><style:table-cell-properties fo:padding=\"4pt\" fo:border=\"0.5pt solid #000000\"{bg}{va_str}/></style:style>",
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<Rgb>,
/// `style:vertical-align` of a table-cell style's `style:table-cell-properties`.
cell_valign: Option<VAlign>,
/// `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<String>,
@@ -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<VAlign> {
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::<u32>().ok()).unwrap_or(1).clamp(1, 63);
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().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##"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="MidCell" style:family="table-cell"><style:table-cell-properties style:vertical-align="middle"/></style:style><style:style style:name="BotCell" style:family="table-cell"><style:table-cell-properties style:vertical-align="bottom"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell table:style-name="MidCell"><text:p>Mid</text:p></table:table-cell><table:table-cell table:style-name="BotCell"><text:p>Bot</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"##
);
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<u8> {
+26
View File
@@ -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<VAlign>,
}
/// 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"));
}
}