Add anchor-based TOC navigation: parse #fragment anchors, find exact page within section

This commit is contained in:
2026-05-22 17:56:48 +08:00
parent 21e9aba274
commit 1d2407098c
3 changed files with 109 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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),
}
})

View File

@@ -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) {