Skip to main content

epub_stream/
layout.rs

1//! Text layout engine for EPUB pagination
2//!
3//! Converts tokens into laid-out pages for display.
4//! Uses greedy line breaking with embedded-graphics font metrics.
5
6extern crate alloc;
7
8use alloc::format;
9use alloc::string::String;
10use alloc::vec;
11use alloc::vec::Vec;
12
13use crate::tokenizer::Token;
14
15/// Text style for layout (bold, italic, etc.)
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub enum TextStyle {
19    /// Normal text
20    #[default]
21    Normal,
22    /// Bold text
23    Bold,
24    /// Italic text
25    Italic,
26    /// Bold and italic text
27    BoldItalic,
28}
29
30impl TextStyle {
31    /// Check if style is bold
32    pub fn is_bold(&self) -> bool {
33        matches!(self, TextStyle::Bold | TextStyle::BoldItalic)
34    }
35
36    /// Check if style is italic
37    pub fn is_italic(&self) -> bool {
38        matches!(self, TextStyle::Italic | TextStyle::BoldItalic)
39    }
40
41    /// Apply bold flag to current style
42    pub fn with_bold(&self, bold: bool) -> Self {
43        match (bold, self.is_italic()) {
44            (true, true) => TextStyle::BoldItalic,
45            (true, false) => TextStyle::Bold,
46            (false, true) => TextStyle::Italic,
47            (false, false) => TextStyle::Normal,
48        }
49    }
50
51    /// Apply italic flag to current style
52    pub fn with_italic(&self, italic: bool) -> Self {
53        match (self.is_bold(), italic) {
54            (true, true) => TextStyle::BoldItalic,
55            (true, false) => TextStyle::Bold,
56            (false, true) => TextStyle::Italic,
57            (false, false) => TextStyle::Normal,
58        }
59    }
60}
61
62/// A span of text with a single style within a line
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct TextSpan {
65    /// Text content of this span
66    pub text: String,
67    /// Style for this span
68    pub style: TextStyle,
69}
70
71impl TextSpan {
72    /// Create a new text span
73    pub fn new(text: String, style: TextStyle) -> Self {
74        Self { text, style }
75    }
76}
77
78/// A single laid-out line of text
79#[derive(Clone, Debug, PartialEq)]
80pub struct Line {
81    /// Styled text spans that make up this line
82    pub spans: Vec<TextSpan>,
83    /// Y position on the page
84    pub y: i32,
85}
86
87impl Line {
88    /// Create a new line (convenience: single span)
89    pub fn new(text: String, y: i32, style: TextStyle) -> Self {
90        Self {
91            spans: vec![TextSpan::new(text, style)],
92            y,
93        }
94    }
95
96    /// Get concatenated text content of all spans
97    pub fn text(&self) -> String {
98        self.spans
99            .iter()
100            .map(|s| s.text.as_str())
101            .collect::<Vec<_>>()
102            .join("")
103    }
104
105    /// Get the primary style (first span's style, or Normal)
106    pub fn style(&self) -> TextStyle {
107        self.spans
108            .first()
109            .map(|s| s.style)
110            .unwrap_or(TextStyle::Normal)
111    }
112
113    /// Check if line is empty
114    pub fn is_empty(&self) -> bool {
115        self.spans.iter().all(|s| s.text.is_empty())
116    }
117
118    /// Get line length in characters
119    pub fn len(&self) -> usize {
120        self.spans.iter().map(|s| s.text.len()).sum()
121    }
122}
123
124/// A single page of laid-out content
125#[derive(Clone, Debug, PartialEq)]
126pub struct Page {
127    /// Lines on this page
128    pub lines: Vec<Line>,
129    /// Page number (1-indexed)
130    pub page_number: usize,
131}
132
133impl Page {
134    /// Create a new empty page
135    pub fn new(page_number: usize) -> Self {
136        Self {
137            lines: Vec::with_capacity(0),
138            page_number,
139        }
140    }
141
142    /// Add a line to the page
143    pub fn add_line(&mut self, line: Line) {
144        self.lines.push(line);
145    }
146
147    /// Check if page has no lines
148    pub fn is_empty(&self) -> bool {
149        self.lines.is_empty()
150    }
151
152    /// Get number of lines on page
153    pub fn line_count(&self) -> usize {
154        self.lines.len()
155    }
156}
157
158/// Font metrics for text measurement
159#[derive(Clone, Debug)]
160pub struct FontMetrics {
161    /// Character width in pixels
162    pub char_width: f32,
163    /// Character height in pixels
164    pub char_height: f32,
165    /// Bold character width (typically same or slightly wider)
166    pub bold_char_width: f32,
167    /// Italic character width (typically same)
168    pub italic_char_width: f32,
169}
170
171impl Default for FontMetrics {
172    fn default() -> Self {
173        // Use FONT_10X20 metrics as a reasonable default
174        Self::font_10x20()
175    }
176}
177
178impl FontMetrics {
179    /// Create metrics for FONT_10X20
180    pub fn font_10x20() -> Self {
181        Self {
182            char_width: 10.0,
183            char_height: 20.0,
184            bold_char_width: 10.0,
185            italic_char_width: 10.0,
186        }
187    }
188
189    /// Get character width for a specific style
190    pub fn char_width_for_style(&self, style: TextStyle) -> f32 {
191        match style {
192            TextStyle::Normal | TextStyle::Italic => self.char_width,
193            TextStyle::Bold | TextStyle::BoldItalic => self.bold_char_width,
194        }
195    }
196
197    /// Measure text width for given style
198    pub fn text_width(&self, text: &str, style: TextStyle) -> f32 {
199        text.chars().count() as f32 * self.char_width_for_style(style)
200    }
201}
202
203/// Layout engine for converting tokens to paginated content
204pub struct LayoutEngine {
205    /// Available page width (pixels)
206    page_width: f32,
207    /// Line height in pixels
208    line_height: f32,
209    /// Font metrics for text measurement
210    font_metrics: FontMetrics,
211    /// Left margin in pixels
212    left_margin: f32,
213    /// Top margin in pixels
214    top_margin: f32,
215    /// Finalized spans for the current line
216    current_spans: Vec<TextSpan>,
217    /// Text being accumulated for the current span
218    current_span_text: String,
219    /// Style of the current span being built
220    current_span_style: TextStyle,
221    /// Current Y position on page
222    current_y: f32,
223    /// Current line width used
224    current_line_width: f32,
225    /// Current page being built
226    current_page_lines: Vec<Line>,
227    /// Completed pages
228    pages: Vec<Page>,
229    /// Current page number
230    page_number: usize,
231    /// Maximum lines per page
232    max_lines_per_page: usize,
233    /// Current line count on page
234    current_line_count: usize,
235    /// Current list nesting depth
236    list_depth: usize,
237    /// Stack tracking ordered vs unordered at each nesting level
238    list_ordered_stack: Vec<bool>,
239    /// Item counter at each list nesting level
240    list_item_counters: Vec<usize>,
241}
242
243impl LayoutEngine {
244    /// Default display width in pixels
245    pub const DISPLAY_WIDTH: f32 = 480.0;
246    /// Default display height in pixels
247    pub const DISPLAY_HEIGHT: f32 = 800.0;
248    /// Default side margins in pixels
249    pub const DEFAULT_MARGIN: f32 = 32.0;
250    /// Top margin - minimal
251    pub const DEFAULT_TOP_MARGIN: f32 = 0.0;
252    /// Header area for title (must match renderer HEADER_HEIGHT)
253    pub const DEFAULT_HEADER_HEIGHT: f32 = 45.0;
254    /// Footer area for progress (must match renderer FOOTER_HEIGHT)
255    pub const DEFAULT_FOOTER_HEIGHT: f32 = 40.0;
256
257    /// Create a new layout engine
258    ///
259    /// # Arguments
260    /// * `page_width` - Available width for content (excluding margins)
261    /// * `page_height` - Available height for content (excluding header/footer)
262    /// * `line_height` - Height of each line in pixels
263    pub fn new(page_width: f32, page_height: f32, line_height: f32) -> Self {
264        let font_metrics = FontMetrics::default();
265        // Reserve 2 extra line heights: 1 for font descent, 1 for safety margin
266        let max_lines = ((page_height - line_height * 2.0) / line_height)
267            .floor()
268            .max(1.0) as usize;
269
270        Self {
271            page_width,
272            line_height,
273            font_metrics,
274            left_margin: Self::DEFAULT_MARGIN,
275            top_margin: Self::DEFAULT_TOP_MARGIN,
276            current_spans: Vec::with_capacity(0),
277            current_span_text: String::with_capacity(0),
278            current_span_style: TextStyle::Normal,
279            current_y: Self::DEFAULT_TOP_MARGIN,
280            current_line_width: 0.0,
281            current_page_lines: Vec::with_capacity(0),
282            pages: Vec::with_capacity(0),
283            page_number: 1,
284            max_lines_per_page: max_lines.max(1),
285            current_line_count: 0,
286            list_depth: 0,
287            list_ordered_stack: Vec::with_capacity(0),
288            list_item_counters: Vec::with_capacity(0),
289        }
290    }
291
292    /// Create layout engine with default display dimensions
293    ///
294    /// Content area: 416x715 (accounting for margins, header, footer)
295    /// Uses 10x20 font with 26px line height for comfortable reading
296    pub fn with_defaults() -> Self {
297        LayoutConfig::default().create_engine()
298    }
299
300    /// Set font metrics
301    pub fn with_font_metrics(mut self, metrics: FontMetrics) -> Self {
302        self.font_metrics = metrics;
303        self
304    }
305
306    /// Set margins
307    pub fn with_margins(mut self, left: f32, top: f32) -> Self {
308        self.left_margin = left;
309        self.top_margin = top;
310        self.current_y = top;
311        self
312    }
313
314    /// Convert tokens into laid-out pages
315    pub fn layout_tokens(&mut self, tokens: &[Token]) -> Vec<Page> {
316        self.reset();
317
318        let mut bold_active = false;
319        let mut italic_active = false;
320        let mut heading_bold = false;
321
322        for token in tokens {
323            match token {
324                Token::Text(ref text) => {
325                    let style =
326                        self.current_style_from_flags(bold_active || heading_bold, italic_active);
327                    self.add_text(text, style);
328                }
329                Token::ParagraphBreak => {
330                    self.flush_line();
331                    self.add_paragraph_space();
332                    heading_bold = false;
333                }
334                Token::Heading(level) => {
335                    self.flush_line();
336                    // Headings get extra space before (more space for higher level headings)
337                    if self.current_line_count > 0 {
338                        // Add 1-2 lines of space before heading based on level
339                        let space_lines = if *level <= 2 { 2 } else { 1 };
340                        for _ in 0..space_lines {
341                            self.add_paragraph_space();
342                        }
343                    }
344                    // Headings are always bold (via heading_bold, not bold_active)
345                    heading_bold = true;
346                    // Note: Currently we use same font size for all headings
347                    // Future: could use larger fonts for h1-h2
348                }
349                Token::Emphasis(start) => {
350                    self.flush_partial_word();
351                    italic_active = *start;
352                    self.current_span_style =
353                        self.current_style_from_flags(bold_active || heading_bold, italic_active);
354                }
355                Token::Strong(start) => {
356                    self.flush_partial_word();
357                    bold_active = *start;
358                    self.current_span_style =
359                        self.current_style_from_flags(bold_active || heading_bold, italic_active);
360                }
361                Token::LineBreak => {
362                    self.flush_line();
363                }
364                // List tokens — track nesting and emit bullet/number prefixes
365                Token::ListStart(ordered) => {
366                    self.flush_line();
367                    self.list_depth += 1;
368                    self.list_ordered_stack.push(*ordered);
369                    self.list_item_counters.push(0);
370                }
371                Token::ListEnd => {
372                    self.flush_line();
373                    self.list_depth = self.list_depth.saturating_sub(1);
374                    self.list_ordered_stack.pop();
375                    self.list_item_counters.pop();
376                    if self.list_depth == 0 {
377                        self.add_paragraph_space();
378                    }
379                }
380                Token::ListItemStart => {
381                    self.flush_line();
382                    // Increment item counter for the current list level
383                    if let Some(counter) = self.list_item_counters.last_mut() {
384                        *counter += 1;
385                    }
386                    // Build indentation prefix based on nesting depth
387                    let indent = "  ".repeat(self.list_depth.saturating_sub(1));
388                    let is_ordered = self.list_ordered_stack.last().copied().unwrap_or(false);
389                    let marker = if is_ordered {
390                        let count = self.list_item_counters.last().copied().unwrap_or(1);
391                        format!("{}{}.", indent, count)
392                    } else {
393                        format!("{}\u{2022}", indent) // bullet: •
394                    };
395                    let marker_width = self.font_metrics.text_width(&marker, TextStyle::Normal);
396                    self.current_span_text.push_str(&marker);
397                    self.current_span_style = TextStyle::Normal;
398                    self.current_line_width = marker_width;
399                }
400                Token::ListItemEnd => {
401                    // Nothing needed — next ListItemStart or ListEnd handles spacing
402                }
403                // Link tokens — text content flows through as Token::Text normally
404                Token::LinkStart(_href) => {
405                    // Text inside the link renders normally via Token::Text
406                    // Future: could track link state for underline rendering
407                }
408                Token::LinkEnd => {
409                    // End of link — no special rendering needed
410                }
411                // Image tokens — render a placeholder line
412                Token::Image { src: _, ref alt } => {
413                    self.flush_line();
414                    let placeholder = if alt.is_empty() {
415                        String::from("[Image]")
416                    } else {
417                        format!("[Image: {}]", alt)
418                    };
419                    let width = self
420                        .font_metrics
421                        .text_width(&placeholder, TextStyle::Normal);
422                    self.current_span_text = placeholder;
423                    self.current_span_style = TextStyle::Normal;
424                    self.current_line_width = width;
425                    self.flush_line();
426                    self.add_paragraph_space();
427                }
428            }
429        }
430
431        // Flush any remaining content
432        self.flush_line();
433        self.finalize_page();
434
435        core::mem::take(&mut self.pages)
436    }
437
438    /// Reset the layout engine state
439    fn reset(&mut self) {
440        self.current_spans.clear();
441        self.current_span_text.clear();
442        self.current_span_style = TextStyle::Normal;
443        self.current_y = self.top_margin;
444        self.current_line_width = 0.0;
445        self.current_page_lines.clear();
446        self.pages.clear();
447        self.page_number = 1;
448        self.current_line_count = 0;
449        self.list_depth = 0;
450        self.list_ordered_stack.clear();
451        self.list_item_counters.clear();
452    }
453
454    /// Get current style based on bold/italic flags
455    fn current_style_from_flags(&self, bold: bool, italic: bool) -> TextStyle {
456        match (bold, italic) {
457            (true, true) => TextStyle::BoldItalic,
458            (true, false) => TextStyle::Bold,
459            (false, true) => TextStyle::Italic,
460            (false, false) => TextStyle::Normal,
461        }
462    }
463
464    /// Add text content, breaking into words and laying out
465    fn add_text(&mut self, text: &str, style: TextStyle) {
466        // Split text into words
467        for word in text.split_whitespace() {
468            self.add_word(word, style);
469        }
470    }
471
472    /// Check if the current line being built is empty (no spans and no pending text)
473    fn current_line_is_empty(&self) -> bool {
474        self.current_spans.is_empty() && self.current_span_text.is_empty()
475    }
476
477    /// Add a single word with greedy line breaking
478    fn add_word(&mut self, word: &str, style: TextStyle) {
479        let word_width = self.font_metrics.text_width(word, style);
480        let space_width = if self.current_line_is_empty() {
481            0.0
482        } else {
483            self.font_metrics.char_width_for_style(style)
484        };
485
486        let total_width = self.current_line_width + space_width + word_width;
487
488        if total_width <= self.page_width || self.current_line_is_empty() {
489            // If style changed from current span, finalize previous span and start new
490            if style != self.current_span_style {
491                if !self.current_span_text.is_empty() {
492                    self.current_spans.push(TextSpan::new(
493                        core::mem::take(&mut self.current_span_text),
494                        self.current_span_style,
495                    ));
496                }
497                self.current_span_style = style;
498            }
499            // Word fits on current line
500            if !self.current_line_is_empty() {
501                self.current_span_text.push(' ');
502                self.current_line_width += space_width;
503            }
504            self.current_span_text.push_str(word);
505            self.current_line_width += word_width;
506        } else {
507            // Word doesn't fit, start new line
508            self.flush_line();
509            self.current_span_style = style;
510            self.current_span_text.push_str(word);
511            self.current_line_width = word_width;
512        }
513    }
514
515    /// Flush current span text (used when style changes mid-line)
516    fn flush_partial_word(&mut self) {
517        if !self.current_span_text.is_empty() {
518            self.current_spans.push(TextSpan::new(
519                core::mem::take(&mut self.current_span_text),
520                self.current_span_style,
521            ));
522        }
523    }
524
525    /// Flush current line and add to page
526    fn flush_line(&mut self) {
527        // Finalize current span
528        if !self.current_span_text.is_empty() {
529            self.current_spans.push(TextSpan::new(
530                core::mem::take(&mut self.current_span_text),
531                self.current_span_style,
532            ));
533        }
534
535        if self.current_spans.is_empty() {
536            return;
537        }
538
539        // Check if we need a new page
540        if self.current_line_count >= self.max_lines_per_page {
541            self.finalize_page();
542            self.current_y = self.top_margin;
543            self.current_line_count = 0;
544        }
545
546        // Create the line from accumulated spans
547        let line = Line {
548            spans: core::mem::take(&mut self.current_spans),
549            y: self.current_y as i32,
550        };
551
552        self.current_page_lines.push(line);
553        self.current_line_count += 1;
554        self.current_y += self.line_height;
555        self.current_line_width = 0.0;
556    }
557
558    /// Add paragraph spacing (half line for compact layout)
559    fn add_paragraph_space(&mut self) {
560        // Check if we need a new page for the space
561        if self.current_line_count >= self.max_lines_per_page {
562            self.finalize_page();
563            self.current_y = self.top_margin;
564            self.current_line_count = 0;
565        }
566
567        // Add half line space between paragraphs (12px for 24px line height)
568        // This saves space while maintaining visual separation
569        if self.current_line_count > 0 {
570            self.current_y += self.line_height * 0.5;
571        }
572    }
573
574    /// Finalize current page and start new one
575    fn finalize_page(&mut self) {
576        if !self.current_page_lines.is_empty() {
577            let mut page = Page::new(self.page_number);
578            core::mem::swap(&mut page.lines, &mut self.current_page_lines);
579            self.pages.push(page);
580            self.page_number += 1;
581        }
582    }
583
584    /// Get the completed pages
585    pub fn into_pages(mut self) -> Vec<Page> {
586        self.finalize_page();
587        self.pages
588    }
589
590    /// Get current page number
591    pub fn current_page_number(&self) -> usize {
592        self.page_number
593    }
594
595    /// Get total pages created so far
596    pub fn total_pages(&self) -> usize {
597        self.pages.len()
598    }
599
600    /// Measure text width for given string and style
601    pub fn measure_text(&self, text: &str, style: TextStyle) -> f32 {
602        self.font_metrics.text_width(text, style)
603    }
604}
605
606/// Layout configuration for the engine
607#[derive(Clone, Debug)]
608pub struct LayoutConfig {
609    /// Page width in pixels
610    pub page_width: f32,
611    /// Page height in pixels
612    pub page_height: f32,
613    /// Line height in pixels
614    pub line_height: f32,
615    /// Left margin in pixels
616    pub left_margin: f32,
617    /// Top margin in pixels
618    pub top_margin: f32,
619    /// Font metrics
620    pub font_metrics: FontMetrics,
621}
622
623impl Default for LayoutConfig {
624    /// Default configuration for e-reader display layout.
625    ///
626    /// Uses the same constants as `LayoutEngine::with_defaults()` for consistency.
627    /// This provides a 416x715 content area with 26px line height for comfortable reading.
628    fn default() -> Self {
629        // Use LayoutEngine constants as single source of truth
630        let content_width = LayoutEngine::DISPLAY_WIDTH - (LayoutEngine::DEFAULT_MARGIN * 2.0);
631        let content_height = LayoutEngine::DISPLAY_HEIGHT
632            - LayoutEngine::DEFAULT_HEADER_HEIGHT
633            - LayoutEngine::DEFAULT_FOOTER_HEIGHT;
634
635        Self {
636            page_width: content_width,
637            page_height: content_height,
638            line_height: 26.0, // ~1.3x font height for comfortable reading
639            left_margin: LayoutEngine::DEFAULT_MARGIN,
640            top_margin: 0.0, // No top margin - header area handled separately
641            font_metrics: FontMetrics::default(),
642        }
643    }
644}
645
646impl LayoutConfig {
647    /// Create layout engine from this configuration
648    pub fn create_engine(&self) -> LayoutEngine {
649        LayoutEngine::new(self.page_width, self.page_height, self.line_height)
650            .with_font_metrics(self.font_metrics.clone())
651            .with_margins(self.left_margin, self.top_margin)
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    fn create_test_tokens() -> Vec<Token> {
660        vec![
661            Token::Text("This is ".to_string()),
662            Token::Emphasis(true),
663            Token::Text("italic".to_string()),
664            Token::Emphasis(false),
665            Token::Text(" and ".to_string()),
666            Token::Strong(true),
667            Token::Text("bold".to_string()),
668            Token::Strong(false),
669            Token::Text(" text.".to_string()),
670            Token::ParagraphBreak,
671            Token::Heading(1),
672            Token::Text("Chapter Title".to_string()),
673            Token::ParagraphBreak,
674            Token::Text("Another paragraph with more content here.".to_string()),
675            Token::ParagraphBreak,
676        ]
677    }
678
679    #[test]
680    fn test_layout_engine_new() {
681        let engine = LayoutEngine::new(460.0, 650.0, 20.0);
682        assert_eq!(engine.current_page_number(), 1);
683        assert_eq!(engine.total_pages(), 0);
684    }
685
686    #[test]
687    fn test_text_style() {
688        let mut style = TextStyle::Normal;
689        assert!(!style.is_bold());
690        assert!(!style.is_italic());
691
692        style = style.with_bold(true);
693        assert!(style.is_bold());
694        assert!(!style.is_italic());
695
696        style = style.with_italic(true);
697        assert!(style.is_bold());
698        assert!(style.is_italic());
699
700        style = style.with_bold(false);
701        assert!(!style.is_bold());
702        assert!(style.is_italic());
703    }
704
705    #[test]
706    fn test_layout_tokens_basic() {
707        let tokens = create_test_tokens();
708        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
709        let pages = engine.layout_tokens(&tokens);
710
711        assert!(!pages.is_empty());
712        assert_eq!(pages[0].page_number, 1);
713
714        // Check that we have lines
715        let total_lines: usize = pages.iter().map(|p| p.line_count()).sum();
716        assert!(total_lines > 0);
717    }
718
719    #[test]
720    fn test_pagination() {
721        // Create a lot of text to force pagination
722        let mut tokens = Vec::with_capacity(0);
723        for i in 0..50 {
724            tokens.push(Token::Text(format!(
725                "This is paragraph number {} with some content. ",
726                i
727            )));
728            tokens.push(Token::Text(
729                "Here is more text to fill the line. ".to_string(),
730            ));
731            tokens.push(Token::Text(
732                "And even more words here to make it long enough.".to_string(),
733            ));
734            tokens.push(Token::ParagraphBreak);
735        }
736
737        let mut engine = LayoutEngine::new(460.0, 200.0, 20.0); // Small page height
738        let pages = engine.layout_tokens(&tokens);
739
740        // Should have multiple pages
741        assert!(pages.len() > 1);
742
743        // Page numbers should be sequential
744        for (i, page) in pages.iter().enumerate() {
745            assert_eq!(page.page_number, i + 1);
746        }
747    }
748
749    #[test]
750    fn test_line_breaking() {
751        // Create text that should wrap
752        let tokens = vec![
753            Token::Text("This is a very long line of text that should definitely wrap to multiple lines because it is longer than the available width".to_string()),
754            Token::ParagraphBreak,
755        ];
756
757        let mut engine = LayoutEngine::new(100.0, 200.0, 20.0); // Narrow page
758        let pages = engine.layout_tokens(&tokens);
759
760        assert!(!pages.is_empty());
761        // Should have multiple lines on the page
762        assert!(pages[0].line_count() > 1);
763    }
764
765    #[test]
766    fn test_empty_input() {
767        let tokens: Vec<Token> = vec![];
768        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
769        let pages = engine.layout_tokens(&tokens);
770
771        // Should have no pages for empty input
772        assert!(pages.is_empty());
773    }
774
775    #[test]
776    fn test_font_metrics() {
777        let metrics = FontMetrics::default(); // default is 10x20
778        assert_eq!(metrics.text_width("hello", TextStyle::Normal), 50.0); // 5 * 10.0
779        assert_eq!(metrics.text_width("hello", TextStyle::Bold), 50.0); // 5 * 10.0
780
781        let metrics_10x20 = FontMetrics::font_10x20();
782        assert_eq!(metrics_10x20.text_width("hello", TextStyle::Normal), 50.0);
783    }
784
785    #[test]
786    fn test_page_struct() {
787        let mut page = Page::new(1);
788        assert!(page.is_empty());
789        assert_eq!(page.line_count(), 0);
790
791        page.add_line(Line::new("Test".to_string(), 10, TextStyle::Normal));
792        assert!(!page.is_empty());
793        assert_eq!(page.line_count(), 1);
794    }
795
796    #[test]
797    fn test_line_struct() {
798        let line = Line::new("Hello".to_string(), 50, TextStyle::Bold);
799        assert_eq!(line.text(), "Hello");
800        assert_eq!(line.y, 50);
801        assert_eq!(line.style(), TextStyle::Bold);
802        assert!(!line.is_empty());
803        assert_eq!(line.len(), 5);
804    }
805
806    #[test]
807    fn test_layout_config() {
808        let config = LayoutConfig::default();
809        // Content width: 480 - 2*32 = 416
810        assert_eq!(config.page_width, 416.0);
811        // Content height: 800 - 45 - 40 = 715
812        assert_eq!(config.page_height, 715.0);
813        // Line height: 26px for comfortable reading (~1.3x font height)
814        assert_eq!(config.line_height, 26.0);
815        // Margins: left=32 (default), top=0 (header handled separately)
816        assert_eq!(config.left_margin, 32.0);
817        assert_eq!(config.top_margin, 0.0);
818
819        let engine = config.create_engine();
820        assert_eq!(engine.current_page_number(), 1);
821    }
822
823    #[test]
824    fn test_with_defaults() {
825        let engine = LayoutEngine::with_defaults();
826        assert_eq!(engine.current_page_number(), 1);
827        // Default content area: 480 - 2*32 = 416 width
828        // 800 - 45 - 40 = 715 height
829    }
830
831    /// Helper: collect all line texts from laid-out pages
832    fn collect_line_texts(pages: &[Page]) -> Vec<String> {
833        pages
834            .iter()
835            .flat_map(|p| p.lines.iter())
836            .map(|l| l.text())
837            .collect()
838    }
839
840    #[test]
841    fn test_unordered_list_layout() {
842        let tokens = vec![
843            Token::ListStart(false),
844            Token::ListItemStart,
845            Token::Text("First".to_string()),
846            Token::ListItemEnd,
847            Token::ListItemStart,
848            Token::Text("Second".to_string()),
849            Token::ListItemEnd,
850            Token::ListEnd,
851        ];
852
853        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
854        let pages = engine.layout_tokens(&tokens);
855        let texts = collect_line_texts(&pages);
856
857        assert_eq!(texts.len(), 2);
858        assert_eq!(texts[0], "\u{2022} First");
859        assert_eq!(texts[1], "\u{2022} Second");
860    }
861
862    #[test]
863    fn test_ordered_list_layout() {
864        let tokens = vec![
865            Token::ListStart(true),
866            Token::ListItemStart,
867            Token::Text("Alpha".to_string()),
868            Token::ListItemEnd,
869            Token::ListItemStart,
870            Token::Text("Beta".to_string()),
871            Token::ListItemEnd,
872            Token::ListItemStart,
873            Token::Text("Gamma".to_string()),
874            Token::ListItemEnd,
875            Token::ListEnd,
876        ];
877
878        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
879        let pages = engine.layout_tokens(&tokens);
880        let texts = collect_line_texts(&pages);
881
882        assert_eq!(texts.len(), 3);
883        assert_eq!(texts[0], "1. Alpha");
884        assert_eq!(texts[1], "2. Beta");
885        assert_eq!(texts[2], "3. Gamma");
886    }
887
888    #[test]
889    fn test_nested_list_layout() {
890        let tokens = vec![
891            Token::ListStart(false),
892            Token::ListItemStart,
893            Token::Text("Outer".to_string()),
894            Token::ListItemEnd,
895            // Nested ordered list
896            Token::ListStart(true),
897            Token::ListItemStart,
898            Token::Text("Inner A".to_string()),
899            Token::ListItemEnd,
900            Token::ListItemStart,
901            Token::Text("Inner B".to_string()),
902            Token::ListItemEnd,
903            Token::ListEnd,
904            Token::ListItemStart,
905            Token::Text("Outer again".to_string()),
906            Token::ListItemEnd,
907            Token::ListEnd,
908        ];
909
910        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
911        let pages = engine.layout_tokens(&tokens);
912        let texts = collect_line_texts(&pages);
913
914        assert_eq!(texts.len(), 4);
915        // Depth 1 unordered — no indentation
916        assert_eq!(texts[0], "\u{2022} Outer");
917        // Depth 2 ordered — 2-space indentation
918        assert_eq!(texts[1], "  1. Inner A");
919        assert_eq!(texts[2], "  2. Inner B");
920        // Back to depth 1 unordered
921        assert_eq!(texts[3], "\u{2022} Outer again");
922    }
923
924    #[test]
925    fn test_image_placeholder_with_alt() {
926        let tokens = vec![Token::Image {
927            src: "img/cover.jpg".to_string(),
928            alt: "Book cover".to_string(),
929        }];
930
931        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
932        let pages = engine.layout_tokens(&tokens);
933        let texts = collect_line_texts(&pages);
934
935        assert_eq!(texts.len(), 1);
936        assert_eq!(texts[0], "[Image: Book cover]");
937    }
938
939    #[test]
940    fn test_image_placeholder_without_alt() {
941        let tokens = vec![Token::Image {
942            src: "img/photo.png".to_string(),
943            alt: String::with_capacity(0),
944        }];
945
946        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
947        let pages = engine.layout_tokens(&tokens);
948        let texts = collect_line_texts(&pages);
949
950        assert_eq!(texts.len(), 1);
951        assert_eq!(texts[0], "[Image]");
952    }
953
954    #[test]
955    fn test_link_text_renders_normally() {
956        let tokens = vec![
957            Token::Text("Click ".to_string()),
958            Token::LinkStart("https://example.com".to_string()),
959            Token::Text("here".to_string()),
960            Token::LinkEnd,
961            Token::Text(" for info.".to_string()),
962        ];
963
964        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
965        let pages = engine.layout_tokens(&tokens);
966        let texts = collect_line_texts(&pages);
967
968        // Link text should render inline with surrounding text
969        assert_eq!(texts.len(), 1);
970        assert_eq!(texts[0], "Click here for info.");
971    }
972
973    #[test]
974    fn test_mixed_content() {
975        let tokens = vec![
976            // Heading
977            Token::Heading(1),
978            Token::Text("My Chapter".to_string()),
979            Token::ParagraphBreak,
980            // Paragraph
981            Token::Text("Some introductory text.".to_string()),
982            Token::ParagraphBreak,
983            // Unordered list
984            Token::ListStart(false),
985            Token::ListItemStart,
986            Token::Text("Item one".to_string()),
987            Token::ListItemEnd,
988            Token::ListItemStart,
989            Token::Text("Item two".to_string()),
990            Token::ListItemEnd,
991            Token::ListEnd,
992            // Image
993            Token::Image {
994                src: "fig1.png".to_string(),
995                alt: "Figure 1".to_string(),
996            },
997            // Link in paragraph
998            Token::Text("Visit ".to_string()),
999            Token::LinkStart("https://example.com".to_string()),
1000            Token::Text("example".to_string()),
1001            Token::LinkEnd,
1002            Token::Text(" site.".to_string()),
1003            Token::ParagraphBreak,
1004        ];
1005
1006        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1007        let pages = engine.layout_tokens(&tokens);
1008        let texts = collect_line_texts(&pages);
1009
1010        // Verify all content types appear in order
1011        assert!(texts.len() >= 6);
1012        assert_eq!(texts[0], "My Chapter");
1013        assert_eq!(texts[1], "Some introductory text.");
1014        assert_eq!(texts[2], "\u{2022} Item one");
1015        assert_eq!(texts[3], "\u{2022} Item two");
1016        assert_eq!(texts[4], "[Image: Figure 1]");
1017        assert_eq!(texts[5], "Visit example site.");
1018    }
1019
1020    #[test]
1021    fn test_list_counters_reset_between_lists() {
1022        let tokens = vec![
1023            // First ordered list
1024            Token::ListStart(true),
1025            Token::ListItemStart,
1026            Token::Text("A".to_string()),
1027            Token::ListItemEnd,
1028            Token::ListItemStart,
1029            Token::Text("B".to_string()),
1030            Token::ListItemEnd,
1031            Token::ListEnd,
1032            // Second ordered list — counters should restart at 1
1033            Token::ListStart(true),
1034            Token::ListItemStart,
1035            Token::Text("X".to_string()),
1036            Token::ListItemEnd,
1037            Token::ListItemStart,
1038            Token::Text("Y".to_string()),
1039            Token::ListItemEnd,
1040            Token::ListEnd,
1041        ];
1042
1043        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1044        let pages = engine.layout_tokens(&tokens);
1045        let texts = collect_line_texts(&pages);
1046
1047        assert_eq!(texts.len(), 4);
1048        assert_eq!(texts[0], "1. A");
1049        assert_eq!(texts[1], "2. B");
1050        // Second list resets
1051        assert_eq!(texts[2], "1. X");
1052        assert_eq!(texts[3], "2. Y");
1053    }
1054
1055    // -- Additional edge case tests ---
1056
1057    #[test]
1058    fn test_layout_all_token_types_together() {
1059        // Exercise every token variant in a single layout pass
1060        let tokens = vec![
1061            Token::Heading(2),
1062            Token::Text("Title".to_string()),
1063            Token::ParagraphBreak,
1064            Token::Text("Normal ".to_string()),
1065            Token::Strong(true),
1066            Token::Text("bold".to_string()),
1067            Token::Strong(false),
1068            Token::Emphasis(true),
1069            Token::Text("italic".to_string()),
1070            Token::Emphasis(false),
1071            Token::ParagraphBreak,
1072            Token::ListStart(false),
1073            Token::ListItemStart,
1074            Token::Text("Bullet".to_string()),
1075            Token::ListItemEnd,
1076            Token::ListEnd,
1077            Token::ListStart(true),
1078            Token::ListItemStart,
1079            Token::Text("Numbered".to_string()),
1080            Token::ListItemEnd,
1081            Token::ListEnd,
1082            Token::LinkStart("http://example.com".to_string()),
1083            Token::Text("link text".to_string()),
1084            Token::LinkEnd,
1085            Token::ParagraphBreak,
1086            Token::Image {
1087                src: "img.png".to_string(),
1088                alt: "Alt text".to_string(),
1089            },
1090            Token::LineBreak,
1091            Token::Text("Final line.".to_string()),
1092        ];
1093
1094        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1095        let pages = engine.layout_tokens(&tokens);
1096
1097        assert!(!pages.is_empty());
1098        let total_lines: usize = pages.iter().map(|p| p.line_count()).sum();
1099        assert!(
1100            total_lines >= 5,
1101            "Expected at least 5 lines, got {}",
1102            total_lines
1103        );
1104    }
1105
1106    #[test]
1107    fn test_layout_only_headings_no_body() {
1108        let tokens = vec![
1109            Token::Heading(1),
1110            Token::Text("Chapter One".to_string()),
1111            Token::ParagraphBreak,
1112            Token::Heading(2),
1113            Token::Text("Section A".to_string()),
1114            Token::ParagraphBreak,
1115            Token::Heading(3),
1116            Token::Text("Subsection i".to_string()),
1117        ];
1118
1119        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1120        let pages = engine.layout_tokens(&tokens);
1121
1122        assert!(!pages.is_empty());
1123        let texts = collect_line_texts(&pages);
1124        assert!(texts.len() >= 3);
1125
1126        // All heading text should be bold
1127        for page in &pages {
1128            for line in &page.lines {
1129                if !line.is_empty() {
1130                    assert!(
1131                        line.style().is_bold(),
1132                        "Heading line '{}' should be bold, was {:?}",
1133                        line.text(),
1134                        line.style()
1135                    );
1136                }
1137            }
1138        }
1139    }
1140
1141    #[test]
1142    fn test_layout_very_long_single_word() {
1143        // A word much wider than the page width
1144        let long_word = "superlongwordthatdoesnotfitinpagewidthatall";
1145        let tokens = vec![Token::Text(long_word.to_string()), Token::ParagraphBreak];
1146
1147        // 100px page width / 10px per char = 10 chars fit
1148        let mut engine = LayoutEngine::new(100.0, 400.0, 20.0);
1149        let pages = engine.layout_tokens(&tokens);
1150
1151        assert!(!pages.is_empty());
1152        // The word should be placed even though it overflows — greedy algorithm
1153        // allows a word on an empty line regardless of width
1154        let texts = collect_line_texts(&pages);
1155        assert!(texts.iter().any(|t| t == long_word));
1156    }
1157
1158    #[test]
1159    fn test_page_boundary_exact_fill() {
1160        // Create a small page: height=100, line_height=20
1161        // max_lines = floor((100 - 2*20) / 20) = 3
1162        let mut engine = LayoutEngine::new(400.0, 100.0, 20.0);
1163
1164        // Exactly 3 lines of content
1165        let tokens = vec![
1166            Token::Text("Line one text".to_string()),
1167            Token::LineBreak,
1168            Token::Text("Line two text".to_string()),
1169            Token::LineBreak,
1170            Token::Text("Line three text".to_string()),
1171        ];
1172
1173        let pages = engine.layout_tokens(&tokens);
1174        assert_eq!(pages.len(), 1);
1175        assert_eq!(pages[0].line_count(), 3);
1176    }
1177
1178    #[test]
1179    fn test_page_boundary_overflow_by_one() {
1180        // 3 lines fit, 4th should overflow
1181        let mut engine = LayoutEngine::new(400.0, 100.0, 20.0);
1182
1183        let tokens = vec![
1184            Token::Text("Line one".to_string()),
1185            Token::LineBreak,
1186            Token::Text("Line two".to_string()),
1187            Token::LineBreak,
1188            Token::Text("Line three".to_string()),
1189            Token::LineBreak,
1190            Token::Text("Line four overflow".to_string()),
1191        ];
1192
1193        let pages = engine.layout_tokens(&tokens);
1194        assert!(pages.len() >= 2, "Expected 2+ pages, got {}", pages.len());
1195        assert_eq!(pages[0].line_count(), 3);
1196        assert!(pages[1].line_count() >= 1);
1197    }
1198
1199    #[test]
1200    fn test_style_transitions_in_paragraph() {
1201        // normal → bold → italic → bolditalic → normal
1202        let tokens = vec![
1203            Token::Text("normal".to_string()),
1204            Token::Strong(true),
1205            Token::Text("bold".to_string()),
1206            Token::Strong(false),
1207            Token::Emphasis(true),
1208            Token::Text("italic".to_string()),
1209            Token::Strong(true),
1210            Token::Text("bolditalic".to_string()),
1211            Token::Strong(false),
1212            Token::Emphasis(false),
1213            Token::Text("normal_again".to_string()),
1214        ];
1215
1216        // Very wide page so everything fits on one line
1217        let mut engine = LayoutEngine::new(2000.0, 650.0, 20.0);
1218        let pages = engine.layout_tokens(&tokens);
1219
1220        assert!(!pages.is_empty());
1221        let texts = collect_line_texts(&pages);
1222        // All words should end up in the output
1223        let joined = texts.join(" ");
1224        assert!(joined.contains("normal"));
1225        assert!(joined.contains("bold"));
1226        assert!(joined.contains("italic"));
1227        assert!(joined.contains("bolditalic"));
1228        assert!(joined.contains("normal_again"));
1229    }
1230
1231    #[test]
1232    fn test_multiple_paragraph_breaks_in_sequence() {
1233        let tokens = vec![
1234            Token::Text("First paragraph.".to_string()),
1235            Token::ParagraphBreak,
1236            Token::ParagraphBreak,
1237            Token::ParagraphBreak,
1238            Token::Text("After multiple breaks.".to_string()),
1239        ];
1240
1241        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1242        let pages = engine.layout_tokens(&tokens);
1243
1244        let texts = collect_line_texts(&pages);
1245        assert_eq!(texts.len(), 2);
1246        assert_eq!(texts[0], "First paragraph.");
1247        assert_eq!(texts[1], "After multiple breaks.");
1248    }
1249
1250    #[test]
1251    fn test_layout_with_custom_font_metrics() {
1252        let custom_metrics = FontMetrics {
1253            char_width: 8.0,
1254            char_height: 16.0,
1255            bold_char_width: 9.0,
1256            italic_char_width: 8.0,
1257        };
1258
1259        // Verify metric calculations
1260        assert_eq!(custom_metrics.text_width("hello", TextStyle::Normal), 40.0);
1261        assert_eq!(custom_metrics.text_width("hello", TextStyle::Bold), 45.0);
1262        assert_eq!(custom_metrics.char_width_for_style(TextStyle::Italic), 8.0);
1263        assert_eq!(
1264            custom_metrics.char_width_for_style(TextStyle::BoldItalic),
1265            9.0
1266        );
1267
1268        // Use in engine
1269        let tokens = vec![
1270            Token::Text("Testing custom font metrics.".to_string()),
1271            Token::ParagraphBreak,
1272        ];
1273        let mut engine = LayoutEngine::new(200.0, 400.0, 20.0).with_font_metrics(custom_metrics);
1274        let pages = engine.layout_tokens(&tokens);
1275        assert!(!pages.is_empty());
1276    }
1277
1278    #[test]
1279    fn test_layout_config_create_engine_works() {
1280        let config = LayoutConfig {
1281            page_width: 300.0,
1282            page_height: 500.0,
1283            line_height: 18.0,
1284            left_margin: 15.0,
1285            top_margin: 20.0,
1286            font_metrics: FontMetrics {
1287                char_width: 8.0,
1288                char_height: 16.0,
1289                bold_char_width: 9.0,
1290                italic_char_width: 8.0,
1291            },
1292        };
1293
1294        let mut engine = config.create_engine();
1295        assert_eq!(engine.current_page_number(), 1);
1296
1297        let tokens = vec![
1298            Token::Text("Config engine test.".to_string()),
1299            Token::ParagraphBreak,
1300            Token::Text("Second paragraph.".to_string()),
1301        ];
1302        let pages = engine.layout_tokens(&tokens);
1303        assert!(!pages.is_empty());
1304        assert!(pages[0].line_count() >= 1);
1305    }
1306
1307    #[test]
1308    fn test_layout_default_config_create_engine() {
1309        let config = LayoutConfig::default();
1310        let mut engine = config.create_engine();
1311
1312        let tokens = vec![
1313            Token::Text("Default config test.".to_string()),
1314            Token::ParagraphBreak,
1315        ];
1316        let pages = engine.layout_tokens(&tokens);
1317        assert!(!pages.is_empty());
1318    }
1319
1320    #[test]
1321    fn test_layout_zero_length_text_tokens() {
1322        let tokens = vec![
1323            Token::Text(String::with_capacity(0)),
1324            Token::Text("visible".to_string()),
1325            Token::Text(String::with_capacity(0)),
1326            Token::ParagraphBreak,
1327        ];
1328
1329        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1330        let pages = engine.layout_tokens(&tokens);
1331
1332        let texts = collect_line_texts(&pages);
1333        assert_eq!(texts.len(), 1);
1334        assert_eq!(texts[0], "visible");
1335    }
1336
1337    #[test]
1338    fn test_heading_gets_extra_space() {
1339        // When heading follows body text, it should have more spacing
1340        let tokens = vec![
1341            Token::Text("Intro paragraph.".to_string()),
1342            Token::ParagraphBreak,
1343            Token::Heading(1),
1344            Token::Text("Chapter Title".to_string()),
1345            Token::ParagraphBreak,
1346            Token::Text("Body text.".to_string()),
1347        ];
1348
1349        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1350        let pages = engine.layout_tokens(&tokens);
1351
1352        assert!(!pages.is_empty());
1353        let intro_line = &pages[0].lines[0];
1354        let heading_line = pages[0]
1355            .lines
1356            .iter()
1357            .find(|l| l.text().contains("Chapter Title"))
1358            .expect("Heading line should exist");
1359
1360        // Heading should be bold
1361        assert!(heading_line.style().is_bold());
1362
1363        // Gap between intro and heading should be bigger than a normal line gap
1364        let gap = heading_line.y - intro_line.y;
1365        assert!(
1366            gap > 20,
1367            "Expected extra spacing before heading, gap was {}",
1368            gap
1369        );
1370    }
1371
1372    #[test]
1373    fn test_heading_level_spacing_difference() {
1374        // h1/h2 should get 2 lines of extra space; h3+ only 1 line
1375        let tokens_h1 = vec![
1376            Token::Text("Intro.".to_string()),
1377            Token::ParagraphBreak,
1378            Token::Heading(1),
1379            Token::Text("H1 Title".to_string()),
1380        ];
1381        let tokens_h4 = vec![
1382            Token::Text("Intro.".to_string()),
1383            Token::ParagraphBreak,
1384            Token::Heading(4),
1385            Token::Text("H4 Title".to_string()),
1386        ];
1387
1388        let mut engine1 = LayoutEngine::new(460.0, 650.0, 20.0);
1389        let pages_h1 = engine1.layout_tokens(&tokens_h1);
1390
1391        let mut engine4 = LayoutEngine::new(460.0, 650.0, 20.0);
1392        let pages_h4 = engine4.layout_tokens(&tokens_h4);
1393
1394        let h1_y = pages_h1[0]
1395            .lines
1396            .iter()
1397            .find(|l| l.text().contains("H1 Title"))
1398            .unwrap()
1399            .y;
1400        let h4_y = pages_h4[0]
1401            .lines
1402            .iter()
1403            .find(|l| l.text().contains("H4 Title"))
1404            .unwrap()
1405            .y;
1406
1407        // h1 gets 2 lines of extra space, h4 gets 1 → h1 should have larger Y
1408        assert!(
1409            h1_y > h4_y,
1410            "h1 spacing (y={}) should be greater than h4 spacing (y={})",
1411            h1_y,
1412            h4_y
1413        );
1414    }
1415
1416    #[test]
1417    fn test_layout_engine_reuse() {
1418        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1419
1420        // First layout
1421        let tokens1 = vec![Token::Text("First run.".to_string()), Token::ParagraphBreak];
1422        let pages1 = engine.layout_tokens(&tokens1);
1423        assert!(!pages1.is_empty());
1424
1425        // Second layout — engine should be reset
1426        let tokens2 = vec![
1427            Token::Text("Second run.".to_string()),
1428            Token::ParagraphBreak,
1429        ];
1430        let pages2 = engine.layout_tokens(&tokens2);
1431        assert!(!pages2.is_empty());
1432        assert_eq!(pages2[0].page_number, 1);
1433        assert_eq!(pages2[0].lines[0].text(), "Second run.");
1434    }
1435
1436    #[test]
1437    fn test_line_empty_and_len_edge_cases() {
1438        let empty_line = Line::new(String::with_capacity(0), 0, TextStyle::Normal);
1439        assert!(empty_line.is_empty());
1440        assert_eq!(empty_line.len(), 0);
1441
1442        let line = Line::new("Hello World!".to_string(), 100, TextStyle::Italic);
1443        assert!(!line.is_empty());
1444        assert_eq!(line.len(), 12);
1445        assert_eq!(line.style(), TextStyle::Italic);
1446    }
1447
1448    #[test]
1449    fn test_text_style_combinations() {
1450        // Normal → with_bold(true) → Bold
1451        assert_eq!(TextStyle::Normal.with_bold(true), TextStyle::Bold);
1452        // Normal → with_italic(true) → Italic
1453        assert_eq!(TextStyle::Normal.with_italic(true), TextStyle::Italic);
1454        // Bold → with_italic(true) → BoldItalic
1455        assert_eq!(TextStyle::Bold.with_italic(true), TextStyle::BoldItalic);
1456        // BoldItalic → with_bold(false) → Italic
1457        assert_eq!(TextStyle::BoldItalic.with_bold(false), TextStyle::Italic);
1458        // BoldItalic → with_italic(false) → Bold
1459        assert_eq!(TextStyle::BoldItalic.with_italic(false), TextStyle::Bold);
1460        // Idempotent
1461        assert_eq!(TextStyle::Bold.with_bold(true), TextStyle::Bold);
1462        assert_eq!(TextStyle::Normal.with_bold(false), TextStyle::Normal);
1463    }
1464
1465    #[test]
1466    fn test_measure_text_various() {
1467        let engine = LayoutEngine::new(460.0, 650.0, 20.0);
1468        // Default 10x20 font: 10px per char
1469        assert_eq!(engine.measure_text("hello", TextStyle::Normal), 50.0);
1470        assert_eq!(engine.measure_text("", TextStyle::Normal), 0.0);
1471        assert_eq!(engine.measure_text("a", TextStyle::Bold), 10.0);
1472        assert_eq!(engine.measure_text("test string", TextStyle::Italic), 110.0);
1473    }
1474
1475    #[test]
1476    fn test_with_margins_affects_layout() {
1477        let tokens = vec![
1478            Token::Text("Margin test.".to_string()),
1479            Token::ParagraphBreak,
1480        ];
1481
1482        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0).with_margins(25.0, 40.0);
1483        let pages = engine.layout_tokens(&tokens);
1484
1485        assert!(!pages.is_empty());
1486        // First line Y should start at the top margin
1487        assert_eq!(pages[0].lines[0].y, 40);
1488    }
1489
1490    #[test]
1491    fn test_into_pages_empty_engine() {
1492        let engine = LayoutEngine::new(460.0, 650.0, 20.0);
1493        let pages = engine.into_pages();
1494        assert!(pages.is_empty());
1495    }
1496
1497    #[test]
1498    fn test_layout_whitespace_only_text() {
1499        let tokens = vec![
1500            Token::Text("   ".to_string()),
1501            Token::Text("visible".to_string()),
1502            Token::ParagraphBreak,
1503        ];
1504
1505        let mut engine = LayoutEngine::new(460.0, 650.0, 20.0);
1506        let pages = engine.layout_tokens(&tokens);
1507
1508        // Whitespace-only text produces no words after split_whitespace
1509        let texts = collect_line_texts(&pages);
1510        assert_eq!(texts.len(), 1);
1511        assert_eq!(texts[0], "visible");
1512    }
1513
1514    #[test]
1515    fn test_large_document_many_paragraphs() {
1516        let mut tokens = Vec::with_capacity(0);
1517        for i in 0..50 {
1518            tokens.push(Token::Text(alloc::format!(
1519                "Paragraph {} with enough text to be meaningful.",
1520                i
1521            )));
1522            tokens.push(Token::ParagraphBreak);
1523        }
1524
1525        let mut engine = LayoutEngine::new(460.0, 200.0, 20.0);
1526        let pages = engine.layout_tokens(&tokens);
1527
1528        // Should produce multiple pages
1529        assert!(pages.len() > 1);
1530
1531        // Page numbers should be sequential
1532        for (i, page) in pages.iter().enumerate() {
1533            assert_eq!(page.page_number, i + 1);
1534        }
1535
1536        // Total lines should account for all 50 paragraphs
1537        let total_lines: usize = pages.iter().map(|p| p.line_count()).sum();
1538        assert!(total_lines >= 50);
1539    }
1540}