1use crate::api::Presentation;
7use crate::exc::Result;
8use crate::generator::SlideContent;
9
10#[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#[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#[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#[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 pub fn is_equal(&self) -> bool {
88 self.title_changed.is_none() && self.slide_changes.is_empty()
89 }
90
91 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
128pub 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
176pub 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
224pub 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}