M5 #19: unstated page margins are an ODF interpretation setting

ODF 1.3 §20.206–20.209 define page margins by referring to XSL's
(§7.10), without saying whether XSL's initial value 0 comes with the
reference; LibreOffice uses 2 cm. The margin a page layout leaves out
is now the profile's `pageMargin`: WordCraft 0 (XSL's), LibreOffice
2 cm, or custom (File › Options › Advanced › OpenDocument files,
`file.options` `odf.pageMargin`). A stated margin is the file's under
every preset. With the LibreOffice preset, LibreOffice's CV, sans-serif
business letter and one-page résumé templates match LibreOffice again.

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-02 22:31:49 +00:00
1 parent ffa309c266
commit 7b25253286
9 files changed
+57 -32

No files matched your search

+1 -1
View File
@@ -140,7 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses WordCraft's own behaviour (the default), LibreOffice's, or custom values for each of eleven cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins now has XSL's 0, as the standard says. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet.
- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses WordCraft's own behaviour (the default), LibreOffice's, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 for WordCraft (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet.
- 2026-11-02: ODT form controls keep their place on the line and multi-line text fields their lines; custom shapes keep their own geometry (LibreOffice draws its own shape after a save). In-line pictures placed against the text are raised from its size, and saved standing on the baseline. The corpus round-trip difference falls to 41k pixels, 90% below this morning's 408k; 42 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
- 2026-11-02: ODT charts keep their whole look through a save: the chart object as read is written back while the chart is unchanged (edited, it's written from WordCraft's chart). Frames outlined by a drawn stroke keep it, and a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls to 47k pixels (408k this morning); 41 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
- 2026-11-02: ODT: saving no longer makes a table's first row repeat on every page (a table style's header row had been taken for Repeat as header row), so LibreOffice's 16 agenda templates round-trip with their text the same; 91 of the 125 corpus documents now do, and the round-trip difference falls to 60k pixels (408k this morning) (gap 19). Readiness unchanged.
+7
View File
@@ -51,6 +51,10 @@ pub const MAX_LENGTH: f32 = 1584.0;
#[serde(default, rename_all = "camelCase")]
pub struct OdfProfile {
pub preset: OdfPreset,
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
/// the standard doesn't say (LibreOffice uses 2 cm).
pub page_margin: f32,
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
/// has no default), points.
pub header_min_height: f32,
@@ -100,6 +104,7 @@ impl OdfProfile {
pub fn wordcraft() -> OdfProfile {
OdfProfile {
preset: OdfPreset::WordCraft,
page_margin: 0.0,
header_min_height: 0.0,
header_height_includes_spacing: false,
dynamic_spacing: false,
@@ -118,6 +123,7 @@ impl OdfProfile {
pub fn libreoffice() -> OdfProfile {
OdfProfile {
preset: OdfPreset::LibreOffice,
page_margin: 2.0 * 72.0 / 2.54,
header_min_height: 0.5 * 72.0 / 2.54,
header_height_includes_spacing: true,
dynamic_spacing: false,
@@ -146,6 +152,7 @@ impl OdfProfile {
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
match self.preset {
OdfPreset::Custom => OdfProfile {
page_margin: length(self.page_margin, 0.0),
header_min_height: length(self.header_min_height, 0.0),
column_gap: length(self.column_gap, 0.0),
hairline: length(self.hairline, 0.05),
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "wordcraft|libreoffice|custom", "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "wordcraft|libreoffice|custom", "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
+12 -15
View File
@@ -474,10 +474,6 @@ fn hyphenation(blocks: &mut [FBlock]) -> bool {
/// The content of a package that has none: an empty text.
const EMPTY_CONTENT: &str = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p/></office:text></office:body></office:document-content>"#;
/// The margin a page layout leaves unsaid: XSL's initial value, 0 (ODF 1.3 §20.206–20.209 are
/// XSL's §7.10 margins; LibreOffice's own pages have 2 cm, but always say so).
const DEFAULT_MARGIN: f32 = 0.0;
/// The page size of a page layout that states none: WordCraft's default page's.
fn default_paper() -> (f32, f32) {
let d = SectionProps::default();
@@ -508,16 +504,16 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
styles.read(s, false);
pages = read_pages(s, paper, &profile);
}
// No page layout at all (no styles.xml, or no master page): the default paper, with XSL's
// margins (0).
// No page layout at all (no styles.xml, or no master page): the default paper, with the
// profile's margins.
if pages.first.is_none() {
pages.first = Some(SectionProps {
page_w: paper.0,
page_h: paper.1,
margin_top: DEFAULT_MARGIN,
margin_bottom: DEFAULT_MARGIN,
margin_left: DEFAULT_MARGIN,
margin_right: DEFAULT_MARGIN,
margin_top: profile.page_margin,
margin_bottom: profile.page_margin,
margin_left: profile.page_margin,
margin_right: profile.page_margin,
..SectionProps::default()
});
}
@@ -923,14 +919,15 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages {
let mut pages = Pages { doc, next: nexts, ..Default::default() };
for (name, layout, header, footer) in masters {
let Some(Layout { attrs: a, columns: cols, column_specs, header: hb, footer: fb, image }) = layouts.get(&layout) else { continue };
// What a layout leaves unsaid: the default paper, and XSL's margins (0).
// What a layout leaves unsaid: the default paper, and the profile's margins (§20.206–20.209
// refer to XSL's margins; whether XSL's initial 0 comes with them the standard doesn't say).
let mut sec = SectionProps {
page_w: paper.0,
page_h: paper.1,
margin_top: DEFAULT_MARGIN,
margin_bottom: DEFAULT_MARGIN,
margin_left: DEFAULT_MARGIN,
margin_right: DEFAULT_MARGIN,
margin_top: profile.page_margin,
margin_bottom: profile.page_margin,
margin_left: profile.page_margin,
margin_right: profile.page_margin,
..SectionProps::default()
};
apply_layout(a, &mut sec);
@@ -143,10 +143,10 @@ fn a_page_layout_without_a_size_is_the_default_pages_size() {
}
#[test]
fn a_document_without_a_page_layout_has_the_default_paper_and_xsl_margins() {
fn a_document_without_a_page_layout_has_the_default_paper_and_the_profiles_margins() {
// No styles.xml at all: the region's paper (the standard leaves the size to the
// application), and XSL's margins, 0: the standard's (LibreOffice's own pages have 2 cm, but
// always say so). It is laid out as an ODF document.
// application), and the profile's margins (the standard leaves them open too): WordCraft's
// 0, XSL's initial value; LibreOffice's 2 cm. It is laid out as an ODF document.
let content = format!(
"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>",
crate::odt::tests::NS_T
@@ -157,12 +157,17 @@ fn a_document_without_a_page_layout_has_the_default_paper_and_xsl_margins() {
assert_eq!((s.page_w, s.page_h), (595.3, 841.9));
assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [0.0; 4], "{s:?}");
assert!(d.settings.odf.is_some());
let lo = crate::import_with("odt", &crate::odt::tests::package(&content, None), &a4, &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
.unwrap()
.unwrap();
let cm2 = 2.0 * 72.0 / 2.54;
assert_eq!([lo.last_section.margin_top, lo.last_section.margin_left], [cm2; 2]);
}
#[test]
fn a_page_layout_stating_nothing_has_xsl_margins() {
// LibreOffice's CV template: a page layout with no properties at all. Its margins are XSL's
// initial value, 0 (the standard's, whatever the profile; LibreOffice draws 2 cm).
fn a_page_layout_stating_nothing_has_the_profiles_margins() {
// LibreOffice's CV template: a page layout with no properties at all. Its margins are the
// profile's: WordCraft's 0 (XSL's initial value), LibreOffice's 2 cm.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="Mpm1"><style:header-style/><style:footer-style/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="Mpm1"/></office:master-styles></office:document-styles>"#
@@ -176,6 +181,15 @@ fn a_page_layout_stating_nothing_has_xsl_margins() {
let one = styles.replace("<style:header-style/>", r#"<style:page-layout-properties fo:margin-left="1in"/><style:header-style/>"#);
let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&one))).unwrap().unwrap();
assert_eq!((d.last_section.margin_left, d.last_section.margin_top), (72.0, 0.0));
let lo = |styles: &str| {
crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(styles)), &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
.last_section
};
let cm2 = 2.0 * 72.0 / 2.54;
let s = lo(&styles);
assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [cm2; 4]);
let s = lo(&one);
assert_eq!((s.margin_left, s.margin_top), (72.0, cm2), "the stated one is the file's under every preset");
}
#[test]
@@ -39,6 +39,7 @@ fn values_a_file_states_are_kept_under_every_preset() {
let bytes = z.finish().unwrap().into_inner();
let odd = OdfProfile {
preset: OdfPreset::Custom,
page_margin: 99.0,
header_min_height: 99.0,
dynamic_spacing: true,
tabs_from_indent: true,
@@ -54,6 +55,7 @@ fn values_a_file_states_are_kept_under_every_preset() {
let s = &d.last_section;
assert!(near(s.header_gap, cm(0.5)), "{:?}: the stated spacing, kept: {}", profile.preset, s.header_gap);
assert!(s.margin_top >= cm(3.0) - 0.05, "{:?}: at least the stated height under the margin: {}", profile.preset, s.margin_top);
assert!(near(s.margin_left, cm(2.0)) && near(s.margin_bottom, cm(2.0)), "{:?}: the stated margins", profile.preset);
assert_eq!(s.columns.count, 2);
assert!(near(s.columns.space, cm(1.0)), "{:?}: {}", profile.preset, s.columns.space);
let p = d.body[0].as_para().unwrap();
+5 -3
View File
@@ -652,9 +652,11 @@ fn odf_files(app: &mut WordApp, ui: &mut Ui) {
let u = unit();
let k = u.pt_per_unit();
egui::Grid::new("options_odf").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
for (key, label, pt) in
[("headerMinHeight", "Header and footer height:", e.header_min_height), ("columnGap", "Gap between columns:", e.column_gap)]
{
for (key, label, pt) in [
("pageMargin", "Page margins:", e.page_margin),
("headerMinHeight", "Header and footer height:", e.header_min_height),
("columnGap", "Gap between columns:", e.column_gap),
] {
let id = ui.label(tl!(label)).id;
let mut v = pt / k;
if ui.add(egui::DragValue::new(&mut v).speed(0.5 / k).range(0.0..=MAX_LENGTH / k).measure(u).max_decimals(2)).labelled_by(id).changed() {
+2 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT interpretation profile: the standard is law, what it leaves open follows WordCraft, LibreOffice or custom settings; unstated margins are XSL's 0); 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, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** 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, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivialLine 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,7 +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-02 | trivial | ODT: what ODF 1.3 defines (including XSL's initial values for `fo:` properties) is followed whatever the settings; what it leaves to the application (Part 3 §16.2) is an interpretation profile, WordCraft's by default, LibreOffice's or custom, in File › Options › Advanced › OpenDocument files and `file.options` `odf`. Every case is in [odf-interpretation.md](odf-interpretation.md). Unstated page margins are now XSL's 0 (not LibreOffice's 2 cm); unstated header heights, column gaps, shape colours and hairlines, tab measuring, frame relations the standard doesn't define and picture-only line heights follow the profile. Google Docs and ONLYOFFICE files checked: no preset of their own needed yet. |
| 2026-11-02 | trivial | ODT: what ODF 1.3 defines (including XSL's initial values for `fo:` properties) is followed whatever the settings; what it leaves to the application (Part 3 §16.2) is an interpretation profile, WordCraft's by default, LibreOffice's or custom, in File › Options › Advanced › OpenDocument files and `file.options` `odf`. Every case is in [odf-interpretation.md](odf-interpretation.md). Unstated page margins (WordCraft 0, XSL's initial value; LibreOffice 2 cm), header heights, column gaps, shape colours and hairlines, tab measuring, frame relations the standard doesn't define and picture-only line heights follow the profile. Google Docs and ONLYOFFICE files checked: no preset of their own needed yet. |
| 2026-11-02 | trivial | ODT: a document opens with LibreOffice's layout for lines of only pictures (as tall as the pictures; see layout-parity), so WordCraft draws LibreOffice's rules and logos at their height. A document with no page layout at all (no styles.xml) had Word's Letter page and 1 inch margins; it has the region's paper and LibreOffice's 2 cm margins (`page_setup::a_document_without_a_page_layout_has_libreoffices_default_page`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's elegant agenda: a paragraph holding only pictures lost its own font size (the pictures' runs had none), so its line grew; the size is the paragraph mark's and the pictures' runs', read and written. An in-line frame's raise was dropped when a parent style gave it a relation no in-line frame has (`style:vertical-rel="paragraph"`); it's measured from the baseline, as LibreOffice reads it. The agenda's round-trip difference falls from 6,542 to 1,331 pixels (`floats::a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's report templates: a table filling the margins (`table:align="margins"`) wider than them ran off the page; it's fitted to them, as LibreOffice fits it. A table's own space above and below (`fo:margin-top`/`fo:margin-bottom`) was dropped; it's kept, laid out and written back. A header or footer taller than its band lost its spacing from the body, moving the text up; it's kept unless the spacing is dynamic (`style:dynamic-spacing`). One report template's round-trip difference falls from 7,646 to 602 pixels (`flow_breaks::a_tables_own_space_above_and_below_is_kept`, `a_tables_columns_keep_their_widths`, `headers::a_headers_spacing_stays_below_content_taller_than_it`) |
+7 -4
View File
@@ -11,8 +11,10 @@ knows of, so none gets lost.
## The rule
1. **What the standard defines is law.** WordCraft follows it whatever the settings say. That
includes `fo:` properties, which the standard defines by referring to XSL 1.0. Where it does,
XSL's initial value is the default.
includes `fo:` properties, which the standard defines by referring to XSL 1.0, for their
meaning. Whether such a reference also brings XSL's initial value as the default the standard
doesn't say; where an application's choice differs from XSL's (page margins), the case is a
setting, with XSL's value as WordCraft's.
2. **What the standard leaves open is a setting.** Each such case is one value of the ODF
interpretation profile (`OdfProfile`, `crates/doc/src/odf_profile.rs`):
- **WordCraft**, the default: what WordCraft does for its own and Word documents.
@@ -41,6 +43,7 @@ Where to set it:
| Case | Standard | Setting (`file.options` `odf.*`) | WordCraft | LibreOffice |
|---|---|---|---|---|
| Page margins a page layout doesn't state | §20.206–20.209 refer to XSL §7.10 (initial value 0); whether that default comes with the reference is not said | `pageMargin` | 0 (XSL's initial value) | 2 cm (its own files always state theirs; some of its templates don't) |
| Height of a header or footer whose style states none | §20.212.2 `fo:min-height`: no default | `headerMinHeight` | 0 (as tall as its text) | 0.5 cm |
| *(meaning)* Whether a header's stated minimum height includes its stated spacing from the body | §20.212.2, §20.207: not said | `headerHeightIncludesSpacing` | no: spacing comes after the height | yes |
| A header taller than its height with `style:dynamic-spacing` unstated | §20.264: no default | `dynamicSpacing` | off: the spacing is kept (on, with no height stated, would always drop the spacing the file states) | off |
@@ -57,7 +60,6 @@ Where to set it:
| Case | Rule | Note |
|---|---|---|
| Page margins a page layout doesn't state | `fo:margin-*` (§20.207 et seq.) refer to XSL §7.10.1: initial value 0 | LibreOffice draws 2 cm, but its own files always state their margins. A file without them (some templates) opens with 0 margins. |
| Paragraph padding on a side without a border | `fo:padding` (§20.217) is XSL's box model: padding takes room whether or not there is a border | Word ignores padding without a border. ODF documents don't. |
| A frame's `style:run-through` when unstated | §20.351: `foreground` | |
| `style:dynamic-spacing="true"/"false"` when stated | §20.264 | WordCraft writes `false` when a header or footer has spacing, so every reader keeps it. |
@@ -109,4 +111,5 @@ one more `OdfPreset` with its values, and the cases above stay the same.
| Date | Change |
|---|---|
| 2026-11-02 | First version: the profile, the eleven open cases, law cases, writer choices, Google Docs and ONLYOFFICE findings. Stated values are never replaced by a preset; the cases about the meaning of stated values are marked. |
| 2026-11-02 | Unstated page margins are a setting (`pageMargin`: WordCraft 0, XSL's initial value; LibreOffice 2 cm), not law: the standard refers to XSL's margins without saying whether XSL's default comes too. |
| 2026-11-02 | First version: the profile, the open cases, law cases, writer choices, Google Docs and ONLYOFFICE findings. Stated values are never replaced by a preset; the cases about the meaning of stated values are marked. |