acorn_lib/powerpoint/
mod.rs

1//! ## PowerPoint utilities
2//!
3//! Here you'll find functions for working with [`OOXML`] and creating PowerPoint files.
4//!
5//! [`OOXML`]: https://en.wikipedia.org/wiki/Office_Open_XML
6use crate::schema::{ContactPoint, Metadata, Notes, Other, ResearchActivity, Sections};
7use crate::util::{files_all, path_to_string, read_file, standard_project_folder, write_file, Label};
8use fancy_regex::Regex;
9use quick_xml::events::Event;
10use quick_xml::{Reader, Writer};
11use std::error::Error;
12use std::fs::create_dir_all;
13use std::fs::File;
14use std::io::{self, Read, Write};
15use std::path::PathBuf;
16use tracing::{debug, error, info};
17use zip::write::SimpleFileOptions;
18use zip::{ZipArchive, ZipWriter};
19
20pub mod ooxml;
21use ooxml::*;
22
23/// Creates zip archive from directory
24pub fn archive(path: PathBuf, destination: Option<PathBuf>) -> Result<PathBuf, Box<dyn Error>> {
25    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
26    let zip_file_path = match destination {
27        | Some(value) => value,
28        | None => path.with_extension("zip"),
29    };
30    let mut zip = match File::create(&zip_file_path) {
31        | Ok(zip_file) => ZipWriter::new(zip_file),
32        | Err(why) => {
33            error!(file = path_to_string(path), "=> {} Create zip archive - {why}", Label::fail());
34            std::process::exit(exitcode::IOERR);
35        }
36    };
37    let files = files_all(path.clone(), None).into_iter().filter(|x| x.is_file());
38    for file_path in files {
39        if let Ok(file) = File::open(file_path.clone()) {
40            let name = match path.canonicalize() {
41                | Ok(relative) => file_path.strip_prefix(relative).unwrap_or_else(|_| &file_path),
42                | Err(_) => &file_path,
43            };
44            debug!(file = path_to_string(name.to_path_buf()), "=> {} Add file to archive", Label::using());
45            match zip.start_file_from_path(name, options) {
46                | Ok(_) => {
47                    let mut buffer = Vec::new();
48                    match io::copy(&mut file.take(u64::MAX), &mut buffer) {
49                        | Ok(_) => match zip.write_all(&buffer) {
50                            | Ok(_) => {}
51                            | Err(why) => {
52                                error!(file = path_to_string(file_path), "=> {} Write zip archive - {why}", Label::fail())
53                            }
54                        },
55                        | Err(why) => {
56                            error!("=> {} Copy buffer - {why}", Label::fail())
57                        }
58                    }
59                }
60                | Err(why) => {
61                    error!(file = path_to_string(file_path), "=> {} Start zip archive - {why}", Label::fail());
62                }
63            }
64        }
65    }
66    match zip.finish() {
67        | Ok(_) => Ok(zip_file_path),
68        | Err(why) => {
69            error!(file = path_to_string(path), "=> {} Finish zip archive - {why}", Label::fail());
70            Err(why.into())
71        }
72    }
73}
74/// Extract zip archive
75pub fn extract(path: PathBuf, destination: Option<PathBuf>) -> Result<PathBuf, Box<dyn Error>> {
76    let root = standard_project_folder("extract", destination);
77    match File::open(path.clone()) {
78        | Ok(zip_file) => match ZipArchive::new(zip_file) {
79            | Ok(mut archive) => {
80                for index in 0..archive.len() {
81                    if let Ok(mut file) = archive.by_index(index) {
82                        let target = root.join(file.name());
83                        if let Some(parent) = target.parent() {
84                            match create_dir_all(parent) {
85                                | Ok(_) => {}
86                                | Err(why) => error!(path = path_to_string(parent.to_path_buf()), "=> {} Create - {}", Label::fail(), why),
87                            }
88                        }
89                        if let Ok(mut output_file) = File::create(&target) {
90                            io::copy(&mut file, &mut output_file).unwrap();
91                        } else {
92                            error!(path = path_to_string(target), "=> {} Create file", Label::fail());
93                            std::process::exit(exitcode::UNAVAILABLE);
94                        }
95                    }
96                }
97                info!(path = root.to_str().unwrap(), "=> {} Extract zip archive", Label::pass());
98                Ok(root)
99            }
100            | Err(err) => {
101                error!(
102                    error = err.to_string(),
103                    path = path.to_str().unwrap(),
104                    "=> {} Read zip archive",
105                    Label::fail()
106                );
107                std::process::exit(exitcode::UNAVAILABLE);
108            }
109        },
110        | Err(err) => {
111            error!(error = err.to_string(), path = path.to_str().unwrap(), "=> {} Read file", Label::fail());
112            std::process::exit(exitcode::UNAVAILABLE);
113        }
114    }
115}
116/// Interpolate research activity data into PowerPoint template
117pub fn interpolate_values(path: PathBuf, data: ResearchActivity) {
118    let updated = match read_file(path.clone()) {
119        | Ok(mut content) => {
120            let ResearchActivity {
121                contact,
122                meta,
123                notes,
124                title,
125                subtitle,
126                sections,
127                ..
128            } = data.clone();
129            let Metadata { doi, partners, .. } = meta.clone();
130            let Sections {
131                achievement,
132                impact,
133                approach,
134                ..
135            } = sections;
136            let ContactPoint {
137                given_name: first,
138                family_name: last,
139                email,
140                ..
141            } = contact;
142            let caption = meta.first_image_caption();
143            let presentation_notes = match notes.clone() {
144                | Some(value) => match value {
145                    | Other::Formatted(Notes { presentation, .. }) => match presentation {
146                        | Some(value) => value,
147                        | None => "".to_string(),
148                    },
149                    | Other::Unformatted(notes) => notes,
150                },
151                | None => "".to_string(),
152            };
153            let managers = match notes.clone() {
154                | Some(value) => match value {
155                    | Other::Formatted(Notes { managers, .. }) => match managers {
156                        | Some(value) => value,
157                        | None => vec![],
158                    },
159                    | _ => vec![],
160                },
161                | None => vec![],
162            };
163            let programs = match notes {
164                | Some(value) => match value {
165                    | Other::Formatted(Notes { programs, .. }) => match programs {
166                        | Some(value) => value,
167                        | None => vec![],
168                    },
169                    | _ => vec![],
170                },
171                | None => vec![],
172            };
173            content = replace_placeholder_with_string(&content, "title", &title);
174            content = replace_placeholder_with_string(&content, "subtitle", &subtitle.unwrap_or_else(|| "".to_string()));
175            content = replace_placeholder_with_string(&content, "first", &first);
176            content = replace_placeholder_with_string(&content, "last", &last);
177            content = replace_placeholder_with_string(&content, "email", &email);
178            content = replace_placeholder_with_string(&content, "partners", &partners.unwrap().join(", "));
179            content = replace_placeholder_with_string(&content, "programs", &programs.join(" and "));
180            content = replace_placeholder_with_string(&content, "managers", &managers.join(" and "));
181            // TODO: Use CiteAs API to get in Chicago format
182            content = replace_placeholder_with_string(&content, "citation", &doi.unwrap()[0]);
183            content = replace_placeholder_with_string(&content, "caption", &caption);
184            content = replace_placeholder_with_string(&content, "notes", &presentation_notes);
185            content = replace_placeholder_with_bullets(&content, "achievement", achievement.unwrap_or_else(std::vec::Vec::new));
186            content = replace_placeholder_with_bullets(&content, "impact", impact);
187            content = replace_placeholder_with_bullets(&content, "technical", approach);
188            content
189        }
190        | Err(why) => {
191            error!("=> {} Cannot read file for interpolation - {why}", Label::fail());
192            std::process::exit(exitcode::IOERR);
193        }
194    };
195    match write_file(path.clone(), updated) {
196        | Ok(_) => {}
197        | Err(why) => {
198            error!("=> {} Cannot write file for interpolation - {why}", Label::fail());
199            std::process::exit(exitcode::IOERR);
200        }
201    }
202}
203/// Parse OOXML paragraph object
204pub fn parse_ooxml_paragraph(content: &str) -> Result<TextParagraph, quick_xml::DeError> {
205    let parsed = quick_xml::de::from_str::<TextParagraph>(content);
206    debug!("=> {} OOXMLParagraph = {:#?}", Label::using(), parsed);
207    parsed
208}
209/// Prettify XML
210pub fn prettify_xml(xml: &str) -> String {
211    let mut buf: Vec<u8> = Vec::new();
212    let mut reader = Reader::from_str(xml);
213    let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
214    loop {
215        let ev = reader.read_event();
216        match ev {
217            | Ok(Event::Eof) => break, // exits the loop when reaching end of file
218            | Ok(event) => writer.write_event(event),
219            | Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
220        }
221        .expect("Failed to parse XML");
222        buf.clear();
223    }
224    let result = std::str::from_utf8(&writer.into_inner())
225        .expect("Failed to convert a slice of bytes to a string slice")
226        .to_string();
227    result
228}
229/// Read OOXML relationships XML file
230pub fn read_xml_rel(path: PathBuf) -> Result<Relationships, quick_xml::DeError> {
231    match read_file(path) {
232        | Ok(content) => {
233            let parsed = quick_xml::de::from_str::<Relationships>(&content);
234            debug!("=> {} Relationships = {:#?}", Label::using(), parsed);
235            parsed
236        }
237        | Err(why) => {
238            error!("=> {} Cannot read xml.rels file - {why}", Label::fail());
239            std::process::exit(exitcode::IOERR);
240        }
241    }
242}
243/// Replace placeholder with bullet values in PowerPoint template
244pub fn replace_placeholder_with_bullets<I: IntoIterator<Item = String>>(xml: &str, placeholder: &str, values: I) -> String {
245    let paragraphs = match Regex::new(r#"<a:p>(?:(?!<a:p>|</a:p>)[\s\S])*</a:p>"#) {
246        | Ok(re) => re
247            .find_iter(xml)
248            .flat_map(|m| m.ok())
249            .map(|m| m.as_str().to_string())
250            .collect::<Vec<String>>(),
251        | Err(_) => unreachable!(),
252    };
253    let selected = match paragraphs
254        .into_iter()
255        .find(|x| match Regex::new(&format!(r"{{{{\s*{placeholder}\s*}}}}")) {
256            | Ok(re) => re.is_match(x).unwrap_or_default(),
257            | Err(_) => false,
258        }) {
259        | Some(value) => value,
260        | None => "".to_string(),
261    };
262    match parse_ooxml_paragraph(&selected) {
263        | Ok(paragraph) => {
264            let TextParagraph {
265                text_paragraph_properties,
266                text_run,
267                end_paragraph_run_properties,
268                ..
269            } = paragraph.clone();
270            let bullet_properties = match text_paragraph_properties.first() {
271                | Some(value) => value,
272                | None => &TextParagraphProperties::init().build(),
273            };
274            let text_properties = match text_run.first() {
275                | Some(first) => match first.text_run_properties.first() {
276                    | Some(value) => value,
277                    | None => todo!(),
278                },
279                | None => todo!(),
280            };
281            let content: Vec<String> = values
282                .into_iter()
283                .map(|value| {
284                    let run = TextRun::init()
285                        .text_run_properties(vec![text_properties.clone()])
286                        .text(TextString { value })
287                        .build();
288                    let custom = TextParagraph::init()
289                        .text_paragraph_properties(vec![bullet_properties.clone()])
290                        .text_run(vec![run])
291                        .maybe_end_paragraph_run_properties(end_paragraph_run_properties.clone())
292                        .build();
293                    prettify_xml(&quick_xml::se::to_string(&custom).unwrap())
294                })
295                .collect();
296            let formatted = content.join("");
297            xml.replace(&selected, &formatted)
298        }
299        | Err(why) => {
300            debug!(selected, "=> {} Parse OOXML Paragraph - {why}", Label::fail());
301            xml.to_string()
302        }
303    }
304}
305/// Replace placeholder with string value in PowerPoint template
306pub fn replace_placeholder_with_string(content: &str, placeholder: &str, value: &str) -> String {
307    match Regex::new(&format!(r"{{{{\s*{placeholder}\s*}}}}")) {
308        | Ok(re) => re.replace_all(content, value).to_string(),
309        | Err(err) => {
310            error!("=> {} Regex replacement - {err}", Label::fail());
311            content.to_string()
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests;