use std::sync::LazyLock;
use html5ever::{driver::ParseOpts, parse_document, tendril::TendrilSink};
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use regex::Regex;
use tracing::debug;
use crate::wrap::FenceTracker;
static TABLE_START_RE: LazyLock<Regex> = lazy_regex!(
r"(?i)^(?:<table(?:\s|>|$))",
"HTML table start pattern should compile"
);
static TABLE_TAG_RE: LazyLock<Regex> = lazy_regex!(
r"(?i)<table(?:\s|>|$)",
"HTML table tag pattern should compile"
);
static TABLE_END_RE: LazyLock<Regex> =
lazy_regex!(r"(?i)</table>", "HTML table end pattern should compile");
fn node_text(handle: &Handle) -> String {
let mut out = String::new();
let mut last_space = false;
collect_text(handle, &mut out, &mut last_space);
if last_space {
out.push(' ');
}
out.trim_start().to_string()
}
fn is_ignored_tag(tag: &str) -> bool {
matches!(
tag,
t if t.eq_ignore_ascii_case("script")
|| t.eq_ignore_ascii_case("style")
|| t.eq_ignore_ascii_case("noscript")
|| t.eq_ignore_ascii_case("template")
|| t.eq_ignore_ascii_case("head")
)
}
fn collect_text(handle: &Handle, out: &mut String, last_space: &mut bool) {
match &handle.data {
NodeData::Text { contents } => {
for ch in contents.borrow().chars() {
push_collapsed_text_char(ch, out, last_space);
}
}
NodeData::Element { name, .. } => {
if is_ignored_tag(name.local.as_ref()) {
return;
}
for child in handle.children.borrow().iter() {
collect_text(child, out, last_space);
}
}
NodeData::Document => {
for child in handle.children.borrow().iter() {
collect_text(child, out, last_space);
}
}
_ => {}
}
}
fn push_collapsed_text_char(ch: char, out: &mut String, last_space: &mut bool) {
if ch.is_whitespace() {
*last_space = true;
return;
}
if *last_space && !out.is_empty() {
out.push(' ');
}
out.push(ch);
*last_space = false;
}
fn is_element(handle: &Handle, tag: &str) -> bool {
if let NodeData::Element { name, .. } = &handle.data {
name.local.as_ref().eq_ignore_ascii_case(tag)
} else {
false
}
}
fn is_table_cell(handle: &Handle) -> bool { is_element(handle, "td") || is_element(handle, "th") }
fn collect_matching<F>(handle: &Handle, pred: F, out: &mut Vec<Handle>)
where
F: Fn(&Handle) -> bool + Copy,
{
if pred(handle) {
out.push(handle.clone());
}
for child in handle.children.borrow().iter() {
collect_matching(child, pred, out);
}
}
fn collect_tables(handle: &Handle, tables: &mut Vec<Handle>) {
collect_matching(handle, |node| is_element(node, "table"), tables);
}
fn collect_rows(handle: &Handle, rows: &mut Vec<Handle>) {
collect_matching(handle, |node| is_element(node, "tr"), rows);
}
fn is_bold_tag(tag: &str) -> bool {
matches!(
tag,
t if t.eq_ignore_ascii_case("strong") || t.eq_ignore_ascii_case("b")
)
}
fn contains_strong(handle: &Handle) -> bool {
if let NodeData::Element { name, .. } = &handle.data
&& is_bold_tag(name.local.as_ref())
{
return true;
}
let children = handle.children.borrow();
children.iter().any(contains_strong)
}
fn parse_row(row: &Handle) -> (Vec<String>, bool) {
let mut cells = Vec::new();
let mut all_header = true;
for child in row.children.borrow().iter() {
if is_table_cell(child) {
let is_header = if is_element(child, "th") {
true
} else {
contains_strong(child)
};
all_header &= is_header;
cells.push(node_text(child));
}
}
(cells, all_header)
}
fn table_node_to_markdown(table: &Handle) -> Vec<String> {
let mut row_handles = Vec::new();
collect_rows(table, &mut row_handles);
if row_handles.is_empty() {
return Vec::new();
}
let (first_cells, explicit_header) = parse_row(&row_handles[0]);
let col_count = first_cells.len();
let fallback_header = !explicit_header && row_handles.len() > 1;
let has_header = explicit_header || fallback_header;
let mut out = Vec::new();
out.push(format!("| {} |", first_cells.join(" | ")));
for row in row_handles.iter().skip(1) {
let (cells, _) = parse_row(row);
out.push(format!("| {} |", cells.join(" | ")));
}
if has_header {
let sep: Vec<String> = (0..col_count).map(|_| "---".to_string()).collect();
out.insert(1, format!("| {} |", sep.join(" | ")));
}
crate::table::reflow_generated_rows(&out)
}
fn table_lines_to_markdown(lines: &[String]) -> Vec<String> {
let indent: String = lines
.first()
.map(|line| crate::textproc::leading_indent(line).to_string())
.unwrap_or_default();
let html: String = lines.join("\n");
let opts = ParseOpts::default();
let dom: RcDom = parse_document(RcDom::default(), opts).one(html);
let mut tables = Vec::new();
collect_tables(&dom.document, &mut tables);
if tables.is_empty() {
return lines.to_vec();
}
let mut out = Vec::new();
for table in tables {
for line in table_node_to_markdown(&table) {
out.push(format!("{indent}{line}"));
}
}
out
}
#[derive(Default)]
struct HtmlTableState {
buf: Vec<String>,
depth: usize,
}
impl HtmlTableState {
fn in_html(&self) -> bool { !self.buf.is_empty() }
fn flush_raw(&mut self, out: &mut Vec<String>) {
if !self.buf.is_empty() {
debug!(
line_count = self.buf.len(),
depth = self.depth,
"flushing buffered HTML table block without conversion"
);
out.append(&mut self.buf);
}
self.depth = 0;
}
fn push_html_line(&mut self, line: &str, out: &mut Vec<String>) {
let trimmed = line.trim_start();
let previous_depth = self.depth;
let start_count = TABLE_TAG_RE.find_iter(trimmed).count();
let end_count = TABLE_END_RE.find_iter(trimmed).count();
self.buf.push(line.to_string());
self.depth += start_count;
if end_count > 0 {
self.depth = self.depth.saturating_sub(end_count);
}
debug!(
previous_depth,
start_count,
end_count,
depth = self.depth,
buffered_lines = self.buf.len(),
"updated HTML table buffer depth"
);
if self.depth == 0 {
debug!(
line_count = self.buf.len(),
"converting HTML table block to Markdown"
);
out.extend(table_lines_to_markdown(&self.buf));
self.buf.clear();
}
}
}
pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut html_state = HtmlTableState::default();
for line in lines {
if html_state.in_html() || TABLE_START_RE.is_match(line.trim_start()) {
html_state.push_html_line(line, &mut out);
continue;
}
out.push(line.clone());
}
html_state.flush_raw(&mut out);
out
}
#[must_use]
pub fn convert_html_tables(lines: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut html_state = HtmlTableState::default();
let mut fences = FenceTracker::new();
for line in lines {
let fence = fences.observe_source_line(line);
if fence.is_fence_marker {
if html_state.in_html() {
html_state.flush_raw(&mut out);
}
out.push(line.clone());
continue;
}
if fence.is_in_fence {
out.push(line.clone());
continue;
}
if html_state.in_html() {
html_state.push_html_line(line, &mut out);
continue;
}
if TABLE_START_RE.is_match(line.trim_start()) {
html_state.push_html_line(line, &mut out);
continue;
}
out.push(line.clone());
}
html_state.flush_raw(&mut out);
out
}
#[cfg(test)]
#[path = "html_tests.rs"]
mod tests;