From 479c3cc5eadae1b3baf2e572e3d8dc52a2aa201e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:24:08 -0500 Subject: [PATCH 1/2] Preserve explicit miter limits in PPTX --- crates/model/src/style.rs | 2 ++ crates/pptx/src/read/dml.rs | 5 ++++- crates/pptx/src/write/dml.rs | 2 +- crates/pptx/tests/it/roundtrip.rs | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 83c494bd..e3369f35 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -324,6 +324,7 @@ pub struct Line { pub dash: Option, pub cap: Option, pub join: Option, + pub miter_limit: Option, pub compound: Option, pub head: Option, pub tail: Option, @@ -347,6 +348,7 @@ impl Line { } self.cap = self.cap.or(parent.cap); self.join = self.join.or(parent.join); + self.miter_limit = self.miter_limit.or(parent.miter_limit); self.compound = self.compound.or(parent.compound); if self.head.is_none() { self.head.clone_from(&parent.head); diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc0..e1e5dcd4 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -232,7 +232,10 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line { } "round" => l.join = Some(LineJoin::Round), "bevel" => l.join = Some(LineJoin::Bevel), - "miter" => l.join = Some(LineJoin::Miter), + "miter" => { + l.join = Some(LineJoin::Miter); + l.miter_limit = e.attr("lim").and_then(|v| v.parse::().ok()).filter(|v| *v <= 1_000_000); + } "headEnd" => l.head = line_end(e), "tailEnd" => l.tail = line_end(e), _ => {} diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 518f8c4a..a6d00ff4 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -233,7 +233,7 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) { match l.join { Some(LineJoin::Round) => w.empty0("a:round"), Some(LineJoin::Bevel) => w.empty0("a:bevel"), - Some(LineJoin::Miter) => w.empty0("a:miter"), + Some(LineJoin::Miter) => w.empty("a:miter", A::new().o("lim", l.miter_limit)), None => {} } for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] { diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9..59904071 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -488,6 +488,20 @@ fn shapes_fills_lines_effects_round_trip() { assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id); } +#[test] +fn line_miter_limit_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut limited = shape(&mut p, "Limited miter", 20.0, 20.0, 100.0, 100.0); + limited.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(200_000), ..Default::default() }); + let mut defaulted = shape(&mut p, "Default miter", 140.0, 20.0, 100.0, 100.0); + defaulted.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..Default::default() }); + Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted]); + + let q = round(&round(&p)); + assert_eq!(by_name(&q.slides[0], "Limited miter").line.as_ref().and_then(|line| line.miter_limit), Some(200_000)); + assert_eq!(by_name(&q.slides[0], "Default miter").line.as_ref().and_then(|line| line.miter_limit), None); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck(); From 1de0101cedbe69e4e37f380c2737b84e7759ba38 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:11:12 -0500 Subject: [PATCH 2/2] Honor miter percentage schema bounds --- crates/pptx/src/read/dml.rs | 23 ++++++++++++++++++++++- crates/pptx/src/write/dml.rs | 5 ++++- crates/pptx/tests/it/roundtrip.rs | 5 ++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index e1e5dcd4..9ac99912 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -234,7 +234,7 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line { "bevel" => l.join = Some(LineJoin::Bevel), "miter" => { l.join = Some(LineJoin::Miter); - l.miter_limit = e.attr("lim").and_then(|v| v.parse::().ok()).filter(|v| *v <= 1_000_000); + l.miter_limit = e.attr("lim").and_then(parse_miter_limit); } "headEnd" => l.head = line_end(e), "tailEnd" => l.tail = line_end(e), @@ -244,6 +244,17 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line { l } +fn parse_miter_limit(value: &str) -> Option { + const MAX: u32 = i32::MAX as u32; + let value = value.trim(); + if let Some(percent) = value.strip_suffix('%') { + let units = percent.trim().parse::().ok()? * 1000.0; + (units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32) + } else { + value.parse::().ok().filter(|limit| *limit <= MAX) + } +} + fn line_end(e: &El) -> Option { let kind = e.attr("type").unwrap_or("none"); Some(LineEnd { kind: kind.to_string(), w: e.attr("w").unwrap_or("med").to_string(), len: e.attr("len").unwrap_or("med").to_string() }) @@ -671,6 +682,16 @@ mod tests { assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); } + #[test] + fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { + assert_eq!(parse_miter_limit("200000"), Some(200_000)); + assert_eq!(parse_miter_limit("200%"), Some(200_000)); + assert_eq!(parse_miter_limit("1000001"), Some(1_000_001)); + assert_eq!(parse_miter_limit("malformed"), None); + assert_eq!(parse_miter_limit("2147483648"), None); + assert_eq!(parse_miter_limit("2147483.648%"), None); + } + #[test] fn body_props() { let d = parse(br#""#).unwrap(); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index a6d00ff4..d46a7aae 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -233,7 +233,10 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) { match l.join { Some(LineJoin::Round) => w.empty0("a:round"), Some(LineJoin::Bevel) => w.empty0("a:bevel"), - Some(LineJoin::Miter) => w.empty("a:miter", A::new().o("lim", l.miter_limit)), + Some(LineJoin::Miter) => { + let limit = l.miter_limit.filter(|limit| *limit <= i32::MAX as u32); + w.empty("a:miter", A::new().o("lim", limit)); + } None => {} } for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] { diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 59904071..d0525241 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -495,11 +495,14 @@ fn line_miter_limit_survives_two_pptx_round_trips() { limited.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(200_000), ..Default::default() }); let mut defaulted = shape(&mut p, "Default miter", 140.0, 20.0, 100.0, 100.0); defaulted.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..Default::default() }); - Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted]); + let mut large = shape(&mut p, "Large miter", 260.0, 20.0, 100.0, 100.0); + large.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(1_000_001), ..Default::default() }); + Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted, large]); let q = round(&round(&p)); assert_eq!(by_name(&q.slides[0], "Limited miter").line.as_ref().and_then(|line| line.miter_limit), Some(200_000)); assert_eq!(by_name(&q.slides[0], "Default miter").line.as_ref().and_then(|line| line.miter_limit), None); + assert_eq!(by_name(&q.slides[0], "Large miter").line.as_ref().and_then(|line| line.miter_limit), Some(1_000_001)); } #[test]