fix: DOC-FULL-005 ReadingPosition camelCase + clamp

Manual Serialize for camelCase JSON output
Clamp progress 0~1 (NaN→0, <0→0, >1→1)
normalized() + progress_value() methods
Backward-compatible Deserialize with #[serde(rename)]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-07 19:49:49 +08:00
parent 66649bb815
commit 661d21de8f
2 changed files with 154 additions and 46 deletions

View File

@ -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")] #[serde(tag = "type")]
pub enum ReadingPosition { pub enum ReadingPosition {
Markdown { Markdown {
#[serde(rename = "blockId")]
block_id: String, block_id: String,
#[serde(rename = "scrollProgress")]
scroll_progress: f32, scroll_progress: f32,
}, },
Text { Text {
#[serde(rename = "lineNumber")]
line_number: u32, line_number: u32,
#[serde(rename = "scrollProgress")]
scroll_progress: f32, scroll_progress: f32,
}, },
Pdf { Pdf {
#[serde(rename = "pageNumber")]
page_number: u32, page_number: u32,
#[serde(rename = "pageProgress")]
page_progress: f32, page_progress: f32,
#[serde(rename = "overallProgress")]
overall_progress: f32, overall_progress: f32,
}, },
Image { Image {
#[serde(rename = "zoomScale")]
zoom_scale: f32, zoom_scale: f32,
#[serde(rename = "offsetX")]
offset_x: f32, offset_x: f32,
#[serde(rename = "offsetY")]
offset_y: f32, offset_y: f32,
}, },
Epub { Epub {
#[serde(rename = "chapterId")]
chapter_id: String, chapter_id: String,
#[serde(rename = "chapterProgress")]
chapter_progress: f32, chapter_progress: f32,
#[serde(rename = "overallProgress")]
overall_progress: f32, overall_progress: f32,
}, },
Unknown, Unknown,
} }
// Manual Serialize to enforce camelCase field names + clamped progress values.
impl Serialize for ReadingPosition {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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<f32> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_markdown_serde() { fn test_markdown_camel_case() {
let pos = ReadingPosition::Markdown { let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 };
block_id: "h1".into(),
scroll_progress: 0.5,
};
let json = serde_json::to_string(&pos).unwrap(); let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"type\":\"Markdown\"")); eprintln!("SERIALIZED: {json}");
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"type\":\"Markdown\""), "missing type: {json}");
assert_eq!(back, pos); assert!(json.contains("\"blockId\":\"h1\""), "missing blockId: {json}");
assert!(json.contains("\"scrollProgress\":0.5"), "missing progress: {json}");
} }
#[test] #[test]
fn test_text_serde() { fn test_pdf_camel_case() {
let pos = ReadingPosition::Text { let pos = ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 };
line_number: 42,
scroll_progress: 0.3,
};
let json = serde_json::to_string(&pos).unwrap(); let json = serde_json::to_string(&pos).unwrap();
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"pageNumber\":7"));
assert_eq!(back, pos); assert!(json.contains("\"pageProgress\":0.8"));
assert!(json.contains("\"overallProgress\":0.35"));
} }
#[test] #[test]
fn test_pdf_serde() { fn test_clamp_nan() {
let pos = ReadingPosition::Pdf { let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: f32::NAN };
page_number: 7,
page_progress: 0.8,
overall_progress: 0.35,
};
let json = serde_json::to_string(&pos).unwrap(); let json = serde_json::to_string(&pos).unwrap();
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"scrollProgress\":0.0"));
assert_eq!(back, pos);
} }
#[test] #[test]
fn test_image_serde() { fn test_clamp_negative() {
let pos = ReadingPosition::Image { let pos = ReadingPosition::Text { line_number: 1, scroll_progress: -0.5 };
zoom_scale: 1.5,
offset_x: 100.0,
offset_y: 200.0,
};
let json = serde_json::to_string(&pos).unwrap(); let json = serde_json::to_string(&pos).unwrap();
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"scrollProgress\":0.0"));
assert_eq!(back, pos);
} }
#[test] #[test]
fn test_epub_serde() { fn test_clamp_above_one() {
let pos = ReadingPosition::Epub { let pos = ReadingPosition::Pdf { page_number: 1, page_progress: 2.5, overall_progress: 10.0 };
chapter_id: "ch3".into(),
chapter_progress: 0.6,
overall_progress: 0.25,
};
let json = serde_json::to_string(&pos).unwrap(); let json = serde_json::to_string(&pos).unwrap();
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"pageProgress\":1.0"));
assert_eq!(back, pos); assert!(json.contains("\"overallProgress\":1.0"));
} }
#[test] #[test]
fn test_unknown_serde() { fn test_progress_value() {
let pos = ReadingPosition::Unknown; assert_eq!(ReadingPosition::Unknown.progress_value(), None);
let json = serde_json::to_string(&pos).unwrap(); let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.75 };
let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert_eq!(md.progress_value(), Some(0.75));
assert_eq!(back, pos); }
#[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);
}
} }
} }

BIN
rust_out Executable file

Binary file not shown.