use super::{Event, EventRenderer, Result, Tag, TagEnd};
const SUBSTANTIAL_LINE: (usize, usize) = (13, 20);
const NEARLY_FULL_LINE: (usize, usize) = (19, 20);
const SHORT_WRAPPED_LINE: (usize, usize) = (1, 2);
const SUBSTANTIAL_FOR_SINGLE_WORD: (usize, usize) = (3, 5);
const SHORT_TAIL_MAX_WORDS: usize = 5;
const LONG_SINGLE_WORD_MIN_WIDTH: usize = 12;
const SHORT_TAIL_WIDTH_DIVISOR: usize = 3;
const SHORT_TAIL_WIDTH_FLOOR: usize = 24;
#[inline]
fn fills_at_least(a: usize, b: usize, ratio: (usize, usize)) -> bool {
a * ratio.1 >= b * ratio.0
}
pub(crate) struct SoftBreakFollowingText {
text: String,
ends_paragraph: bool,
}
impl<'a> EventRenderer<'a> {
pub(super) fn collect_soft_break_following_text(
events: &[Event<'static>],
) -> Option<SoftBreakFollowingText> {
let mut text = String::new();
let mut ends_paragraph = false;
for event in events {
match event {
Event::Text(part)
| Event::Code(part)
| Event::InlineHtml(part)
| Event::InlineMath(part) => text.push_str(part.as_ref()),
Event::FootnoteReference(name) => {
text.push_str("[^");
text.push_str(name.as_ref());
text.push(']');
}
Event::SoftBreak
| Event::HardBreak
| Event::Html(_)
| Event::DisplayMath(_)
| Event::Rule
| Event::TaskListMarker(_) => break,
Event::Start(
Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Link { .. }
| Tag::Image { .. },
)
| Event::End(
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image,
) => {}
Event::End(TagEnd::Paragraph | TagEnd::Item) => {
ends_paragraph = true;
break;
}
Event::Start(_) | Event::End(_) => break,
}
}
if text.trim().is_empty() {
None
} else {
Some(SoftBreakFollowingText {
text,
ends_paragraph,
})
}
}
pub(super) fn handle_soft_break(
&mut self,
next_text: Option<&SoftBreakFollowingText>,
) -> Result<()> {
if self.finalize_pending_callout_label_override() {
self.suppress_next_soft_break = true;
}
if self.suppress_next_soft_break {
self.suppress_next_soft_break = false;
return Ok(());
}
if self.pending_task_marker && self.is_custom_task_marker(&self.pending_task_marker_buffer)
{
self.pending_task_marker_buffer.push(' ');
}
let collapse = self.should_collapse_soft_break(next_text);
if self.in_link {
self.current_link_text
.push(if collapse { ' ' } else { '\n' });
} else if let Some(ref mut table) = self.table_state {
table.current_cell.push(if collapse { ' ' } else { '\n' });
} else if collapse {
self.push_collapsed_soft_break_space();
} else {
self.output.push('\n');
}
self.current_soft_break_segment_start = self.output.len();
Ok(())
}
fn should_collapse_soft_break(&self, next_text: Option<&SoftBreakFollowingText>) -> bool {
if !self.config.is_text_wrapping_enabled() {
return false;
}
if self.config.reflow {
return true;
}
let Some(next_text) = next_text else {
return false;
};
let next_text_trimmed = next_text.text.trim();
if next_text_trimmed.is_empty() {
return false;
}
let current_line_clean = if let Some(last_newline) = self.output.rfind('\n') {
crate::utils::strip_ansi(&self.output[last_newline + 1..])
} else {
crate::utils::strip_ansi(&self.output)
};
let current_line_width = crate::utils::display_width(¤t_line_clean);
let next_text_width = crate::utils::display_width(next_text_trimmed);
let needs_separator = current_line_clean
.chars()
.next_back()
.is_some_and(|ch| !ch.is_whitespace());
let separator_width = usize::from(needs_separator);
let joined_width = current_line_width + separator_width + next_text_width;
let effective_width = self.effective_text_width();
if joined_width > effective_width {
return self
.should_collapse_after_short_wrapped_line(current_line_width, effective_width);
}
if self.should_preserve_short_final_soft_break(
next_text,
next_text_trimmed,
current_line_width,
next_text_width,
joined_width,
effective_width,
) {
return false;
}
true
}
fn should_preserve_short_final_soft_break(
&self,
next_text: &SoftBreakFollowingText,
next_text_trimmed: &str,
current_line_width: usize,
next_text_width: usize,
joined_width: usize,
effective_width: usize,
) -> bool {
if !next_text.ends_paragraph || effective_width == 0 {
return false;
}
let word_count = next_text_trimmed.split_whitespace().count();
let short_tail_width =
(effective_width / SHORT_TAIL_WIDTH_DIVISOR).max(SHORT_TAIL_WIDTH_FLOOR);
let short_final_tail =
word_count <= SHORT_TAIL_MAX_WORDS && next_text_width <= short_tail_width;
let long_single_word_tail = word_count == 1
&& next_text_width >= LONG_SINGLE_WORD_MIN_WIDTH
&& fills_at_least(
current_line_width,
effective_width,
SUBSTANTIAL_FOR_SINGLE_WORD,
);
let current_line_is_substantial =
fills_at_least(current_line_width, effective_width, SUBSTANTIAL_LINE);
let joined_line_is_nearly_full =
fills_at_least(joined_width, effective_width, NEARLY_FULL_LINE);
long_single_word_tail
|| (short_final_tail && current_line_is_substantial && joined_line_is_nearly_full)
}
fn should_collapse_after_short_wrapped_line(
&self,
current_line_width: usize,
effective_width: usize,
) -> bool {
let segment = self
.output
.get(self.current_soft_break_segment_start..)
.unwrap_or("");
let segment_wrapped = segment.contains('\n');
let line_is_short =
!fills_at_least(current_line_width, effective_width, SHORT_WRAPPED_LINE);
segment_wrapped && current_line_width > 0 && line_is_short
}
fn push_collapsed_soft_break_space(&mut self) {
let last_char_is_whitespace = self
.output
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if !self.output.is_empty() && !last_char_is_whitespace {
self.output.push(' ');
}
}
}