M5 #19: LibreOffice reads WordCraft's ODT settings; paragraph spacing setting

Found by comparing WordCraft's own documents in LibreOffice and
bisecting settings.xml (observed only, black-box):

- The settings set's name, ooo:configuration-settings, is a qualified
  name: without xmlns:ooo declared LibreOffice ignored every setting
  WordCraft wrote (its Word layout rules, tab measuring).
- A set LibreOffice reads must be complete: a setting missing from it
  gets its old pre-OpenOffice value, which changes the layout. WordCraft
  now writes LibreOffice's current layout defaults (as a new LibreOffice
  document has them), overlaid with Word's layout rules for documents
  WordCraft lays out as Word does (not for ones opened from ODF), and
  its own choices.
- How the space between paragraphs adds up follows a file's
  AddParaTableSpacing (false: the larger, as Word); unstated, the ODF
  profile's new sumParagraphSpacing (WordCraft: the larger, LibreOffice:
  the sum). WordCraft writes it as it lays the document out.

WordCraft-origin documents in LibreOffice: 142k -> 80k differing pixels
over 42 documents; the LibreOffice corpus: no regressions, 40k -> 39.6k.

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 00:49:05 +00:00
1 parent e635c8bd96
commit 48ff99e52c
8 files changed
+169 -18

No files matched your search

