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 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2308 if add_space {
2309 current_line.push(' ');
2310 current_length += 1;
2311 }
2312 current_line.push_str(word);
2313 current_length += word_len;
2314 }
2315 }
2316 } else if matches!(
2317 element,
2318 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough(_)
2319 ) && element_len > options.line_length
2320 {
2321 let (content, marker): (&str, &str) = match element {
2325 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2326 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2327 Element::Strikethrough(content) => (content.as_str(), "~~"),
2328 _ => unreachable!(),
2329 };
2330
2331 let words: Vec<&str> = content.split_whitespace().collect();
2332 let n = words.len();
2333
2334 if n == 0 {
2335 let full = format!("{marker}{marker}");
2337 let full_len = display_len(&full, length_mode);
2338 if !is_adjacent_to_prev && current_length > 0 {
2339 current_line.push(' ');
2340 current_length += 1;
2341 }
2342 current_line.push_str(&full);
2343 current_length += full_len;
2344 } else {
2345 for (i, word) in words.iter().enumerate() {
2346 let is_first = i == 0;
2347 let is_last = i == n - 1;
2348 let word_str: String = match (is_first, is_last) {
2349 (true, true) => format!("{marker}{word}{marker}"),
2350 (true, false) => format!("{marker}{word}"),
2351 (false, true) => format!("{word}{marker}"),
2352 (false, false) => word.to_string(),
2353 };
2354 let word_len = display_len(&word_str, length_mode);
2355
2356 let needs_space = if is_first {
2357 !is_adjacent_to_prev && current_length > 0
2358 } else {
2359 current_length > 0
2360 };
2361
2362 if needs_space && current_length + 1 + word_len > options.line_length {
2363 lines.push(current_line.trim_end().to_string());
2364 current_line = word_str;
2365 current_length = word_len;
2366 current_line_element_spans.clear();
2367 } else {
2368 if needs_space {
2369 current_line.push(' ');
2370 current_length += 1;
2371 }
2372 current_line.push_str(&word_str);
2373 current_length += word_len;
2374 }
2375 }
2376 }
2377 } else {
2378 if is_adjacent_to_prev {
2382 if current_length + element_len > options.line_length {
2384 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2387 let before = current_line[..last_space].trim_end().to_string();
2388 let after = current_line[last_space + 1..].to_string();
2389 lines.push(before);
2390 current_line = format!("{after}{element_str}");
2391 current_length = display_len(¤t_line, length_mode);
2392 current_line_element_spans.clear();
2393 let start = after.len();
2395 current_line_element_spans.push((start, start + element_str.len()));
2396 } else {
2397 let start = current_line.len();
2399 current_line.push_str(&element_str);
2400 current_length += element_len;
2401 current_line_element_spans.push((start, current_line.len()));
2402 }
2403 } else {
2404 let start = current_line.len();
2405 current_line.push_str(&element_str);
2406 current_length += element_len;
2407 current_line_element_spans.push((start, current_line.len()));
2408 }
2409 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2410 lines.push(current_line.trim().to_string());
2412 current_line.clone_from(&element_str);
2413 current_length = element_len;
2414 current_line_element_spans.clear();
2415 current_line_element_spans.push((0, element_str.len()));
2416 } else {
2417 let ends_with_opener =
2419 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2420 if current_length > 0 && !ends_with_opener {
2421 current_line.push(' ');
2422 current_length += 1;
2423 }
2424 let start = current_line.len();
2425 current_line.push_str(&element_str);
2426 current_length += element_len;
2427 current_line_element_spans.push((start, current_line.len()));
2428 }
2429 }
2430 }
2431
2432 if !current_line.is_empty() {
2434 lines.push(current_line.trim_end().to_string());
2435 }
2436
2437 lines
2438}
2439
2440pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2442 let lines: Vec<&str> = content.lines().collect();
2443 let mut result = Vec::new();
2444 let mut i = 0;
2445
2446 while i < lines.len() {
2447 let line = lines[i];
2448 let trimmed = line.trim();
2449
2450 if trimmed.is_empty() {
2452 result.push(String::new());
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(":::") {
2466 result.push(line.to_string());
2467 i += 1;
2468 continue;
2469 }
2470
2471 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2473 result.push(line.to_string());
2474 i += 1;
2475 while i < lines.len() {
2477 result.push(lines[i].to_string());
2478 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2479 i += 1;
2480 break;
2481 }
2482 i += 1;
2483 }
2484 continue;
2485 }
2486
2487 if calculate_indentation_width_default(line) >= 4 {
2489 result.push(line.to_string());
2491 i += 1;
2492 while i < lines.len() {
2493 let next_line = lines[i];
2494 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2496 result.push(next_line.to_string());
2497 i += 1;
2498 } else {
2499 break;
2500 }
2501 }
2502 continue;
2503 }
2504
2505 if trimmed.starts_with('>') {
2507 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2510 let quote_prefix = line[0..=gt_pos].to_string();
2511 let quote_content = &line[quote_prefix.len()..].trim_start();
2512
2513 let reflowed = reflow_line(quote_content, options);
2514 for reflowed_line in &reflowed {
2515 result.push(format!("{quote_prefix} {reflowed_line}"));
2516 }
2517 i += 1;
2518 continue;
2519 }
2520
2521 if is_horizontal_rule(trimmed) {
2523 result.push(line.to_string());
2524 i += 1;
2525 continue;
2526 }
2527
2528 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2530 let indent = line.len() - line.trim_start().len();
2532 let indent_str = " ".repeat(indent);
2533
2534 let mut marker_end = indent;
2537 let mut content_start = indent;
2538
2539 if trimmed.chars().next().is_some_and(char::is_numeric) {
2540 if let Some(period_pos) = line[indent..].find('.') {
2542 marker_end = indent + period_pos + 1; content_start = marker_end;
2544 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2548 content_start += 1;
2549 }
2550 }
2551 } else {
2552 marker_end = indent + 1; content_start = marker_end;
2555 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2559 content_start += 1;
2560 }
2561 }
2562
2563 let min_continuation_indent = content_start;
2565
2566 let rest = &line[content_start..];
2569 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2570 marker_end = content_start + 3; content_start += 4; }
2573
2574 let marker = &line[indent..marker_end];
2575
2576 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2579 i += 1;
2580
2581 while i < lines.len() {
2585 let next_line = lines[i];
2586 let next_trimmed = next_line.trim();
2587
2588 if is_block_boundary(next_trimmed) {
2590 break;
2591 }
2592
2593 let next_indent = next_line.len() - next_line.trim_start().len();
2595 if next_indent >= min_continuation_indent {
2596 let trimmed_start = next_line.trim_start();
2599 list_content.push(trim_preserving_hard_break(trimmed_start));
2600 i += 1;
2601 } else {
2602 break;
2604 }
2605 }
2606
2607 let combined_content = if options.preserve_breaks {
2610 list_content[0].clone()
2611 } else {
2612 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2614 if has_hard_breaks {
2615 list_content.join("\n")
2617 } else {
2618 list_content.join(" ")
2620 }
2621 };
2622
2623 let trimmed_marker = marker;
2625 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2626 indent + (content_start - indent).min(max_indent)
2629 } else {
2630 content_start
2631 };
2632
2633 let prefix_length = indent + trimmed_marker.len() + 1;
2635
2636 let adjusted_options = ReflowOptions {
2638 line_length: options.line_length.saturating_sub(prefix_length),
2639 ..options.clone()
2640 };
2641
2642 let reflowed = reflow_line(&combined_content, &adjusted_options);
2643 for (j, reflowed_line) in reflowed.iter().enumerate() {
2644 if j == 0 {
2645 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2646 } else {
2647 let continuation_indent = " ".repeat(continuation_spaces);
2649 result.push(format!("{continuation_indent}{reflowed_line}"));
2650 }
2651 }
2652 continue;
2653 }
2654
2655 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2657 result.push(line.to_string());
2658 i += 1;
2659 continue;
2660 }
2661
2662 if trimmed.starts_with('[') && line.contains("]:") {
2664 result.push(line.to_string());
2665 i += 1;
2666 continue;
2667 }
2668
2669 if is_definition_list_item(trimmed) {
2671 result.push(line.to_string());
2672 i += 1;
2673 continue;
2674 }
2675
2676 let mut is_single_line_paragraph = true;
2678 if i + 1 < lines.len() {
2679 let next_trimmed = lines[i + 1].trim();
2680 if !is_block_boundary(next_trimmed) {
2682 is_single_line_paragraph = false;
2683 }
2684 }
2685
2686 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2688 result.push(line.to_string());
2689 i += 1;
2690 continue;
2691 }
2692
2693 let mut paragraph_parts = Vec::new();
2695 let mut current_part = vec![line];
2696 i += 1;
2697
2698 if options.preserve_breaks {
2700 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2702 Some("\\")
2703 } else if line.ends_with(" ") {
2704 Some(" ")
2705 } else {
2706 None
2707 };
2708 let reflowed = reflow_line(line, options);
2709
2710 if let Some(break_marker) = hard_break_type {
2712 if !reflowed.is_empty() {
2713 let mut reflowed_with_break = reflowed;
2714 let last_idx = reflowed_with_break.len() - 1;
2715 if !has_hard_break(&reflowed_with_break[last_idx]) {
2716 reflowed_with_break[last_idx].push_str(break_marker);
2717 }
2718 result.extend(reflowed_with_break);
2719 }
2720 } else {
2721 result.extend(reflowed);
2722 }
2723 } else {
2724 while i < lines.len() {
2726 let prev_line = if !current_part.is_empty() {
2727 current_part.last().unwrap()
2728 } else {
2729 ""
2730 };
2731 let next_line = lines[i];
2732 let next_trimmed = next_line.trim();
2733
2734 if is_block_boundary(next_trimmed) {
2736 break;
2737 }
2738
2739 let prev_trimmed = prev_line.trim();
2742 let abbreviations = get_abbreviations(&options.abbreviations);
2743 let ends_with_sentence = (prev_trimmed.ends_with('.')
2744 || prev_trimmed.ends_with('!')
2745 || prev_trimmed.ends_with('?')
2746 || 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(".\"")
2754 || prev_trimmed.ends_with("!\"")
2755 || prev_trimmed.ends_with("?\"")
2756 || prev_trimmed.ends_with(".'")
2757 || prev_trimmed.ends_with("!'")
2758 || prev_trimmed.ends_with("?'")
2759 || prev_trimmed.ends_with(".\u{201D}")
2760 || prev_trimmed.ends_with("!\u{201D}")
2761 || prev_trimmed.ends_with("?\u{201D}")
2762 || prev_trimmed.ends_with(".\u{2019}")
2763 || prev_trimmed.ends_with("!\u{2019}")
2764 || prev_trimmed.ends_with("?\u{2019}"))
2765 && !text_ends_with_abbreviation(
2766 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2767 &abbreviations,
2768 );
2769
2770 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2771 paragraph_parts.push(current_part.join(" "));
2773 current_part = vec![next_line];
2774 } else {
2775 current_part.push(next_line);
2776 }
2777 i += 1;
2778 }
2779
2780 if !current_part.is_empty() {
2782 if current_part.len() == 1 {
2783 paragraph_parts.push(current_part[0].to_string());
2785 } else {
2786 paragraph_parts.push(current_part.join(" "));
2787 }
2788 }
2789
2790 for (j, part) in paragraph_parts.iter().enumerate() {
2792 let reflowed = reflow_line(part, options);
2793 result.extend(reflowed);
2794
2795 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2799 let last_idx = result.len() - 1;
2800 if !has_hard_break(&result[last_idx]) {
2801 result[last_idx].push_str(" ");
2802 }
2803 }
2804 }
2805 }
2806 }
2807
2808 let result_text = result.join("\n");
2810 if content.ends_with('\n') && !result_text.ends_with('\n') {
2811 format!("{result_text}\n")
2812 } else {
2813 result_text
2814 }
2815}
2816
2817#[derive(Debug, Clone)]
2819pub struct ParagraphReflow {
2820 pub start_byte: usize,
2822 pub end_byte: usize,
2824 pub reflowed_text: String,
2826}
2827
2828#[derive(Debug, Clone)]
2834pub struct BlockquoteLineData {
2835 pub(crate) content: String,
2837 pub(crate) is_explicit: bool,
2839 pub(crate) prefix: Option<String>,
2841}
2842
2843impl BlockquoteLineData {
2844 pub fn explicit(content: String, prefix: String) -> Self {
2846 Self {
2847 content,
2848 is_explicit: true,
2849 prefix: Some(prefix),
2850 }
2851 }
2852
2853 pub fn lazy(content: String) -> Self {
2855 Self {
2856 content,
2857 is_explicit: false,
2858 prefix: None,
2859 }
2860 }
2861}
2862
2863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2865pub enum BlockquoteContinuationStyle {
2866 Explicit,
2867 Lazy,
2868}
2869
2870pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2878 let mut explicit_count = 0usize;
2879 let mut lazy_count = 0usize;
2880
2881 for line in lines.iter().skip(1) {
2882 if line.is_explicit {
2883 explicit_count += 1;
2884 } else {
2885 lazy_count += 1;
2886 }
2887 }
2888
2889 if explicit_count > 0 && lazy_count == 0 {
2890 BlockquoteContinuationStyle::Explicit
2891 } else if lazy_count > 0 && explicit_count == 0 {
2892 BlockquoteContinuationStyle::Lazy
2893 } else if explicit_count >= lazy_count {
2894 BlockquoteContinuationStyle::Explicit
2895 } else {
2896 BlockquoteContinuationStyle::Lazy
2897 }
2898}
2899
2900pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2905 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2906
2907 for (idx, line) in lines.iter().enumerate() {
2908 let Some(prefix) = line.prefix.as_ref() else {
2909 continue;
2910 };
2911 counts
2912 .entry(prefix.clone())
2913 .and_modify(|entry| entry.0 += 1)
2914 .or_insert((1, idx));
2915 }
2916
2917 counts
2918 .into_iter()
2919 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2920 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2921 })
2922 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2923}
2924
2925pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2930 let trimmed = content_line.trim_start();
2931 trimmed.starts_with('>')
2932 || trimmed.starts_with('#')
2933 || trimmed.starts_with("```")
2934 || trimmed.starts_with("~~~")
2935 || is_unordered_list_marker(trimmed)
2936 || is_numbered_list_item(trimmed)
2937 || is_horizontal_rule(trimmed)
2938 || is_definition_list_item(trimmed)
2939 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2940 || trimmed.starts_with(":::")
2941 || (trimmed.starts_with('<')
2942 && !trimmed.starts_with("<http")
2943 && !trimmed.starts_with("<https")
2944 && !trimmed.starts_with("<mailto:"))
2945}
2946
2947pub fn reflow_blockquote_content(
2956 lines: &[BlockquoteLineData],
2957 explicit_prefix: &str,
2958 continuation_style: BlockquoteContinuationStyle,
2959 options: &ReflowOptions,
2960) -> Vec<String> {
2961 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2962 let segments = split_into_segments_strs(&content_strs);
2963 let mut reflowed_content_lines: Vec<String> = Vec::new();
2964
2965 for segment in segments {
2966 let hard_break_type = segment.last().and_then(|&line| {
2967 let line = line.strip_suffix('\r').unwrap_or(line);
2968 if line.ends_with('\\') {
2969 Some("\\")
2970 } else if line.ends_with(" ") {
2971 Some(" ")
2972 } else {
2973 None
2974 }
2975 });
2976
2977 let pieces: Vec<&str> = segment
2978 .iter()
2979 .map(|&line| {
2980 if let Some(l) = line.strip_suffix('\\') {
2981 l.trim_end()
2982 } else if let Some(l) = line.strip_suffix(" ") {
2983 l.trim_end()
2984 } else {
2985 line.trim_end()
2986 }
2987 })
2988 .collect();
2989
2990 let segment_text = pieces.join(" ");
2991 let segment_text = segment_text.trim();
2992 if segment_text.is_empty() {
2993 continue;
2994 }
2995
2996 let mut reflowed = reflow_line(segment_text, options);
2997 if let Some(break_marker) = hard_break_type
2998 && !reflowed.is_empty()
2999 {
3000 let last_idx = reflowed.len() - 1;
3001 if !has_hard_break(&reflowed[last_idx]) {
3002 reflowed[last_idx].push_str(break_marker);
3003 }
3004 }
3005 reflowed_content_lines.extend(reflowed);
3006 }
3007
3008 let mut styled_lines: Vec<String> = Vec::new();
3009 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3010 let force_explicit = idx == 0
3011 || continuation_style == BlockquoteContinuationStyle::Explicit
3012 || should_force_explicit_blockquote_line(line);
3013 if force_explicit {
3014 styled_lines.push(format!("{explicit_prefix}{line}"));
3015 } else {
3016 styled_lines.push(line.clone());
3017 }
3018 }
3019
3020 styled_lines
3021}
3022
3023fn is_blockquote_content_boundary(content: &str) -> bool {
3024 let trimmed = content.trim();
3025 trimmed.is_empty()
3026 || is_block_boundary(trimmed)
3027 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3028 || trimmed.starts_with(":::")
3029 || crate::utils::is_template_directive_only(content)
3030 || is_standalone_attr_list(content)
3031 || is_snippet_block_delimiter(content)
3032}
3033
3034fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3035 let mut segments = Vec::new();
3036 let mut current = Vec::new();
3037
3038 for &line in lines {
3039 current.push(line);
3040 if has_hard_break(line) {
3041 segments.push(current);
3042 current = Vec::new();
3043 }
3044 }
3045
3046 if !current.is_empty() {
3047 segments.push(current);
3048 }
3049
3050 segments
3051}
3052
3053fn reflow_blockquote_paragraph_at_line(
3054 content: &str,
3055 lines: &[&str],
3056 target_idx: usize,
3057 options: &ReflowOptions,
3058) -> Option<ParagraphReflow> {
3059 let mut anchor_idx = target_idx;
3060 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3061 parsed.nesting_level
3062 } else {
3063 let mut found = None;
3064 let mut idx = target_idx;
3065 loop {
3066 if lines[idx].trim().is_empty() {
3067 break;
3068 }
3069 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3070 found = Some((idx, parsed.nesting_level));
3071 break;
3072 }
3073 if idx == 0 {
3074 break;
3075 }
3076 idx -= 1;
3077 }
3078 let (idx, level) = found?;
3079 anchor_idx = idx;
3080 level
3081 };
3082
3083 let mut para_start = anchor_idx;
3085 while para_start > 0 {
3086 let prev_idx = para_start - 1;
3087 let prev_line = lines[prev_idx];
3088
3089 if prev_line.trim().is_empty() {
3090 break;
3091 }
3092
3093 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3094 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3095 break;
3096 }
3097 para_start = prev_idx;
3098 continue;
3099 }
3100
3101 let prev_lazy = prev_line.trim_start();
3102 if is_blockquote_content_boundary(prev_lazy) {
3103 break;
3104 }
3105 para_start = prev_idx;
3106 }
3107
3108 while para_start < lines.len() {
3110 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3111 para_start += 1;
3112 continue;
3113 };
3114 target_level = parsed.nesting_level;
3115 break;
3116 }
3117
3118 if para_start >= lines.len() || para_start > target_idx {
3119 return None;
3120 }
3121
3122 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3125 let mut idx = para_start;
3126 while idx < lines.len() {
3127 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3128 break;
3129 }
3130
3131 let line = lines[idx];
3132 if line.trim().is_empty() {
3133 break;
3134 }
3135
3136 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3137 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3138 break;
3139 }
3140 collected.push((
3141 idx,
3142 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3143 ));
3144 idx += 1;
3145 continue;
3146 }
3147
3148 let lazy_content = line.trim_start();
3149 if is_blockquote_content_boundary(lazy_content) {
3150 break;
3151 }
3152
3153 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3154 idx += 1;
3155 }
3156
3157 if collected.is_empty() {
3158 return None;
3159 }
3160
3161 let para_end = collected[collected.len() - 1].0;
3162 if target_idx < para_start || target_idx > para_end {
3163 return None;
3164 }
3165
3166 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3167
3168 let fallback_prefix = line_data
3169 .iter()
3170 .find_map(|d| d.prefix.clone())
3171 .unwrap_or_else(|| "> ".to_string());
3172 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3173 let continuation_style = blockquote_continuation_style(&line_data);
3174
3175 let adjusted_line_length = options
3176 .line_length
3177 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3178 .max(1);
3179
3180 let adjusted_options = ReflowOptions {
3181 line_length: adjusted_line_length,
3182 ..options.clone()
3183 };
3184
3185 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3186
3187 if styled_lines.is_empty() {
3188 return None;
3189 }
3190
3191 let mut start_byte = 0;
3193 for line in lines.iter().take(para_start) {
3194 start_byte += line.len() + 1;
3195 }
3196
3197 let mut end_byte = start_byte;
3198 for line in lines.iter().take(para_end + 1).skip(para_start) {
3199 end_byte += line.len() + 1;
3200 }
3201
3202 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3203 if !includes_trailing_newline {
3204 end_byte -= 1;
3205 }
3206
3207 let reflowed_joined = styled_lines.join("\n");
3208 let reflowed_text = if includes_trailing_newline {
3209 if reflowed_joined.ends_with('\n') {
3210 reflowed_joined
3211 } else {
3212 format!("{reflowed_joined}\n")
3213 }
3214 } else if reflowed_joined.ends_with('\n') {
3215 reflowed_joined.trim_end_matches('\n').to_string()
3216 } else {
3217 reflowed_joined
3218 };
3219
3220 Some(ParagraphReflow {
3221 start_byte,
3222 end_byte,
3223 reflowed_text,
3224 })
3225}
3226
3227pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3245 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3246}
3247
3248pub fn reflow_paragraph_at_line_with_mode(
3250 content: &str,
3251 line_number: usize,
3252 line_length: usize,
3253 length_mode: ReflowLengthMode,
3254) -> Option<ParagraphReflow> {
3255 let options = ReflowOptions {
3256 line_length,
3257 length_mode,
3258 ..Default::default()
3259 };
3260 reflow_paragraph_at_line_with_options(content, line_number, &options)
3261}
3262
3263pub fn reflow_paragraph_at_line_with_options(
3274 content: &str,
3275 line_number: usize,
3276 options: &ReflowOptions,
3277) -> Option<ParagraphReflow> {
3278 if line_number == 0 {
3279 return None;
3280 }
3281
3282 let lines: Vec<&str> = content.lines().collect();
3283
3284 if line_number > lines.len() {
3286 return None;
3287 }
3288
3289 let target_idx = line_number - 1; let target_line = lines[target_idx];
3291 let trimmed = target_line.trim();
3292
3293 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3296 return Some(blockquote_reflow);
3297 }
3298
3299 if is_paragraph_boundary(trimmed, target_line) {
3301 return None;
3302 }
3303
3304 let mut para_start = target_idx;
3306 while para_start > 0 {
3307 let prev_idx = para_start - 1;
3308 let prev_line = lines[prev_idx];
3309 let prev_trimmed = prev_line.trim();
3310
3311 if is_paragraph_boundary(prev_trimmed, prev_line) {
3313 break;
3314 }
3315
3316 para_start = prev_idx;
3317 }
3318
3319 let mut para_end = target_idx;
3321 while para_end + 1 < lines.len() {
3322 let next_idx = para_end + 1;
3323 let next_line = lines[next_idx];
3324 let next_trimmed = next_line.trim();
3325
3326 if is_paragraph_boundary(next_trimmed, next_line) {
3328 break;
3329 }
3330
3331 para_end = next_idx;
3332 }
3333
3334 let paragraph_lines = &lines[para_start..=para_end];
3336
3337 let mut start_byte = 0;
3339 for line in lines.iter().take(para_start) {
3340 start_byte += line.len() + 1; }
3342
3343 let mut end_byte = start_byte;
3344 for line in paragraph_lines {
3345 end_byte += line.len() + 1; }
3347
3348 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3351
3352 if !includes_trailing_newline {
3354 end_byte -= 1;
3355 }
3356
3357 let paragraph_text = paragraph_lines.join("\n");
3359
3360 let reflowed = reflow_markdown(¶graph_text, options);
3362
3363 let reflowed_text = if includes_trailing_newline {
3367 if reflowed.ends_with('\n') {
3369 reflowed
3370 } else {
3371 format!("{reflowed}\n")
3372 }
3373 } else {
3374 if reflowed.ends_with('\n') {
3376 reflowed.trim_end_matches('\n').to_string()
3377 } else {
3378 reflowed
3379 }
3380 };
3381
3382 Some(ParagraphReflow {
3383 start_byte,
3384 end_byte,
3385 reflowed_text,
3386 })
3387}
3388
3389#[cfg(test)]
3390mod tests {
3391 use super::*;
3392
3393 #[test]
3398 fn test_helper_function_text_ends_with_abbreviation() {
3399 let abbreviations = get_abbreviations(&None);
3401
3402 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3404 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3405 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3406 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3407 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3408 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3409 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3410 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3411
3412 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3414 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3415 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3416 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3417 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3418 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)); }
3424
3425 #[test]
3426 fn test_is_unordered_list_marker() {
3427 assert!(is_unordered_list_marker("- item"));
3429 assert!(is_unordered_list_marker("* item"));
3430 assert!(is_unordered_list_marker("+ item"));
3431 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3433 assert!(is_unordered_list_marker("+"));
3434
3435 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")); }
3446
3447 #[test]
3448 fn test_is_block_boundary() {
3449 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"));
3471 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3474 }
3475
3476 #[test]
3477 fn test_definition_list_boundary_in_single_line_paragraph() {
3478 let options = ReflowOptions {
3481 line_length: 80,
3482 ..Default::default()
3483 };
3484 let input = "Term\n: Definition of the term";
3485 let result = reflow_markdown(input, &options);
3486 assert!(
3488 result.contains(": Definition"),
3489 "Definition list item should not be merged into previous line. Got: {result:?}"
3490 );
3491 let lines: Vec<&str> = result.lines().collect();
3492 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3493 assert_eq!(lines[0], "Term");
3494 assert_eq!(lines[1], ": Definition of the term");
3495 }
3496
3497 #[test]
3498 fn test_is_paragraph_boundary() {
3499 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3501 assert!(is_paragraph_boundary("- item", "- item"));
3502 assert!(is_paragraph_boundary(":::", ":::"));
3503 assert!(is_paragraph_boundary(": definition", ": definition"));
3504
3505 assert!(is_paragraph_boundary("code", " code"));
3507 assert!(is_paragraph_boundary("code", "\tcode"));
3508
3509 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3511 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3515 assert!(!is_paragraph_boundary("text", " text")); }
3517
3518 #[test]
3519 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3520 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3523 let result = reflow_paragraph_at_line(content, 3, 80);
3525 assert!(result.is_none(), "Div marker line should not be reflowed");
3526 }
3527}