#![cfg_attr(not(feature = "vlm"), allow(dead_code))]
use docling_core::{DoclingDocument, Node};
use regex::Regex;
use scraper::{ElementRef, Html};
use crate::backend::markdown::escape_text;
pub fn looks_like_chandra(text: &str) -> bool {
text.contains("data-bbox=") && text.contains("data-label=")
}
fn div_re() -> &'static Regex {
cached_regex!(r"(?s)<div\s+([^>]*?)>(.*?)</div>")
}
fn attr_re(name: &str, attrs: &str) -> Option<String> {
let re = match name {
"data-bbox" => cached_regex!(r#"data-bbox="(\d+\s+\d+\s+\d+\s+\d+)""#),
_ => cached_regex!(r#"data-label="([^"]+)""#),
};
re.captures(attrs).map(|c| c[1].to_string())
}
pub(crate) fn parse_chandra_pages(name: &str, fragments: &[String]) -> DoclingDocument {
let mut doc = DoclingDocument::new(name);
for fragment in fragments {
parse_fragment(fragment, &mut doc);
}
doc
}
fn parse_fragment(content: &str, doc: &mut DoclingDocument) {
for caps in div_re().captures_iter(content) {
let attrs = &caps[1];
let inner = &caps[2];
let (Some(bbox), Some(label)) = (attr_re("data-bbox", attrs), attr_re("data-label", attrs))
else {
continue;
};
let location = parse_bbox(&bbox);
for node in nodes_for(&label, inner) {
doc.push(located(node, location));
}
}
}
fn parse_bbox(raw: &str) -> Option<[u16; 4]> {
let vals: Vec<u16> = raw
.split_whitespace()
.filter_map(|v| v.parse::<u32>().ok())
.map(|v| ((v.min(1000) * 511 + 500) / 1000) as u16)
.collect();
vals.try_into().ok()
}
fn located(node: Node, location: Option<[u16; 4]>) -> Node {
match location {
Some(location) => Node::Located {
location,
inner: Box::new(node),
},
None => node,
}
}
fn nodes_for(label: &str, inner: &str) -> Vec<Node> {
match label {
"Table" | "Form" if inner.to_lowercase().contains("<table") => parse_table_html(inner)
.map(Node::Table)
.into_iter()
.collect(),
"List-Group" => {
let items = parse_list_html(inner);
let items = if items.is_empty() {
vec![strip_tags(inner)]
} else {
items
};
items
.into_iter()
.enumerate()
.map(|(i, text)| Node::ListItem {
ordered: false,
number: 0,
first_in_list: i == 0,
text: escape_text(&text),
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
})
.collect()
}
"Figure" | "Image" | "Diagram" => vec![Node::Picture {
caption: None,
caption_href: None,
image: None,
classification: None,
}],
"Title" => text_node(inner, |t| Node::Heading { level: 1, text: t }),
"Section-Header" => text_node(inner, |t| Node::Heading { level: 2, text: t }),
"Page-Header" | "Page-Footer" => text_node(inner, |t| Node::Furniture {
layer: docling_core::ContentLayer::Furniture,
inner: Box::new(Node::Paragraph { text: t }),
}),
"Equation-Block" | "Chemical-Block" => {
let text = strip_tags(inner);
if text.is_empty() {
Vec::new()
} else {
vec![Node::Formula {
orig: text.clone(),
latex: text,
location: None,
}]
}
}
"Code-Block" => {
let text = strip_tags(inner);
if text.is_empty() {
Vec::new()
} else {
vec![Node::Code {
language: None,
text,
orig: None,
pretty: None,
}]
}
}
_ => text_node(inner, |t| Node::Paragraph { text: t }),
}
}
fn text_node(inner: &str, make: impl Fn(String) -> Node) -> Vec<Node> {
let text = strip_tags(inner);
if text.is_empty() {
Vec::new()
} else {
vec![make(escape_text(&text))]
}
}
fn strip_tags(html: &str) -> String {
let spaced = cached_regex!(r"(?i)<br\s*/?>").replace_all(html, " ");
let text = cached_regex!(r"<[^>]+>").replace_all(&spaced, "");
let text = html_escape_decode(&text);
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn html_escape_decode(text: &str) -> String {
if !text.contains('&') {
return text.to_string();
}
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ")
.replace("&", "&")
}
fn parse_table_html(inner: &str) -> Option<docling_core::Table> {
let dom = Html::parse_fragment(inner);
let table = descendant_table(dom.root_element())?;
crate::backend::html::parse_table(table)
}
fn descendant_table<'a>(root: ElementRef<'a>) -> Option<ElementRef<'a>> {
root.descendants()
.filter_map(ElementRef::wrap)
.find(|e| e.value().name() == "table")
}
fn parse_list_html(inner: &str) -> Vec<String> {
let dom = Html::parse_fragment(inner);
dom.root_element()
.descendants()
.filter_map(ElementRef::wrap)
.filter(|e| e.value().name() == "li")
.map(|li| {
li.text()
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.filter(|t| !t.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(content: &str) -> DoclingDocument {
parse_chandra_pages("page", &[content.to_string()])
}
#[test]
fn br_tags_preserve_spacing() {
let doc =
parse(r#"<div data-bbox="0 0 1000 1000" data-label="Text">Hello<br/>World</div>"#);
assert_eq!(doc.export_to_markdown(), "Hello World\n");
}
#[test]
fn simple_fixture_parses_blocks() {
let content = include_str!("../../tests/data/chandra/chandra_simple.html");
let doc = parse(content);
let md = doc.export_to_markdown();
assert!(md.contains("order to compute the TED score"), "{md}");
assert!(!md.contains("Optimized Table Tokenization"), "{md}");
assert!(
doc.nodes.iter().any(|n| matches!(
n,
Node::Located { inner, .. } if matches!(**inner, Node::Table(_))
)),
"expected a table"
);
}
#[test]
fn multiblock_fixture_keeps_pictures() {
let content = include_str!("../../tests/data/chandra/chandra_multiblock.html");
let doc = parse(content);
assert!(doc.nodes.iter().any(|n| matches!(
n,
Node::Located { inner, .. } if matches!(**inner, Node::Picture { .. })
)));
}
#[test]
fn list_group_fixture_yields_items() {
let content = include_str!("../../tests/data/chandra/chandra_list_group.html");
let doc = parse(content);
let md = doc.export_to_markdown();
assert!(md.contains("- "), "expected list items: {md}");
}
#[test]
fn form_tables_parse_as_tables() {
let content = include_str!("../../tests/data/chandra/chandra_form_table.html");
let doc = parse(content);
let tables = doc
.nodes
.iter()
.filter(|n| {
matches!(
n,
Node::Located { inner, .. } if matches!(**inner, Node::Table(_))
)
})
.count();
assert_eq!(tables, 4);
}
}