use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cfi {
pub raw: String,
pub path: CfiPath,
pub range_start: Option<CfiPath>,
pub range_end: Option<CfiPath>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CfiPath {
pub steps: Vec<CfiStep>,
pub offset: Option<CfiOffset>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CfiStep {
pub index: usize,
pub indirection: bool,
pub element_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CfiOffset {
Character(usize),
Temporal(f64),
Spatial(f64, f64),
}
impl Cfi {
pub fn parse(cfi_str: &str) -> Result<Self, String> {
let clean = cfi_str.trim();
let payload = if clean.starts_with("epubcfi(") && clean.ends_with(')') {
&clean[8..clean.len() - 1]
} else {
clean
};
if payload.contains(',') {
let parts: Vec<&str> = payload.split(',').collect();
if parts.len() < 3 {
return Err("Invalid range CFI: expected 3 comma-separated components".to_string());
}
let parent_str = parts[0];
let start_str = parts[1];
let end_str = parts[2];
let parent_path = parse_single_path(parent_str)?;
let mut start_path = parse_single_path(start_str)?;
let mut end_path = parse_single_path(end_str)?;
let mut full_start_steps = parent_path.steps.clone();
full_start_steps.append(&mut start_path.steps);
let combined_start = CfiPath {
steps: full_start_steps,
offset: start_path.offset,
};
let mut full_end_steps = parent_path.steps.clone();
full_end_steps.append(&mut end_path.steps);
let combined_end = CfiPath {
steps: full_end_steps,
offset: end_path.offset,
};
Ok(Self {
raw: cfi_str.to_string(),
path: combined_start.clone(),
range_start: Some(combined_start),
range_end: Some(combined_end),
})
} else {
let path = parse_single_path(payload)?;
Ok(Self {
raw: cfi_str.to_string(),
path,
range_start: None,
range_end: None,
})
}
}
pub fn from_spine_index(
spine_index: usize,
element_id: Option<&str>,
char_offset: usize,
) -> Self {
let spine_step_idx = (spine_index + 1) * 2;
let steps = vec![
CfiStep {
index: 6,
indirection: false,
element_id: None,
},
CfiStep {
index: spine_step_idx,
indirection: true,
element_id: element_id.map(|s| s.to_string()),
},
CfiStep {
index: 4,
indirection: false,
element_id: None,
},
CfiStep {
index: 2,
indirection: false,
element_id: None,
},
CfiStep {
index: 1,
indirection: false,
element_id: None,
},
];
let id_str = element_id.map(|s| format!("[{}]", s)).unwrap_or_default();
let raw = format!(
"epubcfi(/6/{}{}!/4/2/1:{})",
spine_step_idx, id_str, char_offset
);
let path = CfiPath {
steps: steps.clone(),
offset: Some(CfiOffset::Character(char_offset)),
};
Self {
raw,
path,
range_start: None,
range_end: None,
}
}
pub fn try_spine_index(&self) -> Result<usize, String> {
for step in &self.path.steps {
if step.indirection && step.index >= 2 {
return Ok((step.index / 2) - 1);
}
}
Err("CFI missing indirection step ('!')".to_string())
}
pub fn spine_index(&self) -> usize {
self.try_spine_index().unwrap_or(0)
}
pub fn char_offset(&self) -> usize {
match self.path.offset {
Some(CfiOffset::Character(off)) => off,
_ => 0,
}
}
pub fn compare(&self, other: &Self) -> std::cmp::Ordering {
let s1 = self.spine_index();
let s2 = other.spine_index();
if s1 != s2 {
return s1.cmp(&s2);
}
let o1 = self.char_offset();
let o2 = other.char_offset();
o1.cmp(&o2)
}
pub fn to_cfi_string(&self) -> String {
format!("epubcfi({})", format_path(&self.path))
}
}
impl std::fmt::Display for Cfi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "epubcfi({})", format_path(&self.path))
}
}
fn parse_single_path(s: &str) -> Result<CfiPath, String> {
let mut steps = Vec::new();
let mut offset = None;
let mut chars = s.chars().peekable();
let mut current_num = String::new();
let mut indirection;
while let Some(&ch) = chars.peek() {
if ch == '/' {
chars.next();
current_num.clear();
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
current_num.push(c);
chars.next();
} else {
break;
}
}
if !current_num.is_empty() {
let idx: usize = current_num.parse().unwrap_or(0);
let mut element_id = None;
if let Some(&'[') = chars.peek() {
chars.next();
let mut id_str = String::new();
while let Some(&c) = chars.peek() {
if c == ']' {
chars.next();
break;
}
id_str.push(c);
chars.next();
}
element_id = Some(id_str);
}
if let Some(&'!') = chars.peek() {
indirection = true;
chars.next();
} else {
indirection = false;
}
steps.push(CfiStep {
index: idx,
indirection,
element_id,
});
}
} else if ch == ':' {
chars.next();
let mut off_num = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
off_num.push(c);
chars.next();
} else {
break;
}
}
if let Ok(off) = off_num.parse::<usize>() {
offset = Some(CfiOffset::Character(off));
}
} else {
chars.next();
}
}
Ok(CfiPath { steps, offset })
}
fn format_path(path: &CfiPath) -> String {
let mut out = String::new();
for step in &path.steps {
out.push_str(&format!("/{}", step.index));
if let Some(ref id) = step.element_id {
out.push_str(&format!("[{}]", id));
}
if step.indirection {
out.push('!');
}
}
if let Some(CfiOffset::Character(off)) = path.offset {
out.push_str(&format!(":{}", off));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cfi_parsing_and_formatting() {
let cfi_str = "epubcfi(/6/4[chap01ref]!/4/2/10/1:42)";
let cfi = Cfi::parse(cfi_str).unwrap();
assert_eq!(cfi.spine_index(), 1);
assert_eq!(cfi.char_offset(), 42);
assert_eq!(cfi.to_string(), cfi_str);
}
}