+6
View File
@@ -96,6 +96,10 @@ pub struct OdfProfile {
/// its name) ends with balanced columns at the end of the document too (`true`), else only
/// when text follows on the same page, as in Word.
pub balance_section_columns: bool,
/// The space between two paragraphs is the first's space after plus the second's space
/// before (`true`), else the larger of the two, when the document doesn't say (the
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
pub sum_paragraph_spacing: bool,
}
impl Default for OdfProfile {
@@ -122,6 +126,7 @@ impl OdfProfile {
inline_invalid_relation_is_baseline: false,
picture_lines_fit_pictures: false,
balance_section_columns: false,
sum_paragraph_spacing: false,
}
}
@@ -142,6 +147,7 @@ impl OdfProfile {
inline_invalid_relation_is_baseline: true,
picture_lines_fit_pictures: true,
balance_section_columns: true,
sum_paragraph_spacing: true,
}
}
+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", "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: 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, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: 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| {
+8
View File
@@ -404,6 +404,8 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
/// What [`parse_tracked`] reads besides the flow.
struct Parsed {
flow: Flow,
/// The space between paragraphs is their spacings' sum (else the larger).
sum_spacing: bool,
/// The last section's properties.
last: SectionProps,
/// Whether change tracking is on (`None`: the document has no tracked changes).
@@ -522,6 +524,9 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
// Where tab stops are measured from: as the document's settings say (LibreOffice's own), else
// the profile's choice (the standard ties it only to indexes, §19.512.3).
styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(profile.tabs_from_indent);
// How the space between two paragraphs adds up: as LibreOffice's `AddParaTableSpacing` says
// (false: the larger of the two, as Word), else the profile's choice (the standard doesn't say).
let sum_spacing = settings.as_deref().and_then(|s| setting(s, "AddParaTableSpacing")).unwrap_or(profile.sum_paragraph_spacing);
let meta_xml = read_entry(&mut z, "meta.xml");
let meta = meta_xml.as_deref().map(read_meta).unwrap_or_default();
// Custom document properties, then the user fields not among them.
@@ -603,6 +608,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
kept,
hidden_controls,
page_image,
sum_spacing,
})
}
@@ -647,6 +653,7 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
kept,
hidden_controls,
page_image,
sum_spacing,
} = parse_tracked(bytes, (page.page_w, page.page_h), profile)?;
let mut doc = model::to_doc(&flow);
kept.store(&mut doc);
@@ -671,6 +678,7 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
doc.settings.page_color = page_doc.page_color;
// Laid out with the standard's rules, and the profile's choices made as it is laid out.
doc.settings.odf = Some(profile.layout());
doc.settings.sum_paragraph_spacing = sum_spacing;
// The page background picture (Design › Page Color › Fill Effects › Picture).
if let Some((bytes, fill)) = page_image
&& let Some(ext) = model::sniff_image(&bytes)
+113 -17
View File
@@ -420,6 +420,71 @@ pub(super) const SETTINGS_PART: &str = "odt/settings.xml";
/// Largest `settings.xml` kept, in bytes.
pub(super) const MAX_SETTINGS: usize = 1 << 20;
/// LibreOffice's layout settings for a new document (observed in what LibreOffice 24 saves):
/// written in full, because LibreOffice gives any it doesn't find in a document's set its old,
/// pre-OpenOffice value (observed), which changes how the document is laid out.
const LO_DEFAULTS: &[(&str, bool)] = &[
("NoNumberingShowFollowBy", false),
("HyphenateURLs", false),
("FootnoteInColumnToPageEnd", true),
("GutterAtTop", false),
("ContinuousEndnotes", false),
("FrameAutowidthWithMorePara", false),
("SubtractFlysAnchoredAtFlys", false),
("SurroundTextWrapSmall", false),
("TreatSingleColumnBreakAsPageBreak", false),
("TabOverSpacing", false),
("AutoFirstLineIndentDisregardLineSpace", true),
("TabOverMargin", false),
("ApplyTextAttrToEmptyLineAtEndOfParagraph", true),
("DisableOffPagePositioning", false),
("BackgroundParaOverDrawings", false),
("TabOverflow", true),
("AddVerticalFrameOffsets", false),
("AddFrameOffsets", false),
("TableRowKeep", false),
("ApplyParagraphMarkFormatToNumbering", false),
("JustifyLinesWithShrinking", false),
("CollapseEmptyCellPara", true),
("UnbreakableNumberings", false),
("MsWordCompTrailingBlanks", false),
("AddParaTableSpacing", true),
("OutlineLevelYieldsNumbering", false),
("UseOldNumbering", false),
("AddParaSpacingToTableCells", true),
("PropLineSpacingShrinksFirstLine", true),
("TabAtLeftIndentForParagraphsInList", false),
("DoNotCaptureDrawObjsOnPage", false),
("HeaderSpacingBelowLastPara", false),
("ClippedPictures", false),
("MathBaselineAlignment", true),
("AlignTabStopPosition", true),
("UseFormerLineSpacing", false),
("AddParaLineSpacingToTableCells", true),
("IsKernAsianPunctuation", false),
("DoNotJustifyLinesWithManualBreak", false),
("UseVariableWidthNBSP", false),
("AddParaTableSpacingAtStart", true),
("InvertBorderSpacing", false),
("AddExternalLeading", true),
("UseFormerObjectPositioning", false),
("UnxForceZeroExtLeading", false),
("UseFormerTextWrapping", false),
("ConsiderTextWrapOnObjPos", false),
("StylesNoDefault", false),
("IgnoreFirstLineIndentInNumbering", false),
("NoGapAfterNoteNumber", false),
("EmptyDbFieldHidesPara", true),
("DoNotResetParaAttrsForNumFont", false),
("IgnoreTabsAndBlanksForLineCalculation", false),
("ClipAsCharacterAnchoredWriterFlyFrames", false),
("DropCapPunctuation", true),
("UseOldPrinterMetrics", false),
("TabsRelativeToIndent", true),
("MsWordCompMinLineHeightByFly", false),
("SmallCapsPercentage66", false),
];
/// LibreOffice's layout compatibility switches (`ooo:configuration-settings`) for a document
/// laid out as Word lays it out, as LibreOffice sets them for Word files.
const WORD_COMPAT: &[(&str, bool)] = &[
@@ -447,7 +512,6 @@ const WORD_COMPAT: &[(&str, bool)] = &[
("CollapseEmptyCellPara", true),
("UnbreakableNumberings", true),
("MsWordCompTrailingBlanks", true),
("AddParaTableSpacing", false),
("OutlineLevelYieldsNumbering", false),
("UseOldNumbering", false),
("AddParaSpacingToTableCells", true),
@@ -486,34 +550,66 @@ const WORD_COMPAT: &[(&str, bool)] = &[
/// `settings.xml` (ODF 1.3 §3.10) for `doc`: an ODT's own, kept from opening it, or LibreOffice's
/// switches for Word's layout. Either way tab stops are measured from the margin, as WordCraft
/// writes them (LibreOffice's own default measures them from the paragraph's indent).
/// `settings.xml`: LibreOffice's layout settings for a document laid out as WordCraft lays it
/// out. The set's name, `ooo:configuration-settings`, is a qualified name: without the `ooo`
/// namespace declared LibreOffice ignores the whole set (observed).
fn settings_xml(doc: &Document) -> String {
const TABS: &str = "<config:config-item config:name=\"TabsRelativeToIndent\" config:type=\"boolean\">false</config:config-item>";
// What WordCraft's own layout of the document needs LibreOffice to do the same: tab stops
// from the margin (as written), and the space between paragraphs as WordCraft lays it out
// (ODF leaves it open; LibreOffice's `AddParaTableSpacing` false takes the larger of the two).
let ours = [("TabsRelativeToIndent", false), ("AddParaTableSpacing", doc.settings.sum_paragraph_spacing)];
let item = |name: &str, on: bool| format!("<config:config-item config:name=\"{name}\" config:type=\"boolean\">{on}</config:config-item>");
if let Some(own) = doc.passthrough.get(SETTINGS_PART).filter(|b| b.len() <= MAX_SETTINGS).and_then(|b| std::str::from_utf8(b).ok()) {
let set = "config:name=\"ooo:configuration-settings\"";
let tabs = "config:name=\"TabsRelativeToIndent\"";
if let Some(at) = own.find(tabs) {
// The file's item, its value made false.
let rest = own.get(at..).unwrap_or("");
if let (Some(gt), Some(lt)) = (rest.find('>'), rest.find("</config:config-item>"))
&& gt < lt
{
return format!("{}false{}", own.get(..at + gt + 1).unwrap_or(""), own.get(at + lt..).unwrap_or(""));
let mut out = own.to_string();
let mut ok = true;
for (name, on) in ours {
match with_setting(&out, name, on, &item(name, on)) {
Some(next) => out = next,
None => ok = false,
}
} else if let Some(at) = own.find(set).and_then(|at| own.get(at..)?.find('>').map(|gt| at + gt + 1)) {
return format!("{}{TABS}{}", own.get(..at).unwrap_or(""), own.get(at..).unwrap_or(""));
}
if ok {
return out;
}
}
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-settings xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:config=\"urn:oasis:names:tc:opendocument:xmlns:config:1.0\" office:version=\"1.3\"><office:settings><config:config-item-set config:name=\"ooo:configuration-settings\">",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-settings xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:config=\"urn:oasis:names:tc:opendocument:xmlns:config:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=\"1.3\"><office:settings><config:config-item-set config:name=\"ooo:configuration-settings\">",
);
s.push_str(TABS);
for (name, on) in WORD_COMPAT {
s.push_str(&format!("<config:config-item config:name=\"{name}\" config:type=\"boolean\">{on}</config:config-item>"));
// LibreOffice's defaults; Word's layout rules for a document WordCraft lays out as Word does
// (one opened from ODF is laid out by ODF's rules, `Settings::odf`, as LibreOffice's
// defaults have it); and what WordCraft's layout of this document needs.
let mut all: Vec<(&str, bool)> = LO_DEFAULTS.to_vec();
let word = if doc.settings.odf.is_none() { WORD_COMPAT } else { &[] };
for (name, on) in word.iter().copied().chain(ours) {
match all.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = on,
None => all.push((name, on)),
}
}
for (name, on) in all {
s.push_str(&item(name, on));
}
// Metrics independent of any printer, as LibreOffice's own documents.
s.push_str("<config:config-item config:name=\"PrinterIndependentLayout\" config:type=\"string\">high-resolution</config:config-item>");
s.push_str("</config:config-item-set></office:settings></office:document-settings>\n");
s
}
/// A file's own `settings.xml` (`own`) with LibreOffice's setting `name` made `on`: its item's
/// value changed, or `item` added at the start of the configuration settings. `None` when it has
/// neither.
fn with_setting(own: &str, name: &str, on: bool, item: &str) -> Option<String> {
let key = format!("config:name=\"{name}\"");
if let Some(at) = own.find(&key) {
let rest = own.get(at..)?;
let (gt, lt) = (rest.find('>')?, rest.find("</config:config-item>")?);
return (gt < lt).then(|| format!("{}{on}{}", own.get(..at + gt + 1).unwrap_or(""), own.get(at + lt..).unwrap_or("")));
}
let set = "config:name=\"ooo:configuration-settings\"";
let at = own.find(set).and_then(|at| own.get(at..)?.find('>').map(|gt| at + gt + 1))?;
Some(format!("{}{item}{}", own.get(..at).unwrap_or(""), own.get(at..).unwrap_or("")))
}
fn meta_xml(m: &Meta, props: &[wordcraft_doc::CustomProp]) -> String {
let mut s =
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-meta {NS}><office:meta><meta:generator>WordCraft</meta:generator>");
@@ -93,3 +93,40 @@ fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() {
assert!(content.contains(&format!(r#"text:dont-balance-text-columns="{written}""#)), "{props}: {content}");
}
}
#[test]
fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
// LibreOffice's `AddParaTableSpacing` (settings.xml; false: the larger of the two, observed)
// says how the space between paragraphs adds up; without it, the profile decides
// (LibreOffice adds them, WordCraft as Word takes the larger). Written back as WordCraft lays
// the document out, in a set LibreOffice reads (the `ooo` namespace declared).
let content =
format!("<office:document-content {NS_T}><office:body><office:text><text:p>a</text:p></office:text></office:body></office:document-content>");
let with = |add: Option<bool>| {
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut parts = vec![("mimetype", "application/vnd.oasis.opendocument.text".to_string()), ("content.xml", content.clone())];
if let Some(m) = add {
parts.push(("settings.xml", format!(r#"<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" xmlns:ooo="http://openoffice.org/2004/office"><office:settings><config:config-item-set config:name="ooo:configuration-settings"><config:config-item config:name="AddParaTableSpacing" config:type="boolean">{m}</config:config-item></config:config-item-set></office:settings></office:document-settings>"#)));
}
for (name, data) in parts {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
};
for p in [OdfProfile::wordcraft(), OdfProfile::libreoffice()] {
assert!(import_as(&with(Some(true)), &p).settings.sum_paragraph_spacing);
assert!(!import_as(&with(Some(false)), &p).settings.sum_paragraph_spacing);
}
assert!(import_as(&with(None), &OdfProfile::libreoffice()).settings.sum_paragraph_spacing);
assert!(!import_as(&with(None), &OdfProfile::wordcraft()).settings.sum_paragraph_spacing);
for (add, written) in [(Some(true), "true"), (None, "false")] {
let d = import_as(&with(add), &OdfProfile::wordcraft());
let saved = crate::odt::export(&d).unwrap();
let settings = crate::odt::tests::entry(&saved, "settings.xml");
assert!(settings.contains(&format!(r#"config:name="AddParaTableSpacing" config:type="boolean">{written}<"#)), "{add:?}: {settings}");
assert_eq!(settings.matches("\"AddParaTableSpacing\"").count(), 1);
assert!(settings.contains("xmlns:ooo="), "LibreOffice reads the set only with its namespace");
}
}
+1
View File
@@ -696,6 +696,7 @@ fn odf_files(app: &mut WordApp, ui: &mut Ui) {
),
("pictureLinesFitPictures", "A line holding only pictures is as tall as the pictures", e.picture_lines_fit_pictures),
("balanceSectionColumns", "A section's columns end balanced at the end of the document too", e.balance_section_columns),
("sumParagraphSpacing", "The space between paragraphs is the space after plus the space before", e.sum_paragraph_spacing),
] {
let mut v = value;
if ui.checkbox(&mut v, tl!(label)).changed() {
+1
View File
@@ -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 `settings.xml`: LibreOffice ignored every setting WordCraft wrote, because the set's name (`ooo:configuration-settings`) is a qualified name and the `ooo` namespace wasn't declared (observed); and a set it does read must be complete, since LibreOffice gives any setting it lacks its old pre-OpenOffice value (observed). WordCraft now writes LibreOffice's current layout defaults (observed in a new LibreOffice document), Word's layout rules for documents WordCraft lays out as Word does, and its own tab and paragraph spacing choices. How the space between paragraphs adds up follows a file's `AddParaTableSpacing` (false: the larger of the two, as Word), else the ODF interpretation profile (`sumParagraphSpacing`: WordCraft the larger, LibreOffice the sum). WordCraft's own documents opened in LibreOffice now differ by 80k pixels over 42 documents, from 142k. |
| 2026-11-03 | trivial | ODT cell background pictures (§17.3 `style:background-image` in a cell style: LibreOffice's elegant agenda) are read, drawn under the cell's text and cut off at its edges (once where `style:position` puts it, tiled, or stretched, as `style:repeat` says), and written back; WordCraft's cells can carry one (`CellProps::picture`). DOCX has none, so it stays in WordCraft's model only. |
| 2026-11-03 | trivial | ODT page borders: the padding inside a page border was capped at Word's 31 pt border spacing on opening, so text inside a wide padding (WordCraft's own page borders around 1 inch margins) moved towards the border; it is now any distance on the page. |
| 2026-11-03 | trivial | ODT watermarks: WordCraft's watermark (Design › Watermark) is saved as LibreOffice's and Word's are, a fontwork shape (`draw:text-path`) named PowerPlusWaterMarkObject behind the text in every header (one written for it alone, inside the top margin, when there is none), which LibreOffice draws; such a shape opens as the watermark (its text, colour, see-through, turn and size), not a shape. Before, the watermark was left out of ODT files. |
+2
View File
@@ -55,6 +55,7 @@ Where to set it:
| *(meaning)* A page-anchored frame positioned against a paragraph or character | §20.299, §20.398 Table 23: combination not defined | `pageFrameParagraphIsPage` | the page's text area | the page's edge |
| *(meaning)* An in-line frame positioned against a paragraph or page | §20.398 Table 23: combination not defined | `inlineInvalidRelationIsBaseline` | stands on the baseline | positioned against the baseline (raised by its offset) |
| Height of a line holding only in-line pictures | no line-height algorithm in the standard | `pictureLinesFitPictures` | as tall as its text would be too | as tall as the pictures |
| How the space between two paragraphs adds up when the document doesn't say | not defined (`fo:margin-top`/`-bottom` refer to XSL's margins; whether XSL's space resolution, which keeps the larger, comes with them is not said); LibreOffice's `AddParaTableSpacing` setting says when present (false: the larger) | `sumParagraphSpacing` | the larger of the two (as Word) | their sum |
| *(meaning)* Whether a columned section's columns end balanced when nothing follows it on the page, and when it doesn't say | §20.428 `text:dont-balance-text-columns`: no default, and its value descriptions contradict its name (WordCraft reads the name; so does LibreOffice) | `balanceSectionColumns` | as Word: only when text follows on the same page | balanced |
## Law: what the standard defines and WordCraft follows whatever the profile
@@ -112,6 +113,7 @@ one more `OdfPreset` with its values, and the cases above stay the same.
| Date | Change |
|---|---|
| 2026-11-03 | Paragraph spacing (`sumParagraphSpacing`); a file's `AddParaTableSpacing` is followed, and WordCraft writes it. WordCraft's `settings.xml` now declares the `ooo` namespace, without which LibreOffice ignored all of its settings. |
| 2026-11-03 | Section column balancing (`balanceSectionColumns`); §20.428's contradiction recorded. WordCraft writes `text:dont-balance-text-columns` on every columned section. |
| 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. |