boko 0.5.0

Fast native ebook converter for EPUB, KFX, AZW3, and MOBI — the only KFX writer that needs no Kindle Previewer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! The `Book` runtime handle for reading ebooks via importers.
//!
//! This module wires the pure data model (`crate::model`) to the
//! format-specific importer and exporter backends. It sits above both
//! `crate::import` and `crate::export` in the layering.

use std::collections::HashMap;
use std::io::{self, Seek, Write};
use std::path::Path;
use std::sync::{Arc, OnceLock, RwLock};

use crate::export::{Azw3Exporter, EpubExporter, Exporter, KfxExporter, MarkdownExporter};
use crate::import::{
    Azw3Importer, ChapterId, EpubImporter, Importer, KfxImporter, MobiImporter, SpineEntry,
};
use crate::io::MemorySource;
use crate::model::{AnchorTarget, Chapter, Format, Landmark, Metadata, ResolvedLinks, TocEntry};
use crate::resolved::resolve_book_links;

/// Runtime handle for an ebook.
///
/// `Book` wraps a format-specific `Importer` backend and provides
/// unified access to metadata, table of contents, and content.
///
/// # Example
///
/// ```no_run
/// use boko::Book;
///
/// let mut book = Book::open("input.epub")?;
/// println!("Title: {}", book.metadata().title);
///
/// // Load chapter content (collect spine first to avoid borrow issues)
/// let spine: Vec<_> = book.spine().to_vec();
/// for entry in spine {
///     let raw = book.load_raw(entry.id)?;
///     println!("Chapter {}: {} bytes", entry.id.0, raw.len());
/// }
/// # Ok::<(), boko::Error>(())
/// ```
pub struct Book {
    backend: Box<dyn Importer>,
    /// Cache of parsed IR chapters to avoid re-parsing during normalized export.
    /// Uses RwLock for thread-safe access and Arc for cheap cloning.
    ir_cache: Arc<RwLock<HashMap<ChapterId, Arc<Chapter>>>>,
    /// TOC after format-specific href fixup (AZW3/MOBI `#fileposN` suffixes).
    /// Empty for formats whose hrefs are correct from source.
    fixed_toc: OnceLock<Vec<TocEntry>>,
    /// TOC after href fixup AND target resolution (set by `resolve_links`).
    /// Takes precedence over `fixed_toc` in [`toc`](Self::toc).
    targeted_toc: OnceLock<Vec<TocEntry>>,
    /// Memoized link resolution, shared with callers as an `Arc`.
    resolved_links: OnceLock<Arc<ResolvedLinks>>,
}

