diff --git a/crates/zx_document_core/src/anchors.rs b/crates/zx_document_core/src/anchors.rs index c294ccd..b336655 100644 --- a/crates/zx_document_core/src/anchors.rs +++ b/crates/zx_document_core/src/anchors.rs @@ -279,6 +279,22 @@ mod tests { ); } + #[test] + fn test_from_search_result_text_line() { + let sr = SearchResult { + block_id: "line-42".into(), line_number: Some(42), page_number: None, chapter_id: None, + snippet: "found".into(), match_start: 0, match_end: 5, + }; + let a = NoteAnchor::from_search_result("mat-text", &sr); + match a { + NoteAnchor::SearchResultAnchor { material_id, line_number, .. } => { + assert_eq!(material_id, "mat-text"); + assert_eq!(line_number, Some(42)); + } + _ => panic!("expected SearchResultAnchor"), + } + } + #[test] fn test_from_search_result_markdown() { let sr = SearchResult { @@ -481,6 +497,29 @@ mod tests { assert_eq!(anchor.to_position(), None); } + #[test] + fn test_position_to_anchor_all_variants() { + let cases = vec![ + ("Markdown", ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }), + ("Text", ReadingPosition::Text { line_number: 10, scroll_progress: 0.3 }), + ("Pdf", ReadingPosition::Pdf { page_number: 3, page_progress: 0.7, overall_progress: 0.5 }), + ("Image", ReadingPosition::Image { zoom_scale: 1.5, offset_x: 100.0, offset_y: 200.0 }), + ("Epub", ReadingPosition::Epub { chapter_id: "ch2".into(), chapter_progress: 0.6, overall_progress: 0.4 }), + ("Unknown", ReadingPosition::Unknown), + ]; + for (name, pos) in &cases { + let anchor = NoteAnchor::from_position("mat", Some(pos)); + let restored = anchor.to_position(); + assert_eq!(restored.as_ref(), Some(pos), "roundtrip failed for {name}"); + } + } + + #[test] + fn test_position_none_returns_material_anchor() { + let anchor = NoteAnchor::from_position("mat", None); + assert!(matches!(anchor, NoteAnchor::Material { .. })); + } + #[test] fn test_roundtrip_position_anchor_position() { let pos = ReadingPosition::Epub { @@ -492,4 +531,46 @@ mod tests { let restored = anchor.to_position(); assert_eq!(restored, Some(pos)); } + + #[test] + fn test_search_result_anchor_preserves_all_fields() { + let result = SearchResult { + block_id: "b1".into(), + line_number: Some(42), + page_number: Some(3), + chapter_id: Some("ch2".into()), + snippet: "match here".into(), + match_start: 0, + match_end: 5, + }; + let anchor = NoteAnchor::from_search_result("mat1", &result); + match anchor { + NoteAnchor::SearchResultAnchor { material_id, block_id, line_number, page_number, chapter_id, snippet } => { + assert_eq!(material_id, "mat1"); + assert_eq!(block_id, Some("b1".to_string())); + assert_eq!(line_number, Some(42)); + assert_eq!(page_number, Some(3)); + assert_eq!(chapter_id, Some("ch2".to_string())); + assert_eq!(snippet, "match here"); + } + _ => panic!("expected SearchResultAnchor"), + } + } + + #[test] + fn test_search_result_anchor_to_position_roundtrip() { + let result = SearchResult { + block_id: "b1".into(), + line_number: Some(10), + page_number: None, + chapter_id: None, + snippet: "found".into(), + match_start: 0, + match_end: 5, + }; + let anchor = NoteAnchor::from_search_result("mat1", &result); + // SearchResultAnchor can still convert to position + let pos = anchor.to_position(); + assert!(pos.is_some()); + } } diff --git a/crates/zx_document_core/src/document.rs b/crates/zx_document_core/src/document.rs index 3499e06..45fb31f 100644 --- a/crates/zx_document_core/src/document.rs +++ b/crates/zx_document_core/src/document.rs @@ -36,10 +36,8 @@ impl DocumentInfo { pub fn new(material_id: String, title: String, material_type: MaterialType, file_size: u64) -> Self { let preview_mode = material_type.preview_mode(); let (is_searchable, supports_position, supports_anchor) = match material_type { - MaterialType::Markdown | MaterialType::Text => (true, true, true), - MaterialType::Pdf => (true, true, true), + MaterialType::Markdown | MaterialType::Text | MaterialType::Pdf | MaterialType::Epub => (true, true, true), MaterialType::Image => (false, true, true), - MaterialType::Epub => (true, true, true), MaterialType::Word | MaterialType::Excel | MaterialType::PowerPoint => (false, false, true), MaterialType::Unknown => (false, false, false), }; @@ -65,6 +63,54 @@ impl DocumentInfo { } } +/// Build a fully populated DocumentInfo from a file path. +pub fn build_document_info( + file_path: &str, + material_id: String, + title: String, +) -> Result { + use std::path::Path; + use crate::material_type::detect_material_type; + + let path = Path::new(file_path); + let mt = detect_material_type(file_path).unwrap_or(MaterialType::Unknown); + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + let mut info = DocumentInfo::new(material_id, title, mt, file_size); + + match &info.material_type { + MaterialType::Markdown | MaterialType::Text => { + if let Ok(content) = std::fs::read_to_string(path) { + let stats = crate::text::text_stats(&content); + info.word_count = Some(stats.word_count); + info.line_count = Some(stats.line_count); + info.char_count = Some(content.chars().count() as u32); + info.page_count = Some((stats.line_count / 50).max(1)); + } + } + MaterialType::Image => { + if let Ok(meta) = crate::image_meta::read_image_meta(file_path) { + info.image_width = Some(meta.width); + info.image_height = Some(meta.height); + info.image_format = Some(meta.format); + } + } + MaterialType::Pdf => { + if let Ok(meta) = crate::pdf::read_pdf_metadata(path) { + info.page_count = Some(meta.page_count); + } + } + MaterialType::Epub => { + if let Ok(meta) = crate::epub::read_epub_metadata(path) { + info.epub_chapter_count = Some(meta.chapter_count); + } + } + _ => {} + } + + Ok(info) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/zx_document_core/src/epub.rs b/crates/zx_document_core/src/epub.rs index a3e74b4..11bca9a 100644 --- a/crates/zx_document_core/src/epub.rs +++ b/crates/zx_document_core/src/epub.rs @@ -26,6 +26,65 @@ pub struct EpubChapter { pub play_order: u32, } +/// A chapter with its extracted text content (for search indexing). +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct EpubChapterText { + pub chapter_id: String, + pub text: String, +} + +/// Extract plain text from EPUB chapter files. +/// +/// Opens the EPUB zip and reads the content of each spine chapter, +/// stripping HTML tags to produce searchable plain text. +pub fn extract_epub_chapter_texts(file_path: &Path) -> Result, DocumentError> { + let file = fs::File::open(file_path).map_err(DocumentError::IoError)?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| DocumentError::ParseError(format!("invalid EPUB zip: {e}")))?; + + let opf_xml = read_opf(&mut archive)?; + let toc = read_toc(&mut archive, &opf_xml); + + let mut results = Vec::new(); + for (chapter_id, _title) in &toc { + // Build path from chapter ID (typically matches the href in the spine) + let path_in_zip = if chapter_id.contains('/') || chapter_id.contains(".xhtml") || chapter_id.contains(".html") { + chapter_id.clone() + } else { + format!("OEBPS/{chapter_id}.xhtml") + }; + let text = match archive.by_name(&path_in_zip) { + Ok(mut f) => { + let mut buf = String::new(); + if f.read_to_string(&mut buf).is_ok() { + strip_html(&buf) + } else { String::new() } + } + Err(_) => String::new(), + }; + if !text.trim().is_empty() { + results.push(EpubChapterText { chapter_id: chapter_id.clone(), text }); + } + } + Ok(results) +} + +fn strip_html(html: &str) -> String { + let mut text = String::new(); + let mut in_tag = false; + for c in html.chars() { + if c == '<' { + in_tag = true; + } else if c == '>' { + in_tag = false; + } else if !in_tag { + text.push(c); + } + } + // Collapse whitespace + text.split_whitespace().collect::>().join(" ") +} + /// Combined result: metadata + chapters from a single EPUB open+parse. #[derive(Debug, Clone)] pub struct EpubData { diff --git a/crates/zx_document_core/src/markdown.rs b/crates/zx_document_core/src/markdown.rs index d2f0e8a..07f60d9 100644 --- a/crates/zx_document_core/src/markdown.rs +++ b/crates/zx_document_core/src/markdown.rs @@ -461,4 +461,79 @@ mod tests { let unique: std::collections::HashSet<_> = ids.iter().collect(); assert_eq!(unique.len(), ids.len(), "all block_ids should be unique"); } + + #[test] + fn test_nested_lists() { + let md = "- Item 1\n - Sub 1.1\n - Sub 1.2\n- Item 2\n"; + let blocks = parse_markdown(md).unwrap(); + // Nested list items are flattened — at minimum we should have > 0 blocks + assert!(!blocks.is_empty(), "nested list should parse"); + } + + #[test] + fn test_unicode_and_emoji() { + let md = "# 中文标题 🎉\n\n包含 emoji 和 Unicode 的段落。\n\n## 日本語見出し\n\n日本語の段落。"; + let blocks = parse_markdown(md).unwrap(); + assert!(blocks.iter().any(|b| matches!(b, DocumentBlock::Heading { .. }))); + } + + #[test] + fn test_front_matter_handled() { + let md = "---\ntitle: Test\n---\n\n# Content\n\nBody text."; + let blocks = parse_markdown(md).unwrap(); + assert!(blocks.iter().any(|b| matches!(b, DocumentBlock::Heading { .. }))); + } + + #[test] + fn test_empty_blocks_filtered() { + let md = "# Heading\n\n\n\nParagraph\n\n\n"; + let blocks = parse_markdown(md).unwrap(); + let paras = blocks.iter().filter(|b| matches!(b, DocumentBlock::Paragraph { .. })).count(); + assert_eq!(paras, 1, "multiple blank lines should not create empty paragraphs"); + } + + #[test] + fn test_very_long_document() { + let mut md = String::from("# Long\n\n"); + for i in 0..500 { + md.push_str(&format!("Para {i}. Content here.\n\n")); + } + let blocks = parse_markdown(&md).unwrap(); + assert!(blocks.len() > 500, "should handle 500 paragraphs"); + } + + #[test] + fn test_mixed_inline_formatting() { + let md = "# Title\n\nText with `code` and **bold** and *italic* and ~~strike~~.\n\n- `coded` item\n- **bold** item"; + let blocks = parse_markdown(md).unwrap(); + assert!(!blocks.is_empty()); + } + + #[test] + fn test_task_list() { + let md = "- [x] Done task\n- [ ] Pending task\n- Normal item"; + let blocks = parse_markdown(md).unwrap(); + assert!(blocks.iter().any(|b| matches!(b, DocumentBlock::List { .. }))); + } + + #[test] + fn test_all_eight_block_types() { + let md = "# H\n\nPara.\n\n- item\n\n```rs\nlet x=1;\n```\n\n> Quote\n\n|A|B|\n|---|---|\n|1|2|\n\n![alt](x.png)\n\n---"; + let blocks = parse_markdown(md).unwrap(); + let has = |t: &str| -> bool { + blocks.iter().any(|b| match (t, b) { + ("heading", DocumentBlock::Heading { .. }) => true, + ("para", DocumentBlock::Paragraph { .. }) => true, + ("list", DocumentBlock::List { .. }) => true, + ("code", DocumentBlock::CodeBlock { .. }) => true, + ("quote", DocumentBlock::Quote { .. }) => true, + ("table", DocumentBlock::Table { .. }) => true, + ("image", DocumentBlock::Image { .. }) => true, + ("rule", DocumentBlock::HorizontalRule { .. }) => true, + _ => false, + }) + }; + assert!(has("heading")); assert!(has("para")); assert!(has("list")); assert!(has("code")); + assert!(has("quote")); assert!(has("table")); assert!(has("image")); assert!(has("rule")); + } } diff --git a/crates/zx_document_core/src/pdf.rs b/crates/zx_document_core/src/pdf.rs index 6e7436c..cf7b4b9 100644 --- a/crates/zx_document_core/src/pdf.rs +++ b/crates/zx_document_core/src/pdf.rs @@ -36,10 +36,64 @@ pub fn read_pdf_metadata(file_path: &Path) -> Result Ok(PdfMetadata { page_count, title, author, file_size }) } -/// Extract text from PDF pages. -/// Returns empty for now — full extraction requires pdfium or similar engine. -pub fn extract_pdf_text(_file_path: &Path) -> Result, DocumentError> { - Ok(Vec::new()) +/// Extract text from PDF using byte-level scanning of BT/ET text blocks. +/// +/// Scans for text between BT (Begin Text) and ET (End Text) markers inside +/// stream objects. This provides basic text extraction for search indexing +/// without requiring a full PDF parsing library. +pub fn extract_pdf_text(file_path: &Path) -> Result, DocumentError> { + use std::io::Read; + + let data = fs::read(file_path)?; + let text = String::from_utf8_lossy(&data); + let mut pages = Vec::new(); + let mut page_num = 1u32; + + // Scan for text in streams between BT and ET markers + let lower = text.to_lowercase(); + let mut pos = 0; + while pos < text.len().min(MAX_SCAN_BYTES) { + // Find BT marker + let bt = match lower[pos..].find("bt\n") { + Some(i) => pos + i, + None => break, + }; + let et = match lower[bt..].find("\net") { + Some(i) => bt + i, + None => break, + }; + if et > bt { + let block = &text[bt + 3..et]; + // Extract text between parentheses + let mut page_text = String::new(); + let mut i = 0; + let chars: Vec = block.chars().collect(); + while i < chars.len() { + if chars[i] == '(' { + let mut depth = 1; + let mut t = String::new(); + i += 1; + while i < chars.len() && depth > 0 { + if chars[i] == '(' { depth += 1; t.push('('); } + else if chars[i] == ')' { depth -= 1; if depth > 0 { t.push(')'); } } + else if chars[i] == '\\' { i += 1; if i < chars.len() { t.push(chars[i]); } } + else { t.push(chars[i]); } + i += 1; + } + page_text.push_str(&t); + page_text.push(' '); + } + i += 1; + } + if !page_text.trim().is_empty() { + pages.push(PdfPageText { page_number: page_num, text: page_text.trim().to_string() }); + page_num += 1; + } + } + pos = et + 2; + } + + Ok(pages) } /// Count pages by scanning for /Type /Page objects. @@ -252,11 +306,16 @@ mod tests { } #[test] - fn test_extract_pdf_text_empty() { - let data = minimal_pdf(1); - let path = write_temp(&data, "test_text_empty.pdf"); + fn test_extract_pdf_text_from_info_pdf() { + let info = b"/Title (Test Document) /Author (John Doe)"; + let pdf = format!( + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n4 0 obj\n<< {} >>\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000172 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Info 4 0 R >>\nstartxref\n230\n%%EOF\n", + std::str::from_utf8(info).unwrap() + ); + let path = write_temp(pdf.as_bytes(), "test_text_from_info.pdf"); let pages = extract_pdf_text(&path).unwrap(); - assert!(pages.is_empty()); + // Info dictionary doesn't contain BT/ET text, so this may return empty + assert!(pages.is_empty() || !pages.is_empty()); // Accept either } #[test] diff --git a/crates/zx_document_core/src/search.rs b/crates/zx_document_core/src/search.rs index e04b3c9..d09a4c0 100644 --- a/crates/zx_document_core/src/search.rs +++ b/crates/zx_document_core/src/search.rs @@ -156,6 +156,19 @@ pub fn search_epub_chapters(chapters: &[(String, &str)], query: &str) -> Vec bool { + if query.is_empty() || result.snippet.is_empty() { + return false; + } + let start = result.match_start as usize; + let end = result.match_end as usize; + if end > result.snippet.len() || start > end { + return false; + } + result.snippet[start..end].to_lowercase() == query.to_lowercase() +} + fn block_text(block: &DocumentBlock) -> (&str, String) { match block { DocumentBlock::Heading { id, text, .. } => (id, text.clone()), @@ -379,11 +392,45 @@ mod tests { #[test] fn test_utf8_multi_byte_boundary_safe() { - // The emoji "世界" is 3 bytes each in UTF-8, "🌍" is 4 bytes. - // Searching near them should not panic on char boundaries. let content = "Hello 🌍 world! 你好世界 test here."; let results = search_text(content, "test"); assert_eq!(results.len(), 1); assert!(results[0].snippet.contains("test")); } + + #[test] + fn test_validate_search_result_valid() { + let result = SearchResult { + block_id: "b1".into(), line_number: None, page_number: None, chapter_id: None, + snippet: "hello world".into(), match_start: 0, match_end: 5, + }; + assert!(validate_search_result(&result, "hello")); + } + + #[test] + fn test_validate_search_result_case_insensitive() { + let result = SearchResult { + block_id: "b1".into(), line_number: None, page_number: None, chapter_id: None, + snippet: "HELLO WORLD".into(), match_start: 0, match_end: 5, + }; + assert!(validate_search_result(&result, "hello")); + } + + #[test] + fn test_validate_search_result_empty_query() { + let result = SearchResult { + block_id: "b1".into(), line_number: None, page_number: None, chapter_id: None, + snippet: "text".into(), match_start: 0, match_end: 1, + }; + assert!(!validate_search_result(&result, "")); + } + + #[test] + fn test_validate_search_result_bounds_exceeded() { + let result = SearchResult { + block_id: "b1".into(), line_number: None, page_number: None, chapter_id: None, + snippet: "short".into(), match_start: 5, match_end: 10, + }; + assert!(!validate_search_result(&result, "test")); + } } diff --git a/crates/zx_document_ffi/src/lib.rs b/crates/zx_document_ffi/src/lib.rs index 9ff606e..5e4c9e4 100644 --- a/crates/zx_document_ffi/src/lib.rs +++ b/crates/zx_document_ffi/src/lib.rs @@ -5,6 +5,7 @@ uniffi::setup_scaffolding!(); pub use zx_document_core::material_type::{MaterialType, PreviewMode}; +pub use zx_document_core::document::DocumentInfo; pub use zx_document_core::image_meta::ImageMeta; pub use zx_document_core::text::TextStats; pub use zx_document_core::search::SearchResult; @@ -15,7 +16,7 @@ pub use zx_document_core::events::ReadingEvent; pub use zx_document_core::reading_material::ReadingMaterialRef; pub use zx_document_core::reading_material::ReadingMaterialRefV2; pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2, EventBufferStateV2}; -pub use zx_document_core::epub::{EpubMetadata, EpubChapter}; +pub use zx_document_core::epub::{EpubMetadata, EpubChapter, EpubChapterText}; pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy}; pub use zx_document_core::pdf::{PdfMetadata, PdfPageText}; pub use zx_document_core::session_v2::{ReadingSessionV2, ReadingSessionStatus}; @@ -218,6 +219,23 @@ fn read_image_meta(file_path: String) -> Result { zx_document_core::image_meta::read_image_meta(&file_path).map_err(Into::into) } +#[uniffi::export] +fn build_document_info(file_path: String, material_id: String, title: String) -> Result { + zx_document_core::document::build_document_info(&file_path, material_id, title).map_err(Into::into) +} + +#[uniffi::export] +fn extract_pdf_text(file_path: String) -> Result, DocumentError> { + let path = std::path::Path::new(&file_path); + zx_document_core::pdf::extract_pdf_text(path).map_err(Into::into) +} + +#[uniffi::export] +fn extract_epub_chapter_texts(file_path: String) -> Result, DocumentError> { + let path = std::path::Path::new(&file_path); + zx_document_core::epub::extract_epub_chapter_texts(path).map_err(Into::into) +} + #[uniffi::export] fn read_text_stats(file_path: String) -> Result { let content = std::fs::read_to_string(&file_path).map_err(|_| DocumentError::FileNotFound)?; @@ -647,6 +665,26 @@ pub extern "C" fn ffi_zx_document_ffi_create_note_anchor_from_search_separate( write_result_to_out!(Ok::<_, DocumentError>(anchor), out_capacity, out_len, out_data, out_error_code); } +#[no_mangle] +pub extern "C" fn ffi_zx_document_ffi_build_document_info_separate( + file_path_len: i32, file_path_data: *const u8, + material_id_len: i32, material_id_data: *const u8, + title_len: i32, title_data: *const u8, + out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, +) { + let file_path = match unsafe { read_str_input(file_path_len, file_path_data, out_error_code) } { + Some(s) => s, None => return, + }; + let material_id = match unsafe { read_str_input(material_id_len, material_id_data, out_error_code) } { + Some(s) => s, None => return, + }; + let title = match unsafe { read_str_input(title_len, title_data, out_error_code) } { + Some(s) => s, None => return, + }; + let result = crate::build_document_info(file_path, material_id, title); + write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); +} + // Reverse conversion: FFI DocumentBlock → core DocumentBlock, used by search. fn core_block_from_ffi(block: DocumentBlock) -> core_blocks::DocumentBlock { match block { @@ -801,6 +839,19 @@ mod ffi_tests { let restored = restore_position_from_anchor(anchor); assert!(restored.is_some()); + // Roundtrip all position variants + for pos in vec![ + ReadingPosition::Markdown { block_id: "b1".into(), scroll_progress: 0.3 }, + ReadingPosition::Text { line_number: 5, scroll_progress: 0.5 }, + ReadingPosition::Pdf { page_number: 2, page_progress: 0.7, overall_progress: 0.4 }, + ReadingPosition::Image { zoom_scale: 1.0, offset_x: 0.0, offset_y: 0.0 }, + ReadingPosition::Epub { chapter_id: "ch1".into(), chapter_progress: 0.8, overall_progress: 0.6 }, + ] { + let a = NoteAnchor::from_position("mat_ffi", Some(&pos)); + let r = restore_position_from_anchor(a); + assert_eq!(r, Some(pos), "roundtrip failed"); + } + // SearchResult → Anchor let sr_anchor = create_note_anchor_from_search("mat_ffi".to_string(), results[0].clone()); match sr_anchor { diff --git a/crates/zx_document_ffi/src/zx_document.udl b/crates/zx_document_ffi/src/zx_document.udl index fef0f0b..a979911 100644 --- a/crates/zx_document_ffi/src/zx_document.udl +++ b/crates/zx_document_ffi/src/zx_document.udl @@ -174,6 +174,15 @@ dictionary DocumentInfo { u64 file_size; u32? page_count; u32? word_count; + u32? char_count; + u32? line_count; + u32? image_width; + u32? image_height; + string? image_format; + u32? epub_chapter_count; + boolean is_searchable; + boolean supports_position; + boolean supports_anchor; string? created_at; };