Add anchor-based TOC navigation: parse #fragment anchors, find exact page within section
This commit is contained in:
@@ -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 {
|
||||
|
||||
75
src/book.rs
75
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<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
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<u32> = None;
|
||||
let mut pending_anchor: Option<String> = 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::<u32>().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<String>,
|
||||
pub children: Vec<TocEntry>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
|
||||
continue;
|
||||
}
|
||||
let mut heading_level = 0u8;
|
||||
let mut anchor: Option<String> = 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<ContentBlock> {
|
||||
.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<usize> {
|
||||
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<usize>,
|
||||
pub jump_to_anchor: Option<String>,
|
||||
}
|
||||
|
||||
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<usize> = 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<usize> {
|
||||
let mut jump = None;
|
||||
) -> Option<(usize, Option<String>)> {
|
||||
let mut jump: Option<(usize, Option<String>)> = 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) {
|
||||
|
||||
Reference in New Issue
Block a user