1use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_IMAGE_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, LINKED_IMAGE_INLINE_INLINE,
13 LINKED_IMAGE_INLINE_REF, LINKED_IMAGE_REF_INLINE, LINKED_IMAGE_REF_REF, REF_IMAGE_REGEX, REF_LINK_REGEX,
14 SHORTCUT_REF_REGEX, WIKI_LINK_REGEX,
15};
16use crate::utils::sentence_utils::{
17 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
18 text_ends_with_abbreviation,
19};
20use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
21use std::collections::HashSet;
22use unicode_width::UnicodeWidthStr;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub enum ReflowLengthMode {
27 Chars,
29 #[default]
31 Visual,
32 Bytes,
34}
35
36fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
38 match mode {
39 ReflowLengthMode::Chars => s.chars().count(),
40 ReflowLengthMode::Visual => s.width(),
41 ReflowLengthMode::Bytes => s.len(),
42 }
43}
44
45#[derive(Clone)]
47pub struct ReflowOptions {
48 pub line_length: usize,
50 pub break_on_sentences: bool,
52 pub preserve_breaks: bool,
54 pub sentence_per_line: bool,
56 pub semantic_line_breaks: bool,
58 pub abbreviations: Option<Vec<String>>,
62 pub length_mode: ReflowLengthMode,
64 pub attr_lists: bool,
67 pub myst_roles: bool,
71 pub require_sentence_capital: bool,
76 pub max_list_continuation_indent: Option<usize>,
80}
81
82impl Default for ReflowOptions {
83 fn default() -> Self {
84 Self {
85 line_length: 80,
86 break_on_sentences: true,
87 preserve_breaks: false,
88 sentence_per_line: false,
89 semantic_line_breaks: false,
90 abbreviations: None,
91 length_mode: ReflowLengthMode::default(),
92 attr_lists: false,
93 myst_roles: false,
94 require_sentence_capital: true,
95 max_list_continuation_indent: None,
96 }
97 }
98}
99
100fn compute_inline_code_mask(text: &str) -> Vec<bool> {
103 let chars: Vec<char> = text.chars().collect();
104 let len = chars.len();
105 let mut mask = vec![false; len];
106 let mut i = 0;
107
108 while i < len {
109 if chars[i] == '`' {
110 let open_start = i;
112 let mut backtick_count = 0;
113 while i < len && chars[i] == '`' {
114 backtick_count += 1;
115 i += 1;
116 }
117
118 let mut found_close = false;
120 let content_start = i;
121 while i < len {
122 if chars[i] == '`' {
123 let close_start = i;
124 let mut close_count = 0;
125 while i < len && chars[i] == '`' {
126 close_count += 1;
127 i += 1;
128 }
129 if close_count == backtick_count {
130 for item in mask.iter_mut().take(close_start).skip(content_start) {
132 *item = true;
133 }
134 for item in mask.iter_mut().take(content_start).skip(open_start) {
136 *item = true;
137 }
138 for item in mask.iter_mut().take(i).skip(close_start) {
139 *item = true;
140 }
141 found_close = true;
142 break;
143 }
144 } else {
145 i += 1;
146 }
147 }
148
149 if !found_close {
150 i = open_start + backtick_count;
152 }
153 } else {
154 i += 1;
155 }
156 }
157
158 mask
159}
160
161fn is_sentence_boundary(
165 text: &str,
166 chars: &[char],
167 pos: usize,
168 abbreviations: &HashSet<String>,
169 require_sentence_capital: bool,
170) -> bool {
171 if pos + 1 >= chars.len() {
172 return false;
173 }
174
175 let c = chars[pos];
176 let next_char = chars[pos + 1];
177
178 if is_cjk_sentence_ending(c) {
181 let mut after_punct_pos = pos + 1;
183 while after_punct_pos < chars.len()
184 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
185 {
186 after_punct_pos += 1;
187 }
188
189 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
191 after_punct_pos += 1;
192 }
193
194 if after_punct_pos >= chars.len() {
196 return false;
197 }
198
199 while after_punct_pos < chars.len()
201 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
202 {
203 after_punct_pos += 1;
204 }
205
206 if after_punct_pos >= chars.len() {
207 return false;
208 }
209
210 return true;
213 }
214
215 if c != '.' && c != '!' && c != '?' {
217 return false;
218 }
219
220 let (_space_pos, after_space_pos) = if next_char == ' ' {
222 (pos + 1, pos + 2)
224 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
225 if chars[pos + 2] == ' ' {
227 (pos + 2, pos + 3)
229 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
230 (pos + 3, pos + 4)
232 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
233 && pos + 4 < chars.len()
234 && chars[pos + 3] == chars[pos + 2]
235 && chars[pos + 4] == ' '
236 {
237 (pos + 4, pos + 5)
239 } else {
240 return false;
241 }
242 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
243 (pos + 2, pos + 3)
245 } else if (next_char == '*' || next_char == '_')
246 && pos + 3 < chars.len()
247 && chars[pos + 2] == next_char
248 && chars[pos + 3] == ' '
249 {
250 (pos + 3, pos + 4)
252 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
253 (pos + 3, pos + 4)
255 } else {
256 return false;
257 };
258
259 let mut next_char_pos = after_space_pos;
261 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
262 next_char_pos += 1;
263 }
264
265 if next_char_pos >= chars.len() {
267 return false;
268 }
269
270 let mut first_letter_pos = next_char_pos;
272 while first_letter_pos < chars.len()
273 && (chars[first_letter_pos] == '*'
274 || chars[first_letter_pos] == '_'
275 || chars[first_letter_pos] == '~'
276 || is_opening_quote(chars[first_letter_pos]))
277 {
278 first_letter_pos += 1;
279 }
280
281 if first_letter_pos >= chars.len() {
283 return false;
284 }
285
286 let first_char = chars[first_letter_pos];
287
288 if c == '!' || c == '?' {
290 return true;
291 }
292
293 if pos > 0 {
297 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
299 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
300 return false;
301 }
302
303 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
305 return false;
306 }
307
308 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
312 return false;
313 }
314 }
315
316 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
319 return false;
320 }
321
322 true
323}
324
325pub fn split_into_sentences(text: &str) -> Vec<String> {
327 split_into_sentences_custom(text, &None)
328}
329
330pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
332 let abbreviations = get_abbreviations(custom_abbreviations);
333 split_into_sentences_with_set(text, &abbreviations, true)
334}
335
336fn split_into_sentences_with_set(
339 text: &str,
340 abbreviations: &HashSet<String>,
341 require_sentence_capital: bool,
342) -> Vec<String> {
343 let in_code = compute_inline_code_mask(text);
345 let char_vec: Vec<char> = text.chars().collect();
348
349 let mut sentences = Vec::new();
350 let mut current_sentence = String::new();
351 let mut chars = text.chars().peekable();
352 let mut pos = 0;
353
354 while let Some(c) = chars.next() {
355 current_sentence.push(c);
356
357 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
358 while let Some(&next) = chars.peek() {
360 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
361 current_sentence.push(chars.next().unwrap());
362 pos += 1;
363 } else {
364 break;
365 }
366 }
367
368 if chars.peek() == Some(&' ') {
370 chars.next();
371 pos += 1;
372 }
373
374 sentences.push(current_sentence.trim().to_string());
375 current_sentence.clear();
376 }
377
378 pos += 1;
379 }
380
381 if !current_sentence.trim().is_empty() {
383 sentences.push(current_sentence.trim().to_string());
384 }
385 sentences
386}
387
388fn is_horizontal_rule(line: &str) -> bool {
390 if line.len() < 3 {
391 return false;
392 }
393
394 let mut chars = line.chars();
397 let Some(first_char) = chars.next() else {
398 return false;
399 };
400 if first_char != '-' && first_char != '_' && first_char != '*' {
401 return false;
402 }
403
404 let mut non_space_count = 1usize; for c in chars {
406 if c == ' ' {
407 continue;
408 }
409 if c != first_char {
410 return false;
411 }
412 non_space_count += 1;
413 }
414 non_space_count >= 3
415}
416
417fn is_numbered_list_item(line: &str) -> bool {
419 let mut chars = line.chars();
420
421 if !chars.next().is_some_and(char::is_numeric) {
423 return false;
424 }
425
426 while let Some(c) = chars.next() {
428 if c == '.' {
429 return chars.next() == Some(' ');
432 }
433 if !c.is_numeric() {
434 return false;
435 }
436 }
437
438 false
439}
440
441fn is_unordered_list_marker(s: &str) -> bool {
443 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
444 && !is_horizontal_rule(s)
445 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
446}
447
448fn is_block_boundary_core(trimmed: &str) -> bool {
451 trimmed.is_empty()
452 || trimmed.starts_with('#')
453 || trimmed.starts_with("```")
454 || trimmed.starts_with("~~~")
455 || trimmed.starts_with('>')
456 || (trimmed.starts_with('[') && trimmed.contains("]:"))
457 || is_horizontal_rule(trimmed)
458 || is_unordered_list_marker(trimmed)
459 || is_numbered_list_item(trimmed)
460 || is_definition_list_item(trimmed)
461 || trimmed.starts_with(":::")
462}
463
464fn is_block_boundary(trimmed: &str) -> bool {
467 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
468}
469
470fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
474 is_block_boundary_core(trimmed)
475 || calculate_indentation_width_default(line) >= 4
476 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
477}
478
479fn has_hard_break(line: &str) -> bool {
485 let line = line.strip_suffix('\r').unwrap_or(line);
486 line.ends_with(" ") || line.ends_with('\\')
487}
488
489fn ends_with_sentence_punct(text: &str) -> bool {
491 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
492}
493
494fn trim_preserving_hard_break(s: &str) -> String {
500 let s = s.strip_suffix('\r').unwrap_or(s);
502
503 if s.ends_with('\\') {
505 return s.to_string();
507 }
508
509 if s.ends_with(" ") {
511 let content_end = s.trim_end().len();
513 if content_end == 0 {
514 return String::new();
516 }
517 format!("{} ", &s[..content_end])
519 } else {
520 s.trim_end().to_string()
522 }
523}
524
525fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
527 parse_markdown_elements_inner(text, options.attr_lists, options.myst_roles)
528}
529
530pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
531 if options.sentence_per_line {
533 let elements = parse_elements(line, options);
534 return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
535 }
536
537 if options.semantic_line_breaks {
539 let elements = parse_elements(line, options);
540 return reflow_elements_semantic(&elements, options);
541 }
542
543 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
546 return vec![line.to_string()];
547 }
548
549 let elements = parse_elements(line, options);
551
552 reflow_elements(&elements, options)
554}
555
556#[derive(Debug, Clone)]
558enum LinkedImageSource {
559 Inline(String),
561 Reference(String),
563}
564
565#[derive(Debug, Clone)]
567enum LinkedImageTarget {
568 Inline(String),
570 Reference(String),
572}
573
574#[derive(Debug, Clone)]
576enum Element {
577 Text(String),
579 Link { text: String, url: String },
581 ReferenceLink { text: String, reference: String },
583 EmptyReferenceLink { text: String },
585 ShortcutReference { reference: String },
587 InlineImage { alt: String, url: String },
589 ReferenceImage { alt: String, reference: String },
591 EmptyReferenceImage { alt: String },
593 LinkedImage {
599 alt: String,
600 img_source: LinkedImageSource,
601 link_target: LinkedImageTarget,
602 },
603 FootnoteReference { note: String },
605 Strikethrough(String),
607 WikiLink(String),
609 InlineMath(String),
611 DisplayMath(String),
613 EmojiShortcode(String),
615 Autolink(String),
617 HtmlTag(String),
619 HtmlEntity(String),
621 HugoShortcode(String),
623 AttrList(String),
625 MystRole(String),
629 Code(String),
631 Bold {
633 content: String,
634 underscore: bool,
636 },
637 Italic {
639 content: String,
640 underscore: bool,
642 },
643}
644
645impl std::fmt::Display for Element {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 match self {
648 Element::Text(s) => write!(f, "{s}"),
649 Element::Link { text, url } => write!(f, "[{text}]({url})"),
650 Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
651 Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
652 Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
653 Element::InlineImage { alt, url } => write!(f, ""),
654 Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
655 Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
656 Element::LinkedImage {
657 alt,
658 img_source,
659 link_target,
660 } => {
661 let img_part = match img_source {
663 LinkedImageSource::Inline(url) => format!(""),
664 LinkedImageSource::Reference(r) => format!("![{alt}][{r}]"),
665 };
666 match link_target {
668 LinkedImageTarget::Inline(url) => write!(f, "[{img_part}]({url})"),
669 LinkedImageTarget::Reference(r) => write!(f, "[{img_part}][{r}]"),
670 }
671 }
672 Element::FootnoteReference { note } => write!(f, "[^{note}]"),
673 Element::Strikethrough(s) => write!(f, "~~{s}~~"),
674 Element::WikiLink(s) => write!(f, "[[{s}]]"),
675 Element::InlineMath(s) => write!(f, "${s}$"),
676 Element::DisplayMath(s) => write!(f, "$${s}$$"),
677 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
678 Element::Autolink(s) => write!(f, "{s}"),
679 Element::HtmlTag(s) => write!(f, "{s}"),
680 Element::HtmlEntity(s) => write!(f, "{s}"),
681 Element::HugoShortcode(s) => write!(f, "{s}"),
682 Element::AttrList(s) => write!(f, "{s}"),
683 Element::MystRole(s) => write!(f, "{s}"),
684 Element::Code(s) => write!(f, "`{s}`"),
685 Element::Bold { content, underscore } => {
686 if *underscore {
687 write!(f, "__{content}__")
688 } else {
689 write!(f, "**{content}**")
690 }
691 }
692 Element::Italic { content, underscore } => {
693 if *underscore {
694 write!(f, "_{content}_")
695 } else {
696 write!(f, "*{content}*")
697 }
698 }
699 }
700 }
701}
702
703#[derive(Debug, Clone)]
705struct EmphasisSpan {
706 start: usize,
708 end: usize,
710 content: String,
712 is_strong: bool,
714 is_strikethrough: bool,
716 uses_underscore: bool,
718}
719
720fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
730 let mut spans = Vec::new();
731 let mut options = Options::empty();
732 options.insert(Options::ENABLE_STRIKETHROUGH);
733
734 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
737 let mut strikethrough_stack: Vec<usize> = Vec::new();
738
739 let parser = Parser::new_ext(text, options).into_offset_iter();
740
741 for (event, range) in parser {
742 match event {
743 Event::Start(Tag::Emphasis) => {
744 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
746 emphasis_stack.push((range.start, uses_underscore));
747 }
748 Event::End(TagEnd::Emphasis) => {
749 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
750 let content_start = start_byte + 1;
752 let content_end = range.end - 1;
753 if content_end > content_start
754 && let Some(content) = text.get(content_start..content_end)
755 {
756 spans.push(EmphasisSpan {
757 start: start_byte,
758 end: range.end,
759 content: content.to_string(),
760 is_strong: false,
761 is_strikethrough: false,
762 uses_underscore,
763 });
764 }
765 }
766 }
767 Event::Start(Tag::Strong) => {
768 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
770 strong_stack.push((range.start, uses_underscore));
771 }
772 Event::End(TagEnd::Strong) => {
773 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
774 let content_start = start_byte + 2;
776 let content_end = range.end - 2;
777 if content_end > content_start
778 && let Some(content) = text.get(content_start..content_end)
779 {
780 spans.push(EmphasisSpan {
781 start: start_byte,
782 end: range.end,
783 content: content.to_string(),
784 is_strong: true,
785 is_strikethrough: false,
786 uses_underscore,
787 });
788 }
789 }
790 }
791 Event::Start(Tag::Strikethrough) => {
792 strikethrough_stack.push(range.start);
793 }
794 Event::End(TagEnd::Strikethrough) => {
795 if let Some(start_byte) = strikethrough_stack.pop() {
796 let content_start = start_byte + 2;
798 let content_end = range.end - 2;
799 if content_end > content_start
800 && let Some(content) = text.get(content_start..content_end)
801 {
802 spans.push(EmphasisSpan {
803 start: start_byte,
804 end: range.end,
805 content: content.to_string(),
806 is_strong: false,
807 is_strikethrough: true,
808 uses_underscore: false,
809 });
810 }
811 }
812 }
813 _ => {}
814 }
815 }
816
817 spans.sort_by_key(|s| s.start);
819 spans
820}
821
822fn myst_role_len_at(text: &str) -> Option<usize> {
830 let bytes = text.as_bytes();
831 if bytes.first() != Some(&b'{') {
832 return None;
833 }
834
835 let mut j = 1;
837 match bytes.get(j) {
838 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
839 _ => return None,
840 }
841 while let Some(&b) = bytes.get(j) {
842 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
843 j += 1;
844 } else {
845 break;
846 }
847 }
848 if bytes.get(j) != Some(&b'}') {
849 return None;
850 }
851 j += 1; if bytes.get(j) != Some(&b'`') {
855 return None;
856 }
857 let backtick_start = j;
858 while bytes.get(j) == Some(&b'`') {
859 j += 1;
860 }
861 let backtick_count = j - backtick_start;
862
863 while j + backtick_count <= bytes.len() {
865 if bytes[j] == b'`' {
866 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
867 if close_count == backtick_count {
868 return Some(j + close_count);
869 }
870 j += close_count;
871 } else {
872 j += 1;
873 }
874 }
875
876 None
877}
878
879fn parse_markdown_elements_inner(text: &str, attr_lists: bool, myst_roles: bool) -> Vec<Element> {
890 let mut elements = Vec::new();
891 let mut remaining = text;
892
893 let emphasis_spans = extract_emphasis_spans(text);
895
896 while !remaining.is_empty() {
897 let current_offset = text.len() - remaining.len();
899 let mut earliest_match: Option<(usize, usize, &str)> = None;
902
903 if remaining.contains("[!") {
907 if let Some(m) = LINKED_IMAGE_INLINE_INLINE.find(remaining)
909 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
910 {
911 earliest_match = Some((m.start(), m.end(), "linked_image_ii"));
912 }
913
914 if let Some(m) = LINKED_IMAGE_REF_INLINE.find(remaining)
916 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
917 {
918 earliest_match = Some((m.start(), m.end(), "linked_image_ri"));
919 }
920
921 if let Some(m) = LINKED_IMAGE_INLINE_REF.find(remaining)
923 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
924 {
925 earliest_match = Some((m.start(), m.end(), "linked_image_ir"));
926 }
927
928 if let Some(m) = LINKED_IMAGE_REF_REF.find(remaining)
930 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
931 {
932 earliest_match = Some((m.start(), m.end(), "linked_image_rr"));
933 }
934 }
935
936 if let Some(m) = INLINE_IMAGE_REGEX.find(remaining)
939 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
940 {
941 earliest_match = Some((m.start(), m.end(), "inline_image"));
942 }
943
944 if let Some(m) = REF_IMAGE_REGEX.find(remaining)
946 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
947 {
948 earliest_match = Some((m.start(), m.end(), "ref_image"));
949 }
950
951 if let Some(m) = FOOTNOTE_REF_REGEX.find(remaining)
953 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
954 {
955 earliest_match = Some((m.start(), m.end(), "footnote_ref"));
956 }
957
958 if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
960 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
961 {
962 earliest_match = Some((m.start(), m.end(), "inline_link"));
963 }
964
965 if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
967 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
968 {
969 earliest_match = Some((m.start(), m.end(), "ref_link"));
970 }
971
972 if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
975 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
976 {
977 earliest_match = Some((m.start(), m.end(), "shortcut_ref"));
978 }
979
980 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
982 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
983 {
984 earliest_match = Some((m.start(), m.end(), "wiki_link"));
985 }
986
987 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
989 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
990 {
991 earliest_match = Some((m.start(), m.end(), "display_math"));
992 }
993
994 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
996 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
997 {
998 earliest_match = Some((m.start(), m.end(), "inline_math"));
999 }
1000
1001 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
1005 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1006 {
1007 earliest_match = Some((m.start(), m.end(), "emoji"));
1008 }
1009
1010 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
1012 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1013 {
1014 earliest_match = Some((m.start(), m.end(), "html_entity"));
1015 }
1016
1017 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1020 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1021 {
1022 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1023 }
1024
1025 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1028 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1029 {
1030 let matched_text = &remaining[m.start()..m.end()];
1032 let is_url_autolink = matched_text.starts_with("<http://")
1033 || matched_text.starts_with("<https://")
1034 || matched_text.starts_with("<mailto:")
1035 || matched_text.starts_with("<ftp://")
1036 || matched_text.starts_with("<ftps://");
1037
1038 let is_email_autolink = {
1041 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1042 EMAIL_PATTERN.is_match(content)
1043 };
1044
1045 if is_url_autolink || is_email_autolink {
1046 earliest_match = Some((m.start(), m.end(), "autolink"));
1047 } else {
1048 earliest_match = Some((m.start(), m.end(), "html_tag"));
1049 }
1050 }
1051
1052 let mut next_special = remaining.len();
1054 let mut special_type = "";
1055 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1056 let mut attr_list_len: usize = 0;
1057 let mut myst_role_len: usize = 0;
1058
1059 if let Some(pos) = remaining.find('`')
1061 && pos < next_special
1062 {
1063 next_special = pos;
1064 special_type = "code";
1065 }
1066
1067 if myst_roles
1072 && let Some(pos) = remaining.find('{')
1073 && pos < next_special
1074 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1075 {
1076 next_special = pos;
1077 special_type = "myst_role";
1078 myst_role_len = role_len;
1079 }
1080
1081 if attr_lists
1083 && let Some(pos) = remaining.find('{')
1084 && pos < next_special
1085 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1086 && m.start() == 0
1087 {
1088 next_special = pos;
1089 special_type = "attr_list";
1090 attr_list_len = m.end();
1091 }
1092
1093 for span in &emphasis_spans {
1096 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1097 let pos_in_remaining = span.start - current_offset;
1098 if pos_in_remaining < next_special {
1099 next_special = pos_in_remaining;
1100 special_type = "pulldown_emphasis";
1101 pulldown_emphasis = Some(span);
1102 }
1103 break; }
1105 }
1106
1107 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1109 pos < next_special
1110 } else {
1111 false
1112 };
1113
1114 if should_process_markdown_link {
1115 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1116
1117 if pos > 0 {
1119 elements.push(Element::Text(remaining[..pos].to_string()));
1120 }
1121
1122 match pattern_type {
1124 "linked_image_ii" => {
1126 if let Some(caps) = LINKED_IMAGE_INLINE_INLINE.captures(remaining) {
1127 let alt = caps.get(1).map_or("", |m| m.as_str());
1128 let img_url = caps.get(2).map_or("", |m| m.as_str());
1129 let link_url = caps.get(3).map_or("", |m| m.as_str());
1130 elements.push(Element::LinkedImage {
1131 alt: alt.to_string(),
1132 img_source: LinkedImageSource::Inline(img_url.to_string()),
1133 link_target: LinkedImageTarget::Inline(link_url.to_string()),
1134 });
1135 remaining = &remaining[match_end..];
1136 } else {
1137 elements.push(Element::Text("[".to_string()));
1138 remaining = &remaining[1..];
1139 }
1140 }
1141 "linked_image_ri" => {
1143 if let Some(caps) = LINKED_IMAGE_REF_INLINE.captures(remaining) {
1144 let alt = caps.get(1).map_or("", |m| m.as_str());
1145 let img_ref = caps.get(2).map_or("", |m| m.as_str());
1146 let link_url = caps.get(3).map_or("", |m| m.as_str());
1147 elements.push(Element::LinkedImage {
1148 alt: alt.to_string(),
1149 img_source: LinkedImageSource::Reference(img_ref.to_string()),
1150 link_target: LinkedImageTarget::Inline(link_url.to_string()),
1151 });
1152 remaining = &remaining[match_end..];
1153 } else {
1154 elements.push(Element::Text("[".to_string()));
1155 remaining = &remaining[1..];
1156 }
1157 }
1158 "linked_image_ir" => {
1160 if let Some(caps) = LINKED_IMAGE_INLINE_REF.captures(remaining) {
1161 let alt = caps.get(1).map_or("", |m| m.as_str());
1162 let img_url = caps.get(2).map_or("", |m| m.as_str());
1163 let link_ref = caps.get(3).map_or("", |m| m.as_str());
1164 elements.push(Element::LinkedImage {
1165 alt: alt.to_string(),
1166 img_source: LinkedImageSource::Inline(img_url.to_string()),
1167 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1168 });
1169 remaining = &remaining[match_end..];
1170 } else {
1171 elements.push(Element::Text("[".to_string()));
1172 remaining = &remaining[1..];
1173 }
1174 }
1175 "linked_image_rr" => {
1177 if let Some(caps) = LINKED_IMAGE_REF_REF.captures(remaining) {
1178 let alt = caps.get(1).map_or("", |m| m.as_str());
1179 let img_ref = caps.get(2).map_or("", |m| m.as_str());
1180 let link_ref = caps.get(3).map_or("", |m| m.as_str());
1181 elements.push(Element::LinkedImage {
1182 alt: alt.to_string(),
1183 img_source: LinkedImageSource::Reference(img_ref.to_string()),
1184 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1185 });
1186 remaining = &remaining[match_end..];
1187 } else {
1188 elements.push(Element::Text("[".to_string()));
1189 remaining = &remaining[1..];
1190 }
1191 }
1192 "inline_image" => {
1193 if let Some(caps) = INLINE_IMAGE_REGEX.captures(remaining) {
1194 let alt = caps.get(1).map_or("", |m| m.as_str());
1195 let url = caps.get(2).map_or("", |m| m.as_str());
1196 elements.push(Element::InlineImage {
1197 alt: alt.to_string(),
1198 url: url.to_string(),
1199 });
1200 remaining = &remaining[match_end..];
1201 } else {
1202 elements.push(Element::Text("!".to_string()));
1203 remaining = &remaining[1..];
1204 }
1205 }
1206 "ref_image" => {
1207 if let Some(caps) = REF_IMAGE_REGEX.captures(remaining) {
1208 let alt = caps.get(1).map_or("", |m| m.as_str());
1209 let reference = caps.get(2).map_or("", |m| m.as_str());
1210
1211 if reference.is_empty() {
1212 elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
1213 } else {
1214 elements.push(Element::ReferenceImage {
1215 alt: alt.to_string(),
1216 reference: reference.to_string(),
1217 });
1218 }
1219 remaining = &remaining[match_end..];
1220 } else {
1221 elements.push(Element::Text("!".to_string()));
1222 remaining = &remaining[1..];
1223 }
1224 }
1225 "footnote_ref" => {
1226 if let Some(caps) = FOOTNOTE_REF_REGEX.captures(remaining) {
1227 let note = caps.get(1).map_or("", |m| m.as_str());
1228 elements.push(Element::FootnoteReference { note: note.to_string() });
1229 remaining = &remaining[match_end..];
1230 } else {
1231 elements.push(Element::Text("[".to_string()));
1232 remaining = &remaining[1..];
1233 }
1234 }
1235 "inline_link" => {
1236 if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
1237 let text = caps.get(1).map_or("", |m| m.as_str());
1238 let url = caps.get(2).map_or("", |m| m.as_str());
1239 elements.push(Element::Link {
1240 text: text.to_string(),
1241 url: url.to_string(),
1242 });
1243 remaining = &remaining[match_end..];
1244 } else {
1245 elements.push(Element::Text("[".to_string()));
1247 remaining = &remaining[1..];
1248 }
1249 }
1250 "ref_link" => {
1251 if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
1252 let text = caps.get(1).map_or("", |m| m.as_str());
1253 let reference = caps.get(2).map_or("", |m| m.as_str());
1254
1255 if reference.is_empty() {
1256 elements.push(Element::EmptyReferenceLink { text: text.to_string() });
1258 } else {
1259 elements.push(Element::ReferenceLink {
1261 text: text.to_string(),
1262 reference: reference.to_string(),
1263 });
1264 }
1265 remaining = &remaining[match_end..];
1266 } else {
1267 elements.push(Element::Text("[".to_string()));
1269 remaining = &remaining[1..];
1270 }
1271 }
1272 "shortcut_ref" => {
1273 if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
1274 let reference = caps.get(1).map_or("", |m| m.as_str());
1275 elements.push(Element::ShortcutReference {
1276 reference: reference.to_string(),
1277 });
1278 remaining = &remaining[match_end..];
1279 } else {
1280 elements.push(Element::Text("[".to_string()));
1282 remaining = &remaining[1..];
1283 }
1284 }
1285 "wiki_link" => {
1286 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1287 let content = caps.get(1).map_or("", |m| m.as_str());
1288 elements.push(Element::WikiLink(content.to_string()));
1289 remaining = &remaining[match_end..];
1290 } else {
1291 elements.push(Element::Text("[[".to_string()));
1292 remaining = &remaining[2..];
1293 }
1294 }
1295 "display_math" => {
1296 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1297 let math = caps.get(1).map_or("", |m| m.as_str());
1298 elements.push(Element::DisplayMath(math.to_string()));
1299 remaining = &remaining[match_end..];
1300 } else {
1301 elements.push(Element::Text("$$".to_string()));
1302 remaining = &remaining[2..];
1303 }
1304 }
1305 "inline_math" => {
1306 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1307 let math = caps.get(1).map_or("", |m| m.as_str());
1308 elements.push(Element::InlineMath(math.to_string()));
1309 remaining = &remaining[match_end..];
1310 } else {
1311 elements.push(Element::Text("$".to_string()));
1312 remaining = &remaining[1..];
1313 }
1314 }
1315 "emoji" => {
1317 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1318 let emoji = caps.get(1).map_or("", |m| m.as_str());
1319 elements.push(Element::EmojiShortcode(emoji.to_string()));
1320 remaining = &remaining[match_end..];
1321 } else {
1322 elements.push(Element::Text(":".to_string()));
1323 remaining = &remaining[1..];
1324 }
1325 }
1326 "html_entity" => {
1327 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1329 remaining = &remaining[match_end..];
1330 }
1331 "hugo_shortcode" => {
1332 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1334 remaining = &remaining[match_end..];
1335 }
1336 "autolink" => {
1337 elements.push(Element::Autolink(remaining[pos..match_end].to_string()));
1339 remaining = &remaining[match_end..];
1340 }
1341 "html_tag" => {
1342 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1344 remaining = &remaining[match_end..];
1345 }
1346 _ => {
1347 elements.push(Element::Text("[".to_string()));
1349 remaining = &remaining[1..];
1350 }
1351 }
1352 } else {
1353 if next_special > 0 && next_special < remaining.len() {
1357 elements.push(Element::Text(remaining[..next_special].to_string()));
1358 remaining = &remaining[next_special..];
1359 }
1360
1361 match special_type {
1363 "code" => {
1364 if let Some(code_end) = remaining[1..].find('`') {
1366 let code = &remaining[1..=code_end];
1367 elements.push(Element::Code(code.to_string()));
1368 remaining = &remaining[1 + code_end + 1..];
1369 } else {
1370 elements.push(Element::Text(remaining.to_string()));
1372 break;
1373 }
1374 }
1375 "attr_list" => {
1376 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1377 remaining = &remaining[attr_list_len..];
1378 }
1379 "myst_role" => {
1380 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1381 remaining = &remaining[myst_role_len..];
1382 }
1383 "pulldown_emphasis" => {
1384 if let Some(span) = pulldown_emphasis {
1386 let span_len = span.end - span.start;
1387 if span.is_strikethrough {
1388 elements.push(Element::Strikethrough(span.content.clone()));
1389 } else if span.is_strong {
1390 elements.push(Element::Bold {
1391 content: span.content.clone(),
1392 underscore: span.uses_underscore,
1393 });
1394 } else {
1395 elements.push(Element::Italic {
1396 content: span.content.clone(),
1397 underscore: span.uses_underscore,
1398 });
1399 }
1400 remaining = &remaining[span_len..];
1401 } else {
1402 elements.push(Element::Text(remaining[..1].to_string()));
1404 remaining = &remaining[1..];
1405 }
1406 }
1407 _ => {
1408 elements.push(Element::Text(remaining.to_string()));
1410 break;
1411 }
1412 }
1413 }
1414 }
1415
1416 elements
1417}
1418
1419fn should_insert_space_before_join(current: &str) -> bool {
1420 !current.is_empty()
1421 && !current.ends_with(' ')
1422 && !current.ends_with('(')
1423 && !current.ends_with('[')
1424 && !current.ends_with('-')
1425}
1426
1427fn reflow_elements_sentence_per_line(
1429 elements: &[Element],
1430 custom_abbreviations: &Option<Vec<String>>,
1431 require_sentence_capital: bool,
1432) -> Vec<String> {
1433 let abbreviations = get_abbreviations(custom_abbreviations);
1434 let mut lines = Vec::new();
1435 let mut current_line = String::new();
1436
1437 for (idx, element) in elements.iter().enumerate() {
1438 let element_str = format!("{element}");
1439
1440 if let Element::Text(text) = element {
1442 let combined = format!("{current_line}{text}");
1444 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1446
1447 if sentences.len() > 1 {
1448 for (i, sentence) in sentences.iter().enumerate() {
1450 if i == 0 {
1451 let trimmed = sentence.trim();
1454
1455 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1456 current_line.clone_from(sentence);
1458 } else {
1459 lines.push(sentence.clone());
1461 current_line.clear();
1462 }
1463 } else if i == sentences.len() - 1 {
1464 let trimmed = sentence.trim();
1466 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1467
1468 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1469 lines.push(sentence.clone());
1471 current_line.clear();
1472 } else {
1473 current_line.clone_from(sentence);
1475 }
1476 } else {
1477 lines.push(sentence.clone());
1479 }
1480 }
1481 } else {
1482 let trimmed = combined.trim();
1484
1485 if trimmed.is_empty() {
1489 continue;
1490 }
1491
1492 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1493
1494 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1495 lines.push(trimmed.to_string());
1497 current_line.clear();
1498 } else {
1499 current_line = combined;
1501 }
1502 }
1503 } else if let Element::Italic { content, underscore } = element {
1504 let marker = if *underscore { "_" } else { "*" };
1506 handle_emphasis_sentence_split(
1507 content,
1508 marker,
1509 &abbreviations,
1510 require_sentence_capital,
1511 &mut current_line,
1512 &mut lines,
1513 );
1514 } else if let Element::Bold { content, underscore } = element {
1515 let marker = if *underscore { "__" } else { "**" };
1517 handle_emphasis_sentence_split(
1518 content,
1519 marker,
1520 &abbreviations,
1521 require_sentence_capital,
1522 &mut current_line,
1523 &mut lines,
1524 );
1525 } else if let Element::Strikethrough(content) = element {
1526 handle_emphasis_sentence_split(
1528 content,
1529 "~~",
1530 &abbreviations,
1531 require_sentence_capital,
1532 &mut current_line,
1533 &mut lines,
1534 );
1535 } else {
1536 let is_adjacent = if idx > 0 {
1539 match &elements[idx - 1] {
1540 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1541 _ => true,
1542 }
1543 } else {
1544 false
1545 };
1546
1547 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1549 current_line.push(' ');
1550 }
1551 current_line.push_str(&element_str);
1552 }
1553 }
1554
1555 if !current_line.is_empty() {
1557 lines.push(current_line.trim().to_string());
1558 }
1559 lines
1560}
1561
1562fn handle_emphasis_sentence_split(
1564 content: &str,
1565 marker: &str,
1566 abbreviations: &HashSet<String>,
1567 require_sentence_capital: bool,
1568 current_line: &mut String,
1569 lines: &mut Vec<String>,
1570) {
1571 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1573
1574 if sentences.len() <= 1 {
1575 if should_insert_space_before_join(current_line) {
1577 current_line.push(' ');
1578 }
1579 current_line.push_str(marker);
1580 current_line.push_str(content);
1581 current_line.push_str(marker);
1582
1583 let trimmed = content.trim();
1585 let ends_with_punct = ends_with_sentence_punct(trimmed);
1586 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1587 lines.push(current_line.clone());
1588 current_line.clear();
1589 }
1590 } else {
1591 for (i, sentence) in sentences.iter().enumerate() {
1593 let trimmed = sentence.trim();
1594 if trimmed.is_empty() {
1595 continue;
1596 }
1597
1598 if i == 0 {
1599 if should_insert_space_before_join(current_line) {
1601 current_line.push(' ');
1602 }
1603 current_line.push_str(marker);
1604 current_line.push_str(trimmed);
1605 current_line.push_str(marker);
1606
1607 let ends_with_punct = ends_with_sentence_punct(trimmed);
1609 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1610 lines.push(current_line.clone());
1611 current_line.clear();
1612 }
1613 } else if i == sentences.len() - 1 {
1614 let ends_with_punct = ends_with_sentence_punct(trimmed);
1616
1617 let mut line = String::new();
1618 line.push_str(marker);
1619 line.push_str(trimmed);
1620 line.push_str(marker);
1621
1622 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1623 lines.push(line);
1624 } else {
1625 *current_line = line;
1627 }
1628 } else {
1629 let mut line = String::new();
1631 line.push_str(marker);
1632 line.push_str(trimmed);
1633 line.push_str(marker);
1634 lines.push(line);
1635 }
1636 }
1637 }
1638}
1639
1640const BREAK_WORDS: &[&str] = &[
1644 "and",
1645 "or",
1646 "but",
1647 "nor",
1648 "yet",
1649 "so",
1650 "for",
1651 "which",
1652 "that",
1653 "because",
1654 "when",
1655 "if",
1656 "while",
1657 "where",
1658 "although",
1659 "though",
1660 "unless",
1661 "since",
1662 "after",
1663 "before",
1664 "until",
1665 "as",
1666 "once",
1667 "whether",
1668 "however",
1669 "therefore",
1670 "moreover",
1671 "furthermore",
1672 "nevertheless",
1673 "whereas",
1674];
1675
1676fn is_clause_punctuation(c: char) -> bool {
1678 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1680
1681fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1689 if chars[i] == '\u{2014}' {
1690 return true;
1691 }
1692 match chars.get(i + 1) {
1693 None => true,
1694 Some(next) => next.is_whitespace(),
1695 }
1696}
1697
1698fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1712 debug_assert!(slice.starts_with('('));
1713 let mut depth: i32 = 0;
1714 for (local_byte, c) in slice.char_indices() {
1715 let global_byte = offset + local_byte;
1716 if depth > 0 && is_inside_element(global_byte, element_spans) {
1721 continue;
1722 }
1723 match c {
1724 '(' => depth += 1,
1725 ')' => {
1726 depth -= 1;
1727 if depth == 0 {
1728 let end = local_byte + 1;
1729 let inner = &slice[1..local_byte];
1730 return Some((end, inner));
1731 }
1732 }
1733 _ => {}
1734 }
1735 }
1736 None
1737}
1738
1739fn split_at_parenthetical(
1756 text: &str,
1757 line_length: usize,
1758 element_spans: &[(usize, usize)],
1759 length_mode: ReflowLengthMode,
1760) -> Option<(String, String)> {
1761 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1762
1763 if text.starts_with('(')
1765 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1766 && inner.contains(' ')
1767 {
1768 let tail = &text[end_local..];
1772 let attached_len = tail
1773 .char_indices()
1774 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1775 .last()
1776 .map_or(0, |(idx, c)| idx + c.len_utf8());
1777 let first_end = end_local + attached_len;
1778 let rest_start = first_end;
1779 let first = &text[..first_end];
1780 let first_len = display_len(first, length_mode);
1781 if first_len <= line_length {
1784 let rest = text[rest_start..].trim_start();
1785 if !rest.is_empty() {
1786 return Some((first.to_string(), rest.to_string()));
1787 }
1788 }
1789 }
1790
1791 let mut best_open_byte: Option<usize> = None;
1793 let mut pos = 0usize;
1794 while pos < text.len() {
1795 if text.as_bytes()[pos] != b'(' {
1797 let c = text[pos..].chars().next().unwrap();
1798 pos += c.len_utf8();
1799 continue;
1800 }
1801 if is_inside_element(pos, element_spans) {
1803 pos += 1;
1804 continue;
1805 }
1806 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1807 let first = text[..pos].trim_end();
1808 let first_len = display_len(first, length_mode);
1809 if !first.is_empty()
1810 && first_len >= min_first_len
1811 && first_len <= line_length
1812 && inner.contains(' ')
1813 && best_open_byte.is_none_or(|prev| pos > prev)
1814 {
1815 best_open_byte = Some(pos);
1816 }
1817 pos += end_local;
1818 } else {
1819 pos += 1;
1820 }
1821 }
1822
1823 let open_byte = best_open_byte?;
1824 let first = text[..open_byte].trim_end().to_string();
1825 let rest = text[open_byte..].to_string();
1826 if first.is_empty() || rest.trim().is_empty() {
1827 return None;
1828 }
1829 Some((first, rest))
1830}
1831
1832fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1836 let mut spans = Vec::new();
1837 let mut offset = 0;
1838 for element in elements {
1839 let rendered = format!("{element}");
1840 let len = rendered.len();
1841 if !matches!(element, Element::Text(_)) {
1842 spans.push((offset, offset + len));
1843 }
1844 offset += len;
1845 }
1846 spans
1847}
1848
1849fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1851 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1852}
1853
1854const MIN_SPLIT_RATIO: f64 = 0.3;
1857
1858fn split_at_clause_punctuation(
1862 text: &str,
1863 line_length: usize,
1864 element_spans: &[(usize, usize)],
1865 length_mode: ReflowLengthMode,
1866) -> Option<(String, String)> {
1867 let chars: Vec<char> = text.chars().collect();
1868 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1869
1870 let mut width_acc = 0;
1872 let mut search_end_char = 0;
1873 for (idx, &c) in chars.iter().enumerate() {
1874 let c_width = display_len(&c.to_string(), length_mode);
1875 if width_acc + c_width > line_length {
1876 break;
1877 }
1878 width_acc += c_width;
1879 search_end_char = idx + 1;
1880 }
1881
1882 let mut paren_depth: i32 = 0;
1889 let mut best_pos = None;
1890 for i in (0..search_end_char).rev() {
1891 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1893 let byte_after: usize = byte_start + chars[i].len_utf8();
1895
1896 if !is_inside_element(byte_start, element_spans) {
1897 match chars[i] {
1898 ')' => paren_depth += 1,
1899 '(' => paren_depth = paren_depth.saturating_sub(1),
1900 _ => {}
1901 }
1902 }
1903
1904 if paren_depth == 0
1905 && is_clause_punctuation(chars[i])
1906 && clause_break_allowed_after(&chars, i)
1907 && !is_inside_element(byte_after, element_spans)
1908 {
1909 best_pos = Some(i);
1910 break;
1911 }
1912 }
1913
1914 let pos = best_pos?;
1915
1916 let first: String = chars[..=pos].iter().collect();
1918 let first_display_len = display_len(&first, length_mode);
1919 if first_display_len < min_first_len {
1920 return None;
1921 }
1922
1923 let rest: String = chars[pos + 1..].iter().collect();
1925 let rest = rest.trim_start().to_string();
1926
1927 if rest.is_empty() {
1928 return None;
1929 }
1930
1931 Some((first, rest))
1932}
1933
1934fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1941 let mut map = vec![0i32; text.len()];
1942 let mut depth = 0i32;
1943 for (byte, c) in text.char_indices() {
1944 if !is_inside_element(byte, element_spans) {
1945 match c {
1946 '(' => depth += 1,
1947 ')' => depth = depth.saturating_sub(1),
1948 _ => {}
1949 }
1950 }
1951 let end = (byte + c.len_utf8()).min(map.len());
1953 for slot in &mut map[byte..end] {
1954 *slot = depth;
1955 }
1956 }
1957 map
1958}
1959
1960fn is_standalone_parenthetical(line: &str) -> bool {
1969 let trimmed = line.trim();
1970 if !trimmed.starts_with('(') {
1971 return false;
1972 }
1973 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1975 if !core.ends_with(')') {
1976 return false;
1977 }
1978 let inner = &core[1..core.len() - 1];
1980 if !inner.contains(' ') {
1981 return false;
1982 }
1983 let mut depth = 0i32;
1985 for c in core.chars() {
1986 match c {
1987 '(' => depth += 1,
1988 ')' => depth -= 1,
1989 _ => {}
1990 }
1991 if depth < 0 {
1992 return false;
1993 }
1994 }
1995 depth == 0
1996}
1997
1998fn split_at_break_word(
2002 text: &str,
2003 line_length: usize,
2004 element_spans: &[(usize, usize)],
2005 length_mode: ReflowLengthMode,
2006) -> Option<(String, String)> {
2007 let lower = text.to_lowercase();
2008 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2009 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2014
2015 for &word in BREAK_WORDS {
2016 let mut search_start = 0;
2017 while let Some(pos) = lower[search_start..].find(word) {
2018 let abs_pos = search_start + pos;
2019
2020 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2022 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2023
2024 if preceded_by_space && followed_by_space {
2025 let first_part = text[..abs_pos].trim_end();
2027 let first_part_len = display_len(first_part, length_mode);
2028
2029 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2031
2032 if first_part_len >= min_first_len
2033 && first_part_len <= line_length
2034 && !is_inside_element(abs_pos, element_spans)
2035 && !inside_paren
2036 {
2037 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2039 best_split = Some((abs_pos, word.len()));
2040 }
2041 }
2042 }
2043
2044 search_start = abs_pos + word.len();
2045 }
2046 }
2047
2048 let (byte_start, _word_len) = best_split?;
2049
2050 let first = text[..byte_start].trim_end().to_string();
2051 let rest = text[byte_start..].to_string();
2052
2053 if first.is_empty() || rest.trim().is_empty() {
2054 return None;
2055 }
2056
2057 Some((first, rest))
2058}
2059
2060fn cascade_split_line(
2063 text: &str,
2064 line_length: usize,
2065 abbreviations: &Option<Vec<String>>,
2066 length_mode: ReflowLengthMode,
2067 attr_lists: bool,
2068 myst_roles: bool,
2069) -> Vec<String> {
2070 if line_length == 0 || display_len(text, length_mode) <= line_length {
2071 return vec![text.to_string()];
2072 }
2073
2074 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles);
2075 let element_spans = compute_element_spans(&elements);
2076
2077 if let Some((first, rest)) = split_at_parenthetical(text, line_length, &element_spans, length_mode) {
2080 let mut result = vec![first];
2081 result.extend(cascade_split_line(
2082 &rest,
2083 line_length,
2084 abbreviations,
2085 length_mode,
2086 attr_lists,
2087 myst_roles,
2088 ));
2089 return result;
2090 }
2091
2092 if let Some((first, rest)) = split_at_clause_punctuation(text, line_length, &element_spans, length_mode) {
2094 let mut result = vec![first];
2095 result.extend(cascade_split_line(
2096 &rest,
2097 line_length,
2098 abbreviations,
2099 length_mode,
2100 attr_lists,
2101 myst_roles,
2102 ));
2103 return result;
2104 }
2105
2106 if let Some((first, rest)) = split_at_break_word(text, line_length, &element_spans, length_mode) {
2108 let mut result = vec![first];
2109 result.extend(cascade_split_line(
2110 &rest,
2111 line_length,
2112 abbreviations,
2113 length_mode,
2114 attr_lists,
2115 myst_roles,
2116 ));
2117 return result;
2118 }
2119
2120 let options = ReflowOptions {
2122 line_length,
2123 break_on_sentences: false,
2124 preserve_breaks: false,
2125 sentence_per_line: false,
2126 semantic_line_breaks: false,
2127 abbreviations: abbreviations.clone(),
2128 length_mode,
2129 attr_lists,
2130 myst_roles,
2131 require_sentence_capital: true,
2132 max_list_continuation_indent: None,
2133 };
2134 reflow_elements(&elements, &options)
2135}
2136
2137fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2141 let sentence_lines =
2143 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2144
2145 if options.line_length == 0 {
2148 return sentence_lines;
2149 }
2150
2151 let length_mode = options.length_mode;
2152 let mut result = Vec::new();
2153 for line in sentence_lines {
2154 if display_len(&line, length_mode) <= options.line_length {
2155 result.push(line);
2156 } else {
2157 result.extend(cascade_split_line(
2158 &line,
2159 options.line_length,
2160 &options.abbreviations,
2161 length_mode,
2162 options.attr_lists,
2163 options.myst_roles,
2164 ));
2165 }
2166 }
2167
2168 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2171 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2172 for line in result {
2173 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2174 if is_standalone_parenthetical(&line) {
2177 merged.push(line);
2178 continue;
2179 }
2180
2181 let prev_ends_at_sentence = {
2183 let trimmed = merged.last().unwrap().trim_end();
2184 trimmed
2185 .chars()
2186 .rev()
2187 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2188 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2189 };
2190
2191 if !prev_ends_at_sentence {
2192 let prev = merged.last_mut().unwrap();
2193 let combined = format!("{prev} {line}");
2194 if display_len(&combined, length_mode) <= options.line_length {
2196 *prev = combined;
2197 continue;
2198 }
2199 }
2200 }
2201 merged.push(line);
2202 }
2203 merged
2204}
2205
2206fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2214 line.char_indices()
2215 .rev()
2216 .map(|(pos, _)| pos)
2217 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2218}
2219
2220fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2222 let mut lines = Vec::new();
2223 let mut current_line = String::new();
2224 let mut current_length = 0;
2225 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2227 let length_mode = options.length_mode;
2228
2229 for (idx, element) in elements.iter().enumerate() {
2230 let element_str = format!("{element}");
2233 let element_len = display_len(&element_str, length_mode);
2234
2235 let is_adjacent_to_prev = if idx > 0 {
2241 match (&elements[idx - 1], element) {
2242 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2243 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2244 _ => true,
2245 }
2246 } else {
2247 false
2248 };
2249
2250 if let Element::Text(text) = element {
2252 let has_leading_space = text.starts_with(char::is_whitespace);
2254 let words: Vec<&str> = text.split_whitespace().collect();
2256
2257 for (i, word) in words.iter().enumerate() {
2258 let word_len = display_len(word, length_mode);
2259 let is_trailing_punct = word
2261 .chars()
2262 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2263
2264 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2267
2268 if is_first_adjacent {
2269 if current_length + word_len > options.line_length && current_length > 0 {
2271 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2274 let before = current_line[..last_space].trim_end().to_string();
2275 let after = current_line[last_space + 1..].to_string();
2276 lines.push(before);
2277 current_line = format!("{after}{word}");
2278 current_length = display_len(¤t_line, length_mode);
2279 current_line_element_spans.clear();
2280 } else {
2281 current_line.push_str(word);
2282 current_length += word_len;
2283 }
2284 } else {
2285 current_line.push_str(word);
2286 current_length += word_len;
2287 }
2288 } else if current_length > 0
2289 && current_length + 1 + word_len > options.line_length
2290 && !is_trailing_punct
2291 {
2292 lines.push(current_line.trim().to_string());
2294 current_line = word.to_string();
2295 current_length = word_len;
2296 current_line_element_spans.clear();
2297 } else {
2298 if current_length > 0 && (i > 0 || has_leading_space) && !is_trailing_punct {
2302 current_line.push(' ');
2303 current_length += 1;
2304 }
2305 current_line.push_str(word);
2306 current_length += word_len;
2307 }
2308 }
2309 } else if matches!(
2310 element,
2311 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough(_)
2312 ) && element_len > options.line_length
2313 {
2314 let (content, marker): (&str, &str) = match element {
2318 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2319 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2320 Element::Strikethrough(content) => (content.as_str(), "~~"),
2321 _ => unreachable!(),
2322 };
2323
2324 let words: Vec<&str> = content.split_whitespace().collect();
2325 let n = words.len();
2326
2327 if n == 0 {
2328 let full = format!("{marker}{marker}");
2330 let full_len = display_len(&full, length_mode);
2331 if !is_adjacent_to_prev && current_length > 0 {
2332 current_line.push(' ');
2333 current_length += 1;
2334 }
2335 current_line.push_str(&full);
2336 current_length += full_len;
2337 } else {
2338 for (i, word) in words.iter().enumerate() {
2339 let is_first = i == 0;
2340 let is_last = i == n - 1;
2341 let word_str: String = match (is_first, is_last) {
2342 (true, true) => format!("{marker}{word}{marker}"),
2343 (true, false) => format!("{marker}{word}"),
2344 (false, true) => format!("{word}{marker}"),
2345 (false, false) => word.to_string(),
2346 };
2347 let word_len = display_len(&word_str, length_mode);
2348
2349 let needs_space = if is_first {
2350 !is_adjacent_to_prev && current_length > 0
2351 } else {
2352 current_length > 0
2353 };
2354
2355 if needs_space && current_length + 1 + word_len > options.line_length {
2356 lines.push(current_line.trim_end().to_string());
2357 current_line = word_str;
2358 current_length = word_len;
2359 current_line_element_spans.clear();
2360 } else {
2361 if needs_space {
2362 current_line.push(' ');
2363 current_length += 1;
2364 }
2365 current_line.push_str(&word_str);
2366 current_length += word_len;
2367 }
2368 }
2369 }
2370 } else {
2371 if is_adjacent_to_prev {
2375 if current_length + element_len > options.line_length {
2377 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2380 let before = current_line[..last_space].trim_end().to_string();
2381 let after = current_line[last_space + 1..].to_string();
2382 lines.push(before);
2383 current_line = format!("{after}{element_str}");
2384 current_length = display_len(¤t_line, length_mode);
2385 current_line_element_spans.clear();
2386 let start = after.len();
2388 current_line_element_spans.push((start, start + element_str.len()));
2389 } else {
2390 let start = current_line.len();
2392 current_line.push_str(&element_str);
2393 current_length += element_len;
2394 current_line_element_spans.push((start, current_line.len()));
2395 }
2396 } else {
2397 let start = current_line.len();
2398 current_line.push_str(&element_str);
2399 current_length += element_len;
2400 current_line_element_spans.push((start, current_line.len()));
2401 }
2402 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2403 lines.push(current_line.trim().to_string());
2405 current_line.clone_from(&element_str);
2406 current_length = element_len;
2407 current_line_element_spans.clear();
2408 current_line_element_spans.push((0, element_str.len()));
2409 } else {
2410 let ends_with_opener =
2412 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2413 if current_length > 0 && !ends_with_opener {
2414 current_line.push(' ');
2415 current_length += 1;
2416 }
2417 let start = current_line.len();
2418 current_line.push_str(&element_str);
2419 current_length += element_len;
2420 current_line_element_spans.push((start, current_line.len()));
2421 }
2422 }
2423 }
2424
2425 if !current_line.is_empty() {
2427 lines.push(current_line.trim_end().to_string());
2428 }
2429
2430 lines
2431}
2432
2433pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2435 let lines: Vec<&str> = content.lines().collect();
2436 let mut result = Vec::new();
2437 let mut i = 0;
2438
2439 while i < lines.len() {
2440 let line = lines[i];
2441 let trimmed = line.trim();
2442
2443 if trimmed.is_empty() {
2445 result.push(String::new());
2446 i += 1;
2447 continue;
2448 }
2449
2450 if trimmed.starts_with('#') {
2452 result.push(line.to_string());
2453 i += 1;
2454 continue;
2455 }
2456
2457 if trimmed.starts_with(":::") {
2459 result.push(line.to_string());
2460 i += 1;
2461 continue;
2462 }
2463
2464 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2466 result.push(line.to_string());
2467 i += 1;
2468 while i < lines.len() {
2470 result.push(lines[i].to_string());
2471 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2472 i += 1;
2473 break;
2474 }
2475 i += 1;
2476 }
2477 continue;
2478 }
2479
2480 if calculate_indentation_width_default(line) >= 4 {
2482 result.push(line.to_string());
2484 i += 1;
2485 while i < lines.len() {
2486 let next_line = lines[i];
2487 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2489 result.push(next_line.to_string());
2490 i += 1;
2491 } else {
2492 break;
2493 }
2494 }
2495 continue;
2496 }
2497
2498 if trimmed.starts_with('>') {
2500 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2503 let quote_prefix = line[0..=gt_pos].to_string();
2504 let quote_content = &line[quote_prefix.len()..].trim_start();
2505
2506 let reflowed = reflow_line(quote_content, options);
2507 for reflowed_line in &reflowed {
2508 result.push(format!("{quote_prefix} {reflowed_line}"));
2509 }
2510 i += 1;
2511 continue;
2512 }
2513
2514 if is_horizontal_rule(trimmed) {
2516 result.push(line.to_string());
2517 i += 1;
2518 continue;
2519 }
2520
2521 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2523 let indent = line.len() - line.trim_start().len();
2525 let indent_str = " ".repeat(indent);
2526
2527 let mut marker_end = indent;
2530 let mut content_start = indent;
2531
2532 if trimmed.chars().next().is_some_and(char::is_numeric) {
2533 if let Some(period_pos) = line[indent..].find('.') {
2535 marker_end = indent + period_pos + 1; content_start = marker_end;
2537 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2541 content_start += 1;
2542 }
2543 }
2544 } else {
2545 marker_end = indent + 1; content_start = marker_end;
2548 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2552 content_start += 1;
2553 }
2554 }
2555
2556 let min_continuation_indent = content_start;
2558
2559 let rest = &line[content_start..];
2562 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2563 marker_end = content_start + 3; content_start += 4; }
2566
2567 let marker = &line[indent..marker_end];
2568
2569 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2572 i += 1;
2573
2574 while i < lines.len() {
2578 let next_line = lines[i];
2579 let next_trimmed = next_line.trim();
2580
2581 if is_block_boundary(next_trimmed) {
2583 break;
2584 }
2585
2586 let next_indent = next_line.len() - next_line.trim_start().len();
2588 if next_indent >= min_continuation_indent {
2589 let trimmed_start = next_line.trim_start();
2592 list_content.push(trim_preserving_hard_break(trimmed_start));
2593 i += 1;
2594 } else {
2595 break;
2597 }
2598 }
2599
2600 let combined_content = if options.preserve_breaks {
2603 list_content[0].clone()
2604 } else {
2605 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2607 if has_hard_breaks {
2608 list_content.join("\n")
2610 } else {
2611 list_content.join(" ")
2613 }
2614 };
2615
2616 let trimmed_marker = marker;
2618 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2619 indent + (content_start - indent).min(max_indent)
2622 } else {
2623 content_start
2624 };
2625
2626 let prefix_length = indent + trimmed_marker.len() + 1;
2628
2629 let adjusted_options = ReflowOptions {
2631 line_length: options.line_length.saturating_sub(prefix_length),
2632 ..options.clone()
2633 };
2634
2635 let reflowed = reflow_line(&combined_content, &adjusted_options);
2636 for (j, reflowed_line) in reflowed.iter().enumerate() {
2637 if j == 0 {
2638 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2639 } else {
2640 let continuation_indent = " ".repeat(continuation_spaces);
2642 result.push(format!("{continuation_indent}{reflowed_line}"));
2643 }
2644 }
2645 continue;
2646 }
2647
2648 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2650 result.push(line.to_string());
2651 i += 1;
2652 continue;
2653 }
2654
2655 if trimmed.starts_with('[') && line.contains("]:") {
2657 result.push(line.to_string());
2658 i += 1;
2659 continue;
2660 }
2661
2662 if is_definition_list_item(trimmed) {
2664 result.push(line.to_string());
2665 i += 1;
2666 continue;
2667 }
2668
2669 let mut is_single_line_paragraph = true;
2671 if i + 1 < lines.len() {
2672 let next_trimmed = lines[i + 1].trim();
2673 if !is_block_boundary(next_trimmed) {
2675 is_single_line_paragraph = false;
2676 }
2677 }
2678
2679 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2681 result.push(line.to_string());
2682 i += 1;
2683 continue;
2684 }
2685
2686 let mut paragraph_parts = Vec::new();
2688 let mut current_part = vec![line];
2689 i += 1;
2690
2691 if options.preserve_breaks {
2693 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2695 Some("\\")
2696 } else if line.ends_with(" ") {
2697 Some(" ")
2698 } else {
2699 None
2700 };
2701 let reflowed = reflow_line(line, options);
2702
2703 if let Some(break_marker) = hard_break_type {
2705 if !reflowed.is_empty() {
2706 let mut reflowed_with_break = reflowed;
2707 let last_idx = reflowed_with_break.len() - 1;
2708 if !has_hard_break(&reflowed_with_break[last_idx]) {
2709 reflowed_with_break[last_idx].push_str(break_marker);
2710 }
2711 result.extend(reflowed_with_break);
2712 }
2713 } else {
2714 result.extend(reflowed);
2715 }
2716 } else {
2717 while i < lines.len() {
2719 let prev_line = if !current_part.is_empty() {
2720 current_part.last().unwrap()
2721 } else {
2722 ""
2723 };
2724 let next_line = lines[i];
2725 let next_trimmed = next_line.trim();
2726
2727 if is_block_boundary(next_trimmed) {
2729 break;
2730 }
2731
2732 let prev_trimmed = prev_line.trim();
2735 let abbreviations = get_abbreviations(&options.abbreviations);
2736 let ends_with_sentence = (prev_trimmed.ends_with('.')
2737 || prev_trimmed.ends_with('!')
2738 || prev_trimmed.ends_with('?')
2739 || prev_trimmed.ends_with(".*")
2740 || prev_trimmed.ends_with("!*")
2741 || prev_trimmed.ends_with("?*")
2742 || prev_trimmed.ends_with("._")
2743 || prev_trimmed.ends_with("!_")
2744 || prev_trimmed.ends_with("?_")
2745 || prev_trimmed.ends_with(".\"")
2747 || prev_trimmed.ends_with("!\"")
2748 || prev_trimmed.ends_with("?\"")
2749 || prev_trimmed.ends_with(".'")
2750 || prev_trimmed.ends_with("!'")
2751 || prev_trimmed.ends_with("?'")
2752 || prev_trimmed.ends_with(".\u{201D}")
2753 || prev_trimmed.ends_with("!\u{201D}")
2754 || prev_trimmed.ends_with("?\u{201D}")
2755 || prev_trimmed.ends_with(".\u{2019}")
2756 || prev_trimmed.ends_with("!\u{2019}")
2757 || prev_trimmed.ends_with("?\u{2019}"))
2758 && !text_ends_with_abbreviation(
2759 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2760 &abbreviations,
2761 );
2762
2763 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2764 paragraph_parts.push(current_part.join(" "));
2766 current_part = vec![next_line];
2767 } else {
2768 current_part.push(next_line);
2769 }
2770 i += 1;
2771 }
2772
2773 if !current_part.is_empty() {
2775 if current_part.len() == 1 {
2776 paragraph_parts.push(current_part[0].to_string());
2778 } else {
2779 paragraph_parts.push(current_part.join(" "));
2780 }
2781 }
2782
2783 for (j, part) in paragraph_parts.iter().enumerate() {
2785 let reflowed = reflow_line(part, options);
2786 result.extend(reflowed);
2787
2788 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2792 let last_idx = result.len() - 1;
2793 if !has_hard_break(&result[last_idx]) {
2794 result[last_idx].push_str(" ");
2795 }
2796 }
2797 }
2798 }
2799 }
2800
2801 let result_text = result.join("\n");
2803 if content.ends_with('\n') && !result_text.ends_with('\n') {
2804 format!("{result_text}\n")
2805 } else {
2806 result_text
2807 }
2808}
2809
2810#[derive(Debug, Clone)]
2812pub struct ParagraphReflow {
2813 pub start_byte: usize,
2815 pub end_byte: usize,
2817 pub reflowed_text: String,
2819}
2820
2821#[derive(Debug, Clone)]
2827pub struct BlockquoteLineData {
2828 pub(crate) content: String,
2830 pub(crate) is_explicit: bool,
2832 pub(crate) prefix: Option<String>,
2834}
2835
2836impl BlockquoteLineData {
2837 pub fn explicit(content: String, prefix: String) -> Self {
2839 Self {
2840 content,
2841 is_explicit: true,
2842 prefix: Some(prefix),
2843 }
2844 }
2845
2846 pub fn lazy(content: String) -> Self {
2848 Self {
2849 content,
2850 is_explicit: false,
2851 prefix: None,
2852 }
2853 }
2854}
2855
2856#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2858pub enum BlockquoteContinuationStyle {
2859 Explicit,
2860 Lazy,
2861}
2862
2863pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2871 let mut explicit_count = 0usize;
2872 let mut lazy_count = 0usize;
2873
2874 for line in lines.iter().skip(1) {
2875 if line.is_explicit {
2876 explicit_count += 1;
2877 } else {
2878 lazy_count += 1;
2879 }
2880 }
2881
2882 if explicit_count > 0 && lazy_count == 0 {
2883 BlockquoteContinuationStyle::Explicit
2884 } else if lazy_count > 0 && explicit_count == 0 {
2885 BlockquoteContinuationStyle::Lazy
2886 } else if explicit_count >= lazy_count {
2887 BlockquoteContinuationStyle::Explicit
2888 } else {
2889 BlockquoteContinuationStyle::Lazy
2890 }
2891}
2892
2893pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2898 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2899
2900 for (idx, line) in lines.iter().enumerate() {
2901 let Some(prefix) = line.prefix.as_ref() else {
2902 continue;
2903 };
2904 counts
2905 .entry(prefix.clone())
2906 .and_modify(|entry| entry.0 += 1)
2907 .or_insert((1, idx));
2908 }
2909
2910 counts
2911 .into_iter()
2912 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2913 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2914 })
2915 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2916}
2917
2918pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2923 let trimmed = content_line.trim_start();
2924 trimmed.starts_with('>')
2925 || trimmed.starts_with('#')
2926 || trimmed.starts_with("```")
2927 || trimmed.starts_with("~~~")
2928 || is_unordered_list_marker(trimmed)
2929 || is_numbered_list_item(trimmed)
2930 || is_horizontal_rule(trimmed)
2931 || is_definition_list_item(trimmed)
2932 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2933 || trimmed.starts_with(":::")
2934 || (trimmed.starts_with('<')
2935 && !trimmed.starts_with("<http")
2936 && !trimmed.starts_with("<https")
2937 && !trimmed.starts_with("<mailto:"))
2938}
2939
2940pub fn reflow_blockquote_content(
2949 lines: &[BlockquoteLineData],
2950 explicit_prefix: &str,
2951 continuation_style: BlockquoteContinuationStyle,
2952 options: &ReflowOptions,
2953) -> Vec<String> {
2954 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2955 let segments = split_into_segments_strs(&content_strs);
2956 let mut reflowed_content_lines: Vec<String> = Vec::new();
2957
2958 for segment in segments {
2959 let hard_break_type = segment.last().and_then(|&line| {
2960 let line = line.strip_suffix('\r').unwrap_or(line);
2961 if line.ends_with('\\') {
2962 Some("\\")
2963 } else if line.ends_with(" ") {
2964 Some(" ")
2965 } else {
2966 None
2967 }
2968 });
2969
2970 let pieces: Vec<&str> = segment
2971 .iter()
2972 .map(|&line| {
2973 if let Some(l) = line.strip_suffix('\\') {
2974 l.trim_end()
2975 } else if let Some(l) = line.strip_suffix(" ") {
2976 l.trim_end()
2977 } else {
2978 line.trim_end()
2979 }
2980 })
2981 .collect();
2982
2983 let segment_text = pieces.join(" ");
2984 let segment_text = segment_text.trim();
2985 if segment_text.is_empty() {
2986 continue;
2987 }
2988
2989 let mut reflowed = reflow_line(segment_text, options);
2990 if let Some(break_marker) = hard_break_type
2991 && !reflowed.is_empty()
2992 {
2993 let last_idx = reflowed.len() - 1;
2994 if !has_hard_break(&reflowed[last_idx]) {
2995 reflowed[last_idx].push_str(break_marker);
2996 }
2997 }
2998 reflowed_content_lines.extend(reflowed);
2999 }
3000
3001 let mut styled_lines: Vec<String> = Vec::new();
3002 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3003 let force_explicit = idx == 0
3004 || continuation_style == BlockquoteContinuationStyle::Explicit
3005 || should_force_explicit_blockquote_line(line);
3006 if force_explicit {
3007 styled_lines.push(format!("{explicit_prefix}{line}"));
3008 } else {
3009 styled_lines.push(line.clone());
3010 }
3011 }
3012
3013 styled_lines
3014}
3015
3016fn is_blockquote_content_boundary(content: &str) -> bool {
3017 let trimmed = content.trim();
3018 trimmed.is_empty()
3019 || is_block_boundary(trimmed)
3020 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3021 || trimmed.starts_with(":::")
3022 || crate::utils::is_template_directive_only(content)
3023 || is_standalone_attr_list(content)
3024 || is_snippet_block_delimiter(content)
3025}
3026
3027fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3028 let mut segments = Vec::new();
3029 let mut current = Vec::new();
3030
3031 for &line in lines {
3032 current.push(line);
3033 if has_hard_break(line) {
3034 segments.push(current);
3035 current = Vec::new();
3036 }
3037 }
3038
3039 if !current.is_empty() {
3040 segments.push(current);
3041 }
3042
3043 segments
3044}
3045
3046fn reflow_blockquote_paragraph_at_line(
3047 content: &str,
3048 lines: &[&str],
3049 target_idx: usize,
3050 options: &ReflowOptions,
3051) -> Option<ParagraphReflow> {
3052 let mut anchor_idx = target_idx;
3053 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3054 parsed.nesting_level
3055 } else {
3056 let mut found = None;
3057 let mut idx = target_idx;
3058 loop {
3059 if lines[idx].trim().is_empty() {
3060 break;
3061 }
3062 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3063 found = Some((idx, parsed.nesting_level));
3064 break;
3065 }
3066 if idx == 0 {
3067 break;
3068 }
3069 idx -= 1;
3070 }
3071 let (idx, level) = found?;
3072 anchor_idx = idx;
3073 level
3074 };
3075
3076 let mut para_start = anchor_idx;
3078 while para_start > 0 {
3079 let prev_idx = para_start - 1;
3080 let prev_line = lines[prev_idx];
3081
3082 if prev_line.trim().is_empty() {
3083 break;
3084 }
3085
3086 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3087 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3088 break;
3089 }
3090 para_start = prev_idx;
3091 continue;
3092 }
3093
3094 let prev_lazy = prev_line.trim_start();
3095 if is_blockquote_content_boundary(prev_lazy) {
3096 break;
3097 }
3098 para_start = prev_idx;
3099 }
3100
3101 while para_start < lines.len() {
3103 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3104 para_start += 1;
3105 continue;
3106 };
3107 target_level = parsed.nesting_level;
3108 break;
3109 }
3110
3111 if para_start >= lines.len() || para_start > target_idx {
3112 return None;
3113 }
3114
3115 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3118 let mut idx = para_start;
3119 while idx < lines.len() {
3120 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3121 break;
3122 }
3123
3124 let line = lines[idx];
3125 if line.trim().is_empty() {
3126 break;
3127 }
3128
3129 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3130 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3131 break;
3132 }
3133 collected.push((
3134 idx,
3135 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3136 ));
3137 idx += 1;
3138 continue;
3139 }
3140
3141 let lazy_content = line.trim_start();
3142 if is_blockquote_content_boundary(lazy_content) {
3143 break;
3144 }
3145
3146 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3147 idx += 1;
3148 }
3149
3150 if collected.is_empty() {
3151 return None;
3152 }
3153
3154 let para_end = collected[collected.len() - 1].0;
3155 if target_idx < para_start || target_idx > para_end {
3156 return None;
3157 }
3158
3159 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3160
3161 let fallback_prefix = line_data
3162 .iter()
3163 .find_map(|d| d.prefix.clone())
3164 .unwrap_or_else(|| "> ".to_string());
3165 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3166 let continuation_style = blockquote_continuation_style(&line_data);
3167
3168 let adjusted_line_length = options
3169 .line_length
3170 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3171 .max(1);
3172
3173 let adjusted_options = ReflowOptions {
3174 line_length: adjusted_line_length,
3175 ..options.clone()
3176 };
3177
3178 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3179
3180 if styled_lines.is_empty() {
3181 return None;
3182 }
3183
3184 let mut start_byte = 0;
3186 for line in lines.iter().take(para_start) {
3187 start_byte += line.len() + 1;
3188 }
3189
3190 let mut end_byte = start_byte;
3191 for line in lines.iter().take(para_end + 1).skip(para_start) {
3192 end_byte += line.len() + 1;
3193 }
3194
3195 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3196 if !includes_trailing_newline {
3197 end_byte -= 1;
3198 }
3199
3200 let reflowed_joined = styled_lines.join("\n");
3201 let reflowed_text = if includes_trailing_newline {
3202 if reflowed_joined.ends_with('\n') {
3203 reflowed_joined
3204 } else {
3205 format!("{reflowed_joined}\n")
3206 }
3207 } else if reflowed_joined.ends_with('\n') {
3208 reflowed_joined.trim_end_matches('\n').to_string()
3209 } else {
3210 reflowed_joined
3211 };
3212
3213 Some(ParagraphReflow {
3214 start_byte,
3215 end_byte,
3216 reflowed_text,
3217 })
3218}
3219
3220pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3238 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3239}
3240
3241pub fn reflow_paragraph_at_line_with_mode(
3243 content: &str,
3244 line_number: usize,
3245 line_length: usize,
3246 length_mode: ReflowLengthMode,
3247) -> Option<ParagraphReflow> {
3248 let options = ReflowOptions {
3249 line_length,
3250 length_mode,
3251 ..Default::default()
3252 };
3253 reflow_paragraph_at_line_with_options(content, line_number, &options)
3254}
3255
3256pub fn reflow_paragraph_at_line_with_options(
3267 content: &str,
3268 line_number: usize,
3269 options: &ReflowOptions,
3270) -> Option<ParagraphReflow> {
3271 if line_number == 0 {
3272 return None;
3273 }
3274
3275 let lines: Vec<&str> = content.lines().collect();
3276
3277 if line_number > lines.len() {
3279 return None;
3280 }
3281
3282 let target_idx = line_number - 1; let target_line = lines[target_idx];
3284 let trimmed = target_line.trim();
3285
3286 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3289 return Some(blockquote_reflow);
3290 }
3291
3292 if is_paragraph_boundary(trimmed, target_line) {
3294 return None;
3295 }
3296
3297 let mut para_start = target_idx;
3299 while para_start > 0 {
3300 let prev_idx = para_start - 1;
3301 let prev_line = lines[prev_idx];
3302 let prev_trimmed = prev_line.trim();
3303
3304 if is_paragraph_boundary(prev_trimmed, prev_line) {
3306 break;
3307 }
3308
3309 para_start = prev_idx;
3310 }
3311
3312 let mut para_end = target_idx;
3314 while para_end + 1 < lines.len() {
3315 let next_idx = para_end + 1;
3316 let next_line = lines[next_idx];
3317 let next_trimmed = next_line.trim();
3318
3319 if is_paragraph_boundary(next_trimmed, next_line) {
3321 break;
3322 }
3323
3324 para_end = next_idx;
3325 }
3326
3327 let paragraph_lines = &lines[para_start..=para_end];
3329
3330 let mut start_byte = 0;
3332 for line in lines.iter().take(para_start) {
3333 start_byte += line.len() + 1; }
3335
3336 let mut end_byte = start_byte;
3337 for line in paragraph_lines {
3338 end_byte += line.len() + 1; }
3340
3341 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3344
3345 if !includes_trailing_newline {
3347 end_byte -= 1;
3348 }
3349
3350 let paragraph_text = paragraph_lines.join("\n");
3352
3353 let reflowed = reflow_markdown(¶graph_text, options);
3355
3356 let reflowed_text = if includes_trailing_newline {
3360 if reflowed.ends_with('\n') {
3362 reflowed
3363 } else {
3364 format!("{reflowed}\n")
3365 }
3366 } else {
3367 if reflowed.ends_with('\n') {
3369 reflowed.trim_end_matches('\n').to_string()
3370 } else {
3371 reflowed
3372 }
3373 };
3374
3375 Some(ParagraphReflow {
3376 start_byte,
3377 end_byte,
3378 reflowed_text,
3379 })
3380}
3381
3382#[cfg(test)]
3383mod tests {
3384 use super::*;
3385
3386 #[test]
3391 fn test_helper_function_text_ends_with_abbreviation() {
3392 let abbreviations = get_abbreviations(&None);
3394
3395 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3397 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3398 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3399 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3400 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3401 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3402 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3403 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3404
3405 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3407 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3408 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3409 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3410 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3411 assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); assert!(!text_ends_with_abbreviation("word", &abbreviations)); assert!(!text_ends_with_abbreviation("", &abbreviations)); }
3417
3418 #[test]
3419 fn test_is_unordered_list_marker() {
3420 assert!(is_unordered_list_marker("- item"));
3422 assert!(is_unordered_list_marker("* item"));
3423 assert!(is_unordered_list_marker("+ item"));
3424 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3426 assert!(is_unordered_list_marker("+"));
3427
3428 assert!(!is_unordered_list_marker("---")); assert!(!is_unordered_list_marker("***")); assert!(!is_unordered_list_marker("- - -")); assert!(!is_unordered_list_marker("* * *")); assert!(!is_unordered_list_marker("*emphasis*")); assert!(!is_unordered_list_marker("-word")); assert!(!is_unordered_list_marker("")); assert!(!is_unordered_list_marker("text")); assert!(!is_unordered_list_marker("# heading")); }
3439
3440 #[test]
3441 fn test_is_block_boundary() {
3442 assert!(is_block_boundary("")); assert!(is_block_boundary("# Heading")); assert!(is_block_boundary("## Level 2")); assert!(is_block_boundary("```rust")); assert!(is_block_boundary("~~~")); assert!(is_block_boundary("> quote")); assert!(is_block_boundary("| cell |")); assert!(is_block_boundary("[link]: http://example.com")); assert!(is_block_boundary("---")); assert!(is_block_boundary("***")); assert!(is_block_boundary("- item")); assert!(is_block_boundary("* item")); assert!(is_block_boundary("+ item")); assert!(is_block_boundary("1. item")); assert!(is_block_boundary("10. item")); assert!(is_block_boundary(": definition")); assert!(is_block_boundary(":::")); assert!(is_block_boundary("::::: {.callout-note}")); assert!(!is_block_boundary("regular text"));
3464 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3467 }
3468
3469 #[test]
3470 fn test_definition_list_boundary_in_single_line_paragraph() {
3471 let options = ReflowOptions {
3474 line_length: 80,
3475 ..Default::default()
3476 };
3477 let input = "Term\n: Definition of the term";
3478 let result = reflow_markdown(input, &options);
3479 assert!(
3481 result.contains(": Definition"),
3482 "Definition list item should not be merged into previous line. Got: {result:?}"
3483 );
3484 let lines: Vec<&str> = result.lines().collect();
3485 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3486 assert_eq!(lines[0], "Term");
3487 assert_eq!(lines[1], ": Definition of the term");
3488 }
3489
3490 #[test]
3491 fn test_is_paragraph_boundary() {
3492 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3494 assert!(is_paragraph_boundary("- item", "- item"));
3495 assert!(is_paragraph_boundary(":::", ":::"));
3496 assert!(is_paragraph_boundary(": definition", ": definition"));
3497
3498 assert!(is_paragraph_boundary("code", " code"));
3500 assert!(is_paragraph_boundary("code", "\tcode"));
3501
3502 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3504 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3508 assert!(!is_paragraph_boundary("text", " text")); }
3510
3511 #[test]
3512 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3513 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3516 let result = reflow_paragraph_at_line(content, 3, 80);
3518 assert!(result.is_none(), "Div marker line should not be reflowed");
3519 }
3520}