ODT: default text colour; covered cells' content stays out of the body

The default paragraph style's text colour (fo:color) is read into the
document's default and written back: Google Docs' grey résumé came back
black. What a covered table cell holds (not shown, §9.1.5) is skipped;
it went before the table as empty paragraphs, pushing ONLYOFFICE's table
down the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-03 07:02:10 +00:00
parent 6f6559aacb
commit ca2cf4e0fd
7 changed files with 67 additions and 2 deletions
+6
View File
@@ -919,6 +919,8 @@ impl Body<'_> {
t.row.push(Cell { covered: true, ..Default::default() });
}
}
// What a covered cell holds isn't shown (§9.1.5): skipped (it went before the table).
self.skip = 1;
}
"frame" => {
let w = get(a, "svg:width").and_then(length);
@@ -1276,6 +1278,10 @@ impl Body<'_> {
if self.skip > 0 {
return;
}
// Not a custom shape's geometry (a covered cell, say): nothing more.
if self.geometry.is_none() && self.geometry_xml.is_none() {
return;
}
let xml = self.geometry_xml.take();
let keep = |x: Option<String>| x.filter(|x| x.len() < MAX_GEOMETRY_XML).map(|x| std::sync::Arc::new(format!("{x}</draw:enhanced-geometry>")));
if let Some(g) = self.geometry.take()
+7
View File
@@ -441,6 +441,8 @@ struct Parsed {
font: Option<String>,
/// The document's default font size, points.
size: Option<f32>,
/// The document's default text colour.
color: Option<wordcraft_doc::props::Rgb>,
/// The custom paragraph styles the paragraphs use, parents included, parents first.
named: Vec<super::styles::NamedStyle>,
/// Their ODF names, alongside.
@@ -618,6 +620,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
track,
font: styles.doc_font(),
size: styles.doc_size(),
color: styles.doc_color(),
spacing: styles.doc_spacing(),
looks: styles.kind_looks(),
named,
@@ -665,6 +668,7 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
track,
font,
size,
color,
spacing,
looks,
named,
@@ -705,6 +709,9 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
if size.is_some() {
doc.styles.default_chr.size = size;
}
if let Some(c) = color {
doc.styles.default_chr.color = Some(wordcraft_doc::props::TextColor::Rgb(c));
}
doc.styles.default_chr.kern = kern.then_some(1.0);
doc.styles.default_chr.overlay(&scripts);
if let Some(t) = default_tab {
+6 -1
View File
@@ -341,13 +341,18 @@ fn styles_xml(
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
s.push_str(&format!(
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{scripts}/></style:default-style>",
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
spacing_props(defaults),
x(&body_font),
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
// when unsaid: see `OdfProfile::widow_control`).
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
size = pt(size),
// The document's text colour (a colour, not automatic).
color = match doc.styles.default_chr.color {
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
_ => String::new(),
},
tab = pt(tab),
scripts = super::writer::script_attrs(&doc.styles.default_chr)
));
+9
View File
@@ -1042,6 +1042,8 @@ pub(super) struct Styles {
pub(super) default_kern: bool,
/// Widow and orphan control `style:default-style` for paragraphs states, if it does.
default_widows: Option<bool>,
/// The text colour of `style:default-style` for paragraphs (`fo:color`), if it gives one.
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// The named dashes and gradients graphic styles use.
@@ -1092,6 +1094,7 @@ impl Styles {
self.default_size = Some(sz.min(1638.0));
}
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
script_properties(&a, &mut self.default_scripts, &self.faces);
}
"paragraph-properties" if in_default && cur.is_none() => {
@@ -1173,6 +1176,12 @@ impl Styles {
/// The document's default font size: the Standard paragraph style's (the document's
/// defaults, as for [`Styles::doc_font`]), else the default style's.
/// The document's default text colour: the Standard paragraph style's, else the default
/// style's.
pub(super) fn doc_color(&self) -> Option<wordcraft_doc::props::Rgb> {
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.color).or(self.default_color)
}
pub(super) fn doc_size(&self) -> Option<f32> {
self.map
.get("Standard")
+22
View File
@@ -44,6 +44,28 @@ fn default_paragraph_style_font_becomes_document_font() {
assert_eq!(doc.styles.default_chr.font.as_deref(), Some("DejaVu Serif"));
}
#[test]
fn the_default_text_colour_round_trips() {
// Google Docs' résumé template greys all its text in the default style; it came back black.
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hi</text:p></office:text></office:body></office:document-content>"#
);
let styles = format!(
r##"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:color="#666666"/></style:default-style></office:styles></office:document-styles>"##
);
let grey = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0x66, 0x66, 0x66)));
let doc = import(&package(&content, Some(&styles))).unwrap();
assert_eq!(doc.styles.default_chr.color, grey);
let back = import(&export(&doc).unwrap()).unwrap();
assert_eq!(back.styles.default_chr.color, grey);
// None stated: none written (automatic).
let plain = import(&package(&content, None)).unwrap();
let styles = entry(&export(&plain).unwrap(), "styles.xml");
let default =
styles.split("<style:default-style style:family=\"paragraph\">").nth(1).and_then(|s| s.split("</style:default-style>").next()).unwrap();
assert!(!default.contains("fo:color"), "{default}");
}
#[test]
fn rejects_garbage() {
assert!(parse(b"not a zip").is_err());
@@ -176,3 +176,18 @@ fn a_cells_background_picture_is_read_drawn_and_written() {
let again = cells(&back)[0].picture.clone().expect("kept");
assert_eq!((again.repeat, again.align, again.size), (pic.repeat, pic.align, pic.size));
}
#[test]
fn what_a_covered_cell_holds_stays_out_of_the_document() {
// ONLYOFFICE writes paragraphs into covered cells (§9.1.5: not shown); they went before the
// table as empty paragraphs, pushing it down the page.
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>wide</text:p></table:table-cell><table:covered-table-cell><text:p>hidden</text:p><text:list><text:list-item><text:p/></text:list-item></text:list></table:covered-table-cell></table:table-row><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table><text:p>after</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, None)).unwrap();
assert!(matches!(&*d.body[0], Block::Table(_)), "the table comes first");
assert_eq!(d.body.len(), 2);
assert_eq!(d.body[1].as_para().unwrap().text, "after");
let Block::Table(t) = &*d.body[0] else { panic!() };
assert_eq!(t.rows[0].cells[0].props.span, 2);
}
File diff suppressed because one or more lines are too long