1extern 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct StyleLimits {
25 pub max_selectors: usize,
27 pub max_css_bytes: usize,
29 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct FontLimits {
46 pub max_faces: usize,
48 pub max_bytes_per_font: usize,
50 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#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct LayoutHints {
67 pub base_font_size_px: f32,
69 pub min_font_size_px: f32,
71 pub max_font_size_px: f32,
73 pub min_line_height: f32,
75 pub max_line_height: f32,
77 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
98pub struct StyleConfig {
99 pub limits: StyleLimits,
101 pub hints: LayoutHints,
103}
104
105#[derive(Clone, Copy, Debug, Default, PartialEq)]
107pub struct RenderPrepOptions {
108 pub style: StyleConfig,
110 pub fonts: FontLimits,
112 pub layout_hints: LayoutHints,
114 pub memory: MemoryBudget,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub struct MemoryBudget {
121 pub max_entry_bytes: usize,
123 pub max_css_bytes: usize,
125 pub max_nav_bytes: usize,
127 pub max_inline_style_bytes: usize,
129 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#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct RenderPrepError {
148 pub phase: ErrorPhase,
150 pub code: &'static str,
152 pub message: Box<str>,
154 pub path: Option<Box<str>>,
156 pub chapter_index: Option<usize>,
158 pub limit: Option<Box<ErrorLimitContext>>,
160 pub context: Option<Box<RenderPrepErrorContext>>,
162}
163
164#[derive(Clone, Debug, Default, PartialEq, Eq)]
166pub struct RenderPrepErrorContext {
167 pub source: Option<Box<str>>,
169 pub selector: Option<Box<str>>,
171 pub selector_index: Option<usize>,
173 pub declaration: Option<Box<str>>,
175 pub declaration_index: Option<usize>,
177 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#[derive(Clone, Debug, PartialEq, Eq)]
350pub struct StylesheetSource {
351 pub href: String,
353 pub css: String,
355}
356
357#[derive(Clone, Debug, Default, PartialEq, Eq)]
359pub struct ChapterStylesheets {
360 pub sources: Vec<StylesheetSource>,
362}
363
364impl ChapterStylesheets {
365 pub fn iter(&self) -> impl Iterator<Item = &StylesheetSource> {
367 self.sources.iter()
368 }
369}
370
371#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373pub enum EmbeddedFontStyle {
374 Normal,
376 Italic,
378 Oblique,
380}
381
382#[derive(Clone, Debug, PartialEq, Eq)]
384pub struct EmbeddedFontFace {
385 pub family: String,
387 pub weight: u16,
389 pub style: EmbeddedFontStyle,
391 pub stretch: Option<String>,
393 pub href: String,
395 pub format: Option<String>,
397}
398
399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub enum BlockRole {
402 Body,
404 Paragraph,
406 Heading(u8),
408 ListItem,
410 FigureCaption,
412}
413
414#[derive(Clone, Debug, PartialEq)]
416pub struct ComputedTextStyle {
417 pub family_stack: Vec<String>,
419 pub weight: u16,
421 pub italic: bool,
423 pub size_px: f32,
425 pub line_height: f32,
427 pub letter_spacing: f32,
429 pub block_role: BlockRole,
431}
432
433#[derive(Clone, Debug, PartialEq)]
435pub struct StyledRun {
436 pub text: String,
438 pub style: ComputedTextStyle,
440 pub font_id: u32,
442 pub resolved_family: String,
444}
445
446#[derive(Clone, Debug, PartialEq, Eq)]
448pub struct StyledImage {
449 pub src: String,
451 pub alt: String,
453 pub width_px: Option<u16>,
455 pub height_px: Option<u16>,
457 pub in_figure: bool,
459}
460
461#[derive(Clone, Copy, Debug, PartialEq, Eq)]
463pub enum StyledEvent {
464 ParagraphStart,
466 ParagraphEnd,
468 HeadingStart(u8),
470 HeadingEnd(u8),
472 ListItemStart,
474 ListItemEnd,
476 LineBreak,
478}
479
480#[derive(Clone, Debug, PartialEq)]
482pub enum StyledEventOrRun {
483 Event(StyledEvent),
485 Run(StyledRun),
487 Image(StyledImage),
489}
490
491#[derive(Clone, Debug, Default, PartialEq)]
493pub struct StyledChapter {
494 items: Vec<StyledEventOrRun>,
495}
496
497impl StyledChapter {
498 pub fn iter(&self) -> impl Iterator<Item = &StyledEventOrRun> {
500 self.items.iter()
501 }
502
503 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 pub fn from_items(items: Vec<StyledEventOrRun>) -> Self {
513 Self { items }
514 }
515}
516
517#[derive(Clone, Debug)]
519pub struct Styler {
520 config: StyleConfig,
521 memory: MemoryBudget,
522 parsed: Vec<Stylesheet>,
523}
524
525impl Styler {
526 pub fn new(config: StyleConfig) -> Self {
528 Self {
529 config,
530 memory: MemoryBudget::default(),
531 parsed: Vec::with_capacity(0),
532 }
533 }
534
535 pub fn with_memory_budget(mut self, memory: MemoryBudget) -> Self {
537 self.memory = memory;
538 self
539 }
540
541 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 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 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
979pub struct FontPolicy {
980 pub preferred_families: Vec<String>,
982 pub default_family: String,
984 pub allow_embedded_fonts: bool,
986 pub synthetic_bold: bool,
988 pub synthetic_italic: bool,
990}
991
992impl FontPolicy {
993 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
1005pub type FontFallbackPolicy = FontPolicy;
1007
1008impl Default for FontPolicy {
1009 fn default() -> Self {
1010 Self::serif_default()
1011 }
1012}
1013
1014#[derive(Clone, Debug, PartialEq, Eq)]
1016pub struct ResolvedFontFace {
1017 pub font_id: u32,
1019 pub family: String,
1021 pub embedded: Option<EmbeddedFontFace>,
1023}
1024
1025#[derive(Clone, Debug, PartialEq, Eq)]
1027pub struct FontResolutionTrace {
1028 pub face: ResolvedFontFace,
1030 pub reason_chain: Vec<String>,
1032}
1033
1034#[derive(Clone, Debug)]
1036pub struct FontResolver {
1037 policy: FontPolicy,
1038 limits: FontLimits,
1039 faces: Vec<EmbeddedFontFace>,
1040}
1041
1042impl FontResolver {
1043 pub fn new(policy: FontPolicy) -> Self {
1045 Self {
1046 policy,
1047 limits: FontLimits::default(),
1048 faces: Vec::with_capacity(0),
1049 }
1050 }
1051
1052 pub fn with_limits(mut self, limits: FontLimits) -> Self {
1054 self.limits = limits;
1055 self
1056 }
1057
1058 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 pub fn resolve(&self, style: &ComputedTextStyle) -> ResolvedFontFace {
1142 self.resolve_with_trace(style).face
1143 }
1144
1145 pub fn resolve_with_trace(&self, style: &ComputedTextStyle) -> FontResolutionTrace {
1147 self.resolve_with_trace_for_text(style, None)
1148 }
1149
1150 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#[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#[derive(Clone, Debug, PartialEq)]
1239pub enum RenderPrepTrace {
1240 Event,
1242 Run {
1244 style: Box<ComputedTextStyle>,
1246 font: Box<FontResolutionTrace>,
1248 },
1249}
1250
1251impl RenderPrepTrace {
1252 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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#[derive(Clone, Debug, PartialEq)]
1669pub struct PreparedChapter {
1670 styled: StyledChapter,
1671}
1672
1673impl PreparedChapter {
1674 pub fn iter(&self) -> impl Iterator<Item = &StyledEventOrRun> {
1676 self.styled.iter()
1677 }
1678
1679 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, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x03, 0x20, 0x03, 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}