1use serde::{Deserialize, Serialize};
2
3#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct CfiPath {
23 pub steps: Vec<CfiStep>,
24 pub offset: Option<CfiOffset>,
25}
26
27#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub enum CfiOffset {
38 Character(usize),
39 Temporal(f64),
40 Spatial(f64, f64),
41}
42
43impl Cfi {
44 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 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 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 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 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 pub fn spine_index(&self) -> usize {
161 self.try_spine_index().unwrap_or(0)
162 }
163
164 pub fn char_offset(&self) -> usize {
166 match self.path.offset {
167 Some(CfiOffset::Character(off)) => off,
168 _ => 0,
169 }
170 }
171
172 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 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 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 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 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 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 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}