Skip to main content

dais_sidecar/
metadata.rs

1use crate::types::{PresentationMetadata, SlideGroupMeta};
2
3/// Attempt to extract pdfpc-compatible metadata from a raw pdfpc-format string
4/// embedded in a PDF's info dictionary (typically the "pdfpc" or "pdfpcFormat" key).
5///
6/// `Polylux`, touying, and the `\pdfpc` LaTeX package embed metadata directly
7/// into the compiled PDF (typically in the Info dictionary or XMP stream).
8///
9/// Returns `None` if the input is empty or cannot be parsed.
10pub fn extract_embedded_metadata(raw_pdfpc_data: Option<&str>) -> Option<PresentationMetadata> {
11    let data = raw_pdfpc_data?.trim();
12    if data.is_empty() {
13        return None;
14    }
15
16    // The embedded format is the same INI-like pdfpc format
17    let meta = crate::pdfpc::parser::parse_pdfpc_str(data);
18
19    // Only return if we actually extracted something useful
20    if meta.groups.is_empty() && meta.notes.is_empty() && meta.end_slide.is_none() {
21        return None;
22    }
23
24    Some(meta)
25}
26
27/// Load presentation metadata using the priority chain:
28///
29/// 1. Embedded PDF metadata (highest priority)
30/// 2. `.dais` sidecar file (preferred native format)
31/// 3. `.pdfpc` sidecar file
32/// 4. No metadata (empty default)
33///
34/// The `pdf_path` is the path to the PDF file — sidecars are looked up
35/// by replacing the extension with `.dais` or `.pdfpc`.
36pub fn load_metadata(
37    pdf_path: &std::path::Path,
38    embedded_pdfpc_data: Option<&str>,
39) -> (PresentationMetadata, MetadataSource) {
40    use crate::format::SidecarFormat;
41
42    // Priority 1: .dais sidecar file (user customizations override everything)
43    let dais_path = pdf_path.with_extension("dais");
44    if dais_path.exists() {
45        let format = crate::dais_format::DaisFormat;
46        if let Ok(meta) = format.read(&dais_path) {
47            return (meta, MetadataSource::Sidecar(dais_path));
48        }
49        tracing::warn!("Failed to parse sidecar file: {}", dais_path.display());
50    }
51
52    // Priority 2: .pdfpc sidecar file
53    let sidecar_path = pdf_path.with_extension("pdfpc");
54    if sidecar_path.exists() {
55        let format = crate::pdfpc::PdfpcFormat;
56        if let Ok(meta) = format.read(&sidecar_path) {
57            return (meta, MetadataSource::Sidecar(sidecar_path));
58        }
59        tracing::warn!("Failed to parse sidecar file: {}", sidecar_path.display());
60    }
61
62    // Priority 3: Embedded PDF metadata (Beamer/Quarto defaults)
63    if let Some(meta) = extract_embedded_metadata(embedded_pdfpc_data) {
64        return (meta, MetadataSource::Embedded);
65    }
66
67    // Priority 4: No metadata
68    (PresentationMetadata::default(), MetadataSource::None)
69}
70
71/// Where the metadata was loaded from.
72#[derive(Debug, Clone)]
73pub enum MetadataSource {
74    /// Extracted from PDF info dictionary / XMP.
75    Embedded,
76    /// Loaded from a sidecar file.
77    Sidecar(std::path::PathBuf),
78    /// No metadata found — using 1:1 page-to-slide mapping.
79    None,
80}
81
82/// Parse overlay group definitions from a pdfpc-style overlay string.
83///
84/// Each line: `start_page end_page` (1-based).
85pub fn parse_overlay_groups(overlay_str: &str) -> Vec<SlideGroupMeta> {
86    let mut groups = Vec::new();
87    for line in overlay_str.lines() {
88        let parts: Vec<&str> = line.split_whitespace().collect();
89        if parts.len() == 2
90            && let (Ok(start), Ok(end)) = (parts[0].parse::<usize>(), parts[1].parse::<usize>())
91        {
92            groups.push(SlideGroupMeta {
93                start_page: start.saturating_sub(1),
94                end_page: end.saturating_sub(1),
95            });
96        }
97    }
98    groups
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn extract_none_from_empty() {
107        assert!(extract_embedded_metadata(None).is_none());
108        assert!(extract_embedded_metadata(Some("")).is_none());
109        assert!(extract_embedded_metadata(Some("  ")).is_none());
110    }
111
112    #[test]
113    fn extract_from_embedded_pdfpc_string() {
114        let data = "[notes]\n### 1\nHello\n[overlay]\n1 3\n";
115        let meta = extract_embedded_metadata(Some(data)).unwrap();
116        assert_eq!(meta.notes.len(), 1);
117        assert_eq!(meta.notes[&0], "Hello");
118        assert_eq!(meta.groups.len(), 1);
119    }
120
121    #[test]
122    fn extract_returns_none_if_no_useful_content() {
123        let data = "[file]\ntest.pdf\n";
124        assert!(extract_embedded_metadata(Some(data)).is_none());
125    }
126
127    #[test]
128    fn load_metadata_with_no_sources() {
129        let (meta, source) = load_metadata(std::path::Path::new("nonexistent.pdf"), None);
130        assert!(meta.groups.is_empty());
131        assert!(matches!(source, MetadataSource::None));
132    }
133
134    #[test]
135    fn load_metadata_embedded_fallback_when_no_sidecar() {
136        let data = "[overlay]\n1 3\n";
137        let (meta, source) = load_metadata(std::path::Path::new("nonexistent.pdf"), Some(data));
138        assert_eq!(meta.groups.len(), 1);
139        assert!(matches!(source, MetadataSource::Embedded));
140    }
141
142    #[test]
143    fn load_metadata_sidecar_overrides_embedded() {
144        use crate::format::SidecarFormat;
145        use crate::pdfpc::PdfpcFormat;
146
147        let dir = std::env::temp_dir().join("dais_test_sidecar_over_embedded");
148        let _ = std::fs::create_dir_all(&dir);
149        let pdf_path = dir.join("talk.pdf");
150        std::fs::write(&pdf_path, b"fake pdf").unwrap();
151
152        // Write a .pdfpc sidecar with groups
153        let sidecar_meta = PresentationMetadata {
154            title: Some("Sidecar".to_string()),
155            groups: vec![crate::types::SlideGroupMeta { start_page: 0, end_page: 2 }],
156            ..Default::default()
157        };
158        PdfpcFormat.write(&dir.join("talk.pdfpc"), &sidecar_meta).unwrap();
159
160        // Provide embedded metadata (no groups)
161        let embedded = "[notes]\n### 1\nEmbedded note\n";
162        let (meta, source) = load_metadata(&pdf_path, Some(embedded));
163
164        // Sidecar should win over embedded
165        assert_eq!(meta.title.as_deref(), Some("Sidecar"));
166        assert_eq!(meta.groups.len(), 1);
167        assert!(matches!(source, MetadataSource::Sidecar(_)));
168
169        let _ = std::fs::remove_file(dir.join("talk.pdf"));
170        let _ = std::fs::remove_file(dir.join("talk.pdfpc"));
171    }
172
173    #[test]
174    fn load_metadata_dais_over_pdfpc() {
175        use crate::dais_format::DaisFormat;
176        use crate::format::SidecarFormat;
177        use crate::pdfpc::PdfpcFormat;
178
179        let dir = std::env::temp_dir().join("dais_test_metadata_priority");
180        let _ = std::fs::create_dir_all(&dir);
181        let pdf_path = dir.join("talk.pdf");
182        std::fs::write(&pdf_path, b"fake pdf").unwrap();
183
184        // Write a .dais sidecar with a distinctive title
185        let dais_meta =
186            PresentationMetadata { title: Some("From Dais".to_string()), ..Default::default() };
187        DaisFormat.write(&dir.join("talk.dais"), &dais_meta).unwrap();
188
189        // Write a .pdfpc sidecar with a different title
190        let pdfpc_meta =
191            PresentationMetadata { title: Some("From Pdfpc".to_string()), ..Default::default() };
192        PdfpcFormat.write(&dir.join("talk.pdfpc"), &pdfpc_meta).unwrap();
193
194        // .dais should win
195        let (meta, source) = load_metadata(&pdf_path, None);
196        assert_eq!(meta.title.as_deref(), Some("From Dais"));
197        assert!(
198            matches!(source, MetadataSource::Sidecar(ref p) if p.extension().unwrap() == "dais")
199        );
200
201        // Cleanup
202        let _ = std::fs::remove_file(dir.join("talk.pdf"));
203        let _ = std::fs::remove_file(dir.join("talk.dais"));
204        let _ = std::fs::remove_file(dir.join("talk.pdfpc"));
205    }
206
207    #[test]
208    fn parse_overlay_groups_basic() {
209        let groups = parse_overlay_groups("1 3\n4 5\n");
210        assert_eq!(groups.len(), 2);
211        assert_eq!(groups[0].start_page, 0);
212        assert_eq!(groups[0].end_page, 2);
213        assert_eq!(groups[1].start_page, 3);
214        assert_eq!(groups[1].end_page, 4);
215    }
216}