From b1900e526d02ab4b05c88de0782a0b8953f99be4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:42:28 -0500 Subject: [PATCH 1/5] Preserve point-sized bullets in PPTX --- crates/model/src/text.rs | 3 +++ crates/pptx/src/read/dml.rs | 4 ++++ crates/pptx/src/write/dml.rs | 4 ++++ crates/pptx/tests/it/roundtrip.rs | 24 ++++++++++++++++++++++++ crates/text/src/shape.rs | 4 ++-- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 17d606de..525f1df3 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -240,6 +240,8 @@ pub struct ParaProps { pub bullet_color: Option, /// Bullet size as a fraction of the text size. pub bullet_size: Option, + /// Absolute bullet font size in points, independent of the paragraph's run size. + pub bullet_size_points: Option, pub tabs: Option>, pub default_tab: Option, pub rtl: Option, @@ -270,6 +272,7 @@ impl ParaProps { self.bullet_color.clone_from(&p.bullet_color); } self.bullet_size = self.bullet_size.or(p.bullet_size); + self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc0..7a3cc217 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -433,6 +433,10 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPts" => { + pp.bullet_size_points = + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + } "tabLst" => { pp.tabs = Some( e.children_named("tab") diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 518f8c4a..70b085e2 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -535,6 +535,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: || p.bullet_font.is_some() || p.bullet_color.is_some() || p.bullet_size.is_some() + || p.bullet_size_points.is_some() || p.tabs.is_some() || def.is_some(); if !has_kids { @@ -559,6 +560,9 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } + if let Some(s) = p.bullet_size_points { + w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); + } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9..7936234c 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -488,6 +488,30 @@ 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 bullet_size_in_points_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); + textbox.text = Some(TextBody { + paragraphs: vec![Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(12.0), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], + ..Default::default() + }], + ..Default::default() + }); + Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); + + let q = round(&round(&p)); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.bullet_size_points, Some(12.0)); + assert_eq!(props.bullet_size, None); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck(); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 2be8424f..d5558001 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -76,7 +76,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; rp.strike = None; rp.highlight = None; @@ -105,7 +105,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; let mut st = make_style(ctx, &rp, scale, opts.prompt_color); let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))); From 917d4b96a9ceb2063a2739a098375fad1395f3bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:07:54 -0500 Subject: [PATCH 2/5] Honor bullet size schema bounds and inheritance --- crates/model/src/text.rs | 29 +++++++++++++++++++++++++++-- crates/pptx/src/read/dml.rs | 8 ++++++-- crates/pptx/src/write/dml.rs | 7 +++---- crates/pptx/tests/it/roundtrip.rs | 25 ++++++++++++++----------- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 525f1df3..8d01cdcd 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -271,8 +271,13 @@ impl ParaProps { if self.bullet_color.is_none() { self.bullet_color.clone_from(&p.bullet_color); } - self.bullet_size = self.bullet_size.or(p.bullet_size); - self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); + if self.bullet_size.is_none() && self.bullet_size_points.is_none() { + if p.bullet_size_points.is_some() { + self.bullet_size_points = p.bullet_size_points; + } else { + self.bullet_size = p.bullet_size; + } + } if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } @@ -287,6 +292,26 @@ impl ParaProps { } } +#[cfg(test)] +mod bullet_size_tests { + use super::ParaProps; + + #[test] + fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() { + let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() }; + let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, Some(2.0)); + assert_eq!(child.bullet_size_points, None); + + let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() }; + let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, None); + assert_eq!(child.bullet_size_points, Some(14.0)); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Caps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 7a3cc217..5076c376 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -432,10 +432,14 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), - "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPct" => { + pp.bullet_size_points = None; + pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + } "buSzPts" => { + pp.bullet_size = None; pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); } "tabLst" => { pp.tabs = Some( diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 70b085e2..9efb990f 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -557,12 +557,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: color(w, c); w.close("a:buClr"); } - if let Some(s) = p.bullet_size { + if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) { + w.val("a:buSzPts", ((s * 100.0).round() as u32).to_string()); + } else if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } - if let Some(s) = p.bullet_size_points { - w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); - } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 7936234c..065eb849 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -493,23 +493,26 @@ fn bullet_size_in_points_survives_two_pptx_round_trips() { let mut p = Presentation::default(); let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); textbox.text = Some(TextBody { - paragraphs: vec![Paragraph { - props: deckcraft_model::text::ParaProps { - bullet: Some(Bullet::Char { char: "•".into() }), - bullet_size_points: Some(12.0), + paragraphs: [12.0, 500.0, 4000.0] + .into_iter() + .map(|size| Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(size), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], ..Default::default() - }, - runs: vec![Run::with("Bullet text", RunProps::default())], - ..Default::default() - }], + }) + .collect(), ..Default::default() }); Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); let q = round(&round(&p)); - let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; - assert_eq!(props.bullet_size_points, Some(12.0)); - assert_eq!(props.bullet_size, None); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs; + assert_eq!(props.iter().map(|p| p.props.bullet_size_points).collect::>(), vec![Some(12.0), Some(500.0), Some(4000.0)]); + assert!(props.iter().all(|p| p.props.bullet_size.is_none())); } #[test] From 98138f563b357921ebc2f6fe6ff7ffd57d50aae7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:24:07 -0500 Subject: [PATCH 3/5] Trim whitespace in bullet point sizes --- crates/pptx/src/read/dml.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 5076c376..11949be5 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -23,6 +23,10 @@ pub fn pct(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 100_000.0) } +fn bullet_size_points(value: Option<&str>) -> Option { + value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -438,8 +442,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buSzPts" => { pp.bullet_size = None; - pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); + pp.bullet_size_points = bullet_size_points(e.attr("val")); } "tabLst" => { pp.tabs = Some( @@ -676,6 +679,13 @@ mod tests { assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); } + #[test] + fn bullet_point_size_trims_schema_whitespace() { + let d = parse(br#""#).unwrap(); + assert_eq!(bullet_size_points(d.root.attr("val")), Some(14.0)); + assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); + } + #[test] fn body_props() { let d = parse(br#""#).unwrap(); From 3cfd4c5e6e14bcbda200cb962bc439878aec8d95 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:58 -0600 Subject: [PATCH 4/5] Parse percentage-form bullet sizes --- crates/pptx/src/read/dml.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 37250201..300773a5 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -27,6 +27,12 @@ fn bullet_size_points(value: Option<&str>) -> Option { value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) } +fn bullet_size_percent(value: Option<&str>) -> Option { + let value = value?.trim(); + let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) }; + value.parse::().ok().filter(|v| v.is_finite()).map(|v| v / scale) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -461,7 +467,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buSzPct" => { pp.bullet_size_points = None; pp.bullet_size_follow_text = None; - pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + pp.bullet_size = bullet_size_percent(e.attr("val")).map(|v| v.clamp(0.25, 4.0)); } "buSzPts" => { pp.bullet_size = None; @@ -716,6 +722,14 @@ mod tests { assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); } + #[test] + fn bullet_percentage_size_accepts_numeric_and_percent_forms() { + assert_eq!(bullet_size_percent(Some("100000")), Some(1.0)); + assert_eq!(bullet_size_percent(Some("100%")), Some(1.0)); + assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25)); + assert_eq!(bullet_size_percent(Some("invalid")), None); + } + #[test] fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { assert_eq!(parse_miter_limit("200000"), Some(200_000)); From 97050a353b91a63c2800bcb186d90398b6cecf58 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 23:07:34 -0500 Subject: [PATCH 5/5] Bullets: an own bullet size still clears an inherited follow-text flag (as #1589 did) Co-Authored-By: Claude Opus 5.5 --- crates/model/src/text.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 25c29074..ca992c38 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -288,7 +288,7 @@ impl ParaProps { self.bullet_color.clone_from(&p.bullet_color); } } - if self.bullet_size_follow_text == Some(false) { + if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() { self.bullet_size_follow_text = None; } if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() {