Skip to main content

ebook_rs/
cfi.rs

1use serde::{Deserialize, Serialize};
2
3/// A parsed EPUB Canonical Fragment Identifier (CFI).
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct Cfi {
6    pub raw: String,
7    pub path: CfiPath,
8    pub range_start: Option<CfiPath>,
9    pub range_end: Option<CfiPath>,
10}
11
12/// DOM Element target resolved from CFI element steps and ID assertions.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct CfiDomTarget {
15    pub element_id: Option<String>,
16    pub css_selector: String,
17    pub char_offset: usize,
18}
19
20/// A path component inside a CFI.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct CfiPath {
23    pub steps: Vec<CfiStep>,
24    pub offset: Option<CfiOffset>,
25}
26
27/// A single step in a CFI path (e.g. /6/4[chap01ref]!).
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct CfiStep {
30    pub index: usize,
31    pub indirection: bool,
32    pub element_id: Option<String>,
33}
34
35/// Character or temporal offset at the end of a CFI path.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub enum CfiOffset {
38    Character(usize),
39    Temporal(f64),
40    Spatial(f64, f64),
41}
42
43impl Cfi {
44    /// Parse a CFI string representation into a structured `Cfi`.
45    pub fn parse(cfi_str: &str) -> Result<Self, String> {
46        let clean = cfi_str.trim();
47        let payload = if clean.starts_with("epubcfi(") && clean.ends_with(')') {
48            &clean[8..clean.len() - 1]
49        } else {
50            clean
51        };
52
53        if let Some(parts) = split_cfi_range(payload) {
54            // Range CFI: /6/2!/4/2/1, :10, :45
55            let parent_str = parts[0];
56            let start_str = parts[1];
57            let end_str = parts[2];
58
59            let parent_path = parse_single_path(parent_str)?;
60            let mut start_path = parse_single_path(start_str)?;
61            let mut end_path = parse_single_path(end_str)?;
62
63            // Combine parent steps into range endpoints
64            let mut full_start_steps = parent_path.steps.clone();
65            full_start_steps.append(&mut start_path.steps);
66            let combined_start = CfiPath {
67                steps: full_start_steps,
68                offset: start_path.offset,
69            };
70
71            let mut full_end_steps = parent_path.steps.clone();
72            full_end_steps.append(&mut end_path.steps);
73            let combined_end = CfiPath {
74                steps: full_end_steps,
75                offset: end_path.offset,
76            };
77
78            Ok(Self {
79                raw: cfi_str.to_string(),
80                path: combined_start.clone(),
81                range_start: Some(combined_start),
82                range_end: Some(combined_end),
83            })
84        } else {
85            let path = parse_single_path(payload)?;
86            Ok(Self {
87                raw: cfi_str.to_string(),
88                path,
89                range_start: None,
90                range_end: None,
91            })
92        }
93    }
94
95    /// Helper constructor: Create a simple CFI pointing to a spine index and character offset.
96    /// B5 Fix: Support optional element_id assertion to preserve CFI id assertions on roundtrip.
97    pub fn from_spine_index(
98        spine_index: usize,
99        element_id: Option<&str>,
100        char_offset: usize,
101    ) -> Self {
102        let spine_step_idx = (spine_index + 1) * 2;
103        let steps = vec![
104            CfiStep {
105                index: 6,
106                indirection: false,
107                element_id: None,
108            },
109            CfiStep {
110                index: spine_step_idx,
111                indirection: true,
112                element_id: element_id.map(|s| s.to_string()),
113            },
114            CfiStep {
115                index: 4,
116                indirection: false,
117                element_id: None,
118            },
119            CfiStep {
120                index: 2,
121                indirection: false,
122                element_id: None,
123            },
124            CfiStep {
125                index: 1,
126                indirection: false,
127                element_id: None,
128            },
129        ];
130
131        let id_str = element_id.map(|s| format!("[{}]", s)).unwrap_or_default();
132        let raw = format!(
133            "epubcfi(/6/{}{}!/4/2/1:{})",
134            spine_step_idx, id_str, char_offset
135        );
136        let path = CfiPath {
137            steps: steps.clone(),
138            offset: Some(CfiOffset::Character(char_offset)),
139        };
140
141        Self {
142            raw,
143            path,
144            range_start: None,
145            range_end: None,
146        }
147    }
148
149    /// Extract the 0-based spine item index from this CFI, returning an error if no indirection step `!` is found (B5 Fix).
150    pub fn try_spine_index(&self) -> Result<usize, String> {
151        for step in &self.path.steps {
152            if step.indirection && step.index >= 2 {
153                return Ok((step.index / 2) - 1);
154            }
155        }
156        Err("CFI missing indirection step ('!')".to_string())
157    }
158
159    /// Extract the 0-based spine item index from this CFI (defaults to 0 if missing indirection).
160    pub fn spine_index(&self) -> usize {
161        self.try_spine_index().unwrap_or(0)
162    }
163
164    /// Extract character offset.
165    pub fn char_offset(&self) -> usize {
166        match self.path.offset {
167            Some(CfiOffset::Character(off)) => off,
168            _ => 0,
169        }
170    }
171
172    /// Compare two CFIs by spine index and character offset.
173    pub fn compare(&self, other: &Self) -> std::cmp::Ordering {
174        let s1 = self.spine_index();
175        let s2 = other.spine_index();
176        if s1 != s2 {
177            return s1.cmp(&s2);
178        }
179        let o1 = self.char_offset();
180        let o2 = other.char_offset();
181        o1.cmp(&o2)
182    }
183
184    /// Resolve IDPF element steps and assertion IDs into CSS DOM selectors and target element IDs (F1 Fix).
185    pub fn resolve_dom_path(&self, html: &str) -> Option<CfiDomTarget> {
186        let mut element_id = None;
187        let mut selectors = Vec::new();
188
189        for step in &self.path.steps {
190            if let Some(id) = &step.element_id {
191                element_id = Some(id.clone());
192                selectors.push(format!("#{}", id));
193            } else if !step.indirection && step.index >= 2 {
194                let child_num = step.index / 2;
195                selectors.push(format!("*:nth-child({})", child_num));
196            }
197        }
198
199        // Prune selectors prior to the last ID anchor if present
200        if let Some(id_idx) = selectors.iter().rposition(|s| s.starts_with('#')) {
201            selectors = selectors[id_idx..].to_vec();
202        }
203
204        let css_selector = if selectors.is_empty() {
205            "body".to_string()
206        } else {
207            selectors.join(" > ")
208        };
209
210        // Check if element_id exists in target HTML
211        if element_id.is_none() {
212            let lower_html = html.to_lowercase();
213            for step in &self.path.steps {
214                if let Some(id) = &step.element_id {
215                    let id_lower = id.to_lowercase();
216                    if lower_html.contains(&format!("id=\"{}\"", id_lower))
217                        || lower_html.contains(&format!("id='{}'", id_lower))
218                    {
219                        element_id = Some(id.clone());
220                        break;
221                    }
222                }
223            }
224        }
225
226        Some(CfiDomTarget {
227            element_id,
228            css_selector,
229            char_offset: self.char_offset(),
230        })
231    }
232
233    /// Convert back to formatted `epubcfi(...)` string.
234    pub fn to_cfi_string(&self) -> String {
235        format!("epubcfi({})", format_path(&self.path))
236    }
237}
238
239impl std::fmt::Display for Cfi {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        write!(f, "epubcfi({})", format_path(&self.path))
242    }
243}
244
245fn parse_single_path(s: &str) -> Result<CfiPath, String> {
246    let mut steps = Vec::new();
247    let mut offset = None;
248
249    let mut chars = s.chars().peekable();
250    let mut current_num = String::new();
251    let mut indirection;
252
253    while let Some(&ch) = chars.peek() {
254        if ch == '/' {
255            chars.next();
256            current_num.clear();
257            while let Some(&c) = chars.peek() {
258                if c.is_ascii_digit() {
259                    current_num.push(c);
260                    chars.next();
261                } else {
262                    break;
263                }
264            }
265
266            if !current_num.is_empty() {
267                let idx: usize = current_num.parse().unwrap_or(0);
268
269                // Check for element_id assertion [id]
270                let mut element_id = None;
271                if let Some(&'[') = chars.peek() {
272                    chars.next();
273                    let mut id_str = String::new();
274                    let mut escaped = false;
275                    while let Some(&c) = chars.peek() {
276                        if escaped {
277                            id_str.push(c);
278                            chars.next();
279                            escaped = false;
280                            continue;
281                        }
282                        if c == '^' {
283                            escaped = true;
284                            chars.next();
285                            continue;
286                        }
287                        if c == ']' {
288                            chars.next();
289                            break;
290                        }
291                        id_str.push(c);
292                        chars.next();
293                    }
294                    element_id = Some(id_str);
295                }
296
297                // Check for indirection !
298                if let Some(&'!') = chars.peek() {
299                    indirection = true;
300                    chars.next();
301                } else {
302                    indirection = false;
303                }
304
305                steps.push(CfiStep {
306                    index: idx,
307                    indirection,
308                    element_id,
309                });
310            }
311        } else if ch == ':' {
312            chars.next();
313            let mut off_num = String::new();
314            while let Some(&c) = chars.peek() {
315                if c.is_ascii_digit() {
316                    off_num.push(c);
317                    chars.next();
318                } else {
319                    break;
320                }
321            }
322            if let Ok(off) = off_num.parse::<usize>() {
323                offset = Some(CfiOffset::Character(off));
324            }
325        } else {
326            chars.next();
327        }
328    }
329
330    Ok(CfiPath { steps, offset })
331}
332
333fn split_cfi_range(payload: &str) -> Option<Vec<&str>> {
334    let mut parts = Vec::new();
335    let mut in_bracket = false;
336    let mut escaped = false;
337    let mut last_idx = 0;
338
339    for (idx, c) in payload.char_indices() {
340        if escaped {
341            escaped = false;
342            continue;
343        }
344        match c {
345            '^' => escaped = true,
346            '[' => in_bracket = true,
347            ']' => in_bracket = false,
348            ',' if !in_bracket => {
349                parts.push(&payload[last_idx..idx]);
350                last_idx = idx + 1;
351            }
352            _ => {}
353        }
354    }
355    if parts.is_empty() {
356        None
357    } else {
358        parts.push(&payload[last_idx..]);
359        if parts.len() >= 3 { Some(parts) } else { None }
360    }
361}
362
363fn format_path(path: &CfiPath) -> String {
364    let mut out = String::new();
365    for step in &path.steps {
366        out.push_str(&format!("/{}", step.index));
367        if let Some(ref id) = step.element_id {
368            out.push('[');
369            for c in id.chars() {
370                if matches!(c, '^' | '[' | ']' | '(' | ')' | ',' | ';' | '!') {
371                    out.push('^');
372                }
373                out.push(c);
374            }
375            out.push(']');
376        }
377        if step.indirection {
378            out.push('!');
379        }
380    }
381    if let Some(CfiOffset::Character(off)) = path.offset {
382        out.push_str(&format!(":{}", off));
383    }
384    out
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_cfi_parsing_and_formatting() {
393        let cfi_str = "epubcfi(/6/4[chap01ref]!/4/2/10/1:42)";
394        let cfi = Cfi::parse(cfi_str).unwrap();
395
396        assert_eq!(cfi.spine_index(), 1);
397        assert_eq!(cfi.char_offset(), 42);
398        assert_eq!(cfi.to_string(), cfi_str);
399    }
400
401    #[test]
402    fn test_cfi_with_comma_in_id() {
403        let cfi_str = "epubcfi(/6/4[chap01,heading]!/4/2:10)";
404        let cfi = Cfi::parse(cfi_str).expect("CFI with comma in ID should parse as single path");
405        assert_eq!(cfi.spine_index(), 1);
406        assert_eq!(cfi.char_offset(), 10);
407        assert!(cfi.range_start.is_none());
408    }
409}