ebook-rs 0.11.7

Pure Rust multi-format eBook engine (EPUB2/3, MOBI, AZW3, FB2, LIT, CBZ, PDF, ODT, TXT, MD) with SpeechSynthesis TTS Word Synchronizer, Legacy Charset Decoding, Auto Language Detection, Zstd State Caching, Universal EPUB3 Exporter, Zero-Copy mmap, and Readium LCP/Locator APIs.
Documentation
use crate::archive::EpubArchive;
use crate::book::Book;
use crate::deobfuscate::FontDeobfuscator;
use crate::layout::RenditionLayout;
use crate::metadata::{Metadata, PageProgressionDirection, SpineItem};
use crate::nav::NavPoint;
use crate::opf::OpfPackage;
use crate::section::Section;
use std::collections::HashMap;

/// Comic Book Archive (CBZ / CBR) Parser.
pub struct CbzBook;

impl CbzBook {
    /// Parse CBZ (ZIP) or raw image container bytes into a `Book` struct.
    pub fn parse(bytes: &[u8], title_fallback: &str) -> Result<Book, String> {
        if bytes.starts_with(b"Rar!\x1a\x07\x00")
            || bytes.starts_with(b"Rar!\x1a\x07\x01\x00")
            || bytes.starts_with(b"Rar!\x1a\x07")
        {
            return Err("CBR (RAR format) is not supported in pure-Rust mode (RARv4/RARv5 detected). Please convert the file to CBZ (ZIP format).".to_string());
        }

        let archive = EpubArchive::from_bytes(bytes)?;
        Self::from_archive(archive, title_fallback)
    }

    /// Parse a CBZ comic book directly from an already-extracted in-memory archive container.
    pub fn from_archive(archive: EpubArchive, title_fallback: &str) -> Result<Book, String> {
        let mut image_names: Vec<String> = archive
            .files()
            .keys()
            .filter(|name| {
                let lower = name.to_lowercase();
                !lower.contains("__macosx")
                    && !lower.contains("/.")
                    && !lower.starts_with('.')
                    && (lower.ends_with(".jpg")
                        || lower.ends_with(".jpeg")
                        || lower.ends_with(".png")
                        || lower.ends_with(".webp")
                        || lower.ends_with(".gif")
                        || lower.ends_with(".bmp")
                        || lower.ends_with(".tif")
                        || lower.ends_with(".tiff"))
            })
            .cloned()
            .collect();

        if image_names.is_empty() {
            return Err("CBZ archive contains no valid image pages".to_string());
        }

        // Sort image pages naturally by filename (e.g. page2 before page10)
        image_names.sort_by(|a, b| natural_cmp(a, b));

        let mut sections = Vec::with_capacity(image_names.len());
        let mut spine = Vec::with_capacity(image_names.len());
        let mut toc = Vec::with_capacity(image_names.len());

        for (idx, img_path) in image_names.into_iter().enumerate() {
            let idref = format!("page_{}", idx);
            let href = format!("page_{}.html", idx);

            let raw_html = format!(
                "<div style=\"text-align:center;\"><img src=\"{}\" style=\"max-width:100%;height:auto;\"/></div>",
                img_path
            );
            let processed_html = raw_html.clone();

            let plain_text = format!("[Comic Page {} - {}]", idx + 1, img_path);
            let plain_text_lower = plain_text.to_lowercase();
            let char_count = plain_text.chars().count();

            sections.push(Section {
                index: idx,
                idref: idref.clone(),
                href: href.clone(),
                full_path: href.clone(),
                raw_html,
                processed_html,
                plain_text,
                plain_text_lower,
                char_count,
                viewport_width: None,
                viewport_height: None,
            });

            spine.push(SpineItem {
                index: idx,
                idref,
                href: href.clone(),
                linear: true,
                media_type: "application/xhtml+xml".to_string(),
                properties: Vec::new(),
            });

            toc.push(NavPoint {
                id: format!("toc_{}", idx),
                label: format!("Page {}", idx + 1),
                href: href.clone(),
                full_path: href,
                subitems: Vec::new(),
            });
        }

        let metadata = Metadata {
            title: title_fallback.to_string(),
            creators: vec!["Comic Author".to_string()],
            publishers: Vec::new(),
            languages: vec!["en".to_string()],
            rights: None,
            description: Some("Comic Book Archive".to_string()),
            identifier: None,
            pub_date: None,
            modified_date: None,
            subjects: vec!["Comics".to_string()],
            cover_id: None,
            cover_href: None,
            direction: PageProgressionDirection::Ltr,
            meta_properties: HashMap::new(),
            accessibility: Default::default(),
        };

        let opf = OpfPackage {
            version: "2.0".to_string(),
            opf_path: "content.opf".to_string(),
            opf_dir: "".to_string(),
            metadata,
            manifest: ahash::AHashMap::new(),
            spine,
            guide: Vec::new(),
            toc_item_id: None,
            nav_item_id: None,
        };

        let mut book = Book {
            archive,
            opf,
            layout: RenditionLayout::default(),
            toc,
            landmarks: Vec::new(),
            page_list: Vec::new(),
            sections,
            locations: crate::locations::Locations::default(),
            annotations: crate::annotations::AnnotationManager::default(),
            before_display_hooks: Vec::new(),
            font_deobfuscator: FontDeobfuscator::parse_encryption_xml(""),
            media_overlays: HashMap::new(),
            render_cache: parking_lot::Mutex::new(HashMap::new()),
        };

        book.generate_locations(1000);
        Ok(book)
    }
}

#[allow(dead_code)]
fn base64_encode(data: &[u8]) -> String {
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    let mut i = 0;

    while i < data.len() {
        let b0 = data[i] as u32;
        let b1 = if i + 1 < data.len() {
            data[i + 1] as u32
        } else {
            0
        };
        let b2 = if i + 2 < data.len() {
            data[i + 2] as u32
        } else {
            0
        };

        let triple = (b0 << 16) | (b1 << 8) | b2;

        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);

        if i + 1 < data.len() {
            out.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
        } else {
            out.push('=');
        }

        if i + 2 < data.len() {
            out.push(CHARS[(triple & 0x3F) as usize] as char);
        } else {
            out.push('=');
        }

        i += 3;
    }

    out
}

fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
    let mut a_chars = a.chars().peekable();
    let mut b_chars = b.chars().peekable();

    loop {
        match (a_chars.peek(), b_chars.peek()) {
            (None, None) => return std::cmp::Ordering::Equal,
            (None, Some(_)) => return std::cmp::Ordering::Less,
            (Some(_), None) => return std::cmp::Ordering::Greater,
            (Some(ca), Some(cb)) => {
                if ca.is_ascii_digit() && cb.is_ascii_digit() {
                    let mut a_num: u64 = 0;
                    while let Some(d) = a_chars.peek().and_then(|c| c.to_digit(10)) {
                        a_num = a_num.saturating_mul(10).saturating_add(d as u64);
                        a_chars.next();
                    }
                    let mut b_num: u64 = 0;
                    while let Some(d) = b_chars.peek().and_then(|c| c.to_digit(10)) {
                        b_num = b_num.saturating_mul(10).saturating_add(d as u64);
                        b_chars.next();
                    }
                    if a_num != b_num {
                        return a_num.cmp(&b_num);
                    }
                } else {
                    let ca_lower = ca.to_ascii_lowercase();
                    let cb_lower = cb.to_ascii_lowercase();
                    if ca_lower != cb_lower {
                        return ca_lower.cmp(&cb_lower);
                    }
                    a_chars.next();
                    b_chars.next();
                }
            }
        }
    }
}