mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
* 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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
e619b12ceb
commit
18e25d0883
+118
-1
@@ -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.
|
||||
|
||||
@@ -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% |
|
||||
|
||||
Reference in New Issue
Block a user