diff --git a/src/app.rs b/src/app.rs index 8c449cd..bc19067 100644 --- a/src/app.rs +++ b/src/app.rs @@ -382,6 +382,15 @@ BgType::Custom(ref path) if !path.is_empty() => { if let Some(section) = action.jump_to_section { self.state.current_section = section; self.state.current_page = 0; + if let Some(ref anchor) = action.jump_to_anchor { + if let Some(ref book) = self.state.book { + if section < book.sections.len() { + if let Some(page) = crate::reader::find_page_for_anchor(&book.sections[section], anchor) { + self.state.current_page = page; + } + } + } + } } if action.page_prev { diff --git a/src/book.rs b/src/book.rs index 3cb742e..4a9ec98 100644 --- a/src/book.rs +++ b/src/book.rs @@ -43,10 +43,33 @@ fn tag_name_from(tag_content: &str) -> &str { .trim_end_matches('/') } +fn extract_id_from_tag(tag_content: &str) -> Option { + if let Some(id_pos) = tag_content.find("id=\"") { + let after_quote = &tag_content[id_pos + 4..]; + if let Some(end_quote) = after_quote.find('\"') { + let id_val = &after_quote[..end_quote]; + if !id_val.is_empty() { + return Some(id_val.to_string()); + } + } + } + if let Some(id_pos) = tag_content.find("id='") { + let after_quote = &tag_content[id_pos + 4..]; + if let Some(end_quote) = after_quote.find('\'') { + let id_val = &after_quote[..end_quote]; + if !id_val.is_empty() { + return Some(id_val.to_string()); + } + } + } + None +} + pub fn strip_html(input: &str) -> String { let mut out = String::with_capacity(input.len()); let mut pos = 0; let mut heading_level: Option = None; + let mut pending_anchor: Option = None; while pos < input.len() { // Find next '<' @@ -68,8 +91,14 @@ pub fn strip_html(input: &str) -> String { if let Some(level) = heading_level { out.push('\x01'); out.push(char::from_digit(level, 10).unwrap_or('1')); + if let Some(ref anchor) = pending_anchor { + out.push('\x03'); + out.push_str(anchor); + out.push('\x04'); + } out.push_str(&text); out.push('\x02'); + pending_anchor = None; } else { out.push_str(&text); } @@ -101,6 +130,13 @@ pub fn strip_html(input: &str) -> String { } pos = tag_end + 1; } + "a" => { + // Capture anchor id + if heading_level.is_none() { + pending_anchor = extract_id_from_tag(tag_content); + } + pos = tag_end + 1; + } "br" => { if !out.is_empty() { out.push('\n'); @@ -124,13 +160,18 @@ pub fn strip_html(input: &str) -> String { "/li" | "/dd" | "/dt" | "/ol" | "/ul" => { pos = tag_end + 1; } - "h1" => { heading_level = Some(1); pos = tag_end + 1; } - "h2" => { heading_level = Some(2); pos = tag_end + 1; } - "h3" => { heading_level = Some(3); pos = tag_end + 1; } - "h4" => { heading_level = Some(4); pos = tag_end + 1; } - "h5" => { heading_level = Some(5); pos = tag_end + 1; } - "h6" => { heading_level = Some(6); pos = tag_end + 1; } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { + let level = name[1..2].parse::().unwrap_or(1); + heading_level = Some(level); + if pending_anchor.is_none() { + pending_anchor = extract_id_from_tag(tag_content); + } + pos = tag_end + 1; + } "p" | "div" | "blockquote" => { + if pending_anchor.is_none() { + pending_anchor = extract_id_from_tag(tag_content); + } pos = tag_end + 1; } "/p" | "/div" | "/blockquote" => { @@ -138,10 +179,12 @@ pub fn strip_html(input: &str) -> String { out.push('\n'); } out.push('\n'); + pending_anchor = None; pos = tag_end + 1; } "/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => { heading_level = None; + pending_anchor = None; if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } @@ -149,6 +192,9 @@ pub fn strip_html(input: &str) -> String { pos = tag_end + 1; } _ => { + if pending_anchor.is_none() { + pending_anchor = extract_id_from_tag(tag_content); + } pos = tag_end + 1; } } @@ -177,6 +223,7 @@ pub fn strip_html(input: &str) -> String { pub struct TocEntry { pub label: String, pub section: usize, + pub anchor: Option, pub children: Vec, } @@ -184,6 +231,7 @@ pub struct TocEntry { pub struct ContentBlock { pub text: String, pub heading_level: u8, // 0 = body, 1-6 = h1-h6 + pub anchor: Option, } #[derive(Debug, Clone)] @@ -307,6 +355,19 @@ fn extract_filename(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } +fn extract_fragment(path: &str) -> Option { + if let Some(hash_pos) = path.find('#') { + let fragment = &path[hash_pos + 1..]; + if !fragment.is_empty() { + Some(fragment.to_string()) + } else { + None + } + } else { + None + } +} + fn build_toc( entries: &[epub::doc::NavPoint], spine: &[String], @@ -315,6 +376,7 @@ fn build_toc( .iter() .map(|e| { let content_str = e.content.to_string_lossy(); + let anchor = extract_fragment(&content_str); let content_file = extract_filename(&content_str); let section = spine .iter() @@ -328,6 +390,7 @@ fn build_toc( TocEntry { label: e.label.clone(), section, + anchor, children: build_toc(&e.children, spine), } }) diff --git a/src/reader.rs b/src/reader.rs index 270f907..d7e04fd 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -11,6 +11,7 @@ fn parse_blocks(raw_text: &str) -> Vec { continue; } let mut heading_level = 0u8; + let mut anchor: Option = None; let mut text = trimmed.to_string(); if text.contains('\x01') { if let Some(pos) = text.find('\x01') { @@ -20,19 +21,28 @@ fn parse_blocks(raw_text: &str) -> Vec { .next() .and_then(|c| c.to_digit(10)) .unwrap_or(1) as u8; + let after_level = &text[pos + 2..]; + if let Some(anchor_start) = after_level.find('\x03') { + if let Some(anchor_end) = after_level.find('\x04') { + let anchor_str = &after_level[anchor_start + 1..anchor_end]; + anchor = Some(anchor_str.to_string()); + } + } text.drain(pos..pos + 2); } + text = text.replace(&['\x03', '\x04'][..], ""); text = text.replace('\x02', ""); } let text = text.trim().to_string(); if !text.is_empty() { - blocks.push(ContentBlock { text, heading_level }); + blocks.push(ContentBlock { text, heading_level, anchor }); } } if blocks.is_empty() { blocks.push(ContentBlock { text: String::new(), heading_level: 0, + anchor: None, }); } blocks @@ -201,6 +211,17 @@ pub fn find_page_by_snippet(section: &crate::book::Section, snippet: &str) -> Op None } +pub fn find_page_for_anchor(section: &crate::book::Section, anchor: &str) -> Option { + for (block_idx, block) in section.blocks.iter().enumerate() { + if let Some(ref a) = block.anchor { + if a == anchor { + return find_page_for_block(section, block_idx); + } + } + } + None +} + pub struct ReaderAction { pub go_back: bool, pub toggle_theme: bool, @@ -212,6 +233,7 @@ pub struct ReaderAction { pub page_prev: bool, pub toggle_sidebar: bool, pub jump_to_section: Option, + pub jump_to_anchor: Option, } pub fn reading_view( @@ -239,6 +261,7 @@ pub fn reading_view( page_prev: false, toggle_sidebar: false, jump_to_section: None, + jump_to_anchor: None, }; let mut jump_to_bookmark: Option = None; @@ -271,8 +294,9 @@ pub fn reading_view( ui.separator(); if sidebar_tab == 0 { egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(section) = render_toc(ui, &book.toc, *current_section) { + if let Some((section, anchor)) = render_toc(ui, &book.toc, *current_section) { action.jump_to_section = Some(section); + action.jump_to_anchor = anchor; } }); } else { @@ -549,8 +573,8 @@ fn render_toc( ui: &mut egui::Ui, entries: &[crate::book::TocEntry], current_section: usize, -) -> Option { - let mut jump = None; +) -> Option<(usize, Option)> { + let mut jump: Option<(usize, Option)> = None; for entry in entries { let label_text = egui::RichText::new(&entry.label); let response = ui.add( @@ -559,9 +583,10 @@ fn render_toc( .wrap() ); if response.clicked() { - jump = Some(entry.section); + jump = Some((entry.section, entry.anchor.clone())); } - response.on_hover_text(format!("跳转到: {} (章节 {})", entry.label, entry.section)); + let anchor_info = entry.anchor.as_ref().map(|a| format!(" @{a}")).unwrap_or_default(); + response.on_hover_text(format!("跳转到: {} (章节 {}{})", entry.label, entry.section, anchor_info)); if !entry.children.is_empty() { ui.indent(&entry.label, |ui| { if let Some(s) = render_toc(ui, &entry.children, current_section) {