use std::collections::HashMap;
pub(crate) struct ChapterSplit {
pub(crate) chapters: Vec<Vec<u8>>,
pub(crate) chapter_paths: Vec<String>,
pub(crate) filepos_to_chapter: HashMap<String, usize>,
}
pub(crate) fn split_mobi_html(html: &[u8], ncx_positions: Option<&[u32]>) -> ChapterSplit {
let html_str = String::from_utf8_lossy(html);
let (head_content, body_content) = extract_head_and_body(&html_str);
let pagebreak_positions = find_pagebreaks(body_content.as_bytes());
let body_chunks = if !pagebreak_positions.is_empty() {
split_at_pagebreaks(body_content, &pagebreak_positions)
} else if let Some(positions) = ncx_positions {
let ncx_chunks = split_at_ncx_anchors(body_content, positions);
if ncx_chunks.len() > 1 {
ncx_chunks
} else {
vec![body_content.to_string()]
}
} else {
vec![body_content.to_string()]
};
let body_chunks: Vec<String> = body_chunks
.into_iter()
.filter(|chunk| !chunk.trim().is_empty())
.collect();
let mut chapters = Vec::with_capacity(body_chunks.len());
let mut chapter_paths = Vec::with_capacity(body_chunks.len());
let mut filepos_to_chapter: HashMap<String, usize> = HashMap::new();
for (i, chunk) in body_chunks.iter().enumerate() {
let chapter_path = format!("chapter_{}.xhtml", i);
chapter_paths.push(chapter_path);
collect_filepos_anchors(chunk, i, &mut filepos_to_chapter);
let doc = format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!DOCTYPE html>\n\
<html xmlns=\"http://www.w3.org/1999/xhtml\">\n\
<head>\n{}</head>\n\
<body>\n{}\n</body>\n\
</html>",
head_content, chunk
);
chapters.push(doc.into_bytes());
}
rewrite_cross_chapter_links(&mut chapters, &filepos_to_chapter, &chapter_paths);
neutralize_bare_filename_links(&mut chapters);
if chapters.is_empty() {
chapters.push(html.to_vec());
chapter_paths.push("chapter_0.xhtml".to_string());
}
ChapterSplit {
chapters,
chapter_paths,
filepos_to_chapter,
}
}
pub(crate) fn split_mobi_html_ncx_only(html: &[u8], ncx_positions: &[u32]) -> ChapterSplit {
let html_str = String::from_utf8_lossy(html);
let (head_content, body_content) = extract_head_and_body(&html_str);
let body_chunks = {
let ncx_chunks = split_at_ncx_anchors(body_content, ncx_positions);
if ncx_chunks.len() > 1 {
ncx_chunks
} else {
vec![body_content.to_string()]
}
};
let body_chunks: Vec<String> = body_chunks
.into_iter()
.filter(|chunk| !chunk.trim().is_empty())
.collect();
let mut chapters = Vec::with_capacity(body_chunks.len());
let mut chapter_paths = Vec::with_capacity(body_chunks.len());
let mut filepos_to_chapter: HashMap<String, usize> = HashMap::new();
for (i, chunk) in body_chunks.iter().enumerate() {
let chapter_path = format!("chapter_{}.xhtml", i);
chapter_paths.push(chapter_path);
collect_filepos_anchors(chunk, i, &mut filepos_to_chapter);
let doc = format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!DOCTYPE html>\n\
<html xmlns=\"http://www.w3.org/1999/xhtml\">\n\
<head>\n{}</head>\n\
<body>\n{}\n</body>\n\
</html>",
head_content, chunk
);
chapters.push(doc.into_bytes());
}
rewrite_cross_chapter_links(&mut chapters, &filepos_to_chapter, &chapter_paths);
neutralize_bare_filename_links(&mut chapters);
if chapters.is_empty() {
chapters.push(html.to_vec());
chapter_paths.push("chapter_0.xhtml".to_string());
}
ChapterSplit {
chapters,
chapter_paths,
filepos_to_chapter,
}
}
fn find_ci(haystack: &[u8], needle_lower: &[u8], mut from: usize) -> Option<usize> {
let first = *needle_lower.first()?;
while from + needle_lower.len() <= haystack.len() {
let pos = from + memchr::memchr(first, &haystack[from..])?;
if pos + needle_lower.len() > haystack.len() {
return None;
}
if haystack[pos..pos + needle_lower.len()].eq_ignore_ascii_case(needle_lower) {
return Some(pos);
}
from = pos + 1;
}
None
}
fn rfind_ci(haystack: &[u8], needle_lower: &[u8]) -> Option<usize> {
let first = *needle_lower.first()?;
let mut end = haystack.len();
while end >= needle_lower.len() {
let pos = memchr::memrchr(first, &haystack[..end])?;
if pos + needle_lower.len() <= haystack.len()
&& haystack[pos..pos + needle_lower.len()].eq_ignore_ascii_case(needle_lower)
{
return Some(pos);
}
end = pos;
}
None
}
fn extract_head_and_body(html: &str) -> (&str, &str) {
let bytes = html.as_bytes();
let head_content = if let Some(head_start) = find_ci(bytes, b"<head", 0) {
let after_tag = html[head_start..].find('>').map(|p| head_start + p + 1);
let head_end = find_ci(bytes, b"</head>", 0);
match (after_tag, head_end) {
(Some(start), Some(end)) if start <= end => &html[start..end],
_ => "",
}
} else {
""
};
let body_content = if let Some(body_start) = find_ci(bytes, b"<body", 0) {
let after_tag = html[body_start..].find('>').map(|p| body_start + p + 1);
let body_end = rfind_ci(bytes, b"</body>");
match (after_tag, body_end) {
(Some(start), Some(end)) if start <= end => &html[start..end],
(Some(start), None) => &html[start..],
_ => html,
}
} else {
html
};
(head_content, body_content)
}
struct PagebreakPos {
start: usize,
end: usize,
}
fn find_pagebreaks(body: &[u8]) -> Vec<PagebreakPos> {
let mut results = Vec::new();
let needle = b"<mbp:pagebreak";
let mut search_from = 0;
while let Some(tag_start) = find_ci(body, needle, search_from) {
if let Some(close_rel) = memchr::memchr(b'>', &body[tag_start..]) {
let tag_end = tag_start + close_rel + 1;
results.push(PagebreakPos {
start: tag_start,
end: tag_end,
});
search_from = tag_end;
} else {
search_from = tag_start + needle.len();
}
}
results
}
fn split_at_pagebreaks(body: &str, pagebreaks: &[PagebreakPos]) -> Vec<String> {
let mut chunks = Vec::with_capacity(pagebreaks.len() + 1);
let mut last_end = 0;
for pb in pagebreaks {
chunks.push(body[last_end..pb.start].to_string());
last_end = pb.end;
}
chunks.push(body[last_end..].to_string());
chunks
}
fn collect_filepos_anchors(chunk: &str, chapter_idx: usize, map: &mut HashMap<String, usize>) {
let needle = "id=\"filepos";
let mut search_pos = 0;
while let Some(rel) = chunk[search_pos..].find(needle) {
let value_start = search_pos + rel + needle.len();
let value_end = chunk[value_start..]
.find('"')
.map(|p| value_start + p)
.unwrap_or(value_start);
if value_end > value_start {
let filepos_key = format!("filepos{}", &chunk[value_start..value_end]);
map.insert(filepos_key, chapter_idx);
}
search_pos = value_end + 1;
if search_pos >= chunk.len() {
break;
}
}
}
fn rewrite_cross_chapter_links(
chapters: &mut [Vec<u8>],
filepos_to_chapter: &HashMap<String, usize>,
chapter_paths: &[String],
) {
let needle = b"href=\"#filepos";
for (chapter_idx, chapter) in chapters.iter_mut().enumerate() {
let mut output = Vec::with_capacity(chapter.len());
let mut pos = 0;
while pos < chapter.len() {
if pos + needle.len() < chapter.len() && chapter[pos..].starts_with(needle) {
let value_start = pos + b"href=\"#".len();
let quote_end = chapter[value_start..]
.iter()
.position(|&b| b == b'"')
.map(|p| value_start + p);
if let Some(end) = quote_end {
let filepos_key =
String::from_utf8_lossy(&chapter[value_start..end]).to_string();
let target_chapter = filepos_to_chapter
.get(&filepos_key)
.copied()
.unwrap_or(chapter_idx);
if target_chapter != chapter_idx {
output.extend_from_slice(b"href=\"");
output.extend_from_slice(chapter_paths[target_chapter].as_bytes());
output.push(b'#');
output.extend_from_slice(filepos_key.as_bytes());
output.push(b'"');
} else {
output.extend_from_slice(&chapter[pos..end + 1]);
}
pos = end + 1;
continue;
}
}
output.push(chapter[pos]);
pos += 1;
}
*chapter = output;
}
}
fn split_at_ncx_anchors(body: &str, positions: &[u32]) -> Vec<String> {
if positions.is_empty() {
return vec![body.to_string()];
}
let body_bytes = body.as_bytes();
let mut wanted: rustc_hash::FxHashSet<u32> = positions.iter().copied().collect();
let mut split_offsets = Vec::new();
const PREFIX: &[u8] = b"id=\"filepos";
let finder = memchr::memmem::Finder::new(PREFIX);
let mut at = 0;
while let Some(rel) = finder.find(&body_bytes[at..]) {
let id_offset = at + rel;
let digits_start = id_offset + PREFIX.len();
let mut end = digits_start;
while end < body_bytes.len() && body_bytes[end].is_ascii_digit() {
end += 1;
}
let canonical =
end > digits_start && (body_bytes[digits_start] != b'0' || end == digits_start + 1);
if canonical
&& body_bytes.get(end) == Some(&b'"')
&& let Ok(pos) = body[digits_start..end].parse::<u32>()
&& wanted.remove(&pos)
{
let tag_start = body_bytes[..id_offset]
.iter()
.rposition(|&b| b == b'<')
.unwrap_or(id_offset);
if tag_start > 0 {
split_offsets.push(tag_start);
}
}
at = digits_start;
}
split_offsets.sort_unstable();
split_offsets.dedup();
if split_offsets.is_empty() {
return vec![body.to_string()];
}
let mut chunks = Vec::with_capacity(split_offsets.len() + 1);
let mut last_end = 0;
for &offset in &split_offsets {
if offset > last_end {
chunks.push(body[last_end..offset].to_string());
}
last_end = offset;
}
if last_end < body.len() {
chunks.push(body[last_end..].to_string());
}
chunks
}
fn neutralize_bare_filename_links(chapters: &mut [Vec<u8>]) {
for chapter in chapters.iter_mut() {
let mut output = Vec::with_capacity(chapter.len());
let mut pos = 0;
while pos < chapter.len() {
if pos + 6 <= chapter.len()
&& chapter[pos..pos + 5].eq_ignore_ascii_case(b"href=")
&& chapter[pos + 5] == b'"'
{
let value_start = pos + 6;
if let Some(quote_rel) = chapter[value_start..].iter().position(|&b| b == b'"') {
let value = &chapter[value_start..value_start + quote_rel];
if is_bare_filename_link(value) {
let attr_end = value_start + quote_rel + 1;
let remaining_tag = &chapter[attr_end..];
let has_correct_href = remaining_tag
.windows(6)
.take_while(|w| !w.starts_with(b">") && !w.starts_with(b"<"))
.any(|w| w == b"href=\"");
if has_correct_href {
pos = attr_end;
while pos < chapter.len() && chapter[pos] == b' ' {
pos += 1;
}
continue;
} else {
output.extend_from_slice(b"href=\"#\"");
pos = attr_end;
continue;
}
}
}
}
output.push(chapter[pos]);
pos += 1;
}
*chapter = output;
}
}
fn is_bare_filename_link(href: &[u8]) -> bool {
let href_str = String::from_utf8_lossy(href);
let path_part = href_str.split('#').next().unwrap_or(&href_str);
let path_lower = path_part.to_ascii_lowercase();
(path_lower.ends_with(".htm") || path_lower.ends_with(".html"))
&& !href_str.starts_with('#')
&& !href_str.contains("://")
&& !path_lower.ends_with(".xhtml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_head_and_body() {
let html = r#"<html><head><title>Test</title><link rel="stylesheet" href="style.css"/></head><body><p>Hello</p></body></html>"#;
let (head, body) = extract_head_and_body(html);
assert!(head.contains("<title>Test</title>"));
assert!(head.contains("style.css"));
assert_eq!(body, "<p>Hello</p>");
}
#[test]
fn test_extract_head_and_body_no_tags() {
let html = "<p>Just content</p>";
let (head, body) = extract_head_and_body(html);
assert!(head.is_empty());
assert_eq!(body, html);
}
#[test]
fn test_find_pagebreaks() {
let body = b"<p>Ch1</p><mbp:pagebreak/><p>Ch2</p><mbp:pagebreak /><p>Ch3</p>";
let pbs = find_pagebreaks(body);
assert_eq!(pbs.len(), 2);
assert_eq!(&body[pbs[0].start..pbs[0].end], b"<mbp:pagebreak/>");
assert_eq!(&body[pbs[1].start..pbs[1].end], b"<mbp:pagebreak />");
}
#[test]
fn test_find_pagebreaks_case_insensitive() {
let body = b"<p>A</p><MBP:PAGEBREAK/><p>B</p>";
let pbs = find_pagebreaks(body);
assert_eq!(pbs.len(), 1);
}
#[test]
fn test_find_pagebreaks_with_attributes() {
let body = b"<p>A</p><mbp:pagebreak kindle:kindlefix=\"true\"/><p>B</p>";
let pbs = find_pagebreaks(body);
assert_eq!(pbs.len(), 1);
}
#[test]
fn test_find_pagebreaks_none() {
let body = b"<p>No breaks here</p>";
let pbs = find_pagebreaks(body);
assert!(pbs.is_empty());
}
#[test]
fn test_split_at_pagebreaks() {
let body = "<p>Ch1</p><mbp:pagebreak/><p>Ch2</p><mbp:pagebreak /><p>Ch3</p>";
let pbs = find_pagebreaks(body.as_bytes());
let chunks = split_at_pagebreaks(body, &pbs);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0], "<p>Ch1</p>");
assert_eq!(chunks[1], "<p>Ch2</p>");
assert_eq!(chunks[2], "<p>Ch3</p>");
}
#[test]
fn test_split_mobi_html_with_pagebreaks() {
let html = br#"<html><head><title>T</title></head><body>
<h1>Chapter 1</h1><p>Text1</p>
<mbp:pagebreak/>
<h1>Chapter 2</h1><p>Text2</p>
<mbp:pagebreak/>
<h1>Chapter 3</h1><p>Text3</p>
</body></html>"#;
let split = split_mobi_html(html, None);
assert_eq!(split.chapters.len(), 3);
assert_eq!(split.chapter_paths.len(), 3);
assert_eq!(split.chapter_paths[0], "chapter_0.xhtml");
assert_eq!(split.chapter_paths[1], "chapter_1.xhtml");
assert_eq!(split.chapter_paths[2], "chapter_2.xhtml");
for ch in &split.chapters {
let s = String::from_utf8_lossy(ch);
assert!(s.contains("<html"), "Missing <html>: {}", s);
assert!(s.contains("</html>"), "Missing </html>: {}", s);
assert!(s.contains("<head>"), "Missing <head>: {}", s);
assert!(s.contains("<body>"), "Missing <body>: {}", s);
}
let ch0 = String::from_utf8_lossy(&split.chapters[0]);
let ch1 = String::from_utf8_lossy(&split.chapters[1]);
let ch2 = String::from_utf8_lossy(&split.chapters[2]);
assert!(ch0.contains("Chapter 1"));
assert!(ch1.contains("Chapter 2"));
assert!(ch2.contains("Chapter 3"));
}
#[test]
fn test_split_mobi_html_no_pagebreaks() {
let html = b"<html><head></head><body><p>Single chapter</p></body></html>";
let split = split_mobi_html(html, None);
assert_eq!(split.chapters.len(), 1);
assert_eq!(split.chapter_paths[0], "chapter_0.xhtml");
let ch = String::from_utf8_lossy(&split.chapters[0]);
assert!(ch.contains("Single chapter"));
}
#[test]
fn test_split_mobi_html_empty_chunks_filtered() {
let html = b"<html><head></head><body><mbp:pagebreak/><p>Only chapter</p></body></html>";
let split = split_mobi_html(html, None);
assert_eq!(split.chapters.len(), 1);
let ch = String::from_utf8_lossy(&split.chapters[0]);
assert!(ch.contains("Only chapter"));
}
#[test]
fn test_collect_filepos_anchors() {
let chunk = r#"<a id="filepos100" /><p>Text</p><a id="filepos500" />"#;
let mut map = HashMap::new();
collect_filepos_anchors(chunk, 2, &mut map);
assert_eq!(map.get("filepos100"), Some(&2));
assert_eq!(map.get("filepos500"), Some(&2));
assert_eq!(map.len(), 2);
}
#[test]
fn test_cross_chapter_link_rewriting() {
let ch0 = concat!(
"<html><body>",
"<a id=\"filepos100\" />",
"<a href=\"#filepos100\">self</a>",
"<a href=\"#filepos500\">cross</a>",
"</body></html>",
);
let ch1 = concat!(
"<html><body>",
"<a id=\"filepos500\" />",
"<p>Ch2</p>",
"</body></html>",
);
let mut chapters = vec![ch0.as_bytes().to_vec(), ch1.as_bytes().to_vec()];
let mut map = HashMap::new();
map.insert("filepos100".to_string(), 0);
map.insert("filepos500".to_string(), 1);
let paths = vec!["chapter_0.xhtml".to_string(), "chapter_1.xhtml".to_string()];
rewrite_cross_chapter_links(&mut chapters, &map, &paths);
let ch0 = String::from_utf8_lossy(&chapters[0]);
assert!(
ch0.contains(r##"href="#filepos100""##),
"Same-chapter link should be unchanged: {}",
ch0
);
assert!(
ch0.contains(r##"href="chapter_1.xhtml#filepos500""##),
"Cross-chapter link should be rewritten: {}",
ch0
);
}
#[test]
fn test_head_content_shared_across_chapters() {
let html =
br#"<html><head><title>Book</title><link rel="stylesheet" href="s.css"/></head><body>
<p>Ch1</p><mbp:pagebreak/><p>Ch2</p>
</body></html>"#;
let split = split_mobi_html(html, None);
assert_eq!(split.chapters.len(), 2);
for ch in &split.chapters {
let s = String::from_utf8_lossy(ch);
assert!(
s.contains("<title>Book</title>"),
"Head should contain title: {}",
s
);
assert!(
s.contains("s.css"),
"Head should contain stylesheet link: {}",
s
);
}
}
#[test]
fn test_filepos_to_chapter_mapping() {
let html = br#"<html><head></head><body>
<a id="filepos10" /><p>Ch1</p>
<mbp:pagebreak/>
<a id="filepos200" /><p>Ch2</p>
<mbp:pagebreak/>
<a id="filepos500" /><p>Ch3</p>
</body></html>"#;
let split = split_mobi_html(html, None);
assert_eq!(split.filepos_to_chapter.get("filepos10"), Some(&0));
assert_eq!(split.filepos_to_chapter.get("filepos200"), Some(&1));
assert_eq!(split.filepos_to_chapter.get("filepos500"), Some(&2));
}
#[test]
fn test_toc_uses_chapter_paths() {
let html = br#"<html><head></head><body>
<a id="filepos0" /><p>Ch1</p>
<mbp:pagebreak/>
<a id="filepos100" /><p>Ch2</p>
</body></html>"#;
let split = split_mobi_html(html, None);
let filepos0_ch = split
.filepos_to_chapter
.get("filepos0")
.copied()
.unwrap_or(0);
let filepos100_ch = split
.filepos_to_chapter
.get("filepos100")
.copied()
.unwrap_or(0);
let href0 = format!("{}#filepos0", split.chapter_paths[filepos0_ch]);
let href1 = format!("{}#filepos100", split.chapter_paths[filepos100_ch]);
assert_eq!(href0, "chapter_0.xhtml#filepos0");
assert_eq!(href1, "chapter_1.xhtml#filepos100");
}
#[test]
fn test_split_ncx_fallback_basic() {
let html = br#"<html><head><title>Book</title></head><body>
<a id="filepos0" /><h1>Preamble</h1><p>Front matter</p>
<a id="filepos100" /><h1>Chapter 1</h1><p>Text1</p>
<a id="filepos500" /><h1>Chapter 2</h1><p>Text2</p>
</body></html>"#;
let ncx_positions = vec![0, 100, 500];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 3);
assert_eq!(split.chapter_paths[0], "chapter_0.xhtml");
assert_eq!(split.chapter_paths[1], "chapter_1.xhtml");
assert_eq!(split.chapter_paths[2], "chapter_2.xhtml");
let ch0 = String::from_utf8_lossy(&split.chapters[0]);
let ch1 = String::from_utf8_lossy(&split.chapters[1]);
let ch2 = String::from_utf8_lossy(&split.chapters[2]);
assert!(
ch0.contains("Preamble"),
"Ch0 should have preamble: {}",
ch0
);
assert!(
ch1.contains("Chapter 1"),
"Ch1 should have Chapter 1: {}",
ch1
);
assert!(
ch2.contains("Chapter 2"),
"Ch2 should have Chapter 2: {}",
ch2
);
}
#[test]
fn test_split_ncx_fallback_filepos_to_chapter_map() {
let html = br#"<html><head></head><body>
<a id="filepos0" /><p>Preamble</p>
<a id="filepos200" /><h1>Ch1</h1><a id="filepos300" /><p>More ch1</p>
<a id="filepos800" /><h1>Ch2</h1>
</body></html>"#;
let ncx_positions = vec![0, 200, 800];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 3);
assert_eq!(split.filepos_to_chapter.get("filepos0"), Some(&0));
assert_eq!(split.filepos_to_chapter.get("filepos200"), Some(&1));
assert_eq!(split.filepos_to_chapter.get("filepos300"), Some(&1));
assert_eq!(split.filepos_to_chapter.get("filepos800"), Some(&2));
}
#[test]
fn test_split_ncx_no_matching_anchors() {
let html = b"<html><head></head><body><p>No anchors here</p></body></html>";
let ncx_positions = vec![100, 200, 300];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 1);
}
#[test]
fn test_split_ncx_empty_positions() {
let html = b"<html><head></head><body><p>Content</p></body></html>";
let ncx_positions: Vec<u32> = vec![];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 1);
}
#[test]
fn test_pagebreaks_preferred_over_ncx() {
let html = br#"<html><head></head><body>
<a id="filepos0" /><p>Ch1</p>
<mbp:pagebreak/>
<a id="filepos100" /><p>Ch2</p>
<mbp:pagebreak/>
<a id="filepos200" /><p>Ch3</p>
</body></html>"#;
let ncx_positions = vec![0, 200];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 3);
}
#[test]
fn test_ncx_cross_chapter_links() {
let html = br##"<html><head></head><body>
<a id="filepos0" /><a href="#filepos500">Go to Ch2</a><p>Ch1</p>
<a id="filepos500" /><a href="#filepos0">Back to Ch1</a><p>Ch2</p>
</body></html>"##;
let ncx_positions = vec![0, 500];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 2);
let ch0 = String::from_utf8_lossy(&split.chapters[0]);
let ch1 = String::from_utf8_lossy(&split.chapters[1]);
assert!(
ch0.contains(r##"href="chapter_1.xhtml#filepos500""##),
"Ch0 cross-link should be rewritten: {}",
ch0
);
assert!(
ch1.contains(r##"href="chapter_0.xhtml#filepos0""##),
"Ch1 cross-link should be rewritten: {}",
ch1
);
}
#[test]
fn test_neutralize_bare_filename_links() {
let html = br#"<a href="cover.htm">Cover</a> and <a href="Book_oeb_01_r1.html">Ch1</a>"#;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
result.contains(r##"href="#""##),
"Bare .htm link should be neutralized: {}",
result
);
assert!(
!result.contains("cover.htm"),
"Original .htm reference should be removed: {}",
result
);
assert!(
!result.contains("oeb_01_r1.html"),
"Original .html reference should be removed: {}",
result
);
}
#[test]
fn test_neutralize_preserves_filepos_links() {
let html =
br##"<a href="#filepos100">Ch1</a> and <a href="chapter_0.xhtml#filepos200">Ch2</a>"##;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
result.contains(r##"href="#filepos100""##),
"filepos link should be preserved: {}",
result
);
assert!(
result.contains("chapter_0.xhtml"),
"xhtml link should be preserved: {}",
result
);
}
#[test]
fn test_neutralize_preserves_xhtml_links() {
let html = br#"<a href="chapter_1.xhtml">Link</a>"#;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
result.contains("chapter_1.xhtml"),
"xhtml link should be preserved: {}",
result
);
}
#[test]
fn test_is_bare_filename_link_cases() {
assert!(is_bare_filename_link(b"cover.htm"));
assert!(is_bare_filename_link(b"Book_oeb_01_r1.html"));
assert!(is_bare_filename_link(b"Cover.HTML"));
assert!(is_bare_filename_link(b"file.HTM"));
assert!(!is_bare_filename_link(b"#filepos100"));
assert!(!is_bare_filename_link(b"chapter_0.xhtml"));
assert!(!is_bare_filename_link(b"http://example.com/file.html"));
assert!(!is_bare_filename_link(b"https://example.com/page.htm"));
assert!(!is_bare_filename_link(b"#"));
assert!(!is_bare_filename_link(b"image.jpg"));
assert!(is_bare_filename_link(b"Book_oeb_ftn_r1.html#f1"));
assert!(is_bare_filename_link(b"cover.htm#section"));
assert!(!is_bare_filename_link(b"chapter_0.xhtml#filepos100"));
}
#[test]
fn test_neutralize_uppercase_href() {
let html = br##"<A HREF="Asim_oeb_tp_r1.html" href="#filepos1129"> Title Page</A>"##;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
!result.contains("oeb_tp_r1.html"),
"Uppercase HREF OEB link should be removed: {}",
result
);
assert!(
result.contains(r##"href="#filepos1129""##),
"Lowercase filepos href should be preserved: {}",
result
);
}
#[test]
fn test_neutralize_uppercase_href_no_fallback() {
let html = br#"<A HREF="cover.htm"> Cover</A>"#;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
!result.contains("cover.htm"),
"OEB link should be neutralized: {}",
result
);
assert!(
result.contains(r##"href="#""##),
"Should have fallback href: {}",
result
);
}
#[test]
fn test_neutralize_href_with_fragment() {
let html = br#"<a href="Book_oeb_ftn_r1.html#f1">Note</a>"#;
let mut chapters = vec![html.to_vec()];
neutralize_bare_filename_links(&mut chapters);
let result = String::from_utf8_lossy(&chapters[0]);
assert!(
!result.contains("oeb_ftn_r1.html"),
"OEB link with fragment should be neutralized: {}",
result
);
}
#[test]
fn test_ncx_split_with_oeb_links_neutralized() {
let html = br#"<html><head></head><body>
<a id="filepos0" /><a href="cover.htm">Cover</a>
<a href="Book_oeb_01_r1.html">Ch1</a>
<a href="Book_oeb_02_r1.html">Ch2</a>
<p>Preamble content</p>
<a id="filepos500" /><h1>Chapter 1</h1><p>Text1</p>
<a id="filepos1000" /><h1>Chapter 2</h1><p>Text2</p>
</body></html>"#;
let ncx_positions = vec![0, 500, 1000];
let split = split_mobi_html(html, Some(&ncx_positions));
assert_eq!(split.chapters.len(), 3);
let ch0 = String::from_utf8_lossy(&split.chapters[0]);
assert!(
!ch0.contains("cover.htm"),
"OEB links should be neutralized: {}",
ch0
);
assert!(
!ch0.contains("oeb_01_r1.html"),
"OEB links should be neutralized: {}",
ch0
);
assert!(ch0.contains("Cover"), "Link text should be preserved");
assert!(ch0.contains("Ch1"), "Link text should be preserved");
}
}