use std::sync::LazyLock;
use html5ever::{driver::ParseOpts, parse_document, tendril::TendrilSink};
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use regex::Regex;
use crate::wrap::is_fence;
static TABLE_START_RE: LazyLock<Regex> = lazy_regex!(
r"(?i)^<table(?:\s|>|$)",
"HTML table start 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() {
if ch.is_whitespace() {
*last_space = true;
} else {
if *last_space && !out.is_empty() {
out.push(' ');
}
out.push(ch);
*last_space = false;
}
}
}
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 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_tables(handle: &Handle, tables: &mut Vec<Handle>) {
if is_element(handle, "table") {
tables.push(handle.clone());
}
for child in handle.children.borrow().iter() {
collect_tables(child, tables);
}
}
fn collect_rows(handle: &Handle, rows: &mut Vec<Handle>) {
if is_element(handle, "tr") {
rows.push(handle.clone());
}
for child in handle.children.borrow().iter() {
collect_rows(child, 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::reflow_table(&out)
}
fn table_lines_to_markdown(lines: &[String]) -> Vec<String> {
let indent: String = lines
.first()
.map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
.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
}
fn push_html_line(
line: &str,
buf: &mut Vec<String>,
depth: &mut usize,
in_html: &mut bool,
out: &mut Vec<String>,
) {
buf.push(line.to_string());
*depth += TABLE_START_RE.find_iter(line).count();
if TABLE_END_RE.is_match(line) {
*depth = depth.saturating_sub(TABLE_END_RE.find_iter(line).count());
if *depth == 0 {
out.extend(html_table_to_markdown(buf));
buf.clear();
*in_html = false;
}
}
}
pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut buf = Vec::new();
let mut depth = 0usize;
for line in lines {
if depth > 0 || TABLE_START_RE.is_match(line.trim_start()) {
buf.push(line.to_string());
depth += TABLE_START_RE.find_iter(line).count();
if TABLE_END_RE.is_match(line) {
depth = depth.saturating_sub(TABLE_END_RE.find_iter(line).count());
if depth == 0 {
out.extend(table_lines_to_markdown(&buf));
buf.clear();
}
}
continue;
}
out.push(line.to_string());
}
if !buf.is_empty() {
out.extend(buf);
}
out
}
#[must_use]
pub fn convert_html_tables(lines: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut buf = Vec::new();
let mut depth = 0usize;
let mut in_html = false;
let mut in_code = false;
for line in lines {
if is_fence(line).is_some() {
if in_html {
out.append(&mut buf);
in_html = false;
depth = 0;
}
in_code = !in_code;
out.push(line.to_string());
continue;
}
if in_code {
out.push(line.to_string());
continue;
}
if in_html {
push_html_line(line, &mut buf, &mut depth, &mut in_html, &mut out);
continue;
}
if TABLE_START_RE.is_match(line.trim_start()) {
in_html = true;
push_html_line(line, &mut buf, &mut depth, &mut in_html, &mut out);
continue;
}
out.push(line.to_string());
}
if !buf.is_empty() {
out.extend(buf);
}
out
}
#[cfg(test)]
mod tests {
use html5ever::{driver::ParseOpts, parse_document, tendril::TendrilSink};
use markup5ever_rcdom::RcDom;
use super::*;
#[test]
fn element_detection() {
let dom: RcDom = parse_document(RcDom::default(), ParseOpts::default())
.one("<table></table>".to_string());
let html = dom.document.children.borrow()[0].clone();
let body = html.children.borrow()[1].clone();
let table = body.children.borrow()[0].clone();
assert!(is_element(&table, "table"));
assert!(is_element(&table, "TABLE"));
assert!(!is_element(&table, "tr"));
}
#[test]
fn table_cell_detection() {
let dom: RcDom = parse_document(RcDom::default(), ParseOpts::default())
.one("<table><tr><th>a</th><td>b</td></tr></table>".to_string());
let html = dom.document.children.borrow()[0].clone();
let body = html.children.borrow()[1].clone();
let table = body.children.borrow()[0].clone();
let tbody = table.children.borrow()[0].clone();
let tr = tbody.children.borrow()[0].clone();
let th = tr.children.borrow()[0].clone();
let td = tr.children.borrow()[1].clone();
assert!(is_table_cell(&th));
assert!(is_table_cell(&td));
}
}