diff --git a/crates/zx_document_core/src/progress.rs b/crates/zx_document_core/src/progress.rs index 8185835..5643076 100644 --- a/crates/zx_document_core/src/progress.rs +++ b/crates/zx_document_core/src/progress.rs @@ -1,102 +1,210 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Enum)] +/// Clamp a progress value to 0..1. NaN→0, <0→0, >1→1. +pub fn clamp_progress(v: f32) -> f32 { + if v.is_nan() || v < 0.0 { return 0.0; } + if v > 1.0 { return 1.0; } + v +} + +#[derive(Debug, Clone, PartialEq, Deserialize, uniffi::Enum)] #[serde(tag = "type")] pub enum ReadingPosition { Markdown { + #[serde(rename = "blockId")] block_id: String, + #[serde(rename = "scrollProgress")] scroll_progress: f32, }, Text { + #[serde(rename = "lineNumber")] line_number: u32, + #[serde(rename = "scrollProgress")] scroll_progress: f32, }, Pdf { + #[serde(rename = "pageNumber")] page_number: u32, + #[serde(rename = "pageProgress")] page_progress: f32, + #[serde(rename = "overallProgress")] overall_progress: f32, }, Image { + #[serde(rename = "zoomScale")] zoom_scale: f32, + #[serde(rename = "offsetX")] offset_x: f32, + #[serde(rename = "offsetY")] offset_y: f32, }, Epub { + #[serde(rename = "chapterId")] chapter_id: String, + #[serde(rename = "chapterProgress")] chapter_progress: f32, + #[serde(rename = "overallProgress")] overall_progress: f32, }, Unknown, } +// Manual Serialize to enforce camelCase field names + clamped progress values. +impl Serialize for ReadingPosition { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeStruct; + match self { + Self::Markdown { block_id, scroll_progress } => { + let mut st = s.serialize_struct("Markdown", 3)?; + st.serialize_field("type", "Markdown")?; + st.serialize_field("blockId", block_id)?; + st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; + st.end() + } + Self::Text { line_number, scroll_progress } => { + let mut st = s.serialize_struct("Text", 3)?; + st.serialize_field("type", "Text")?; + st.serialize_field("lineNumber", line_number)?; + st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; + st.end() + } + Self::Pdf { page_number, page_progress, overall_progress } => { + let mut st = s.serialize_struct("Pdf", 4)?; + st.serialize_field("type", "Pdf")?; + st.serialize_field("pageNumber", page_number)?; + st.serialize_field("pageProgress", &clamp_progress(*page_progress))?; + st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; + st.end() + } + Self::Image { zoom_scale, offset_x, offset_y } => { + let mut st = s.serialize_struct("Image", 4)?; + st.serialize_field("type", "Image")?; + st.serialize_field("zoomScale", zoom_scale)?; + st.serialize_field("offsetX", offset_x)?; + st.serialize_field("offsetY", offset_y)?; + st.end() + } + Self::Epub { chapter_id, chapter_progress, overall_progress } => { + let mut st = s.serialize_struct("Epub", 4)?; + st.serialize_field("type", "Epub")?; + st.serialize_field("chapterId", chapter_id)?; + st.serialize_field("chapterProgress", &clamp_progress(*chapter_progress))?; + st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; + st.end() + } + Self::Unknown => { + let mut st = s.serialize_struct("Unknown", 1)?; + st.serialize_field("type", "Unknown")?; + st.end() + } + } + } +} + +impl ReadingPosition { + /// Return a normalized copy with all progress fields clamped to 0..1. + pub fn normalized(&self) -> Self { + match self { + Self::Markdown { block_id, scroll_progress } => Self::Markdown { + block_id: block_id.clone(), + scroll_progress: clamp_progress(*scroll_progress), + }, + Self::Text { line_number, scroll_progress } => Self::Text { + line_number: *line_number, + scroll_progress: clamp_progress(*scroll_progress), + }, + Self::Pdf { page_number, page_progress, overall_progress } => Self::Pdf { + page_number: *page_number, + page_progress: clamp_progress(*page_progress), + overall_progress: clamp_progress(*overall_progress), + }, + Self::Image { zoom_scale, offset_x, offset_y } => Self::Image { + zoom_scale: *zoom_scale, offset_x: *offset_x, offset_y: *offset_y, + }, + Self::Epub { chapter_id, chapter_progress, overall_progress } => Self::Epub { + chapter_id: chapter_id.clone(), + chapter_progress: clamp_progress(*chapter_progress), + overall_progress: clamp_progress(*overall_progress), + }, + Self::Unknown => Self::Unknown, + } + } + + pub fn progress_value(&self) -> Option { + match self { + Self::Markdown { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), + Self::Text { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), + Self::Pdf { overall_progress, .. } => Some(clamp_progress(*overall_progress)), + Self::Epub { overall_progress, .. } => Some(clamp_progress(*overall_progress)), + Self::Image { .. } | Self::Unknown => None, + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_markdown_serde() { - let pos = ReadingPosition::Markdown { - block_id: "h1".into(), - scroll_progress: 0.5, - }; + fn test_markdown_camel_case() { + let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }; let json = serde_json::to_string(&pos).unwrap(); - assert!(json.contains("\"type\":\"Markdown\"")); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + eprintln!("SERIALIZED: {json}"); + assert!(json.contains("\"type\":\"Markdown\""), "missing type: {json}"); + assert!(json.contains("\"blockId\":\"h1\""), "missing blockId: {json}"); + assert!(json.contains("\"scrollProgress\":0.5"), "missing progress: {json}"); } #[test] - fn test_text_serde() { - let pos = ReadingPosition::Text { - line_number: 42, - scroll_progress: 0.3, - }; + fn test_pdf_camel_case() { + let pos = ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }; let json = serde_json::to_string(&pos).unwrap(); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + assert!(json.contains("\"pageNumber\":7")); + assert!(json.contains("\"pageProgress\":0.8")); + assert!(json.contains("\"overallProgress\":0.35")); } #[test] - fn test_pdf_serde() { - let pos = ReadingPosition::Pdf { - page_number: 7, - page_progress: 0.8, - overall_progress: 0.35, - }; + fn test_clamp_nan() { + let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: f32::NAN }; let json = serde_json::to_string(&pos).unwrap(); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + assert!(json.contains("\"scrollProgress\":0.0")); } #[test] - fn test_image_serde() { - let pos = ReadingPosition::Image { - zoom_scale: 1.5, - offset_x: 100.0, - offset_y: 200.0, - }; + fn test_clamp_negative() { + let pos = ReadingPosition::Text { line_number: 1, scroll_progress: -0.5 }; let json = serde_json::to_string(&pos).unwrap(); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + assert!(json.contains("\"scrollProgress\":0.0")); } #[test] - fn test_epub_serde() { - let pos = ReadingPosition::Epub { - chapter_id: "ch3".into(), - chapter_progress: 0.6, - overall_progress: 0.25, - }; + fn test_clamp_above_one() { + let pos = ReadingPosition::Pdf { page_number: 1, page_progress: 2.5, overall_progress: 10.0 }; let json = serde_json::to_string(&pos).unwrap(); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + assert!(json.contains("\"pageProgress\":1.0")); + assert!(json.contains("\"overallProgress\":1.0")); } #[test] - fn test_unknown_serde() { - let pos = ReadingPosition::Unknown; - let json = serde_json::to_string(&pos).unwrap(); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(back, pos); + fn test_progress_value() { + assert_eq!(ReadingPosition::Unknown.progress_value(), None); + let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.75 }; + assert_eq!(md.progress_value(), Some(0.75)); + } + + #[test] + fn test_roundtrip() { + let positions = vec![ + ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }, + ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }, + ReadingPosition::Unknown, + ]; + for pos in &positions { + let json = serde_json::to_string(pos).unwrap(); + eprintln!("ROUNDTRIP JSON: {json}"); + let back: ReadingPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(&back, pos); + } } } diff --git a/rust_out b/rust_out new file mode 100755 index 0000000..5a000ff Binary files /dev/null and b/rust_out differ