Skip to main content

epub_stream_render/
render_engine.rs

1use epub_stream::navigation::NavPoint;
2use epub_stream::{
3    BlockRole, ChapterRef, EpubBook, FontPolicy, Locator, Navigation, RenderPrep, RenderPrepError,
4    RenderPrepOptions, StyledEventOrRun,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::fmt;
9use std::fs::{self, File, OpenOptions};
10use std::io::{self, BufWriter, Read, Write};
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::mpsc::{sync_channel, Receiver};
14use std::sync::{Arc, Mutex};
15use std::time::Instant;
16
17use crate::render_ir::{
18    DrawCommand, ImageObjectCommand, JustifyMode, OverlayContent, OverlayItem, OverlayRect,
19    OverlaySize, OverlaySlot, PageAnnotation, PageChromeCommand, PageChromeKind, PageMetrics,
20    PaginationProfileId, RectCommand, RenderPage, ResolvedTextStyle, RuleCommand, TextCommand,
21};
22use crate::render_layout::{
23    LayoutConfig, LayoutEngine, LayoutSession as CoreLayoutSession, TextMeasurer,
24};
25
26/// Cancellation hook for long-running layout operations.
27pub trait CancelToken {
28    fn is_cancelled(&self) -> bool;
29}
30
31/// Never-cancel token for default call paths.
32#[derive(Clone, Copy, Debug, Default)]
33pub struct NeverCancel;
34
35impl CancelToken for NeverCancel {
36    fn is_cancelled(&self) -> bool {
37        false
38    }
39}
40
41/// Runtime diagnostics from render preparation/layout.
42#[derive(Clone, Debug, PartialEq)]
43pub enum RenderDiagnostic {
44    ReflowTimeMs(u32),
45    Cancelled,
46    MemoryLimitExceeded {
47        kind: &'static str,
48        actual: usize,
49        limit: usize,
50    },
51    CacheHit {
52        chapter_index: usize,
53        page_count: usize,
54    },
55    CacheMiss {
56        chapter_index: usize,
57    },
58}
59
60type DiagnosticCallback = Arc<Mutex<Box<dyn FnMut(RenderDiagnostic) + Send + 'static>>>;
61type DiagnosticSink = Option<DiagnosticCallback>;
62
63/// Render-engine options.
64#[derive(Clone, Copy, Debug, Default, PartialEq)]
65pub struct RenderEngineOptions {
66    /// Prep options passed to `RenderPrep`.
67    pub prep: RenderPrepOptions,
68    /// Layout options used to produce pages.
69    pub layout: LayoutConfig,
70}
71
72impl RenderEngineOptions {
73    /// Build options for a target display size.
74    pub fn for_display(width: i32, height: i32) -> Self {
75        Self {
76            prep: RenderPrepOptions::default(),
77            layout: LayoutConfig::for_display(width, height),
78        }
79    }
80}
81
82/// Alias used for chapter page slicing.
83pub type PageRange = core::ops::Range<usize>;
84
85/// Compact chapter-level page span used by `RenderBookPageMap`.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct RenderBookPageMapEntry {
88    /// Chapter index in spine order (0-based).
89    pub chapter_index: usize,
90    /// Chapter href in OPF-relative form.
91    pub chapter_href: String,
92    /// First global rendered page index for this chapter.
93    pub first_page_index: usize,
94    /// Rendered page count for this chapter.
95    pub page_count: usize,
96}
97
98impl RenderBookPageMapEntry {
99    fn contains_global_page(&self, global_page_index: usize) -> bool {
100        if self.page_count == 0 {
101            return false;
102        }
103        let start = self.first_page_index;
104        let end = start.saturating_add(self.page_count);
105        global_page_index >= start && global_page_index < end
106    }
107}
108
109/// Deterministic locator resolution kind for rendered page targets.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum RenderLocatorTargetKind {
112    /// Locator mapped directly to the chapter start page.
113    ChapterStart,
114    /// Locator fragment mapped to a chapter start fallback.
115    FragmentFallbackChapterStart,
116    /// Locator fragment matched to an explicit anchor page.
117    ///
118    /// Reserved for future anchor-index integration.
119    FragmentAnchor,
120}
121
122/// Resolved rendered page target for locator/href operations.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct RenderLocatorPageTarget {
125    /// Resolved global page index.
126    pub page_index: usize,
127    /// Resolved chapter index.
128    pub chapter_index: usize,
129    /// Resolved chapter href.
130    pub chapter_href: String,
131    /// Optional fragment payload (without leading '#').
132    pub fragment: Option<String>,
133    /// Resolution strategy used for this target.
134    pub kind: RenderLocatorTargetKind,
135}
136
137/// Persisted rendered reading position token.
138///
139/// The token stores chapter identity hints plus normalized chapter/global progress
140/// so callers can remap positions after reflow/profile changes.
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142pub struct RenderReadingPositionToken {
143    /// Chapter index hint from the source pagination profile.
144    pub chapter_index: usize,
145    /// Optional chapter href hint for robust remap across index shifts.
146    pub chapter_href: Option<String>,
147    /// Page offset within the chapter in the source pagination profile.
148    pub chapter_page_index: usize,
149    /// Total pages in the source chapter.
150    pub chapter_page_count: usize,
151    /// Chapter progress ratio in `[0.0, 1.0]`.
152    pub chapter_progress: f32,
153    /// Global page index in the source rendered sequence.
154    pub global_page_index: usize,
155    /// Total global pages in the source rendered sequence.
156    pub global_page_count: usize,
157}
158
159impl RenderReadingPositionToken {
160    fn normalized_chapter_progress(&self) -> f32 {
161        if self.chapter_page_count > 1 {
162            return page_progress_from_count(self.chapter_page_index, self.chapter_page_count);
163        }
164        normalize_progress(self.chapter_progress)
165    }
166
167    fn normalized_global_progress(&self) -> f32 {
168        page_progress_from_count(self.global_page_index, self.global_page_count)
169    }
170}
171
172/// Compact chapter-level rendered page index for locator and remap operations.
173#[derive(Clone, Debug, Default, PartialEq, Eq)]
174pub struct RenderBookPageMap {
175    entries: Vec<RenderBookPageMapEntry>,
176    total_pages: usize,
177}
178
179impl RenderBookPageMap {
180    /// Build a compact page map from spine chapters and rendered page counts.
181    ///
182    /// `chapter_page_counts` is interpreted in spine-index space.
183    /// Missing entries are treated as `0` pages.
184    pub fn from_chapter_page_counts(
185        chapters: &[ChapterRef],
186        chapter_page_counts: &[usize],
187    ) -> Self {
188        let mut entries = Vec::with_capacity(chapters.len());
189        let mut first_page_index = 0usize;
190
191        for (order_index, chapter) in chapters.iter().enumerate() {
192            let page_count = chapter_page_counts
193                .get(chapter.index)
194                .copied()
195                .or_else(|| chapter_page_counts.get(order_index).copied())
196                .unwrap_or(0);
197            entries.push(RenderBookPageMapEntry {
198                chapter_index: chapter.index,
199                chapter_href: chapter.href.clone(),
200                first_page_index,
201                page_count,
202            });
203            first_page_index = first_page_index.saturating_add(page_count);
204        }
205
206        Self {
207            entries,
208            total_pages: first_page_index,
209        }
210    }
211
212    /// Chapter-level entries in spine order.
213    pub fn entries(&self) -> &[RenderBookPageMapEntry] {
214        &self.entries
215    }
216
217    /// Total rendered page count represented by this map.
218    pub fn total_pages(&self) -> usize {
219        self.total_pages
220    }
221
222    /// Resolve the first rendered page for `chapter_index`.
223    ///
224    /// Returns `None` if the chapter has zero rendered pages or is absent.
225    pub fn chapter_start_page_index(&self, chapter_index: usize) -> Option<usize> {
226        self.chapter_entry_with_pages(chapter_index)
227            .map(|entry| entry.first_page_index)
228    }
229
230    /// Resolve the rendered global page range for `chapter_index`.
231    ///
232    /// Returns `None` if the chapter has zero rendered pages or is absent.
233    pub fn chapter_page_range(&self, chapter_index: usize) -> Option<PageRange> {
234        self.chapter_entry_with_pages(chapter_index).map(|entry| {
235            entry.first_page_index..entry.first_page_index.saturating_add(entry.page_count)
236        })
237    }
238
239    /// Resolve a chapter/fragment href into a rendered page target.
240    ///
241    /// Fragment mapping is best-effort. Until anchor-level mappings are available,
242    /// fragment hrefs deterministically fall back to chapter start.
243    pub fn resolve_href(&self, href: &str) -> Option<RenderLocatorPageTarget> {
244        self.resolve_href_with_fragment_progress(href, None)
245    }
246
247    /// Resolve a chapter/fragment href with optional normalized fragment progress.
248    ///
249    /// When `fragment_progress` is provided for hrefs containing a fragment,
250    /// the target page is resolved to the nearest page in that chapter and the
251    /// target kind is marked as `FragmentAnchor`.
252    pub fn resolve_href_with_fragment_progress(
253        &self,
254        href: &str,
255        fragment_progress: Option<f32>,
256    ) -> Option<RenderLocatorPageTarget> {
257        let (base_href, fragment) = split_href_fragment(href);
258        if base_href.is_empty() {
259            return None;
260        }
261
262        let chapter_index = self.find_chapter_index_for_href(base_href)?;
263        let chapter = self.chapter_entry_with_pages(chapter_index)?;
264        let mut page_index = chapter.first_page_index;
265        let kind = match fragment {
266            Some(_) => {
267                if let Some(progress) = fragment_progress {
268                    let local_index = progress_to_page_index(progress, chapter.page_count.max(1));
269                    page_index = chapter.first_page_index.saturating_add(local_index);
270                    RenderLocatorTargetKind::FragmentAnchor
271                } else {
272                    RenderLocatorTargetKind::FragmentFallbackChapterStart
273                }
274            }
275            None => RenderLocatorTargetKind::ChapterStart,
276        };
277
278        Some(RenderLocatorPageTarget {
279            page_index,
280            chapter_index: chapter.chapter_index,
281            chapter_href: chapter.chapter_href.clone(),
282            fragment: fragment.map(ToOwned::to_owned),
283            kind,
284        })
285    }
286
287    /// Alias for resolving TOC href targets.
288    pub fn resolve_toc_href(&self, href: &str) -> Option<RenderLocatorPageTarget> {
289        self.resolve_href(href)
290    }
291
292    /// Resolve a `epub_stream::Locator` into a rendered page target.
293    ///
294    /// Pass `navigation` when resolving `Locator::TocId`.
295    pub fn resolve_locator(
296        &self,
297        locator: &Locator,
298        navigation: Option<&Navigation>,
299    ) -> Option<RenderLocatorPageTarget> {
300        match locator {
301            Locator::Chapter(chapter_index) => self.resolve_chapter_start(*chapter_index, None),
302            Locator::Href(href) => self.resolve_href(href),
303            Locator::Fragment(_) => None,
304            Locator::TocId(id) => {
305                let navigation = navigation?;
306                let href = find_toc_href(navigation, id)?;
307                self.resolve_href(&href)
308            }
309            Locator::Position(pos) => self.resolve_position_locator(pos),
310        }
311    }
312
313    /// Build a persisted reading-position token from a rendered global page index.
314    ///
315    /// Out-of-range indices are clamped to the nearest valid page.
316    pub fn reading_position_token_for_page_index(
317        &self,
318        global_page_index: usize,
319    ) -> Option<RenderReadingPositionToken> {
320        if self.total_pages == 0 {
321            return None;
322        }
323
324        let clamped = global_page_index.min(self.total_pages.saturating_sub(1));
325        let chapter = self.entry_for_global_page(clamped)?;
326        let chapter_offset = clamped.saturating_sub(chapter.first_page_index);
327
328        Some(RenderReadingPositionToken {
329            chapter_index: chapter.chapter_index,
330            chapter_href: Some(chapter.chapter_href.clone()),
331            chapter_page_index: chapter_offset,
332            chapter_page_count: chapter.page_count.max(1),
333            chapter_progress: page_progress_from_count(chapter_offset, chapter.page_count.max(1)),
334            global_page_index: clamped,
335            global_page_count: self.total_pages.max(1),
336        })
337    }
338
339    /// Remap a persisted reading-position token into this page map.
340    ///
341    /// Remap keeps chapter identity when the chapter is still present and has
342    /// rendered pages; otherwise it falls back to global progress remap.
343    pub fn remap_reading_position_token(
344        &self,
345        token: &RenderReadingPositionToken,
346    ) -> Option<usize> {
347        if self.total_pages == 0 {
348            return None;
349        }
350
351        if let Some(chapter) = self.chapter_entry_for_token(token) {
352            let local_index =
353                progress_to_page_index(token.normalized_chapter_progress(), chapter.page_count);
354            return Some(chapter.first_page_index.saturating_add(local_index));
355        }
356
357        Some(progress_to_page_index(
358            token.normalized_global_progress(),
359            self.total_pages,
360        ))
361    }
362
363    fn resolve_position_locator(
364        &self,
365        position: &epub_stream::ReadingPosition,
366    ) -> Option<RenderLocatorPageTarget> {
367        if let Some(href) = position.chapter_href.as_deref() {
368            if let Some(anchor) = position
369                .anchor
370                .as_deref()
371                .filter(|anchor| !anchor.is_empty())
372            {
373                let mut href_with_fragment = String::with_capacity(
374                    href.len().saturating_add(anchor.len()).saturating_add(1),
375                );
376                href_with_fragment.push_str(href);
377                href_with_fragment.push('#');
378                href_with_fragment.push_str(anchor);
379                if let Some(target) = self.resolve_href(&href_with_fragment) {
380                    return Some(target);
381                }
382            }
383            if let Some(target) = self.resolve_href(href) {
384                return Some(target);
385            }
386        }
387        self.resolve_chapter_start(position.chapter_index, position.anchor.as_deref())
388    }
389
390    fn resolve_chapter_start(
391        &self,
392        chapter_index: usize,
393        anchor: Option<&str>,
394    ) -> Option<RenderLocatorPageTarget> {
395        let chapter = self.chapter_entry_with_pages(chapter_index)?;
396        let has_anchor = anchor.is_some_and(|value| !value.is_empty());
397        let kind = if has_anchor {
398            RenderLocatorTargetKind::FragmentFallbackChapterStart
399        } else {
400            RenderLocatorTargetKind::ChapterStart
401        };
402
403        Some(RenderLocatorPageTarget {
404            page_index: chapter.first_page_index,
405            chapter_index: chapter.chapter_index,
406            chapter_href: chapter.chapter_href.clone(),
407            fragment: anchor.map(ToOwned::to_owned),
408            kind,
409        })
410    }
411
412    fn chapter_entry_for_token(
413        &self,
414        token: &RenderReadingPositionToken,
415    ) -> Option<&RenderBookPageMapEntry> {
416        if let Some(href) = token.chapter_href.as_deref() {
417            let (base_href, _) = split_href_fragment(href);
418            if let Some(chapter_index) = self.find_chapter_index_for_href(base_href) {
419                if let Some(chapter) = self.chapter_entry_with_pages(chapter_index) {
420                    return Some(chapter);
421                }
422            }
423        }
424        self.chapter_entry_with_pages(token.chapter_index)
425    }
426
427    fn chapter_entry_with_pages(&self, chapter_index: usize) -> Option<&RenderBookPageMapEntry> {
428        self.entries
429            .iter()
430            .find(|entry| entry.chapter_index == chapter_index && entry.page_count > 0)
431    }
432
433    fn entry_for_global_page(&self, global_page_index: usize) -> Option<&RenderBookPageMapEntry> {
434        self.entries
435            .iter()
436            .find(|entry| entry.contains_global_page(global_page_index))
437    }
438
439    fn find_chapter_index_for_href(&self, base_href: &str) -> Option<usize> {
440        if base_href.is_empty() {
441            return None;
442        }
443
444        if let Some(entry) = self
445            .entries
446            .iter()
447            .find(|entry| entry.chapter_href == base_href)
448        {
449            return Some(entry.chapter_index);
450        }
451
452        let normalized_target = normalize_rel_path(base_href);
453        if let Some(entry) = self
454            .entries
455            .iter()
456            .find(|entry| normalize_rel_path(&entry.chapter_href) == normalized_target)
457        {
458            return Some(entry.chapter_index);
459        }
460
461        let target_basename = basename_of(&normalized_target);
462        if target_basename.is_empty() {
463            return None;
464        }
465        let mut candidate: Option<usize> = None;
466        for entry in &self.entries {
467            let normalized_entry = normalize_rel_path(&entry.chapter_href);
468            let entry_basename = basename_of(&normalized_entry);
469            if entry_basename != target_basename {
470                continue;
471            }
472            if candidate.is_some() {
473                return None;
474            }
475            candidate = Some(entry.chapter_index);
476        }
477        candidate
478    }
479}
480
481/// Storage hooks for render-page caches.
482pub trait RenderCacheStore {
483    /// Load cached pages for `chapter_index` and pagination profile, if available.
484    fn load_chapter_pages(
485        &self,
486        _profile: PaginationProfileId,
487        _chapter_index: usize,
488    ) -> Option<Vec<RenderPage>> {
489        None
490    }
491
492    /// Persist rendered chapter pages for the pagination profile.
493    fn store_chapter_pages(
494        &self,
495        _profile: PaginationProfileId,
496        _chapter_index: usize,
497        _pages: &[RenderPage],
498    ) {
499    }
500}
501
502const CACHE_SCHEMA_VERSION: u8 = 1;
503const DEFAULT_MAX_CACHE_FILE_BYTES: usize = 4 * 1024 * 1024;
504static CACHE_WRITE_NONCE: AtomicUsize = AtomicUsize::new(0);
505
506/// File-backed render-page cache store.
507///
508/// Cache paths are deterministic by pagination profile and chapter index:
509/// `<root>/<profile-hex>/chapter-<index>.json`.
510///
511/// The store uses a JSON envelope with a schema version and enforces
512/// `max_file_bytes` on both reads and writes. When I/O, decode, or size checks
513/// fail, operations return `None`/no-op instead of bubbling errors.
514#[derive(Clone, Debug)]
515pub struct FileRenderCacheStore {
516    root: PathBuf,
517    max_file_bytes: usize,
518}
519
520impl FileRenderCacheStore {
521    /// Create a new cache store rooted at `root`.
522    pub fn new(root: impl Into<PathBuf>) -> Self {
523        Self {
524            root: root.into(),
525            max_file_bytes: DEFAULT_MAX_CACHE_FILE_BYTES,
526        }
527    }
528
529    /// Set the maximum allowed cache file size in bytes.
530    ///
531    /// Values of `0` are treated as `1` to keep the cap explicit.
532    pub fn with_max_file_bytes(mut self, max_file_bytes: usize) -> Self {
533        self.max_file_bytes = max_file_bytes.max(1);
534        self
535    }
536
537    /// Root directory for cache files.
538    pub fn cache_root(&self) -> &Path {
539        &self.root
540    }
541
542    /// Maximum allowed cache file size in bytes.
543    pub fn max_file_bytes(&self) -> usize {
544        self.max_file_bytes
545    }
546
547    /// Deterministic cache path for profile/chapter payload.
548    pub fn chapter_cache_path(
549        &self,
550        profile: PaginationProfileId,
551        chapter_index: usize,
552    ) -> PathBuf {
553        let profile_dir = profile_hex(profile);
554        self.root
555            .join(profile_dir)
556            .join(format!("chapter-{}.json", chapter_index))
557    }
558}
559
560impl RenderCacheStore for FileRenderCacheStore {
561    fn load_chapter_pages(
562        &self,
563        profile: PaginationProfileId,
564        chapter_index: usize,
565    ) -> Option<Vec<RenderPage>> {
566        let path = self.chapter_cache_path(profile, chapter_index);
567        let max_file_bytes = self.max_file_bytes as u64;
568        if fs::metadata(&path).ok()?.len() > max_file_bytes {
569            return None;
570        }
571
572        let file = File::open(path).ok()?;
573        let mut reader = file.take(max_file_bytes.saturating_add(1));
574        let mut payload = Vec::with_capacity(0);
575        if reader.read_to_end(&mut payload).is_err() {
576            return None;
577        }
578        if payload.len() > self.max_file_bytes {
579            return None;
580        }
581        let envelope: PersistedCacheEnvelope = serde_json::from_slice(&payload).ok()?;
582        envelope.into_render_pages()
583    }
584
585    fn store_chapter_pages(
586        &self,
587        profile: PaginationProfileId,
588        chapter_index: usize,
589        pages: &[RenderPage],
590    ) {
591        let final_path = self.chapter_cache_path(profile, chapter_index);
592        let Some(parent) = final_path.parent() else {
593            return;
594        };
595        if fs::create_dir_all(parent).is_err() {
596            return;
597        }
598
599        let nonce = CACHE_WRITE_NONCE.fetch_add(1, Ordering::Relaxed);
600        let temp_path = parent.join(format!(
601            "chapter-{}.json.tmp-{}-{}",
602            chapter_index,
603            std::process::id(),
604            nonce
605        ));
606
607        let envelope = PersistedCacheEnvelope::from_pages(pages);
608        let file = match OpenOptions::new()
609            .write(true)
610            .create_new(true)
611            .open(&temp_path)
612        {
613            Ok(file) => file,
614            Err(_) => return,
615        };
616        let writer = BufWriter::new(file);
617        let mut writer = CappedWriter::new(writer, self.max_file_bytes);
618        if serde_json::to_writer(&mut writer, &envelope).is_err() {
619            remove_file_quiet(&temp_path);
620            return;
621        }
622        if writer.flush().is_err() {
623            remove_file_quiet(&temp_path);
624            return;
625        }
626        let mut writer = writer.into_inner();
627        if writer.flush().is_err() {
628            remove_file_quiet(&temp_path);
629            return;
630        }
631        let file = match writer.into_inner() {
632            Ok(file) => file,
633            Err(_) => {
634                remove_file_quiet(&temp_path);
635                return;
636            }
637        };
638        if file.sync_all().is_err() {
639            remove_file_quiet(&temp_path);
640            return;
641        }
642        drop(file);
643        if fs::rename(&temp_path, &final_path).is_err() {
644            remove_file_quiet(&temp_path);
645            return;
646        }
647        sync_directory(parent);
648    }
649}
650
651/// Resolve a page index in `new_pages` by chapter progress carried by
652/// `old_pages[old_page_index]`.
653pub fn remap_page_index_by_chapter_progress(
654    old_pages: &[RenderPage],
655    old_page_index: usize,
656    new_pages: &[RenderPage],
657) -> Option<usize> {
658    let target = chapter_progress_for_index(old_pages, old_page_index);
659    resolve_page_index_for_chapter_progress(target, new_pages)
660}
661
662/// Resolve a chapter progress value (`[0, 1]`) into a valid page index.
663///
664/// Returns `None` when `pages` is empty.
665pub fn resolve_page_index_for_chapter_progress(
666    chapter_progress: f32,
667    pages: &[RenderPage],
668) -> Option<usize> {
669    if pages.is_empty() {
670        return None;
671    }
672    let target = normalize_progress(chapter_progress);
673    let mut best_idx = 0usize;
674    let mut best_distance = f32::INFINITY;
675    let mut prev_progress = 0.0f32;
676
677    for (idx, page) in pages.iter().enumerate() {
678        let mut page_progress = page_progress_for_index(page, idx, pages.len());
679        if idx > 0 && page_progress < prev_progress {
680            page_progress = prev_progress;
681        }
682        prev_progress = page_progress;
683
684        let distance = (page_progress - target).abs();
685        if distance < best_distance {
686            best_distance = distance;
687            best_idx = idx;
688            continue;
689        }
690        if (distance - best_distance).abs() <= f32::EPSILON && idx < best_idx {
691            best_idx = idx;
692            continue;
693        }
694        if page_progress > target && distance > best_distance {
695            break;
696        }
697    }
698
699    Some(best_idx)
700}
701
702fn normalize_progress(progress: f32) -> f32 {
703    if progress.is_finite() {
704        return progress.clamp(0.0, 1.0);
705    }
706    0.0
707}
708
709fn page_progress_from_count(page_index: usize, page_count: usize) -> f32 {
710    if page_count <= 1 {
711        return 1.0;
712    }
713    let clamped = page_index.min(page_count.saturating_sub(1));
714    (clamped as f32 / (page_count - 1) as f32).clamp(0.0, 1.0)
715}
716
717fn progress_to_page_index(progress: f32, page_count: usize) -> usize {
718    if page_count <= 1 {
719        return 0;
720    }
721    let max_index = page_count.saturating_sub(1);
722    let scaled = normalize_progress(progress) * max_index as f32;
723    let rounded = scaled.round();
724    if !rounded.is_finite() || rounded <= 0.0 {
725        return 0;
726    }
727    let index = rounded as usize;
728    index.min(max_index)
729}
730
731fn split_href_fragment(href: &str) -> (&str, Option<&str>) {
732    let (base, fragment) = match href.split_once('#') {
733        Some((base, fragment)) => (base, Some(fragment)),
734        None => (href, None),
735    };
736    let fragment = fragment.filter(|value| !value.is_empty());
737    (base, fragment)
738}
739
740/// Estimate normalized chapter progress for an anchor fragment in XHTML bytes.
741///
742/// This is a lightweight best-effort helper intended for locator remap flows.
743/// It searches for `id="<fragment>"`, `id='<fragment>'`, `name="<fragment>"`,
744/// and `name='<fragment>'` byte patterns and maps the match offset into `[0, 1]`.
745pub fn estimate_fragment_progress_in_html(chapter_html: &[u8], fragment: &str) -> Option<f32> {
746    if chapter_html.is_empty() {
747        return None;
748    }
749    let fragment = fragment.trim();
750    if fragment.is_empty() {
751        return None;
752    }
753    let needle = fragment.as_bytes();
754
755    let mut patterns: [Vec<u8>; 4] = [
756        Vec::with_capacity(needle.len() + 5),
757        Vec::with_capacity(needle.len() + 5),
758        Vec::with_capacity(needle.len() + 7),
759        Vec::with_capacity(needle.len() + 7),
760    ];
761    patterns[0].extend_from_slice(b"id=\"");
762    patterns[0].extend_from_slice(needle);
763    patterns[0].push(b'"');
764
765    patterns[1].extend_from_slice(b"id='");
766    patterns[1].extend_from_slice(needle);
767    patterns[1].push(b'\'');
768
769    patterns[2].extend_from_slice(b"name=\"");
770    patterns[2].extend_from_slice(needle);
771    patterns[2].push(b'"');
772
773    patterns[3].extend_from_slice(b"name='");
774    patterns[3].extend_from_slice(needle);
775    patterns[3].push(b'\'');
776
777    let mut best: Option<usize> = None;
778    for pattern in &patterns {
779        if let Some(pos) = find_bytes(chapter_html, pattern) {
780            best = Some(best.map_or(pos, |current| current.min(pos)));
781        }
782    }
783    let position = best?;
784    if chapter_html.len() <= 1 {
785        return Some(0.0);
786    }
787    Some((position as f32 / (chapter_html.len() - 1) as f32).clamp(0.0, 1.0))
788}
789
790fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
791    if needle.is_empty() || needle.len() > haystack.len() {
792        return None;
793    }
794    haystack
795        .windows(needle.len())
796        .position(|window| window == needle)
797}
798
799fn normalize_rel_path(path: &str) -> String {
800    let base = path
801        .split_once('?')
802        .map_or(path, |(without_query, _)| without_query);
803    let slash_normalized = base.replace('\\', "/");
804    let mut out_parts: Vec<&str> = Vec::with_capacity(0);
805
806    for part in slash_normalized.split('/') {
807        if part.is_empty() || part == "." {
808            continue;
809        }
810        if part == ".." {
811            let _ = out_parts.pop();
812            continue;
813        }
814        out_parts.push(part);
815    }
816
817    out_parts.join("/")
818}
819
820fn basename_of(path: &str) -> &str {
821    path.rsplit('/').next().unwrap_or(path)
822}
823
824fn find_toc_href(navigation: &Navigation, id: &str) -> Option<String> {
825    fn visit(points: &[NavPoint], id: &str) -> Option<String> {
826        for point in points {
827            let (_, fragment) = split_href_fragment(&point.href);
828            if point.label == id || fragment == Some(id) {
829                return Some(point.href.clone());
830            }
831            if let Some(hit) = visit(&point.children, id) {
832                return Some(hit);
833            }
834        }
835        None
836    }
837    visit(&navigation.toc, id)
838}
839
840fn chapter_progress_for_index(pages: &[RenderPage], page_index: usize) -> f32 {
841    if pages.is_empty() {
842        return 0.0;
843    }
844    let clamped_index = page_index.min(pages.len().saturating_sub(1));
845    page_progress_for_index(&pages[clamped_index], clamped_index, pages.len())
846}
847
848fn page_progress_for_index(page: &RenderPage, fallback_index: usize, fallback_total: usize) -> f32 {
849    if let Some(chapter_page_count) = page.metrics.chapter_page_count {
850        if chapter_page_count <= 1 {
851            return 1.0;
852        }
853        let chapter_page_index = page
854            .metrics
855            .chapter_page_index
856            .min(chapter_page_count.saturating_sub(1));
857        return (chapter_page_index as f32 / (chapter_page_count - 1) as f32).clamp(0.0, 1.0);
858    }
859    if page.metrics.progress_chapter.is_finite() {
860        return page.metrics.progress_chapter.clamp(0.0, 1.0);
861    }
862    if fallback_total <= 1 {
863        return 1.0;
864    }
865    (fallback_index as f32 / (fallback_total - 1) as f32).clamp(0.0, 1.0)
866}
867
868fn profile_hex(profile: PaginationProfileId) -> String {
869    const HEX: &[u8; 16] = b"0123456789abcdef";
870    let mut out = String::with_capacity(64);
871    for byte in profile.0 {
872        out.push(HEX[(byte >> 4) as usize] as char);
873        out.push(HEX[(byte & 0x0f) as usize] as char);
874    }
875    out
876}
877
878fn remove_file_quiet(path: &Path) {
879    let _ = fs::remove_file(path);
880}
881
882fn sync_directory(path: &Path) {
883    if let Ok(dir) = File::open(path) {
884        let _ = dir.sync_all();
885    }
886}
887
888struct CappedWriter<W> {
889    inner: W,
890    max_bytes: usize,
891    written: usize,
892}
893
894impl<W> CappedWriter<W> {
895    fn new(inner: W, max_bytes: usize) -> Self {
896        Self {
897            inner,
898            max_bytes: max_bytes.max(1),
899            written: 0,
900        }
901    }
902
903    fn into_inner(self) -> W {
904        self.inner
905    }
906}
907
908impl<W: Write> Write for CappedWriter<W> {
909    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
910        let remaining = self.max_bytes.saturating_sub(self.written);
911        if buf.len() > remaining {
912            return Err(io::Error::new(
913                io::ErrorKind::InvalidData,
914                "cache file exceeds max_file_bytes",
915            ));
916        }
917        self.inner.write_all(buf)?;
918        self.written = self.written.saturating_add(buf.len());
919        Ok(buf.len())
920    }
921
922    fn flush(&mut self) -> io::Result<()> {
923        self.inner.flush()
924    }
925}
926
927#[derive(Clone, Debug, Serialize, Deserialize)]
928struct PersistedCacheEnvelope {
929    version: u8,
930    pages: Vec<PersistedRenderPage>,
931}
932
933impl PersistedCacheEnvelope {
934    fn from_pages(pages: &[RenderPage]) -> Self {
935        Self {
936            version: CACHE_SCHEMA_VERSION,
937            pages: pages.iter().map(PersistedRenderPage::from).collect(),
938        }
939    }
940
941    fn into_render_pages(self) -> Option<Vec<RenderPage>> {
942        if self.version != CACHE_SCHEMA_VERSION {
943            return None;
944        }
945        Some(self.pages.into_iter().map(RenderPage::from).collect())
946    }
947}
948
949#[derive(Clone, Debug, Serialize, Deserialize)]
950struct PersistedRenderPage {
951    page_number: usize,
952    commands: Vec<PersistedDrawCommand>,
953    content_commands: Vec<PersistedDrawCommand>,
954    chrome_commands: Vec<PersistedDrawCommand>,
955    overlay_commands: Vec<PersistedDrawCommand>,
956    overlay_items: Vec<PersistedOverlayItem>,
957    annotations: Vec<PersistedPageAnnotation>,
958    metrics: PersistedPageMetrics,
959}
960
961impl From<&RenderPage> for PersistedRenderPage {
962    fn from(page: &RenderPage) -> Self {
963        Self {
964            page_number: page.page_number,
965            commands: page
966                .commands
967                .iter()
968                .map(PersistedDrawCommand::from)
969                .collect(),
970            content_commands: page
971                .content_commands
972                .iter()
973                .map(PersistedDrawCommand::from)
974                .collect(),
975            chrome_commands: page
976                .chrome_commands
977                .iter()
978                .map(PersistedDrawCommand::from)
979                .collect(),
980            overlay_commands: page
981                .overlay_commands
982                .iter()
983                .map(PersistedDrawCommand::from)
984                .collect(),
985            overlay_items: page
986                .overlay_items
987                .iter()
988                .map(PersistedOverlayItem::from)
989                .collect(),
990            annotations: page
991                .annotations
992                .iter()
993                .map(PersistedPageAnnotation::from)
994                .collect(),
995            metrics: page.metrics.into(),
996        }
997    }
998}
999
1000impl From<PersistedRenderPage> for RenderPage {
1001    fn from(value: PersistedRenderPage) -> Self {
1002        Self {
1003            page_number: value.page_number,
1004            commands: value.commands.into_iter().map(DrawCommand::from).collect(),
1005            content_commands: value
1006                .content_commands
1007                .into_iter()
1008                .map(DrawCommand::from)
1009                .collect(),
1010            chrome_commands: value
1011                .chrome_commands
1012                .into_iter()
1013                .map(DrawCommand::from)
1014                .collect(),
1015            overlay_commands: value
1016                .overlay_commands
1017                .into_iter()
1018                .map(DrawCommand::from)
1019                .collect(),
1020            overlay_items: value
1021                .overlay_items
1022                .into_iter()
1023                .map(OverlayItem::from)
1024                .collect(),
1025            annotations: value
1026                .annotations
1027                .into_iter()
1028                .map(PageAnnotation::from)
1029                .collect(),
1030            metrics: value.metrics.into(),
1031        }
1032    }
1033}
1034
1035#[derive(Clone, Debug, Serialize, Deserialize)]
1036struct PersistedOverlayItem {
1037    slot: PersistedOverlaySlot,
1038    z: i32,
1039    content: PersistedOverlayContent,
1040}
1041
1042impl From<&OverlayItem> for PersistedOverlayItem {
1043    fn from(value: &OverlayItem) -> Self {
1044        Self {
1045            slot: (&value.slot).into(),
1046            z: value.z,
1047            content: (&value.content).into(),
1048        }
1049    }
1050}
1051
1052impl From<PersistedOverlayItem> for OverlayItem {
1053    fn from(value: PersistedOverlayItem) -> Self {
1054        Self {
1055            slot: value.slot.into(),
1056            z: value.z,
1057            content: value.content.into(),
1058        }
1059    }
1060}
1061
1062#[derive(Clone, Debug, Serialize, Deserialize)]
1063enum PersistedOverlaySlot {
1064    TopLeft,
1065    TopCenter,
1066    TopRight,
1067    BottomLeft,
1068    BottomCenter,
1069    BottomRight,
1070    Custom(PersistedOverlayRect),
1071}
1072
1073impl From<&OverlaySlot> for PersistedOverlaySlot {
1074    fn from(value: &OverlaySlot) -> Self {
1075        match value {
1076            OverlaySlot::TopLeft => Self::TopLeft,
1077            OverlaySlot::TopCenter => Self::TopCenter,
1078            OverlaySlot::TopRight => Self::TopRight,
1079            OverlaySlot::BottomLeft => Self::BottomLeft,
1080            OverlaySlot::BottomCenter => Self::BottomCenter,
1081            OverlaySlot::BottomRight => Self::BottomRight,
1082            OverlaySlot::Custom(rect) => Self::Custom((*rect).into()),
1083        }
1084    }
1085}
1086
1087impl From<PersistedOverlaySlot> for OverlaySlot {
1088    fn from(value: PersistedOverlaySlot) -> Self {
1089        match value {
1090            PersistedOverlaySlot::TopLeft => Self::TopLeft,
1091            PersistedOverlaySlot::TopCenter => Self::TopCenter,
1092            PersistedOverlaySlot::TopRight => Self::TopRight,
1093            PersistedOverlaySlot::BottomLeft => Self::BottomLeft,
1094            PersistedOverlaySlot::BottomCenter => Self::BottomCenter,
1095            PersistedOverlaySlot::BottomRight => Self::BottomRight,
1096            PersistedOverlaySlot::Custom(rect) => Self::Custom(rect.into()),
1097        }
1098    }
1099}
1100
1101#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1102struct PersistedOverlayRect {
1103    x: i32,
1104    y: i32,
1105    width: u32,
1106    height: u32,
1107}
1108
1109impl From<OverlayRect> for PersistedOverlayRect {
1110    fn from(value: OverlayRect) -> Self {
1111        Self {
1112            x: value.x,
1113            y: value.y,
1114            width: value.width,
1115            height: value.height,
1116        }
1117    }
1118}
1119
1120impl From<PersistedOverlayRect> for OverlayRect {
1121    fn from(value: PersistedOverlayRect) -> Self {
1122        Self {
1123            x: value.x,
1124            y: value.y,
1125            width: value.width,
1126            height: value.height,
1127        }
1128    }
1129}
1130
1131#[derive(Clone, Debug, Serialize, Deserialize)]
1132enum PersistedOverlayContent {
1133    Text(String),
1134    Command(PersistedDrawCommand),
1135}
1136
1137impl From<&OverlayContent> for PersistedOverlayContent {
1138    fn from(value: &OverlayContent) -> Self {
1139        match value {
1140            OverlayContent::Text(text) => Self::Text(text.clone()),
1141            OverlayContent::Command(cmd) => Self::Command(cmd.into()),
1142        }
1143    }
1144}
1145
1146impl From<PersistedOverlayContent> for OverlayContent {
1147    fn from(value: PersistedOverlayContent) -> Self {
1148        match value {
1149            PersistedOverlayContent::Text(text) => Self::Text(text),
1150            PersistedOverlayContent::Command(cmd) => Self::Command(cmd.into()),
1151        }
1152    }
1153}
1154
1155#[derive(Clone, Debug, Serialize, Deserialize)]
1156struct PersistedPageAnnotation {
1157    kind: String,
1158    value: Option<String>,
1159}
1160
1161impl From<&PageAnnotation> for PersistedPageAnnotation {
1162    fn from(value: &PageAnnotation) -> Self {
1163        Self {
1164            kind: value.kind.clone(),
1165            value: value.value.clone(),
1166        }
1167    }
1168}
1169
1170impl From<PersistedPageAnnotation> for PageAnnotation {
1171    fn from(value: PersistedPageAnnotation) -> Self {
1172        Self {
1173            kind: value.kind,
1174            value: value.value,
1175        }
1176    }
1177}
1178
1179#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1180struct PersistedPageMetrics {
1181    chapter_index: usize,
1182    chapter_page_index: usize,
1183    chapter_page_count: Option<usize>,
1184    global_page_index: Option<usize>,
1185    global_page_count_estimate: Option<usize>,
1186    progress_chapter: f32,
1187    progress_book: Option<f32>,
1188}
1189
1190impl From<PageMetrics> for PersistedPageMetrics {
1191    fn from(value: PageMetrics) -> Self {
1192        Self {
1193            chapter_index: value.chapter_index,
1194            chapter_page_index: value.chapter_page_index,
1195            chapter_page_count: value.chapter_page_count,
1196            global_page_index: value.global_page_index,
1197            global_page_count_estimate: value.global_page_count_estimate,
1198            progress_chapter: normalize_progress(value.progress_chapter),
1199            progress_book: value.progress_book.map(normalize_progress),
1200        }
1201    }
1202}
1203
1204impl From<PersistedPageMetrics> for PageMetrics {
1205    fn from(value: PersistedPageMetrics) -> Self {
1206        Self {
1207            chapter_index: value.chapter_index,
1208            chapter_page_index: value.chapter_page_index,
1209            chapter_page_count: value.chapter_page_count,
1210            global_page_index: value.global_page_index,
1211            global_page_count_estimate: value.global_page_count_estimate,
1212            progress_chapter: normalize_progress(value.progress_chapter),
1213            progress_book: value.progress_book.map(normalize_progress),
1214        }
1215    }
1216}
1217
1218#[derive(Clone, Debug, Serialize, Deserialize)]
1219enum PersistedDrawCommand {
1220    Text(PersistedTextCommand),
1221    Rule(PersistedRuleCommand),
1222    ImageObject(PersistedImageObjectCommand),
1223    Rect(PersistedRectCommand),
1224    PageChrome(PersistedPageChromeCommand),
1225}
1226
1227impl From<&DrawCommand> for PersistedDrawCommand {
1228    fn from(value: &DrawCommand) -> Self {
1229        match value {
1230            DrawCommand::Text(cmd) => Self::Text(cmd.into()),
1231            DrawCommand::Rule(cmd) => Self::Rule((*cmd).into()),
1232            DrawCommand::ImageObject(cmd) => Self::ImageObject(cmd.into()),
1233            DrawCommand::Rect(cmd) => Self::Rect((*cmd).into()),
1234            DrawCommand::PageChrome(cmd) => Self::PageChrome(cmd.into()),
1235        }
1236    }
1237}
1238
1239impl From<PersistedDrawCommand> for DrawCommand {
1240    fn from(value: PersistedDrawCommand) -> Self {
1241        match value {
1242            PersistedDrawCommand::Text(cmd) => Self::Text(cmd.into()),
1243            PersistedDrawCommand::Rule(cmd) => Self::Rule(cmd.into()),
1244            PersistedDrawCommand::ImageObject(cmd) => Self::ImageObject(cmd.into()),
1245            PersistedDrawCommand::Rect(cmd) => Self::Rect(cmd.into()),
1246            PersistedDrawCommand::PageChrome(cmd) => Self::PageChrome(cmd.into()),
1247        }
1248    }
1249}
1250
1251#[derive(Clone, Debug, Serialize, Deserialize)]
1252struct PersistedTextCommand {
1253    x: i32,
1254    baseline_y: i32,
1255    text: String,
1256    font_id: Option<u32>,
1257    style: PersistedResolvedTextStyle,
1258}
1259
1260impl From<&TextCommand> for PersistedTextCommand {
1261    fn from(value: &TextCommand) -> Self {
1262        Self {
1263            x: value.x,
1264            baseline_y: value.baseline_y,
1265            text: value.text.clone(),
1266            font_id: value.font_id,
1267            style: (&value.style).into(),
1268        }
1269    }
1270}
1271
1272impl From<PersistedTextCommand> for TextCommand {
1273    fn from(value: PersistedTextCommand) -> Self {
1274        Self {
1275            x: value.x,
1276            baseline_y: value.baseline_y,
1277            text: value.text,
1278            font_id: value.font_id,
1279            style: value.style.into(),
1280        }
1281    }
1282}
1283
1284#[derive(Clone, Debug, Serialize, Deserialize)]
1285struct PersistedResolvedTextStyle {
1286    font_id: Option<u32>,
1287    family: String,
1288    weight: u16,
1289    italic: bool,
1290    size_px: f32,
1291    line_height: f32,
1292    letter_spacing: f32,
1293    role: PersistedBlockRole,
1294    justify_mode: PersistedJustifyMode,
1295}
1296
1297impl From<&ResolvedTextStyle> for PersistedResolvedTextStyle {
1298    fn from(value: &ResolvedTextStyle) -> Self {
1299        Self {
1300            font_id: value.font_id,
1301            family: value.family.clone(),
1302            weight: value.weight,
1303            italic: value.italic,
1304            size_px: value.size_px,
1305            line_height: value.line_height,
1306            letter_spacing: value.letter_spacing,
1307            role: value.role.into(),
1308            justify_mode: value.justify_mode.into(),
1309        }
1310    }
1311}
1312
1313impl From<PersistedResolvedTextStyle> for ResolvedTextStyle {
1314    fn from(value: PersistedResolvedTextStyle) -> Self {
1315        Self {
1316            font_id: value.font_id,
1317            family: value.family,
1318            weight: value.weight,
1319            italic: value.italic,
1320            size_px: value.size_px,
1321            line_height: value.line_height,
1322            letter_spacing: value.letter_spacing,
1323            role: value.role.into(),
1324            justify_mode: value.justify_mode.into(),
1325        }
1326    }
1327}
1328
1329#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1330enum PersistedBlockRole {
1331    Body,
1332    Paragraph,
1333    Heading(u8),
1334    ListItem,
1335    FigureCaption,
1336}
1337
1338impl From<BlockRole> for PersistedBlockRole {
1339    fn from(value: BlockRole) -> Self {
1340        match value {
1341            BlockRole::Body => Self::Body,
1342            BlockRole::Paragraph => Self::Paragraph,
1343            BlockRole::Heading(level) => Self::Heading(level),
1344            BlockRole::ListItem => Self::ListItem,
1345            BlockRole::FigureCaption => Self::FigureCaption,
1346        }
1347    }
1348}
1349
1350impl From<PersistedBlockRole> for BlockRole {
1351    fn from(value: PersistedBlockRole) -> Self {
1352        match value {
1353            PersistedBlockRole::Body => Self::Body,
1354            PersistedBlockRole::Paragraph => Self::Paragraph,
1355            PersistedBlockRole::Heading(level) => Self::Heading(level),
1356            PersistedBlockRole::ListItem => Self::ListItem,
1357            PersistedBlockRole::FigureCaption => Self::FigureCaption,
1358        }
1359    }
1360}
1361
1362#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1363enum PersistedJustifyMode {
1364    None,
1365    InterWord { extra_px_total: i32 },
1366    AlignRight { offset_px: i32 },
1367    AlignCenter { offset_px: i32 },
1368}
1369
1370impl From<JustifyMode> for PersistedJustifyMode {
1371    fn from(value: JustifyMode) -> Self {
1372        match value {
1373            JustifyMode::None => Self::None,
1374            JustifyMode::InterWord { extra_px_total } => Self::InterWord { extra_px_total },
1375            JustifyMode::AlignRight { offset_px } => Self::AlignRight { offset_px },
1376            JustifyMode::AlignCenter { offset_px } => Self::AlignCenter { offset_px },
1377        }
1378    }
1379}
1380
1381impl From<PersistedJustifyMode> for JustifyMode {
1382    fn from(value: PersistedJustifyMode) -> Self {
1383        match value {
1384            PersistedJustifyMode::None => Self::None,
1385            PersistedJustifyMode::InterWord { extra_px_total } => {
1386                Self::InterWord { extra_px_total }
1387            }
1388            PersistedJustifyMode::AlignRight { offset_px } => Self::AlignRight { offset_px },
1389            PersistedJustifyMode::AlignCenter { offset_px } => Self::AlignCenter { offset_px },
1390        }
1391    }
1392}
1393
1394#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1395struct PersistedRuleCommand {
1396    x: i32,
1397    y: i32,
1398    length: u32,
1399    thickness: u32,
1400    horizontal: bool,
1401}
1402
1403impl From<RuleCommand> for PersistedRuleCommand {
1404    fn from(value: RuleCommand) -> Self {
1405        Self {
1406            x: value.x,
1407            y: value.y,
1408            length: value.length,
1409            thickness: value.thickness,
1410            horizontal: value.horizontal,
1411        }
1412    }
1413}
1414
1415impl From<PersistedRuleCommand> for RuleCommand {
1416    fn from(value: PersistedRuleCommand) -> Self {
1417        Self {
1418            x: value.x,
1419            y: value.y,
1420            length: value.length,
1421            thickness: value.thickness,
1422            horizontal: value.horizontal,
1423        }
1424    }
1425}
1426
1427#[derive(Clone, Debug, Serialize, Deserialize)]
1428struct PersistedImageObjectCommand {
1429    src: String,
1430    alt: String,
1431    x: i32,
1432    y: i32,
1433    width: u32,
1434    height: u32,
1435}
1436
1437impl From<&ImageObjectCommand> for PersistedImageObjectCommand {
1438    fn from(value: &ImageObjectCommand) -> Self {
1439        Self {
1440            src: value.src.clone(),
1441            alt: value.alt.clone(),
1442            x: value.x,
1443            y: value.y,
1444            width: value.width,
1445            height: value.height,
1446        }
1447    }
1448}
1449
1450impl From<PersistedImageObjectCommand> for ImageObjectCommand {
1451    fn from(value: PersistedImageObjectCommand) -> Self {
1452        Self {
1453            src: value.src,
1454            alt: value.alt,
1455            x: value.x,
1456            y: value.y,
1457            width: value.width,
1458            height: value.height,
1459        }
1460    }
1461}
1462
1463#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1464struct PersistedRectCommand {
1465    x: i32,
1466    y: i32,
1467    width: u32,
1468    height: u32,
1469    fill: bool,
1470}
1471
1472impl From<RectCommand> for PersistedRectCommand {
1473    fn from(value: RectCommand) -> Self {
1474        Self {
1475            x: value.x,
1476            y: value.y,
1477            width: value.width,
1478            height: value.height,
1479            fill: value.fill,
1480        }
1481    }
1482}
1483
1484impl From<PersistedRectCommand> for RectCommand {
1485    fn from(value: PersistedRectCommand) -> Self {
1486        Self {
1487            x: value.x,
1488            y: value.y,
1489            width: value.width,
1490            height: value.height,
1491            fill: value.fill,
1492        }
1493    }
1494}
1495
1496#[derive(Clone, Debug, Serialize, Deserialize)]
1497struct PersistedPageChromeCommand {
1498    kind: PersistedPageChromeKind,
1499    text: Option<String>,
1500    current: Option<usize>,
1501    total: Option<usize>,
1502}
1503
1504impl From<&PageChromeCommand> for PersistedPageChromeCommand {
1505    fn from(value: &PageChromeCommand) -> Self {
1506        Self {
1507            kind: value.kind.into(),
1508            text: value.text.clone(),
1509            current: value.current,
1510            total: value.total,
1511        }
1512    }
1513}
1514
1515impl From<PersistedPageChromeCommand> for PageChromeCommand {
1516    fn from(value: PersistedPageChromeCommand) -> Self {
1517        Self {
1518            kind: value.kind.into(),
1519            text: value.text,
1520            current: value.current,
1521            total: value.total,
1522        }
1523    }
1524}
1525
1526#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
1527enum PersistedPageChromeKind {
1528    Header,
1529    Footer,
1530    Progress,
1531}
1532
1533impl From<PageChromeKind> for PersistedPageChromeKind {
1534    fn from(value: PageChromeKind) -> Self {
1535        match value {
1536            PageChromeKind::Header => Self::Header,
1537            PageChromeKind::Footer => Self::Footer,
1538            PageChromeKind::Progress => Self::Progress,
1539        }
1540    }
1541}
1542
1543impl From<PersistedPageChromeKind> for PageChromeKind {
1544    fn from(value: PersistedPageChromeKind) -> Self {
1545        match value {
1546            PersistedPageChromeKind::Header => Self::Header,
1547            PersistedPageChromeKind::Footer => Self::Footer,
1548            PersistedPageChromeKind::Progress => Self::Progress,
1549        }
1550    }
1551}
1552
1553/// Per-run configuration used by `RenderEngine::begin`.
1554#[derive(Clone)]
1555pub struct RenderConfig<'a> {
1556    page_range: Option<PageRange>,
1557    cache: Option<&'a dyn RenderCacheStore>,
1558    cancel: Option<&'a dyn CancelToken>,
1559    embedded_fonts: bool,
1560    forced_font_family: Option<String>,
1561    text_measurer: Option<Arc<dyn TextMeasurer>>,
1562}
1563
1564impl<'a> Default for RenderConfig<'a> {
1565    fn default() -> Self {
1566        Self {
1567            page_range: None,
1568            cache: None,
1569            cancel: None,
1570            embedded_fonts: true,
1571            forced_font_family: None,
1572            text_measurer: None,
1573        }
1574    }
1575}
1576
1577impl<'a> RenderConfig<'a> {
1578    /// Limit emitted pages to the given chapter range `[start, end)`.
1579    pub fn with_page_range(mut self, range: PageRange) -> Self {
1580        self.page_range = Some(range);
1581        self
1582    }
1583
1584    /// Use cache hooks for loading/storing chapter pages.
1585    pub fn with_cache(mut self, cache: &'a dyn RenderCacheStore) -> Self {
1586        self.cache = Some(cache);
1587        self
1588    }
1589
1590    /// Attach an optional cancellation token for session operations.
1591    pub fn with_cancel(mut self, cancel: &'a dyn CancelToken) -> Self {
1592        self.cancel = Some(cancel);
1593        self
1594    }
1595
1596    /// Enable or disable embedded-font registration for this render run.
1597    ///
1598    /// Disable this in constrained environments to skip EPUB font-face loading
1599    /// and rely on fallback font policy.
1600    pub fn with_embedded_fonts(mut self, enabled: bool) -> Self {
1601        self.embedded_fonts = enabled;
1602        self
1603    }
1604
1605    /// Force a single fallback family for all text shaping/layout.
1606    ///
1607    /// This disables embedded font matching to keep measurement/rendering consistent
1608    /// with the requested family.
1609    pub fn with_forced_font_family(mut self, family: impl Into<String>) -> Self {
1610        let family = family.into();
1611        let trimmed = family.trim();
1612        if trimmed.is_empty() {
1613            self.forced_font_family = None;
1614            return self;
1615        }
1616        self.forced_font_family = Some(trimmed.to_string());
1617        self.embedded_fonts = false;
1618        self
1619    }
1620
1621    /// Attach a glyph-width measurer used by line layout.
1622    pub fn with_text_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
1623        self.text_measurer = Some(measurer);
1624        self
1625    }
1626}
1627
1628/// Render engine for chapter -> page conversion.
1629#[derive(Clone)]
1630pub struct RenderEngine {
1631    opts: RenderEngineOptions,
1632    layout: LayoutEngine,
1633    diagnostic_sink: DiagnosticSink,
1634}
1635
1636impl fmt::Debug for RenderEngine {
1637    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1638        f.debug_struct("RenderEngine")
1639            .field("opts", &self.opts)
1640            .field("layout", &self.layout)
1641            .finish_non_exhaustive()
1642    }
1643}
1644
1645impl RenderEngine {
1646    /// Create a render engine.
1647    pub fn new(opts: RenderEngineOptions) -> Self {
1648        Self {
1649            layout: LayoutEngine::new(opts.layout),
1650            opts,
1651            diagnostic_sink: None,
1652        }
1653    }
1654
1655    /// Register or replace the diagnostics sink.
1656    pub fn set_diagnostic_sink<F>(&mut self, sink: F)
1657    where
1658        F: FnMut(RenderDiagnostic) + Send + 'static,
1659    {
1660        self.diagnostic_sink = Some(Arc::new(Mutex::new(Box::new(sink))));
1661    }
1662
1663    fn emit_diagnostic(&self, diagnostic: RenderDiagnostic) {
1664        let Some(sink) = &self.diagnostic_sink else {
1665            return;
1666        };
1667        if let Ok(mut sink) = sink.lock() {
1668            sink(diagnostic);
1669        }
1670    }
1671
1672    /// Stable fingerprint for all layout-affecting settings.
1673    pub fn pagination_profile_id(&self) -> PaginationProfileId {
1674        let payload = format!("{:?}|{:?}", self.opts.prep, self.opts.layout);
1675        PaginationProfileId::from_bytes(payload.as_bytes())
1676    }
1677
1678    /// Begin a chapter layout session for embedded/incremental integrations.
1679    pub fn begin<'a>(
1680        &'a self,
1681        chapter_index: usize,
1682        config: RenderConfig<'a>,
1683    ) -> LayoutSession<'a> {
1684        let profile = self.pagination_profile_id();
1685        let text_measurer = config.text_measurer.clone();
1686        let mut pending = VecDeque::new();
1687        let mut cached_hit = false;
1688        if let Some(cache) = config.cache {
1689            if let Some(pages) = cache.load_chapter_pages(profile, chapter_index) {
1690                cached_hit = true;
1691                self.emit_diagnostic(RenderDiagnostic::CacheHit {
1692                    chapter_index,
1693                    page_count: pages.len(),
1694                });
1695                let range = normalize_page_range(config.page_range.clone());
1696                let total_pages = pages.len();
1697                for (idx, mut page) in pages.into_iter().enumerate() {
1698                    Self::annotate_page_for_chapter(&mut page, chapter_index);
1699                    Self::annotate_page_metrics(&mut page, total_pages);
1700                    if page_in_range(idx, &range) {
1701                        pending.push_back(page);
1702                    }
1703                }
1704            }
1705        }
1706        if config.cache.is_some() && !cached_hit {
1707            self.emit_diagnostic(RenderDiagnostic::CacheMiss { chapter_index });
1708        }
1709        LayoutSession {
1710            engine: self,
1711            chapter_index,
1712            profile,
1713            cfg: config,
1714            inner: if cached_hit {
1715                None
1716            } else {
1717                Some(self.layout.start_session_with_text_measurer(text_measurer))
1718            },
1719            pending_pages: pending,
1720            rendered_pages: Vec::with_capacity(0),
1721            page_index: 0,
1722            completed: cached_hit,
1723        }
1724    }
1725
1726    fn annotate_page_for_chapter(page: &mut RenderPage, chapter_index: usize) {
1727        page.metrics.chapter_index = chapter_index;
1728        page.metrics.chapter_page_index = page.page_number.saturating_sub(1);
1729    }
1730
1731    fn annotate_page_metrics(page: &mut RenderPage, chapter_page_count: usize) {
1732        let chapter_page_count = chapter_page_count.max(1);
1733        page.metrics.chapter_page_count = Some(chapter_page_count);
1734        page.metrics.global_page_index = Some(page.metrics.chapter_page_index);
1735        page.metrics.global_page_count_estimate = Some(chapter_page_count);
1736        page.metrics.progress_chapter = if chapter_page_count <= 1 {
1737            1.0
1738        } else {
1739            page.metrics.chapter_page_index as f32 / (chapter_page_count - 1) as f32
1740        }
1741        .clamp(0.0, 1.0);
1742        page.metrics.progress_book = Some(page.metrics.progress_chapter);
1743    }
1744
1745    /// Prepare and layout a chapter into render pages.
1746    pub fn prepare_chapter<R: std::io::Read + std::io::Seek>(
1747        &self,
1748        book: &mut EpubBook<R>,
1749        chapter_index: usize,
1750    ) -> Result<Vec<RenderPage>, RenderEngineError> {
1751        self.prepare_chapter_with_config_collect(book, chapter_index, RenderConfig::default())
1752    }
1753
1754    /// Prepare and layout a chapter into render pages with explicit run config.
1755    pub fn prepare_chapter_with_config_collect<R: std::io::Read + std::io::Seek>(
1756        &self,
1757        book: &mut EpubBook<R>,
1758        chapter_index: usize,
1759        config: RenderConfig<'_>,
1760    ) -> Result<Vec<RenderPage>, RenderEngineError> {
1761        let page_limit = self.opts.prep.memory.max_pages_in_memory;
1762        let mut pages = Vec::with_capacity(page_limit.min(8));
1763        let mut dropped_pages = 0usize;
1764        self.prepare_chapter_with_config(book, chapter_index, config, |page| {
1765            if pages.len() < page_limit {
1766                pages.push(page);
1767            } else {
1768                dropped_pages = dropped_pages.saturating_add(1);
1769            }
1770        })?;
1771        let chapter_total = pages.len().max(1);
1772        for page in pages.iter_mut() {
1773            Self::annotate_page_for_chapter(page, chapter_index);
1774            if page.metrics.chapter_page_count.is_none() {
1775                Self::annotate_page_metrics(page, chapter_total);
1776            }
1777        }
1778        if dropped_pages > 0 {
1779            self.emit_diagnostic(RenderDiagnostic::MemoryLimitExceeded {
1780                kind: "max_pages_in_memory",
1781                actual: pages.len().saturating_add(dropped_pages),
1782                limit: page_limit,
1783            });
1784            return Err(RenderEngineError::LimitExceeded {
1785                kind: "max_pages_in_memory",
1786                actual: pages.len().saturating_add(dropped_pages),
1787                limit: page_limit,
1788            });
1789        }
1790        Ok(pages)
1791    }
1792
1793    /// Prepare and layout a chapter and stream each page.
1794    pub fn prepare_chapter_with<R, F>(
1795        &self,
1796        book: &mut EpubBook<R>,
1797        chapter_index: usize,
1798        on_page: F,
1799    ) -> Result<(), RenderEngineError>
1800    where
1801        R: std::io::Read + std::io::Seek,
1802        F: FnMut(RenderPage),
1803    {
1804        self.prepare_chapter_with_config(book, chapter_index, RenderConfig::default(), on_page)
1805    }
1806
1807    /// Prepare and layout a chapter with explicit config and stream each page.
1808    pub fn prepare_chapter_with_config<R, F>(
1809        &self,
1810        book: &mut EpubBook<R>,
1811        chapter_index: usize,
1812        config: RenderConfig<'_>,
1813        mut on_page: F,
1814    ) -> Result<(), RenderEngineError>
1815    where
1816        R: std::io::Read + std::io::Seek,
1817        F: FnMut(RenderPage),
1818    {
1819        let cancel = config.cancel.unwrap_or(&NeverCancel);
1820        self.prepare_chapter_with_cancel_and_config(book, chapter_index, cancel, config, |page| {
1821            on_page(page)
1822        })
1823    }
1824
1825    /// Prepare and layout caller-provided chapter bytes and stream each page.
1826    ///
1827    /// This path avoids internal chapter-byte allocation and is intended for
1828    /// embedded call sites that keep a reusable chapter buffer.
1829    pub fn prepare_chapter_bytes_with<R, F>(
1830        &self,
1831        book: &mut EpubBook<R>,
1832        chapter_index: usize,
1833        html: &[u8],
1834        on_page: F,
1835    ) -> Result<(), RenderEngineError>
1836    where
1837        R: std::io::Read + std::io::Seek,
1838        F: FnMut(RenderPage),
1839    {
1840        self.prepare_chapter_bytes_with_config(
1841            book,
1842            chapter_index,
1843            html,
1844            RenderConfig::default(),
1845            on_page,
1846        )
1847    }
1848
1849    /// Prepare and layout caller-provided chapter bytes with explicit config.
1850    pub fn prepare_chapter_bytes_with_config<R, F>(
1851        &self,
1852        book: &mut EpubBook<R>,
1853        chapter_index: usize,
1854        html: &[u8],
1855        config: RenderConfig<'_>,
1856        on_page: F,
1857    ) -> Result<(), RenderEngineError>
1858    where
1859        R: std::io::Read + std::io::Seek,
1860        F: FnMut(RenderPage),
1861    {
1862        let cancel = config.cancel.unwrap_or(&NeverCancel);
1863        self.prepare_chapter_bytes_with_cancel_and_config(
1864            book,
1865            chapter_index,
1866            html,
1867            cancel,
1868            config,
1869            on_page,
1870        )
1871    }
1872
1873    /// Prepare and layout a chapter while honoring cancellation.
1874    pub fn prepare_chapter_with_cancel<R, C, F>(
1875        &self,
1876        book: &mut EpubBook<R>,
1877        chapter_index: usize,
1878        cancel: &C,
1879        on_page: F,
1880    ) -> Result<(), RenderEngineError>
1881    where
1882        R: std::io::Read + std::io::Seek,
1883        C: CancelToken,
1884        F: FnMut(RenderPage),
1885    {
1886        let config = RenderConfig::default().with_cancel(cancel);
1887        self.prepare_chapter_with_cancel_and_config(book, chapter_index, cancel, config, on_page)
1888    }
1889
1890    fn prepare_chapter_with_cancel_and_config<R, C, F>(
1891        &self,
1892        book: &mut EpubBook<R>,
1893        chapter_index: usize,
1894        cancel: &C,
1895        config: RenderConfig<'_>,
1896        mut on_page: F,
1897    ) -> Result<(), RenderEngineError>
1898    where
1899        R: std::io::Read + std::io::Seek,
1900        C: CancelToken + ?Sized,
1901        F: FnMut(RenderPage),
1902    {
1903        let embedded_fonts = config.embedded_fonts;
1904        let forced_font_family = config.forced_font_family.clone();
1905        let defer_emit_until_finish = config.page_range.is_some();
1906        let started = Instant::now();
1907        if cancel.is_cancelled() {
1908            self.emit_diagnostic(RenderDiagnostic::Cancelled);
1909            return Err(RenderEngineError::Cancelled);
1910        }
1911        let mut session = self.begin(chapter_index, config);
1912        session.set_hyphenation_language(book.language());
1913        if session.is_complete() {
1914            session.drain_pages(&mut on_page);
1915            return Ok(());
1916        }
1917        let mut prep = if let Some(family) = forced_font_family.as_deref() {
1918            RenderPrep::new(self.opts.prep).with_font_policy(forced_font_policy(family))
1919        } else {
1920            RenderPrep::new(self.opts.prep).with_serif_default()
1921        };
1922        if embedded_fonts {
1923            prep = prep.with_embedded_fonts_from_book(book)?;
1924        }
1925        let mut saw_cancelled = false;
1926        prep.prepare_chapter_with(book, chapter_index, |item| {
1927            if saw_cancelled || cancel.is_cancelled() {
1928                saw_cancelled = true;
1929                return;
1930            }
1931            if session.push(item).is_err() {
1932                saw_cancelled = true;
1933                return;
1934            }
1935            if !defer_emit_until_finish {
1936                session.drain_pages(&mut on_page);
1937            }
1938        })?;
1939        if saw_cancelled || cancel.is_cancelled() {
1940            self.emit_diagnostic(RenderDiagnostic::Cancelled);
1941            return Err(RenderEngineError::Cancelled);
1942        }
1943        session.finish()?;
1944        session.drain_pages(&mut on_page);
1945        let elapsed = started.elapsed().as_millis().min(u32::MAX as u128) as u32;
1946        self.emit_diagnostic(RenderDiagnostic::ReflowTimeMs(elapsed));
1947        Ok(())
1948    }
1949
1950    fn prepare_chapter_bytes_with_cancel_and_config<R, C, F>(
1951        &self,
1952        book: &mut EpubBook<R>,
1953        chapter_index: usize,
1954        html: &[u8],
1955        cancel: &C,
1956        config: RenderConfig<'_>,
1957        mut on_page: F,
1958    ) -> Result<(), RenderEngineError>
1959    where
1960        R: std::io::Read + std::io::Seek,
1961        C: CancelToken + ?Sized,
1962        F: FnMut(RenderPage),
1963    {
1964        let embedded_fonts = config.embedded_fonts;
1965        let forced_font_family = config.forced_font_family.clone();
1966        let defer_emit_until_finish = config.page_range.is_some();
1967        let started = Instant::now();
1968        if cancel.is_cancelled() {
1969            self.emit_diagnostic(RenderDiagnostic::Cancelled);
1970            return Err(RenderEngineError::Cancelled);
1971        }
1972        let mut session = self.begin(chapter_index, config);
1973        session.set_hyphenation_language(book.language());
1974        if session.is_complete() {
1975            session.drain_pages(&mut on_page);
1976            return Ok(());
1977        }
1978        let mut prep = if let Some(family) = forced_font_family.as_deref() {
1979            RenderPrep::new(self.opts.prep).with_font_policy(forced_font_policy(family))
1980        } else {
1981            RenderPrep::new(self.opts.prep).with_serif_default()
1982        };
1983        if embedded_fonts {
1984            prep = prep.with_embedded_fonts_from_book(book)?;
1985        }
1986        let mut saw_cancelled = false;
1987        prep.prepare_chapter_bytes_with(book, chapter_index, html, |item| {
1988            if saw_cancelled || cancel.is_cancelled() {
1989                saw_cancelled = true;
1990                return;
1991            }
1992            if session.push(item).is_err() {
1993                saw_cancelled = true;
1994                return;
1995            }
1996            if !defer_emit_until_finish {
1997                session.drain_pages(&mut on_page);
1998            }
1999        })?;
2000        if saw_cancelled || cancel.is_cancelled() {
2001            self.emit_diagnostic(RenderDiagnostic::Cancelled);
2002            return Err(RenderEngineError::Cancelled);
2003        }
2004        session.finish()?;
2005        session.drain_pages(&mut on_page);
2006        let elapsed = started.elapsed().as_millis().min(u32::MAX as u128) as u32;
2007        self.emit_diagnostic(RenderDiagnostic::ReflowTimeMs(elapsed));
2008        Ok(())
2009    }
2010
2011    /// Prepare and layout a chapter, returning pages within `[start, end)`.
2012    ///
2013    /// Range indices are zero-based over the emitted chapter page sequence.
2014    /// Returned `RenderPage::page_number` values remain 1-based chapter page numbers.
2015    pub fn prepare_chapter_page_range<R: std::io::Read + std::io::Seek>(
2016        &self,
2017        book: &mut EpubBook<R>,
2018        chapter_index: usize,
2019        start: usize,
2020        end: usize,
2021    ) -> Result<Vec<RenderPage>, RenderEngineError> {
2022        self.page_range(book, chapter_index, start..end)
2023    }
2024
2025    /// Alias for chapter page range rendering.
2026    pub fn page_range<R: std::io::Read + std::io::Seek>(
2027        &self,
2028        book: &mut EpubBook<R>,
2029        chapter_index: usize,
2030        range: PageRange,
2031    ) -> Result<Vec<RenderPage>, RenderEngineError> {
2032        if range.start >= range.end {
2033            return Ok(Vec::with_capacity(0));
2034        }
2035        self.prepare_chapter_with_config_collect(
2036            book,
2037            chapter_index,
2038            RenderConfig::default().with_page_range(range),
2039        )
2040    }
2041
2042    /// Prepare and layout a chapter and return pages as an iterator.
2043    ///
2044    /// This iterator is eager: pages are prepared first, then iterated.
2045    pub fn prepare_chapter_iter<R: std::io::Read + std::io::Seek>(
2046        &self,
2047        book: &mut EpubBook<R>,
2048        chapter_index: usize,
2049    ) -> Result<RenderPageIter, RenderEngineError> {
2050        let pages = self.prepare_chapter(book, chapter_index)?;
2051        Ok(RenderPageIter {
2052            inner: pages.into_iter(),
2053        })
2054    }
2055
2056    /// Prepare and layout a chapter as a streaming iterator.
2057    ///
2058    /// Unlike `prepare_chapter_iter`, this method streams pages incrementally from a
2059    /// worker thread using a bounded channel (`capacity=1`) for backpressure.
2060    /// It requires ownership of the book so the worker can read resources directly.
2061    pub fn prepare_chapter_iter_streaming<R>(
2062        &self,
2063        mut book: EpubBook<R>,
2064        chapter_index: usize,
2065    ) -> RenderPageStreamIter
2066    where
2067        R: std::io::Read + std::io::Seek + Send + 'static,
2068    {
2069        let (tx, rx) = sync_channel(1);
2070        let engine = self.clone();
2071
2072        std::thread::spawn(move || {
2073            let mut receiver_closed = false;
2074            let result = engine.prepare_chapter_with(&mut book, chapter_index, |page| {
2075                if receiver_closed {
2076                    return;
2077                }
2078                if tx.send(StreamMessage::Page(page)).is_err() {
2079                    receiver_closed = true;
2080                }
2081            });
2082
2083            if receiver_closed {
2084                return;
2085            }
2086            match result {
2087                Ok(()) => {
2088                    let _ = tx.send(StreamMessage::Done);
2089                }
2090                Err(err) => {
2091                    let _ = tx.send(StreamMessage::Error(err));
2092                }
2093            }
2094        });
2095
2096        RenderPageStreamIter {
2097            rx,
2098            finished: false,
2099        }
2100    }
2101
2102    /// Prepare with an overlay composer that maps page metrics into overlay items.
2103    pub fn prepare_chapter_with_overlay_composer<R, O, F>(
2104        &self,
2105        book: &mut EpubBook<R>,
2106        chapter_index: usize,
2107        viewport: OverlaySize,
2108        composer: &O,
2109        mut on_page: F,
2110    ) -> Result<(), RenderEngineError>
2111    where
2112        R: std::io::Read + std::io::Seek,
2113        O: crate::render_ir::OverlayComposer,
2114        F: FnMut(RenderPage),
2115    {
2116        self.prepare_chapter_with(book, chapter_index, |mut page| {
2117            let overlays = composer.compose(&page.metrics, viewport);
2118            for item in overlays {
2119                page.overlay_items.push(item.clone());
2120                if let OverlayContent::Command(cmd) = item.content {
2121                    page.push_overlay_command(cmd);
2122                }
2123            }
2124            page.sync_commands();
2125            on_page(page);
2126        })
2127    }
2128}
2129
2130/// Incremental wrapper session returned by `RenderEngine::begin`.
2131pub struct LayoutSession<'a> {
2132    engine: &'a RenderEngine,
2133    chapter_index: usize,
2134    profile: PaginationProfileId,
2135    cfg: RenderConfig<'a>,
2136    inner: Option<CoreLayoutSession>,
2137    pending_pages: VecDeque<RenderPage>,
2138    rendered_pages: Vec<RenderPage>,
2139    page_index: usize,
2140    completed: bool,
2141}
2142
2143impl LayoutSession<'_> {
2144    /// Set the hyphenation language hint (e.g. "en", "en-US").
2145    pub fn set_hyphenation_language(&mut self, language_tag: &str) {
2146        if let Some(inner) = self.inner.as_mut() {
2147            inner.set_hyphenation_language(language_tag);
2148        }
2149    }
2150
2151    /// Push one styled item through layout and enqueue closed pages.
2152    pub fn push(&mut self, item: StyledEventOrRun) -> Result<(), RenderEngineError> {
2153        if self.completed {
2154            return Ok(());
2155        }
2156        if self.cfg.cancel.is_some_and(|cancel| cancel.is_cancelled()) {
2157            self.engine.emit_diagnostic(RenderDiagnostic::Cancelled);
2158            return Err(RenderEngineError::Cancelled);
2159        }
2160        if let Some(inner) = self.inner.as_mut() {
2161            let chapter = self.chapter_index;
2162            let range = normalize_page_range(self.cfg.page_range.clone());
2163            let rendered = &mut self.rendered_pages;
2164            let pending = &mut self.pending_pages;
2165            let page_index = &mut self.page_index;
2166            let capture_for_cache = self.cfg.cache.is_some();
2167            inner.push_item_with_pages(item, &mut |mut page| {
2168                RenderEngine::annotate_page_for_chapter(&mut page, chapter);
2169                if capture_for_cache {
2170                    rendered.push(page.clone());
2171                }
2172                if page_in_range(*page_index, &range) {
2173                    pending.push_back(page);
2174                }
2175                *page_index += 1;
2176            });
2177        }
2178        Ok(())
2179    }
2180
2181    /// Drain currently available pages in FIFO order.
2182    pub fn drain_pages<F>(&mut self, mut on_page: F)
2183    where
2184        F: FnMut(RenderPage),
2185    {
2186        while let Some(page) = self.pending_pages.pop_front() {
2187            on_page(page);
2188        }
2189    }
2190
2191    /// Finish layout and enqueue any remaining pages.
2192    pub fn finish(&mut self) -> Result<(), RenderEngineError> {
2193        if self.completed {
2194            return Ok(());
2195        }
2196        if self.cfg.cancel.is_some_and(|cancel| cancel.is_cancelled()) {
2197            self.engine.emit_diagnostic(RenderDiagnostic::Cancelled);
2198            return Err(RenderEngineError::Cancelled);
2199        }
2200        if let Some(inner) = self.inner.as_mut() {
2201            let chapter = self.chapter_index;
2202            let range = normalize_page_range(self.cfg.page_range.clone());
2203            let rendered = &mut self.rendered_pages;
2204            let pending = &mut self.pending_pages;
2205            let page_index = &mut self.page_index;
2206            let capture_for_cache = self.cfg.cache.is_some();
2207            inner.finish(&mut |mut page| {
2208                RenderEngine::annotate_page_for_chapter(&mut page, chapter);
2209                if capture_for_cache {
2210                    rendered.push(page.clone());
2211                }
2212                if page_in_range(*page_index, &range) {
2213                    pending.push_back(page);
2214                }
2215                *page_index += 1;
2216            });
2217        }
2218        let chapter_total = self.page_index.max(1);
2219        for page in self.pending_pages.iter_mut() {
2220            RenderEngine::annotate_page_metrics(page, chapter_total);
2221        }
2222        for page in self.rendered_pages.iter_mut() {
2223            RenderEngine::annotate_page_metrics(page, chapter_total);
2224        }
2225        if let Some(cache) = self.cfg.cache {
2226            if !self.rendered_pages.is_empty() {
2227                cache.store_chapter_pages(self.profile, self.chapter_index, &self.rendered_pages);
2228            }
2229        }
2230        self.completed = true;
2231        Ok(())
2232    }
2233
2234    fn is_complete(&self) -> bool {
2235        self.completed
2236    }
2237}
2238
2239fn normalize_page_range(range: Option<PageRange>) -> Option<PageRange> {
2240    match range {
2241        Some(r) if r.start < r.end => Some(r),
2242        Some(_) => Some(0..0),
2243        None => None,
2244    }
2245}
2246
2247fn page_in_range(idx: usize, range: &Option<PageRange>) -> bool {
2248    range.as_ref().map(|r| r.contains(&idx)).unwrap_or(true)
2249}
2250
2251fn forced_font_policy(family: &str) -> FontPolicy {
2252    let mut policy = FontPolicy::serif_default();
2253    let normalized = family
2254        .split(',')
2255        .next()
2256        .map(str::trim)
2257        .map(|part| part.trim_matches('"').trim_matches('\''))
2258        .filter(|part| !part.is_empty())
2259        .unwrap_or("serif");
2260    policy.preferred_families = vec![normalized.to_string()];
2261    policy.default_family = normalized.to_string();
2262    policy.allow_embedded_fonts = false;
2263    policy
2264}
2265
2266/// Stable page iterator wrapper returned by `RenderEngine::prepare_chapter_iter`.
2267#[derive(Debug)]
2268pub struct RenderPageIter {
2269    inner: std::vec::IntoIter<RenderPage>,
2270}
2271
2272impl Iterator for RenderPageIter {
2273    type Item = RenderPage;
2274
2275    fn next(&mut self) -> Option<Self::Item> {
2276        self.inner.next()
2277    }
2278
2279    fn size_hint(&self) -> (usize, Option<usize>) {
2280        self.inner.size_hint()
2281    }
2282}
2283
2284impl ExactSizeIterator for RenderPageIter {
2285    fn len(&self) -> usize {
2286        self.inner.len()
2287    }
2288}
2289
2290impl std::iter::FusedIterator for RenderPageIter {}
2291
2292enum StreamMessage {
2293    Page(RenderPage),
2294    Error(RenderEngineError),
2295    Done,
2296}
2297
2298/// Streaming page iterator produced by `RenderEngine::prepare_chapter_iter_streaming`.
2299#[derive(Debug)]
2300pub struct RenderPageStreamIter {
2301    rx: Receiver<StreamMessage>,
2302    finished: bool,
2303}
2304
2305impl Iterator for RenderPageStreamIter {
2306    type Item = Result<RenderPage, RenderEngineError>;
2307
2308    fn next(&mut self) -> Option<Self::Item> {
2309        if self.finished {
2310            return None;
2311        }
2312        match self.rx.recv() {
2313            Ok(StreamMessage::Page(page)) => Some(Ok(page)),
2314            Ok(StreamMessage::Error(err)) => {
2315                self.finished = true;
2316                Some(Err(err))
2317            }
2318            Ok(StreamMessage::Done) | Err(_) => {
2319                self.finished = true;
2320                None
2321            }
2322        }
2323    }
2324}
2325
2326/// Render engine error.
2327#[derive(Debug)]
2328pub enum RenderEngineError {
2329    /// Render prep failed.
2330    Prep(RenderPrepError),
2331    /// Layout run was cancelled.
2332    Cancelled,
2333    /// Render page collection exceeded configured memory limits.
2334    LimitExceeded {
2335        kind: &'static str,
2336        actual: usize,
2337        limit: usize,
2338    },
2339}
2340
2341impl core::fmt::Display for RenderEngineError {
2342    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2343        match self {
2344            Self::Prep(err) => write!(f, "render prep failed: {}", err),
2345            Self::Cancelled => write!(f, "render cancelled"),
2346            Self::LimitExceeded {
2347                kind,
2348                actual,
2349                limit,
2350            } => write!(
2351                f,
2352                "render memory limit exceeded: {} (actual={} limit={})",
2353                kind, actual, limit
2354            ),
2355        }
2356    }
2357}
2358
2359impl std::error::Error for RenderEngineError {}
2360
2361impl From<RenderPrepError> for RenderEngineError {
2362    fn from(value: RenderPrepError) -> Self {
2363        Self::Prep(value)
2364    }
2365}
2366
2367#[cfg(test)]
2368mod tests {
2369    use super::*;
2370    use crate::render_ir::{
2371        DrawCommand, ImageObjectCommand, JustifyMode, OverlayItem, OverlayRect, OverlaySlot,
2372        PageAnnotation, PageChromeCommand, PageChromeKind, RectCommand, ResolvedTextStyle,
2373        RuleCommand, TextCommand,
2374    };
2375    use epub_stream::{BlockRole, ChapterRef, ComputedTextStyle, StyledEvent, StyledRun};
2376    use std::fs;
2377    use std::path::PathBuf;
2378
2379    fn body_run(text: &str) -> StyledEventOrRun {
2380        StyledEventOrRun::Run(StyledRun {
2381            text: text.to_string(),
2382            style: ComputedTextStyle {
2383                family_stack: vec!["serif".to_string()],
2384                weight: 400,
2385                italic: false,
2386                size_px: 16.0,
2387                line_height: 1.4,
2388                letter_spacing: 0.0,
2389                block_role: BlockRole::Body,
2390            },
2391            font_id: 0,
2392            resolved_family: "serif".to_string(),
2393        })
2394    }
2395
2396    fn cache_fixture_page(page_number: usize, chapter_page_count: usize) -> RenderPage {
2397        let mut page = RenderPage::new(page_number);
2398        page.push_content_command(DrawCommand::Text(TextCommand {
2399            x: 12,
2400            baseline_y: 24,
2401            text: format!("page-{page_number}"),
2402            font_id: Some(3),
2403            style: ResolvedTextStyle {
2404                font_id: Some(3),
2405                family: "serif".to_string(),
2406                weight: 400,
2407                italic: false,
2408                size_px: 16.0,
2409                line_height: 1.4,
2410                letter_spacing: 0.0,
2411                role: BlockRole::Body,
2412                justify_mode: JustifyMode::InterWord { extra_px_total: 6 },
2413            },
2414        }));
2415        page.push_content_command(DrawCommand::Rule(RuleCommand {
2416            x: 10,
2417            y: 28,
2418            length: 100,
2419            thickness: 1,
2420            horizontal: true,
2421        }));
2422        page.push_content_command(DrawCommand::ImageObject(ImageObjectCommand {
2423            src: "images/pic.png".to_string(),
2424            alt: "diagram".to_string(),
2425            x: 10,
2426            y: 40,
2427            width: 64,
2428            height: 48,
2429        }));
2430        page.push_chrome_command(DrawCommand::PageChrome(PageChromeCommand {
2431            kind: PageChromeKind::Footer,
2432            text: Some(format!("{page_number}/{chapter_page_count}")),
2433            current: Some(page_number),
2434            total: Some(chapter_page_count),
2435        }));
2436        page.push_overlay_command(DrawCommand::Rect(RectCommand {
2437            x: 6,
2438            y: 6,
2439            width: 18,
2440            height: 8,
2441            fill: true,
2442        }));
2443        page.overlay_items.push(OverlayItem {
2444            slot: OverlaySlot::TopRight,
2445            z: 1,
2446            content: OverlayContent::Text("bookmark".to_string()),
2447        });
2448        page.overlay_items.push(OverlayItem {
2449            slot: OverlaySlot::Custom(OverlayRect {
2450                x: 4,
2451                y: 5,
2452                width: 10,
2453                height: 11,
2454            }),
2455            z: 2,
2456            content: OverlayContent::Command(DrawCommand::Rule(RuleCommand {
2457                x: 2,
2458                y: 3,
2459                length: 12,
2460                thickness: 1,
2461                horizontal: false,
2462            })),
2463        });
2464        page.annotations.push(PageAnnotation {
2465            kind: "note".to_string(),
2466            value: Some(format!("a{page_number}")),
2467        });
2468        page.metrics.chapter_index = 4;
2469        page.metrics.chapter_page_index = page_number.saturating_sub(1);
2470        page.metrics.chapter_page_count = Some(chapter_page_count);
2471        page.metrics.global_page_index = Some(page_number.saturating_sub(1));
2472        page.metrics.global_page_count_estimate = Some(chapter_page_count);
2473        page.metrics.progress_chapter = if chapter_page_count <= 1 {
2474            1.0
2475        } else {
2476            page.metrics.chapter_page_index as f32 / (chapter_page_count - 1) as f32
2477        };
2478        page.metrics.progress_book = Some(page.metrics.progress_chapter);
2479        page.sync_commands();
2480        page
2481    }
2482
2483    fn progress_pages(count: usize) -> Vec<RenderPage> {
2484        let mut pages = Vec::with_capacity(count);
2485        for idx in 0..count {
2486            let mut page = RenderPage::new(idx + 1);
2487            page.metrics.chapter_page_index = idx;
2488            page.metrics.chapter_page_count = Some(count);
2489            page.metrics.progress_chapter = if count <= 1 {
2490                1.0
2491            } else {
2492                idx as f32 / (count - 1) as f32
2493            };
2494            page.metrics.progress_book = Some(page.metrics.progress_chapter);
2495            pages.push(page);
2496        }
2497        pages
2498    }
2499
2500    fn temp_cache_root(label: &str) -> PathBuf {
2501        let nonce = CACHE_WRITE_NONCE.fetch_add(1, Ordering::Relaxed);
2502        std::env::temp_dir().join(format!(
2503            "epub-stream-render-{label}-{}-{nonce}",
2504            std::process::id()
2505        ))
2506    }
2507
2508    fn sample_chapters() -> Vec<ChapterRef> {
2509        vec![
2510            ChapterRef {
2511                index: 0,
2512                idref: "c0".to_string(),
2513                href: "text/ch0.xhtml".to_string(),
2514                media_type: "application/xhtml+xml".to_string(),
2515            },
2516            ChapterRef {
2517                index: 1,
2518                idref: "c1".to_string(),
2519                href: "text/ch1.xhtml".to_string(),
2520                media_type: "application/xhtml+xml".to_string(),
2521            },
2522        ]
2523    }
2524
2525    #[test]
2526    fn begin_push_and_drain_pages_streams_incrementally() {
2527        let mut opts = RenderEngineOptions::for_display(300, 120);
2528        opts.layout.margin_top = 8;
2529        opts.layout.margin_bottom = 8;
2530        let engine = RenderEngine::new(opts);
2531
2532        let mut items = Vec::new();
2533        for _ in 0..40 {
2534            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphStart));
2535            items.push(body_run("one two three four five six seven eight nine ten"));
2536            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphEnd));
2537        }
2538
2539        let mut session = engine.begin(3, RenderConfig::default());
2540        let mut streamed = Vec::new();
2541        for item in &items {
2542            session.push(item.clone()).expect("push should pass");
2543            session.drain_pages(|page| streamed.push(page));
2544        }
2545        session.finish().expect("finish should pass");
2546        session.drain_pages(|page| streamed.push(page));
2547
2548        let mut expected = engine.layout.layout_items(items);
2549        for page in &mut expected {
2550            page.metrics.chapter_index = 3;
2551        }
2552        assert_eq!(streamed, expected);
2553        assert!(streamed.iter().all(|page| page.metrics.chapter_index == 3));
2554    }
2555
2556    #[test]
2557    fn forced_font_family_config_disables_embedded_fonts() {
2558        let cfg = RenderConfig::default().with_forced_font_family("  monospace ");
2559        assert_eq!(cfg.forced_font_family.as_deref(), Some("monospace"));
2560        assert!(!cfg.embedded_fonts);
2561    }
2562
2563    #[test]
2564    fn empty_forced_font_family_is_ignored() {
2565        let cfg = RenderConfig::default().with_forced_font_family("   ");
2566        assert!(cfg.forced_font_family.is_none());
2567        assert!(cfg.embedded_fonts);
2568    }
2569
2570    #[test]
2571    fn forced_font_policy_uses_first_family_entry() {
2572        let policy = forced_font_policy("Alegreya, serif");
2573        assert_eq!(policy.default_family, "Alegreya");
2574        assert_eq!(policy.preferred_families, vec!["Alegreya".to_string()]);
2575        assert!(!policy.allow_embedded_fonts);
2576    }
2577
2578    #[test]
2579    fn cache_roundtrip_load_store() {
2580        let root = temp_cache_root("cache-roundtrip");
2581        let store = FileRenderCacheStore::new(&root).with_max_file_bytes(256 * 1024);
2582        let profile = PaginationProfileId::from_bytes(b"profile-a");
2583        let chapter_index = 9;
2584        let pages = vec![cache_fixture_page(1, 2), cache_fixture_page(2, 2)];
2585
2586        store.store_chapter_pages(profile, chapter_index, &pages);
2587        let cache_path = store.chapter_cache_path(profile, chapter_index);
2588        assert!(cache_path.exists());
2589
2590        let loaded = store.load_chapter_pages(profile, chapter_index);
2591        assert_eq!(loaded, Some(pages.clone()));
2592
2593        let tiny_cap = FileRenderCacheStore::new(&root).with_max_file_bytes(48);
2594        tiny_cap.store_chapter_pages(profile, chapter_index + 1, &pages);
2595        assert!(tiny_cap
2596            .load_chapter_pages(profile, chapter_index + 1)
2597            .is_none());
2598
2599        let _ = fs::remove_dir_all(root);
2600    }
2601
2602    #[test]
2603    fn remap_helpers_monotonicity_and_bounds() {
2604        let old_pages = progress_pages(7);
2605        let new_pages = progress_pages(11);
2606
2607        let mut prev = 0usize;
2608        for old_idx in 0..old_pages.len() {
2609            let mapped = remap_page_index_by_chapter_progress(&old_pages, old_idx, &new_pages)
2610                .expect("new pages should resolve");
2611            assert!(mapped < new_pages.len());
2612            assert!(mapped >= prev);
2613            prev = mapped;
2614        }
2615
2616        assert_eq!(
2617            remap_page_index_by_chapter_progress(&old_pages, usize::MAX, &new_pages),
2618            Some(new_pages.len() - 1)
2619        );
2620
2621        let mut prev_resolved = 0usize;
2622        for step in 0..=50 {
2623            let progress = step as f32 / 50.0;
2624            let mapped = resolve_page_index_for_chapter_progress(progress, &new_pages)
2625                .expect("new pages should resolve");
2626            assert!(mapped < new_pages.len());
2627            assert!(mapped >= prev_resolved);
2628            prev_resolved = mapped;
2629        }
2630
2631        assert_eq!(
2632            resolve_page_index_for_chapter_progress(-10.0, &new_pages),
2633            Some(0)
2634        );
2635        assert_eq!(
2636            resolve_page_index_for_chapter_progress(10.0, &new_pages),
2637            Some(new_pages.len() - 1)
2638        );
2639        assert_eq!(
2640            resolve_page_index_for_chapter_progress(f32::NAN, &new_pages),
2641            Some(0)
2642        );
2643        assert_eq!(resolve_page_index_for_chapter_progress(0.5, &[]), None);
2644    }
2645
2646    #[test]
2647    fn resolve_href_with_fragment_progress_maps_anchor_inside_chapter() {
2648        let map = RenderBookPageMap::from_chapter_page_counts(&sample_chapters(), &[3, 5]);
2649        let target = map
2650            .resolve_href_with_fragment_progress("text/ch1.xhtml#intro", Some(0.5))
2651            .expect("target should resolve");
2652        assert_eq!(target.chapter_index, 1);
2653        assert_eq!(target.page_index, 5);
2654        assert_eq!(target.kind, RenderLocatorTargetKind::FragmentAnchor);
2655        assert_eq!(target.fragment.as_deref(), Some("intro"));
2656
2657        let fallback = map
2658            .resolve_href("text/ch1.xhtml#intro")
2659            .expect("fallback should resolve");
2660        assert_eq!(fallback.page_index, 3);
2661        assert_eq!(
2662            fallback.kind,
2663            RenderLocatorTargetKind::FragmentFallbackChapterStart
2664        );
2665    }
2666
2667    #[test]
2668    fn estimate_fragment_progress_in_html_matches_id_and_name_patterns() {
2669        let html = br#"
2670            <html><body>
2671                <p>intro text</p>
2672                <h2 id="middle">Middle</h2>
2673                <a name='end-anchor'>End</a>
2674            </body></html>
2675        "#;
2676        let middle = estimate_fragment_progress_in_html(html, "middle")
2677            .expect("middle anchor should resolve");
2678        let end = estimate_fragment_progress_in_html(html, "end-anchor")
2679            .expect("end anchor should resolve");
2680        assert!(middle > 0.0);
2681        assert!(end > middle);
2682        assert!(estimate_fragment_progress_in_html(html, "missing").is_none());
2683        assert!(estimate_fragment_progress_in_html(&[], "middle").is_none());
2684        assert!(estimate_fragment_progress_in_html(html, "").is_none());
2685    }
2686}