impl Book {
    /// Open an ebook file, auto-detecting the format.
    pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
        let path = path.as_ref();
        let format = Format::from_path(path).ok_or_else(|| crate::Error::UnsupportedFormat {
            detail: format!("unknown file format: {}", path.display()),
        })?;
        Self::open_format(path, format)
    }

    /// Open an ebook file with an explicit format.
    pub fn open_format(path: impl AsRef<Path>, format: Format) -> crate::Result<Self> {
        let backend: Box<dyn Importer> = match format {
            Format::Epub => Box::new(EpubImporter::open(path.as_ref())?),
            Format::Azw3 => Box::new(Azw3Importer::open(path.as_ref())?),
            Format::Mobi => Box::new(MobiImporter::open(path.as_ref())?),
            Format::Kfx => Box::new(KfxImporter::open(path.as_ref())?),
            Format::Markdown => {
                return Err(crate::Error::UnsupportedFormat {
                    detail: "Markdown format is export-only".into(),
                });
            }
        };
        Ok(Self::from_backend(backend))
    }

    /// Swap the importer backend, returning the old one.
    ///
    /// Cached chapters are dropped: they were produced by the old backend
    /// and may not reflect the new one's view (e.g. rewritten asset paths
    /// after [`optimize`](Self::optimize)).
    pub(crate) fn replace_backend(&mut self, backend: Box<dyn Importer>) -> Box<dyn Importer> {
        self.ir_cache
            .write()
            .unwrap_or_else(|e| e.into_inner())
            .clear();
        std::mem::replace(&mut self.backend, backend)
    }

    fn from_backend(backend: Box<dyn Importer>) -> Self {
        Self {
            backend,
            ir_cache: Arc::new(RwLock::new(HashMap::new())),
            fixed_toc: OnceLock::new(),
            targeted_toc: OnceLock::new(),
            resolved_links: OnceLock::new(),
        }
    }

    /// Create a Book from in-memory bytes with an explicit format.
    ///
    /// This is useful for reading from stdin or other non-file sources.
    pub fn from_bytes(data: &[u8], format: Format) -> crate::Result<Self> {
        let source = Arc::new(MemorySource::new(data.to_vec()));
        let backend: Box<dyn Importer> = match format {
            Format::Epub => Box::new(EpubImporter::from_source(source)?),
            Format::Azw3 => Box::new(Azw3Importer::from_source(source)?),
            Format::Mobi => Box::new(MobiImporter::from_source(source)?),
            Format::Kfx => Box::new(KfxImporter::from_source(source)?),
            Format::Markdown => {
                return Err(crate::Error::UnsupportedFormat {
                    detail: "Markdown format is export-only".into(),
                });
            }
        };
        Ok(Self::from_backend(backend))
    }

    /// Book metadata.
    pub fn metadata(&self) -> &Metadata {
        self.backend.metadata()
    }

    /// Table of contents.
    ///
    /// Serves the most-resolved view available: after `resolve_links` the
    /// entries carry resolved targets; after `resolve_toc` (AZW3/MOBI) the
    /// hrefs carry fragment suffixes; otherwise the importer's entries as
    /// parsed from source.
    pub fn toc(&self) -> &[TocEntry] {
        if let Some(toc) = self.targeted_toc.get() {
            return toc;
        }
        if let Some(toc) = self.fixed_toc.get() {
            return toc;
        }
        self.backend.toc()
    }

    /// Landmarks (structural navigation points).
    pub fn landmarks(&self) -> &[Landmark] {
        self.backend.landmarks()
    }

    /// Reading order (spine).
    pub fn spine(&self) -> &[SpineEntry] {
        self.backend.spine()
    }

    /// Get the internal source path for a chapter.
    pub fn source_id(&self, id: ChapterId) -> Option<&str> {
        self.backend.source_id(id)
    }

    /// Load raw chapter bytes.
    pub fn load_raw(&self, id: ChapterId) -> crate::Result<Vec<u8>> {
        self.backend.load_raw(id)
    }

    /// Load a chapter as normalized IR.
    ///
    /// This parses the chapter's HTML content and any linked or inline CSS,
    /// producing a normalized tree structure suitable for rendering.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use boko::{Book, Role};
    ///
    /// let mut book = Book::open("input.epub")?;
    /// let spine: Vec<_> = book.spine().to_vec();
    ///
    /// for entry in spine {
    ///     let chapter = book.load_chapter(entry.id)?;
    ///     for id in chapter.iter_dfs() {
    ///         let node = chapter.node(id).unwrap();
    ///         if matches!(node.role, Role::Heading(_)) {
    ///             // Process heading...
    ///         }
    ///     }
    /// }
    /// # Ok::<(), boko::Error>(())
    /// ```
    pub fn load_chapter(&self, id: ChapterId) -> crate::Result<Chapter> {
        self.backend.load_chapter(id)
    }

    /// Load a chapter as IR with caching.
    ///
    /// This method caches parsed IR chapters to avoid re-parsing when the same
    /// chapter is loaded multiple times (e.g., during normalized export).
    /// Returns an `Arc<Chapter>` for cheap cloning and thread-safe sharing.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use boko::Book;
    ///
    /// let mut book = Book::open("input.epub")?;
    /// let spine: Vec<_> = book.spine().to_vec();
    ///
    /// // First call parses the chapter
    /// let chapter1 = book.load_chapter_cached(spine[0].id)?;
    ///
    /// // Second call returns cached version (cheap Arc clone)
    /// let chapter2 = book.load_chapter_cached(spine[0].id)?;
    /// # Ok::<(), boko::Error>(())
    /// ```
    pub fn load_chapter_cached(&self, id: ChapterId) -> crate::Result<Arc<Chapter>> {
        // Fast path: check read lock first
        {
            let cache = self
                .ir_cache
                .read()
                .map_err(|_| io::Error::other("IR cache lock poisoned"))?;
            if let Some(chapter) = cache.get(&id) {
                return Ok(Arc::clone(chapter));
            }
        }

        // Slow path: load chapter (no lock held during IO)
        let chapter = self.backend.load_chapter(id)?;
        let chapter_arc = Arc::new(chapter);

        // Write to cache
        {
            let mut cache = self
                .ir_cache
                .write()
                .map_err(|_| io::Error::other("IR cache lock poisoned"))?;
            cache.insert(id, Arc::clone(&chapter_arc));
        }

        Ok(chapter_arc)
    }

    /// Load several chapters as IR with caching, in spine order.
    ///
    /// Like calling [`load_chapter_cached`](Self::load_chapter_cached) per
    /// id, but uncached chapters are handed to the backend as one batch so
    /// importers that support it (EPUB) compile them in parallel.
    pub fn load_chapters_cached(&self, ids: &[ChapterId]) -> crate::Result<Vec<Arc<Chapter>>> {
        // Collect the ids that still need compiling.
        let missing: Vec<ChapterId> = {
            let cache = self
                .ir_cache
                .read()
                .map_err(|_| io::Error::other("IR cache lock poisoned"))?;
            ids.iter()
                .copied()
                .filter(|id| !cache.contains_key(id))
                .collect()
        };

        if !missing.is_empty() {
            let loaded = self.backend.load_chapters(&missing);
            let mut cache = self
                .ir_cache
                .write()
                .map_err(|_| io::Error::other("IR cache lock poisoned"))?;
            for (id, chapter) in missing.into_iter().zip(loaded) {
                cache.insert(id, Arc::new(chapter?));
            }
        }

        let cache = self
            .ir_cache
            .read()
            .map_err(|_| io::Error::other("IR cache lock poisoned"))?;
        ids.iter()
            .map(|id| {
                cache
                    .get(id)
                    .cloned()
                    .ok_or_else(|| crate::Error::NotFound {
                        what: format!("chapter {}", id.0),
                    })
            })
            .collect()
    }

    /// Clear the IR cache.
    ///
    /// Call this to free memory after normalized export is complete.
    pub fn clear_cache(&self) {
        if let Ok(mut cache) = self.ir_cache.write() {
            cache.clear();
        }
    }

    /// Resolve all internal links in the book.
    ///
    /// Uses `load_chapter_cached()` internally, so chapters are parsed once
    /// and reused for subsequent export operations. Call this before export
    /// to benefit from caching.
    ///
    /// Returns both forward mappings (source -> target) and reverse mappings
    /// (target -> sources) for efficient lookup during traversal.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use boko::Book;
    ///
    /// let mut book = Book::open("input.epub")?;
    /// let resolved = book.resolve_links()?;
    ///
    /// // Check for broken links
    /// for (source, href) in resolved.broken_links() {
    ///     eprintln!("Broken link at {:?}: {}", source, href);
    /// }
    /// # Ok::<(), boko::Error>(())
    /// ```
    pub fn resolve_links(&self) -> crate::Result<Arc<ResolvedLinks>> {
        if let Some(resolved) = self.resolved_links.get() {
            return Ok(Arc::clone(resolved));
        }
        let resolved = Arc::new(resolve_book_links(self)?);
        // A concurrent resolution may have won the race; both computed the
        // same thing, so whichever landed first is shared.
        Ok(Arc::clone(self.resolved_links.get_or_init(|| resolved)))
    }

    /// Index anchors for link resolution.
    ///
    /// Called internally by `resolve_links()`. Delegates to the format-specific
    /// importer to build anchor maps.
    pub(crate) fn index_anchors(&self, chapters: &[(ChapterId, Arc<Chapter>)]) {
        self.backend.index_anchors(chapters);
    }

    /// Resolve TOC hrefs (fills in fragments for AZW3/MOBI).
    ///
    /// Computes the importer's fixed-up TOC once and caches it; formats whose
    /// hrefs are already correct (EPUB/KFX) cache nothing and
    /// [`toc`](Self::toc) keeps serving the importer's entries.
    pub(crate) fn resolve_toc(&self) {
        if self.fixed_toc.get().is_none()
            && let Some(fixed) = self.backend.resolve_toc()
        {
            let _ = self.fixed_toc.set(fixed);
        }
    }

    /// Resolve TOC entry targets using `resolve_href()`.
    ///
    /// Called internally by `resolve_links()` after `index_anchors`, so
    /// fragment anchors resolve. Produces the final targeted TOC from the
    /// fixed (or original) entries and caches it once.
    pub(crate) fn resolve_toc_targets(&self) {
        if self.targeted_toc.get().is_some() {
            return;
        }

        fn apply_targets(entries: &mut [TocEntry], backend: &dyn Importer) {
            for entry in entries {
                entry.target = backend.resolve_href(ChapterId(0), &entry.href);
                apply_targets(&mut entry.children, backend);
            }
        }

        let mut toc = self
            .fixed_toc
            .get()
            .cloned()
            .unwrap_or_else(|| self.backend.toc().to_vec());
        apply_targets(&mut toc, &*self.backend);
        let _ = self.targeted_toc.set(toc);
    }

    /// Resolve a single href using format-specific logic.
    ///
    /// Called internally by `resolve_links()`. Delegates to the format-specific
    /// importer.
    pub(crate) fn resolve_href(&self, from_chapter: ChapterId, href: &str) -> Option<AnchorTarget> {
        self.backend.resolve_href(from_chapter, href)
    }

    /// Load an asset by archive entry name (e.g. `"OEBPS/images/cover.jpg"`).
    pub fn load_asset(&self, path: &str) -> crate::Result<Vec<u8>> {
        self.backend.load_asset(path)
    }

    /// List all assets as archive entry names (forward-slash separated).
    pub fn list_assets(&self) -> &[String] {
        self.backend.list_assets()
    }

    /// Collect all @font-face definitions from CSS files.
    ///
    /// Returns font-face rules that map font family names to font files.
    /// Used by KFX export to create font entities linking font-family
    /// names to resource locations.
    pub fn font_faces(&self) -> Vec<crate::model::FontFace> {
        self.backend.font_faces()
    }

    /// Whether this book requires normalized export for HTML-based formats.
    ///
    /// Returns true for binary formats (KFX) where the raw content is not HTML.
    /// Exporters should use IR-based output when this returns true.
    pub fn requires_normalized_export(&self) -> bool {
        self.backend.requires_normalized_export()
    }

    /// Export the book to a different format.
    ///
    /// # Supported Export Formats
    ///
    /// | Format   | Support |
    /// |----------|---------|
    /// | EPUB     | ✓       |
    /// | AZW3     | ✓       |
    /// | MOBI     | ✗       |
    /// | Text     | ✓       |
    /// | Markdown | ✓       |
    ///
    /// # Example
    ///
    /// ```no_run
    /// use boko::{Book, Format};
    /// use std::fs::File;
    ///
    /// let book = Book::open("input.azw3")?;
    /// let mut file = File::create("output.epub")?;
    /// book.export(Format::Epub, &mut file)?;
    /// # Ok::<(), boko::Error>(())
    /// ```
    pub fn export<W: Write + Seek>(&self, format: Format, writer: &mut W) -> crate::Result<()> {
        match format {
            Format::Epub => EpubExporter::new().export(self, writer),
            Format::Azw3 => Azw3Exporter::new().export(self, writer),
            Format::Markdown => MarkdownExporter::new().export(self, writer),
            Format::Kfx => KfxExporter::new().export(self, writer),
            Format::Mobi => Err(crate::Error::UnsupportedFormat {
                detail: format!("{:?} export is not supported", format),
            }),
        }
    }
}