use super::slide::SlideParser;
#[derive(Debug, Clone)]
pub struct ParagraphEdit<'a> {
pub text: &'a str,
pub level: u32,
pub bold: bool,
pub italic: bool,
pub font_size_pts: u32,
}
impl<'a> ParagraphEdit<'a> {
pub fn new(text: &'a str) -> Self {
Self {
text,
level: 0,
bold: false,
italic: false,
font_size_pts: 18,
}
}
pub fn with_level(mut self, level: u32) -> Self {
self.level = level.min(8);
self
}
pub fn with_bold(mut self, bold: bool) -> Self {
self.bold = bold;
self
}
pub fn with_italic(mut self, italic: bool) -> Self {
self.italic = italic;
self
}
pub fn with_font_size(mut self, pts: u32) -> Self {
self.font_size_pts = pts.max(8);
self
}
pub fn to_xml(&self) -> String {
let lvl_attr = if self.level > 0 {
format!(r#" lvl="{}""#, self.level)
} else {
String::new()
};
let bold = if self.bold { "1" } else { "0" };
let italic = if self.italic { "1" } else { "0" };
let sz = self.font_size_pts * 100;
let escaped = escape_xml(self.text);
format!(
r#"<a:p><a:pPr{lvl_attr}><a:defRPr/></a:pPr><a:r><a:rPr lang="en-US" sz="{sz}" b="{bold}" i="{italic}" dirty="0"/><a:t>{escaped}</a:t></a:r></a:p>"#,
)
}
}
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub(crate) fn nth_sp_range(slide_xml: &str, shape_idx: usize) -> Option<(usize, usize)> {
const OPEN_SHORT: &str = "<p:sp>";
const OPEN_ATTR: &str = "<p:sp ";
const CLOSE: &str = "</p:sp>";
let mut depth: usize = 0;
let mut count: usize = 0;
let mut start: Option<usize> = None;
let mut i = 0;
while i < slide_xml.len() {
let rest = &slide_xml[i..];
if rest.starts_with(OPEN_SHORT) || rest.starts_with(OPEN_ATTR) {
if depth == 0 && count == shape_idx {
start = Some(i);
}
depth += 1;
i += OPEN_SHORT.len();
} else if rest.starts_with(CLOSE) {
depth = depth.saturating_sub(1);
if depth == 0 {
if let Some(s) = start {
return Some((s, i + CLOSE.len()));
}
count += 1;
}
i += CLOSE.len();
} else {
let cur_char_len = rest
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(1);
i += cur_char_len;
}
}
None
}
pub(crate) fn txbody_range(sp_xml: &str) -> Option<(usize, usize)> {
let start_tag = "<p:txBody>";
let end_tag = "</p:txBody>";
let start = sp_xml.find(start_tag)? + start_tag.len();
let end = sp_xml[start..].find(end_tag).map(|o| start + o)?;
Some((start, end))
}
pub fn count_paragraphs_in_txbody(sp_xml: &str) -> usize {
let Some((start, end)) = txbody_range(sp_xml) else {
return 0;
};
sp_xml[start..end].matches("<a:p>").count()
+ sp_xml[start..end].matches("<a:p ").count()
}
pub fn read_paragraphs(slide_xml: &str, shape_idx: usize) -> Vec<String> {
let Some((sp_start, sp_end)) = nth_sp_range(slide_xml, shape_idx) else {
return Vec::new();
};
let sp_xml = &slide_xml[sp_start..sp_end];
let Some((tb_start, tb_end)) = txbody_range(sp_xml) else {
return Vec::new();
};
let inner = &sp_xml[tb_start..tb_end];
let wrapped = format!(
r#"<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="999" name="X"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:txBody>{inner}</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#
);
let parsed = SlideParser::parse(&wrapped);
parsed
.map(|s| {
s.shapes
.into_iter()
.flat_map(|sh| sh.paragraphs.into_iter().map(|p| p.text()))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"<?xml version="1.0"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:txBody><a:p><a:r><a:t>Original Title</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr/><p:nvPr><p:ph type="body"/></p:nvPr></p:nvSpPr>
<p:txBody>
<a:p><a:r><a:t>Bullet 1</a:t></a:r></a:p>
<a:p><a:r><a:t>Bullet 2</a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>"#;
#[test]
fn paragraph_edit_emits_clean_xml() {
let p = ParagraphEdit::new("Hello").with_bold(true).with_font_size(24);
let xml = p.to_xml();
assert!(xml.starts_with("<a:p>"));
assert!(xml.contains("Hello"));
assert!(xml.contains(r#"b="1""#));
assert!(xml.contains(r#"sz="2400""#));
}
#[test]
fn paragraph_edit_escapes_special_chars() {
let p = ParagraphEdit::new("a < b & c > d");
let xml = p.to_xml();
assert!(xml.contains("<"));
assert!(xml.contains("&"));
assert!(xml.contains(">"));
assert!(!xml.contains("a < b"));
}
#[test]
fn paragraph_edit_with_level_sets_lvl_attr() {
let p = ParagraphEdit::new("Nested").with_level(2);
let xml = p.to_xml();
assert!(xml.contains(r#"lvl="2""#));
}
#[test]
fn paragraph_edit_no_level_omits_lvl_attr() {
let p = ParagraphEdit::new("Top level");
let xml = p.to_xml();
assert!(!xml.contains("lvl="));
}
#[test]
fn nth_sp_range_first() {
let (start, end) = nth_sp_range(SAMPLE, 0).unwrap();
let sp = &SAMPLE[start..end];
assert!(sp.contains("Original Title"));
assert!(!sp.contains("Bullet 1"));
}
#[test]
fn nth_sp_range_second() {
let (start, end) = nth_sp_range(SAMPLE, 1).unwrap();
let sp = &SAMPLE[start..end];
assert!(sp.contains("Bullet 1"));
assert!(!sp.contains("Original Title"));
}
#[test]
fn nth_sp_range_out_of_bounds() {
assert!(nth_sp_range(SAMPLE, 5).is_none());
}
#[test]
fn read_paragraphs_returns_each_paragraph_text() {
let paras = read_paragraphs(SAMPLE, 1);
assert_eq!(paras, vec!["Bullet 1", "Bullet 2"]);
}
#[test]
fn txbody_range_finds_inner_offsets() {
let (sp_start, sp_end) = nth_sp_range(SAMPLE, 1).unwrap();
let sp_xml = &SAMPLE[sp_start..sp_end];
let (start, end) = txbody_range(sp_xml).unwrap();
let inner = &sp_xml[start..end];
assert!(inner.contains("Bullet 1"));
assert!(inner.contains("Bullet 2"));
assert!(!inner.contains("<p:txBody>"));
assert!(!inner.contains("</p:txBody>"));
}
}