use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::import::ChapterId;
use crate::model::{Book, Chapter, NodeId, Role};
use crate::style::{StyleId, StylePool};
use super::{generate_css, synthesize_xhtml_document_with_class_list};
#[derive(Debug)]
pub struct GlobalStylePool {
pool: StylePool,
remaps: Vec<HashMap<StyleId, StyleId>>,
}
impl Default for GlobalStylePool {
fn default() -> Self {
Self::new()
}
}
impl GlobalStylePool {
pub fn new() -> Self {
Self {
pool: StylePool::new(),
remaps: Vec::new(),
}
}
pub fn merge(&mut self, chapter_idx: usize, chapter: &Chapter) {
while self.remaps.len() <= chapter_idx {
self.remaps.push(HashMap::new());
}
let remap = &mut self.remaps[chapter_idx];
for (local_id, style) in chapter.styles.iter() {
let global_id = self.pool.intern_ref(style);
remap.insert(local_id, global_id);
}
}
pub fn remap(&self, chapter_idx: usize, local_id: StyleId) -> StyleId {
self.remaps
.get(chapter_idx)
.and_then(|m| m.get(&local_id))
.copied()
.unwrap_or(StyleId::DEFAULT)
}
pub fn pool(&self) -> &StylePool {
&self.pool
}
pub fn used_styles(&self) -> Vec<StyleId> {
let mut set = HashSet::new();
for map in &self.remaps {
set.extend(map.values().copied());
}
let mut styles: Vec<StyleId> = set.into_iter().collect();
styles.sort_by_key(|s| s.0);
styles
}
}
#[derive(Debug, Clone)]
pub struct ChapterContent {
pub id: ChapterId,
pub source_path: String,
pub document: String,
}
#[derive(Debug)]
pub struct NormalizedContent {
pub styles: GlobalStylePool,
pub chapters: Vec<ChapterContent>,
pub assets: HashSet<String>,
pub css: String,
pub source_to_output: HashMap<String, String>,
pub anchor_to_output: HashMap<String, String>,
pub href_remap: HashMap<String, String>,
}
impl NormalizedContent {
pub fn rewrite_toc(&self, toc: &[crate::model::TocEntry]) -> Vec<crate::model::TocEntry> {
toc.iter().map(|e| self.rewrite_toc_entry(e)).collect()
}
pub fn rewrite_link(&self, href: &str) -> String {
if let Some(mapped) = self.href_remap.get(href) {
return mapped.clone();
}
rewrite_href(&self.source_to_output, &self.anchor_to_output, None, href)
}
fn rewrite_toc_entry(&self, entry: &crate::model::TocEntry) -> crate::model::TocEntry {
let mut out = entry.clone();
out.href = self.rewrite_link(&entry.href);
out.children = entry
.children
.iter()
.map(|c| self.rewrite_toc_entry(c))
.collect();
out
}
}
fn rewrite_href(
source_to_output: &HashMap<String, String>,
anchor_to_output: &HashMap<String, String>,
base_source: Option<&str>,
href: &str,
) -> String {
if href.is_empty() || href.contains("://") || href.starts_with("mailto:") {
return href.to_string();
}
let (file, frag) = match href.split_once('#') {
Some((f, fr)) => (f, Some(fr)),
None => (href, None),
};
let output = if file.is_empty() {
frag.and_then(|fr| anchor_to_output.get(fr).cloned())
.or_else(|| base_source.and_then(|b| source_to_output.get(b).cloned()))
} else {
let resolved = match base_source {
Some(b) => crate::dom::resolve_path(b, file),
None => file.to_string(),
};
source_to_output
.get(&resolved)
.or_else(|| source_to_output.get(file))
.cloned()
};
match (output, frag) {
(Some(o), Some(fr)) => format!("{o}#{fr}"),
(Some(o), None) => o,
(None, Some(fr)) if file.is_empty() => format!("#{fr}"),
_ => href.to_string(),
}
}
fn xml_unescape(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains('&') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let (repl, len) = if tail.starts_with("&") {
("&", 5)
} else if tail.starts_with("<") {
("<", 4)
} else if tail.starts_with(">") {
(">", 4)
} else if tail.starts_with(""") {
("\"", 6)
} else if tail.starts_with("'") {
("'", 5)
} else if tail.starts_with("'") {
("'", 6)
} else {
("&", 1) };
out.push_str(repl);
rest = &tail[len..];
}
out.push_str(rest);
std::borrow::Cow::Owned(out)
}
fn rewrite_document_hrefs(
doc: &str,
base_source: &str,
source_to_output: &HashMap<String, String>,
anchor_to_output: &HashMap<String, String>,
href_remap: &HashMap<String, String>,
) -> String {
const NEEDLE: &str = " href=\"";
if !doc.contains(NEEDLE) {
return doc.to_string();
}
let mut out = String::with_capacity(doc.len());
let mut rest = doc;
while let Some(pos) = rest.find(NEEDLE) {
let (before, after) = rest.split_at(pos + NEEDLE.len());
out.push_str(before);
if let Some(end) = after.find('"') {
let unescaped = xml_unescape(&after[..end]);
let key: &str = &unescaped;
let rewritten = match href_remap.get(key) {
Some(mapped) => mapped.clone(),
None => rewrite_href(source_to_output, anchor_to_output, Some(base_source), key),
};
super::escape_xml_into(&mut out, &rewritten);
rest = &after[end..];
} else {
rest = after;
}
}
out.push_str(rest);
out
}
pub fn normalize_book(book: &Book) -> crate::Result<NormalizedContent> {
let spine = book.spine();
let mut global_styles = GlobalStylePool::new();
let mut ir_chapters: Vec<(ChapterId, String, Arc<Chapter>)> = Vec::with_capacity(spine.len());
let mut source_to_output: HashMap<String, String> = HashMap::new();
let mut anchor_to_output: HashMap<String, String> = HashMap::new();
let spine_ids: Vec<ChapterId> = spine.iter().map(|e| e.id).collect();
let loaded = book.load_chapters_cached(&spine_ids)?;
for ((idx, entry), chapter) in spine.iter().enumerate().zip(loaded) {
let source_path = book
.source_id(entry.id)
.unwrap_or("unknown.xhtml")
.to_string();
global_styles.merge(idx, &chapter);
let output_name = format!("chapter_{idx}.xhtml");
source_to_output.insert(source_path.clone(), output_name.clone());
for node_id in chapter.iter_dfs() {
if let Some(id) = chapter.semantics.id(node_id) {
anchor_to_output
.entry(id.to_string())
.or_insert_with(|| output_name.clone());
}
}
ir_chapters.push((entry.id, source_path, chapter));
}
let chapter_pos: HashMap<ChapterId, usize> = ir_chapters
.iter()
.enumerate()
.map(|(i, (id, _, _))| (*id, i))
.collect();
let mut per_chapter_remap: Vec<HashMap<String, String>> =
vec![HashMap::new(); ir_chapters.len()];
let mut href_remap: HashMap<String, String> = HashMap::new();
if let Ok(resolved) = book.resolve_links() {
for (idx, (chapter_id, _, chapter)) in ir_chapters.iter().enumerate() {
for node_id in chapter.iter_dfs() {
let Some(href) = chapter.semantics.href(node_id) else {
continue;
};
if href.is_empty() || href.contains("://") || href.starts_with("mailto:") {
continue;
}
let target = resolved.get(crate::model::GlobalNodeId::new(*chapter_id, node_id));
let output = match target {
Some(crate::model::AnchorTarget::Internal(gid)) => {
let Some(&tidx) = chapter_pos.get(&gid.chapter) else {
continue;
};
let frag = ir_chapters[tidx].2.semantics.id(gid.node);
match frag {
Some(frag) => format!("chapter_{tidx}.xhtml#{frag}"),
None => format!("chapter_{tidx}.xhtml"),
}
}
Some(crate::model::AnchorTarget::Chapter(cid)) => {
let Some(&tidx) = chapter_pos.get(cid) else {
continue;
};
format!("chapter_{tidx}.xhtml")
}
_ => continue,
};
per_chapter_remap[idx].insert(href.to_string(), output.clone());
href_remap.entry(href.to_string()).or_insert(output);
}
}
}
let used_styles = global_styles.used_styles();
let css_artifact = generate_css(global_styles.pool(), &used_styles);
let synthesize_one = |(idx, (chapter_id, source_path, ir)): (
usize,
&(ChapterId, String, Arc<Chapter>),
)|
-> (ChapterContent, HashSet<String>) {
let mut remapped_class_list: Vec<Option<&str>> = vec![None; ir.styles.len()];
for (local_id, _) in ir.styles.iter() {
let global_id = global_styles.remap(idx, local_id);
if let Some(class_name) = css_artifact.class_name_fast(global_id) {
let slot = remapped_class_list
.get_mut(local_id.0 as usize)
.expect("style id out of bounds");
*slot = Some(class_name);
}
}
let title = extract_chapter_title(ir).unwrap_or_else(|| source_path.clone());
let result = synthesize_xhtml_document_with_class_list(
ir,
&remapped_class_list,
&title,
Some("style.css"),
);
let document = rewrite_document_hrefs(
&result.body,
source_path,
&source_to_output,
&anchor_to_output,
&per_chapter_remap[idx],
);
(
ChapterContent {
id: *chapter_id,
source_path: source_path.clone(),
document,
},
result.assets,
)
};
#[cfg(all(feature = "parallel", not(target_arch = "wasm32")))]
let synthesized: Vec<(ChapterContent, HashSet<String>)> = {
use rayon::prelude::*;
ir_chapters
.par_iter()
.enumerate()
.map(synthesize_one)
.collect()
};
#[cfg(not(all(feature = "parallel", not(target_arch = "wasm32"))))]
let synthesized: Vec<(ChapterContent, HashSet<String>)> =
ir_chapters.iter().enumerate().map(synthesize_one).collect();
let mut chapters = Vec::with_capacity(synthesized.len());
let mut all_assets = HashSet::new();
for (content, assets) in synthesized {
all_assets.extend(assets);
chapters.push(content);
}
Ok(NormalizedContent {
styles: global_styles,
chapters,
assets: all_assets,
css: css_artifact.stylesheet,
source_to_output,
anchor_to_output,
href_remap,
})
}
fn extract_chapter_title(ir: &Chapter) -> Option<String> {
for node_id in ir.iter_dfs() {
if let Some(node) = ir.node(node_id)
&& matches!(node.role, Role::Heading(_))
{
let mut title = String::new();
collect_text_recursive(ir, node_id, &mut title);
if !title.is_empty() {
return Some(title.trim().to_string());
}
}
}
None
}
fn collect_text_recursive(ir: &Chapter, node_id: NodeId, buf: &mut String) {
if let Some(node) = ir.node(node_id)
&& node.role == Role::Text
{
buf.push_str(ir.text(node.text));
}
for child_id in ir.children(node_id) {
collect_text_recursive(ir, child_id, buf);
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use crate::model::Node;
use crate::style::{ComputedStyle, FontWeight};
#[test]
fn xml_unescape_reverses_escape_xml() {
for raw in ["a&b", "x<y>z", "he said \"hi\"", "it's", "plain/path#frag"] {
let escaped = super::super::escape_xml(raw);
assert_eq!(xml_unescape(&escaped), raw, "round trip for {raw:?}");
}
assert!(matches!(
xml_unescape("chapter_0.xhtml#frag"),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(xml_unescape("a&unknown;b"), "a&unknown;b");
}
#[test]
fn rewrite_document_hrefs_handles_escaped_ampersand() {
let mut href_remap = HashMap::new();
href_remap.insert("ch1.xhtml#a&b".to_string(), "chapter_0.xhtml#x".to_string());
let doc = r#"<a href="ch1.xhtml#a&b">link</a>"#;
let out = rewrite_document_hrefs(
doc,
"src.xhtml",
&HashMap::new(),
&HashMap::new(),
&href_remap,
);
assert!(out.contains(r#"href="chapter_0.xhtml#x""#), "{out}");
}
#[test]
fn test_global_style_pool_new() {
let pool = GlobalStylePool::new();
assert_eq!(pool.pool().len(), 1); assert!(pool.remaps.is_empty());
}
#[test]
fn test_global_style_pool_merge() {
let mut global = GlobalStylePool::new();
let mut chapter1 = Chapter::new();
let mut bold = ComputedStyle::default();
bold.font_weight = FontWeight::BOLD;
let bold_id = chapter1.styles.intern(bold.clone());
let mut chapter2 = Chapter::new();
let bold_id2 = chapter2.styles.intern(bold);
global.merge(0, &chapter1);
global.merge(1, &chapter2);
let global_id1 = global.remap(0, bold_id);
let global_id2 = global.remap(1, bold_id2);
assert_eq!(global_id1, global_id2);
assert_eq!(global.pool().len(), 2);
}
#[test]
fn test_global_style_pool_remap_unknown() {
let global = GlobalStylePool::new();
let result = global.remap(999, StyleId(999));
assert_eq!(result, StyleId::DEFAULT);
}
#[test]
fn test_global_style_pool_used_styles() {
let mut global = GlobalStylePool::new();
let mut chapter = Chapter::new();
let mut bold = ComputedStyle::default();
bold.font_weight = FontWeight::BOLD;
chapter.styles.intern(bold);
global.merge(0, &chapter);
let used = global.used_styles();
assert!(!used.is_empty());
}
#[test]
fn test_extract_chapter_title() {
let mut chapter = Chapter::new();
let h1 = chapter.alloc_node(Node::new(Role::Heading(1)));
chapter.append_child(NodeId::ROOT, h1);
let text_range = chapter.append_text("Chapter One");
let mut text_node = Node::new(Role::Text);
text_node.text = text_range;
let text_id = chapter.alloc_node(text_node);
chapter.append_child(h1, text_id);
let title = extract_chapter_title(&chapter);
assert_eq!(title, Some("Chapter One".to_string()));
}
#[test]
fn test_extract_chapter_title_no_heading() {
let chapter = Chapter::new();
let title = extract_chapter_title(&chapter);
assert_eq!(title, None);
}
#[test]
fn rewrite_href_maps_anchors_and_paths() {
let source_to_output =
HashMap::from([("text/ch2.xhtml".to_string(), "chapter_1.xhtml".to_string())]);
let anchor_to_output = HashMap::from([("sec3".to_string(), "chapter_1.xhtml".to_string())]);
let rw = |href: &str| rewrite_href(&source_to_output, &anchor_to_output, None, href);
assert_eq!(rw("#sec3"), "chapter_1.xhtml#sec3");
assert_eq!(rw("text/ch2.xhtml#x"), "chapter_1.xhtml#x");
assert_eq!(rw("text/ch2.xhtml"), "chapter_1.xhtml");
assert_eq!(rw("#missing"), "#missing");
assert_eq!(rw("https://example.com/a"), "https://example.com/a");
}
}