Skip to main content

epub_stream_render/
render_layout.rs

1use epub_stream::{
2    BlockRole, ComputedTextStyle, StyledEvent, StyledEventOrRun, StyledImage, StyledRun,
3};
4use std::sync::Arc;
5
6use crate::render_ir::{
7    CoverPageMode, DrawCommand, ImageObjectCommand, JustificationStrategy, JustifyMode,
8    ObjectLayoutConfig, PageChromeCommand, PageChromeConfig, PageChromeKind, RenderIntent,
9    RenderPage, ResolvedTextStyle, TextCommand, TypographyConfig,
10};
11
12const SOFT_HYPHEN: char = '\u{00AD}';
13const LINE_FIT_GUARD_PX: f32 = 6.0;
14#[cfg(target_os = "espidf")]
15const MAX_BUFFERED_PARAGRAPH_WORDS: usize = 0;
16#[cfg(not(target_os = "espidf"))]
17const MAX_BUFFERED_PARAGRAPH_WORDS: usize = 64;
18const MAX_BUFFERED_PARAGRAPH_CHARS: usize = 1200;
19
20/// Optional text measurement hook for glyph-accurate line fitting.
21pub trait TextMeasurer: Send + Sync {
22    /// Measure rendered text width for the provided style.
23    fn measure_text_px(&self, text: &str, style: &ResolvedTextStyle) -> f32;
24
25    /// Conservative (safe upper-bound) width estimate.
26    ///
27    /// Default delegates to `measure_text_px`.
28    fn conservative_text_px(&self, text: &str, style: &ResolvedTextStyle) -> f32 {
29        self.measure_text_px(text, style)
30    }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34enum HyphenationLang {
35    Unknown,
36    English,
37}
38
39impl HyphenationLang {
40    fn from_tag(tag: &str) -> Self {
41        let lower = tag.to_ascii_lowercase();
42        if lower.starts_with("en") {
43            Self::English
44        } else {
45            Self::Unknown
46        }
47    }
48}
49
50/// Policy for discretionary soft-hyphen handling.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum SoftHyphenPolicy {
53    /// Treat soft hyphens as invisible and never break on them.
54    Ignore,
55    /// Use soft hyphens as break opportunities and show `-` when broken.
56    Discretionary,
57}
58
59/// Layout configuration for page construction.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct LayoutConfig {
62    /// Physical display width.
63    pub display_width: i32,
64    /// Physical display height.
65    pub display_height: i32,
66    /// Left margin.
67    pub margin_left: i32,
68    /// Right margin.
69    pub margin_right: i32,
70    /// Top margin.
71    pub margin_top: i32,
72    /// Bottom margin.
73    pub margin_bottom: i32,
74    /// Extra gap between lines.
75    pub line_gap_px: i32,
76    /// Gap after paragraph/list item end.
77    pub paragraph_gap_px: i32,
78    /// Gap around heading blocks.
79    pub heading_gap_px: i32,
80    /// Keep headings with at least this many subsequent lines.
81    pub heading_keep_with_next_lines: u8,
82    /// Left indent for list items.
83    pub list_indent_px: i32,
84    /// First-line indent for paragraph/body text.
85    pub first_line_indent_px: i32,
86    /// Suppress first-line indent on paragraph immediately after a heading.
87    pub suppress_indent_after_heading: bool,
88    /// Minimum words for justification.
89    pub justify_min_words: usize,
90    /// Required fill ratio for justification.
91    pub justify_min_fill_ratio: f32,
92    /// Minimum final line height in px.
93    pub min_line_height_px: i32,
94    /// Maximum final line height in px.
95    pub max_line_height_px: i32,
96    /// Soft-hyphen handling policy.
97    pub soft_hyphen_policy: SoftHyphenPolicy,
98    /// Page chrome emission policy.
99    pub page_chrome: PageChromeConfig,
100    /// Typography policy surface.
101    pub typography: TypographyConfig,
102    /// Non-text object layout policy surface.
103    pub object_layout: ObjectLayoutConfig,
104    /// Theme/render intent surface.
105    pub render_intent: RenderIntent,
106}
107
108impl LayoutConfig {
109    /// Convenience for a display size with sensible defaults.
110    pub fn for_display(width: i32, height: i32) -> Self {
111        Self {
112            display_width: width,
113            display_height: height,
114            ..Self::default()
115        }
116    }
117
118    fn content_width(self) -> i32 {
119        (self.display_width - self.margin_left - self.margin_right).max(1)
120    }
121
122    fn content_bottom(self) -> i32 {
123        self.display_height - self.margin_bottom
124    }
125}
126
127impl Default for LayoutConfig {
128    fn default() -> Self {
129        Self {
130            display_width: 480,
131            display_height: 800,
132            margin_left: 32,
133            margin_right: 32,
134            margin_top: 48,
135            margin_bottom: 40,
136            line_gap_px: 0,
137            paragraph_gap_px: 8,
138            heading_gap_px: 10,
139            heading_keep_with_next_lines: 2,
140            list_indent_px: 12,
141            first_line_indent_px: 18,
142            suppress_indent_after_heading: true,
143            justify_min_words: 7,
144            justify_min_fill_ratio: 0.75,
145            min_line_height_px: 14,
146            max_line_height_px: 48,
147            soft_hyphen_policy: SoftHyphenPolicy::Discretionary,
148            page_chrome: PageChromeConfig::default(),
149            typography: TypographyConfig::default(),
150            object_layout: ObjectLayoutConfig::default(),
151            render_intent: RenderIntent::default(),
152        }
153    }
154}
155
156/// Deterministic layout engine that emits render pages.
157#[derive(Clone)]
158pub struct LayoutEngine {
159    cfg: LayoutConfig,
160    text_measurer: Option<Arc<dyn TextMeasurer>>,
161}
162
163/// Incremental layout session for streaming styled items into pages.
164pub struct LayoutSession {
165    engine: LayoutEngine,
166    st: LayoutState,
167    ctx: BlockCtx,
168}
169
170impl core::fmt::Debug for LayoutEngine {
171    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
172        f.debug_struct("LayoutEngine")
173            .field("cfg", &self.cfg)
174            .field("has_text_measurer", &self.text_measurer.is_some())
175            .finish()
176    }
177}
178
179impl LayoutEngine {
180    /// Create a layout engine.
181    pub fn new(cfg: LayoutConfig) -> Self {
182        Self {
183            cfg,
184            text_measurer: None,
185        }
186    }
187
188    /// Install a shared text measurer for glyph-accurate width fitting.
189    pub fn with_text_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
190        self.text_measurer = Some(measurer);
191        self
192    }
193
194    /// Layout styled items into pages.
195    pub fn layout_items<I>(&self, items: I) -> Vec<RenderPage>
196    where
197        I: IntoIterator<Item = StyledEventOrRun>,
198    {
199        let mut pages = Vec::with_capacity(8);
200        self.layout_with(items, |page| pages.push(page));
201        pages
202    }
203
204    /// Start an incremental layout session.
205    pub fn start_session(&self) -> LayoutSession {
206        self.start_session_with_text_measurer(self.text_measurer.clone())
207    }
208
209    /// Start an incremental layout session with an explicit text measurer override.
210    pub fn start_session_with_text_measurer(
211        &self,
212        measurer: Option<Arc<dyn TextMeasurer>>,
213    ) -> LayoutSession {
214        LayoutSession {
215            engine: self.clone(),
216            st: LayoutState::new(self.cfg, measurer),
217            ctx: BlockCtx::default(),
218        }
219    }
220
221    /// Layout styled items and stream each page.
222    pub fn layout_with<I, F>(&self, items: I, mut on_page: F)
223    where
224        I: IntoIterator<Item = StyledEventOrRun>,
225        F: FnMut(RenderPage),
226    {
227        let mut session = self.start_session();
228        for item in items {
229            session.push_item(item);
230        }
231        session.finish(&mut on_page);
232    }
233
234    fn handle_run(&self, st: &mut LayoutState, ctx: &mut BlockCtx, run: StyledRun) {
235        let mut style = to_resolved_style(&run.style);
236        style.font_id = Some(run.font_id);
237        if !run.resolved_family.is_empty() {
238            style.family = run.resolved_family.clone();
239        }
240        if let Some(level) = ctx.heading_level {
241            style.role = BlockRole::Heading(level);
242        }
243        if ctx.in_list {
244            style.role = BlockRole::ListItem;
245        }
246        if matches!(style.role, BlockRole::FigureCaption) {
247            style.italic = true;
248            style.size_px = (style.size_px * 0.92).max(12.0);
249            style.line_height = style.line_height.max(1.3);
250            style.justify_mode = JustifyMode::None;
251        }
252
253        for word in run.text.split_whitespace() {
254            let mut extra_indent_px = 0;
255            if ctx.pending_indent
256                && matches!(style.role, BlockRole::Body | BlockRole::Paragraph)
257                && !ctx.in_list
258                && ctx.heading_level.is_none()
259            {
260                extra_indent_px = self.cfg.first_line_indent_px.max(0);
261                ctx.pending_indent = false;
262            }
263            st.push_word(word, style.clone(), extra_indent_px);
264        }
265    }
266
267    fn handle_event(&self, st: &mut LayoutState, ctx: &mut BlockCtx, ev: StyledEvent) {
268        match ev {
269            StyledEvent::ParagraphStart => {
270                if ctx.keep_with_next_pending {
271                    st.set_pending_keep_with_next(self.cfg.heading_keep_with_next_lines);
272                    ctx.keep_with_next_pending = false;
273                }
274                st.begin_paragraph();
275                if !ctx.suppress_next_indent {
276                    ctx.pending_indent = true;
277                }
278                ctx.suppress_next_indent = false;
279            }
280            StyledEvent::ParagraphEnd => {
281                st.flush_buffered_paragraph(false, true);
282                st.flush_line(true, false);
283                st.end_paragraph();
284                st.add_vertical_gap(self.cfg.paragraph_gap_px);
285                ctx.pending_indent = true;
286            }
287            StyledEvent::HeadingStart(level) => {
288                st.flush_buffered_paragraph(false, true);
289                st.flush_line(true, false);
290                st.end_paragraph();
291                st.add_vertical_gap(self.cfg.heading_gap_px);
292                ctx.heading_level = Some(level.clamp(1, 6));
293                ctx.pending_indent = false;
294            }
295            StyledEvent::HeadingEnd(_) => {
296                st.flush_buffered_paragraph(false, true);
297                st.flush_line(true, false);
298                st.add_vertical_gap(self.cfg.heading_gap_px);
299                ctx.heading_level = None;
300                ctx.pending_indent = false;
301                ctx.suppress_next_indent = self.cfg.suppress_indent_after_heading;
302                ctx.keep_with_next_pending = true;
303            }
304            StyledEvent::ListItemStart => {
305                if ctx.keep_with_next_pending {
306                    st.set_pending_keep_with_next(self.cfg.heading_keep_with_next_lines);
307                    ctx.keep_with_next_pending = false;
308                }
309                st.flush_buffered_paragraph(false, true);
310                st.flush_line(true, false);
311                st.end_paragraph();
312                ctx.in_list = true;
313                ctx.pending_indent = false;
314            }
315            StyledEvent::ListItemEnd => {
316                st.flush_buffered_paragraph(false, true);
317                st.flush_line(true, false);
318                st.add_vertical_gap(self.cfg.paragraph_gap_px.saturating_sub(2));
319                ctx.in_list = false;
320                ctx.pending_indent = true;
321            }
322            StyledEvent::LineBreak => {
323                st.flush_buffered_paragraph(true, false);
324                st.flush_line(false, true);
325                ctx.pending_indent = false;
326            }
327        }
328    }
329
330    fn handle_image(&self, st: &mut LayoutState, _ctx: &mut BlockCtx, image: StyledImage) {
331        st.flush_buffered_paragraph(false, true);
332        st.flush_line(true, false);
333        st.place_inline_image_block(image);
334    }
335}
336
337impl LayoutSession {
338    fn push_item_impl(&mut self, item: StyledEventOrRun) {
339        match item {
340            StyledEventOrRun::Run(run) => self.engine.handle_run(&mut self.st, &mut self.ctx, run),
341            StyledEventOrRun::Event(ev) => {
342                self.engine.handle_event(&mut self.st, &mut self.ctx, ev);
343            }
344            StyledEventOrRun::Image(image) => {
345                self.engine.handle_image(&mut self.st, &mut self.ctx, image);
346            }
347        }
348    }
349
350    /// Push one styled item into the layout state.
351    pub fn push_item(&mut self, item: StyledEventOrRun) {
352        self.push_item_impl(item);
353    }
354
355    /// Set the hyphenation language hint (e.g. "en", "en-US").
356    pub fn set_hyphenation_language(&mut self, language_tag: &str) {
357        self.st.hyphenation_lang = HyphenationLang::from_tag(language_tag);
358    }
359
360    /// Push one styled item and emit any fully closed pages.
361    pub fn push_item_with_pages<F>(&mut self, item: StyledEventOrRun, on_page: &mut F)
362    where
363        F: FnMut(RenderPage),
364    {
365        self.push_item_impl(item);
366        for page in self.st.drain_emitted_pages() {
367            on_page(page);
368        }
369    }
370
371    /// Finish the session and stream resulting pages.
372    pub fn finish<F>(&mut self, on_page: &mut F)
373    where
374        F: FnMut(RenderPage),
375    {
376        self.st.flush_buffered_paragraph(false, true);
377        self.st.flush_line(true, false);
378        let chrome_cfg = self.engine.cfg.page_chrome;
379        if !chrome_cfg.header_enabled && !chrome_cfg.footer_enabled && !chrome_cfg.progress_enabled
380        {
381            self.st.flush_page_if_non_empty();
382            for page in self.st.drain_emitted_pages() {
383                on_page(page);
384            }
385            return;
386        }
387        let mut pages = core::mem::take(&mut self.st).into_pages();
388        annotate_page_chrome(&mut pages, self.engine.cfg);
389        for page in pages {
390            on_page(page);
391        }
392    }
393}
394
395#[derive(Clone, Debug, Default)]
396struct BlockCtx {
397    heading_level: Option<u8>,
398    in_list: bool,
399    pending_indent: bool,
400    suppress_next_indent: bool,
401    keep_with_next_pending: bool,
402}
403
404#[derive(Clone, Debug)]
405struct CurrentLine {
406    text: String,
407    style: ResolvedTextStyle,
408    width_px: f32,
409    line_height_px: i32,
410    left_inset_px: i32,
411}
412
413#[derive(Clone, Debug)]
414struct ParagraphWord {
415    text: String,
416    style: ResolvedTextStyle,
417    left_inset_px: i32,
418    width_px: f32,
419}
420
421#[derive(Clone)]
422struct LayoutState {
423    cfg: LayoutConfig,
424    text_measurer: Option<Arc<dyn TextMeasurer>>,
425    page_no: usize,
426    cursor_y: i32,
427    page: RenderPage,
428    line: Option<CurrentLine>,
429    emitted: Vec<RenderPage>,
430    in_paragraph: bool,
431    lines_on_current_paragraph_page: usize,
432    pending_keep_with_next_lines: u8,
433    hyphenation_lang: HyphenationLang,
434    paragraph_words: Vec<ParagraphWord>,
435    paragraph_chars: usize,
436    replay_direct_mode: bool,
437    buffered_flush_mode: bool,
438}
439
440impl Default for LayoutState {
441    fn default() -> Self {
442        Self::new(LayoutConfig::default(), None)
443    }
444}
445
446impl LayoutState {
447    fn new(cfg: LayoutConfig, text_measurer: Option<Arc<dyn TextMeasurer>>) -> Self {
448        Self {
449            cfg,
450            text_measurer,
451            page_no: 1,
452            cursor_y: cfg.margin_top,
453            page: RenderPage::new(1),
454            line: None,
455            emitted: Vec::with_capacity(2),
456            in_paragraph: false,
457            lines_on_current_paragraph_page: 0,
458            pending_keep_with_next_lines: 0,
459            hyphenation_lang: HyphenationLang::Unknown,
460            paragraph_words: Vec::with_capacity(MAX_BUFFERED_PARAGRAPH_WORDS),
461            paragraph_chars: 0,
462            replay_direct_mode: false,
463            buffered_flush_mode: false,
464        }
465    }
466
467    fn begin_paragraph(&mut self) {
468        self.in_paragraph = true;
469        self.lines_on_current_paragraph_page = 0;
470        self.paragraph_words.clear();
471        self.paragraph_chars = 0;
472    }
473
474    fn end_paragraph(&mut self) {
475        self.paragraph_words.clear();
476        self.paragraph_chars = 0;
477        self.in_paragraph = false;
478        self.lines_on_current_paragraph_page = 0;
479    }
480
481    fn set_pending_keep_with_next(&mut self, lines: u8) {
482        self.pending_keep_with_next_lines = lines.max(1);
483    }
484
485    fn is_cover_page_candidate(&self, image: &StyledImage) -> bool {
486        if self.page_no != 1 || image.in_figure {
487            return false;
488        }
489        if !self.page.content_commands.is_empty()
490            || !self.page.chrome_commands.is_empty()
491            || !self.page.overlay_commands.is_empty()
492        {
493            return false;
494        }
495        let src_lower = image.src.to_ascii_lowercase();
496        let alt_lower = image.alt.to_ascii_lowercase();
497        if src_lower.contains("cover") || alt_lower.contains("cover") {
498            return true;
499        }
500        // Heuristic fallback: first render item on first page is often the cover,
501        // especially for books that use opaque hashed asset names.
502        self.cursor_y <= self.cfg.margin_top + 2
503    }
504
505    fn place_inline_image_block(&mut self, image: StyledImage) {
506        let content_w = self.cfg.content_width().max(8);
507        let content_h = (self.cfg.content_bottom() - self.cfg.margin_top).max(16);
508        let default_max_h = ((content_h as f32)
509            * self.cfg.object_layout.max_inline_image_height_ratio)
510            .round()
511            .clamp(40.0, content_h as f32) as i32;
512        let cover_mode = if self.is_cover_page_candidate(&image) {
513            self.cfg.object_layout.cover_page_mode
514        } else {
515            CoverPageMode::RespectCss
516        };
517        let (intrinsic_w, intrinsic_h) = match (image.width_px, image.height_px) {
518            (Some(w), Some(h)) if w > 0 && h > 0 => (w as f32, h as f32),
519            // Unknown intrinsic dimensions default to a portrait-friendly ratio.
520            _ => (720.0, 972.0),
521        };
522
523        let (mut image_x, mut image_y, mut image_w, mut image_h, enable_caption) = match cover_mode
524        {
525            CoverPageMode::Contain => {
526                let box_w = content_w.max(1) as f32;
527                let box_h = content_h.max(1) as f32;
528                let scale = (box_w / intrinsic_w).min(box_h / intrinsic_h);
529                let out_w = (intrinsic_w * scale).round().max(24.0) as i32;
530                let out_h = (intrinsic_h * scale).round().max(18.0) as i32;
531                let out_x = self.cfg.margin_left + ((content_w - out_w) / 2);
532                let out_y = self.cfg.margin_top + ((content_h - out_h) / 2);
533                (out_x, out_y, out_w, out_h, false)
534            }
535            CoverPageMode::FullBleed => {
536                let view_w = self.cfg.display_width.max(1) as f32;
537                let view_h = self.cfg.display_height.max(1) as f32;
538                let scale = (view_w / intrinsic_w).max(view_h / intrinsic_h);
539                let out_w = (intrinsic_w * scale).round().max(24.0) as i32;
540                let out_h = (intrinsic_h * scale).round().max(18.0) as i32;
541                let out_x = (self.cfg.display_width - out_w) / 2;
542                let out_y = (self.cfg.display_height - out_h) / 2;
543                (out_x, out_y, out_w, out_h, false)
544            }
545            CoverPageMode::RespectCss => {
546                let max_h = default_max_h;
547                let (mut out_w, mut out_h) = match (image.width_px, image.height_px) {
548                    (Some(w), Some(h)) if w > 0 && h > 0 => {
549                        let w = w as f32;
550                        let h = h as f32;
551                        let fit_w = content_w as f32 / w;
552                        let fit_h = max_h as f32 / h;
553                        let scale = fit_w.min(fit_h).min(1.0);
554                        let out_w = (w * scale).round().max(24.0) as i32;
555                        let out_h = (h * scale).round().max(18.0) as i32;
556                        (out_w, out_h)
557                    }
558                    _ => {
559                        let out_w = ((content_w as f32) * 0.72)
560                            .round()
561                            .clamp(60.0, content_w as f32)
562                            as i32;
563                        let out_h =
564                            ((out_w as f32) * 1.35).round().clamp(36.0, max_h as f32) as i32;
565                        (out_w, out_h)
566                    }
567                };
568                out_w = out_w.min(content_w);
569                out_h = out_h.min(max_h).max(18);
570                let out_x = self.cfg.margin_left + ((content_w - out_w) / 2);
571                let out_y = self.cursor_y;
572                (out_x, out_y, out_w, out_h, true)
573            }
574        };
575        image_w = image_w.max(1);
576        image_h = image_h.max(1);
577
578        let mut caption = String::with_capacity(0);
579        if enable_caption && self.cfg.object_layout.alt_text_fallback {
580            let alt = image.alt.trim();
581            if !alt.is_empty() {
582                caption = alt.to_string();
583            }
584        }
585        let caption_style = ResolvedTextStyle {
586            font_id: None,
587            family: "serif".to_string(),
588            weight: 400,
589            italic: true,
590            size_px: 14.0,
591            line_height: 1.2,
592            letter_spacing: 0.0,
593            role: BlockRole::Paragraph,
594            justify_mode: JustifyMode::None,
595        };
596        let caption_line_h = line_height_px(&caption_style, &self.cfg);
597        let caption_gap = if caption.is_empty() { 0 } else { 6 };
598        let reserve_caption_h = if enable_caption && image.in_figure && caption.is_empty() {
599            caption_line_h + 4
600        } else {
601            0
602        };
603        let block_h = image_h
604            + caption_gap
605            + if caption.is_empty() {
606                0
607            } else {
608                caption_line_h
609            }
610            + reserve_caption_h;
611
612        if enable_caption && self.cursor_y + block_h > self.cfg.content_bottom() {
613            self.start_next_page();
614            image_y = self.cursor_y;
615            image_x = self.cfg.margin_left + ((content_w - image_w) / 2);
616        }
617        self.page
618            .push_content_command(DrawCommand::ImageObject(ImageObjectCommand {
619                src: image.src.clone(),
620                alt: image.alt.clone(),
621                x: image_x,
622                y: image_y,
623                width: image_w.max(1) as u32,
624                height: image_h.max(1) as u32,
625            }));
626        // Keep src as structured annotation for debug/telemetry.
627        if !image.src.is_empty() {
628            self.page
629                .annotations
630                .push(crate::render_ir::PageAnnotation {
631                    kind: "inline_image_src".to_string(),
632                    value: Some(image.src),
633                });
634        }
635        self.page.sync_commands();
636        self.cursor_y = (image_y + image_h).max(self.cursor_y);
637
638        if enable_caption && !caption.is_empty() {
639            self.cursor_y += caption_gap;
640            let max_caption_w = (content_w - 8).max(1) as f32;
641            let caption_text =
642                truncate_text_to_width(self, &caption, &caption_style, max_caption_w);
643            self.page
644                .push_content_command(DrawCommand::Text(TextCommand {
645                    x: self.cfg.margin_left + 4,
646                    baseline_y: self.cursor_y + line_ascent_px(&caption_style, caption_line_h),
647                    text: caption_text,
648                    font_id: caption_style.font_id,
649                    style: caption_style,
650                }));
651            self.page.sync_commands();
652            self.cursor_y += caption_line_h;
653        }
654        if enable_caption {
655            self.cursor_y += self.cfg.paragraph_gap_px.max(4);
656        }
657    }
658
659    fn measure_text(&self, text: &str, style: &ResolvedTextStyle) -> f32 {
660        self.text_measurer
661            .as_ref()
662            .map(|m| m.measure_text_px(text, style))
663            .unwrap_or_else(|| heuristic_measure_text(text, style))
664    }
665
666    fn conservative_measure_text(&self, text: &str, style: &ResolvedTextStyle) -> f32 {
667        self.text_measurer
668            .as_ref()
669            .map(|m| m.conservative_text_px(text, style))
670            .unwrap_or_else(|| conservative_heuristic_measure_text(text, style))
671    }
672
673    fn push_word(&mut self, word: &str, style: ResolvedTextStyle, extra_first_line_indent_px: i32) {
674        if word.is_empty() {
675            return;
676        }
677
678        let mut left_inset_px = if matches!(style.role, BlockRole::ListItem) {
679            self.cfg.list_indent_px
680        } else {
681            0
682        };
683        left_inset_px += extra_first_line_indent_px.max(0);
684
685        let sanitized_word = strip_soft_hyphens(word);
686        if self.should_buffer_paragraph_word(&style, word) {
687            let projected_chars = self
688                .paragraph_chars
689                .saturating_add(sanitized_word.chars().count())
690                .saturating_add(usize::from(!self.paragraph_words.is_empty()));
691            if self.paragraph_words.len() >= MAX_BUFFERED_PARAGRAPH_WORDS
692                || projected_chars > MAX_BUFFERED_PARAGRAPH_CHARS
693            {
694                self.flush_buffered_paragraph(false, false);
695            }
696            let word_w = self.measure_text(&sanitized_word, &style);
697            self.paragraph_chars = self
698                .paragraph_chars
699                .saturating_add(sanitized_word.chars().count())
700                .saturating_add(usize::from(!self.paragraph_words.is_empty()));
701            self.paragraph_words.push(ParagraphWord {
702                text: sanitized_word,
703                style,
704                left_inset_px,
705                width_px: word_w,
706            });
707            return;
708        }
709        self.push_word_direct(word, style, left_inset_px);
710    }
711
712    fn should_buffer_paragraph_word(&self, style: &ResolvedTextStyle, raw_word: &str) -> bool {
713        if cfg!(target_os = "espidf") {
714            return false;
715        }
716        if self.replay_direct_mode {
717            return false;
718        }
719        if !uses_inter_word_justification(&self.cfg) {
720            return false;
721        }
722        if !self.in_paragraph || self.line.is_some() {
723            return false;
724        }
725        if raw_word.contains(SOFT_HYPHEN) {
726            return false;
727        }
728        if raw_word.chars().count() > 18 {
729            return false;
730        }
731        matches!(style.role, BlockRole::Body | BlockRole::Paragraph)
732    }
733
734    fn push_word_direct(&mut self, word: &str, style: ResolvedTextStyle, left_inset_px: i32) {
735        if word.is_empty() {
736            return;
737        }
738        let display_word = strip_soft_hyphens(word);
739        if display_word.is_empty() {
740            return;
741        }
742
743        if self.line.is_none() {
744            self.prepare_for_new_line(&style);
745            self.line = Some(CurrentLine {
746                text: String::with_capacity(64),
747                style: style.clone(),
748                width_px: 0.0,
749                line_height_px: line_height_px(&style, &self.cfg),
750                left_inset_px,
751            });
752        }
753
754        let Some(mut line) = self.line.take() else {
755            return;
756        };
757
758        if line.text.is_empty() {
759            line.style = style.clone();
760            line.left_inset_px = left_inset_px;
761            line.line_height_px = line_height_px(&style, &self.cfg);
762        }
763
764        let space_w = if line.text.is_empty() {
765            0.0
766        } else {
767            self.measure_text(" ", &line.style)
768        };
769        let word_w = self.measure_text(&display_word, &style);
770        let max_width = ((self.cfg.content_width() - line.left_inset_px).max(1) as f32
771            - line_fit_guard_px(&style))
772        .max(1.0);
773
774        let projected = line.width_px + space_w + word_w;
775        let overflow = projected - max_width;
776        let hang_credit = if self.cfg.typography.hanging_punctuation.enabled {
777            trailing_hang_credit_px(word, &style)
778        } else {
779            0.0
780        };
781
782        if overflow > hang_credit {
783            if (self.cfg.soft_hyphen_policy == SoftHyphenPolicy::Discretionary
784                || matches!(
785                    self.cfg.typography.hyphenation.soft_hyphen_policy,
786                    crate::render_ir::HyphenationMode::Discretionary
787                ))
788                && word.contains(SOFT_HYPHEN)
789                && self.try_break_word_at_soft_hyphen(&mut line, word, &style, max_width, space_w)
790            {
791                return;
792            }
793            if (self.cfg.soft_hyphen_policy == SoftHyphenPolicy::Discretionary
794                || matches!(
795                    self.cfg.typography.hyphenation.soft_hyphen_policy,
796                    crate::render_ir::HyphenationMode::Discretionary
797                ))
798                && !word.contains(SOFT_HYPHEN)
799                && self.try_auto_hyphenate(&mut line, word, &style, max_width, space_w)
800            {
801                return;
802            }
803            #[cfg(not(target_os = "espidf"))]
804            if let Some((left_text, right_text, left_w, right_w)) =
805                self.optimize_overflow_break(&line, word, &style, max_width)
806            {
807                let continuation_inset = if matches!(style.role, BlockRole::ListItem) {
808                    self.cfg.list_indent_px
809                } else {
810                    0
811                };
812                line.text = left_text;
813                line.width_px = left_w;
814                line.style = style.clone();
815                self.line = Some(line);
816                self.flush_line(false, false);
817                self.line = Some(CurrentLine {
818                    text: right_text,
819                    style: style.clone(),
820                    width_px: right_w,
821                    line_height_px: line_height_px(&style, &self.cfg),
822                    left_inset_px: continuation_inset,
823                });
824                return;
825            }
826            if line.text.is_empty() {
827                line.text = display_word.clone();
828                line.width_px = word_w;
829                line.style = style;
830                self.line = Some(line);
831                return;
832            }
833            self.line = Some(line);
834            self.flush_line(false, false);
835            self.line = Some(CurrentLine {
836                text: display_word,
837                style: style.clone(),
838                width_px: word_w,
839                line_height_px: line_height_px(&style, &self.cfg),
840                left_inset_px,
841            });
842            return;
843        }
844
845        if !line.text.is_empty() {
846            line.text.push(' ');
847            line.width_px += space_w;
848        }
849        line.text.push_str(&display_word);
850        line.width_px += word_w;
851        line.style = style;
852        self.line = Some(line);
853    }
854
855    fn prepare_for_new_line(&mut self, style: &ResolvedTextStyle) {
856        if self.pending_keep_with_next_lines > 0 {
857            let reserve = self.pending_keep_with_next_lines as i32;
858            let line_h = line_height_px(style, &self.cfg);
859            let required = reserve.saturating_mul(line_h + self.cfg.line_gap_px.max(0));
860            let remaining = self.cfg.content_bottom() - self.cursor_y;
861            if remaining < required && !self.page.content_commands.is_empty() {
862                self.start_next_page();
863            }
864            self.pending_keep_with_next_lines = 0;
865        }
866        if self.cfg.typography.widow_orphan_control.enabled
867            && self.in_paragraph
868            && self.lines_on_current_paragraph_page == 0
869        {
870            let min_lines = self.cfg.typography.widow_orphan_control.min_lines.max(1) as i32;
871            let line_h = line_height_px(style, &self.cfg);
872            let required = min_lines.saturating_mul(line_h + self.cfg.line_gap_px.max(0));
873            let remaining = self.cfg.content_bottom() - self.cursor_y;
874            if remaining < required && !self.page.content_commands.is_empty() {
875                self.start_next_page();
876            }
877        }
878    }
879
880    fn flush_buffered_paragraph(&mut self, mark_last_hard_break: bool, is_last_in_block: bool) {
881        if self.paragraph_words.is_empty() {
882            return;
883        }
884        if self.paragraph_words.len() < 2 {
885            let replay = core::mem::take(&mut self.paragraph_words);
886            self.paragraph_chars = 0;
887            self.replay_direct_mode = true;
888            for word in replay {
889                self.push_word_direct(&word.text, word.style, word.left_inset_px);
890            }
891            self.replay_direct_mode = false;
892            return;
893        }
894        let breaks = match self.optimize_paragraph_breaks() {
895            Some(breaks) if !breaks.is_empty() => breaks,
896            _ => {
897                let replay = core::mem::take(&mut self.paragraph_words);
898                self.paragraph_chars = 0;
899                self.replay_direct_mode = true;
900                for word in replay {
901                    self.push_word_direct(&word.text, word.style, word.left_inset_px);
902                }
903                self.replay_direct_mode = false;
904                return;
905            }
906        };
907
908        let words = core::mem::take(&mut self.paragraph_words);
909        self.paragraph_chars = 0;
910        self.buffered_flush_mode = true;
911        let mut start = 0usize;
912        for (idx, end) in breaks.iter().copied().enumerate() {
913            if end <= start || end > words.len() {
914                break;
915            }
916            let mut text = String::with_capacity(64);
917            for (offset, word) in words[start..end].iter().enumerate() {
918                if offset > 0 {
919                    text.push(' ');
920                }
921                text.push_str(&word.text);
922            }
923            let style = words[end - 1].style.clone();
924            let left_inset_px = words[start].left_inset_px;
925            self.prepare_for_new_line(&style);
926            let width_px = self.measure_text(&text, &style);
927            self.line = Some(CurrentLine {
928                text,
929                style,
930                width_px,
931                line_height_px: line_height_px(&words[end - 1].style, &self.cfg),
932                left_inset_px,
933            });
934            let is_last_line = idx + 1 == breaks.len();
935            self.flush_line(
936                is_last_line && is_last_in_block,
937                is_last_line && mark_last_hard_break,
938            );
939            start = end;
940        }
941        self.buffered_flush_mode = false;
942    }
943
944    fn optimize_paragraph_breaks(&self) -> Option<Vec<usize>> {
945        let words = &self.paragraph_words;
946        let n = words.len();
947        if n == 0 {
948            return Some(Vec::with_capacity(0));
949        }
950        if n == 1 {
951            return Some(vec![1]);
952        }
953
954        for (idx, word) in words.iter().enumerate() {
955            let available = ((self.cfg.content_width() - word.left_inset_px).max(1) as f32
956                - line_fit_guard_px(&word.style))
957            .max(1.0);
958            if word.width_px > available && idx == 0 {
959                return None;
960            }
961        }
962
963        let inf = i64::MAX / 4;
964        let mut dp = Vec::with_capacity(n + 1);
965        dp.resize(n + 1, inf);
966        let mut next_break = Vec::with_capacity(n + 1);
967        next_break.resize(n + 1, n);
968        dp[n] = 0;
969
970        for i in (0..n).rev() {
971            let available = ((self.cfg.content_width() - words[i].left_inset_px).max(1) as f32
972                - line_fit_guard_px(&words[i].style))
973            .max(1.0);
974            let mut line_width = 0.0f32;
975            for j in i..n {
976                if j == i {
977                    line_width += words[j].width_px;
978                } else {
979                    line_width += self.measure_text(" ", &words[j - 1].style) + words[j].width_px;
980                }
981                let slack = available - line_width;
982                if slack < 0.0 {
983                    break;
984                }
985                let is_last = j + 1 == n;
986                let words_in_line = j + 1 - i;
987                let fill_ratio = if available > 0.0 {
988                    line_width / available
989                } else {
990                    0.0
991                };
992                let mut badness = if is_last {
993                    let rag = (1.0 - fill_ratio).max(0.0);
994                    (rag * rag * 120.0).round() as i64
995                } else {
996                    let ratio = (slack / available).clamp(0.0, 1.2);
997                    (ratio * ratio * ratio * 2400.0).round() as i64
998                };
999                if matches!(
1000                    effective_justification_strategy(&self.cfg),
1001                    JustificationStrategy::AdaptiveInterWord
1002                ) {
1003                    let min_fill = self
1004                        .cfg
1005                        .typography
1006                        .justification
1007                        .min_fill_ratio
1008                        .max(self.cfg.justify_min_fill_ratio);
1009                    if !is_last && fill_ratio < min_fill {
1010                        badness += ((min_fill - fill_ratio) * 8000.0).round() as i64;
1011                    }
1012                }
1013                if !is_last && words_in_line == 1 {
1014                    badness += 3000;
1015                }
1016                if !is_last && words[j].text.chars().count() <= 2 {
1017                    badness += 4200;
1018                }
1019                if i > 0 && words[i].text.chars().count() <= 2 {
1020                    badness += 1000;
1021                }
1022                let candidate = badness.saturating_add(dp[j + 1]);
1023                if candidate < dp[i] {
1024                    dp[i] = candidate;
1025                    next_break[i] = j + 1;
1026                }
1027            }
1028        }
1029
1030        if dp[0] >= inf {
1031            return None;
1032        }
1033        let mut out = Vec::with_capacity(n / 2 + 1);
1034        let mut cursor = 0usize;
1035        while cursor < n {
1036            let next = next_break[cursor];
1037            if next <= cursor || next > n {
1038                return None;
1039            }
1040            out.push(next);
1041            cursor = next;
1042        }
1043        Some(out)
1044    }
1045
1046    #[cfg(not(target_os = "espidf"))]
1047    fn optimize_overflow_break(
1048        &self,
1049        line: &CurrentLine,
1050        incoming_word: &str,
1051        style: &ResolvedTextStyle,
1052        max_width: f32,
1053    ) -> Option<(String, String, f32, f32)> {
1054        if line.text.is_empty() || incoming_word.is_empty() {
1055            return None;
1056        }
1057        let mut words: Vec<&str> = line.text.split_whitespace().collect();
1058        words.push(incoming_word);
1059        if words.len() < 3 {
1060            return None;
1061        }
1062        let mut best: Option<(String, String, f32, f32, i32)> = None;
1063        // Keep at least one word on each side.
1064        for break_idx in 1..words.len() {
1065            let left_words = &words[..break_idx];
1066            let right_words = &words[break_idx..];
1067            let left = left_words.join(" ");
1068            let right = right_words.join(" ");
1069            let left_w = self.measure_text(&left, style);
1070            if left_w > max_width {
1071                continue;
1072            }
1073            let right_w = self.measure_text(&right, style);
1074            let slack = (max_width - left_w).max(0.0);
1075            let last_left_len = left_words
1076                .last()
1077                .map(|w| w.chars().count() as i32)
1078                .unwrap_or_default();
1079            let first_right_len = right_words
1080                .first()
1081                .map(|w| w.chars().count() as i32)
1082                .unwrap_or_default();
1083            let mut score = (slack * slack).round() as i32;
1084            if last_left_len <= 2 {
1085                score += 1400;
1086            }
1087            if first_right_len <= 2 {
1088                score += 900;
1089            }
1090            if right_words.len() == 1 {
1091                score += 400;
1092            }
1093            match best {
1094                Some((_, _, _, _, best_score)) if score >= best_score => {}
1095                _ => best = Some((left, right, left_w, right_w, score)),
1096            }
1097        }
1098        best.map(|(left, right, left_w, right_w, _)| (left, right, left_w, right_w))
1099    }
1100
1101    fn try_break_word_at_soft_hyphen(
1102        &mut self,
1103        line: &mut CurrentLine,
1104        raw_word: &str,
1105        style: &ResolvedTextStyle,
1106        max_width: f32,
1107        space_w: f32,
1108    ) -> bool {
1109        let parts: Vec<&str> = raw_word.split(SOFT_HYPHEN).collect();
1110        if parts.len() < 2 {
1111            return false;
1112        }
1113
1114        let mut best_prefix: Option<(String, String)> = None;
1115        for i in 1..parts.len() {
1116            let prefix = parts[..i].concat();
1117            let suffix = parts[i..].concat();
1118            if prefix.is_empty() || suffix.is_empty() {
1119                continue;
1120            }
1121            let candidate = format!("{prefix}-");
1122            let candidate_w = self.measure_text(&candidate, style);
1123            let added = if line.text.is_empty() {
1124                candidate_w
1125            } else {
1126                space_w + candidate_w
1127            };
1128            if line.width_px + added <= max_width {
1129                best_prefix = Some((candidate, suffix));
1130            } else {
1131                break;
1132            }
1133        }
1134
1135        let Some((prefix_with_hyphen, remainder)) = best_prefix else {
1136            return false;
1137        };
1138
1139        if !line.text.is_empty() {
1140            line.text.push(' ');
1141            line.width_px += space_w;
1142        }
1143        line.text.push_str(&prefix_with_hyphen);
1144        line.width_px += self.measure_text(&prefix_with_hyphen, style);
1145
1146        self.line = Some(line.clone());
1147        self.flush_line(false, false);
1148        self.push_word(&remainder, style.clone(), 0);
1149        true
1150    }
1151
1152    fn try_auto_hyphenate(
1153        &mut self,
1154        line: &mut CurrentLine,
1155        word: &str,
1156        style: &ResolvedTextStyle,
1157        max_width: f32,
1158        space_w: f32,
1159    ) -> bool {
1160        if !matches!(self.hyphenation_lang, HyphenationLang::English) {
1161            return false;
1162        }
1163        let candidates = english_hyphenation_candidates(word);
1164        if candidates.is_empty() {
1165            return false;
1166        }
1167
1168        let mut best_split: Option<(String, String, f32, i32)> = None;
1169        for split in candidates {
1170            let Some((left, right)) = split_word_at_char_boundary(word, split) else {
1171                continue;
1172            };
1173            if left.chars().count() < 3 || right.chars().count() < 3 {
1174                continue;
1175            }
1176            let left_h = format!("{left}-");
1177            let candidate_w = self.measure_text(&left_h, style);
1178            let added = if line.text.is_empty() {
1179                candidate_w
1180            } else {
1181                space_w + candidate_w
1182            };
1183            if line.width_px + added <= max_width {
1184                let left_len = left.chars().count() as i32;
1185                let right_len = right.chars().count() as i32;
1186                let balance_penalty = (left_len - right_len).abs();
1187                // Prefer fitting split near the right edge while avoiding overly
1188                // unbalanced chunks that produce bad rhythm.
1189                let fit_slack = (max_width - (line.width_px + added)).round() as i32;
1190                let score = fit_slack.saturating_mul(2).saturating_add(balance_penalty);
1191                match best_split {
1192                    Some((_, _, _, best_score)) if score >= best_score => {}
1193                    _ => best_split = Some((left_h, right.to_string(), candidate_w, score)),
1194                }
1195            }
1196        }
1197
1198        let Some((prefix_with_hyphen, remainder, _, _)) = best_split else {
1199            return false;
1200        };
1201
1202        if !line.text.is_empty() {
1203            line.text.push(' ');
1204            line.width_px += space_w;
1205        }
1206        line.text.push_str(&prefix_with_hyphen);
1207        line.width_px += self.measure_text(&prefix_with_hyphen, style);
1208
1209        self.line = Some(line.clone());
1210        self.flush_line(false, false);
1211        self.push_word(&remainder, style.clone(), 0);
1212        true
1213    }
1214
1215    fn flush_line(&mut self, is_last_in_block: bool, hard_break: bool) {
1216        let Some(mut line) = self.line.take() else {
1217            return;
1218        };
1219        if line.text.trim().is_empty() {
1220            return;
1221        }
1222
1223        if self.cursor_y + line.line_height_px > self.cfg.content_bottom() {
1224            self.start_next_page();
1225        }
1226        let short_line_words = line.text.split_whitespace().count();
1227        let remaining_after = self.cfg.content_bottom()
1228            - (self.cursor_y + line.line_height_px + self.cfg.line_gap_px.max(0));
1229        if short_line_words <= 2
1230            && !is_last_in_block
1231            && !self.page.content_commands.is_empty()
1232            && remaining_after < line.line_height_px
1233        {
1234            self.start_next_page();
1235        }
1236
1237        let available_width = ((self.cfg.content_width() - line.left_inset_px) as f32
1238            - line_fit_guard_px(&line.style)) as i32;
1239        let quality_remainder = if matches!(
1240            effective_justification_strategy(&self.cfg),
1241            JustificationStrategy::AdaptiveInterWord
1242        ) && !is_last_in_block
1243            && !hard_break
1244            && !self.buffered_flush_mode
1245            && !cfg!(target_os = "espidf")
1246        {
1247            self.rebalance_line_for_quality(&mut line, available_width)
1248        } else {
1249            None
1250        };
1251        if let Some(overflow_word) = quality_remainder {
1252            let continuation_inset = if matches!(line.style.role, BlockRole::ListItem) {
1253                self.cfg.list_indent_px
1254            } else {
1255                0
1256            };
1257            self.line = Some(CurrentLine {
1258                text: overflow_word.clone(),
1259                style: line.style.clone(),
1260                width_px: self.measure_text(&overflow_word, &line.style),
1261                line_height_px: line_height_px(&line.style, &self.cfg),
1262                left_inset_px: continuation_inset,
1263            });
1264        }
1265        if !self.buffered_flush_mode && !cfg!(target_os = "espidf") {
1266            if let Some(overflow_word) =
1267                self.rebalance_line_for_right_edge(&mut line, available_width)
1268            {
1269                let continuation_inset = if matches!(line.style.role, BlockRole::ListItem) {
1270                    self.cfg.list_indent_px
1271                } else {
1272                    0
1273                };
1274                self.line = Some(CurrentLine {
1275                    text: overflow_word.clone(),
1276                    style: line.style.clone(),
1277                    width_px: self.measure_text(&overflow_word, &line.style),
1278                    line_height_px: line_height_px(&line.style, &self.cfg),
1279                    left_inset_px: continuation_inset,
1280                });
1281            }
1282        }
1283        let words = line.text.split_whitespace().count();
1284        let spaces = line.text.chars().filter(|c| *c == ' ').count() as i32;
1285        let fill_ratio = if available_width > 0 {
1286            line.width_px / available_width as f32
1287        } else {
1288            0.0
1289        };
1290        line.style.justify_mode = resolve_line_justify_mode(
1291            &self.cfg,
1292            &line,
1293            LineJustifyInputs {
1294                available_width,
1295                words,
1296                spaces,
1297                fill_ratio,
1298                is_last_in_block,
1299                hard_break,
1300                measured_space_width: self.measure_text(" ", &line.style).max(1.0),
1301            },
1302        );
1303
1304        self.page
1305            .push_content_command(DrawCommand::Text(TextCommand {
1306                x: self.cfg.margin_left + line.left_inset_px,
1307                baseline_y: self.cursor_y + line_ascent_px(&line.style, line.line_height_px),
1308                text: line.text,
1309                font_id: line.style.font_id,
1310                style: line.style,
1311            }));
1312        self.page.sync_commands();
1313
1314        self.cursor_y += line.line_height_px + self.cfg.line_gap_px;
1315        if self.in_paragraph {
1316            self.lines_on_current_paragraph_page =
1317                self.lines_on_current_paragraph_page.saturating_add(1);
1318        }
1319    }
1320
1321    fn rebalance_line_for_right_edge(
1322        &self,
1323        line: &mut CurrentLine,
1324        available_width: i32,
1325    ) -> Option<String> {
1326        if available_width <= 0 {
1327            return None;
1328        }
1329        let measured = self.measure_text(&line.text, &line.style);
1330        if measured <= available_width as f32 {
1331            return None;
1332        }
1333        let conservative = self.conservative_measure_text(&line.text, &line.style);
1334        if conservative <= available_width as f32 {
1335            return None;
1336        }
1337        let source = line.text.clone();
1338        let (head, tail) = source.rsplit_once(' ')?;
1339        let head = head.trim_end();
1340        let tail = tail.trim_start();
1341        if head.is_empty() || tail.is_empty() {
1342            return None;
1343        }
1344        line.text = head.to_string();
1345        line.width_px = self.measure_text(&line.text, &line.style);
1346        Some(tail.to_string())
1347    }
1348
1349    fn rebalance_line_for_quality(
1350        &self,
1351        line: &mut CurrentLine,
1352        available_width: i32,
1353    ) -> Option<String> {
1354        if available_width <= 0 {
1355            return None;
1356        }
1357        let words: Vec<&str> = line.text.split_whitespace().collect();
1358        let tail = *words.last()?;
1359        if tail.chars().count() > 2 || words.len() < 3 {
1360            return None;
1361        }
1362        let source = line.text.clone();
1363        let (head, tail) = source.rsplit_once(' ')?;
1364        let head = head.trim_end();
1365        let tail = tail.trim_start();
1366        if head.is_empty() || tail.is_empty() {
1367            return None;
1368        }
1369        let head_w = self.measure_text(head, &line.style);
1370        let fill = head_w / available_width as f32;
1371        if fill < 0.55 {
1372            return None;
1373        }
1374        line.text = head.to_string();
1375        line.width_px = head_w;
1376        Some(tail.to_string())
1377    }
1378
1379    fn add_vertical_gap(&mut self, gap_px: i32) {
1380        if gap_px <= 0 {
1381            return;
1382        }
1383        self.cursor_y += gap_px;
1384        if self.cursor_y >= self.cfg.content_bottom() {
1385            self.start_next_page();
1386        }
1387    }
1388
1389    fn start_next_page(&mut self) {
1390        self.flush_page_if_non_empty();
1391        self.page_no += 1;
1392        self.page = RenderPage::new(self.page_no);
1393        self.cursor_y = self.cfg.margin_top;
1394        if self.in_paragraph {
1395            self.lines_on_current_paragraph_page = 0;
1396        }
1397    }
1398
1399    fn flush_page_if_non_empty(&mut self) {
1400        if self.page.content_commands.is_empty()
1401            && self.page.chrome_commands.is_empty()
1402            && self.page.overlay_commands.is_empty()
1403        {
1404            return;
1405        }
1406        let mut page = core::mem::replace(&mut self.page, RenderPage::new(self.page_no + 1));
1407        page.metrics.chapter_page_index = page.page_number.saturating_sub(1);
1408        page.sync_commands();
1409        self.emitted.push(page);
1410    }
1411
1412    fn into_pages(mut self) -> Vec<RenderPage> {
1413        self.flush_page_if_non_empty();
1414        self.emitted
1415    }
1416
1417    fn drain_emitted_pages(&mut self) -> Vec<RenderPage> {
1418        core::mem::take(&mut self.emitted)
1419    }
1420}
1421
1422fn truncate_text_to_width(
1423    st: &LayoutState,
1424    text: &str,
1425    style: &ResolvedTextStyle,
1426    max_width: f32,
1427) -> String {
1428    if st.measure_text(text, style) <= max_width {
1429        return text.to_string();
1430    }
1431    let mut out = String::with_capacity(text.len().min(64));
1432    for ch in text.chars() {
1433        let mut candidate = out.clone();
1434        candidate.push(ch);
1435        candidate.push('…');
1436        if st.measure_text(&candidate, style) > max_width {
1437            break;
1438        }
1439        out.push(ch);
1440    }
1441    out.push('…');
1442    out
1443}
1444
1445fn to_resolved_style(style: &ComputedTextStyle) -> ResolvedTextStyle {
1446    let family = style
1447        .family_stack
1448        .first()
1449        .cloned()
1450        .unwrap_or_else(|| "serif".to_string());
1451    ResolvedTextStyle {
1452        font_id: None,
1453        family,
1454        weight: style.weight,
1455        italic: style.italic,
1456        size_px: style.size_px,
1457        line_height: style.line_height,
1458        letter_spacing: style.letter_spacing,
1459        role: style.block_role,
1460        justify_mode: JustifyMode::None,
1461    }
1462}
1463
1464fn effective_justification_strategy(cfg: &LayoutConfig) -> JustificationStrategy {
1465    if cfg.typography.justification.enabled {
1466        cfg.typography.justification.strategy
1467    } else {
1468        JustificationStrategy::AlignLeft
1469    }
1470}
1471
1472fn uses_inter_word_justification(cfg: &LayoutConfig) -> bool {
1473    matches!(
1474        effective_justification_strategy(cfg),
1475        JustificationStrategy::AdaptiveInterWord | JustificationStrategy::FullInterWord
1476    )
1477}
1478
1479#[derive(Clone, Copy, Debug)]
1480struct LineJustifyInputs {
1481    available_width: i32,
1482    words: usize,
1483    spaces: i32,
1484    fill_ratio: f32,
1485    is_last_in_block: bool,
1486    hard_break: bool,
1487    measured_space_width: f32,
1488}
1489
1490fn resolve_line_justify_mode(
1491    cfg: &LayoutConfig,
1492    line: &CurrentLine,
1493    inputs: LineJustifyInputs,
1494) -> JustifyMode {
1495    if !matches!(line.style.role, BlockRole::Body | BlockRole::Paragraph) {
1496        return JustifyMode::None;
1497    }
1498    if inputs.available_width <= 0 {
1499        return JustifyMode::None;
1500    }
1501    let slack = (inputs.available_width as f32 - line.width_px).max(0.0) as i32;
1502    let strategy = effective_justification_strategy(cfg);
1503    match strategy {
1504        JustificationStrategy::AlignLeft => JustifyMode::None,
1505        JustificationStrategy::AlignRight => {
1506            if slack > 0 {
1507                JustifyMode::AlignRight { offset_px: slack }
1508            } else {
1509                JustifyMode::None
1510            }
1511        }
1512        JustificationStrategy::AlignCenter => {
1513            if slack > 1 {
1514                JustifyMode::AlignCenter {
1515                    offset_px: slack / 2,
1516                }
1517            } else {
1518                JustifyMode::None
1519            }
1520        }
1521        JustificationStrategy::AdaptiveInterWord | JustificationStrategy::FullInterWord => {
1522            if inputs.is_last_in_block || inputs.hard_break || inputs.spaces <= 0 {
1523                return JustifyMode::None;
1524            }
1525            let min_words = cfg
1526                .typography
1527                .justification
1528                .min_words
1529                .max(cfg.justify_min_words);
1530            if inputs.words < min_words {
1531                return JustifyMode::None;
1532            }
1533
1534            if matches!(strategy, JustificationStrategy::AdaptiveInterWord) {
1535                let min_fill = cfg
1536                    .typography
1537                    .justification
1538                    .min_fill_ratio
1539                    .max(cfg.justify_min_fill_ratio);
1540                if inputs.fill_ratio < min_fill || ends_with_terminal_punctuation(&line.text) {
1541                    return JustifyMode::None;
1542                }
1543            }
1544
1545            let mut extra = slack;
1546            if matches!(strategy, JustificationStrategy::AdaptiveInterWord) {
1547                let stretch = cfg
1548                    .typography
1549                    .justification
1550                    .max_space_stretch_ratio
1551                    .clamp(0.0, 8.0);
1552                let max_extra_per_space = (inputs.measured_space_width * stretch).round() as i32;
1553                if max_extra_per_space > 0 {
1554                    extra = extra.min(inputs.spaces.saturating_mul(max_extra_per_space));
1555                }
1556            }
1557            if extra > 0 {
1558                JustifyMode::InterWord {
1559                    extra_px_total: extra,
1560                }
1561            } else {
1562                JustifyMode::None
1563            }
1564        }
1565    }
1566}
1567
1568fn heuristic_measure_text(text: &str, style: &ResolvedTextStyle) -> f32 {
1569    let chars = text.chars().count();
1570    if chars == 0 {
1571        return 0.0;
1572    }
1573    // Dynamic width model:
1574    // - per-glyph class widths (narrow/regular/wide/punctuation/digit)
1575    // - style/family modifiers
1576    // This is more stable across font sizes/families than a single scalar.
1577    let family = style.family.to_ascii_lowercase();
1578    let proportional = !(family.contains("mono") || family.contains("fixed"));
1579    let mut em_sum = 0.0f32;
1580    if proportional {
1581        for ch in text.chars() {
1582            em_sum += proportional_glyph_em_width(ch);
1583        }
1584    } else {
1585        // Fixed-width fallback still uses a small class delta for punctuation.
1586        for ch in text.chars() {
1587            em_sum += if ch == ' ' { 0.52 } else { 0.58 };
1588        }
1589    }
1590
1591    let mut family_scale = if family.contains("serif") {
1592        1.03
1593    } else if family.contains("sans") {
1594        0.99
1595    } else {
1596        1.00
1597    };
1598    if style.weight >= 700 {
1599        family_scale += 0.03;
1600    }
1601    if style.italic {
1602        family_scale += 0.01;
1603    }
1604    if style.size_px >= 24.0 {
1605        family_scale += 0.01;
1606    }
1607
1608    let mut width = em_sum * style.size_px * family_scale;
1609    if chars > 1 {
1610        width += (chars as f32 - 1.0) * style.letter_spacing;
1611    }
1612    width
1613}
1614
1615fn proportional_glyph_em_width(ch: char) -> f32 {
1616    match ch {
1617        ' ' => 0.32,
1618        '\t' => 1.28,
1619        '\u{00A0}' => 0.32,
1620        'i' | 'l' | 'I' | '|' | '!' => 0.24,
1621        '.' | ',' | ':' | ';' | '\'' | '"' | '`' => 0.23,
1622        '-' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' => 0.34,
1623        '(' | ')' | '[' | ']' | '{' | '}' => 0.30,
1624        'f' | 't' | 'j' | 'r' => 0.34,
1625        'm' | 'w' | 'M' | 'W' | '@' | '%' | '&' | '#' => 0.74,
1626        c if c.is_ascii_digit() => 0.52,
1627        c if c.is_ascii_uppercase() => 0.64,
1628        c if c.is_ascii_lowercase() => 0.52,
1629        c if c.is_whitespace() => 0.32,
1630        c if c.is_ascii_punctuation() => 0.42,
1631        _ => 0.56,
1632    }
1633}
1634
1635fn line_height_px(style: &ResolvedTextStyle, cfg: &LayoutConfig) -> i32 {
1636    let min_lh = cfg.min_line_height_px.min(cfg.max_line_height_px);
1637    let max_lh = cfg.max_line_height_px.max(cfg.min_line_height_px);
1638    (style.size_px * style.line_height)
1639        .round()
1640        .clamp(min_lh as f32, max_lh as f32) as i32
1641}
1642
1643fn line_fit_guard_px(style: &ResolvedTextStyle) -> f32 {
1644    let family = style.family.to_ascii_lowercase();
1645    let proportional = !(family.contains("mono") || family.contains("fixed"));
1646    let mut guard = LINE_FIT_GUARD_PX;
1647    // Proportional and larger sizes can have right-side overhangs in rendered
1648    // glyph bitmaps; reserve a tiny extra safety band to avoid clipping.
1649    if proportional {
1650        guard += 2.0;
1651    }
1652    if style.size_px >= 24.0 {
1653        guard += 2.0;
1654    }
1655    if style.weight >= 700 {
1656        guard += 1.0;
1657    }
1658    guard
1659}
1660
1661fn conservative_heuristic_measure_text(text: &str, style: &ResolvedTextStyle) -> f32 {
1662    let chars = text.chars().count();
1663    if chars == 0 {
1664        return 0.0;
1665    }
1666    let family = style.family.to_ascii_lowercase();
1667    let proportional = !(family.contains("mono") || family.contains("fixed"));
1668    let mut em_sum = 0.0f32;
1669    if proportional {
1670        for ch in text.chars() {
1671            em_sum += match ch {
1672                ' ' | '\u{00A0}' => 0.32,
1673                '\t' => 1.28,
1674                'i' | 'l' | 'I' | '|' | '!' => 0.24,
1675                '.' | ',' | ':' | ';' | '\'' | '"' | '`' => 0.23,
1676                '-' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' => 0.34,
1677                '(' | ')' | '[' | ']' | '{' | '}' => 0.30,
1678                'f' | 't' | 'j' | 'r' => 0.34,
1679                'm' | 'w' | 'M' | 'W' | '@' | '%' | '&' | '#' => 0.74,
1680                c if c.is_ascii_digit() => 0.52,
1681                c if c.is_ascii_uppercase() => 0.61,
1682                c if c.is_ascii_lowercase() => 0.50,
1683                c if c.is_whitespace() => 0.32,
1684                c if c.is_ascii_punctuation() => 0.42,
1685                _ => 0.56,
1686            };
1687        }
1688    } else {
1689        for ch in text.chars() {
1690            em_sum += if ch == ' ' { 0.52 } else { 0.58 };
1691        }
1692    }
1693    let mut scale = if proportional { 1.05 } else { 1.02 };
1694    if style.weight >= 700 {
1695        scale += 0.02;
1696    }
1697    if style.italic {
1698        scale += 0.01;
1699    }
1700    if style.size_px >= 24.0 {
1701        scale += 0.01;
1702    }
1703    let mut width = em_sum * style.size_px * scale;
1704    if chars > 1 {
1705        width += (chars as f32 - 1.0) * style.letter_spacing.max(0.0);
1706    }
1707    width
1708}
1709
1710fn line_ascent_px(style: &ResolvedTextStyle, line_height_px: i32) -> i32 {
1711    let approx = (style.size_px * 0.78).round() as i32;
1712    approx.clamp(1, line_height_px.saturating_sub(1).max(1))
1713}
1714
1715fn trailing_hang_credit_px(word: &str, style: &ResolvedTextStyle) -> f32 {
1716    let Some(last) = word.chars().last() else {
1717        return 0.0;
1718    };
1719    if matches!(
1720        last,
1721        '.' | ',' | ';' | ':' | '!' | '?' | '"' | '\'' | ')' | ']' | '}' | '»'
1722    ) {
1723        (style.size_px * 0.18).clamp(1.0, 4.0)
1724    } else {
1725        0.0
1726    }
1727}
1728
1729fn ends_with_terminal_punctuation(line: &str) -> bool {
1730    let Some(last) = line.trim_end().chars().last() else {
1731        return false;
1732    };
1733    matches!(
1734        last,
1735        '.' | ';' | ':' | '!' | '?' | '"' | '\'' | ')' | ']' | '}'
1736    )
1737}
1738
1739fn split_word_at_char_boundary(word: &str, split_chars: usize) -> Option<(&str, &str)> {
1740    if split_chars == 0 {
1741        return None;
1742    }
1743    let mut split_byte = None;
1744    for (idx, (byte, _)) in word.char_indices().enumerate() {
1745        if idx == split_chars {
1746            split_byte = Some(byte);
1747            break;
1748        }
1749    }
1750    let split_byte = split_byte?;
1751    Some((&word[..split_byte], &word[split_byte..]))
1752}
1753
1754fn english_hyphenation_candidates(word: &str) -> Vec<usize> {
1755    let chars: Vec<char> = word.chars().collect();
1756    if chars.len() < 7 {
1757        return Vec::with_capacity(0);
1758    }
1759    let mut candidates = Vec::with_capacity(chars.len() / 2);
1760    if let Some(exception) = english_hyphenation_exception(word) {
1761        candidates.extend_from_slice(exception);
1762    }
1763    let is_vowel = |c: char| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y');
1764
1765    for i in 3..(chars.len().saturating_sub(3)) {
1766        let prev = chars[i - 1];
1767        let next = chars[i];
1768        if !prev.is_ascii_alphabetic() || !next.is_ascii_alphabetic() {
1769            continue;
1770        }
1771        if is_vowel(prev) != is_vowel(next) {
1772            candidates.push(i);
1773        }
1774    }
1775
1776    const SUFFIXES: &[&str] = &[
1777        "tion", "sion", "ment", "ness", "less", "able", "ible", "ally", "ingly", "edly", "ing",
1778        "ed", "ly",
1779    ];
1780    let lower = word.to_ascii_lowercase();
1781    for suffix in SUFFIXES {
1782        if lower.ends_with(suffix) {
1783            let split = chars.len().saturating_sub(suffix.chars().count());
1784            if split >= 3 && split + 3 <= chars.len() {
1785                candidates.push(split);
1786            }
1787        }
1788    }
1789
1790    candidates.sort_unstable();
1791    candidates.dedup();
1792    candidates
1793}
1794
1795fn english_hyphenation_exception(word: &str) -> Option<&'static [usize]> {
1796    let lower = word.to_ascii_lowercase();
1797    match lower.as_str() {
1798        "characteristically" => Some(&[4, 6, 9, 12]),
1799        "accessibility" => Some(&[3, 6, 9]),
1800        "fundamental" => Some(&[3, 6]),
1801        "functionality" => Some(&[4, 7, 10]),
1802        "publication" => Some(&[3, 6]),
1803        "consortium" => Some(&[3, 6]),
1804        "adventure" => Some(&[2, 5]),
1805        "marvellous" => Some(&[3, 6]),
1806        "extraordinary" => Some(&[5, 8]),
1807        "responsibility" => Some(&[3, 6, 9]),
1808        "determined" => Some(&[3, 6]),
1809        "encounter" => Some(&[2, 5]),
1810        "obedient" => Some(&[2, 5]),
1811        "endeavour" => Some(&[2, 5]),
1812        "providence" => Some(&[3, 6]),
1813        "language" => Some(&[3]),
1814        "fortune" => Some(&[3]),
1815        "navigator" => Some(&[3, 6]),
1816        "navigators" => Some(&[3, 6]),
1817        "apparently" => Some(&[3, 6]),
1818        "hitherto" => Some(&[3]),
1819        "merchantman" => Some(&[4, 7]),
1820        _ => None,
1821    }
1822}
1823
1824fn strip_soft_hyphens(text: &str) -> String {
1825    if text.contains(SOFT_HYPHEN) {
1826        text.chars().filter(|ch| *ch != SOFT_HYPHEN).collect()
1827    } else {
1828        text.to_string()
1829    }
1830}
1831
1832fn annotate_page_chrome(pages: &mut [RenderPage], cfg: LayoutConfig) {
1833    if pages.is_empty() {
1834        return;
1835    }
1836    let total = pages.len();
1837    for page in pages.iter_mut() {
1838        if cfg.page_chrome.header_enabled {
1839            page.push_chrome_command(DrawCommand::PageChrome(PageChromeCommand {
1840                kind: PageChromeKind::Header,
1841                text: Some(format!("Page {}", page.page_number)),
1842                current: None,
1843                total: None,
1844            }));
1845        }
1846        if cfg.page_chrome.footer_enabled {
1847            page.push_chrome_command(DrawCommand::PageChrome(PageChromeCommand {
1848                kind: PageChromeKind::Footer,
1849                text: Some(format!("Page {}", page.page_number)),
1850                current: None,
1851                total: None,
1852            }));
1853        }
1854        if cfg.page_chrome.progress_enabled {
1855            page.push_chrome_command(DrawCommand::PageChrome(PageChromeCommand {
1856                kind: PageChromeKind::Progress,
1857                text: None,
1858                current: Some(page.page_number),
1859                total: Some(total),
1860            }));
1861        }
1862        page.sync_commands();
1863    }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869
1870    struct WideMeasurer;
1871
1872    impl TextMeasurer for WideMeasurer {
1873        fn measure_text_px(&self, text: &str, style: &ResolvedTextStyle) -> f32 {
1874            text.chars().count() as f32 * (style.size_px * 0.9).max(1.0)
1875        }
1876    }
1877
1878    fn body_run(text: &str) -> StyledEventOrRun {
1879        StyledEventOrRun::Run(StyledRun {
1880            text: text.to_string(),
1881            style: ComputedTextStyle {
1882                family_stack: vec!["serif".to_string()],
1883                weight: 400,
1884                italic: false,
1885                size_px: 16.0,
1886                line_height: 1.4,
1887                letter_spacing: 0.0,
1888                block_role: BlockRole::Body,
1889            },
1890            font_id: 0,
1891            resolved_family: "serif".to_string(),
1892        })
1893    }
1894
1895    fn inline_image(
1896        src: &str,
1897        alt: &str,
1898        width_px: Option<u16>,
1899        height_px: Option<u16>,
1900    ) -> StyledEventOrRun {
1901        StyledEventOrRun::Image(StyledImage {
1902            src: src.to_string(),
1903            alt: alt.to_string(),
1904            width_px,
1905            height_px,
1906            in_figure: false,
1907        })
1908    }
1909
1910    fn caption_run(text: &str) -> StyledEventOrRun {
1911        StyledEventOrRun::Run(StyledRun {
1912            text: text.to_string(),
1913            style: ComputedTextStyle {
1914                family_stack: vec!["serif".to_string()],
1915                weight: 400,
1916                italic: false,
1917                size_px: 16.0,
1918                line_height: 1.2,
1919                letter_spacing: 0.0,
1920                block_role: BlockRole::FigureCaption,
1921            },
1922            font_id: 0,
1923            resolved_family: "serif".to_string(),
1924        })
1925    }
1926
1927    #[test]
1928    fn layout_splits_into_multiple_pages() {
1929        let cfg = LayoutConfig {
1930            display_height: 120,
1931            margin_top: 8,
1932            margin_bottom: 8,
1933            ..LayoutConfig::default()
1934        };
1935        let engine = LayoutEngine::new(cfg);
1936        let mut items = Vec::new();
1937        for _ in 0..50 {
1938            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphStart));
1939            items.push(body_run("hello world epub-stream renderer pipeline"));
1940            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphEnd));
1941        }
1942
1943        let pages = engine.layout_items(items);
1944        assert!(pages.len() > 1);
1945    }
1946
1947    #[test]
1948    fn inline_image_emits_rect_annotation_and_caption() {
1949        let cfg = LayoutConfig {
1950            display_width: 320,
1951            display_height: 480,
1952            margin_left: 20,
1953            margin_right: 20,
1954            margin_top: 20,
1955            margin_bottom: 20,
1956            ..LayoutConfig::default()
1957        };
1958        let engine = LayoutEngine::new(cfg);
1959        let items = vec![
1960            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
1961            body_run("Before image"),
1962            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
1963            inline_image("images/pic.jpg", "Picture caption", Some(600), Some(400)),
1964        ];
1965        let pages = engine.layout_items(items);
1966        assert!(!pages.is_empty());
1967        let first = &pages[0];
1968        assert!(first
1969            .annotations
1970            .iter()
1971            .any(|a| a.kind == "inline_image_src" && a.value.as_deref() == Some("images/pic.jpg")));
1972        assert!(first
1973            .commands
1974            .iter()
1975            .any(|cmd| matches!(cmd, DrawCommand::ImageObject(_))));
1976        assert!(first.commands.iter().any(|cmd| match cmd {
1977            DrawCommand::Text(t) => t.text.contains("Picture caption"),
1978            _ => false,
1979        }));
1980    }
1981
1982    #[test]
1983    fn inline_image_moves_to_next_page_when_remaining_space_is_too_small() {
1984        let cfg = LayoutConfig {
1985            display_width: 320,
1986            display_height: 120,
1987            margin_left: 8,
1988            margin_right: 8,
1989            margin_top: 8,
1990            margin_bottom: 8,
1991            paragraph_gap_px: 8,
1992            ..LayoutConfig::default()
1993        };
1994        let engine = LayoutEngine::new(cfg);
1995        let items = vec![
1996            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
1997            body_run("This paragraph uses enough space to force an image page break."),
1998            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
1999            inline_image("images/diagram.png", "Diagram", Some(240), Some(180)),
2000        ];
2001        let pages = engine.layout_items(items);
2002        assert!(pages.len() >= 2);
2003        let page0_has_image_object = pages[0]
2004            .commands
2005            .iter()
2006            .any(|cmd| matches!(cmd, DrawCommand::ImageObject(_)));
2007        let page1_has_image_object = pages[1]
2008            .commands
2009            .iter()
2010            .any(|cmd| matches!(cmd, DrawCommand::ImageObject(_)));
2011        assert!(!page0_has_image_object);
2012        assert!(page1_has_image_object);
2013    }
2014
2015    #[test]
2016    fn cover_page_mode_contain_fits_within_content_area_without_distortion() {
2017        let cfg = LayoutConfig {
2018            display_width: 320,
2019            display_height: 480,
2020            margin_left: 20,
2021            margin_right: 20,
2022            margin_top: 20,
2023            margin_bottom: 20,
2024            object_layout: crate::render_ir::ObjectLayoutConfig {
2025                cover_page_mode: crate::render_ir::CoverPageMode::Contain,
2026                ..crate::render_ir::ObjectLayoutConfig::default()
2027            },
2028            ..LayoutConfig::default()
2029        };
2030        let engine = LayoutEngine::new(cfg);
2031        let pages = engine.layout_items(vec![inline_image(
2032            "images/cover.jpg",
2033            "Cover image",
2034            Some(600),
2035            Some(900),
2036        )]);
2037        let cmd = pages[0]
2038            .commands
2039            .iter()
2040            .find_map(|c| match c {
2041                DrawCommand::ImageObject(img) => Some(img),
2042                _ => None,
2043            })
2044            .expect("expected image command");
2045        assert_eq!(cmd.width, 280);
2046        assert_eq!(cmd.height, 420);
2047        assert_eq!(cmd.x, 20);
2048        assert_eq!(cmd.y, 30);
2049    }
2050
2051    #[test]
2052    fn cover_page_mode_full_bleed_can_crop_by_viewport_clip() {
2053        let cfg = LayoutConfig {
2054            display_width: 320,
2055            display_height: 480,
2056            margin_left: 20,
2057            margin_right: 20,
2058            margin_top: 20,
2059            margin_bottom: 20,
2060            object_layout: crate::render_ir::ObjectLayoutConfig {
2061                cover_page_mode: crate::render_ir::CoverPageMode::FullBleed,
2062                ..crate::render_ir::ObjectLayoutConfig::default()
2063            },
2064            ..LayoutConfig::default()
2065        };
2066        let engine = LayoutEngine::new(cfg);
2067        let pages = engine.layout_items(vec![inline_image(
2068            "images/cover.jpg",
2069            "Cover image",
2070            Some(1000),
2071            Some(1200),
2072        )]);
2073        let cmd = pages[0]
2074            .commands
2075            .iter()
2076            .find_map(|c| match c {
2077                DrawCommand::ImageObject(img) => Some(img),
2078                _ => None,
2079            })
2080            .expect("expected image command");
2081        assert_eq!(cmd.height, 480);
2082        assert_eq!(cmd.width, 400);
2083        assert_eq!(cmd.y, 0);
2084        assert_eq!(cmd.x, -40);
2085    }
2086
2087    #[test]
2088    fn figure_caption_runs_render_unjustified_and_italic() {
2089        let cfg = LayoutConfig {
2090            display_width: 360,
2091            ..LayoutConfig::default()
2092        };
2093        let engine = LayoutEngine::new(cfg);
2094        let items = vec![
2095            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2096            caption_run("This is a figure caption line that should not be justified."),
2097            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2098        ];
2099        let pages = engine.layout_items(items);
2100        let first_text = pages[0]
2101            .commands
2102            .iter()
2103            .find_map(|cmd| match cmd {
2104                DrawCommand::Text(text) => Some(text),
2105                _ => None,
2106            })
2107            .expect("caption text expected");
2108        assert_eq!(first_text.style.justify_mode, JustifyMode::None);
2109        assert!(first_text.style.italic);
2110    }
2111
2112    #[test]
2113    fn layout_assigns_justify_mode_for_body_lines() {
2114        let engine = LayoutEngine::new(LayoutConfig::default());
2115        let items = vec![
2116            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2117            body_run("one two three four five six seven eight nine ten eleven twelve"),
2118            body_run("one two three four five six seven eight nine ten eleven twelve"),
2119            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2120        ];
2121
2122        let pages = engine.layout_items(items);
2123        let mut saw_justified = false;
2124        for page in pages {
2125            for cmd in page.commands {
2126                if let DrawCommand::Text(t) = cmd {
2127                    if matches!(t.style.justify_mode, JustifyMode::InterWord { .. }) {
2128                        saw_justified = true;
2129                    }
2130                }
2131            }
2132        }
2133        assert!(saw_justified);
2134    }
2135
2136    #[test]
2137    fn layout_supports_right_and_center_alignment_strategies() {
2138        let text = "alpha beta gamma delta epsilon zeta eta theta";
2139        let right_cfg = LayoutConfig {
2140            typography: TypographyConfig {
2141                justification: crate::render_ir::JustificationConfig {
2142                    enabled: true,
2143                    strategy: crate::render_ir::JustificationStrategy::AlignRight,
2144                    ..crate::render_ir::JustificationConfig::default()
2145                },
2146                ..TypographyConfig::default()
2147            },
2148            ..LayoutConfig::default()
2149        };
2150        let center_cfg = LayoutConfig {
2151            typography: TypographyConfig {
2152                justification: crate::render_ir::JustificationConfig {
2153                    enabled: true,
2154                    strategy: crate::render_ir::JustificationStrategy::AlignCenter,
2155                    ..crate::render_ir::JustificationConfig::default()
2156                },
2157                ..TypographyConfig::default()
2158            },
2159            ..LayoutConfig::default()
2160        };
2161        let items = vec![
2162            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2163            body_run(text),
2164            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2165        ];
2166        let right_pages = LayoutEngine::new(right_cfg).layout_items(items.clone());
2167        let center_pages = LayoutEngine::new(center_cfg).layout_items(items);
2168        let right_mode = right_pages
2169            .iter()
2170            .flat_map(|p| p.commands.iter())
2171            .find_map(|cmd| match cmd {
2172                DrawCommand::Text(t) => Some(t.style.justify_mode),
2173                _ => None,
2174            })
2175            .expect("expected text line");
2176        let center_mode = center_pages
2177            .iter()
2178            .flat_map(|p| p.commands.iter())
2179            .find_map(|cmd| match cmd {
2180                DrawCommand::Text(t) => Some(t.style.justify_mode),
2181                _ => None,
2182            })
2183            .expect("expected text line");
2184        assert!(matches!(right_mode, JustifyMode::AlignRight { .. }));
2185        assert!(matches!(center_mode, JustifyMode::AlignCenter { .. }));
2186    }
2187
2188    #[test]
2189    fn full_inter_word_justification_uses_more_slack_than_adaptive() {
2190        let mut adaptive_cfg = LayoutConfig::default();
2191        adaptive_cfg.typography.justification.strategy =
2192            crate::render_ir::JustificationStrategy::AdaptiveInterWord;
2193        adaptive_cfg
2194            .typography
2195            .justification
2196            .max_space_stretch_ratio = 0.15;
2197        adaptive_cfg.typography.justification.min_words = 2;
2198        adaptive_cfg.justify_min_words = 2;
2199
2200        let mut full_cfg = adaptive_cfg;
2201        full_cfg.typography.justification.strategy =
2202            crate::render_ir::JustificationStrategy::FullInterWord;
2203
2204        let items = vec![
2205            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2206            body_run("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"),
2207            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2208        ];
2209        let adaptive_pages = LayoutEngine::new(adaptive_cfg).layout_items(items.clone());
2210        let full_pages = LayoutEngine::new(full_cfg).layout_items(items);
2211
2212        let adaptive_max = adaptive_pages
2213            .iter()
2214            .flat_map(|p| p.commands.iter())
2215            .filter_map(|cmd| match cmd {
2216                DrawCommand::Text(t) => match t.style.justify_mode {
2217                    JustifyMode::InterWord { extra_px_total } => Some(extra_px_total),
2218                    _ => None,
2219                },
2220                _ => None,
2221            })
2222            .max()
2223            .unwrap_or(0);
2224        let full_max = full_pages
2225            .iter()
2226            .flat_map(|p| p.commands.iter())
2227            .filter_map(|cmd| match cmd {
2228                DrawCommand::Text(t) => match t.style.justify_mode {
2229                    JustifyMode::InterWord { extra_px_total } => Some(extra_px_total),
2230                    _ => None,
2231                },
2232                _ => None,
2233            })
2234            .max()
2235            .unwrap_or(0);
2236        assert!(
2237            full_max >= adaptive_max,
2238            "full inter-word should not use less extra slack (adaptive={}, full={})",
2239            adaptive_max,
2240            full_max
2241        );
2242    }
2243
2244    #[test]
2245    fn custom_text_measurer_changes_wrap_behavior() {
2246        let cfg = LayoutConfig {
2247            display_width: 280,
2248            margin_left: 12,
2249            margin_right: 12,
2250            ..LayoutConfig::default()
2251        };
2252        let default_engine = LayoutEngine::new(cfg);
2253        let measured_engine = LayoutEngine::new(cfg).with_text_measurer(Arc::new(WideMeasurer));
2254        let items = vec![
2255            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2256            body_run("one two three four five six seven eight nine ten eleven twelve"),
2257            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2258        ];
2259
2260        let default_lines = default_engine
2261            .layout_items(items.clone())
2262            .iter()
2263            .flat_map(|p| p.commands.iter())
2264            .filter(|cmd| matches!(cmd, DrawCommand::Text(_)))
2265            .count();
2266        let measured_lines = measured_engine
2267            .layout_items(items)
2268            .iter()
2269            .flat_map(|p| p.commands.iter())
2270            .filter(|cmd| matches!(cmd, DrawCommand::Text(_)))
2271            .count();
2272        assert!(measured_lines > default_lines);
2273    }
2274
2275    #[test]
2276    fn soft_hyphen_is_invisible_when_not_broken() {
2277        let engine = LayoutEngine::new(LayoutConfig {
2278            display_width: 640,
2279            ..LayoutConfig::default()
2280        });
2281        let items = vec![
2282            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2283            body_run("co\u{00AD}operate"),
2284            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2285        ];
2286        let pages = engine.layout_items(items);
2287        let texts: Vec<String> = pages
2288            .iter()
2289            .flat_map(|p| p.commands.iter())
2290            .filter_map(|cmd| match cmd {
2291                DrawCommand::Text(t) => Some(t.text.clone()),
2292                _ => None,
2293            })
2294            .collect();
2295        assert_eq!(texts, vec!["cooperate".to_string()]);
2296    }
2297
2298    #[test]
2299    fn soft_hyphen_emits_visible_hyphen_on_break() {
2300        let engine = LayoutEngine::new(LayoutConfig {
2301            display_width: 150,
2302            soft_hyphen_policy: SoftHyphenPolicy::Discretionary,
2303            ..LayoutConfig::default()
2304        });
2305        let items = vec![
2306            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2307            body_run("extra\u{00AD}ordinary"),
2308            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2309        ];
2310        let pages = engine.layout_items(items);
2311        let texts: Vec<String> = pages
2312            .iter()
2313            .flat_map(|p| p.commands.iter())
2314            .filter_map(|cmd| match cmd {
2315                DrawCommand::Text(t) => Some(t.text.clone()),
2316                _ => None,
2317            })
2318            .collect();
2319        assert!(texts.iter().any(|t| t.ends_with('-')));
2320        assert!(!texts.iter().any(|t| t.contains('\u{00AD}')));
2321    }
2322
2323    #[test]
2324    fn golden_ir_fragment_includes_font_id_and_page_chrome() {
2325        let engine = LayoutEngine::new(LayoutConfig {
2326            page_chrome: PageChromeConfig {
2327                header_enabled: true,
2328                footer_enabled: true,
2329                progress_enabled: true,
2330                ..PageChromeConfig::default()
2331            },
2332            ..LayoutConfig::default()
2333        });
2334        let items = vec![
2335            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2336            body_run("alpha beta gamma delta"),
2337            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2338        ];
2339
2340        let pages = engine.layout_items(items);
2341        assert_eq!(pages.len(), 1);
2342        let page = &pages[0];
2343        let first_text = page
2344            .commands
2345            .iter()
2346            .find_map(|cmd| match cmd {
2347                DrawCommand::Text(t) => Some(t),
2348                _ => None,
2349            })
2350            .expect("missing text command");
2351        assert_eq!(first_text.text, "alpha beta gamma delta");
2352        assert_eq!(first_text.font_id, Some(0));
2353        assert_eq!(first_text.style.font_id, Some(0));
2354
2355        let chrome_kinds: Vec<PageChromeKind> = page
2356            .commands
2357            .iter()
2358            .filter_map(|cmd| match cmd {
2359                DrawCommand::PageChrome(c) => Some(c.kind),
2360                _ => None,
2361            })
2362            .collect();
2363        assert_eq!(
2364            chrome_kinds,
2365            vec![
2366                PageChromeKind::Header,
2367                PageChromeKind::Footer,
2368                PageChromeKind::Progress
2369            ]
2370        );
2371    }
2372
2373    #[test]
2374    fn page_chrome_policy_controls_emitted_markers() {
2375        let engine = LayoutEngine::new(LayoutConfig {
2376            page_chrome: PageChromeConfig {
2377                header_enabled: false,
2378                footer_enabled: true,
2379                progress_enabled: true,
2380                ..PageChromeConfig::default()
2381            },
2382            ..LayoutConfig::default()
2383        });
2384        let items = vec![
2385            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2386            body_run("alpha beta gamma delta"),
2387            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2388        ];
2389
2390        let pages = engine.layout_items(items);
2391        assert_eq!(pages.len(), 1);
2392        let chrome_kinds: Vec<PageChromeKind> = pages[0]
2393            .commands
2394            .iter()
2395            .filter_map(|cmd| match cmd {
2396                DrawCommand::PageChrome(c) => Some(c.kind),
2397                _ => None,
2398            })
2399            .collect();
2400        assert_eq!(
2401            chrome_kinds,
2402            vec![PageChromeKind::Footer, PageChromeKind::Progress]
2403        );
2404    }
2405
2406    #[test]
2407    fn finish_without_chrome_streams_pages_without_marker_commands() {
2408        let engine = LayoutEngine::new(LayoutConfig::default());
2409        let mut session = engine.start_session();
2410        session.push_item(StyledEventOrRun::Event(StyledEvent::ParagraphStart));
2411        session.push_item(body_run(
2412            "A long enough paragraph to produce wrapped lines without any page chrome markers.",
2413        ));
2414        session.push_item(StyledEventOrRun::Event(StyledEvent::ParagraphEnd));
2415        let mut pages = Vec::with_capacity(2);
2416        session.finish(&mut |page| pages.push(page));
2417        assert!(!pages.is_empty());
2418        for page in pages {
2419            assert!(!page.content_commands.is_empty());
2420            let has_chrome = page
2421                .commands
2422                .iter()
2423                .any(|cmd| matches!(cmd, DrawCommand::PageChrome(_)));
2424            assert!(!has_chrome);
2425        }
2426    }
2427
2428    #[test]
2429    fn explicit_line_break_line_is_not_justified() {
2430        let cfg = LayoutConfig {
2431            display_width: 640,
2432            ..LayoutConfig::default()
2433        };
2434        let engine = LayoutEngine::new(cfg);
2435        let items = vec![
2436            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2437            body_run("one two three four five six seven eight nine ten"),
2438            StyledEventOrRun::Event(StyledEvent::LineBreak),
2439            body_run("eleven twelve thirteen fourteen fifteen sixteen"),
2440            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2441        ];
2442        let pages = engine.layout_items(items);
2443        let first_line = pages
2444            .iter()
2445            .flat_map(|p| p.commands.iter())
2446            .find_map(|cmd| match cmd {
2447                DrawCommand::Text(t) if t.text.starts_with("one two") => Some(t),
2448                _ => None,
2449            })
2450            .expect("line-break line should exist");
2451        assert_eq!(first_line.style.justify_mode, JustifyMode::None);
2452    }
2453
2454    #[test]
2455    fn widow_orphan_control_moves_new_paragraph_to_next_page_when_needed() {
2456        let cfg = LayoutConfig {
2457            display_width: 320,
2458            display_height: 70,
2459            margin_top: 8,
2460            margin_bottom: 8,
2461            paragraph_gap_px: 8,
2462            line_gap_px: 0,
2463            typography: TypographyConfig {
2464                widow_orphan_control: crate::render_ir::WidowOrphanControl {
2465                    enabled: true,
2466                    min_lines: 2,
2467                },
2468                ..TypographyConfig::default()
2469            },
2470            ..LayoutConfig::default()
2471        };
2472        let engine = LayoutEngine::new(cfg);
2473        let items = vec![
2474            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2475            body_run("alpha beta gamma"),
2476            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2477            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2478            body_run("delta epsilon zeta"),
2479            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2480        ];
2481        let pages = engine.layout_items(items);
2482        assert!(pages.len() >= 2);
2483        let page1_text: Vec<String> = pages[0]
2484            .commands
2485            .iter()
2486            .filter_map(|cmd| match cmd {
2487                DrawCommand::Text(t) => Some(t.text.clone()),
2488                _ => None,
2489            })
2490            .collect();
2491        let page2_text: Vec<String> = pages[1]
2492            .commands
2493            .iter()
2494            .filter_map(|cmd| match cmd {
2495                DrawCommand::Text(t) => Some(t.text.clone()),
2496                _ => None,
2497            })
2498            .collect();
2499        assert!(page1_text.iter().any(|t| t.contains("alpha")));
2500        assert!(!page1_text.iter().any(|t| t.contains("delta")));
2501        assert!(page2_text.iter().any(|t| t.contains("delta")));
2502    }
2503
2504    #[test]
2505    fn first_line_baseline_accounts_for_ascent() {
2506        let cfg = LayoutConfig {
2507            margin_top: 8,
2508            ..LayoutConfig::default()
2509        };
2510        let engine = LayoutEngine::new(cfg);
2511        let items = vec![
2512            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2513            body_run("alpha"),
2514            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2515        ];
2516        let pages = engine.layout_items(items);
2517        let first_line = pages[0]
2518            .commands
2519            .iter()
2520            .find_map(|cmd| match cmd {
2521                DrawCommand::Text(t) => Some(t),
2522                _ => None,
2523            })
2524            .expect("text command should exist");
2525        assert!(first_line.baseline_y > cfg.margin_top);
2526    }
2527
2528    #[test]
2529    fn heading_keep_with_next_moves_following_paragraph_to_next_page() {
2530        let cfg = LayoutConfig {
2531            display_height: 96,
2532            margin_top: 8,
2533            margin_bottom: 8,
2534            heading_keep_with_next_lines: 2,
2535            ..LayoutConfig::default()
2536        };
2537        let engine = LayoutEngine::new(cfg);
2538        let items = vec![
2539            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2540            body_run("alpha beta gamma delta epsilon"),
2541            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2542            StyledEventOrRun::Event(StyledEvent::HeadingStart(1)),
2543            body_run("Heading"),
2544            StyledEventOrRun::Event(StyledEvent::HeadingEnd(1)),
2545            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2546            body_run("next paragraph should move"),
2547            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2548        ];
2549        let pages = engine.layout_items(items);
2550        assert!(pages.len() >= 2);
2551    }
2552
2553    #[test]
2554    fn english_auto_hyphenation_breaks_long_word_when_needed() {
2555        let cfg = LayoutConfig {
2556            display_width: 170,
2557            ..LayoutConfig::default()
2558        };
2559        let engine = LayoutEngine::new(cfg);
2560        let mut session = engine.start_session();
2561        session.set_hyphenation_language("en-US");
2562        let items = vec![
2563            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2564            body_run("characteristically"),
2565            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2566        ];
2567        for item in items {
2568            session.push_item(item);
2569        }
2570        let mut pages = Vec::new();
2571        session.finish(&mut |p| pages.push(p));
2572        let texts: Vec<String> = pages
2573            .iter()
2574            .flat_map(|p| p.commands.iter())
2575            .filter_map(|cmd| match cmd {
2576                DrawCommand::Text(t) => Some(t.text.clone()),
2577                _ => None,
2578            })
2579            .collect();
2580        assert!(texts.iter().any(|t| t.ends_with('-')));
2581    }
2582
2583    #[test]
2584    fn english_exception_hyphenation_handles_accessibility() {
2585        let cfg = LayoutConfig {
2586            display_width: 170,
2587            ..LayoutConfig::default()
2588        };
2589        let engine = LayoutEngine::new(cfg);
2590        let mut session = engine.start_session();
2591        session.set_hyphenation_language("en");
2592        let items = vec![
2593            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2594            body_run("accessibility"),
2595            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2596        ];
2597        for item in items {
2598            session.push_item(item);
2599        }
2600        let mut pages = Vec::with_capacity(2);
2601        session.finish(&mut |p| pages.push(p));
2602        let texts: Vec<String> = pages
2603            .iter()
2604            .flat_map(|p| p.commands.iter())
2605            .filter_map(|cmd| match cmd {
2606                DrawCommand::Text(t) => Some(t.text.clone()),
2607                _ => None,
2608            })
2609            .collect();
2610        assert!(texts.iter().any(|t| t.ends_with('-')));
2611    }
2612
2613    #[test]
2614    fn quality_rebalance_avoids_short_trailing_word_on_wrapped_line() {
2615        let cfg = LayoutConfig {
2616            display_width: 220,
2617            typography: TypographyConfig {
2618                justification: crate::render_ir::JustificationConfig {
2619                    enabled: true,
2620                    min_words: 3,
2621                    min_fill_ratio: 0.5,
2622                    ..crate::render_ir::JustificationConfig::default()
2623                },
2624                ..TypographyConfig::default()
2625            },
2626            ..LayoutConfig::default()
2627        };
2628        let engine = LayoutEngine::new(cfg);
2629        let items = vec![
2630            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2631            body_run("alpha beta gamma delta to epsilon zeta eta theta"),
2632            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2633        ];
2634        let pages = engine.layout_items(items);
2635        let lines: Vec<String> = pages
2636            .iter()
2637            .flat_map(|p| p.commands.iter())
2638            .filter_map(|cmd| match cmd {
2639                DrawCommand::Text(t) => Some(t.text.clone()),
2640                _ => None,
2641            })
2642            .collect();
2643        assert!(
2644            lines.iter().all(|line| !line.ends_with(" to")),
2645            "quality rebalance should avoid short trailing 'to': {:?}",
2646            lines
2647        );
2648    }
2649
2650    #[test]
2651    fn paragraph_optimizer_keeps_non_terminal_lines_reasonably_filled() {
2652        let cfg = LayoutConfig {
2653            display_width: 260,
2654            margin_left: 10,
2655            margin_right: 10,
2656            typography: TypographyConfig {
2657                justification: crate::render_ir::JustificationConfig {
2658                    enabled: true,
2659                    min_words: 4,
2660                    min_fill_ratio: 0.72,
2661                    ..crate::render_ir::JustificationConfig::default()
2662                },
2663                ..TypographyConfig::default()
2664            },
2665            ..LayoutConfig::default()
2666        };
2667        let engine = LayoutEngine::new(cfg);
2668        let items = vec![
2669            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2670            body_run(
2671                "The quick brown fox jumps over the lazy dog while curious readers inspect global paragraph balancing across many lines and widths",
2672            ),
2673            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2674        ];
2675        let pages = engine.layout_items(items);
2676        let lines: Vec<&TextCommand> = pages
2677            .iter()
2678            .flat_map(|p| p.commands.iter())
2679            .filter_map(|cmd| match cmd {
2680                DrawCommand::Text(t)
2681                    if matches!(t.style.role, BlockRole::Body | BlockRole::Paragraph) =>
2682                {
2683                    Some(t)
2684                }
2685                _ => None,
2686            })
2687            .collect();
2688        assert!(lines.len() >= 3, "expected wrapped paragraph");
2689
2690        for line in lines.iter().take(lines.len().saturating_sub(1)) {
2691            let words = line.text.split_whitespace().count();
2692            if words < 4 {
2693                continue;
2694            }
2695            let available =
2696                ((cfg.content_width()).max(1) as f32 - line_fit_guard_px(&line.style)).max(1.0);
2697            let ratio = heuristic_measure_text(&line.text, &line.style) / available;
2698            assert!(
2699                ratio >= 0.60,
2700                "non-terminal line underfilled too much: '{}' ratio={}",
2701                line.text,
2702                ratio
2703            );
2704        }
2705    }
2706
2707    #[test]
2708    fn layout_invariants_are_deterministic_and_non_overlapping() {
2709        let cfg = LayoutConfig {
2710            display_height: 180,
2711            margin_top: 10,
2712            margin_bottom: 10,
2713            page_chrome: PageChromeConfig {
2714                progress_enabled: true,
2715                ..PageChromeConfig::default()
2716            },
2717            ..LayoutConfig::default()
2718        };
2719        let engine = LayoutEngine::new(cfg);
2720        let mut items = Vec::new();
2721        for _ in 0..30 {
2722            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphStart));
2723            items.push(body_run(
2724                "one two three four five six seven eight nine ten eleven twelve",
2725            ));
2726            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphEnd));
2727        }
2728
2729        let first = engine.layout_items(items.clone());
2730        let second = engine.layout_items(items);
2731        assert_eq!(first, second);
2732
2733        let mut prev_page_no = 0usize;
2734        for page in &first {
2735            assert!(page.page_number > prev_page_no);
2736            prev_page_no = page.page_number;
2737
2738            let mut prev_baseline = i32::MIN;
2739            for cmd in &page.commands {
2740                if let DrawCommand::Text(text) = cmd {
2741                    assert!(text.baseline_y > prev_baseline);
2742                    prev_baseline = text.baseline_y;
2743                }
2744            }
2745        }
2746    }
2747
2748    #[test]
2749    fn incremental_session_matches_batch_layout() {
2750        let cfg = LayoutConfig {
2751            page_chrome: PageChromeConfig {
2752                progress_enabled: true,
2753                footer_enabled: true,
2754                ..PageChromeConfig::default()
2755            },
2756            ..LayoutConfig::default()
2757        };
2758        let engine = LayoutEngine::new(cfg);
2759        let items = vec![
2760            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2761            body_run("alpha beta gamma delta epsilon zeta eta theta"),
2762            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2763            StyledEventOrRun::Event(StyledEvent::ParagraphStart),
2764            body_run("iota kappa lambda mu nu xi omicron pi rho"),
2765            StyledEventOrRun::Event(StyledEvent::ParagraphEnd),
2766        ];
2767
2768        let batch = engine.layout_items(items.clone());
2769        let mut session = engine.start_session();
2770        for item in items {
2771            session.push_item(item);
2772        }
2773        let mut streamed = Vec::new();
2774        session.finish(&mut |page| streamed.push(page));
2775        assert_eq!(batch, streamed);
2776    }
2777
2778    #[test]
2779    fn incremental_push_item_with_pages_matches_batch_layout() {
2780        let cfg = LayoutConfig {
2781            display_height: 130,
2782            margin_top: 8,
2783            margin_bottom: 8,
2784            ..LayoutConfig::default()
2785        };
2786        let engine = LayoutEngine::new(cfg);
2787        let mut items = Vec::new();
2788        for _ in 0..40 {
2789            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphStart));
2790            items.push(body_run("one two three four five six seven eight nine ten"));
2791            items.push(StyledEventOrRun::Event(StyledEvent::ParagraphEnd));
2792        }
2793
2794        let batch = engine.layout_items(items.clone());
2795        assert!(batch.len() > 1);
2796
2797        let mut session = engine.start_session();
2798        let mut streamed = Vec::new();
2799        let mut during_push = Vec::new();
2800        for item in items {
2801            session.push_item_with_pages(item, &mut |page| {
2802                during_push.push(page.clone());
2803                streamed.push(page);
2804            });
2805        }
2806        session.finish(&mut |page| streamed.push(page));
2807
2808        assert_eq!(batch, streamed);
2809        assert!(!during_push.is_empty());
2810        assert_eq!(during_push, batch[..during_push.len()].to_vec());
2811        let during_push_numbers: Vec<usize> =
2812            during_push.iter().map(|page| page.page_number).collect();
2813        let batch_prefix_numbers: Vec<usize> = batch
2814            .iter()
2815            .take(during_push_numbers.len())
2816            .map(|page| page.page_number)
2817            .collect();
2818        assert_eq!(during_push_numbers, batch_prefix_numbers);
2819    }
2820}