Skip to main content

epub_stream/
render_prep.rs

1//! Stream-first style/font preparation APIs for rendering pipelines.
2
3extern crate alloc;
4
5use alloc::boxed::Box;
6use alloc::format;
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use core::cmp::min;
10use core::fmt;
11use quick_xml::events::Event;
12use quick_xml::reader::Reader;
13use std::collections::{BTreeMap, BTreeSet};
14
15use crate::book::EpubBook;
16use crate::css::{
17    parse_inline_style, parse_stylesheet, CssStyle, FontSize, FontStyle, FontWeight, LineHeight,
18    Stylesheet,
19};
20use crate::error::{EpubError, ErrorLimitContext, ErrorPhase, PhaseError, PhaseErrorContext};
21
22/// Limits for stylesheet parsing and application.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct StyleLimits {
25    /// Maximum number of stylesheet rules to process.
26    pub max_selectors: usize,
27    /// Maximum bytes read for any individual stylesheet.
28    pub max_css_bytes: usize,
29    /// Maximum supported list nesting depth (reserved for downstream layout usage).
30    pub max_nesting: usize,
31}
32
33impl Default for StyleLimits {
34    fn default() -> Self {
35        Self {
36            max_selectors: 4096,
37            max_css_bytes: 512 * 1024,
38            max_nesting: 32,
39        }
40    }
41}
42
43/// Limits for embedded font enumeration and registration.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct FontLimits {
46    /// Maximum number of font faces accepted.
47    pub max_faces: usize,
48    /// Maximum bytes for any one font file.
49    pub max_bytes_per_font: usize,
50    /// Maximum total bytes across all registered font files.
51    pub max_total_font_bytes: usize,
52}
53
54impl Default for FontLimits {
55    fn default() -> Self {
56        Self {
57            max_faces: 64,
58            max_bytes_per_font: 8 * 1024 * 1024,
59            max_total_font_bytes: 64 * 1024 * 1024,
60        }
61    }
62}
63
64/// Safe layout hint clamps for text style normalization.
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct LayoutHints {
67    /// Default base font size in pixels.
68    pub base_font_size_px: f32,
69    /// Lower clamp for effective font size.
70    pub min_font_size_px: f32,
71    /// Upper clamp for effective font size.
72    pub max_font_size_px: f32,
73    /// Lower clamp for effective line-height multiplier.
74    pub min_line_height: f32,
75    /// Upper clamp for effective line-height multiplier.
76    pub max_line_height: f32,
77    /// Global text scale multiplier applied after CSS size resolution.
78    ///
79    /// This lets reader UIs scale books even when EPUB CSS uses fixed px sizes.
80    pub text_scale: f32,
81}
82
83impl Default for LayoutHints {
84    fn default() -> Self {
85        Self {
86            base_font_size_px: 16.0,
87            min_font_size_px: 10.0,
88            max_font_size_px: 42.0,
89            min_line_height: 1.1,
90            max_line_height: 2.2,
91            text_scale: 1.0,
92        }
93    }
94}
95
96/// Style engine options.
97#[derive(Clone, Copy, Debug, Default, PartialEq)]
98pub struct StyleConfig {
99    /// Hard parsing limits.
100    pub limits: StyleLimits,
101    /// Normalization and clamp hints.
102    pub hints: LayoutHints,
103}
104
105/// Render-prep orchestration options.
106#[derive(Clone, Copy, Debug, Default, PartialEq)]
107pub struct RenderPrepOptions {
108    /// Stylesheet parsing and resolution options.
109    pub style: StyleConfig,
110    /// Font registration limits.
111    pub fonts: FontLimits,
112    /// Final style normalization hints.
113    pub layout_hints: LayoutHints,
114    /// Hard memory/resource budgets.
115    pub memory: MemoryBudget,
116}
117
118/// Hard memory/resource budgets for open/parse/style/layout/render paths.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub struct MemoryBudget {
121    /// Max bytes allowed for a single heavy entry read (e.g. chapter XHTML).
122    pub max_entry_bytes: usize,
123    /// Max bytes allowed for a stylesheet payload.
124    pub max_css_bytes: usize,
125    /// Max bytes allowed for a navigation document payload.
126    pub max_nav_bytes: usize,
127    /// Max bytes allowed for a single inline `style="..."` attribute payload.
128    pub max_inline_style_bytes: usize,
129    /// Max page objects allowed in memory for eager consumers.
130    pub max_pages_in_memory: usize,
131}
132
133impl Default for MemoryBudget {
134    fn default() -> Self {
135        Self {
136            max_entry_bytes: 4 * 1024 * 1024,
137            max_css_bytes: 512 * 1024,
138            max_nav_bytes: 512 * 1024,
139            max_inline_style_bytes: 16 * 1024,
140            max_pages_in_memory: 128,
141        }
142    }
143}
144
145/// Structured error for style/font preparation operations.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct RenderPrepError {
148    /// Processing phase where this error originated.
149    pub phase: ErrorPhase,
150    /// Stable machine-readable code.
151    pub code: &'static str,
152    /// Human-readable message.
153    pub message: Box<str>,
154    /// Optional archive path context.
155    pub path: Option<Box<str>>,
156    /// Optional chapter index context.
157    pub chapter_index: Option<usize>,
158    /// Optional typed actual-vs-limit context.
159    pub limit: Option<Box<ErrorLimitContext>>,
160    /// Optional additional context.
161    pub context: Option<Box<RenderPrepErrorContext>>,
162}
163
164/// Extended optional context for render-prep errors.
165#[derive(Clone, Debug, Default, PartialEq, Eq)]
166pub struct RenderPrepErrorContext {
167    /// Optional source context (stylesheet href, inline style location, tokenizer phase).
168    pub source: Option<Box<str>>,
169    /// Optional selector context.
170    pub selector: Option<Box<str>>,
171    /// Optional selector index for structured consumers.
172    pub selector_index: Option<usize>,
173    /// Optional declaration context.
174    pub declaration: Option<Box<str>>,
175    /// Optional declaration index for structured consumers.
176    pub declaration_index: Option<usize>,
177    /// Optional tokenizer/read offset in bytes.
178    pub token_offset: Option<usize>,
179}
180
181impl RenderPrepError {
182    fn new_with_phase(phase: ErrorPhase, code: &'static str, message: impl Into<String>) -> Self {
183        Self {
184            phase,
185            code,
186            message: message.into().into_boxed_str(),
187            path: None,
188            chapter_index: None,
189            limit: None,
190            context: None,
191        }
192    }
193
194    fn new(code: &'static str, message: impl Into<String>) -> Self {
195        Self::new_with_phase(ErrorPhase::Style, code, message)
196    }
197
198    fn with_path(mut self, path: impl Into<String>) -> Self {
199        self.path = Some(path.into().into_boxed_str());
200        self
201    }
202
203    fn with_phase(mut self, phase: ErrorPhase) -> Self {
204        self.phase = phase;
205        self
206    }
207
208    fn with_source(mut self, source: impl Into<String>) -> Self {
209        let ctx = self
210            .context
211            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
212        ctx.source = Some(source.into().into_boxed_str());
213        self
214    }
215
216    fn with_chapter_index(mut self, chapter_index: usize) -> Self {
217        self.chapter_index = Some(chapter_index);
218        self
219    }
220
221    fn with_limit(mut self, kind: &'static str, actual: usize, limit: usize) -> Self {
222        self.limit = Some(Box::new(ErrorLimitContext::new(kind, actual, limit)));
223        self
224    }
225
226    fn with_selector(mut self, selector: impl Into<String>) -> Self {
227        let ctx = self
228            .context
229            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
230        ctx.selector = Some(selector.into().into_boxed_str());
231        self
232    }
233
234    fn with_selector_index(mut self, selector_index: usize) -> Self {
235        let ctx = self
236            .context
237            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
238        ctx.selector_index = Some(selector_index);
239        self
240    }
241
242    fn with_declaration(mut self, declaration: impl Into<String>) -> Self {
243        let ctx = self
244            .context
245            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
246        ctx.declaration = Some(declaration.into().into_boxed_str());
247        self
248    }
249
250    fn with_declaration_index(mut self, declaration_index: usize) -> Self {
251        let ctx = self
252            .context
253            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
254        ctx.declaration_index = Some(declaration_index);
255        self
256    }
257
258    fn with_token_offset(mut self, token_offset: usize) -> Self {
259        let ctx = self
260            .context
261            .get_or_insert_with(|| Box::new(RenderPrepErrorContext::default()));
262        ctx.token_offset = Some(token_offset);
263        self
264    }
265}
266
267impl fmt::Display for RenderPrepError {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        write!(f, "{}:{}: {}", self.phase, self.code, self.message)?;
270        if let Some(path) = self.path.as_deref() {
271            write!(f, " [path={}]", path)?;
272        }
273        if let Some(chapter_index) = self.chapter_index {
274            write!(f, " [chapter_index={}]", chapter_index)?;
275        }
276        if let Some(limit) = self.limit.as_deref() {
277            write!(
278                f,
279                " [limit_kind={} actual={} limit={}]",
280                limit.kind, limit.actual, limit.limit
281            )?;
282        }
283        if let Some(ctx) = &self.context {
284            if let Some(source) = ctx.source.as_deref() {
285                write!(f, " [source={}]", source)?;
286            }
287            if let Some(selector) = ctx.selector.as_deref() {
288                write!(f, " [selector={}]", selector)?;
289            }
290            if let Some(selector_index) = ctx.selector_index {
291                write!(f, " [selector_index={}]", selector_index)?;
292            }
293            if let Some(declaration) = ctx.declaration.as_deref() {
294                write!(f, " [declaration={}]", declaration)?;
295            }
296            if let Some(declaration_index) = ctx.declaration_index {
297                write!(f, " [declaration_index={}]", declaration_index)?;
298            }
299            if let Some(token_offset) = ctx.token_offset {
300                write!(f, " [token_offset={}]", token_offset)?;
301            }
302        }
303        Ok(())
304    }
305}
306
307impl std::error::Error for RenderPrepError {}
308
309impl From<RenderPrepError> for PhaseError {
310    fn from(err: RenderPrepError) -> Self {
311        let mut ctx = PhaseErrorContext {
312            path: err.path.clone(),
313            href: err.path.clone(),
314            chapter_index: err.chapter_index,
315            source: None,
316            selector: None,
317            selector_index: None,
318            declaration: None,
319            declaration_index: None,
320            token_offset: None,
321            limit: err.limit.clone(),
322        };
323
324        if let Some(extra) = &err.context {
325            ctx.source = extra.source.clone();
326            ctx.selector = extra.selector.clone();
327            ctx.selector_index = extra.selector_index;
328            ctx.declaration = extra.declaration.clone();
329            ctx.declaration_index = extra.declaration_index;
330            ctx.token_offset = extra.token_offset;
331        }
332
333        PhaseError {
334            phase: err.phase,
335            code: err.code,
336            message: err.message,
337            context: Some(Box::new(ctx)),
338        }
339    }
340}
341
342impl From<RenderPrepError> for EpubError {
343    fn from(err: RenderPrepError) -> Self {
344        EpubError::Phase(err.into())
345    }
346}
347
348/// Source stylesheet payload in chapter cascade order.
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub struct StylesheetSource {
351    /// Archive path or inline marker for this stylesheet.
352    pub href: String,
353    /// Raw CSS bytes decoded as UTF-8.
354    pub css: String,
355}
356
357/// Collection of resolved stylesheet sources.
358#[derive(Clone, Debug, Default, PartialEq, Eq)]
359pub struct ChapterStylesheets {
360    /// Sources in cascade order.
361    pub sources: Vec<StylesheetSource>,
362}
363
364impl ChapterStylesheets {
365    /// Iterate all stylesheet sources.
366    pub fn iter(&self) -> impl Iterator<Item = &StylesheetSource> {
367        self.sources.iter()
368    }
369}
370
371/// Font style descriptor for `@font-face` metadata.
372#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373pub enum EmbeddedFontStyle {
374    /// Upright style.
375    Normal,
376    /// Italic style.
377    Italic,
378    /// Oblique style.
379    Oblique,
380}
381
382/// Embedded font face metadata extracted from EPUB CSS.
383#[derive(Clone, Debug, PartialEq, Eq)]
384pub struct EmbeddedFontFace {
385    /// Requested font family from `@font-face`.
386    pub family: String,
387    /// Numeric weight (e.g. 400, 700).
388    pub weight: u16,
389    /// Style variant.
390    pub style: EmbeddedFontStyle,
391    /// Optional stretch descriptor.
392    pub stretch: Option<String>,
393    /// OPF-relative href to font resource.
394    pub href: String,
395    /// Optional format hint from `format(...)`.
396    pub format: Option<String>,
397}
398
399/// Semantic block role for computed styles.
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub enum BlockRole {
402    /// Body text.
403    Body,
404    /// Paragraph block.
405    Paragraph,
406    /// Heading block by level.
407    Heading(u8),
408    /// List item block.
409    ListItem,
410    /// Figure caption block.
411    FigureCaption,
412}
413
414/// Cascaded and normalized text style for rendering.
415#[derive(Clone, Debug, PartialEq)]
416pub struct ComputedTextStyle {
417    /// Ordered family preference stack.
418    pub family_stack: Vec<String>,
419    /// Numeric weight.
420    pub weight: u16,
421    /// Italic toggle.
422    pub italic: bool,
423    /// Effective font size in pixels.
424    pub size_px: f32,
425    /// Effective line-height multiplier.
426    pub line_height: f32,
427    /// Effective letter spacing in pixels.
428    pub letter_spacing: f32,
429    /// Semantic block role.
430    pub block_role: BlockRole,
431}
432
433/// Styled text run.
434#[derive(Clone, Debug, PartialEq)]
435pub struct StyledRun {
436    /// Run text payload.
437    pub text: String,
438    /// Computed style for this run.
439    pub style: ComputedTextStyle,
440    /// Stable resolved font identity (0 means policy fallback).
441    pub font_id: u32,
442    /// Resolved family selected by the font resolver.
443    pub resolved_family: String,
444}
445
446/// Styled inline image payload.
447#[derive(Clone, Debug, PartialEq, Eq)]
448pub struct StyledImage {
449    /// OPF-relative image href.
450    pub src: String,
451    /// Optional caption/alt text.
452    pub alt: String,
453    /// Optional intrinsic width hint in CSS px.
454    pub width_px: Option<u16>,
455    /// Optional intrinsic height hint in CSS px.
456    pub height_px: Option<u16>,
457    /// Whether image appears inside a `<figure>` container.
458    pub in_figure: bool,
459}
460
461/// Structured block/layout events.
462#[derive(Clone, Copy, Debug, PartialEq, Eq)]
463pub enum StyledEvent {
464    /// Paragraph starts.
465    ParagraphStart,
466    /// Paragraph ends.
467    ParagraphEnd,
468    /// Heading starts.
469    HeadingStart(u8),
470    /// Heading ends.
471    HeadingEnd(u8),
472    /// List item starts.
473    ListItemStart,
474    /// List item ends.
475    ListItemEnd,
476    /// Explicit line break.
477    LineBreak,
478}
479
480/// Stream item for styled output.
481#[derive(Clone, Debug, PartialEq)]
482pub enum StyledEventOrRun {
483    /// Structural event.
484    Event(StyledEvent),
485    /// Styled text run.
486    Run(StyledRun),
487    /// Styled inline image.
488    Image(StyledImage),
489}
490
491/// Styled chapter output.
492#[derive(Clone, Debug, Default, PartialEq)]
493pub struct StyledChapter {
494    items: Vec<StyledEventOrRun>,
495}
496
497impl StyledChapter {
498    /// Iterate full event/run stream.
499    pub fn iter(&self) -> impl Iterator<Item = &StyledEventOrRun> {
500        self.items.iter()
501    }
502
503    /// Iterate only text runs.
504    pub fn runs(&self) -> impl Iterator<Item = &StyledRun> {
505        self.items.iter().filter_map(|item| match item {
506            StyledEventOrRun::Run(run) => Some(run),
507            _ => None,
508        })
509    }
510
511    /// Build from a pre-collected item vector.
512    pub fn from_items(items: Vec<StyledEventOrRun>) -> Self {
513        Self { items }
514    }
515}
516
517/// Lightweight style system with CSS cascade resolution.
518#[derive(Clone, Debug)]
519pub struct Styler {
520    config: StyleConfig,
521    memory: MemoryBudget,
522    parsed: Vec<Stylesheet>,
523}
524
525impl Styler {
526    /// Create a styler with explicit config.
527    pub fn new(config: StyleConfig) -> Self {
528        Self {
529            config,
530            memory: MemoryBudget::default(),
531            parsed: Vec::with_capacity(0),
532        }
533    }
534
535    /// Override hard memory budget used in style paths.
536    pub fn with_memory_budget(mut self, memory: MemoryBudget) -> Self {
537        self.memory = memory;
538        self
539    }
540
541    /// Parse and load stylesheets in cascade order.
542    pub fn load_stylesheets(
543        &mut self,
544        sources: &ChapterStylesheets,
545    ) -> Result<(), RenderPrepError> {
546        self.clear_stylesheets();
547        for source in &sources.sources {
548            self.push_stylesheet_source(&source.href, &source.css)?;
549        }
550        Ok(())
551    }
552
553    fn clear_stylesheets(&mut self) {
554        self.parsed.clear();
555    }
556
557    fn push_stylesheet_source(&mut self, href: &str, css: &str) -> Result<(), RenderPrepError> {
558        let css_limit = min(self.config.limits.max_css_bytes, self.memory.max_css_bytes);
559        if css.len() > css_limit {
560            let err = RenderPrepError::new(
561                "STYLE_CSS_TOO_LARGE",
562                format!(
563                    "Stylesheet exceeds max_css_bytes ({} > {})",
564                    css.len(),
565                    css_limit
566                ),
567            )
568            .with_phase(ErrorPhase::Style)
569            .with_limit("max_css_bytes", css.len(), css_limit)
570            .with_path(href.to_string())
571            .with_source(href.to_string());
572            return Err(err);
573        }
574        let parsed = parse_stylesheet(css).map_err(|e| {
575            RenderPrepError::new_with_phase(
576                ErrorPhase::Style,
577                "STYLE_PARSE_ERROR",
578                format!("Failed to parse stylesheet: {}", e),
579            )
580            .with_path(href.to_string())
581            .with_source(href.to_string())
582        })?;
583        if parsed.len() > self.config.limits.max_selectors {
584            let err = RenderPrepError::new(
585                "STYLE_SELECTOR_LIMIT",
586                format!(
587                    "Stylesheet exceeds max_selectors ({} > {})",
588                    parsed.len(),
589                    self.config.limits.max_selectors
590                ),
591            )
592            .with_phase(ErrorPhase::Style)
593            .with_limit(
594                "max_selectors",
595                parsed.len(),
596                self.config.limits.max_selectors,
597            )
598            .with_selector(format!("selector_count={}", parsed.len()))
599            .with_selector_index(self.config.limits.max_selectors)
600            .with_path(href.to_string())
601            .with_source(href.to_string());
602            return Err(err);
603        }
604        self.parsed.push(parsed);
605        Ok(())
606    }
607
608    /// Style a chapter and return a stream of events and runs.
609    pub fn style_chapter(&self, html: &str) -> Result<StyledChapter, RenderPrepError> {
610        let mut items = Vec::with_capacity(0);
611        self.style_chapter_with(html, |item| items.push(item))?;
612        Ok(StyledChapter { items })
613    }
614
615    /// Style a chapter and append results into an output buffer.
616    pub fn style_chapter_into(
617        &self,
618        html: &str,
619        out: &mut Vec<StyledEventOrRun>,
620    ) -> Result<(), RenderPrepError> {
621        self.style_chapter_with(html, |item| out.push(item))
622    }
623
624    /// Style a chapter and stream each item to a callback.
625    pub fn style_chapter_with<F>(&self, html: &str, mut on_item: F) -> Result<(), RenderPrepError>
626    where
627        F: FnMut(StyledEventOrRun),
628    {
629        self.style_chapter_bytes_with(html.as_bytes(), &mut on_item)
630    }
631
632    /// Style a chapter from XHTML bytes and stream each item to a callback.
633    pub fn style_chapter_bytes_with<F>(
634        &self,
635        html_bytes: &[u8],
636        mut on_item: F,
637    ) -> Result<(), RenderPrepError>
638    where
639        F: FnMut(StyledEventOrRun),
640    {
641        let mut reader = Reader::from_reader(html_bytes);
642        reader.config_mut().trim_text(false);
643        let mut buf = Vec::with_capacity(0);
644        let mut stack: Vec<ElementCtx> = Vec::with_capacity(0);
645        let mut skip_depth = 0usize;
646        let mut table_row_cells: Vec<usize> = Vec::with_capacity(0);
647
648        loop {
649            match reader.read_event_into(&mut buf) {
650                Ok(Event::Start(e)) => {
651                    let tag = decode_tag_name(&reader, e.name().as_ref())?;
652                    if should_skip_tag(&tag) {
653                        skip_depth += 1;
654                        buf.clear();
655                        continue;
656                    }
657                    if skip_depth > 0 {
658                        buf.clear();
659                        continue;
660                    }
661                    let ctx =
662                        element_ctx_from_start(&reader, &e, self.memory.max_inline_style_bytes)?;
663                    if matches!(ctx.tag.as_str(), "img" | "image") {
664                        let in_figure = stack.iter().any(|parent| parent.tag == "figure");
665                        emit_image_event(&ctx, in_figure, &mut on_item);
666                        buf.clear();
667                        continue;
668                    }
669                    if ctx.tag == "tr" {
670                        table_row_cells.push(0);
671                    } else if matches!(ctx.tag.as_str(), "td" | "th") {
672                        if let Some(cell_count) = table_row_cells.last_mut() {
673                            if *cell_count > 0 {
674                                let (resolved, role, bold_tag, italic_tag) =
675                                    self.resolve_context_style(&stack);
676                                let style =
677                                    self.compute_style(resolved, role, bold_tag, italic_tag);
678                                on_item(StyledEventOrRun::Run(StyledRun {
679                                    text: " | ".to_string(),
680                                    style,
681                                    font_id: 0,
682                                    resolved_family: String::with_capacity(0),
683                                }));
684                            }
685                            *cell_count = cell_count.saturating_add(1);
686                        }
687                    }
688                    emit_start_event(&ctx.tag, &mut on_item);
689                    stack.push(ctx);
690                }
691                Ok(Event::Empty(e)) => {
692                    let tag = decode_tag_name(&reader, e.name().as_ref())?;
693                    if skip_depth > 0 || should_skip_tag(&tag) {
694                        buf.clear();
695                        continue;
696                    }
697                    let ctx =
698                        element_ctx_from_start(&reader, &e, self.memory.max_inline_style_bytes)?;
699                    if matches!(ctx.tag.as_str(), "img" | "image") {
700                        let in_figure = stack.iter().any(|parent| parent.tag == "figure");
701                        emit_image_event(&ctx, in_figure, &mut on_item);
702                        buf.clear();
703                        continue;
704                    }
705                    if matches!(ctx.tag.as_str(), "td" | "th") {
706                        if let Some(cell_count) = table_row_cells.last_mut() {
707                            if *cell_count > 0 {
708                                let (resolved, role, bold_tag, italic_tag) =
709                                    self.resolve_context_style(&stack);
710                                let style =
711                                    self.compute_style(resolved, role, bold_tag, italic_tag);
712                                on_item(StyledEventOrRun::Run(StyledRun {
713                                    text: " | ".to_string(),
714                                    style,
715                                    font_id: 0,
716                                    resolved_family: String::with_capacity(0),
717                                }));
718                            }
719                            *cell_count = cell_count.saturating_add(1);
720                        }
721                    }
722                    emit_start_event(&ctx.tag, &mut on_item);
723                    if ctx.tag == "br" {
724                        on_item(StyledEventOrRun::Event(StyledEvent::LineBreak));
725                    }
726                    emit_end_event(&ctx.tag, &mut on_item);
727                }
728                Ok(Event::End(e)) => {
729                    let tag = decode_tag_name(&reader, e.name().as_ref())?;
730                    if should_skip_tag(&tag) {
731                        skip_depth = skip_depth.saturating_sub(1);
732                        buf.clear();
733                        continue;
734                    }
735                    if skip_depth > 0 {
736                        buf.clear();
737                        continue;
738                    }
739                    emit_end_event(&tag, &mut on_item);
740                    if tag == "tr" {
741                        table_row_cells.pop();
742                    }
743                    if let Some(last) = stack.last() {
744                        if last.tag == tag {
745                            stack.pop();
746                        }
747                    }
748                }
749                Ok(Event::Text(e)) => {
750                    if skip_depth > 0 {
751                        buf.clear();
752                        continue;
753                    }
754                    let text = e
755                        .decode()
756                        .map_err(|err| {
757                            RenderPrepError::new(
758                                "STYLE_TOKENIZE_ERROR",
759                                format!("Decode error: {:?}", err),
760                            )
761                            .with_phase(ErrorPhase::Style)
762                            .with_source("text node decode")
763                            .with_token_offset(reader_token_offset(&reader))
764                        })?
765                        .to_string();
766                    let preserve_ws = is_preformatted_context(&stack);
767                    let normalized = normalize_plain_text_whitespace(&text, preserve_ws);
768                    if normalized.is_empty() {
769                        buf.clear();
770                        continue;
771                    }
772                    let (resolved, role, bold_tag, italic_tag) = self.resolve_context_style(&stack);
773                    let style = self.compute_style(resolved, role, bold_tag, italic_tag);
774                    on_item(StyledEventOrRun::Run(StyledRun {
775                        text: normalized,
776                        style,
777                        font_id: 0,
778                        resolved_family: String::with_capacity(0),
779                    }));
780                }
781                Ok(Event::CData(e)) => {
782                    if skip_depth > 0 {
783                        buf.clear();
784                        continue;
785                    }
786                    let text = reader
787                        .decoder()
788                        .decode(&e)
789                        .map_err(|err| {
790                            RenderPrepError::new(
791                                "STYLE_TOKENIZE_ERROR",
792                                format!("Decode error: {:?}", err),
793                            )
794                            .with_phase(ErrorPhase::Style)
795                            .with_source("cdata decode")
796                            .with_token_offset(reader_token_offset(&reader))
797                        })?
798                        .to_string();
799                    let preserve_ws = is_preformatted_context(&stack);
800                    let normalized = normalize_plain_text_whitespace(&text, preserve_ws);
801                    if normalized.is_empty() {
802                        buf.clear();
803                        continue;
804                    }
805                    let (resolved, role, bold_tag, italic_tag) = self.resolve_context_style(&stack);
806                    let style = self.compute_style(resolved, role, bold_tag, italic_tag);
807                    on_item(StyledEventOrRun::Run(StyledRun {
808                        text: normalized,
809                        style,
810                        font_id: 0,
811                        resolved_family: String::with_capacity(0),
812                    }));
813                }
814                Ok(Event::GeneralRef(e)) => {
815                    if skip_depth > 0 {
816                        buf.clear();
817                        continue;
818                    }
819                    let entity_name = e.decode().map_err(|err| {
820                        RenderPrepError::new(
821                            "STYLE_TOKENIZE_ERROR",
822                            format!("Decode error: {:?}", err),
823                        )
824                        .with_phase(ErrorPhase::Style)
825                        .with_source("entity decode")
826                        .with_token_offset(reader_token_offset(&reader))
827                    })?;
828                    let entity = format!("&{};", entity_name);
829                    let resolved_entity = quick_xml::escape::unescape(&entity)
830                        .map_err(|err| {
831                            RenderPrepError::new(
832                                "STYLE_TOKENIZE_ERROR",
833                                format!("Unescape error: {:?}", err),
834                            )
835                            .with_phase(ErrorPhase::Style)
836                            .with_source("entity unescape")
837                            .with_token_offset(reader_token_offset(&reader))
838                        })?
839                        .to_string();
840                    let preserve_ws = is_preformatted_context(&stack);
841                    let normalized = normalize_plain_text_whitespace(&resolved_entity, preserve_ws);
842                    if normalized.is_empty() {
843                        buf.clear();
844                        continue;
845                    }
846                    let (resolved, role, bold_tag, italic_tag) = self.resolve_context_style(&stack);
847                    let style = self.compute_style(resolved, role, bold_tag, italic_tag);
848                    on_item(StyledEventOrRun::Run(StyledRun {
849                        text: normalized,
850                        style,
851                        font_id: 0,
852                        resolved_family: String::with_capacity(0),
853                    }));
854                }
855                Ok(Event::Eof) => break,
856                Ok(_) => {}
857                Err(err) => {
858                    return Err(RenderPrepError::new(
859                        "STYLE_TOKENIZE_ERROR",
860                        format!("XML error: {:?}", err),
861                    )
862                    .with_phase(ErrorPhase::Style)
863                    .with_source("xml tokenizer")
864                    .with_token_offset(reader_token_offset(&reader)));
865                }
866            }
867            buf.clear();
868        }
869
870        Ok(())
871    }
872
873    fn resolve_tag_style(&self, tag: &str, classes: &[String]) -> CssStyle {
874        let class_refs: Vec<&str> = classes.iter().map(String::as_str).collect();
875        let mut style = CssStyle::new();
876        for ss in &self.parsed {
877            style.merge(&ss.resolve(tag, &class_refs));
878        }
879        style
880    }
881
882    fn compute_style(
883        &self,
884        resolved: CssStyle,
885        role: BlockRole,
886        bold_tag: bool,
887        italic_tag: bool,
888    ) -> ComputedTextStyle {
889        let mut size_px = match resolved.font_size {
890            Some(FontSize::Px(px)) => px,
891            Some(FontSize::Em(em)) => self.config.hints.base_font_size_px * em,
892            None => {
893                if matches!(role, BlockRole::Heading(1 | 2)) {
894                    self.config.hints.base_font_size_px * 1.25
895                } else if matches!(role, BlockRole::FigureCaption) {
896                    self.config.hints.base_font_size_px * 0.90
897                } else {
898                    self.config.hints.base_font_size_px
899                }
900            }
901        };
902        size_px *= self.config.hints.text_scale.clamp(0.5, 3.0);
903        size_px = size_px.clamp(
904            self.config.hints.min_font_size_px,
905            self.config.hints.max_font_size_px,
906        );
907
908        let mut line_height = match resolved.line_height {
909            Some(LineHeight::Px(px)) => (px / size_px).max(1.0),
910            Some(LineHeight::Multiplier(m)) => m,
911            None => {
912                if matches!(role, BlockRole::FigureCaption) {
913                    1.3
914                } else {
915                    1.4
916                }
917            }
918        };
919        line_height = line_height.clamp(
920            self.config.hints.min_line_height,
921            self.config.hints.max_line_height,
922        );
923
924        let weight = match resolved.font_weight.unwrap_or(FontWeight::Normal) {
925            FontWeight::Bold => 700,
926            FontWeight::Normal => 400,
927        };
928        let italic = matches!(
929            resolved.font_style.unwrap_or(FontStyle::Normal),
930            FontStyle::Italic
931        );
932        let final_weight = if bold_tag { 700 } else { weight };
933        let final_italic = italic || italic_tag || matches!(role, BlockRole::FigureCaption);
934
935        let family_stack = resolved
936            .font_family
937            .as_ref()
938            .map(|fam| split_family_stack(fam))
939            .unwrap_or_else(|| vec!["serif".to_string()]);
940        let letter_spacing = resolved.letter_spacing.unwrap_or(0.0);
941
942        ComputedTextStyle {
943            family_stack,
944            weight: final_weight,
945            italic: final_italic,
946            size_px,
947            line_height,
948            letter_spacing,
949            block_role: role,
950        }
951    }
952
953    fn resolve_context_style(&self, stack: &[ElementCtx]) -> (CssStyle, BlockRole, bool, bool) {
954        let mut merged = CssStyle::new();
955        let mut role = BlockRole::Body;
956        let mut bold_tag = false;
957        let mut italic_tag = false;
958
959        for ctx in stack {
960            merged.merge(&self.resolve_tag_style(&ctx.tag, &ctx.classes));
961            if let Some(inline) = &ctx.inline_style {
962                merged.merge(inline);
963            }
964            if matches!(ctx.tag.as_str(), "strong" | "b") {
965                bold_tag = true;
966            }
967            if matches!(ctx.tag.as_str(), "em" | "i") {
968                italic_tag = true;
969            }
970            role = role_from_tag(&ctx.tag).unwrap_or(role);
971        }
972
973        (merged, role, bold_tag, italic_tag)
974    }
975}
976
977/// Fallback policy for font matching.
978#[derive(Clone, Debug, PartialEq, Eq)]
979pub struct FontPolicy {
980    /// Preferred family order used when style stack has no embedded match.
981    pub preferred_families: Vec<String>,
982    /// Final fallback family.
983    pub default_family: String,
984    /// Whether embedded fonts are allowed for matching.
985    pub allow_embedded_fonts: bool,
986    /// Whether synthetic bold is allowed.
987    pub synthetic_bold: bool,
988    /// Whether synthetic italic is allowed.
989    pub synthetic_italic: bool,
990}
991
992impl FontPolicy {
993    /// Serif-first policy.
994    pub fn serif_default() -> Self {
995        Self {
996            preferred_families: vec!["serif".to_string()],
997            default_family: "serif".to_string(),
998            allow_embedded_fonts: true,
999            synthetic_bold: false,
1000            synthetic_italic: false,
1001        }
1002    }
1003}
1004
1005/// First-class public fallback policy alias.
1006pub type FontFallbackPolicy = FontPolicy;
1007
1008impl Default for FontPolicy {
1009    fn default() -> Self {
1010        Self::serif_default()
1011    }
1012}
1013
1014/// Resolved font face for a style request.
1015#[derive(Clone, Debug, PartialEq, Eq)]
1016pub struct ResolvedFontFace {
1017    /// Stable resolver identity for the chosen face (0 means policy fallback face).
1018    pub font_id: u32,
1019    /// Chosen family.
1020    pub family: String,
1021    /// Selected face metadata when matched in EPUB.
1022    pub embedded: Option<EmbeddedFontFace>,
1023}
1024
1025/// Trace output for fallback reasoning.
1026#[derive(Clone, Debug, PartialEq, Eq)]
1027pub struct FontResolutionTrace {
1028    /// Final selected face.
1029    pub face: ResolvedFontFace,
1030    /// Resolution reasoning chain.
1031    pub reason_chain: Vec<String>,
1032}
1033
1034/// Font resolution engine.
1035#[derive(Clone, Debug)]
1036pub struct FontResolver {
1037    policy: FontPolicy,
1038    limits: FontLimits,
1039    faces: Vec<EmbeddedFontFace>,
1040}
1041
1042impl FontResolver {
1043    /// Create a resolver with explicit policy and limits.
1044    pub fn new(policy: FontPolicy) -> Self {
1045        Self {
1046            policy,
1047            limits: FontLimits::default(),
1048            faces: Vec::with_capacity(0),
1049        }
1050    }
1051
1052    /// Override registration limits.
1053    pub fn with_limits(mut self, limits: FontLimits) -> Self {
1054        self.limits = limits;
1055        self
1056    }
1057
1058    /// Register EPUB fonts and validate byte limits via callback.
1059    pub fn register_epub_fonts<I, F>(
1060        &mut self,
1061        fonts: I,
1062        mut loader: F,
1063    ) -> Result<(), RenderPrepError>
1064    where
1065        I: IntoIterator<Item = EmbeddedFontFace>,
1066        F: FnMut(&str) -> Result<Vec<u8>, EpubError>,
1067    {
1068        self.faces.clear();
1069        let mut total = 0usize;
1070        let mut dedupe_keys: Vec<(String, u16, EmbeddedFontStyle, String)> = Vec::with_capacity(0);
1071
1072        for face in fonts {
1073            let normalized_family = normalize_family(&face.family);
1074            let dedupe_key = (
1075                normalized_family,
1076                face.weight,
1077                face.style,
1078                face.href.to_ascii_lowercase(),
1079            );
1080            if dedupe_keys.contains(&dedupe_key) {
1081                continue;
1082            }
1083            if self.faces.len() >= self.limits.max_faces {
1084                return Err(RenderPrepError::new_with_phase(
1085                    ErrorPhase::Style,
1086                    "FONT_FACE_LIMIT",
1087                    "Too many embedded font faces",
1088                )
1089                .with_limit(
1090                    "max_faces",
1091                    self.faces.len() + 1,
1092                    self.limits.max_faces,
1093                ));
1094            }
1095            let bytes = loader(&face.href).map_err(|e| {
1096                RenderPrepError::new_with_phase(ErrorPhase::Style, "FONT_LOAD_ERROR", e.to_string())
1097                    .with_path(face.href.clone())
1098            })?;
1099            if bytes.len() > self.limits.max_bytes_per_font {
1100                let err = RenderPrepError::new_with_phase(
1101                    ErrorPhase::Style,
1102                    "FONT_BYTES_PER_FACE_LIMIT",
1103                    format!(
1104                        "Font exceeds max_bytes_per_font ({} > {})",
1105                        bytes.len(),
1106                        self.limits.max_bytes_per_font
1107                    ),
1108                )
1109                .with_path(face.href.clone())
1110                .with_limit(
1111                    "max_bytes_per_font",
1112                    bytes.len(),
1113                    self.limits.max_bytes_per_font,
1114                );
1115                return Err(err);
1116            }
1117            total += bytes.len();
1118            if total > self.limits.max_total_font_bytes {
1119                return Err(RenderPrepError::new_with_phase(
1120                    ErrorPhase::Style,
1121                    "FONT_TOTAL_BYTES_LIMIT",
1122                    format!(
1123                        "Total font bytes exceed max_total_font_bytes ({} > {})",
1124                        total, self.limits.max_total_font_bytes
1125                    ),
1126                )
1127                .with_limit(
1128                    "max_total_font_bytes",
1129                    total,
1130                    self.limits.max_total_font_bytes,
1131                ));
1132            }
1133            dedupe_keys.push(dedupe_key);
1134            self.faces.push(face);
1135        }
1136
1137        Ok(())
1138    }
1139
1140    /// Resolve a style request to a concrete face.
1141    pub fn resolve(&self, style: &ComputedTextStyle) -> ResolvedFontFace {
1142        self.resolve_with_trace(style).face
1143    }
1144
1145    /// Resolve with full fallback reasoning.
1146    pub fn resolve_with_trace(&self, style: &ComputedTextStyle) -> FontResolutionTrace {
1147        self.resolve_with_trace_for_text(style, None)
1148    }
1149
1150    /// Resolve with full fallback reasoning and optional text context.
1151    pub fn resolve_with_trace_for_text(
1152        &self,
1153        style: &ComputedTextStyle,
1154        text: Option<&str>,
1155    ) -> FontResolutionTrace {
1156        let mut reasons = Vec::with_capacity(0);
1157        for family in &style.family_stack {
1158            if !self.policy.allow_embedded_fonts {
1159                reasons.push("embedded fonts disabled by policy".to_string());
1160                break;
1161            }
1162            let requested = normalize_family(family);
1163            let mut candidates: Vec<(usize, EmbeddedFontFace)> = self
1164                .faces
1165                .iter()
1166                .enumerate()
1167                .filter(|(_, face)| normalize_family(&face.family) == requested)
1168                .map(|(idx, face)| (idx, face.clone()))
1169                .collect();
1170            if !candidates.is_empty() {
1171                candidates.sort_by_key(|(_, face)| {
1172                    let weight_delta = (face.weight as i32 - style.weight as i32).unsigned_abs();
1173                    let style_penalty = if style.italic {
1174                        if matches!(
1175                            face.style,
1176                            EmbeddedFontStyle::Italic | EmbeddedFontStyle::Oblique
1177                        ) {
1178                            0
1179                        } else {
1180                            1000
1181                        }
1182                    } else if matches!(face.style, EmbeddedFontStyle::Normal) {
1183                        0
1184                    } else {
1185                        1000
1186                    };
1187                    weight_delta + style_penalty
1188                });
1189                let (chosen_idx, chosen) = candidates[0].clone();
1190                reasons.push(format!(
1191                    "matched embedded family '{}' via nearest weight/style",
1192                    family
1193                ));
1194                return FontResolutionTrace {
1195                    face: ResolvedFontFace {
1196                        font_id: chosen_idx as u32 + 1,
1197                        family: chosen.family.clone(),
1198                        embedded: Some(chosen),
1199                    },
1200                    reason_chain: reasons,
1201                };
1202            }
1203            reasons.push(format!("family '{}' unavailable in embedded set", family));
1204        }
1205
1206        for family in &self.policy.preferred_families {
1207            reasons.push(format!("preferred fallback family candidate '{}'", family));
1208        }
1209        reasons.push(format!(
1210            "fallback to policy default '{}'",
1211            self.policy.default_family
1212        ));
1213        if text.is_some_and(has_non_ascii) {
1214            reasons
1215                .push("missing glyph risk: non-ASCII text with no embedded face match".to_string());
1216        }
1217        FontResolutionTrace {
1218            face: ResolvedFontFace {
1219                font_id: 0,
1220                family: self.policy.default_family.clone(),
1221                embedded: None,
1222            },
1223            reason_chain: reasons,
1224        }
1225    }
1226}
1227
1228/// Render-prep orchestrator.
1229#[derive(Clone, Debug)]
1230pub struct RenderPrep {
1231    opts: RenderPrepOptions,
1232    styler: Styler,
1233    font_resolver: FontResolver,
1234    image_dimension_cache: BTreeMap<String, Option<(u16, u16)>>,
1235}
1236
1237/// Structured trace context for a streamed chapter item.
1238#[derive(Clone, Debug, PartialEq)]
1239pub enum RenderPrepTrace {
1240    /// Non-text structural event.
1241    Event,
1242    /// Text run with style context and font-resolution trace.
1243    Run {
1244        /// Style used for this run during resolution.
1245        style: Box<ComputedTextStyle>,
1246        /// Font resolution details for this run.
1247        font: Box<FontResolutionTrace>,
1248    },
1249}
1250
1251impl RenderPrepTrace {
1252    /// Return font-resolution trace when this item is a text run.
1253    pub fn font_trace(&self) -> Option<&FontResolutionTrace> {
1254        match self {
1255            Self::Run { font, .. } => Some(font.as_ref()),
1256            Self::Event => None,
1257        }
1258    }
1259
1260    /// Return style context when this item is a text run.
1261    pub fn style_context(&self) -> Option<&ComputedTextStyle> {
1262        match self {
1263            Self::Run { style, .. } => Some(style.as_ref()),
1264            Self::Event => None,
1265        }
1266    }
1267}
1268
1269impl RenderPrep {
1270    /// Create a render-prep engine.
1271    pub fn new(opts: RenderPrepOptions) -> Self {
1272        let styler = Styler::new(opts.style).with_memory_budget(opts.memory);
1273        let font_resolver = FontResolver::new(FontPolicy::default()).with_limits(opts.fonts);
1274        Self {
1275            opts,
1276            styler,
1277            font_resolver,
1278            image_dimension_cache: BTreeMap::new(),
1279        }
1280    }
1281
1282    /// Use serif default fallback policy.
1283    pub fn with_serif_default(mut self) -> Self {
1284        self.font_resolver =
1285            FontResolver::new(FontPolicy::serif_default()).with_limits(self.opts.fonts);
1286        self
1287    }
1288
1289    /// Override fallback font policy used during style-to-face resolution.
1290    pub fn with_font_policy(mut self, policy: FontPolicy) -> Self {
1291        self.font_resolver = FontResolver::new(policy).with_limits(self.opts.fonts);
1292        self
1293    }
1294
1295    /// Register all embedded fonts from a book.
1296    pub fn with_embedded_fonts_from_book<R: std::io::Read + std::io::Seek>(
1297        self,
1298        book: &mut EpubBook<R>,
1299    ) -> Result<Self, RenderPrepError> {
1300        let fonts = book
1301            .embedded_fonts_with_options(self.opts.fonts)
1302            .map_err(|e| {
1303                RenderPrepError::new_with_phase(
1304                    ErrorPhase::Parse,
1305                    "BOOK_EMBEDDED_FONTS",
1306                    e.to_string(),
1307                )
1308            })?;
1309        self.with_registered_fonts(fonts, |href| book.read_resource(href))
1310    }
1311
1312    fn load_chapter_html_with_budget<R: std::io::Read + std::io::Seek>(
1313        &self,
1314        book: &mut EpubBook<R>,
1315        index: usize,
1316    ) -> Result<(String, Vec<u8>), RenderPrepError> {
1317        let chapter = book.chapter(index).map_err(|e| {
1318            RenderPrepError::new_with_phase(ErrorPhase::Parse, "BOOK_CHAPTER_REF", e.to_string())
1319                .with_chapter_index(index)
1320        })?;
1321        let href = chapter.href;
1322        let bytes = book.read_resource(&href).map_err(|e| {
1323            RenderPrepError::new_with_phase(ErrorPhase::Parse, "BOOK_CHAPTER_HTML", e.to_string())
1324                .with_path(href.clone())
1325                .with_chapter_index(index)
1326        })?;
1327        if bytes.len() > self.opts.memory.max_entry_bytes {
1328            return Err(RenderPrepError::new_with_phase(
1329                ErrorPhase::Parse,
1330                "ENTRY_BYTES_LIMIT",
1331                format!(
1332                    "Chapter entry exceeds max_entry_bytes ({} > {})",
1333                    bytes.len(),
1334                    self.opts.memory.max_entry_bytes
1335                ),
1336            )
1337            .with_path(href.clone())
1338            .with_chapter_index(index)
1339            .with_limit(
1340                "max_entry_bytes",
1341                bytes.len(),
1342                self.opts.memory.max_entry_bytes,
1343            ));
1344        }
1345        Ok((href, bytes))
1346    }
1347
1348    fn apply_chapter_stylesheets_with_budget<R: std::io::Read + std::io::Seek>(
1349        &mut self,
1350        book: &mut EpubBook<R>,
1351        chapter_index: usize,
1352        chapter_href: &str,
1353        html: &[u8],
1354    ) -> Result<(), RenderPrepError> {
1355        let mut scratch = Vec::with_capacity(0);
1356        self.apply_chapter_stylesheets_with_budget_scratch(
1357            book,
1358            chapter_index,
1359            chapter_href,
1360            html,
1361            &mut scratch,
1362        )
1363    }
1364
1365    fn apply_chapter_stylesheets_with_budget_scratch<R: std::io::Read + std::io::Seek>(
1366        &mut self,
1367        book: &mut EpubBook<R>,
1368        chapter_index: usize,
1369        chapter_href: &str,
1370        html: &[u8],
1371        scratch_buf: &mut Vec<u8>,
1372    ) -> Result<(), RenderPrepError> {
1373        let links = parse_stylesheet_links_bytes(chapter_href, html);
1374        self.styler.clear_stylesheets();
1375        let css_limit = min(
1376            self.opts.style.limits.max_css_bytes,
1377            self.opts.memory.max_css_bytes,
1378        );
1379        scratch_buf.clear();
1380        for href in links {
1381            scratch_buf.clear();
1382            book.read_resource_into_with_hard_cap(&href, scratch_buf, css_limit)
1383                .map_err(|e| {
1384                    RenderPrepError::new_with_phase(
1385                        ErrorPhase::Parse,
1386                        "BOOK_CHAPTER_STYLESHEET_READ",
1387                        e.to_string(),
1388                    )
1389                    .with_path(href.clone())
1390                    .with_chapter_index(chapter_index)
1391                })?;
1392            if scratch_buf.len() > css_limit {
1393                return Err(RenderPrepError::new_with_phase(
1394                    ErrorPhase::Parse,
1395                    "STYLE_CSS_TOO_LARGE",
1396                    format!(
1397                        "Stylesheet exceeds max_css_bytes ({} > {})",
1398                        scratch_buf.len(),
1399                        css_limit
1400                    ),
1401                )
1402                .with_path(href.clone())
1403                .with_chapter_index(chapter_index)
1404                .with_limit("max_css_bytes", scratch_buf.len(), css_limit));
1405            }
1406            let css = core::str::from_utf8(scratch_buf).map_err(|_| {
1407                RenderPrepError::new_with_phase(
1408                    ErrorPhase::Parse,
1409                    "STYLE_CSS_NOT_UTF8",
1410                    format!("Stylesheet is not UTF-8: {}", href),
1411                )
1412                .with_path(href.clone())
1413                .with_chapter_index(chapter_index)
1414            })?;
1415            self.styler
1416                .push_stylesheet_source(&href, css)
1417                .map_err(|e| e.with_chapter_index(chapter_index))?;
1418        }
1419        Ok(())
1420    }
1421
1422    fn collect_intrinsic_image_dimensions<R: std::io::Read + std::io::Seek>(
1423        &mut self,
1424        book: &mut EpubBook<R>,
1425        chapter_href: &str,
1426        html: &[u8],
1427    ) -> BTreeMap<String, (u16, u16)> {
1428        let mut out = BTreeMap::new();
1429        let sources = collect_image_sources_from_html(chapter_href, html);
1430        for src in sources {
1431            if let Some((w, h)) = self.resolve_intrinsic_image_dimensions(book, &src) {
1432                out.insert(resource_path_without_fragment(&src).to_string(), (w, h));
1433            }
1434        }
1435        out
1436    }
1437
1438    fn resolve_intrinsic_image_dimensions<R: std::io::Read + std::io::Seek>(
1439        &mut self,
1440        book: &mut EpubBook<R>,
1441        src: &str,
1442    ) -> Option<(u16, u16)> {
1443        let key = resource_path_without_fragment(src);
1444        if let Some(cached) = self.image_dimension_cache.get(key) {
1445            return *cached;
1446        }
1447
1448        let mut bytes = Vec::with_capacity(0);
1449        let cap = self.opts.memory.max_entry_bytes.max(16 * 1024);
1450        let dimensions = match book.read_resource_into_with_hard_cap(key, &mut bytes, cap) {
1451            Ok(_) => infer_image_dimensions_from_bytes(&bytes),
1452            Err(_) => None,
1453        };
1454        self.image_dimension_cache
1455            .insert(key.to_string(), dimensions);
1456        dimensions
1457    }
1458
1459    /// Register fonts from any external source with a byte loader callback.
1460    pub fn with_registered_fonts<I, F>(
1461        mut self,
1462        fonts: I,
1463        mut loader: F,
1464    ) -> Result<Self, RenderPrepError>
1465    where
1466        I: IntoIterator<Item = EmbeddedFontFace>,
1467        F: FnMut(&str) -> Result<Vec<u8>, EpubError>,
1468    {
1469        self.font_resolver
1470            .register_epub_fonts(fonts, |href| loader(href))?;
1471        Ok(self)
1472    }
1473
1474    /// Prepare a chapter into styled runs/events.
1475    pub fn prepare_chapter<R: std::io::Read + std::io::Seek>(
1476        &mut self,
1477        book: &mut EpubBook<R>,
1478        index: usize,
1479    ) -> Result<PreparedChapter, RenderPrepError> {
1480        let mut items = Vec::with_capacity(0);
1481        self.prepare_chapter_with(book, index, |item| items.push(item))?;
1482        Ok(PreparedChapter {
1483            styled: StyledChapter::from_items(items),
1484        })
1485    }
1486
1487    /// Prepare a chapter and append results into an output buffer.
1488    pub fn prepare_chapter_into<R: std::io::Read + std::io::Seek>(
1489        &mut self,
1490        book: &mut EpubBook<R>,
1491        index: usize,
1492        out: &mut Vec<StyledEventOrRun>,
1493    ) -> Result<(), RenderPrepError> {
1494        self.prepare_chapter_with(book, index, |item| out.push(item))
1495    }
1496
1497    /// Prepare a chapter and stream each styled item via callback.
1498    pub fn prepare_chapter_with<R: std::io::Read + std::io::Seek, F: FnMut(StyledEventOrRun)>(
1499        &mut self,
1500        book: &mut EpubBook<R>,
1501        index: usize,
1502        mut on_item: F,
1503    ) -> Result<(), RenderPrepError> {
1504        let (chapter_href, html) = self.load_chapter_html_with_budget(book, index)?;
1505        self.apply_chapter_stylesheets_with_budget(book, index, &chapter_href, &html)?;
1506        let image_dimensions =
1507            self.collect_intrinsic_image_dimensions(book, chapter_href.as_str(), &html);
1508        let font_resolver = &self.font_resolver;
1509        let chapter_href_ref = chapter_href.as_str();
1510        self.styler.style_chapter_bytes_with(&html, |item| {
1511            let item =
1512                resolve_item_assets_for_chapter(chapter_href_ref, Some(&image_dimensions), item);
1513            let (item, _) = resolve_item_with_font(font_resolver, item);
1514            on_item(item);
1515        })
1516    }
1517
1518    /// Prepare a chapter from caller-provided XHTML bytes and stream each styled item.
1519    ///
1520    /// This avoids re-reading chapter bytes from the ZIP archive and is intended for
1521    /// embedded call sites that already own a reusable chapter buffer.
1522    #[inline(never)]
1523    pub fn prepare_chapter_bytes_with<
1524        R: std::io::Read + std::io::Seek,
1525        F: FnMut(StyledEventOrRun),
1526    >(
1527        &mut self,
1528        book: &mut EpubBook<R>,
1529        index: usize,
1530        html: &[u8],
1531        mut on_item: F,
1532    ) -> Result<(), RenderPrepError> {
1533        let chapter = book.chapter(index).map_err(|e| {
1534            RenderPrepError::new_with_phase(ErrorPhase::Parse, "BOOK_CHAPTER_REF", e.to_string())
1535                .with_chapter_index(index)
1536        })?;
1537        let chapter_href = chapter.href;
1538        if html.len() > self.opts.memory.max_entry_bytes {
1539            return Err(RenderPrepError::new_with_phase(
1540                ErrorPhase::Parse,
1541                "ENTRY_BYTES_LIMIT",
1542                format!(
1543                    "Chapter entry exceeds max_entry_bytes ({} > {})",
1544                    html.len(),
1545                    self.opts.memory.max_entry_bytes
1546                ),
1547            )
1548            .with_path(chapter_href.clone())
1549            .with_chapter_index(index)
1550            .with_limit(
1551                "max_entry_bytes",
1552                html.len(),
1553                self.opts.memory.max_entry_bytes,
1554            ));
1555        }
1556        self.apply_chapter_stylesheets_with_budget(book, index, &chapter_href, html)?;
1557        let image_dimensions =
1558            self.collect_intrinsic_image_dimensions(book, chapter_href.as_str(), html);
1559        let font_resolver = &self.font_resolver;
1560        let chapter_href_ref = chapter_href.as_str();
1561        self.styler.style_chapter_bytes_with(html, |item| {
1562            let item =
1563                resolve_item_assets_for_chapter(chapter_href_ref, Some(&image_dimensions), item);
1564            let (item, _) = resolve_item_with_font(font_resolver, item);
1565            on_item(item);
1566        })
1567    }
1568
1569    /// Prepare chapter bytes with caller-provided stylesheet scratch.
1570    ///
1571    /// This avoids transient stylesheet `Vec<u8>` allocations by reusing `stylesheet_scratch`.
1572    #[inline(never)]
1573    pub fn prepare_chapter_bytes_with_scratch<
1574        R: std::io::Read + std::io::Seek,
1575        F: FnMut(StyledEventOrRun),
1576    >(
1577        &mut self,
1578        book: &mut EpubBook<R>,
1579        index: usize,
1580        html: &[u8],
1581        stylesheet_scratch: &mut Vec<u8>,
1582        mut on_item: F,
1583    ) -> Result<(), RenderPrepError> {
1584        let chapter = book.chapter(index).map_err(|e| {
1585            RenderPrepError::new_with_phase(ErrorPhase::Parse, "BOOK_CHAPTER_REF", e.to_string())
1586                .with_chapter_index(index)
1587        })?;
1588        let chapter_href = chapter.href;
1589        if html.len() > self.opts.memory.max_entry_bytes {
1590            return Err(RenderPrepError::new_with_phase(
1591                ErrorPhase::Parse,
1592                "ENTRY_BYTES_LIMIT",
1593                format!(
1594                    "Chapter entry exceeds max_entry_bytes ({} > {})",
1595                    html.len(),
1596                    self.opts.memory.max_entry_bytes
1597                ),
1598            )
1599            .with_path(chapter_href.clone())
1600            .with_chapter_index(index)
1601            .with_limit(
1602                "max_entry_bytes",
1603                html.len(),
1604                self.opts.memory.max_entry_bytes,
1605            ));
1606        }
1607        self.apply_chapter_stylesheets_with_budget_scratch(
1608            book,
1609            index,
1610            &chapter_href,
1611            html,
1612            stylesheet_scratch,
1613        )?;
1614        let font_resolver = &self.font_resolver;
1615        let chapter_href_ref = chapter_href.as_str();
1616        self.styler.style_chapter_bytes_with(html, |item| {
1617            let item = resolve_item_assets_for_chapter(chapter_href_ref, None, item);
1618            let (item, _) = resolve_item_with_font(font_resolver, item);
1619            on_item(item);
1620        })
1621    }
1622
1623    /// Prepare a chapter and stream each styled item with structured trace context.
1624    pub fn prepare_chapter_with_trace_context<
1625        R: std::io::Read + std::io::Seek,
1626        F: FnMut(StyledEventOrRun, RenderPrepTrace),
1627    >(
1628        &mut self,
1629        book: &mut EpubBook<R>,
1630        index: usize,
1631        mut on_item: F,
1632    ) -> Result<(), RenderPrepError> {
1633        let (chapter_href, html) = self.load_chapter_html_with_budget(book, index)?;
1634        self.apply_chapter_stylesheets_with_budget(book, index, &chapter_href, &html)?;
1635        let image_dimensions =
1636            self.collect_intrinsic_image_dimensions(book, chapter_href.as_str(), &html);
1637        let font_resolver = &self.font_resolver;
1638        let chapter_href_ref = chapter_href.as_str();
1639        self.styler.style_chapter_bytes_with(&html, |item| {
1640            let item =
1641                resolve_item_assets_for_chapter(chapter_href_ref, Some(&image_dimensions), item);
1642            let (item, trace) = resolve_item_with_font(font_resolver, item);
1643            on_item(item, trace);
1644        })
1645    }
1646
1647    /// Prepare a chapter and stream each styled item with optional font-resolution trace.
1648    #[deprecated(
1649        since = "0.2.0",
1650        note = "Use prepare_chapter_with_trace_context for stable structured trace output."
1651    )]
1652    pub fn prepare_chapter_with_trace<
1653        R: std::io::Read + std::io::Seek,
1654        F: FnMut(StyledEventOrRun, Option<FontResolutionTrace>),
1655    >(
1656        &mut self,
1657        book: &mut EpubBook<R>,
1658        index: usize,
1659        mut on_item: F,
1660    ) -> Result<(), RenderPrepError> {
1661        self.prepare_chapter_with_trace_context(book, index, |item, trace| {
1662            on_item(item, trace.font_trace().cloned());
1663        })
1664    }
1665}
1666
1667/// Prepared chapter stream returned by render-prep.
1668#[derive(Clone, Debug, PartialEq)]
1669pub struct PreparedChapter {
1670    styled: StyledChapter,
1671}
1672
1673impl PreparedChapter {
1674    /// Iterate full styled stream.
1675    pub fn iter(&self) -> impl Iterator<Item = &StyledEventOrRun> {
1676        self.styled.iter()
1677    }
1678
1679    /// Iterate styled runs.
1680    pub fn runs(&self) -> impl Iterator<Item = &StyledRun> {
1681        self.styled.runs()
1682    }
1683}
1684
1685#[derive(Clone, Debug, Default)]
1686struct ElementCtx {
1687    tag: String,
1688    classes: Vec<String>,
1689    inline_style: Option<CssStyle>,
1690    img_src: Option<String>,
1691    img_alt: Option<String>,
1692    img_width_px: Option<u16>,
1693    img_height_px: Option<u16>,
1694}
1695
1696fn reader_token_offset(reader: &Reader<&[u8]>) -> usize {
1697    usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX)
1698}
1699
1700fn first_non_empty_declaration_index(style_attr: &str) -> Option<usize> {
1701    style_attr
1702        .split(';')
1703        .enumerate()
1704        .find(|(_, decl)| !decl.trim().is_empty())
1705        .map(|(idx, _)| idx)
1706}
1707
1708fn decode_tag_name(reader: &Reader<&[u8]>, raw: &[u8]) -> Result<String, RenderPrepError> {
1709    reader
1710        .decoder()
1711        .decode(raw)
1712        .map(|v| v.to_string())
1713        .map_err(|err| {
1714            RenderPrepError::new_with_phase(
1715                ErrorPhase::Style,
1716                "STYLE_TOKENIZE_ERROR",
1717                format!("Decode error: {:?}", err),
1718            )
1719            .with_source("tag name decode")
1720            .with_token_offset(reader_token_offset(reader))
1721        })
1722        .map(|tag| {
1723            tag.rsplit(':')
1724                .next()
1725                .unwrap_or(tag.as_str())
1726                .to_ascii_lowercase()
1727        })
1728}
1729
1730fn element_ctx_from_start(
1731    reader: &Reader<&[u8]>,
1732    e: &quick_xml::events::BytesStart<'_>,
1733    max_inline_style_bytes: usize,
1734) -> Result<ElementCtx, RenderPrepError> {
1735    let tag = decode_tag_name(reader, e.name().as_ref())?;
1736    let mut classes = Vec::with_capacity(0);
1737    let mut inline_style = None;
1738    let mut img_src: Option<String> = None;
1739    let mut img_alt: Option<String> = None;
1740    let mut img_width_px: Option<u16> = None;
1741    let mut img_height_px: Option<u16> = None;
1742    for attr in e.attributes().flatten() {
1743        let key = match reader.decoder().decode(attr.key.as_ref()) {
1744            Ok(v) => v.to_ascii_lowercase(),
1745            Err(_) => continue,
1746        };
1747        let val = match reader.decoder().decode(&attr.value) {
1748            Ok(v) => v.to_string(),
1749            Err(_) => continue,
1750        };
1751        if key == "class" {
1752            classes = val
1753                .split_whitespace()
1754                .map(|v| v.trim().to_string())
1755                .filter(|v| !v.is_empty())
1756                .collect();
1757        } else if key == "style" {
1758            if val.len() > max_inline_style_bytes {
1759                let mut prep_err = RenderPrepError::new_with_phase(
1760                    ErrorPhase::Style,
1761                    "STYLE_INLINE_BYTES_LIMIT",
1762                    format!(
1763                        "Inline style exceeds max_inline_style_bytes ({} > {})",
1764                        val.len(),
1765                        max_inline_style_bytes
1766                    ),
1767                )
1768                .with_source(format!("inline style on <{}>", tag))
1769                .with_declaration(val.clone())
1770                .with_token_offset(reader_token_offset(reader))
1771                .with_limit(
1772                    "max_inline_style_bytes",
1773                    val.len(),
1774                    max_inline_style_bytes,
1775                );
1776                if let Some(declaration_index) = first_non_empty_declaration_index(&val) {
1777                    prep_err = prep_err.with_declaration_index(declaration_index);
1778                }
1779                return Err(prep_err);
1780            }
1781            let parsed = parse_inline_style(&val).map_err(|err| {
1782                let mut prep_err = RenderPrepError::new_with_phase(
1783                    ErrorPhase::Style,
1784                    "STYLE_INLINE_PARSE_ERROR",
1785                    err.to_string(),
1786                )
1787                .with_source(format!("inline style on <{}>", tag))
1788                .with_declaration(val.clone())
1789                .with_token_offset(reader_token_offset(reader));
1790                if let Some(declaration_index) = first_non_empty_declaration_index(&val) {
1791                    prep_err = prep_err.with_declaration_index(declaration_index);
1792                }
1793                prep_err
1794            })?;
1795            inline_style = Some(parsed);
1796        } else if key == "src"
1797            || ((key == "href" || key == "xlink:href") && matches!(tag.as_str(), "img" | "image"))
1798        {
1799            if !val.is_empty() {
1800                img_src = Some(val);
1801            }
1802        } else if key == "alt" {
1803            img_alt = Some(val);
1804        } else if key == "title" {
1805            if img_alt.is_none() && !val.is_empty() {
1806                img_alt = Some(val);
1807            }
1808        } else if key == "width" {
1809            img_width_px = parse_dimension_hint_px(&val);
1810        } else if key == "height" {
1811            img_height_px = parse_dimension_hint_px(&val);
1812        }
1813    }
1814    Ok(ElementCtx {
1815        tag,
1816        classes,
1817        inline_style,
1818        img_src,
1819        img_alt,
1820        img_width_px,
1821        img_height_px,
1822    })
1823}
1824
1825fn parse_dimension_hint_px(raw: &str) -> Option<u16> {
1826    let trimmed = raw.trim().trim_end_matches("px").trim();
1827    let parsed = trimmed.parse::<u32>().ok()?;
1828    if parsed == 0 || parsed > u16::MAX as u32 {
1829        return None;
1830    }
1831    Some(parsed as u16)
1832}
1833
1834fn emit_image_event<F: FnMut(StyledEventOrRun)>(
1835    ctx: &ElementCtx,
1836    in_figure: bool,
1837    on_item: &mut F,
1838) {
1839    if !matches!(ctx.tag.as_str(), "img" | "image") {
1840        return;
1841    }
1842    let Some(src) = ctx.img_src.clone() else {
1843        return;
1844    };
1845    on_item(StyledEventOrRun::Image(StyledImage {
1846        src,
1847        alt: ctx.img_alt.clone().unwrap_or_default(),
1848        width_px: ctx.img_width_px,
1849        height_px: ctx.img_height_px,
1850        in_figure,
1851    }));
1852}
1853
1854fn emit_start_event<F: FnMut(StyledEventOrRun)>(tag: &str, on_item: &mut F) {
1855    match tag {
1856        "p" | "div" | "figure" | "figcaption" | "table" | "tr" => {
1857            on_item(StyledEventOrRun::Event(StyledEvent::ParagraphStart))
1858        }
1859        "li" => on_item(StyledEventOrRun::Event(StyledEvent::ListItemStart)),
1860        "h1" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(1))),
1861        "h2" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(2))),
1862        "h3" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(3))),
1863        "h4" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(4))),
1864        "h5" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(5))),
1865        "h6" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingStart(6))),
1866        _ => {}
1867    }
1868}
1869
1870fn emit_end_event<F: FnMut(StyledEventOrRun)>(tag: &str, on_item: &mut F) {
1871    match tag {
1872        "p" | "div" | "figure" | "figcaption" | "table" | "tr" => {
1873            on_item(StyledEventOrRun::Event(StyledEvent::ParagraphEnd))
1874        }
1875        "li" => on_item(StyledEventOrRun::Event(StyledEvent::ListItemEnd)),
1876        "h1" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(1))),
1877        "h2" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(2))),
1878        "h3" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(3))),
1879        "h4" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(4))),
1880        "h5" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(5))),
1881        "h6" => on_item(StyledEventOrRun::Event(StyledEvent::HeadingEnd(6))),
1882        _ => {}
1883    }
1884}
1885
1886fn role_from_tag(tag: &str) -> Option<BlockRole> {
1887    match tag {
1888        "p" | "div" => Some(BlockRole::Paragraph),
1889        "li" => Some(BlockRole::ListItem),
1890        "figcaption" => Some(BlockRole::FigureCaption),
1891        "h1" => Some(BlockRole::Heading(1)),
1892        "h2" => Some(BlockRole::Heading(2)),
1893        "h3" => Some(BlockRole::Heading(3)),
1894        "h4" => Some(BlockRole::Heading(4)),
1895        "h5" => Some(BlockRole::Heading(5)),
1896        "h6" => Some(BlockRole::Heading(6)),
1897        _ => None,
1898    }
1899}
1900
1901fn should_skip_tag(tag: &str) -> bool {
1902    matches!(tag, "script" | "style" | "head" | "noscript")
1903}
1904
1905fn is_preformatted_context(stack: &[ElementCtx]) -> bool {
1906    stack.iter().any(|ctx| {
1907        matches!(
1908            ctx.tag.as_str(),
1909            "pre" | "code" | "kbd" | "samp" | "textarea"
1910        )
1911    })
1912}
1913
1914fn normalize_plain_text_whitespace(text: &str, preserve: bool) -> String {
1915    if preserve {
1916        return text.to_string();
1917    }
1918    let mut result = String::with_capacity(text.len());
1919    let mut prev_space = true;
1920    for ch in text.chars() {
1921        if ch.is_whitespace() {
1922            if !prev_space {
1923                result.push(' ');
1924                prev_space = true;
1925            }
1926        } else {
1927            result.push(ch);
1928            prev_space = false;
1929        }
1930    }
1931    if result.ends_with(' ') {
1932        result.pop();
1933    }
1934    result
1935}
1936
1937fn normalize_family(family: &str) -> String {
1938    family
1939        .trim()
1940        .trim_matches('"')
1941        .trim_matches('\'')
1942        .to_ascii_lowercase()
1943}
1944
1945fn has_non_ascii(text: &str) -> bool {
1946    !text.is_ascii()
1947}
1948
1949fn resolve_item_with_font(
1950    font_resolver: &FontResolver,
1951    item: StyledEventOrRun,
1952) -> (StyledEventOrRun, RenderPrepTrace) {
1953    match item {
1954        StyledEventOrRun::Run(mut run) => {
1955            let trace = font_resolver.resolve_with_trace_for_text(&run.style, Some(&run.text));
1956            run.font_id = trace.face.font_id;
1957            run.resolved_family = trace.face.family.clone();
1958            let style = run.style.clone();
1959            (
1960                StyledEventOrRun::Run(run),
1961                RenderPrepTrace::Run {
1962                    style: Box::new(style),
1963                    font: Box::new(trace),
1964                },
1965            )
1966        }
1967        StyledEventOrRun::Event(event) => (StyledEventOrRun::Event(event), RenderPrepTrace::Event),
1968        StyledEventOrRun::Image(image) => (StyledEventOrRun::Image(image), RenderPrepTrace::Event),
1969    }
1970}
1971
1972fn resolve_item_assets_for_chapter(
1973    chapter_href: &str,
1974    image_dimensions: Option<&BTreeMap<String, (u16, u16)>>,
1975    mut item: StyledEventOrRun,
1976) -> StyledEventOrRun {
1977    if let StyledEventOrRun::Image(image) = &mut item {
1978        image.src = resolve_relative(chapter_href, &image.src);
1979        if let Some(dimensions) = image_dimensions {
1980            let key = resource_path_without_fragment(&image.src);
1981            if let Some((intrinsic_w, intrinsic_h)) = dimensions.get(key).copied() {
1982                match (image.width_px, image.height_px) {
1983                    (None, None) => {
1984                        image.width_px = Some(intrinsic_w);
1985                        image.height_px = Some(intrinsic_h);
1986                    }
1987                    (Some(width), None) if intrinsic_w > 0 => {
1988                        let ratio = intrinsic_h as f32 / intrinsic_w as f32;
1989                        let resolved = ((width as f32) * ratio).round();
1990                        image.height_px = bounded_nonzero_u16_f32(resolved);
1991                    }
1992                    (None, Some(height)) if intrinsic_h > 0 => {
1993                        let ratio = intrinsic_w as f32 / intrinsic_h as f32;
1994                        let resolved = ((height as f32) * ratio).round();
1995                        image.width_px = bounded_nonzero_u16_f32(resolved);
1996                    }
1997                    _ => {}
1998                }
1999            }
2000        }
2001    }
2002    item
2003}
2004
2005fn split_family_stack(value: &str) -> Vec<String> {
2006    value
2007        .split(',')
2008        .map(|part| part.trim().trim_matches('"').trim_matches('\''))
2009        .filter(|part| !part.is_empty())
2010        .map(|part| part.to_string())
2011        .collect()
2012}
2013
2014pub(crate) fn resolve_relative(base_path: &str, rel: &str) -> String {
2015    if rel.contains("://") {
2016        return rel.to_string();
2017    }
2018    if rel.starts_with('/') {
2019        return normalize_path(rel.trim_start_matches('/'));
2020    }
2021    let base_dir = base_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
2022    if base_dir.is_empty() {
2023        normalize_path(rel)
2024    } else {
2025        normalize_path(&format!("{}/{}", base_dir, rel))
2026    }
2027}
2028
2029fn resource_path_without_fragment(path: &str) -> &str {
2030    path.split('#').next().unwrap_or(path)
2031}
2032
2033fn bounded_nonzero_u16(value: u32) -> Option<u16> {
2034    if value == 0 || value > u16::MAX as u32 {
2035        None
2036    } else {
2037        Some(value as u16)
2038    }
2039}
2040
2041fn bounded_nonzero_u16_f32(value: f32) -> Option<u16> {
2042    if !value.is_finite() {
2043        return None;
2044    }
2045    let rounded = value.round();
2046    if rounded <= 0.0 || rounded > u16::MAX as f32 {
2047        None
2048    } else {
2049        Some(rounded as u16)
2050    }
2051}
2052
2053fn collect_image_sources_from_html(chapter_href: &str, html: &[u8]) -> Vec<String> {
2054    let mut reader = Reader::from_reader(html);
2055    let mut buf = Vec::with_capacity(0);
2056    let mut out = BTreeSet::new();
2057
2058    loop {
2059        match reader.read_event_into(&mut buf) {
2060            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
2061                let Ok(tag) = decode_tag_name(&reader, e.name().as_ref()) else {
2062                    buf.clear();
2063                    continue;
2064                };
2065                if !matches!(tag.as_str(), "img" | "image") {
2066                    buf.clear();
2067                    continue;
2068                }
2069                if let Some(src) = image_src_from_start(&reader, &tag, &e) {
2070                    out.insert(resolve_relative(chapter_href, &src));
2071                }
2072            }
2073            Ok(Event::Eof) => break,
2074            Ok(_) => {}
2075            Err(_) => break,
2076        }
2077        buf.clear();
2078    }
2079
2080    out.into_iter().collect()
2081}
2082
2083fn image_src_from_start(
2084    reader: &Reader<&[u8]>,
2085    tag: &str,
2086    start: &quick_xml::events::BytesStart<'_>,
2087) -> Option<String> {
2088    for attr in start.attributes().flatten() {
2089        let key = match reader.decoder().decode(attr.key.as_ref()) {
2090            Ok(v) => v.to_ascii_lowercase(),
2091            Err(_) => continue,
2092        };
2093        if key == "src"
2094            || ((key == "href" || key == "xlink:href") && matches!(tag, "img" | "image"))
2095        {
2096            let value = match reader.decoder().decode(&attr.value) {
2097                Ok(v) => v.to_string(),
2098                Err(_) => continue,
2099            };
2100            if !value.is_empty() {
2101                return Some(value);
2102            }
2103        }
2104    }
2105    None
2106}
2107
2108fn infer_image_dimensions_from_bytes(bytes: &[u8]) -> Option<(u16, u16)> {
2109    infer_png_dimensions(bytes)
2110        .or_else(|| infer_jpeg_dimensions(bytes))
2111        .or_else(|| infer_gif_dimensions(bytes))
2112        .or_else(|| infer_webp_dimensions(bytes))
2113        .or_else(|| infer_svg_dimensions(bytes))
2114}
2115
2116fn infer_png_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
2117    const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
2118    if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
2119        return None;
2120    }
2121    let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
2122    let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
2123    Some((bounded_nonzero_u16(width)?, bounded_nonzero_u16(height)?))
2124}
2125
2126fn infer_gif_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
2127    if bytes.len() < 10 {
2128        return None;
2129    }
2130    if &bytes[..6] != b"GIF87a" && &bytes[..6] != b"GIF89a" {
2131        return None;
2132    }
2133    let width = u16::from_le_bytes([bytes[6], bytes[7]]);
2134    let height = u16::from_le_bytes([bytes[8], bytes[9]]);
2135    if width == 0 || height == 0 {
2136        return None;
2137    }
2138    Some((width, height))
2139}
2140
2141fn infer_jpeg_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
2142    if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
2143        return None;
2144    }
2145    let mut i = 2usize;
2146    while i + 1 < bytes.len() {
2147        while i < bytes.len() && bytes[i] != 0xFF {
2148            i += 1;
2149        }
2150        while i < bytes.len() && bytes[i] == 0xFF {
2151            i += 1;
2152        }
2153        if i >= bytes.len() {
2154            break;
2155        }
2156        let marker = bytes[i];
2157        i += 1;
2158
2159        if marker == 0xD9 || marker == 0xDA {
2160            break;
2161        }
2162        if i + 1 >= bytes.len() {
2163            break;
2164        }
2165        let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
2166        if seg_len < 2 {
2167            break;
2168        }
2169        let payload_start = i + 2;
2170        let payload_end = i.saturating_add(seg_len);
2171        if payload_end > bytes.len() {
2172            break;
2173        }
2174        if is_jpeg_sof_marker(marker) && seg_len >= 7 {
2175            if payload_start + 4 >= bytes.len() {
2176                break;
2177            }
2178            let height =
2179                u16::from_be_bytes([bytes[payload_start + 1], bytes[payload_start + 2]]) as u32;
2180            let width =
2181                u16::from_be_bytes([bytes[payload_start + 3], bytes[payload_start + 4]]) as u32;
2182            return Some((bounded_nonzero_u16(width)?, bounded_nonzero_u16(height)?));
2183        }
2184        i = payload_end;
2185    }
2186    None
2187}
2188
2189fn is_jpeg_sof_marker(marker: u8) -> bool {
2190    matches!(
2191        marker,
2192        0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
2193    )
2194}
2195
2196fn infer_webp_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
2197    if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
2198        return None;
2199    }
2200    let mut offset = 12usize;
2201    while offset + 8 <= bytes.len() {
2202        let chunk_tag = &bytes[offset..offset + 4];
2203        let chunk_len = u32::from_le_bytes([
2204            bytes[offset + 4],
2205            bytes[offset + 5],
2206            bytes[offset + 6],
2207            bytes[offset + 7],
2208        ]) as usize;
2209        let payload_start = offset + 8;
2210        let payload_end = payload_start.saturating_add(chunk_len);
2211        if payload_end > bytes.len() {
2212            break;
2213        }
2214
2215        match chunk_tag {
2216            b"VP8X" if chunk_len >= 10 => {
2217                let w_minus_1 = (bytes[payload_start + 4] as u32)
2218                    | ((bytes[payload_start + 5] as u32) << 8)
2219                    | ((bytes[payload_start + 6] as u32) << 16);
2220                let h_minus_1 = (bytes[payload_start + 7] as u32)
2221                    | ((bytes[payload_start + 8] as u32) << 8)
2222                    | ((bytes[payload_start + 9] as u32) << 16);
2223                return Some((
2224                    bounded_nonzero_u16(w_minus_1 + 1)?,
2225                    bounded_nonzero_u16(h_minus_1 + 1)?,
2226                ));
2227            }
2228            b"VP8L" if chunk_len >= 5 && bytes[payload_start] == 0x2F => {
2229                let bits = u32::from_le_bytes([
2230                    bytes[payload_start + 1],
2231                    bytes[payload_start + 2],
2232                    bytes[payload_start + 3],
2233                    bytes[payload_start + 4],
2234                ]);
2235                let width = (bits & 0x3FFF) + 1;
2236                let height = ((bits >> 14) & 0x3FFF) + 1;
2237                return Some((bounded_nonzero_u16(width)?, bounded_nonzero_u16(height)?));
2238            }
2239            b"VP8 " if chunk_len >= 10 => {
2240                if bytes[payload_start + 3..payload_start + 6] != [0x9D, 0x01, 0x2A] {
2241                    return None;
2242                }
2243                let width =
2244                    u16::from_le_bytes([bytes[payload_start + 6], bytes[payload_start + 7]])
2245                        & 0x3FFF;
2246                let height =
2247                    u16::from_le_bytes([bytes[payload_start + 8], bytes[payload_start + 9]])
2248                        & 0x3FFF;
2249                if width == 0 || height == 0 {
2250                    return None;
2251                }
2252                return Some((width, height));
2253            }
2254            _ => {}
2255        }
2256
2257        offset = payload_end + (chunk_len & 1);
2258    }
2259    None
2260}
2261
2262fn infer_svg_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
2263    let mut reader = Reader::from_reader(bytes);
2264    let mut buf = Vec::with_capacity(0);
2265    loop {
2266        match reader.read_event_into(&mut buf) {
2267            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
2268                let tag = decode_tag_name(&reader, e.name().as_ref()).ok()?;
2269                if tag != "svg" {
2270                    buf.clear();
2271                    continue;
2272                }
2273                let mut width = None;
2274                let mut height = None;
2275                let mut view_box = None;
2276                for attr in e.attributes().flatten() {
2277                    let key = match reader.decoder().decode(attr.key.as_ref()) {
2278                        Ok(v) => v.to_ascii_lowercase(),
2279                        Err(_) => continue,
2280                    };
2281                    let value = match reader.decoder().decode(&attr.value) {
2282                        Ok(v) => v.to_string(),
2283                        Err(_) => continue,
2284                    };
2285                    match key.as_str() {
2286                        "width" => width = parse_svg_length_px(&value),
2287                        "height" => height = parse_svg_length_px(&value),
2288                        "viewbox" => view_box = parse_svg_view_box(&value),
2289                        _ => {}
2290                    }
2291                }
2292                if let (Some(w), Some(h)) = (width, height) {
2293                    return Some((bounded_nonzero_u16_f32(w)?, bounded_nonzero_u16_f32(h)?));
2294                }
2295                if let Some((w, h)) = view_box {
2296                    return Some((bounded_nonzero_u16_f32(w)?, bounded_nonzero_u16_f32(h)?));
2297                }
2298                return None;
2299            }
2300            Ok(Event::Eof) => break,
2301            Ok(_) => {}
2302            Err(_) => break,
2303        }
2304        buf.clear();
2305    }
2306    None
2307}
2308
2309fn parse_svg_length_px(raw: &str) -> Option<f32> {
2310    let trimmed = raw.trim();
2311    if trimmed.is_empty() || trimmed.ends_with('%') {
2312        return None;
2313    }
2314    let mut boundary = 0usize;
2315    for (idx, ch) in trimmed.char_indices() {
2316        if ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E') {
2317            boundary = idx + ch.len_utf8();
2318        } else {
2319            break;
2320        }
2321    }
2322    if boundary == 0 {
2323        return None;
2324    }
2325    let value = trimmed[..boundary].trim().parse::<f32>().ok()?;
2326    let unit = trimmed[boundary..].trim().to_ascii_lowercase();
2327    let factor = match unit.as_str() {
2328        "" | "px" => 1.0,
2329        "pt" => 96.0 / 72.0,
2330        "pc" => 16.0,
2331        "in" => 96.0,
2332        "cm" => 96.0 / 2.54,
2333        "mm" => 96.0 / 25.4,
2334        "q" => 96.0 / 101.6,
2335        _ => return None,
2336    };
2337    Some(value * factor)
2338}
2339
2340fn parse_svg_view_box(raw: &str) -> Option<(f32, f32)> {
2341    let mut nums = raw
2342        .split(|ch: char| ch.is_whitespace() || ch == ',')
2343        .filter(|part| !part.trim().is_empty())
2344        .filter_map(|part| part.trim().parse::<f32>().ok());
2345    let _min_x = nums.next()?;
2346    let _min_y = nums.next()?;
2347    let width = nums.next()?;
2348    let height = nums.next()?;
2349    if width <= 0.0 || height <= 0.0 {
2350        return None;
2351    }
2352    Some((width, height))
2353}
2354
2355fn normalize_path(path: &str) -> String {
2356    let mut parts: Vec<&str> = Vec::with_capacity(0);
2357    for part in path.split('/') {
2358        match part {
2359            "" | "." => {}
2360            ".." => {
2361                parts.pop();
2362            }
2363            _ => parts.push(part),
2364        }
2365    }
2366    parts.join("/")
2367}
2368
2369pub(crate) fn parse_stylesheet_links(chapter_href: &str, html: &str) -> Vec<String> {
2370    parse_stylesheet_links_bytes(chapter_href, html.as_bytes())
2371}
2372
2373pub(crate) fn parse_stylesheet_links_bytes(chapter_href: &str, html_bytes: &[u8]) -> Vec<String> {
2374    let mut out = Vec::with_capacity(0);
2375    let mut reader = Reader::from_reader(html_bytes);
2376    reader.config_mut().trim_text(true);
2377    let mut buf = Vec::with_capacity(0);
2378
2379    loop {
2380        match reader.read_event_into(&mut buf) {
2381            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
2382                let tag = match reader.decoder().decode(e.name().as_ref()) {
2383                    Ok(v) => v.to_string(),
2384                    Err(_) => {
2385                        buf.clear();
2386                        continue;
2387                    }
2388                };
2389                let tag_local = tag.rsplit(':').next().unwrap_or(tag.as_str());
2390                if tag_local != "link" {
2391                    buf.clear();
2392                    continue;
2393                }
2394                let mut href = None;
2395                let mut rel = None;
2396                for attr in e.attributes().flatten() {
2397                    let key = match reader.decoder().decode(attr.key.as_ref()) {
2398                        Ok(v) => v,
2399                        Err(_) => continue,
2400                    };
2401                    let val = match reader.decoder().decode(&attr.value) {
2402                        Ok(v) => v.to_string(),
2403                        Err(_) => continue,
2404                    };
2405                    if key == "href" {
2406                        href = Some(val);
2407                    } else if key == "rel" {
2408                        rel = Some(val);
2409                    }
2410                }
2411                if let (Some(href), Some(rel)) = (href, rel) {
2412                    if rel
2413                        .split_whitespace()
2414                        .any(|v| v.eq_ignore_ascii_case("stylesheet"))
2415                    {
2416                        out.push(resolve_relative(chapter_href, &href));
2417                    }
2418                }
2419            }
2420            Ok(Event::Eof) => break,
2421            Ok(_) => {}
2422            Err(_) => break,
2423        }
2424        buf.clear();
2425    }
2426
2427    out
2428}
2429
2430fn font_src_rank(path: &str) -> u8 {
2431    let lower = path.to_ascii_lowercase();
2432    if lower.ends_with(".ttf") || lower.ends_with(".otf") {
2433        3
2434    } else if lower.ends_with(".woff2") {
2435        2
2436    } else if lower.ends_with(".woff") {
2437        1
2438    } else {
2439        0
2440    }
2441}
2442
2443fn extract_font_face_src(css_href: &str, src_value: &str) -> Option<String> {
2444    let lower = src_value.to_ascii_lowercase();
2445    let mut search_from = 0usize;
2446    let mut best: Option<(u8, String)> = None;
2447
2448    while let Some(idx) = lower[search_from..].find("url(") {
2449        let start = search_from + idx + 4;
2450        let tail = &src_value[start..];
2451        let Some(end) = tail.find(')') else {
2452            break;
2453        };
2454        let raw = tail[..end].trim().trim_matches('"').trim_matches('\'');
2455        if !raw.is_empty() && !raw.starts_with("data:") {
2456            let resolved = resolve_relative(css_href, raw);
2457            let rank = font_src_rank(&resolved);
2458            match &best {
2459                Some((best_rank, _)) if *best_rank >= rank => {}
2460                _ => best = Some((rank, resolved)),
2461            }
2462        }
2463        search_from = start + end + 1;
2464    }
2465
2466    best.map(|(_, path)| path)
2467}
2468
2469pub(crate) fn parse_font_faces_from_css(css_href: &str, css: &str) -> Vec<EmbeddedFontFace> {
2470    let mut out = Vec::with_capacity(0);
2471    let lower = css.to_ascii_lowercase();
2472    let mut search_from = 0usize;
2473
2474    while let Some(idx) = lower[search_from..].find("@font-face") {
2475        let start = search_from + idx;
2476        let block_start = match css[start..].find('{') {
2477            Some(i) => start + i + 1,
2478            None => break,
2479        };
2480        let block_end = match css[block_start..].find('}') {
2481            Some(i) => block_start + i,
2482            None => break,
2483        };
2484        let block = &css[block_start..block_end];
2485
2486        let mut family = None;
2487        let mut weight = 400u16;
2488        let mut style = EmbeddedFontStyle::Normal;
2489        let mut stretch = None;
2490        let mut href = None;
2491        let mut format_hint = None;
2492
2493        for decl in block.split(';') {
2494            let decl = decl.trim();
2495            if decl.is_empty() {
2496                continue;
2497            }
2498            let Some(colon) = decl.find(':') else {
2499                continue;
2500            };
2501            let key = decl[..colon].trim().to_ascii_lowercase();
2502            let value = decl[colon + 1..].trim();
2503            match key.as_str() {
2504                "font-family" => {
2505                    let val = value.trim_matches('"').trim_matches('\'').trim();
2506                    if !val.is_empty() {
2507                        family = Some(val.to_string());
2508                    }
2509                }
2510                "font-weight" => {
2511                    let lower = value.to_ascii_lowercase();
2512                    weight = if lower == "bold" {
2513                        700
2514                    } else if lower == "normal" {
2515                        400
2516                    } else {
2517                        lower.parse::<u16>().unwrap_or(400)
2518                    };
2519                }
2520                "font-style" => {
2521                    let lower = value.to_ascii_lowercase();
2522                    style = if lower == "italic" {
2523                        EmbeddedFontStyle::Italic
2524                    } else if lower == "oblique" {
2525                        EmbeddedFontStyle::Oblique
2526                    } else {
2527                        EmbeddedFontStyle::Normal
2528                    };
2529                }
2530                "font-stretch" => {
2531                    if !value.is_empty() {
2532                        stretch = Some(value.to_string());
2533                    }
2534                }
2535                "src" => {
2536                    href = extract_font_face_src(css_href, value);
2537                    if let Some(fmt_idx) = value.to_ascii_lowercase().find("format(") {
2538                        let fmt_tail = &value[fmt_idx + 7..];
2539                        if let Some(end_paren) = fmt_tail.find(')') {
2540                            let raw = fmt_tail[..end_paren]
2541                                .trim()
2542                                .trim_matches('"')
2543                                .trim_matches('\'');
2544                            if !raw.is_empty() {
2545                                format_hint = Some(raw.to_string());
2546                            }
2547                        }
2548                    }
2549                }
2550                _ => {}
2551            }
2552        }
2553
2554        if let (Some(family), Some(href)) = (family, href) {
2555            out.push(EmbeddedFontFace {
2556                family,
2557                weight,
2558                style,
2559                stretch,
2560                href,
2561                format: format_hint,
2562            });
2563        }
2564
2565        search_from = block_end + 1;
2566    }
2567
2568    out
2569}
2570
2571#[cfg(test)]
2572mod tests {
2573    use super::*;
2574
2575    #[test]
2576    fn skip_tag_retains_semantic_elements() {
2577        assert!(!should_skip_tag("nav"));
2578        assert!(!should_skip_tag("header"));
2579        assert!(!should_skip_tag("footer"));
2580        assert!(!should_skip_tag("aside"));
2581        assert!(should_skip_tag("script"));
2582    }
2583
2584    #[test]
2585    fn normalize_whitespace_preserves_preformatted_context() {
2586        let s = "a\n  b\t c";
2587        assert_eq!(normalize_plain_text_whitespace(s, true), s);
2588        assert_eq!(normalize_plain_text_whitespace(s, false), "a b c");
2589    }
2590
2591    #[test]
2592    fn parse_stylesheet_links_resolves_relative_paths() {
2593        let html = r#"<html><head>
2594<link rel="stylesheet" href="../styles/base.css"/>
2595<link rel="alternate stylesheet" href="theme.css"/>
2596</head></html>"#;
2597        let links = parse_stylesheet_links("text/ch1.xhtml", html);
2598        assert_eq!(links, vec!["styles/base.css", "text/theme.css"]);
2599    }
2600
2601    #[test]
2602    fn parse_font_faces_prefers_ttf_otf_sources() {
2603        let css = r#"
2604@font-face {
2605  font-family: "Test";
2606  src: local("Test"), url("../fonts/test.woff2") format("woff2"), url("../fonts/test.ttf") format("truetype");
2607}
2608"#;
2609        let faces = parse_font_faces_from_css("styles/main.css", css);
2610        assert_eq!(faces.len(), 1);
2611        assert_eq!(faces[0].href, "fonts/test.ttf");
2612    }
2613
2614    #[test]
2615    fn parse_font_faces_extracts_basic_metadata() {
2616        let css = r#"
2617@font-face {
2618  font-family: 'Literata';
2619  font-style: italic;
2620  font-weight: 700;
2621  src: url('../fonts/Literata-Italic.woff2') format('woff2');
2622}
2623"#;
2624        let faces = parse_font_faces_from_css("styles/main.css", css);
2625        assert_eq!(faces.len(), 1);
2626        let face = &faces[0];
2627        assert_eq!(face.family, "Literata");
2628        assert_eq!(face.weight, 700);
2629        assert_eq!(face.style, EmbeddedFontStyle::Italic);
2630        assert_eq!(face.href, "fonts/Literata-Italic.woff2");
2631        assert_eq!(face.format.as_deref(), Some("woff2"));
2632    }
2633
2634    #[test]
2635    fn styler_emits_runs_for_text() {
2636        let mut styler = Styler::new(StyleConfig::default());
2637        styler
2638            .load_stylesheets(&ChapterStylesheets::default())
2639            .expect("load should succeed");
2640        let chapter = styler
2641            .style_chapter("<h1>Title</h1><p>Hello world</p>")
2642            .expect("style should succeed");
2643        assert!(chapter.runs().count() >= 2);
2644    }
2645
2646    #[test]
2647    fn styler_style_chapter_with_streams_items() {
2648        let mut styler = Styler::new(StyleConfig::default());
2649        styler
2650            .load_stylesheets(&ChapterStylesheets::default())
2651            .expect("load should succeed");
2652        let mut seen = 0usize;
2653        styler
2654            .style_chapter_with("<p>Hello</p>", |_item| {
2655                seen += 1;
2656            })
2657            .expect("style_chapter_with should succeed");
2658        assert!(seen > 0);
2659    }
2660
2661    #[test]
2662    fn infer_image_dimensions_parses_common_formats() {
2663        let mut png = Vec::from(&b"\x89PNG\r\n\x1a\n"[..]);
2664        png.extend_from_slice(&[0, 0, 0, 13]);
2665        png.extend_from_slice(b"IHDR");
2666        png.extend_from_slice(&640u32.to_be_bytes());
2667        png.extend_from_slice(&960u32.to_be_bytes());
2668        png.extend_from_slice(&[8, 2, 0, 0, 0]);
2669        assert_eq!(infer_image_dimensions_from_bytes(&png), Some((640, 960)));
2670
2671        let gif = [
2672            b'G', b'I', b'F', b'8', b'9', b'a', 0x20, 0x03, 0x58, 0x02, 0, 0,
2673        ];
2674        assert_eq!(infer_image_dimensions_from_bytes(&gif), Some((800, 600)));
2675
2676        let jpeg = [
2677            0xFF, 0xD8, // SOI
2678            0xFF, 0xE0, 0x00, 0x10, // APP0 len=16
2679            b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, // APP0 payload
2680            0xFF, 0xC0, 0x00, 0x11, // SOF0 len=17
2681            0x08, // precision
2682            0x02, 0x58, // height 600
2683            0x03, 0x20, // width 800
2684            0x03, // components
2685            0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0xFF, 0xD9,
2686        ];
2687        assert_eq!(infer_image_dimensions_from_bytes(&jpeg), Some((800, 600)));
2688
2689        let mut webp = Vec::from(&b"RIFF"[..]);
2690        webp.extend_from_slice(&0u32.to_le_bytes());
2691        webp.extend_from_slice(b"WEBPVP8X");
2692        webp.extend_from_slice(&10u32.to_le_bytes());
2693        webp.extend_from_slice(&[0, 0, 0, 0]);
2694        let w_minus_1 = 799u32;
2695        webp.extend_from_slice(&[
2696            (w_minus_1 & 0xFF) as u8,
2697            ((w_minus_1 >> 8) & 0xFF) as u8,
2698            ((w_minus_1 >> 16) & 0xFF) as u8,
2699        ]);
2700        let h_minus_1 = 599u32;
2701        webp.extend_from_slice(&[
2702            (h_minus_1 & 0xFF) as u8,
2703            ((h_minus_1 >> 8) & 0xFF) as u8,
2704            ((h_minus_1 >> 16) & 0xFF) as u8,
2705        ]);
2706        assert_eq!(infer_image_dimensions_from_bytes(&webp), Some((800, 600)));
2707
2708        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 480"></svg>"#;
2709        assert_eq!(infer_image_dimensions_from_bytes(svg), Some((320, 480)));
2710    }
2711
2712    #[test]
2713    fn resolve_item_assets_uses_intrinsic_dimensions_when_missing() {
2714        let mut map = BTreeMap::new();
2715        map.insert("images/cover.jpg".to_string(), (600u16, 900u16));
2716        let item = StyledEventOrRun::Image(StyledImage {
2717            src: "../images/cover.jpg".to_string(),
2718            alt: String::new(),
2719            width_px: None,
2720            height_px: Some(300),
2721            in_figure: false,
2722        });
2723        let resolved = resolve_item_assets_for_chapter("text/ch01.xhtml", Some(&map), item);
2724        let StyledEventOrRun::Image(image) = resolved else {
2725            panic!("expected image");
2726        };
2727        assert_eq!(image.src, "images/cover.jpg");
2728        assert_eq!(image.width_px, Some(200));
2729        assert_eq!(image.height_px, Some(300));
2730    }
2731
2732    #[test]
2733    fn styler_emits_inline_image_event_with_dimension_hints() {
2734        let mut styler = Styler::new(StyleConfig::default());
2735        styler
2736            .load_stylesheets(&ChapterStylesheets::default())
2737            .expect("load should succeed");
2738        let chapter = styler
2739            .style_chapter("<p>Intro</p><img src=\"images/cover.jpg\" alt=\"Cover\" width=\"320\" height=\"480\"/>")
2740            .expect("style should succeed");
2741
2742        let image = chapter
2743            .iter()
2744            .find_map(|item| match item {
2745                StyledEventOrRun::Image(img) => Some(img),
2746                _ => None,
2747            })
2748            .expect("expected image event");
2749        assert_eq!(image.src, "images/cover.jpg");
2750        assert_eq!(image.alt, "Cover");
2751        assert_eq!(image.width_px, Some(320));
2752        assert_eq!(image.height_px, Some(480));
2753    }
2754
2755    #[test]
2756    fn styler_parses_px_dimension_hints_and_ignores_missing_src() {
2757        let mut styler = Styler::new(StyleConfig::default());
2758        styler
2759            .load_stylesheets(&ChapterStylesheets::default())
2760            .expect("load should succeed");
2761        let chapter = styler
2762            .style_chapter("<img alt=\"No source\" width=\"80px\" height=\"60px\"/><img src=\"images/inline.png\" width=\"80px\" height=\"60px\"/>")
2763            .expect("style should succeed");
2764
2765        let images: Vec<&StyledImage> = chapter
2766            .iter()
2767            .filter_map(|item| match item {
2768                StyledEventOrRun::Image(img) => Some(img),
2769                _ => None,
2770            })
2771            .collect();
2772        assert_eq!(images.len(), 1);
2773        assert_eq!(images[0].src, "images/inline.png");
2774        assert_eq!(images[0].width_px, Some(80));
2775        assert_eq!(images[0].height_px, Some(60));
2776    }
2777
2778    #[test]
2779    fn styler_marks_images_inside_figure_and_figcaption_role() {
2780        let mut styler = Styler::new(StyleConfig::default());
2781        styler
2782            .load_stylesheets(&ChapterStylesheets::default())
2783            .expect("load should succeed");
2784        let chapter = styler
2785            .style_chapter(
2786                "<figure><img src=\"images/inline.png\"/><figcaption>Figure caption</figcaption></figure>",
2787            )
2788            .expect("style should succeed");
2789
2790        let image = chapter
2791            .iter()
2792            .find_map(|item| match item {
2793                StyledEventOrRun::Image(img) => Some(img),
2794                _ => None,
2795            })
2796            .expect("expected image");
2797        assert!(image.in_figure);
2798
2799        let caption_run = chapter
2800            .runs()
2801            .find(|run| run.text.contains("Figure caption"))
2802            .expect("caption text run expected");
2803        assert!(matches!(
2804            caption_run.style.block_role,
2805            BlockRole::FigureCaption
2806        ));
2807    }
2808
2809    #[test]
2810    fn styler_emits_svg_image_event_from_xlink_href() {
2811        let mut styler = Styler::new(StyleConfig::default());
2812        styler
2813            .load_stylesheets(&ChapterStylesheets::default())
2814            .expect("load should succeed");
2815        let chapter = styler
2816            .style_chapter(
2817                "<p>Intro</p><svg><image xlink:href=\"images/cover.svg\" title=\"Vector cover\" width=\"240\" height=\"320\"></image></svg><p>Outro</p>",
2818            )
2819            .expect("style should succeed");
2820
2821        let image = chapter
2822            .iter()
2823            .find_map(|item| match item {
2824                StyledEventOrRun::Image(img) => Some(img),
2825                _ => None,
2826            })
2827            .expect("expected svg image event");
2828        assert_eq!(image.src, "images/cover.svg");
2829        assert_eq!(image.alt, "Vector cover");
2830        assert_eq!(image.width_px, Some(240));
2831        assert_eq!(image.height_px, Some(320));
2832        assert!(chapter.runs().any(|run| run.text == "Outro"));
2833    }
2834
2835    #[test]
2836    fn styler_linearizes_basic_table_rows_and_cells() {
2837        let mut styler = Styler::new(StyleConfig::default());
2838        styler
2839            .load_stylesheets(&ChapterStylesheets::default())
2840            .expect("load should succeed");
2841        let chapter = styler
2842            .style_chapter(
2843                "<table><tr><th>Col A</th><th>Col B</th></tr><tr><td>1</td><td>2</td></tr></table>",
2844            )
2845            .expect("style should succeed");
2846
2847        let runs: Vec<&str> = chapter.runs().map(|run| run.text.as_str()).collect();
2848        assert!(runs.windows(3).any(|w| w == ["Col A", " | ", "Col B"]));
2849        assert!(runs.windows(3).any(|w| w == ["1", " | ", "2"]));
2850        let starts = chapter
2851            .iter()
2852            .filter(|item| matches!(item, StyledEventOrRun::Event(StyledEvent::ParagraphStart)))
2853            .count();
2854        let ends = chapter
2855            .iter()
2856            .filter(|item| matches!(item, StyledEventOrRun::Event(StyledEvent::ParagraphEnd)))
2857            .count();
2858        assert!(starts >= 2);
2859        assert_eq!(starts, ends);
2860    }
2861
2862    #[test]
2863    fn styler_applies_class_and_inline_style() {
2864        let mut styler = Styler::new(StyleConfig::default());
2865        styler
2866            .load_stylesheets(&ChapterStylesheets {
2867                sources: vec![StylesheetSource {
2868                    href: "main.css".to_string(),
2869                    css: ".intro { font-size: 20px; font-style: normal; }".to_string(),
2870                }],
2871            })
2872            .expect("load should succeed");
2873        let chapter = styler
2874            .style_chapter("<p class=\"intro\" style=\"font-style: italic\">Hello</p>")
2875            .expect("style should succeed");
2876        let first = chapter.runs().next().expect("expected run");
2877        assert_eq!(first.style.size_px, 20.0);
2878        assert!(first.style.italic);
2879    }
2880
2881    #[test]
2882    fn styler_propagates_stylesheet_letter_spacing_px() {
2883        let mut styler = Styler::new(StyleConfig::default());
2884        styler
2885            .load_stylesheets(&ChapterStylesheets {
2886                sources: vec![StylesheetSource {
2887                    href: "main.css".to_string(),
2888                    css: "p { letter-spacing: 1.5px; }".to_string(),
2889                }],
2890            })
2891            .expect("load should succeed");
2892        let chapter = styler
2893            .style_chapter("<p>Hello</p>")
2894            .expect("style should succeed");
2895        let first = chapter.runs().next().expect("expected run");
2896        assert_eq!(first.style.letter_spacing, 1.5);
2897    }
2898
2899    #[test]
2900    fn styler_inline_letter_spacing_normal_overrides_parent() {
2901        let mut styler = Styler::new(StyleConfig::default());
2902        styler
2903            .load_stylesheets(&ChapterStylesheets {
2904                sources: vec![StylesheetSource {
2905                    href: "main.css".to_string(),
2906                    css: "p { letter-spacing: 2px; }".to_string(),
2907                }],
2908            })
2909            .expect("load should succeed");
2910        let chapter = styler
2911            .style_chapter("<p style=\"letter-spacing: normal\">Hello</p>")
2912            .expect("style should succeed");
2913        let first = chapter.runs().next().expect("expected run");
2914        assert_eq!(first.style.letter_spacing, 0.0);
2915    }
2916
2917    #[test]
2918    fn styler_respects_stylesheet_precedence_order() {
2919        let mut styler = Styler::new(StyleConfig::default());
2920        styler
2921            .load_stylesheets(&ChapterStylesheets {
2922                sources: vec![
2923                    StylesheetSource {
2924                        href: "a.css".to_string(),
2925                        css: "p { font-size: 12px; }".to_string(),
2926                    },
2927                    StylesheetSource {
2928                        href: "b.css".to_string(),
2929                        css: "p { font-size: 18px; }".to_string(),
2930                    },
2931                ],
2932            })
2933            .expect("load should succeed");
2934        let chapter = styler
2935            .style_chapter("<p>Hello</p>")
2936            .expect("style should succeed");
2937        let first = chapter.runs().next().expect("expected run");
2938        assert_eq!(first.style.size_px, 18.0);
2939    }
2940
2941    #[test]
2942    fn styler_enforces_css_byte_limit() {
2943        let mut styler = Styler::new(StyleConfig {
2944            limits: StyleLimits {
2945                max_css_bytes: 4,
2946                ..StyleLimits::default()
2947            },
2948            hints: LayoutHints::default(),
2949        });
2950        let styles = ChapterStylesheets {
2951            sources: vec![StylesheetSource {
2952                href: "a.css".to_string(),
2953                css: "p { font-weight: bold; }".to_string(),
2954            }],
2955        };
2956        let err = styler.load_stylesheets(&styles).expect_err("should reject");
2957        assert_eq!(err.code, "STYLE_CSS_TOO_LARGE");
2958        assert_eq!(err.phase, ErrorPhase::Style);
2959        let limit = err.limit.expect("expected limit context");
2960        assert_eq!(limit.kind.as_ref(), "max_css_bytes");
2961        assert!(limit.actual > limit.limit);
2962    }
2963
2964    #[test]
2965    fn styler_enforces_selector_limit() {
2966        let mut styler = Styler::new(StyleConfig {
2967            limits: StyleLimits {
2968                max_selectors: 1,
2969                ..StyleLimits::default()
2970            },
2971            hints: LayoutHints::default(),
2972        });
2973        let styles = ChapterStylesheets {
2974            sources: vec![StylesheetSource {
2975                href: "a.css".to_string(),
2976                css: "p { font-weight: bold; } h1 { font-style: italic; }".to_string(),
2977            }],
2978        };
2979        let err = styler.load_stylesheets(&styles).expect_err("should reject");
2980        assert_eq!(err.code, "STYLE_SELECTOR_LIMIT");
2981        assert_eq!(err.phase, ErrorPhase::Style);
2982        let limit = err.limit.expect("expected limit context");
2983        assert_eq!(limit.kind.as_ref(), "max_selectors");
2984        assert_eq!(limit.actual, 2);
2985        assert_eq!(limit.limit, 1);
2986        let ctx = err.context.expect("expected context");
2987        assert_eq!(ctx.selector_index, Some(1));
2988    }
2989
2990    #[test]
2991    fn styler_enforces_inline_style_byte_limit() {
2992        let mut styler = Styler::new(StyleConfig::default()).with_memory_budget(MemoryBudget {
2993            max_inline_style_bytes: 8,
2994            ..MemoryBudget::default()
2995        });
2996        styler
2997            .load_stylesheets(&ChapterStylesheets::default())
2998            .expect("load should succeed");
2999        let err = styler
3000            .style_chapter("<p style=\"font-weight: bold\">Hi</p>")
3001            .expect_err("should reject oversized inline style");
3002        assert_eq!(err.code, "STYLE_INLINE_BYTES_LIMIT");
3003        assert_eq!(err.phase, ErrorPhase::Style);
3004        let limit = err.limit.expect("expected limit context");
3005        assert_eq!(limit.kind.as_ref(), "max_inline_style_bytes");
3006        assert!(limit.actual > limit.limit);
3007        let ctx = err.context.expect("expected context");
3008        assert!(ctx.declaration.is_some());
3009        assert!(ctx.token_offset.is_some());
3010    }
3011
3012    #[test]
3013    fn style_tokenize_error_sets_token_offset_context() {
3014        let mut styler = Styler::new(StyleConfig::default());
3015        styler
3016            .load_stylesheets(&ChapterStylesheets::default())
3017            .expect("load should succeed");
3018        let err = styler
3019            .style_chapter("<p class=\"x></p>")
3020            .expect_err("should reject malformed xml");
3021        assert_eq!(err.code, "STYLE_TOKENIZE_ERROR");
3022        let ctx = err.context.expect("expected context");
3023        assert!(ctx.token_offset.is_some());
3024    }
3025
3026    #[test]
3027    fn render_prep_error_context_supports_typed_indices() {
3028        let err = RenderPrepError::new("TEST", "typed context")
3029            .with_phase(ErrorPhase::Style)
3030            .with_chapter_index(7)
3031            .with_limit("max_css_bytes", 10, 4)
3032            .with_selector_index(3)
3033            .with_declaration_index(1)
3034            .with_token_offset(9);
3035        assert_eq!(err.phase, ErrorPhase::Style);
3036        assert_eq!(err.chapter_index, Some(7));
3037        let limit = err.limit.expect("expected limit context");
3038        assert_eq!(limit.kind.as_ref(), "max_css_bytes");
3039        assert_eq!(limit.actual, 10);
3040        assert_eq!(limit.limit, 4);
3041        let ctx = err.context.expect("expected context");
3042        assert_eq!(ctx.selector_index, Some(3));
3043        assert_eq!(ctx.declaration_index, Some(1));
3044        assert_eq!(ctx.token_offset, Some(9));
3045    }
3046
3047    #[test]
3048    fn render_prep_error_bridges_to_phase_error() {
3049        let err = RenderPrepError::new("STYLE_CSS_TOO_LARGE", "limit")
3050            .with_phase(ErrorPhase::Style)
3051            .with_path("styles/main.css")
3052            .with_chapter_index(2)
3053            .with_selector_index(4)
3054            .with_limit("max_css_bytes", 1024, 256);
3055        let phase: PhaseError = err.into();
3056        assert_eq!(phase.phase, ErrorPhase::Style);
3057        assert_eq!(phase.code, "STYLE_CSS_TOO_LARGE");
3058        let ctx = phase.context.expect("expected context");
3059        assert_eq!(ctx.chapter_index, Some(2));
3060        let limit = ctx.limit.expect("expected limit");
3061        assert_eq!(limit.actual, 1024);
3062        assert_eq!(limit.limit, 256);
3063    }
3064
3065    #[test]
3066    fn font_resolver_trace_reports_fallback_chain() {
3067        let resolver = FontResolver::new(FontPolicy::serif_default());
3068        let style = ComputedTextStyle {
3069            family_stack: vec!["A".to_string(), "B".to_string()],
3070            weight: 400,
3071            italic: false,
3072            size_px: 16.0,
3073            line_height: 1.4,
3074            letter_spacing: 0.0,
3075            block_role: BlockRole::Body,
3076        };
3077        let trace = resolver.resolve_with_trace(&style);
3078        assert_eq!(trace.face.family, "serif");
3079        assert!(trace.reason_chain.len() >= 2);
3080    }
3081
3082    #[test]
3083    fn font_resolver_chooses_nearest_weight_and_style() {
3084        let mut resolver = FontResolver::new(FontPolicy::serif_default());
3085        let faces = vec![
3086            EmbeddedFontFace {
3087                family: "Literata".to_string(),
3088                weight: 400,
3089                style: EmbeddedFontStyle::Normal,
3090                stretch: None,
3091                href: "a.ttf".to_string(),
3092                format: None,
3093            },
3094            EmbeddedFontFace {
3095                family: "Literata".to_string(),
3096                weight: 700,
3097                style: EmbeddedFontStyle::Italic,
3098                stretch: None,
3099                href: "b.ttf".to_string(),
3100                format: None,
3101            },
3102        ];
3103        resolver
3104            .register_epub_fonts(faces, |_href| Ok(vec![1, 2, 3]))
3105            .expect("register should succeed");
3106        let style = ComputedTextStyle {
3107            family_stack: vec!["Literata".to_string()],
3108            weight: 680,
3109            italic: true,
3110            size_px: 16.0,
3111            line_height: 1.4,
3112            letter_spacing: 0.0,
3113            block_role: BlockRole::Body,
3114        };
3115        let trace = resolver.resolve_with_trace(&style);
3116        let chosen = trace.face.embedded.expect("should match embedded");
3117        assert_eq!(chosen.href, "b.ttf");
3118    }
3119
3120    #[test]
3121    fn font_resolver_reports_missing_glyph_risk_for_non_ascii_fallback() {
3122        let resolver = FontResolver::new(FontPolicy::serif_default());
3123        let style = ComputedTextStyle {
3124            family_stack: vec!["NoSuchFamily".to_string()],
3125            weight: 400,
3126            italic: false,
3127            size_px: 16.0,
3128            line_height: 1.4,
3129            letter_spacing: 0.0,
3130            block_role: BlockRole::Body,
3131        };
3132        let trace = resolver.resolve_with_trace_for_text(&style, Some("Привет"));
3133        assert!(trace
3134            .reason_chain
3135            .iter()
3136            .any(|v| v.contains("missing glyph risk")));
3137    }
3138
3139    #[test]
3140    fn font_resolver_deduplicates_faces() {
3141        let mut resolver = FontResolver::new(FontPolicy::serif_default()).with_limits(FontLimits {
3142            max_faces: 8,
3143            ..FontLimits::default()
3144        });
3145        let face = EmbeddedFontFace {
3146            family: "Literata".to_string(),
3147            weight: 400,
3148            style: EmbeddedFontStyle::Normal,
3149            stretch: None,
3150            href: "a.ttf".to_string(),
3151            format: None,
3152        };
3153        resolver
3154            .register_epub_fonts(vec![face.clone(), face], |_href| Ok(vec![1, 2, 3]))
3155            .expect("register should succeed");
3156        let style = ComputedTextStyle {
3157            family_stack: vec!["Literata".to_string()],
3158            weight: 400,
3159            italic: false,
3160            size_px: 16.0,
3161            line_height: 1.4,
3162            letter_spacing: 0.0,
3163            block_role: BlockRole::Body,
3164        };
3165        let trace = resolver.resolve_with_trace(&style);
3166        assert!(trace.face.embedded.is_some());
3167    }
3168
3169    #[test]
3170    fn font_resolver_register_rejects_too_many_faces() {
3171        let mut resolver = FontResolver::new(FontPolicy::serif_default()).with_limits(FontLimits {
3172            max_faces: 1,
3173            ..FontLimits::default()
3174        });
3175        let faces = vec![
3176            EmbeddedFontFace {
3177                family: "A".to_string(),
3178                weight: 400,
3179                style: EmbeddedFontStyle::Normal,
3180                stretch: None,
3181                href: "a.ttf".to_string(),
3182                format: None,
3183            },
3184            EmbeddedFontFace {
3185                family: "B".to_string(),
3186                weight: 400,
3187                style: EmbeddedFontStyle::Normal,
3188                stretch: None,
3189                href: "b.ttf".to_string(),
3190                format: None,
3191            },
3192        ];
3193        let err = resolver
3194            .register_epub_fonts(faces, |_href| Ok(vec![1, 2, 3]))
3195            .expect_err("should reject");
3196        assert_eq!(err.code, "FONT_FACE_LIMIT");
3197    }
3198
3199    #[test]
3200    fn render_prep_with_registered_fonts_uses_external_loader() {
3201        let called = std::cell::Cell::new(0usize);
3202        let prep = RenderPrep::new(RenderPrepOptions::default()).with_registered_fonts(
3203            vec![EmbeddedFontFace {
3204                family: "Custom".to_string(),
3205                weight: 400,
3206                style: EmbeddedFontStyle::Normal,
3207                stretch: None,
3208                href: "fonts/custom.ttf".to_string(),
3209                format: Some("truetype".to_string()),
3210            }],
3211            |href| {
3212                assert_eq!(href, "fonts/custom.ttf");
3213                called.set(called.get() + 1);
3214                Ok(vec![1, 2, 3, 4])
3215            },
3216        );
3217        assert!(prep.is_ok());
3218        assert_eq!(called.get(), 1);
3219    }
3220}