From 8770fb90c3a232a248c791545d144c459d0c3ac6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:45:55 -0500 Subject: [PATCH] Preserve bullet follow-text resets in PPTX --- crates/model/src/text.rs | 39 ++++++++++++++++++++++++++++--- crates/pptx/src/read/dml.rs | 3 +++ crates/pptx/src/write/dml.rs | 15 +++++++++--- crates/pptx/tests/it/roundtrip.rs | 27 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 17d606de..1c70e397 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -236,10 +236,13 @@ pub struct ParaProps { pub space_after: Option, pub bullet: Option, pub bullet_font: Option, + pub bullet_font_follow_text: Option, /// `None` = follow text colour. pub bullet_color: Option, + pub bullet_color_follow_text: Option, /// Bullet size as a fraction of the text size. pub bullet_size: Option, + pub bullet_size_follow_text: Option, pub tabs: Option>, pub default_tab: Option, pub rtl: Option, @@ -263,13 +266,15 @@ impl ParaProps { if self.bullet.is_none() { self.bullet.clone_from(&p.bullet); } - if self.bullet_font.is_none() { + if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() { self.bullet_font.clone_from(&p.bullet_font); } - if self.bullet_color.is_none() { + if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() { self.bullet_color.clone_from(&p.bullet_color); } - self.bullet_size = self.bullet_size.or(p.bullet_size); + if self.bullet_size_follow_text.is_none() { + self.bullet_size = self.bullet_size.or(p.bullet_size); + } if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } @@ -632,3 +637,31 @@ impl TextBody { n + self.paragraphs.len().saturating_sub(1) } } + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_color::Rgba; + + #[test] + fn explicit_bullet_follow_text_resets_block_inheritance() { + let parent = ParaProps { + bullet_font: Some("Inter".into()), + bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))), + bullet_size: Some(2.0), + ..Default::default() + }; + let mut child = ParaProps { + bullet_font_follow_text: Some(true), + bullet_color_follow_text: Some(true), + bullet_size_follow_text: Some(true), + ..Default::default() + }; + + child.inherit(&parent); + + assert_eq!(child.bullet_font, None); + assert_eq!(child.bullet_color, None); + assert_eq!(child.bullet_size, None); + } +} diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc0..fe02d9b4 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -431,8 +431,11 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } } "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), + "buFontTx" => pp.bullet_font_follow_text = Some(true), "buClr" => pp.bullet_color = color(e), + "buClrTx" => pp.bullet_color_follow_text = Some(true), "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzTx" => pp.bullet_size_follow_text = Some(true), "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..5c47aa30 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -533,8 +533,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: || p.space_after.is_some() || p.bullet.is_some() || p.bullet_font.is_some() + || p.bullet_font_follow_text.is_some() || p.bullet_color.is_some() + || p.bullet_color_follow_text.is_some() || p.bullet_size.is_some() + || p.bullet_size_follow_text.is_some() || p.tabs.is_some() || def.is_some(); if !has_kids { @@ -551,15 +554,21 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: if let Some(s) = &p.space_after { spacing(w, "a:spcAft", s); } - if let Some(c) = &p.bullet_color { + if p.bullet_color_follow_text == Some(true) { + w.empty0("a:buClrTx"); + } else if let Some(c) = &p.bullet_color { w.open0("a:buClr"); color(w, c); w.close("a:buClr"); } - if let Some(s) = p.bullet_size { + if p.bullet_size_follow_text == Some(true) { + w.empty0("a:buSzTx"); + } else if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } - if let Some(f) = &p.bullet_font { + if p.bullet_font_follow_text == Some(true) { + w.empty0("a:buFontTx"); + } else if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } match &p.bullet { diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9..d0c1a597 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -488,6 +488,33 @@ 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_follow_text_resets_survive_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut textbox = shape(&mut p, "Follow text bullets", 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_color_follow_text: Some(true), + bullet_size_follow_text: Some(true), + bullet_font_follow_text: Some(true), + ..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], "Follow text bullets").text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.bullet_color_follow_text, Some(true)); + assert_eq!(props.bullet_size_follow_text, Some(true)); + assert_eq!(props.bullet_font_follow_text, Some(true)); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck();