Skip to main content

ppt_rs/generator/
hyperlinks.rs

1//! Hyperlink support for PPTX elements
2//!
3//! Provides hyperlink types for shapes, text, and images.
4
5use crate::core::escape_xml;
6
7/// Hyperlink action types
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum HyperlinkAction {
10    /// Link to external URL
11    Url(String),
12    /// Link to another slide in the presentation
13    Slide(u32),
14    /// Link to first slide
15    FirstSlide,
16    /// Link to last slide
17    LastSlide,
18    /// Link to next slide
19    NextSlide,
20    /// Link to previous slide
21    PreviousSlide,
22    /// Link to end show
23    EndShow,
24    /// Link to email address
25    Email { address: String, subject: Option<String> },
26    /// Link to file
27    File(String),
28}
29
30impl HyperlinkAction {
31    /// Create URL hyperlink
32    pub fn url(url: &str) -> Self {
33        HyperlinkAction::Url(url.to_string())
34    }
35
36    /// Create slide hyperlink
37    pub fn slide(slide_num: u32) -> Self {
38        HyperlinkAction::Slide(slide_num)
39    }
40
41    /// Create email hyperlink
42    pub fn email(address: &str) -> Self {
43        HyperlinkAction::Email {
44            address: address.to_string(),
45            subject: None,
46        }
47    }
48
49    /// Create email hyperlink with subject
50    pub fn email_with_subject(address: &str, subject: &str) -> Self {
51        HyperlinkAction::Email {
52            address: address.to_string(),
53            subject: Some(subject.to_string()),
54        }
55    }
56
57    /// Create file hyperlink
58    pub fn file(path: &str) -> Self {
59        HyperlinkAction::File(path.to_string())
60    }
61
62    /// Get the relationship target for this action
63    pub fn relationship_target(&self) -> String {
64        match self {
65            HyperlinkAction::Url(url) => url.clone(),
66            HyperlinkAction::Slide(num) => format!("slide{}.xml", num),
67            HyperlinkAction::FirstSlide => "ppaction://hlinkshowjump?jump=firstslide".to_string(),
68            HyperlinkAction::LastSlide => "ppaction://hlinkshowjump?jump=lastslide".to_string(),
69            HyperlinkAction::NextSlide => "ppaction://hlinkshowjump?jump=nextslide".to_string(),
70            HyperlinkAction::PreviousSlide => "ppaction://hlinkshowjump?jump=previousslide".to_string(),
71            HyperlinkAction::EndShow => "ppaction://hlinkshowjump?jump=endshow".to_string(),
72            HyperlinkAction::Email { address, subject } => {
73                let mut mailto = format!("mailto:{}", address);
74                if let Some(subj) = subject {
75                    mailto.push_str(&format!("?subject={}", subj));
76                }
77                mailto
78            }
79            HyperlinkAction::File(path) => format!("file:///{}", path.replace('\\', "/")),
80        }
81    }
82
83    /// Check if this is an external link
84    pub fn is_external(&self) -> bool {
85        matches!(
86            self,
87            HyperlinkAction::Url(_) | HyperlinkAction::Email { .. } | HyperlinkAction::File(_)
88        )
89    }
90
91    /// Get the action type for internal links
92    pub fn action_type(&self) -> Option<&'static str> {
93        match self {
94            HyperlinkAction::FirstSlide => Some("ppaction://hlinkshowjump?jump=firstslide"),
95            HyperlinkAction::LastSlide => Some("ppaction://hlinkshowjump?jump=lastslide"),
96            HyperlinkAction::NextSlide => Some("ppaction://hlinkshowjump?jump=nextslide"),
97            HyperlinkAction::PreviousSlide => Some("ppaction://hlinkshowjump?jump=previousslide"),
98            HyperlinkAction::EndShow => Some("ppaction://hlinkshowjump?jump=endshow"),
99            _ => None,
100        }
101    }
102}
103
104/// Hyperlink definition
105#[derive(Clone, Debug)]
106pub struct Hyperlink {
107    /// The action to perform when clicked
108    pub action: HyperlinkAction,
109    /// Tooltip text shown on hover
110    pub tooltip: Option<String>,
111    /// Highlight click (visual feedback)
112    pub highlight_click: bool,
113    /// Relationship ID (set during XML generation)
114    pub r_id: Option<String>,
115}
116
117impl Hyperlink {
118    /// Create a new hyperlink
119    pub fn new(action: HyperlinkAction) -> Self {
120        Hyperlink {
121            action,
122            tooltip: None,
123            highlight_click: true,
124            r_id: None,
125        }
126    }
127
128    /// Create URL hyperlink
129    pub fn url(url: &str) -> Self {
130        Self::new(HyperlinkAction::url(url))
131    }
132
133    /// Create slide hyperlink
134    pub fn slide(slide_num: u32) -> Self {
135        Self::new(HyperlinkAction::slide(slide_num))
136    }
137
138    /// Create email hyperlink
139    pub fn email(address: &str) -> Self {
140        Self::new(HyperlinkAction::email(address))
141    }
142
143    /// Set tooltip
144    pub fn with_tooltip(mut self, tooltip: &str) -> Self {
145        self.tooltip = Some(tooltip.to_string());
146        self
147    }
148
149    /// Set highlight click
150    pub fn with_highlight_click(mut self, highlight: bool) -> Self {
151        self.highlight_click = highlight;
152        self
153    }
154
155    /// Set relationship ID
156    pub fn with_r_id(mut self, r_id: &str) -> Self {
157        self.r_id = Some(r_id.to_string());
158        self
159    }
160}
161
162/// Generate hyperlink XML for text run
163pub fn generate_text_hyperlink_xml(hyperlink: &Hyperlink, r_id: &str) -> String {
164    let mut xml = format!(r#"<a:hlinkClick r:id="{}""#, r_id);
165
166    if let Some(tooltip) = &hyperlink.tooltip {
167        xml.push_str(&format!(r#" tooltip="{}""#, escape_xml(tooltip)));
168    }
169
170    if hyperlink.highlight_click {
171        xml.push_str(r#" highlightClick="1""#);
172    }
173
174    // Add action for internal navigation
175    if let Some(action) = hyperlink.action.action_type() {
176        xml.push_str(&format!(r#" action="{}""#, action));
177    }
178
179    xml.push_str("/>");
180    xml
181}
182
183/// Generate hyperlink XML for shape
184pub fn generate_shape_hyperlink_xml(hyperlink: &Hyperlink, r_id: &str) -> String {
185    let mut xml = format!(r#"<a:hlinkClick r:id="{}""#, r_id);
186
187    if let Some(tooltip) = &hyperlink.tooltip {
188        xml.push_str(&format!(r#" tooltip="{}""#, escape_xml(tooltip)));
189    }
190
191    if hyperlink.highlight_click {
192        xml.push_str(r#" highlightClick="1""#);
193    }
194
195    if let Some(action) = hyperlink.action.action_type() {
196        xml.push_str(&format!(r#" action="{}""#, action));
197    }
198
199    xml.push_str("/>");
200    xml
201}
202
203/// Generate relationship XML for hyperlink
204pub fn generate_hyperlink_relationship_xml(hyperlink: &Hyperlink, r_id: &str) -> String {
205    let target = hyperlink.action.relationship_target();
206    let target_mode = if hyperlink.action.is_external() {
207        r#" TargetMode="External""#
208    } else {
209        ""
210    };
211
212    format!(
213        r#"<Relationship Id="{}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="{}"{}/>"#,
214        r_id,
215        escape_xml(&target),
216        target_mode
217    )
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_hyperlink_url() {
226        let link = Hyperlink::url("https://example.com");
227        assert!(matches!(link.action, HyperlinkAction::Url(_)));
228        assert!(link.action.is_external());
229    }
230
231    #[test]
232    fn test_hyperlink_slide() {
233        let link = Hyperlink::slide(3);
234        assert!(matches!(link.action, HyperlinkAction::Slide(3)));
235        assert!(!link.action.is_external());
236    }
237
238    #[test]
239    fn test_hyperlink_email() {
240        let link = Hyperlink::email("test@example.com");
241        assert!(link.action.is_external());
242        assert!(link.action.relationship_target().starts_with("mailto:"));
243    }
244
245    #[test]
246    fn test_hyperlink_with_tooltip() {
247        let link = Hyperlink::url("https://example.com")
248            .with_tooltip("Click here");
249        assert_eq!(link.tooltip, Some("Click here".to_string()));
250    }
251
252    #[test]
253    fn test_hyperlink_action_types() {
254        assert!(HyperlinkAction::FirstSlide.action_type().is_some());
255        assert!(HyperlinkAction::LastSlide.action_type().is_some());
256        assert!(HyperlinkAction::NextSlide.action_type().is_some());
257        assert!(HyperlinkAction::PreviousSlide.action_type().is_some());
258        assert!(HyperlinkAction::EndShow.action_type().is_some());
259        assert!(HyperlinkAction::url("test").action_type().is_none());
260    }
261
262    #[test]
263    fn test_generate_text_hyperlink_xml() {
264        let link = Hyperlink::url("https://example.com")
265            .with_tooltip("Example");
266        let xml = generate_text_hyperlink_xml(&link, "rId1");
267        assert!(xml.contains("hlinkClick"));
268        assert!(xml.contains("rId1"));
269        assert!(xml.contains("Example"));
270    }
271
272    #[test]
273    fn test_generate_relationship_xml() {
274        let link = Hyperlink::url("https://example.com");
275        let xml = generate_hyperlink_relationship_xml(&link, "rId1");
276        assert!(xml.contains("Relationship"));
277        assert!(xml.contains("hyperlink"));
278        assert!(xml.contains("External"));
279    }
280
281    #[test]
282    fn test_email_with_subject() {
283        let action = HyperlinkAction::email_with_subject("test@example.com", "Hello");
284        let target = action.relationship_target();
285        assert!(target.contains("mailto:"));
286        assert!(target.contains("subject=Hello"));
287    }
288}