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 committed 2026-11-03 07:02:10 +00:00
1 parent 6f6559aacb
commit ca2cf4e0fd
7 files changed
+67 -2

No files matched your search

+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);
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequenceLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a foLine truncated
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
| 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) |
| 2026-11-03 | trivial | ODT, found with Google Docs' and ONLYOFFICE's files: the default paragraph style's text colour (`fo:color`, Google's grey résumé) is read into the document's default and written back (it came back black; round trip 9.2k → 3.4k differing pixels); what a covered table cell holds (§9.1.5: not shown) is skipped instead of landing before the table as empty paragraphs, which pushed ONLYOFFICE's table down the page. |
| 2026-11-03 | trivial | ODT: widow and orphan control a file doesn't state is a profile case (`widowControl`: WordCraft on, as XSL's initial value; LibreOffice off, as it reads such files), and WordCraft writes it on its default style either way. Tab stops measured from the indent (LibreOffice's `TabsRelativeToIndent`, its default) now count the default stops from the indent too, as measured in LibreOffice, and such a file is written back measuring them so; it was written from the margin, which moved LibreOffice's default stops in every indented paragraph. |
| 2026-11-03 | trivial | ODT, found with ONLYOFFICE's converter snapshots: kerning turned on or off span by span (`style:letter-kerning`, §20.317) is read and written where it differs from the document's default (without it LibreOffice set the text wider and every page of a 10-page file broke elsewhere after a save); a note reference keeps its own formatting (a span around `text:note`); a note starting with a tab keeps it (only the space WordCraft puts after the mark is left out). The 10-page RTF-derived snapshot's round trip went from 333k to 279k differing pixels, its DOCX-derived twin from 5.3k to 2.2k with the same text. |
| 2026-11-03 | trivial | ODT fixed fields (§19.807 `text:fixed`) open as locked fields and are written back fixed, a date or time with the value its text shows under its picture (`text:date-value`, `text:time-value`, which LibreOffice needs to keep it); one whose text can't be read back is written as its text. Sender fields (§7.3.6 `text:sender-*`) open as `SENDER` fields and are written back as themselves. |