ODT import reads page size, orientation and margins from styles.xml (#810) (#829)

* ODT import reads page size, orientation and margins from styles.xml (#810)

* Review fixes for #829: clamp ODT page size and margins

Page width/height from styles.xml are kept to 36..=1584 pt (the DOCX
reader's floor, Word's 22-inch maximum) and each margin is capped at the
page's longer side, like w:pgMar. Adds a hostile-values test and collapses
a nested if for clippy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MarekSpodBiedry
2026-10-12 08:01:25 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent e619b12ceb
commit 18e25d0883
2 changed files with 121 additions and 3 deletions
+118 -1
View File
@@ -1404,7 +1404,63 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// Parse an `.odt` package into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
Ok(model::to_doc(&parse(bytes)?))
let mut doc = model::to_doc(&parse(bytes)?);
if let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes))
&& let Some(s) = read_entry(&mut z, "styles.xml")
{
apply_page_layout(&s, &mut doc.last_section);
}
Ok(doc)
}
/// Read the page size, orientation and margins of the first master page in `styles.xml` into `sec`.
fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) {
let mut layouts: HashMap<String, Attrs> = HashMap::new();
let mut cur: Option<String> = None;
let mut master: Option<String> = None;
events(xml, |ev| match ev {
Ev::Start(q, a) => match local(&q) {
"page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from),
"page-layout-properties" => {
if let Some(n) = &cur {
layouts.insert(n.clone(), a);
}
}
"master-page" if master.is_none() => master = get(&a, "style:page-layout-name").map(String::from),
_ => {}
},
Ev::End(q) => {
if local(&q) == "page-layout" {
cur = None;
}
}
Ev::Text(_) => {}
});
let Some(a) = master.and_then(|m| layouts.remove(&m)) else { return };
let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0);
// Like the DOCX reader: a page is at least half an inch; at most 22 inches (Word's limit), so a hostile
// `1e30pt` can't reach layout.
let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(1584.0));
if let (Some(w), Some(h)) = (page("fo:page-width"), page("fo:page-height")) {
sec.page_w = w;
sec.page_h = h;
}
sec.landscape = match get(&a, "style:print-orientation") {
Some("landscape") => true,
Some("portrait") => false,
_ => sec.page_w > sec.page_h,
};
for (k, slot) in [
("fo:margin-top", &mut sec.margin_top),
("fo:margin-bottom", &mut sec.margin_bottom),
("fo:margin-left", &mut sec.margin_left),
("fo:margin-right", &mut sec.margin_right),
] {
if let Some(v) = len(k) {
// No margin is larger than the page's longer side (as `w:pgMar` in the DOCX reader).
*slot = v.min(sec.page_w.max(sec.page_h));
}
}
}
#[cfg(test)]
@@ -1465,6 +1521,67 @@ mod tests {
}
}
#[test]
fn page_size_orientation_and_margins_round_trip() {
// #810: the page setup written on export is read back on import.
let mut doc = Document::new();
doc.last_section.page_w = 841.0;
doc.last_section.page_h = 595.0;
doc.last_section.landscape = true;
doc.last_section.margin_left = 36.0;
doc.last_section.margin_top = 54.0;
let back = import(&export(&doc).unwrap()).unwrap();
let s = &back.last_section;
assert!(s.landscape);
assert_eq!((s.page_w, s.page_h), (841.0, 595.0));
assert_eq!((s.margin_left, s.margin_top), (36.0, 54.0));
}
#[test]
fn page_layout_follows_the_first_master_page() {
let xml = br#"<office:document-styles><office:automatic-styles>
<style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout>
<style:page-layout style:name="pm2">
<style:page-layout-properties fo:page-width="10in" fo:page-height="5in" fo:margin-left="1in" style:print-orientation="landscape"/>
</style:page-layout>
</office:automatic-styles><office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm2"/>
<style:master-page style:name="Other" style:page-layout-name="pm1"/>
</office:master-styles></office:document-styles>"#;
let mut sec = wordcraft_doc::SectionProps::default();
apply_page_layout(xml, &mut sec);
assert!(sec.landscape);
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 72.0));
}
#[test]
fn hostile_page_layout_values_are_clamped() {
let styles = |w: &str, h: &str, m: &str| {
format!(
r#"<office:document-styles><office:automatic-styles><style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="{w}" fo:page-height="{h}" fo:margin-top="{m}"
fo:margin-bottom="{m}" fo:margin-left="{m}" fo:margin-right="{m}"/></style:page-layout>
</office:automatic-styles><office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles>
</office:document-styles>"#
)
};
// Huge values: the page is capped at 22 inches and margins at the page's longer side.
let mut sec = wordcraft_doc::SectionProps::default();
apply_page_layout(styles("1e30pt", "1e30in", "1e30pt").as_bytes(), &mut sec);
assert_eq!((sec.page_w, sec.page_h), (1584.0, 1584.0));
assert_eq!((sec.margin_top, sec.margin_left), (1584.0, 1584.0));
// Negative, zero, tiny and non-numeric values leave the defaults alone.
let def = wordcraft_doc::SectionProps::default();
for (w, h, m) in [("-10in", "-5in", "-1in"), ("0pt", "0pt", "NaNpt"), ("1pt", "2pt", "infpt"), ("NaN", "inf", "x")] {
let mut sec = wordcraft_doc::SectionProps::default();
apply_page_layout(styles(w, h, m).as_bytes(), &mut sec);
assert_eq!((sec.page_w, sec.page_h), (def.page_w, def.page_h), "{w} {h}");
assert_eq!((sec.margin_top, sec.margin_right), (def.margin_top, def.margin_right), "{m}");
assert!(sec.page_w.is_finite() && sec.margin_left.is_finite());
}
}
#[test]
fn table_cell_style_background_is_shading() {
// #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading.
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (ODT page size, orientation and margins read on open, #810; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
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;
@@ -26,7 +26,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86). No footnotes, comments, tracked changes, fields or RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
@@ -162,6 +162,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added |
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |