diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index e2266f7ed..ab288fc30 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1404,7 +1404,63 @@ pub fn parse(bytes: &[u8]) -> Result { /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { - 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 = HashMap::new(); + let mut cur: Option = None; + let mut master: Option = 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#" + + + + + + + + "#; + 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#" + + + + "# + ) + }; + // 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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0b38c646d..6d8763c90 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 `` 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% |