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