Files
epub-read/src/reader.rs

710 lines
26 KiB
Rust

use eframe::egui;
use crate::book::{Book, ContentBlock};
use crate::style::{StyleProfile, TextAlignment};
use crate::theme::{self, BgType, Theme};
fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
let mut blocks = Vec::new();
for paragraph in raw_text.split("\n\n") {
let trimmed = paragraph.trim();
if trimmed.is_empty() {
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') {
let rest = &text[pos + 1..];
heading_level = rest
.chars()
.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());
// Remove \x03<anchor>\x04 from text to prevent anchor ID contamination
let rm_start = pos + 2 + anchor_start;
let rm_end = pos + 2 + anchor_end + 1;
text.replace_range(rm_start..rm_end, "");
}
}
text.drain(pos..pos + 2);
}
text = text.replace('\x02', "");
}
let text = text.trim().to_string();
if !text.is_empty() {
blocks.push(ContentBlock { text, heading_level, anchor });
}
}
if blocks.is_empty() {
blocks.push(ContentBlock {
text: String::new(),
heading_level: 0,
anchor: None,
});
}
blocks
}
fn heading_font_size(base_size: f32, level: u8) -> f32 {
match level {
1 => base_size * 1.6,
2 => base_size * 1.35,
3 => base_size * 1.15,
4 => base_size * 1.05,
_ => base_size,
}
}
fn heading_top_spacing(para_spacing: f32, level: u8) -> f32 {
match level {
1 => para_spacing * 3.5,
2 => para_spacing * 3.0,
3 => para_spacing * 2.5,
4 => para_spacing * 2.0,
_ => 0.0,
}
}
fn measure_block_height(
ctx: &egui::Context,
text: &str,
font_size: f32,
available_width: f32,
) -> f32 {
if text.is_empty() || available_width <= 0.0 {
return 0.0;
}
let mut job = egui::text::LayoutJob::default();
job.text = text.to_string();
job.sections = vec![egui::text::LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
format: egui::text::TextFormat {
font_id: egui::FontId::proportional(font_size),
..Default::default()
},
}];
job.wrap = egui::text::TextWrapping {
max_width: available_width,
..Default::default()
};
let galley = ctx.fonts(|f| f.layout_job(job));
galley.size().y
}
fn measure_line_height(ctx: &egui::Context, font_size: f32) -> f32 {
let test_text = "M";
let mut job = egui::text::LayoutJob::default();
job.text = test_text.to_string();
job.sections = vec![egui::text::LayoutSection {
leading_space: 0.0,
byte_range: 0..test_text.len(),
format: egui::text::TextFormat {
font_id: egui::FontId::proportional(font_size),
..Default::default()
},
}];
job.wrap = egui::text::TextWrapping {
max_width: 100000.0,
..Default::default()
};
let galley = ctx.fonts(|f| f.layout_job(job));
galley.size().y
}
pub fn recalculate_pages(
ctx: &egui::Context,
book: &mut Book,
font_size: f32,
panel_width: f32,
panel_height: f32,
style: &StyleProfile,
) {
let available_width = panel_width.max(100.0);
let available_height = panel_height.max(100.0);
let indent_str = if style.first_line_indent > 0.0 {
"\u{3000}".repeat(style.first_line_indent as usize)
} else {
String::new()
};
let line_height = measure_line_height(ctx, font_size);
let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height;
for section in &mut book.sections {
if section.blocks.is_empty() {
section.blocks = parse_blocks(&section.content);
}
let mut page_block_ranges: Vec<(usize, usize)> = Vec::new();
let mut page_start_block: usize = 0;
let mut current_height: f32 = 0.0;
for (i, block) in section.blocks.iter().enumerate() {
let block_font_size = if block.heading_level > 0 {
heading_font_size(font_size, block.heading_level)
} else {
font_size
};
let display_text = if block.heading_level > 0 || indent_str.is_empty() {
block.text.clone()
} else {
format!("{}{}", indent_str, block.text)
};
let block_height = measure_block_height(ctx, &display_text, block_font_size, available_width);
let spacing = if i > page_start_block && i > 0 {
if block.heading_level > 0 {
para_spacing + heading_top_spacing(para_spacing, block.heading_level)
} else {
para_spacing
}
} else {
0.0
};
let needed = block_height + spacing;
if current_height > 0.0 && current_height + needed > available_height && i > page_start_block {
page_block_ranges.push((page_start_block, i));
page_start_block = i;
current_height = block_height;
} else {
current_height += needed;
}
}
if page_start_block < section.blocks.len() {
page_block_ranges.push((page_start_block, section.blocks.len()));
}
if page_block_ranges.is_empty() {
page_block_ranges.push((0, section.blocks.len().min(1)));
}
section.page_block_ranges = page_block_ranges;
}
}
pub fn find_page_for_block(section: &crate::book::Section, block_index: usize) -> Option<usize> {
for (page_idx, &(start, end)) in section.page_block_ranges.iter().enumerate() {
if block_index >= start && block_index < end {
return Some(page_idx);
}
}
None
}
pub fn find_page_by_snippet(section: &crate::book::Section, snippet: &str) -> Option<usize> {
let search = snippet.chars().take(30).collect::<String>();
for (block_idx, block) in section.blocks.iter().enumerate() {
if block.text.contains(&search) {
return find_page_for_block(section, block_idx);
}
}
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,
pub toggle_bookmark: bool,
pub switch_bg: Option<BgType>,
pub switch_to_profile: Option<String>,
pub switch_font: Option<String>,
pub page_next: bool,
pub page_prev: bool,
pub toggle_sidebar: bool,
pub jump_to_section: Option<usize>,
pub jump_to_anchor: Option<String>,
}
pub fn reading_view(
ui: &mut egui::Ui,
book: &mut Book,
current_section: &mut usize,
current_page: &mut usize,
sidebar_open: &mut bool,
style: &mut StyleProfile,
theme: &Theme,
bg_type: BgType,
_file_path: &str,
profile_names: &[String],
bookmarks: &[crate::theme::Bookmark],
current_font: &str,
) -> (ReaderAction, Option<usize>) {
let mut action = ReaderAction {
go_back: false,
toggle_theme: false,
toggle_bookmark: false,
switch_bg: None,
switch_to_profile: None,
switch_font: None,
page_next: false,
page_prev: false,
toggle_sidebar: false,
jump_to_section: None,
jump_to_anchor: None,
};
let mut jump_to_bookmark: Option<usize> = None;
let colors = theme::reader_colors(theme);
let has_bookmark = bookmarks.iter().any(|b| b.section == *current_section && b.page == *current_page);
let sidebar_tab_id = ui.make_persistent_id("sidebar_tab");
let mut sidebar_tab: usize = ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id));
if *sidebar_open {
egui::SidePanel::left("toc_sidebar")
.resizable(true)
.default_width(240.0)
.show_inside(ui, |ui| {
ui.horizontal(|ui| {
let toc_response = ui.selectable_label(sidebar_tab == 0, "📋 目录");
let bm_response = ui.selectable_label(sidebar_tab == 1,
format!("🔖 书签 ({})", bookmarks.len())
);
if toc_response.clicked() {
sidebar_tab = 0;
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 0);
}
if bm_response.clicked() {
sidebar_tab = 1;
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 1);
}
});
ui.separator();
if sidebar_tab == 0 {
egui::ScrollArea::vertical().show(ui, |ui| {
if let Some((section, anchor)) = render_toc(ui, &book.toc, *current_section) {
action.jump_to_section = Some(section);
action.jump_to_anchor = anchor;
}
});
} else {
egui::ScrollArea::vertical().show(ui, |ui| {
render_bookmarks(ui, bookmarks, &mut jump_to_bookmark);
});
}
});
}
egui::TopBottomPanel::top("reader_toolbar")
.show_inside(ui, |ui| {
ui.horizontal(|ui| {
if ui.button("← 返回").on_hover_text("返回书架").clicked() {
action.go_back = true;
}
ui.separator();
ui.label(format!("{}", &book.title));
ui.label(format!("[{}]", book.layout.label()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (theme_icon, theme_hint) = match theme {
Theme::Dark => ("🌞", "切换到浅色主题"),
Theme::Light => ("🌙", "切换到夜间主题"),
Theme::Sepia => ("📜", "切换到棕褐色主题"),
};
if ui.button(theme_icon).on_hover_text(theme_hint).clicked() {
action.toggle_theme = true;
}
let bookmark_icon = if has_bookmark { "🔴" } else { "🔖" };
let bookmark_hint = if has_bookmark { "移除书签" } else { "添加书签" };
if ui.button(bookmark_icon).on_hover_text(bookmark_hint).clicked() {
action.toggle_bookmark = true;
}
if ui.button("A⁻").on_hover_text("缩小字体").clicked() {
style.font_size = (style.font_size - 2.0).max(10.0);
}
if ui.button("A⁺").on_hover_text("放大字体").clicked() {
style.font_size = (style.font_size + 2.0).min(48.0);
}
egui::ComboBox::from_id_salt("profile_selector")
.width(110.0)
.selected_text(&style.name)
.show_ui(ui, |ui| {
for name in profile_names {
let selected = *name == style.name;
if ui.selectable_label(selected, name).clicked() {
action.switch_to_profile = Some(name.clone());
}
}
});
egui::ComboBox::from_id_salt("bg_type_selector")
.width(100.0)
.selected_text(bg_type.label())
.show_ui(ui, |ui| {
for &label in BgType::ALL.iter() {
let selected = bg_type.label() == label;
if ui.selectable_label(selected, label).clicked() {
action.switch_bg = Some(theme::BgType::from_label(label));
}
}
});
egui::ComboBox::from_id_salt("font_selector")
.width(100.0)
.selected_text(current_font)
.show_ui(ui, |ui| {
for name in crate::font::font_display_names() {
let selected = name == current_font;
if ui.selectable_label(selected, &name).clicked() {
action.switch_font = Some(name);
}
}
});
if ui.button("").on_hover_text("打开/关闭目录").clicked() {
*sidebar_open = !*sidebar_open;
}
});
});
});
let panel_size = ui.available_size();
let recalc_width = (panel_size.x - 48.0).max(100.0);
let recalc_height = (panel_size.y - 45.0).max(200.0);
recalculate_pages(ui.ctx(), book, style.font_size, recalc_width, recalc_height, style);
let total_pages = if *current_section < book.sections.len() {
let section = &book.sections[*current_section];
if section.page_block_ranges.is_empty() { 0 } else { section.page_block_ranges.len() }
} else { 0 };
egui::TopBottomPanel::bottom("reader_progress")
.show_inside(ui, |ui| {
ui.horizontal(|ui| {
let section_title = book.sections.get(*current_section)
.map(|s| s.title.as_str())
.unwrap_or("");
ui.label(section_title);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let prev_enabled = *current_page > 0 || *current_section > 0;
let next_enabled = if *current_section < book.sections.len() {
*current_page + 1 < book.sections[*current_section].page_block_ranges.len()
|| *current_section + 1 < book.sections.len()
} else {
false
};
let next_btn = egui::Button::new(
egui::RichText::new("下一页 ▶").size(13.0)
).min_size(egui::vec2(100.0, 28.0));
let prev_btn = egui::Button::new(
egui::RichText::new("◀ 上一页").size(13.0)
).min_size(egui::vec2(100.0, 28.0));
if !next_enabled {
ui.add_enabled(false, next_btn);
} else if ui.add(next_btn).on_hover_text("下一页 (→键)").clicked() {
action.page_next = true;
}
ui.add_space(8.0);
if !prev_enabled {
ui.add_enabled(false, prev_btn);
} else if ui.add(prev_btn).on_hover_text("上一页 (←键)").clicked() {
action.page_prev = true;
}
ui.separator();
let label = if total_pages > 0 {
format!("{}/{}", *current_page + 1, total_pages)
} else {
"1/1".to_string()
};
ui.label(label);
let mut progress = if total_pages > 0 {
*current_page as f32 / total_pages as f32
} else { 0.0 };
if ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")).changed() && total_pages > 0 {
*current_page = (progress * total_pages as f32).round() as usize;
}
});
});
});
// --- Center text area ---
egui::CentralPanel::default().show_inside(ui, |ui| {
let available = ui.available_size();
let (rect, response) = ui.allocate_at_least(available, egui::Sense::click());
let inset = 24.0;
let text_rect = egui::Rect::from_min_size(
egui::pos2(rect.min.x + inset, rect.min.y),
egui::vec2((rect.width() - inset * 2.0).max(100.0), rect.height()),
);
if let Some(section) = book.sections.get(*current_section) {
let max_page = section.page_block_ranges.len().saturating_sub(1);
if *current_page > max_page {
*current_page = max_page;
}
}
if let Some(section) = book.sections.get(*current_section) {
if *current_page < section.page_block_ranges.len() {
let (block_start, block_end) = section.page_block_ranges[*current_page];
let indent_str = if style.first_line_indent > 0.0 {
"\u{3000}".repeat(style.first_line_indent as usize)
} else {
String::new()
};
let line_height = measure_line_height(ui.ctx(), style.font_size);
let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height;
let align = match style.alignment {
TextAlignment::Left => egui::Align::LEFT,
TextAlignment::Center => egui::Align::Center,
TextAlignment::Right => egui::Align::RIGHT,
};
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.with_layout(
egui::Layout::top_down(align).with_main_justify(false),
|ui| {
for i in block_start..block_end {
if i > block_start {
ui.add_space(para_spacing);
}
let block = &section.blocks[i];
let display_text = if block.heading_level > 0 || indent_str.is_empty() {
block.text.clone()
} else {
format!("{}{}", indent_str, block.text)
};
if display_text.is_empty() {
continue;
}
if block.heading_level > 0 && i > block_start {
ui.add_space(heading_top_spacing(para_spacing, block.heading_level));
}
let block_font_size = if block.heading_level > 0 {
heading_font_size(style.font_size, block.heading_level)
} else {
style.font_size
};
let mut rt = egui::RichText::new(&display_text)
.size(block_font_size)
.color(colors.text);
if block.heading_level >= 1 && block.heading_level <= 4 {
rt = rt.strong();
}
let label = egui::Label::new(rt).wrap();
if block.heading_level == 1 {
ui.centered_and_justified(|ui| {
ui.add(label);
});
} else {
ui.add(label);
}
}
},
);
});
if has_bookmark {
let painter = ui.painter();
let bookmark_pos = egui::pos2(rect.max.x - 30.0, rect.min.y + 10.0);
painter.text(
bookmark_pos,
egui::Align2::RIGHT_TOP,
"🔴",
egui::FontId::proportional(18.0),
egui::Color32::RED,
);
}
}
}
if response.clicked() {
if let Some(click_pos) = response.interact_pointer_pos() {
let x_ratio = (click_pos.x - rect.min.x) / rect.width();
if x_ratio < 0.3 {
action.page_prev = true;
} else if x_ratio > 0.7 {
action.page_next = true;
}
}
}
if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
action.page_next = true;
}
if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
action.page_prev = true;
}
if ui.input(|i| i.key_pressed(egui::Key::B)) {
action.toggle_sidebar = true;
}
});
(action, jump_to_bookmark)
}
fn render_toc(
ui: &mut egui::Ui,
entries: &[crate::book::TocEntry],
current_section: usize,
) -> 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(
egui::Button::new(label_text)
.frame(false)
.wrap()
);
if response.clicked() {
jump = Some((entry.section, entry.anchor.clone()));
}
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) {
jump = Some(s);
}
});
}
}
jump
}
fn render_bookmarks(
ui: &mut egui::Ui,
bookmarks: &[crate::theme::Bookmark],
jump_to_bookmark: &mut Option<usize>,
) {
if bookmarks.is_empty() {
ui.horizontal(|ui| {
ui.label("暂无书签");
});
ui.label("点击工具栏 🔖 按钮添加书签");
return;
}
for (idx, bm) in bookmarks.iter().enumerate() {
let label_text = egui::RichText::new(&bm.label).size(13.0);
if ui.add(
egui::Button::new(label_text)
.frame(false)
.wrap()
).on_hover_text("点击跳转到该书签").clicked() {
*jump_to_bookmark = Some(idx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_blocks_empty() {
let blocks = parse_blocks("");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].text.is_empty());
}
#[test]
fn test_parse_blocks_single_paragraph() {
let blocks = parse_blocks("Hello World");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello World");
assert_eq!(blocks[0].heading_level, 0);
}
#[test]
fn test_parse_blocks_multiple_paragraphs() {
let blocks = parse_blocks("第一段\n\n第二段\n\n第三段");
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].text, "第一段");
assert_eq!(blocks[1].text, "第二段");
assert_eq!(blocks[2].text, "第三段");
}
#[test]
fn test_parse_blocks_heading() {
let blocks = parse_blocks("\x011标题\x02\n\n正文内容");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].heading_level, 1);
assert_eq!(blocks[0].text, "标题");
assert_eq!(blocks[1].heading_level, 0);
assert_eq!(blocks[1].text, "正文内容");
}
#[test]
fn test_parse_blocks_heading_levels() {
let blocks = parse_blocks("\x011一级\x02\n\n\x012二级\x02\n\n\x013三级\x02\n\n正文");
assert_eq!(blocks.len(), 4);
assert_eq!(blocks[0].heading_level, 1);
assert_eq!(blocks[0].text, "一级");
assert_eq!(blocks[1].heading_level, 2);
assert_eq!(blocks[1].text, "二级");
assert_eq!(blocks[2].heading_level, 3);
assert_eq!(blocks[2].text, "三级");
assert_eq!(blocks[3].heading_level, 0);
assert_eq!(blocks[3].text, "正文");
}
#[test]
fn test_parse_blocks_extra_newlines() {
let blocks = parse_blocks("段一\n\n\n\n段二");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].text, "段一");
assert_eq!(blocks[1].text, "段二");
}
#[test]
fn test_parse_blocks_heading_with_anchor() {
// \x011\x03toc1\x04Chapter 1\x02 → heading_level=1, anchor="toc1", text="Chapter 1"
let blocks = parse_blocks("\x011\x03toc1\x04Chapter 1\x02\n\nbody text");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].heading_level, 1);
assert_eq!(blocks[0].anchor.as_deref(), Some("toc1"));
assert_eq!(blocks[0].text, "Chapter 1");
assert_eq!(blocks[1].heading_level, 0);
assert_eq!(blocks[1].text, "body text");
}
#[test]
fn test_parse_blocks_heading_without_anchor() {
// \x011Title\x02 → heading_level=1, anchor=None, text="Title"
let blocks = parse_blocks("\x011Title\x02");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].heading_level, 1);
assert_eq!(blocks[0].anchor, None);
assert_eq!(blocks[0].text, "Title");
}
}