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
20pub trait TextMeasurer: Send + Sync {
22 fn measure_text_px(&self, text: &str, style: &ResolvedTextStyle) -> f32;
24
25 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum SoftHyphenPolicy {
53 Ignore,
55 Discretionary,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct LayoutConfig {
62 pub display_width: i32,
64 pub display_height: i32,
66 pub margin_left: i32,
68 pub margin_right: i32,
70 pub margin_top: i32,
72 pub margin_bottom: i32,
74 pub line_gap_px: i32,
76 pub paragraph_gap_px: i32,
78 pub heading_gap_px: i32,
80 pub heading_keep_with_next_lines: u8,
82 pub list_indent_px: i32,
84 pub first_line_indent_px: i32,
86 pub suppress_indent_after_heading: bool,
88 pub justify_min_words: usize,
90 pub justify_min_fill_ratio: f32,
92 pub min_line_height_px: i32,
94 pub max_line_height_px: i32,
96 pub soft_hyphen_policy: SoftHyphenPolicy,
98 pub page_chrome: PageChromeConfig,
100 pub typography: TypographyConfig,
102 pub object_layout: ObjectLayoutConfig,
104 pub render_intent: RenderIntent,
106}
107
108impl LayoutConfig {
109 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#[derive(Clone)]
158pub struct LayoutEngine {
159 cfg: LayoutConfig,
160 text_measurer: Option<Arc<dyn TextMeasurer>>,
161}
162
163pub 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 pub fn new(cfg: LayoutConfig) -> Self {
182 Self {
183 cfg,
184 text_measurer: None,
185 }
186 }
187
188 pub fn with_text_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
190 self.text_measurer = Some(measurer);
191 self
192 }
193
194 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 pub fn start_session(&self) -> LayoutSession {
206 self.start_session_with_text_measurer(self.text_measurer.clone())
207 }
208
209 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 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 pub fn push_item(&mut self, item: StyledEventOrRun) {
352 self.push_item_impl(item);
353 }
354
355 pub fn set_hyphenation_language(&mut self, language_tag: &str) {
357 self.st.hyphenation_lang = HyphenationLang::from_tag(language_tag);
358 }
359
360 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 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 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 _ => (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 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 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 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 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 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 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}