ebook-rs 0.13.0

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;

/// Plain Text (.txt) and Markdown (.md) document parser engine.
pub struct TxtBook;

impl TxtBook {
    /// Parse plain text (.txt) or markdown (.md) byte slice into a `Book` instance.
    pub fn parse(bytes: &[u8], title_fallback: &str, is_markdown: bool) -> Result<Book, String> {
        let text = String::from_utf8_lossy(bytes);
        if text.trim().is_empty() {
            return Err("Text file is empty".to_string());
        }

        let mut title = title_fallback.to_string();
        let mut sections = Vec::new();
        let mut spine = Vec::new();
        let mut toc = Vec::new();

        if is_markdown {
            let mut current_section_html = String::new();
            let mut section_index = 0;
            let mut plain_text_buf = String::new();

            let mut in_code_block = false;
            let mut code_lang = String::new();
            let mut code_buf = String::new();

            for line in text.lines() {
                let trimmed = line.trim();
                if trimmed.starts_with("```") {
                    if in_code_block {
                        let pre_html = format!(
                            "<pre><code class=\"language-{}\">\n{}</code></pre>",
                            if code_lang.is_empty() {
                                "text"
                            } else {
                                &code_lang
                            },
                            xml_escape(&code_buf)
                        );
                        current_section_html.push_str(&pre_html);
                        current_section_html.push('\n');
                        plain_text_buf.push_str(&code_buf);
                        plain_text_buf.push('\n');

                        in_code_block = false;
                        code_lang.clear();
                        code_buf.clear();
                    } else {
                        in_code_block = true;
                        code_lang = trimmed.trim_start_matches('`').trim().to_string();
                    }
                    continue;
                }

                if in_code_block {
                    code_buf.push_str(line);
                    code_buf.push('\n');
                    continue;
                }

                if trimmed.starts_with('#') {
                    let level = trimmed.chars().take_while(|c| *c == '#').count();
                    let heading_text = trimmed.trim_start_matches('#').trim();

                    if title == title_fallback && level == 1 {
                        title = heading_text.to_string();
                    }

                    if !plain_text_buf.is_empty() {
                        let sec_href = format!("section_{}.html", section_index);
                        let full_html = format!(
                            "<div class=\"md-content\">\n{}\n</div>",
                            current_section_html
                        );

                        let char_count = plain_text_buf.chars().count();
                        let plain_text_lower = plain_text_buf.to_lowercase();

                        sections.push(Section {
                            index: section_index,
                            idref: format!("sec_{}", section_index),
                            href: sec_href.clone(),
                            full_path: sec_href.clone(),
                            raw_html: full_html.clone(),
                            processed_html: full_html,
                            plain_text: plain_text_buf.clone(),
                            plain_text_lower,
                            char_count,
                            viewport_width: None,
                            viewport_height: None,
                        });

                        spine.push(SpineItem {
                            idref: format!("sec_{}", section_index),
                            linear: true,
                            properties: Vec::new(),
                            index: section_index,
                            href: sec_href,
                            media_type: "application/xhtml+xml".to_string(),
                        });

                        section_index += 1;
                        current_section_html.clear();
                        plain_text_buf.clear();
                    }

                    let h_tag = format!(
                        "<h{}>{}</h{}>",
                        level.min(6),
                        xml_escape(heading_text),
                        level.min(6)
                    );
                    current_section_html.push_str(&h_tag);
                    current_section_html.push('\n');
                    plain_text_buf.push_str(heading_text);
                    plain_text_buf.push('\n');

                    let href = format!("section_{}.html", section_index);
                    toc.push(NavPoint {
                        id: format!("nav_{}", toc.len() + 1),
                        label: heading_text.to_string(),
                        href: href.clone(),
                        full_path: href,
                        subitems: Vec::new(),
                    });
                } else if !trimmed.is_empty() {
                    let p_html = format!("<p>{}</p>", xml_escape(trimmed));
                    current_section_html.push_str(&p_html);
                    current_section_html.push('\n');
                    plain_text_buf.push_str(trimmed);
                    plain_text_buf.push('\n');
                }
            }

            if !plain_text_buf.is_empty() || sections.is_empty() {
                let sec_href = format!("section_{}.html", section_index);
                let full_html = format!(
                    "<div class=\"md-content\">\n{}\n</div>",
                    current_section_html
                );

                let char_count = plain_text_buf.chars().count();
                let plain_text_lower = plain_text_buf.to_lowercase();

                sections.push(Section {
                    index: section_index,
                    idref: format!("sec_{}", section_index),
                    href: sec_href.clone(),
                    full_path: sec_href.clone(),
                    raw_html: full_html.clone(),
                    processed_html: full_html,
                    plain_text: plain_text_buf.clone(),
                    plain_text_lower,
                    char_count,
                    viewport_width: None,
                    viewport_height: None,
                });

                spine.push(SpineItem {
                    idref: format!("sec_{}", section_index),
                    linear: true,
                    properties: Vec::new(),
                    index: section_index,
                    href: sec_href,
                    media_type: "application/xhtml+xml".to_string(),
                });
            }
        } else {
            let mut html_buf = String::new();
            let mut plain_text_buf = String::new();

            for paragraph in text.split("\n\n") {
                let p_clean = paragraph.trim();
                if !p_clean.is_empty() {
                    html_buf.push_str(&format!("<p>{}</p>\n", xml_escape(p_clean)));
                    plain_text_buf.push_str(p_clean);
                    plain_text_buf.push('\n');
                }
            }

            let sec_href = "section_0.html".to_string();
            let full_html = format!("<div class=\"txt-content\">\n{}\n</div>", html_buf);
            let char_count = plain_text_buf.chars().count();
            let plain_text_lower = plain_text_buf.to_lowercase();

            sections.push(Section {
                index: 0,
                idref: "sec_0".to_string(),
                href: sec_href.clone(),
                full_path: sec_href.clone(),
                raw_html: full_html.clone(),
                processed_html: full_html,
                plain_text: plain_text_buf,
                plain_text_lower,
                char_count,
                viewport_width: None,
                viewport_height: None,
            });

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

            toc.push(NavPoint {
                id: "nav_1".to_string(),
                label: title.clone(),
                href: sec_href.clone(),
                full_path: sec_href,
                subitems: Vec::new(),
            });
        }

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

        let opf = OpfPackage {
            version: "3.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: EpubArchive::empty(),
            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)
    }
}

fn xml_escape(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}