use std::io::Read;
use std::path::{Path, PathBuf};
#[allow(dead_code)]
pub fn detect_linked_excel(pptx_path: &Path) -> Option<PathBuf> {
let all = detect_all_linked_excels(pptx_path);
all.into_iter().next()
}
pub fn detect_all_linked_excels(pptx_path: &Path) -> Vec<PathBuf> {
let file = match std::fs::File::open(pptx_path) {
Ok(f) => f,
Err(_) => return vec![],
};
let mut archive = match zip::ZipArchive::new(file) {
Ok(a) => a,
Err(_) => return vec![],
};
let mut seen_filenames: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut paths: Vec<PathBuf> = Vec::new();
let rels_indices: Vec<usize> = (0..archive.len())
.filter(|&i| {
if let Ok(entry) = archive.by_index(i) {
let name = entry.name().to_string();
if !name.ends_with(".rels") { return false; }
let normalized = name.replace('\\', "/");
let parts: Vec<&str> = normalized.split('/').collect();
if parts.len() < 3 { return false; }
let rels_dir = parts[parts.len() - 2];
let parent_dir = parts[parts.len() - 3];
rels_dir == "_rels" && parent_dir == "slides"
} else {
false
}
})
.collect();
for i in rels_indices {
let mut entry = match archive.by_index(i) {
Ok(e) => e,
Err(_) => continue,
};
let mut xml_data = Vec::new();
if entry.read_to_end(&mut xml_data).is_err() {
continue;
}
for path in extract_all_excel_paths_from_rels(&xml_data) {
let filename_lower = path.file_name()
.map(|f| f.to_string_lossy().to_lowercase())
.unwrap_or_default();
if !filename_lower.is_empty() && seen_filenames.insert(filename_lower) {
paths.push(path);
}
}
}
paths
}
fn extract_all_excel_paths_from_rels(data: &[u8]) -> Vec<PathBuf> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_reader(data);
let mut paths = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
if e.local_name().as_ref() != b"Relationship" {
continue;
}
let target_mode = e.try_get_attribute("TargetMode")
.ok().flatten()
.map(|a| String::from_utf8_lossy(a.value.as_ref()).to_string());
if target_mode.as_deref() != Some("External") {
continue;
}
let target = match e.try_get_attribute("Target").ok().flatten() {
Some(a) => String::from_utf8_lossy(a.value.as_ref()).to_string(),
None => continue,
};
if !target.starts_with("file:///") {
continue;
}
let path_str = &target["file:///".len()..];
let path_str = strip_suffix_after_extension(path_str);
let path_str = percent_decode(path_str);
let path_str = path_str.replace('/', std::path::MAIN_SEPARATOR_STR);
paths.push(PathBuf::from(path_str));
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => continue,
}
}
paths
}
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(
std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
16,
) {
result.push(byte as char);
i += 3;
continue;
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn strip_suffix_after_extension(path: &str) -> &str {
let lower = path.to_lowercase();
for ext in &[".xlsx", ".xls", ".xlsm", ".xlsb"] {
if let Some(pos) = lower.find(ext) {
let end = pos + ext.len();
return &path[..end];
}
}
if let Some(pos) = path.find('!') {
&path[..pos]
} else {
path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_suffix_xlsx() {
assert_eq!(
strip_suffix_after_extension("C:/Data/report.xlsx!Sheet1!R1C1"),
"C:/Data/report.xlsx"
);
}
#[test]
fn test_strip_suffix_no_suffix() {
assert_eq!(
strip_suffix_after_extension("C:/Data/report.xlsx"),
"C:/Data/report.xlsx"
);
}
#[test]
fn test_strip_suffix_xls() {
assert_eq!(
strip_suffix_after_extension("C:/Data/old.xls!Sheet1!A1"),
"C:/Data/old.xls"
);
}
#[test]
fn test_strip_suffix_bang_in_name() {
assert_eq!(
strip_suffix_after_extension("C:/Data/report!v2.xlsx!Sheet1"),
"C:/Data/report!v2.xlsx"
);
}
#[test]
fn test_strip_suffix_no_extension() {
assert_eq!(
strip_suffix_after_extension("C:/Data/report!Sheet1"),
"C:/Data/report"
);
}
#[test]
fn test_extract_from_rels_xml() {
let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Target="file:///C:/Users/data/report.xlsx!Tables!R1C1:R5C5" TargetMode="External"/>
</Relationships>"#;
let results = extract_all_excel_paths_from_rels(xml);
assert!(!results.is_empty());
let path = &results[0];
assert!(path.to_string_lossy().contains("report.xlsx"));
}
#[test]
fn test_extract_no_external_links() {
let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Target="../media/image.png"/>
</Relationships>"#;
assert!(extract_all_excel_paths_from_rels(xml).is_empty());
}
#[test]
fn test_percent_decode() {
assert_eq!(percent_decode("hello%20world"), "hello world");
assert_eq!(percent_decode("no%20change%21"), "no change!");
assert_eq!(percent_decode("plain"), "plain");
assert_eq!(percent_decode("100%25"), "100%");
assert_eq!(percent_decode("~%20PMI"), "~ PMI");
}
#[test]
fn test_detect_linked_excel_on_real_pptx() {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("quick_test_files")
.join("test_template.pptx");
if !path.exists() {
return;
}
let result = detect_linked_excel(&path);
assert!(result.is_some(), "Expected to find linked Excel in test_template.pptx");
let excel_path = result.unwrap();
println!("Detected Excel: {}", excel_path.display());
assert!(excel_path.to_string_lossy().contains(".xlsx"));
}
}