From 44b133a3c9a2e56b0b8e9a2e5bc8a13966b0f14a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:45:18 -0600 Subject: [PATCH 1/2] Preserve text overflow settings in PPTX --- crates/model/src/resolve.rs | 2 ++ crates/model/src/table.rs | 1 + crates/model/src/text.rs | 31 +++++++++++++++++++++++++++++++ crates/pptx/src/read/dml.rs | 2 ++ crates/pptx/src/read/shapes.rs | 1 + crates/pptx/src/write/dml.rs | 4 +++- crates/pptx/src/write/shapes.rs | 1 + crates/pptx/tests/it/roundtrip.rs | 27 ++++++++++++++++++++++++++- 8 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8d87229f3..a310bccbf 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -249,6 +249,8 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { rtl_col: Some(false), upright: Some(false), space_first_last: Some(false), + vert_overflow: None, + horz_overflow: None, }); b } diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 0705d1a08..a8031493a 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -23,6 +23,7 @@ pub struct Cell { pub h_merge: bool, pub v_merge: bool, pub vertical: Option, + pub horz_overflow: Option, } impl Cell { diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 362f43939..1839ef30a 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -121,6 +121,35 @@ pub struct BodyProps { pub upright: Option, /// Space before the first paragraph and after the last count too (`spcFirstLastPara`). pub space_first_last: Option, + pub vert_overflow: Option, + pub horz_overflow: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextOverflow { + Overflow, + Clip, + Ellipsis, +} + +impl TextOverflow { + pub fn xml(self) -> &'static str { + match self { + Self::Overflow => "overflow", + Self::Clip => "clip", + Self::Ellipsis => "ellipsis", + } + } + + pub fn from_xml(value: &str) -> Option { + Some(match value { + "overflow" => Self::Overflow, + "clip" => Self::Clip, + "ellipsis" => Self::Ellipsis, + _ => return None, + }) + } } impl BodyProps { @@ -144,6 +173,8 @@ impl BodyProps { self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); self.space_first_last = self.space_first_last.or(p.space_first_last); + self.vert_overflow = self.vert_overflow.or(p.vert_overflow); + self.horz_overflow = self.horz_overflow.or(p.horz_overflow); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index a2ed6abe7..e24d7c94b 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -358,6 +358,8 @@ pub fn body_pr(b: &El) -> BodyProps { rtl_col: b.bool("rtlCol"), upright: b.bool("upright"), space_first_last: b.bool("spcFirstLastPara"), + vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), + horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), ..Default::default() }; for e in b.elements() { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 6d8acc4ad..f5497b8c3 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -805,6 +805,7 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { ]); } c.anchor = pr.attr("anchor").and_then(Anchor::from_xml); + c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml); c.vertical = pr.attr("vert").and_then(TextDir::from_xml); for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { if let Some(ln) = pr.child(n) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 320810958..e355601bf 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -468,7 +468,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .o("anchor", b.anchor.map(|a| a.xml())) .b("anchorCtr", b.anchor_ctr) .b("upright", b.upright) - .b("spcFirstLastPara", b.space_first_last); + .b("spcFirstLastPara", b.space_first_last) + .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) + .o("horzOverflow", b.horz_overflow.map(|v| v.xml())); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..f18bbf466 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -746,6 +746,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { other => other.xml(), }), ); + a = a.o("horzOverflow", c.horz_overflow.map(|v| v.xml())); w.open("a:tcPr", a); for (tag, l) in [ ("a:lnL", &c.borders[0]), diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..3832d2c6c 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -12,7 +12,7 @@ use deckcraft_model::style::{ }; use deckcraft_model::table::Table; use deckcraft_model::text::{ - Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, + Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, TextOverflow, }; use deckcraft_model::{ Background, Comment, CustomPath, CustomShow, Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId, @@ -468,6 +468,31 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn text_overflow_policies_survive_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Text overflow"); + let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); + let mut body = TextBody::from_text("policy"); + body.body.vert_overflow = Some(TextOverflow::Clip); + body.body.horz_overflow = Some(TextOverflow::Ellipsis); + text.text = Some(body); + let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0); + let mut table = Table::new(1, 1, 200.0, 80.0); + table.rows[0].cells[0].horz_overflow = Some(TextOverflow::Clip); + table_shape.kind = ShapeKind::Table(table); + slide.shapes = vec![text, table_shape]; + p.slides.push(Arc::new(slide)); + + let twice = round(&round(&p)); + let slide = twice.slides.last().expect("added slide"); + let text = by_name(slide, "Text").text.as_ref().expect("text body"); + assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip)); + assert_eq!(text.body.horz_overflow, Some(TextOverflow::Ellipsis)); + let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") }; + assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip)); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From 67c1d78948f3fe1322f7df25305fa139eb0a94e1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:47:09 -0500 Subject: [PATCH 2/2] PPTX: horzOverflow never reads or writes ellipsis (vertical only in ST_TextHorzOverflowType) --- crates/model/src/text.rs | 10 ++++++++++ crates/pptx/src/read/dml.rs | 2 +- crates/pptx/src/read/shapes.rs | 2 +- crates/pptx/src/write/dml.rs | 2 +- crates/pptx/src/write/shapes.rs | 2 +- crates/pptx/tests/it/roundtrip.rs | 23 +++++++++++++++++++++-- 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 1839ef30a..d53d0472b 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -150,6 +150,16 @@ impl TextOverflow { _ => return None, }) } + + /// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`. + pub fn from_horz_xml(value: &str) -> Option { + Self::from_xml(value).filter(|v| *v != Self::Ellipsis) + } + + /// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only. + pub fn horz_xml(v: Option) -> Option<&'static str> { + v.filter(|v| *v != Self::Ellipsis).map(Self::xml) + } } impl BodyProps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index e24d7c94b..98e9d5c96 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -359,7 +359,7 @@ pub fn body_pr(b: &El) -> BodyProps { upright: b.bool("upright"), space_first_last: b.bool("spcFirstLastPara"), vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), - horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), + horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml), ..Default::default() }; for e in b.elements() { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index f5497b8c3..864a3e029 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -805,7 +805,7 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { ]); } c.anchor = pr.attr("anchor").and_then(Anchor::from_xml); - c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml); + c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml); c.vertical = pr.attr("vert").and_then(TextDir::from_xml); for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { if let Some(ln) = pr.child(n) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index e355601bf..ec6224d7a 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -470,7 +470,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .b("upright", b.upright) .b("spcFirstLastPara", b.space_first_last) .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) - .o("horzOverflow", b.horz_overflow.map(|v| v.xml())); + .o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow)); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index f18bbf466..847e741dc 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -746,7 +746,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { other => other.xml(), }), ); - a = a.o("horzOverflow", c.horz_overflow.map(|v| v.xml())); + a = a.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(c.horz_overflow)); w.open("a:tcPr", a); for (tag, l) in [ ("a:lnL", &c.borders[0]), diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 3832d2c6c..51670caa0 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -475,7 +475,7 @@ fn text_overflow_policies_survive_two_pptx_round_trips() { let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); let mut body = TextBody::from_text("policy"); body.body.vert_overflow = Some(TextOverflow::Clip); - body.body.horz_overflow = Some(TextOverflow::Ellipsis); + body.body.horz_overflow = Some(TextOverflow::Overflow); text.text = Some(body); let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0); let mut table = Table::new(1, 1, 200.0, 80.0); @@ -488,11 +488,30 @@ fn text_overflow_policies_survive_two_pptx_round_trips() { let slide = twice.slides.last().expect("added slide"); let text = by_name(slide, "Text").text.as_ref().expect("text body"); assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip)); - assert_eq!(text.body.horz_overflow, Some(TextOverflow::Ellipsis)); + assert_eq!(text.body.horz_overflow, Some(TextOverflow::Overflow)); let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") }; assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip)); } +#[test] +fn horizontal_ellipsis_is_not_written_because_only_vertical_overflow_has_it() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Horizontal ellipsis"); + let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); + let mut body = TextBody::from_text("policy"); + body.body.vert_overflow = Some(TextOverflow::Ellipsis); + body.body.horz_overflow = Some(TextOverflow::Ellipsis); + text.text = Some(body); + slide.shapes = vec![text]; + p.slides.push(Arc::new(slide)); + + let once = round(&p); + let slide = once.slides.last().expect("added slide"); + let text = by_name(slide, "Text").text.as_ref().expect("text body"); + assert_eq!(text.body.vert_overflow, Some(TextOverflow::Ellipsis)); + assert_eq!(text.body.horz_overflow, None); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck();