Skip to main content

ppt_rs/diff/
mod.rs

1//! Semantic comparison of presentations for review and CI.
2//!
3//! Compares slide-level content (title, bullets, images, charts, tables, media)
4//! without requiring byte-identical OOXML.
5
6use crate::api::Presentation;
7use crate::exc::Result;
8use crate::generator::SlideContent;
9
10/// Semantic snapshot of one slide's review-relevant content.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SlideSemantic {
13    pub title: String,
14    pub bullets: Vec<String>,
15    pub image_count: usize,
16    pub image_alt_texts: Vec<String>,
17    pub chart_summaries: Vec<String>,
18    pub table_rows: usize,
19    pub table_cols: usize,
20    pub video_count: usize,
21    pub audio_count: usize,
22}
23
24/// A single field-level change on a slide.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum FieldChange {
27    Title {
28        before: String,
29        after: String,
30    },
31    Bullets {
32        before: Vec<String>,
33        after: Vec<String>,
34    },
35    Images {
36        before_count: usize,
37        after_count: usize,
38        before_alt_texts: Vec<String>,
39        after_alt_texts: Vec<String>,
40    },
41    Charts {
42        before: Vec<String>,
43        after: Vec<String>,
44    },
45    Table {
46        before_rows: usize,
47        before_cols: usize,
48        after_rows: usize,
49        after_cols: usize,
50    },
51    Media {
52        before_videos: usize,
53        before_audios: usize,
54        after_videos: usize,
55        after_audios: usize,
56    },
57}
58
59/// Slide-level addition, removal, or modification.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum SlideChange {
62    Added {
63        index: usize,
64        slide: SlideSemantic,
65    },
66    Removed {
67        index: usize,
68        slide: SlideSemantic,
69    },
70    Modified {
71        index: usize,
72        before: SlideSemantic,
73        after: SlideSemantic,
74        fields: Vec<FieldChange>,
75    },
76}
77
78/// Semantic diff between two presentations.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PresentationDiff {
81    pub title_changed: Option<(String, String)>,
82    pub slide_changes: Vec<SlideChange>,
83}
84
85impl PresentationDiff {
86    /// True when titles and every slide match semantically.
87    pub fn is_equal(&self) -> bool {
88        self.title_changed.is_none() && self.slide_changes.is_empty()
89    }
90
91    /// One-line human summary for CLI output.
92    pub fn summary(&self) -> String {
93        if self.is_equal() {
94            return "No semantic differences".to_string();
95        }
96        let mut parts = Vec::new();
97        if self.title_changed.is_some() {
98            parts.push("title changed".to_string());
99        }
100        let added = self
101            .slide_changes
102            .iter()
103            .filter(|c| matches!(c, SlideChange::Added { .. }))
104            .count();
105        let removed = self
106            .slide_changes
107            .iter()
108            .filter(|c| matches!(c, SlideChange::Removed { .. }))
109            .count();
110        let modified = self
111            .slide_changes
112            .iter()
113            .filter(|c| matches!(c, SlideChange::Modified { .. }))
114            .count();
115        if added > 0 {
116            parts.push(format!("{added} slide(s) added"));
117        }
118        if removed > 0 {
119            parts.push(format!("{removed} slide(s) removed"));
120        }
121        if modified > 0 {
122            parts.push(format!("{modified} slide(s) modified"));
123        }
124        parts.join(", ")
125    }
126}
127
128/// Build a semantic snapshot from generator slide content.
129pub fn slide_semantic(slide: &SlideContent) -> SlideSemantic {
130    let mut image_alt_texts: Vec<String> = slide
131        .images
132        .iter()
133        .filter_map(|img| img.alt_text.as_deref().map(str::to_string))
134        .collect();
135    image_alt_texts.sort();
136
137    let mut chart_summaries: Vec<String> = slide
138        .charts
139        .iter()
140        .map(|c| {
141            format!(
142                "{:?}:{} ({} categories, {} series)",
143                c.chart_type,
144                c.title,
145                c.categories.len(),
146                c.series.len()
147            )
148        })
149        .collect();
150    chart_summaries.sort();
151
152    let (table_rows, table_cols) = slide
153        .table
154        .as_ref()
155        .map(|t| {
156            (
157                t.rows.len(),
158                t.rows.first().map(|r| r.cells.len()).unwrap_or(0),
159            )
160        })
161        .unwrap_or((0, 0));
162
163    SlideSemantic {
164        title: slide.title.clone(),
165        bullets: slide.content.clone(),
166        image_count: slide.images.len(),
167        image_alt_texts,
168        chart_summaries,
169        table_rows,
170        table_cols,
171        video_count: slide.videos.len(),
172        audio_count: slide.audios.len(),
173    }
174}
175
176/// Compare two in-memory presentations.
177pub fn compare_presentations(a: &Presentation, b: &Presentation) -> PresentationDiff {
178    let title_changed = if a.get_title() == b.get_title() {
179        None
180    } else {
181        Some((a.get_title().to_string(), b.get_title().to_string()))
182    };
183
184    let slides_a: Vec<SlideSemantic> = a.slides().iter().map(slide_semantic).collect();
185    let slides_b: Vec<SlideSemantic> = b.slides().iter().map(slide_semantic).collect();
186
187    let max_len = slides_a.len().max(slides_b.len());
188    let mut slide_changes = Vec::new();
189
190    for i in 0..max_len {
191        match (slides_a.get(i), slides_b.get(i)) {
192            (Some(before), Some(after)) => {
193                if before != after {
194                    slide_changes.push(SlideChange::Modified {
195                        index: i,
196                        before: before.clone(),
197                        after: after.clone(),
198                        fields: diff_slide_fields(before, after),
199                    });
200                }
201            }
202            (Some(before), None) => {
203                slide_changes.push(SlideChange::Removed {
204                    index: i,
205                    slide: before.clone(),
206                });
207            }
208            (None, Some(after)) => {
209                slide_changes.push(SlideChange::Added {
210                    index: i,
211                    slide: after.clone(),
212                });
213            }
214            (None, None) => {}
215        }
216    }
217
218    PresentationDiff {
219        title_changed,
220        slide_changes,
221    }
222}
223
224/// Load two PPTX files and compare them semantically.
225pub fn compare_pptx_paths(old_path: &str, new_path: &str) -> Result<PresentationDiff> {
226    let a = Presentation::from_path(old_path)?;
227    let b = Presentation::from_path(new_path)?;
228    Ok(compare_presentations(&a, &b))
229}
230
231fn diff_slide_fields(before: &SlideSemantic, after: &SlideSemantic) -> Vec<FieldChange> {
232    let mut fields = Vec::new();
233    if before.title != after.title {
234        fields.push(FieldChange::Title {
235            before: before.title.clone(),
236            after: after.title.clone(),
237        });
238    }
239    if before.bullets != after.bullets {
240        fields.push(FieldChange::Bullets {
241            before: before.bullets.clone(),
242            after: after.bullets.clone(),
243        });
244    }
245    if before.image_count != after.image_count || before.image_alt_texts != after.image_alt_texts {
246        fields.push(FieldChange::Images {
247            before_count: before.image_count,
248            after_count: after.image_count,
249            before_alt_texts: before.image_alt_texts.clone(),
250            after_alt_texts: after.image_alt_texts.clone(),
251        });
252    }
253    if before.chart_summaries != after.chart_summaries {
254        fields.push(FieldChange::Charts {
255            before: before.chart_summaries.clone(),
256            after: after.chart_summaries.clone(),
257        });
258    }
259    if before.table_rows != after.table_rows || before.table_cols != after.table_cols {
260        fields.push(FieldChange::Table {
261            before_rows: before.table_rows,
262            before_cols: before.table_cols,
263            after_rows: after.table_rows,
264            after_cols: after.table_cols,
265        });
266    }
267    if before.video_count != after.video_count || before.audio_count != after.audio_count {
268        fields.push(FieldChange::Media {
269            before_videos: before.video_count,
270            before_audios: before.audio_count,
271            after_videos: after.video_count,
272            after_audios: after.audio_count,
273        });
274    }
275    fields
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::generator::SlideContent;
282    use crate::generator::charts::{Chart, ChartSeries, ChartType};
283    use crate::generator::images::Image;
284    use crate::helpers::tables::simple_table;
285
286    fn deck(title: &str, slides: Vec<SlideContent>) -> Presentation {
287        let mut pres = Presentation::with_title(title);
288        for slide in slides {
289            pres = pres.add_slide(slide);
290        }
291        pres
292    }
293
294    #[test]
295    fn identical_presentations_have_no_diff() {
296        let a = deck(
297            "Same",
298            vec![
299                SlideContent::new("A").add_bullet("one"),
300                SlideContent::new("B").add_bullet("two"),
301            ],
302        );
303        let b = deck(
304            "Same",
305            vec![
306                SlideContent::new("A").add_bullet("one"),
307                SlideContent::new("B").add_bullet("two"),
308            ],
309        );
310        assert!(compare_presentations(&a, &b).is_equal());
311    }
312
313    #[test]
314    fn detects_title_and_bullet_changes() {
315        let a = deck(
316            "Old Title",
317            vec![SlideContent::new("Slide").add_bullet("alpha")],
318        );
319        let b = deck(
320            "New Title",
321            vec![SlideContent::new("Slide").add_bullet("beta")],
322        );
323        let diff = compare_presentations(&a, &b);
324        assert!(!diff.is_equal());
325        assert_eq!(
326            diff.title_changed,
327            Some(("Old Title".into(), "New Title".into()))
328        );
329        assert_eq!(diff.slide_changes.len(), 1);
330        assert!(matches!(
331            &diff.slide_changes[0],
332            SlideChange::Modified { index: 0, fields, .. }
333            if fields.iter().any(|f| matches!(f, FieldChange::Bullets { .. }))
334        ));
335    }
336
337    #[test]
338    fn detects_added_and_removed_slides() {
339        let a = deck("T", vec![SlideContent::new("Only")]);
340        let b = deck(
341            "T",
342            vec![SlideContent::new("First"), SlideContent::new("Second")],
343        );
344        let diff = compare_presentations(&a, &b);
345        assert!(
346            diff.slide_changes
347                .iter()
348                .any(|c| matches!(c, SlideChange::Modified { index: 0, .. }))
349        );
350        assert!(
351            diff.slide_changes
352                .iter()
353                .any(|c| matches!(c, SlideChange::Added { index: 1, .. }))
354        );
355    }
356
357    #[test]
358    fn detects_chart_and_image_differences() {
359        let chart = Chart::new("Sales", ChartType::Bar, vec!["Q1".into()], 0, 0, 1, 1)
360            .add_series(ChartSeries::new("Revenue", vec![1.0]));
361        let a = deck(
362            "T",
363            vec![SlideContent::new("Charts").add_chart(chart.clone())],
364        );
365        let img = Image::new("logo.png", 100, 100, "PNG").with_alt_text("logo");
366        let b = deck(
367            "T",
368            vec![SlideContent::new("Charts").add_image(img).add_chart(chart)],
369        );
370        let diff = compare_presentations(&a, &b);
371        let modified = diff
372            .slide_changes
373            .iter()
374            .find_map(|c| match c {
375                SlideChange::Modified { fields, .. } => Some(fields),
376                _ => None,
377            })
378            .expect("slide modified");
379        assert!(
380            modified
381                .iter()
382                .any(|f| matches!(f, FieldChange::Images { .. }))
383        );
384    }
385
386    #[test]
387    fn detects_table_shape_change() {
388        let table = simple_table(2, 1).build();
389        let a = deck("T", vec![SlideContent::new("Table")]);
390        let b = deck("T", vec![SlideContent::new("Table").table(table)]);
391        let diff = compare_presentations(&a, &b);
392        let fields = match &diff.slide_changes[0] {
393            SlideChange::Modified { fields, .. } => fields,
394            other => panic!("expected modified, got {other:?}"),
395        };
396        assert!(
397            fields
398                .iter()
399                .any(|f| matches!(f, FieldChange::Table { .. }))
400        );
401    }
402
403    #[test]
404    fn compare_pptx_paths_round_trip() {
405        let path = "test_semantic_diff_roundtrip.pptx";
406        deck(
407            "Export",
408            vec![SlideContent::new("Slide 1").add_bullet("Point")],
409        )
410        .save(path)
411        .unwrap();
412        let diff = compare_pptx_paths(path, path).unwrap();
413        assert!(diff.is_equal());
414        let _ = std::fs::remove_file(path);
415    }
416}