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
@@ -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")]@@ -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 => {@@ -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,@@ -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 =@@ -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",@@ -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")@@ -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");@@ -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"}));@@ -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", "") + ®ion("i2", "insertion", "Bob", "2024-03-02T10:00:00", "");