use std::collections::HashMap;
use std::ops::Range;
use crate::diagram::DiagramSource;
use crate::markdown::ast::{Block, Inline};
pub fn promote_image_paragraphs(
blocks: &mut [Block],
_real_ranges: Option<&mut Vec<Range<usize>>>,
) {
for block in blocks.iter_mut() {
if let Block::Paragraph { inlines } = block {
if let Some((alt, url)) = extract_lone_image(inlines) {
*block = Block::ImageBlock { alt, url };
}
}
}
}
pub fn promote_diagram_code_blocks(blocks: &mut [Block]) -> HashMap<String, DiagramSource> {
let mut sources = HashMap::new();
for block in blocks.iter_mut() {
let is_mermaid = matches!(
block,
Block::CodeBlock { language: Some(lang), .. } if lang.eq_ignore_ascii_case("mermaid")
);
if !is_mermaid {
continue;
}
let Block::CodeBlock { content, .. } = std::mem::replace(block, Block::HorizontalRule)
else {
continue;
};
let source = DiagramSource::Mermaid(content);
let url = crate::diagram::synthetic_url(&source);
sources.insert(url.clone(), source);
*block = Block::ImageBlock {
alt: "mermaid diagram".to_string(),
url,
};
}
sources
}
pub fn reconstruct_broken_display_math(
blocks: &mut [Block],
real_ranges: &[Range<usize>],
source: &str,
) {
for (block, range) in blocks.iter_mut().zip(real_ranges) {
let Block::Paragraph { inlines } = block else {
continue;
};
if collect_display_math_only(inlines).is_some() {
continue;
}
let Some(raw) = source.get(range.clone()) else {
continue;
};
let trimmed = raw.trim_matches(|c: char| c.is_ascii_whitespace());
let Some(inner) = trimmed
.strip_prefix("$$")
.and_then(|body| body.strip_suffix("$$"))
else {
continue;
};
if inner.is_empty() || inner.contains("$$") {
continue;
}
*inlines = vec![Inline::Math {
source: inner.to_string(),
display: true,
}];
}
}
pub fn promote_display_math_paragraphs(
blocks: &mut Vec<Block>,
real_ranges: &mut Vec<Range<usize>>,
source: &str,
) -> HashMap<String, DiagramSource> {
let mut sources = HashMap::new();
let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
let mut out_ranges: Vec<Range<usize>> = Vec::with_capacity(real_ranges.len());
for (block, range) in blocks.drain(..).zip(real_ranges.drain(..)) {
let Block::Paragraph { inlines } = &block else {
out.push(block);
out_ranges.push(range);
continue;
};
let Some(math_sources) = collect_display_math_only(inlines) else {
out.push(block);
out_ranges.push(range);
continue;
};
let Some(piece_ranges) = split_math_ranges(source, &range, math_sources.len()) else {
out.push(block);
out_ranges.push(range);
continue;
};
for (i, formula) in math_sources.iter().enumerate() {
let diagram_source = DiagramSource::Latex(formula.clone());
let url = crate::diagram::synthetic_url(&diagram_source);
sources.insert(url.clone(), diagram_source);
out.push(Block::ImageBlock {
alt: "math".to_string(),
url,
});
out_ranges.push(piece_ranges[i].clone());
}
}
*blocks = out;
*real_ranges = out_ranges;
sources
}
pub fn split_display_math_paragraphs(
blocks: &mut Vec<Block>,
real_ranges: &mut Vec<Range<usize>>,
source: &str,
) {
let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
let mut out_ranges: Vec<Range<usize>> = Vec::with_capacity(real_ranges.len());
for (block, range) in blocks.drain(..).zip(real_ranges.drain(..)) {
let split = match &block {
Block::Paragraph { inlines } => collect_display_math_only(inlines)
.filter(|formulas| formulas.len() >= 2)
.and_then(|formulas| {
split_math_ranges(source, &range, formulas.len())
.map(|ranges| (formulas, ranges))
}),
_ => None,
};
match split {
Some((formulas, piece_ranges)) => {
for (i, formula) in formulas.into_iter().enumerate() {
out.push(Block::Paragraph {
inlines: vec![Inline::Math {
source: formula,
display: true,
}],
});
out_ranges.push(piece_ranges[i].clone());
}
}
None => {
out.push(block);
out_ranges.push(range);
}
}
}
*blocks = out;
*real_ranges = out_ranges;
}
fn collect_display_math_only(inlines: &[Inline]) -> Option<Vec<String>> {
let mut formulas: Vec<String> = Vec::new();
for inline in inlines {
match inline {
Inline::Math {
source,
display: true,
} => formulas.push(source.clone()),
Inline::Math { .. } => return None, Inline::Text(t) if t.trim().is_empty() => {}
Inline::SoftBreak | Inline::HardBreak => {}
_ => return None,
}
}
(!formulas.is_empty()).then_some(formulas)
}
pub(crate) fn display_math_block_body(block: &Block) -> Option<String> {
let Block::Paragraph { inlines } = block else {
return None;
};
let mut formula: Option<&str> = None;
for inline in inlines {
match inline {
Inline::Math {
source,
display: true,
} if formula.is_none() => formula = Some(source),
Inline::Math { .. } => return None, Inline::Text(t) if t.trim().is_empty() => {}
Inline::SoftBreak | Inline::HardBreak => {}
_ => return None,
}
}
let inner = formula?.strip_prefix('\n')?.strip_suffix('\n')?;
Some(inner.to_string())
}
fn split_math_ranges(source: &str, para: &Range<usize>, count: usize) -> Option<Vec<Range<usize>>> {
let body = source.get(para.clone())?;
let mut ranges = Vec::with_capacity(count);
let mut search_from = 0usize;
for i in 0..count {
let open = body[search_from..].find("$$")? + search_from;
let close_rel = body[open + 2..].find("$$")?;
let close = open + 2 + close_rel;
let end = if i + 1 == count {
body.len()
} else {
close + 2
};
ranges.push(para.start + open..para.start + end);
search_from = close + 2;
}
Some(ranges)
}
fn extract_lone_image(inlines: &[Inline]) -> Option<(String, String)> {
let mut image: Option<(String, String)> = None;
for inline in inlines {
match inline {
Inline::Image { alt, url } => {
if image.is_some() {
return None;
}
image = Some((alt.clone(), url.clone()));
}
Inline::Text(t) if t.trim().is_empty() => {}
Inline::SoftBreak | Inline::HardBreak => {}
_ => return None,
}
}
image
}
pub(super) fn is_html_comment_only(body: &str) -> bool {
let mut rest = body.trim();
if rest.is_empty() {
return false;
}
while !rest.is_empty() {
if !rest.starts_with("<!--") {
return false;
}
let Some(close) = rest[4..].find("-->") else {
return false;
};
rest = rest[4 + close + 3..].trim_start();
}
true
}
pub fn promote_html_comments(blocks: &mut [Block]) {
for block in blocks.iter_mut() {
if let Block::Html(body) = block {
if is_html_comment_only(body) {
let body = std::mem::take(body);
*block = Block::HtmlComment(body);
}
}
}
}
pub fn attach_trailing_tui_columns_comments(blocks: &mut Vec<Block>) {
let mut i = 0;
while i + 1 < blocks.len() {
let is_pair = matches!(
(&blocks[i], &blocks[i + 1]),
(Block::Table { user_widths: None, .. }, Block::HtmlComment(body))
if crate::markdown::table_layout::parse_column_widths_comment(body).is_some()
);
if is_pair {
let body = match &blocks[i + 1] {
Block::HtmlComment(s) => s.clone(),
_ => unreachable!(),
};
let widths = crate::markdown::table_layout::parse_column_widths_comment(&body).unwrap();
if let Block::Table { user_widths, .. } = &mut blocks[i] {
*user_widths = Some(widths);
}
blocks.remove(i + 1);
continue;
}
i += 1;
}
}
pub fn annotate_list_blanks(blocks: &mut [Block], ranges: &[Range<usize>], source: &str) {
for (block, range) in blocks.iter_mut().zip(ranges.iter()) {
let Block::List { items, .. } = block else {
continue;
};
let list_src = &source[range.clone()];
let item_offsets = top_level_item_offsets(list_src);
if item_offsets.len() != items.len() {
continue;
}
for k in 1..item_offsets.len() {
let prev_line_end = line_end_in_str(list_src, item_offsets[k - 1]);
let between_start = (prev_line_end + 1).min(item_offsets[k]);
if let Some(gap_start) =
separator_blank_run_start(list_src, between_start, item_offsets[k])
{
items[k].blank_lines_before = list_src.as_bytes()[gap_start..item_offsets[k]]
.iter()
.filter(|&&b| b == b'\n')
.count();
}
}
}
}
fn top_level_item_offsets(list_src: &str) -> Vec<usize> {
let bytes = list_src.as_bytes();
let mut offsets = Vec::new();
let mut first_indent: Option<String> = None;
let mut pos = 0;
while pos < bytes.len() {
let line_end = line_end_in_str(list_src, pos);
let line = &list_src[pos..line_end];
if let Some((indent, _, _)) = parse_marker_line(line) {
if first_indent.is_none() {
first_indent = Some(indent.clone());
}
if first_indent.as_deref() == Some(indent.as_str()) {
offsets.push(pos);
}
}
pos = if line_end < bytes.len() {
line_end + 1
} else {
line_end
};
}
offsets
}
fn line_end_in_str(s: &str, start: usize) -> usize {
let bytes = s.as_bytes();
let mut p = start;
while p < bytes.len() && bytes[p] != b'\n' {
p += 1;
}
p
}
fn separator_blank_run_start(s: &str, start: usize, end: usize) -> Option<usize> {
let bytes = s.as_bytes();
let mut pos = start;
let mut fence: Option<(char, usize)> = None;
let mut run_start: Option<usize> = None;
while pos < end {
let mut le = pos;
while le < end && bytes[le] != b'\n' {
le += 1;
}
let line = &s[pos..le];
if let Some((fence_char, min_count)) = fence {
if is_closing_fence(line, fence_char, min_count) {
fence = None;
}
run_start = None;
} else if let Some((c, count)) = parse_opening_fence(line) {
fence = Some((c, count));
run_start = None;
} else if line.chars().all(char::is_whitespace) {
run_start.get_or_insert(pos);
} else {
run_start = None;
}
pos = if le < end { le + 1 } else { le };
}
run_start
}
pub fn parse_opening_fence(line: &str) -> Option<(char, usize)> {
let trimmed = line.trim_start();
let first = trimmed.chars().next()?;
if first != '`' && first != '~' {
return None;
}
let count = trimmed.chars().take_while(|&c| c == first).count();
if count < 3 {
return None;
}
if first == '`' && trimmed[count..].contains('`') {
return None;
}
Some((first, count))
}
pub fn is_closing_fence(line: &str, fence_char: char, min_count: usize) -> bool {
let trimmed = line.trim_start();
let count = trimmed.chars().take_while(|&c| c == fence_char).count();
if count < min_count {
return false;
}
trimmed[count..].chars().all(char::is_whitespace)
}
fn parse_marker_line(line: &str) -> Option<(String, char, Option<u64>)> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let indent = line[..i].to_owned();
let rest = &line[i..];
let rb = rest.as_bytes();
if let Some(&c) = rb.first() {
if matches!(c, b'-' | b'*' | b'+') && rb.get(1) == Some(&b' ') {
return Some((indent, c as char, None));
}
}
let digits_len = rb.iter().take_while(|b| b.is_ascii_digit()).count();
if digits_len > 0 {
let num: u64 = rest[..digits_len].parse().ok()?;
let delim = *rb.get(digits_len)?;
if matches!(delim, b'.' | b')') && rb.get(digits_len + 1) == Some(&b' ') {
return Some((indent, delim as char, Some(num)));
}
}
None
}