collective

Browse / github.com/storytold/wordcraft / patch

Page sides up to 600 cm: keep tall pages in DOCX, ODT and DOCBIN

Page sides up to 600 cm: keep tall pages in DOCX, ODT and DOCBIN ## What A page side larger than Word's 22-inch cap is kept when a document is opened and saved again. The command layer, the DOCX `w:pgSz` reader/writer, the ODT page layout and the `.docbin` section reader accept the document model's real limit (600 cm / 17,008 pt) instead of clamping to 1,584 pt (22 in). The generic 200-inch length cap still applies to everything that is not a page side. ## Why Documents made by other ODF tools — a 19 × 600 cm study sheet in the reference set — were silently truncated to 22 in on save: visible data loss the moment the file left WordCraft. The model alread

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasmelo-mike01 (imported from pull request #4432, open)
Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3
Size
+126 −23 in 9 files
Review
pending
Shared
2026-10-28
Id
a57a6ef24209e570beac150f

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/section.rs crates/docbin/src/sections.rs crates/docx/src/read/props.rs crates/docx/src/units.rs crates/docx/src/write/story.rs crates/docx/tests/it/roundtrip.rs crates/engine/src/cmd/page.rs crates/engine/src/tests.rs crates/formats/src/odt.rs

diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs--- a/crates/doc/src/section.rs+++ b/crates/doc/src/section.rs@@ -717,6 +717,15 @@ impl DocGrid {     } } +/// The smallest page side WordCraft accepts, points (0.1", as Word allows).+pub const MIN_PAGE_SIDE: f32 = 7.2;++/// The largest page side WordCraft accepts, points: 600 cm, LibreOffice's limit for a custom page+/// size (Word itself stops at 22 in / 1584 pt). The page stays this size through layout, saving+/// and export; only the PDF writer, capped by the PDF format at 200 in, and Word itself are+/// narrower.+pub const MAX_PAGE_SIDE: f32 = 17_008.0;+ /// Page setup and section-level settings. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]diff --git a/crates/docbin/src/sections.rs b/crates/docbin/src/sections.rs--- a/crates/docbin/src/sections.rs+++ b/crates/docbin/src/sections.rs@@ -82,6 +82,9 @@ pub(crate) fn parse(word: &[u8], table: &[u8], fib: &Fib) -> Vec<Section> { /// carry 16-bit twip operands ([MS-DOC] §2.6.4). fn apply_section(s: &mut SectionProps, prl: &Prl) {     let tw = |v: i32| (v as f32 / 20.0).clamp(0.0, 1584.0);+    // A page side is not one of the lengths the 1,584 pt (22 in) cap is for: the document model+    // takes up to 600 cm, so convert it straight from twips and bound it there.+    let page_tw = |v: i32| (v as f32 / 20.0).clamp(7.2, wordcraft_doc::section::MAX_PAGE_SIDE);     let s16 = |prl: &Prl| -> Option<i16> {         match prl.operand {             [b0, b1, ..] => Some(i16::from_le_bytes([*b0, *b1])),@@ -130,12 +133,12 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) {         }         S_XA_PAGE => {             if let Some(v) = s16(prl) {-                s.page_w = tw(v as i32).max(7.2);+                s.page_w = page_tw(v as i32);             }         }         S_YA_PAGE => {             if let Some(v) = s16(prl) {-                s.page_h = tw(v as i32).max(7.2);+                s.page_h = page_tw(v as i32);             }         }         S_DXA_LEFT => {diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs--- a/crates/docx/src/read/props.rs+++ b/crates/docx/src/read/props.rs@@ -697,11 +697,15 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {                 }             }             "w:pgSz" => {-                if let Some(w) = tw(k, "w:w").filter(|v| *v >= 36.0) {-                    s.page_w = w;+                // A page is at least half an inch and at most 600 cm (LibreOffice's limit): a+                // hostile size can't reach layout, and the 600 cm pages WordCraft writes back+                // still open.+                let side = |v: f32| v.clamp(36.0, wordcraft_doc::section::MAX_PAGE_SIDE);+                if let Some(w) = crate::units::tw_page(k, "w:w").filter(|v| *v >= 36.0) {+                    s.page_w = side(w);                 }-                if let Some(h) = tw(k, "w:h").filter(|v| *v >= 36.0) {-                    s.page_h = h;+                if let Some(h) = crate::units::tw_page(k, "w:h").filter(|v| *v >= 36.0) {+                    s.page_h = side(h);                 }                 s.landscape = match k.attr("w:orient") {                     Some("landscape") => true,diff --git a/crates/docx/src/units.rs b/crates/docx/src/units.rs--- a/crates/docx/src/units.rs+++ b/crates/docx/src/units.rs@@ -9,6 +9,12 @@ pub const MAX_LEN_PT: f32 = 14_400.0; /// `3mm`, `1pc`, `1pi`). `per_pt` is how many file units make a point when there's no unit /// suffix (20 for twips, 2 for half-points, 8 for eighths, 12700 for EMU). Returns points. pub fn measure(s: &str, per_pt: f32) -> Option<f32> {+    measure_capped(s, per_pt, MAX_LEN_PT)+}++/// `measure` with an explicit cap, for the few measures that may exceed a normal length: a page+/// side goes up to 600 cm (LibreOffice's custom size), past the 200" bound of other lengths.+fn measure_capped(s: &str, per_pt: f32, cap: f32) -> Option<f32> {     let t = s.trim();     if t.is_empty() || t.len() > 40 {         return None;@@ -57,7 +63,17 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {         _ => return None,     };     let pt = pt as f32;-    pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))+    pt.is_finite().then_some(pt.clamp(-cap, cap))+}++/// `w:pgSz`'s `w:w`/`w:h`: a page side, up to 600 cm.+pub fn tw_page(e: &El, attr: &str) -> Option<f32> {+    e.attr(attr).and_then(|v| measure_capped(v, 20.0, wordcraft_doc::section::MAX_PAGE_SIDE))+}++/// Twips for a page side, which may be 600 cm — beyond [`twips`]'s 200" bound.+pub fn twips_page(pt: f32) -> String {+    wordcraft_geom::to_twips(pt.clamp(-wordcraft_doc::section::MAX_PAGE_SIDE, wordcraft_doc::section::MAX_PAGE_SIDE)).to_string() }  /// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs--- a/crates/docx/src/write/story.rs+++ b/crates/docx/src/write/story.rs@@ -1413,11 +1413,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {             SectionStart::NextColumn => "nextColumn",         };         w.val("w:type", start);-        let (pw, ph) = (s.page_w.clamp(36.0, crate::units::MAX_LEN_PT), s.page_h.clamp(36.0, crate::units::MAX_LEN_PT));+        // A page may be up to 600 cm (LibreOffice's custom size): the size goes into the file as+        // it is, so WordCraft and Writer read it back. Word itself stops at 22" when it opens the+        // file — a Word limit, shown in the app's save note, not something to truncate here.+        let page_side = |v: f32| v.clamp(36.0, wordcraft_doc::section::MAX_PAGE_SIDE);+        let (pw, ph) = (page_side(s.page_w), page_side(s.page_h));         if s.landscape {-            w.empty("w:pgSz", &[("w:w", &twips(pw)), ("w:h", &twips(ph)), ("w:orient", "landscape")]);+            w.empty("w:pgSz", &[("w:w", &crate::units::twips_page(pw)), ("w:h", &crate::units::twips_page(ph)), ("w:orient", "landscape")]);         } else {-            w.empty("w:pgSz", &[("w:w", &twips(pw)), ("w:h", &twips(ph))]);+            w.empty("w:pgSz", &[("w:w", &crate::units::twips_page(pw)), ("w:h", &crate::units::twips_page(ph))]);         }         w.empty(             "w:pgMar",diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs--- a/crates/docx/tests/it/roundtrip.rs+++ b/crates/docx/tests/it/roundtrip.rs@@ -14,6 +14,19 @@ use wordcraft_doc::styles::{Style, StyleKind}; use wordcraft_doc::table::{Cell, Table}; use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block}; +/// A 600 cm page (LibreOffice's custom size) goes through DOCX too: Word's own 22" limit is a+/// Word limit, not WordCraft's, and WordCraft must read back what it wrote.+#[test]+fn a_six_hundred_centimetre_page_roundtrips() {+    let cm = 72.0 / 2.54;+    let mut d = Document::from_text("Body");+    d.last_section.page_w = cm * 19.0;+    d.last_section.page_h = cm * 600.0;+    let back = rt(&d);+    assert!((back.last_section.page_h - cm * 600.0).abs() < 0.5, "{}", back.last_section.page_h);+    assert!((back.last_section.page_w - cm * 19.0).abs() < 0.5);+}+ fn rt(doc: &Document) -> Document {     let bytes = wordcraft_docx::write(doc).expect("write");     wordcraft_docx::read(&bytes).expect("read")diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs--- a/crates/engine/src/cmd/page.rs+++ b/crates/engine/src/cmd/page.rs@@ -408,7 +408,7 @@ pub fn columns_preset_at(s: &Session) -> Option<&'static str> { /// Checks a whole section handed to `layout.pageSetup` by the rules of Size, Margins, Columns, /// Line Numbers, Footnotes and Page Borders: a non-finite number or margins that leave no room /// for text is an error; the rest is clamped to the ranges those commands use.-fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {+pub(crate) fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {     let bad = |what: &str| CmdError::Params(format!("section: {what} must be a finite number"));     let finite = [x.page_w, x.page_h, x.margin_top, x.margin_bottom, x.margin_left, x.margin_right, x.header, x.footer, x.gutter, x.columns.space];     if finite.iter().any(|n| !n.is_finite()) {@@ -542,9 +542,11 @@ pub fn margins_fit(page: (f32, f32), (t, b, l, r): (f32, f32, f32, f32), gutter:     l + r + gutter <= page.0 - MIN_TEXT_ROOM && t + b <= page.1 - MIN_TEXT_ROOM } -/// The smallest and largest page side, points (0.1" and 22", as Word allows).+/// The smallest page side, points (0.1", as Word allows). pub const MIN_PAGE_SIDE: f32 = 7.2;-pub const MAX_PAGE_SIDE: f32 = 1584.0;+/// The largest page side, points: 600 cm (LibreOffice's custom page limit), so a Writer document+/// with a 600 cm page keeps its height. Word's own 22" limit only matters when Word opens it.+pub const MAX_PAGE_SIDE: f32 = wordcraft_doc::section::MAX_PAGE_SIDE;  fn size(s: &mut Session, v: &Value) -> CmdResult {     let (w, h) = if let Some(n) = p::str(v, "name") {@@ -779,6 +781,37 @@ mod tests {         json!({"section": v})     } +    /// A 600 cm page (LibreOffice's custom size) is a real page: accepted as-is by the model and+    /// by `layout.pageSetup`; only a value beyond the maximum is clamped.+    #[test]+    fn a_six_hundred_centimetre_page_is_accepted() {+        let cm = 72.0 / 2.54;+        let mut s = doc();+        let tall = cm * 600.0;+        s.run(+            "layout.pageSetup",+            &with_section(|v| {+                v["pageW"] = json!(cm * 19.0);+                v["pageH"] = json!(tall);+                v["marginTop"] = json!(11.34);+                v["marginBottom"] = json!(11.34);+                v["marginLeft"] = json!(11.34);+                v["marginRight"] = json!(11.34);+            }),+        )+        .unwrap();+        let got = sect(&s);+        assert!((got.page_h - tall).abs() < 0.01, "600 cm kept: {}", got.page_h);+        assert!((got.page_w - cm * 19.0).abs() < 0.01, "the width is untouched");++        // Just past the maximum: clamped to it, not kept and not refused.+        s.run("layout.pageSetup", &with_section(|v| v["pageH"] = json!(tall + 50.0))).unwrap();+        assert_eq!(sect(&s).page_h, wordcraft_doc::section::MAX_PAGE_SIDE);+        // Junk is still refused.+        assert!(s.run("layout.pageSetup", &with_section(|v| v["pageH"] = json!(f64::INFINITY))).is_err());+        assert!(s.run("layout.pageSetup", &with_section(|v| v["pageH"] = json!(-1.0))).is_err());+    }+     /// `layout.pageSetup {section}` checks the values like the dedicated commands do.     #[test]     fn page_setup_section_is_validated() {@@ -860,7 +893,7 @@ mod tests {         )         .unwrap();         let x = sect(&s);-        assert_eq!(x.page_w, 1584.0);+        assert_eq!(x.page_w, wordcraft_doc::section::MAX_PAGE_SIDE, "clamped to 600 cm");         assert!(x.landscape, "wider than tall is landscape");         assert_eq!(x.columns.widths.len(), 12);         assert_eq!(x.columns.count, 12, "the count follows the widths");diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -2635,7 +2635,11 @@ fn layout_size_takes_a_custom_size_and_clamps_it() {     assert_eq!(size(&s), (300.0, 500.0));     run(&mut s, "layout.margins", json!({"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}));     run(&mut s, "layout.size", json!({"width": 40.0, "height": 5000.0}));-    assert_eq!(size(&s), (40.0, 1584.0), "clamped to 22\"");+    // A 600 cm page is a real page now: 5000 pt stays, and only past the model's maximum is it+    // clamped.+    assert_eq!(size(&s), (40.0, 5000.0), "5000 pt is under 600 cm");+    run(&mut s, "layout.size", json!({"width": 40.0, "height": 1e6}));+    assert_eq!(size(&s), (40.0, wordcraft_doc::section::MAX_PAGE_SIDE), "clamped to 600 cm");     for junk in [         json!({"width": 1e300, "height": 400.0}),         json!({"width": "NaN", "height": 400.0}),@@ -2646,7 +2650,7 @@ fn layout_size_takes_a_custom_size_and_clamps_it() {         json!({"width": 1.0, "height": 400.0}),     ] {         assert!(s.run("layout.size", &junk).is_err(), "{junk}");-        assert_eq!(size(&s), (40.0, 1584.0), "{junk} leaves the size alone");+        assert_eq!(size(&s), (40.0, wordcraft_doc::section::MAX_PAGE_SIDE), "{junk} leaves the size alone");     }     // A landscape section keeps its orientation: the long side goes across.     run(&mut s, "layout.orientation", json!({"value": "landscape"}));diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs--- a/crates/formats/src/odt.rs+++ b/crates/formats/src/odt.rs@@ -24,7 +24,7 @@ use std::sync::Arc; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use wordcraft_doc::props::VAlign;-use wordcraft_doc::section::{Columns, SectionStart};+use wordcraft_doc::section::{Columns, MAX_PAGE_SIDE, SectionStart}; use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps};  use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of};@@ -2717,9 +2717,10 @@ fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) { /// A `style:page-layout-properties` element's page size, orientation and margins into `sec`. fn apply_layout(a: &Attrs, sec: &mut SectionProps) {     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));+    // Like the DOCX reader: a page is at least half an inch; at most 600 cm (the model's limit,+    // LibreOffice's custom size), so a hostile `1e30pt` can't reach layout but a Writer page of+    // 600 cm still opens instead of being truncated to Word's 22 inches.+    let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(MAX_PAGE_SIDE));     if let (Some(w), Some(h)) = (page("fo:page-width"), page("fo:page-height")) {         sec.page_w = w;         sec.page_h = h;@@ -3033,11 +3034,13 @@ mod tests {                 </office:document-styles>"#             )         };-        // Huge values: the page is capped at 22 inches and margins at the page's longer side.+        // Huge values: the page is capped at the model's 600 cm and margins at the page's+        // longer side.+        let max = wordcraft_doc::section::MAX_PAGE_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));+        assert_eq!((sec.page_w, sec.page_h), (max, max));+        assert_eq!((sec.margin_top, sec.margin_left), (max, max));         // 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")] {@@ -3697,6 +3700,20 @@ mod tests {         assert_eq!(marks(&back), want, "after a save");     } +    /// A 600 cm page (LibreOffice's custom size) survives ODT: the reader used to clamp page+    /// sides to 22 inches (55.88 cm), which truncated the Writer document.+    #[test]+    fn a_six_hundred_centimetre_page_survives_odt() {+        let cm = 72.0 / 2.54;+        let mut d = wordcraft_doc::Document::new();+        d.ensure_nonempty();+        d.last_section.page_w = cm * 19.0;+        d.last_section.page_h = cm * 600.0;+        let back = super::import(&super::export(&d).unwrap()).unwrap();+        assert!((back.last_section.page_h - cm * 600.0).abs() < 0.5, "{}", back.last_section.page_h);+        assert!((back.last_section.page_w - cm * 19.0).abs() < 0.5);+    }+     #[test]     fn insertions_end_before_a_table_and_inside_a_cell() {         let regions = region("i1", "insertion", "Alice", "2024-03-01T10:00:00", "") + &region("i2", "insertion", "Bob", "2024-03-02T10:00:00", "");