use docling_core::{ContentLayer, DoclingDocument, InlineRun, Node, Script, Table};
use scraper::{ElementRef, Html, Node as HtmlNode, Selector};
use crate::backend::images::{ImageResolver, NoFetch};
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
macro_rules! cached_selector {
($sel:literal) => {{
static SEL: std::sync::OnceLock<Selector> = std::sync::OnceLock::new();
SEL.get_or_init(|| Selector::parse($sel).unwrap())
}};
}
pub struct HtmlBackend;
impl DeclarativeBackend for HtmlBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
Ok(convert_html(&source.name, source.text()?, &NoFetch))
}
}
pub(crate) fn convert_html(name: &str, html: &str, images: &dyn ImageResolver) -> DoclingDocument {
let mut doc = DoclingDocument::new(name);
append_fragment(html, &mut doc.nodes, images);
doc
}
pub(crate) fn append_fragment(html: &str, out: &mut Vec<Node>, images: &dyn ImageResolver) {
let parsed = Html::parse_document(html);
if let Some(title) = parsed.select(cached_selector!("title")).next() {
let text = normalize_ws(&title.text().collect::<String>());
if !text.is_empty() {
out.push(Node::Furniture {
layer: docling_core::ContentLayer::Furniture,
inner: Box::new(Node::Heading { level: 1, text }),
});
}
}
let start = out.len();
let body = parsed.select(cached_selector!("body")).next();
let root = body.unwrap_or_else(|| parsed.root_element());
if within_depth_limit(root, MAX_DOM_DEPTH) {
let srcs: Vec<String> = root
.select(cached_selector!("img"))
.filter_map(|img| img_src(img.value()))
.collect();
if !srcs.is_empty() {
images.prefetch(&srcs);
}
walk_block(root, out, 0, Fmt::default(), images);
} else {
let text = normalize_ws(&root.text().collect::<String>());
if !text.is_empty() {
out.push(Node::Paragraph { text });
}
}
mark_leading_furniture(&mut out[start..]);
}
fn first_href(el: ElementRef) -> Option<String> {
el.select(cached_selector!("a"))
.next()
.and_then(|a| a.value().attr("href"))
.filter(|h| !h.is_empty())
.map(str::to_string)
}
fn mark_leading_furniture(nodes: &mut [Node]) {
let Some(first) = nodes.iter().position(|n| matches!(n, Node::Heading { .. })) else {
return;
};
for n in &mut nodes[..first] {
match n {
Node::ListItem { layer, .. } => *layer = Some(ContentLayer::Furniture),
Node::Furniture { .. } => {}
other => {
let inner = std::mem::replace(other, Node::PageBreak);
*other = Node::Furniture {
layer: ContentLayer::Furniture,
inner: Box::new(inner),
};
}
}
}
}
fn is_hidden(e: &scraper::node::Element) -> bool {
if e.attr("hidden").is_some() {
return true;
}
e.attr("style").is_some_and(|style| {
style.split(';').any(|decl| {
let mut it = decl.splitn(2, ':');
match (it.next(), it.next()) {
(Some(prop), Some(val)) => {
let (prop, val) = (prop.trim(), val.trim());
(prop.eq_ignore_ascii_case("display") && val.eq_ignore_ascii_case("none"))
|| (prop.eq_ignore_ascii_case("visibility")
&& val.eq_ignore_ascii_case("hidden"))
}
_ => false,
}
})
})
}
fn is_skipped(name: &str) -> bool {
matches!(
name,
"script" | "style" | "head" | "title" | "noscript" | "template" | "svg"
)
}
fn is_block(name: &str) -> bool {
matches!(
name,
"h1" | "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "p"
| "ul"
| "ol"
| "pre"
| "table"
| "figure"
| "blockquote"
| "div"
| "section"
| "article"
| "main"
| "header"
| "footer"
| "nav"
| "aside"
| "details"
| "hr"
| "dl"
| "button"
| "input"
| "label"
| "body"
| "html"
)
}
const MAX_DOM_DEPTH: usize = 2000;
fn max_dom_depth() -> usize {
std::env::var("DOCLING_RS_MAX_HTML_DEPTH")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(MAX_DOM_DEPTH)
}
fn within_depth_limit(root: ElementRef, _default: usize) -> bool {
let limit = max_dom_depth();
let mut stack = vec![(*root, 1usize)];
while let Some((node, depth)) = stack.pop() {
if depth > limit {
return false;
}
for child in node.children() {
stack.push((child, depth + 1));
}
}
true
}
fn walk_block(
elem: ElementRef,
nodes: &mut Vec<Node>,
list_level: u8,
base: Fmt,
images: &dyn ImageResolver,
) {
let mut inline = RunBuf::default();
for child in elem.children() {
match child.value() {
HtmlNode::Text(text) => {
let run = normalize_ws(text);
if !run.is_empty() {
inline.md.push(serialize_run(&run, base, None));
inline.push_rich(base.to_inline_run(&run));
}
}
HtmlNode::Element(e) => {
let Some(cref) = ElementRef::wrap(child) else {
continue;
};
let name = e.name();
if is_skipped(name) || is_hidden(e) {
continue;
}
if name == "img" {
flush_inline(&mut inline, nodes);
nodes.push(Node::Picture {
caption: e.attr("alt").filter(|a| !a.is_empty()).map(str::to_string),
image: img_src(e).and_then(|s| images.resolve(&s)),
classification: None,
});
} else if name == "signature" || name == "stamp" {
flush_inline(&mut inline, nodes);
nodes.push(Node::Picture {
caption: None,
image: None,
classification: None,
});
let mut label = name.to_string();
label[..1].make_ascii_uppercase();
nodes.push(Node::Paragraph { text: label });
} else if is_block(name) {
flush_inline(&mut inline, nodes);
handle_block(cref, name, nodes, list_level, base, images);
} else if name == "a" {
if let Some((caption, src)) = image_wrapper(cref) {
flush_inline(&mut inline, nodes);
nodes.push(Node::Picture {
caption,
image: src.as_deref().and_then(|s| images.resolve(s)),
classification: None,
});
} else if has_descendant(cref, "img") {
flush_inline(&mut inline, nodes);
walk_block(cref, nodes, list_level, base, images);
} else {
collect_element(cref, base, None, &mut inline);
}
} else if has_descendant(cref, "img") {
flush_inline(&mut inline, nodes);
walk_block(cref, nodes, list_level, base, images);
} else {
collect_element(cref, base, None, &mut inline);
}
}
_ => {}
}
}
flush_inline(&mut inline, nodes);
}
fn flush_inline(buf: &mut RunBuf, nodes: &mut Vec<Node>) {
if !buf.md.is_empty() {
push_inline_paragraph(nodes, finalize(&buf.md), std::mem::take(&mut buf.rich));
}
*buf = RunBuf::default();
}
fn push_inline_paragraph(nodes: &mut Vec<Node>, text: String, runs: Vec<InlineRun>) {
if text.is_empty() {
return;
}
let unwrapped = nodes.iter().any(|n| matches!(n, Node::Heading { .. }));
nodes.push(docling_core::inline_paragraph_node(text, runs, unwrapped));
}
fn handle_block(
elem: ElementRef,
name: &str,
nodes: &mut Vec<Node>,
list_level: u8,
base: Fmt,
images: &dyn ImageResolver,
) {
match name {
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
let level: u8 = name[1..].parse().unwrap_or(1);
let text = render_inline_fmt(elem, base);
if !text.is_empty() {
nodes.push(Node::Heading { level, text });
}
}
"p" => {
if let Some(code) = lone_code(elem) {
nodes.push(Node::Code {
language: None,
text: code,
orig: None,
});
} else {
let (text, runs) = render_inline(elem, base);
push_inline_paragraph(nodes, text, runs);
}
}
"ul" | "ol" => walk_list(elem, name == "ol", nodes, list_level, base),
"dl" => walk_dl(elem, nodes, list_level, base),
"pre" => {
let mut runs = RunBuf::default();
collect_runs(elem, Fmt { code: true, ..base }, None, &mut runs);
if runs.md.len() > 1 {
nodes.push(Node::Paragraph {
text: runs.md.join(" "),
});
} else {
let (language, text) = extract_pre(elem);
nodes.push(Node::Code {
language,
text,
orig: None,
});
}
}
"table" => {
if base.raw {
let text = flatten_nested_table(elem);
if !text.is_empty() {
nodes.push(Node::Paragraph { text });
}
} else if let Some(table) = parse_table(elem) {
nodes.push(Node::Table(table));
}
}
"figure" => {
if has_descendant(elem, "img") {
nodes.push(Node::Picture {
caption: figure_caption(elem),
image: figure_img_src(elem).and_then(|s| images.resolve(&s)),
classification: None,
});
}
}
"hr" => {}
"input" => {
let ty = elem.value().attr("type").unwrap_or("").to_ascii_lowercase();
if ty == "hidden" {
return;
}
if ty == "checkbox" || ty == "radio" {
let text = checkbox_label_text(elem);
if !text.is_empty() {
nodes.push(Node::CheckboxItem {
checked: elem.value().attr("checked").is_some(),
text,
});
}
} else {
let text = ["value", "placeholder", "name"]
.iter()
.find_map(|a| {
elem.value()
.attr(a)
.map(str::trim)
.filter(|t| !t.is_empty())
})
.unwrap_or("");
if !text.is_empty() {
nodes.push(Node::Paragraph {
text: super::markdown::escape_html(&super::markdown::escape_underscores(
&normalize_ws(text),
)),
});
}
}
}
"label" => {
if !label_feeds_checkbox(elem) {
let (text, runs) = render_inline(elem, base);
push_inline_paragraph(nodes, text, runs);
}
}
"footer" => {
let mut inner: Vec<Node> = Vec::new();
walk_block(elem, &mut inner, list_level, base, images);
for mut n in inner {
match &mut n {
Node::ListItem { layer, .. } => *layer = Some(ContentLayer::Furniture),
Node::Furniture { .. } => {}
_ => {
let inner_node = std::mem::replace(&mut n, Node::PageBreak);
n = Node::Furniture {
layer: ContentLayer::Furniture,
inner: Box::new(inner_node),
};
}
}
nodes.push(n);
}
}
_ if !base.raw => match detect_field_region(elem) {
Some(items) => nodes.push(Node::FieldRegion { items }),
None => walk_block(elem, nodes, list_level, base, images),
},
_ => walk_block(elem, nodes, list_level, base, images),
}
}
fn detect_field_region(elem: ElementRef) -> Option<Vec<docling_core::FieldItem>> {
let is_form_region = elem
.value()
.attr("class")
.is_some_and(|c| c.split_whitespace().any(|cls| cls == "form_region"));
if !is_form_region {
return None;
}
let mut fields: std::collections::BTreeMap<u32, docling_core::FieldItem> =
std::collections::BTreeMap::new();
for el in elem.select(cached_selector!("[id]")) {
let Some(id) = el.value().attr("id") else {
continue;
};
let Some((n, kind)) = parse_kvp_id(id) else {
continue;
};
let text = normalize_ws(&el.text().collect::<String>());
if text.is_empty() {
continue;
}
let field = fields.entry(n).or_default();
match kind {
KvpKind::Marker => field.marker.get_or_insert(text),
KvpKind::Key => field.key.get_or_insert(text),
KvpKind::Value => field.value.get_or_insert(text),
};
}
if fields.is_empty() {
return None;
}
Some(fields.into_values().collect())
}
enum KvpKind {
Marker,
Key,
Value,
}
fn parse_kvp_id(id: &str) -> Option<(u32, KvpKind)> {
let rest = id.strip_prefix("key")?;
if let Ok(n) = rest.parse::<u32>() {
return Some((n, KvpKind::Key));
}
let (num, suffix) = rest.split_once('_')?;
let n = num.parse::<u32>().ok()?;
if suffix == "marker" {
Some((n, KvpKind::Marker))
} else if suffix
.strip_prefix("value")
.is_some_and(|m| m.parse::<u32>().is_ok())
{
Some((n, KvpKind::Value))
} else {
None
}
}
fn walk_list(list: ElementRef, ordered: bool, nodes: &mut Vec<Node>, level: u8, base: Fmt) {
let start = list
.value()
.attr("start")
.and_then(|s| s.trim().parse().ok())
.filter(|_| ordered);
let has_start = start.is_some();
let mut number = start.unwrap_or(1);
let mut first = level == 0;
for child in list.children() {
let Some(li) = ElementRef::wrap(child) else {
continue;
};
if li.value().name() != "li" {
continue;
}
let mut runs = RunBuf::default();
collect_li_inline(li, base, &mut runs);
let mut text = finalize(&runs.md);
let mut nested: Vec<(&str, ElementRef)> = Vec::new();
append_li_blocks(li, &mut text, &mut nested);
if !text.is_empty() {
nodes.push(Node::ListItem {
ordered,
number,
first_in_list: std::mem::take(&mut first),
text,
level,
marker: has_start.then(|| format!("{number}.")),
location: None,
dclx: None,
href: first_href(li),
layer: None,
});
}
number += 1;
for (kind, el) in nested {
match kind {
"ol" => walk_list(el, true, nodes, level + 1, base),
"dl" => walk_dl(el, nodes, level, base),
_ => walk_list(el, false, nodes, level + 1, base),
}
}
}
}
fn collect_li_inline(li: ElementRef, base: Fmt, runs: &mut RunBuf) {
for child in li.children() {
match child.value() {
HtmlNode::Text(t) => {
let run = normalize_ws(t);
if !run.is_empty() {
runs.md.push(serialize_run(&run, base, None));
runs.push_rich(base.to_inline_run(&run));
}
}
HtmlNode::Element(e) => {
let Some(cref) = ElementRef::wrap(child) else {
continue;
};
if is_hidden(e) {
continue;
}
match e.name() {
"ul" | "ol" | "dl" | "img" => {} "p" | "div" | "section" | "article" | "blockquote" => {
collect_li_inline(cref, base, runs)
}
_ => collect_element(cref, base, None, runs),
}
}
_ => {}
}
}
}
fn append_li_blocks<'a>(
elem: ElementRef<'a>,
text: &mut String,
nested: &mut Vec<(&'a str, ElementRef<'a>)>,
) {
fn fold_img(text: &mut String, img: ElementRef) {
text.push('\n');
if let Some(alt) = img.value().attr("alt").filter(|a| !a.is_empty()) {
text.push_str(&normalize_ws(alt));
text.push('\n');
}
text.push_str("<!-- image -->");
}
for child in elem.children().filter_map(ElementRef::wrap) {
let e = child.value();
if is_hidden(e) {
continue;
}
match e.name() {
"img" => fold_img(text, child),
"ul" => nested.push(("ul", child)),
"ol" => nested.push(("ol", child)),
"dl" => nested.push(("dl", child)),
"p" | "div" | "section" | "blockquote" => append_li_blocks(child, text, nested),
_ => {
if has_descendant(child, "img") {
for img in child.select(cached_selector!("img")) {
fold_img(text, img);
}
}
}
}
}
}
fn walk_dl(dl: ElementRef, nodes: &mut Vec<Node>, level: u8, base: Fmt) {
let bold = Fmt { bold: true, ..base };
for child in dl.children() {
let Some(c) = ElementRef::wrap(child) else {
continue;
};
match c.value().name() {
"dt" => {
let text = render_inline_fmt(c, bold);
if !text.is_empty() {
nodes.push(Node::ListItem {
ordered: false,
number: 1,
first_in_list: false,
text,
level,
marker: None,
location: None,
dclx: None,
href: first_href(c),
layer: None,
});
}
}
"dd" => walk_dd(c, nodes, level + 1, base),
_ => {}
}
}
}
fn walk_dd(dd: ElementRef, nodes: &mut Vec<Node>, level: u8, base: Fmt) {
let mut runs = RunBuf::default();
let mut nested: Vec<(&str, ElementRef)> = Vec::new();
for child in dd.children() {
match child.value() {
HtmlNode::Text(t) => {
let run = normalize_ws(t);
if !run.is_empty() {
runs.md.push(serialize_run(&run, base, None));
runs.push_rich(base.to_inline_run(&run));
}
}
HtmlNode::Element(e) => {
let Some(cref) = ElementRef::wrap(child) else {
continue;
};
match e.name() {
"dl" | "ul" | "ol" => nested.push((e.name(), cref)),
_ => collect_element(cref, base, None, &mut runs),
}
}
_ => {}
}
}
let text = finalize(&runs.md);
if !text.is_empty() {
nodes.push(Node::ListItem {
ordered: false,
number: 1,
first_in_list: false,
text,
level,
marker: None,
location: None,
dclx: None,
href: first_href(dd),
layer: None,
});
}
for (kind, el) in nested {
match kind {
"dl" => walk_dl(el, nodes, level, base),
"ol" => walk_list(el, true, nodes, level, base),
_ => walk_list(el, false, nodes, level, base),
}
}
}
#[derive(Clone, Copy, Default)]
struct Fmt {
bold: bool,
italic: bool,
strike: bool,
code: bool,
underline: bool,
script: Script,
raw: bool,
}
impl Fmt {
fn to_inline_run(self, text: &str) -> InlineRun {
InlineRun {
text: text.to_string(),
bold: self.bold,
italic: self.italic,
underline: self.underline,
strike: self.strike,
script: self.script,
code: self.code,
formula: false,
}
}
}
#[derive(Default)]
struct RunBuf {
md: Vec<String>,
rich: Vec<InlineRun>,
merge_next: bool,
prev_link: bool,
}
impl RunBuf {
fn push_rich(&mut self, run: InlineRun) {
if self.merge_next {
self.merge_next = false;
if !self.prev_link {
if let Some(last) = self.rich.last_mut() {
if same_style(last, &run) {
last.text.push('\n');
last.text.push_str(&run.text);
return;
}
}
}
self.drop_single_sentinel();
}
self.rich.push(run);
self.prev_link = false;
}
fn push_rich_link(&mut self, run: InlineRun) {
if self.merge_next {
self.merge_next = false;
self.drop_single_sentinel();
}
self.rich.push(run);
self.prev_link = true;
}
fn drop_single_sentinel(&mut self) {
let n = self.md.len();
if n >= 2 && self.md[n - 2] == BR_SENTINEL && (n < 3 || self.md[n - 3] != BR_SENTINEL) {
self.md.remove(n - 2);
}
}
}
fn same_style(a: &InlineRun, b: &InlineRun) -> bool {
a.bold == b.bold
&& a.italic == b.italic
&& a.underline == b.underline
&& a.strike == b.strike
&& a.script == b.script
&& a.code == b.code
}
fn render_inline_fmt(elem: ElementRef, base: Fmt) -> String {
let mut runs = RunBuf::default();
collect_runs(elem, base, None, &mut runs);
finalize(&runs.md)
}
fn render_inline(elem: ElementRef, base: Fmt) -> (String, Vec<InlineRun>) {
let mut runs = RunBuf::default();
collect_runs(elem, base, None, &mut runs);
(finalize(&runs.md), runs.rich)
}
const BR_SENTINEL: &str = "\u{e000}";
fn finalize(runs: &[String]) -> String {
let joined = runs.join(" ");
if !joined.contains(BR_SENTINEL) {
return joined;
}
let nl = joined.replace(BR_SENTINEL, "\n");
let segments: Vec<&str> = nl.split('\n').collect();
let last = segments.len() - 1;
let mut out = String::with_capacity(nl.len());
for (i, seg) in segments.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(match (i, i == last) {
(0, _) => seg.trim_end(),
(_, true) => seg.trim_start(),
_ => seg.trim(),
});
}
out.trim_matches('\n').to_string()
}
fn collect_runs(elem: ElementRef, fmt: Fmt, hyperlink: Option<&str>, runs: &mut RunBuf) {
for child in elem.children() {
match child.value() {
HtmlNode::Text(text) => {
let normalized = normalize_ws(text);
if !normalized.is_empty() {
runs.md.push(serialize_run(&normalized, fmt, hyperlink));
if hyperlink.is_some() {
runs.push_rich_link(fmt.to_inline_run(&normalized));
} else {
runs.push_rich(fmt.to_inline_run(&normalized));
}
}
}
HtmlNode::Element(_) => {
if let Some(cref) = ElementRef::wrap(child) {
collect_element(cref, fmt, hyperlink, runs);
}
}
_ => {}
}
}
}
fn collect_element(elem: ElementRef, fmt: Fmt, hyperlink: Option<&str>, runs: &mut RunBuf) {
let e = elem.value();
if is_hidden(e) {
return;
}
match e.name() {
"b" | "strong" => collect_runs(elem, Fmt { bold: true, ..fmt }, hyperlink, runs),
"i" | "em" | "var" => collect_runs(
elem,
Fmt {
italic: true,
..fmt
},
hyperlink,
runs,
),
"s" | "del" | "strike" => collect_runs(
elem,
Fmt {
strike: true,
..fmt
},
hyperlink,
runs,
),
"code" | "kbd" | "samp" => collect_runs(elem, Fmt { code: true, ..fmt }, hyperlink, runs),
"u" | "ins" => collect_runs(
elem,
Fmt {
underline: true,
..fmt
},
hyperlink,
runs,
),
"sub" => collect_runs(
elem,
Fmt {
script: Script::Sub,
..fmt
},
hyperlink,
runs,
),
"sup" => collect_runs(
elem,
Fmt {
script: Script::Super,
..fmt
},
hyperlink,
runs,
),
"br" => {
runs.md.push(BR_SENTINEL.to_string());
runs.merge_next = true;
}
"a" => {
let href = e.attr("href").map(normalize_url);
let link = href.as_deref().or(hyperlink);
let mut inner = RunBuf::default();
collect_runs(elem, fmt, None, &mut inner);
if inner.rich.len() > 1 {
let joined = inner
.rich
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let uniform = inner.rich.windows(2).all(|w| same_style(&w[0], &w[1]));
let run_fmt = if uniform {
let r = &inner.rich[0];
Fmt {
bold: r.bold,
italic: r.italic,
strike: r.strike,
code: r.code,
underline: r.underline,
script: r.script,
..fmt
}
} else {
fmt
};
runs.md.push(serialize_run(&joined, run_fmt, link));
if link.is_some() {
runs.push_rich_link(run_fmt.to_inline_run(&joined));
} else {
runs.push_rich(run_fmt.to_inline_run(&joined));
}
} else {
collect_runs(elem, fmt, link, runs);
}
}
"img" => {}
"script" | "style" => {}
_ => collect_runs(elem, fmt, hyperlink, runs),
}
}
fn normalize_url(href: &str) -> String {
if let Some(rest) = href
.strip_prefix("https://")
.or_else(|| href.strip_prefix("http://"))
{
if !rest.is_empty() && !rest.contains('/') {
return format!("{href}/");
}
}
href.to_string()
}
fn serialize_run(text: &str, fmt: Fmt, hyperlink: Option<&str>) -> String {
let mut res = if fmt.code {
format!("`{text}`")
} else if fmt.raw {
text.to_string()
} else {
super::markdown::escape_html(&super::markdown::escape_underscores(text))
};
if fmt.bold {
res = format!("**{res}**");
}
if fmt.italic {
res = format!("*{res}*");
}
if fmt.strike {
res = format!("~~{res}~~");
}
if let Some(href) = hyperlink {
res = format!("[{res}]({href})");
}
res
}
fn extract_pre(pre: ElementRef) -> (Option<String>, String) {
let mut language = pre
.select(cached_selector!("code"))
.next()
.and_then(|code| code.value().attr("class").map(str::to_string))
.and_then(|c| lang_from_class(&c));
if language.is_none() {
language = pre.value().attr("class").and_then(lang_from_class);
}
let text = pre.text().collect::<String>();
(language, text.trim_matches('\n').to_string())
}
fn lang_from_class(class: &str) -> Option<String> {
class.split_whitespace().find_map(|c| {
c.strip_prefix("language-")
.or_else(|| c.strip_prefix("lang-"))
.map(str::to_string)
})
}
fn parse_table(table: ElementRef) -> Option<Table> {
parse_table_cells(table, |cell| (render_cell(cell), is_rich_cell(cell)))
}
fn flatten_nested_table(table: ElementRef) -> String {
parse_table_cells(table, |cell| {
let mut out = String::new();
subtree_text(cell, &mut out);
(out.trim().to_string(), false)
})
.map(|t| {
t.rows
.iter()
.flatten()
.filter(|c| !c.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
fn subtree_text(elem: ElementRef, out: &mut String) {
for child in elem.children() {
match child.value() {
HtmlNode::Text(t) => {
let t: &str = t;
if t.chars().all(|c| c.is_ascii_whitespace()) {
out.push(if t.contains('\n') { '\n' } else { ' ' });
} else {
for c in t.chars() {
match c {
'\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{00ad}' | '\u{feff}'
| '\u{2060}' => {}
'\u{00a0}' | '\u{202f}' => out.push(' '),
'\u{2010}'..='\u{2015}' => out.push('-'),
'\u{2018}' | '\u{2019}' => out.push('\''),
'\u{201c}' | '\u{201d}' => out.push('"'),
'\u{2026}' => out.push_str("..."),
_ => out.push(c),
}
}
}
}
HtmlNode::Element(e) => {
if is_skipped(e.name()) || is_hidden(e) {
continue;
}
if e.name() == "br" {
out.push('\n');
continue;
}
if let Some(cref) = ElementRef::wrap(child) {
subtree_text(cref, out);
if matches!(e.name(), "p" | "li" | "th" | "td") {
out.push(' ');
}
}
}
_ => {}
}
}
}
fn parse_table_cells(
table: ElementRef,
render_cell: impl Fn(ElementRef) -> (String, bool),
) -> Option<Table> {
let mut trs: Vec<ElementRef> = Vec::new();
for child in table.children().filter_map(ElementRef::wrap) {
match child.value().name() {
"tr" => trs.push(child),
"thead" | "tbody" | "tfoot" => {
trs.extend(
child
.children()
.filter_map(ElementRef::wrap)
.filter(|c| c.value().name() == "tr"),
);
}
_ => {}
}
}
let (mut num_rows, mut num_cols) = (0usize, 0usize);
for tr in &trs {
let cells = row_cells(*tr);
let col_count: usize = cells.iter().map(|c| span_attr(*c, "colspan")).sum();
num_cols = num_cols.max(col_count);
if !is_row_header(&cells) {
num_rows += 1;
}
}
if num_rows == 0 || num_cols == 0 {
return None;
}
let mut grid: Vec<Vec<Option<String>>> = vec![vec![None; num_cols]; num_rows];
let mut th_grid: Vec<Vec<bool>> = vec![vec![false; num_cols]; num_rows];
let mut row_idx: isize = -1;
let mut start_row_span: usize = 0;
for tr in &trs {
let cells = row_cells(*tr);
let row_header = is_row_header(&cells);
if row_header {
start_row_span += 1;
} else {
row_idx += 1;
start_row_span = 0;
}
let base = (row_idx + start_row_span as isize).max(0) as usize;
let all_th = cells.iter().all(|c| c.value().name() == "th");
let mut col = 0;
for cell in cells {
let colspan = span_attr(cell, "colspan");
let mut rowspan = span_attr(cell, "rowspan");
if row_header {
rowspan = rowspan.saturating_sub(1);
}
while col < num_cols && base < num_rows && grid[base][col].is_some() {
col += 1;
}
let (text, rich) = render_cell(cell);
let is_th = cell.value().name() == "th" && all_th;
let mut anchor_filled = false;
for r in start_row_span..start_row_span + rowspan {
let gr = (row_idx + r as isize).max(0) as usize;
for dc in 0..colspan {
let gc = col + dc;
if gr < num_rows && gc < num_cols {
grid[gr][gc] = Some(if rich && anchor_filled {
String::new()
} else {
text.clone()
});
anchor_filled = true;
th_grid[gr][gc] = is_th;
}
}
}
col += colspan;
}
}
let rows: Vec<Vec<String>> = grid
.into_iter()
.map(|row| row.into_iter().map(Option::unwrap_or_default).collect())
.collect();
(!rows.is_empty()).then_some(Table {
rows,
location: None,
structure: Some(docling_core::TableStructure {
col_header: th_grid,
..Default::default()
}),
cell_blocks: None,
})
}
fn row_cells(tr: ElementRef) -> Vec<ElementRef> {
tr.children()
.filter_map(ElementRef::wrap)
.filter(|c| matches!(c.value().name(), "td" | "th"))
.collect()
}
fn is_row_header(cells: &[ElementRef]) -> bool {
!cells.is_empty()
&& cells
.iter()
.all(|c| c.value().name() == "th" && span_attr(*c, "rowspan") > 1)
}
fn span_attr(cell: ElementRef, name: &str) -> usize {
cell.value()
.attr(name)
.and_then(|v| v.trim().parse().ok())
.filter(|&n| n >= 1)
.unwrap_or(1)
}
fn render_cell(cell: ElementRef) -> String {
let raw = Fmt {
raw: true,
..Fmt::default()
};
if is_rich_cell(cell) {
let mut nodes: Vec<Node> = Vec::new();
walk_block(cell, &mut nodes, 0, raw, &NoFetch);
let mut doc = DoclingDocument::new("");
doc.nodes = nodes;
doc.export_to_markdown().trim().to_string()
} else {
render_inline_fmt(cell, raw)
}
}
fn is_rich_cell(cell: ElementRef) -> bool {
if has_descendant(cell, "br") {
return true;
}
let direct_blocks = cell
.children()
.filter_map(ElementRef::wrap)
.filter(|c| matches!(c.value().name(), "p" | "div" | "li"))
.count();
if direct_blocks > 1 {
return true;
}
let (runs, markup) = cell_richness(cell);
match runs {
0 => has_descendant(cell, "img") || has_descendant(cell, "input"),
1 => markup,
_ => true,
}
}
fn lone_code(p: ElementRef) -> Option<String> {
let mut code: Option<ElementRef> = None;
for child in p.children() {
match child.value() {
HtmlNode::Text(t) => {
if !t.trim().is_empty() {
return None;
}
}
HtmlNode::Element(e) => {
if !matches!(e.name(), "code" | "kbd" | "samp") || code.is_some() {
return None;
}
code = ElementRef::wrap(child);
}
_ => {}
}
}
code.map(|c| c.text().collect::<String>())
}
fn image_wrapper(elem: ElementRef) -> Option<(Option<String>, Option<String>)> {
let mut imgs = elem.select(cached_selector!("img"));
let img = imgs.next()?;
if imgs.next().is_some() || !elem.text().collect::<String>().trim().is_empty() {
return None;
}
let caption = img
.value()
.attr("alt")
.filter(|a| !a.is_empty())
.map(str::to_string);
let src = img_src(img.value());
Some((caption, src))
}
fn figure_img_src(fig: ElementRef) -> Option<String> {
fig.select(cached_selector!("img"))
.next()
.and_then(|img| img_src(img.value()))
}
fn img_src(el: &scraper::node::Element) -> Option<String> {
let attr = |k: &str| el.attr(k).map(str::trim).filter(|s| !s.is_empty());
if let Some(s) = attr("src") {
if !s.starts_with("data:") {
return Some(s.to_string());
}
}
for k in ["data-src", "data-original", "data-lazy-src", "data-lazy"] {
if let Some(s) = attr(k) {
return Some(s.to_string());
}
}
for k in ["srcset", "data-srcset"] {
if let Some(s) = attr(k) {
if let Some(u) = s
.split(',')
.next()
.and_then(|c| c.split_whitespace().next())
.filter(|u| !u.is_empty())
{
return Some(u.to_string());
}
}
}
attr("src").map(str::to_string)
}
fn has_descendant(elem: ElementRef, name: &str) -> bool {
let sel = match name {
"br" => cached_selector!("br"),
"img" => cached_selector!("img"),
"input" => cached_selector!("input"),
_ => return Selector::parse(name).is_ok_and(|s| elem.select(&s).next().is_some()),
};
elem.select(sel).next().is_some()
}
fn cell_richness(cell: ElementRef) -> (usize, bool) {
fn walk(elem: ElementRef, marked: bool, count: &mut usize, markup: &mut bool) {
for child in elem.children() {
match child.value() {
HtmlNode::Text(t) => {
if !normalize_ws(t).is_empty() {
*count += 1;
if marked {
*markup = true;
}
}
}
HtmlNode::Element(e) => {
let Some(cref) = ElementRef::wrap(child) else {
continue;
};
let marks = matches!(
e.name(),
"b" | "strong"
| "i"
| "em"
| "var"
| "s"
| "del"
| "strike"
| "code"
| "kbd"
| "samp"
| "a"
);
walk(cref, marked || marks, count, markup);
}
_ => {}
}
}
}
let mut count = 0;
let mut markup = false;
walk(cell, false, &mut count, &mut markup);
(count, markup)
}
fn figure_caption(fig: ElementRef) -> Option<String> {
if let Some(cap) = fig.select(cached_selector!("figcaption")).next() {
let mut parts: Vec<String> = Vec::new();
for t in cap.text() {
let frag = normalize_ws(t);
if !frag.is_empty() {
parts.push(frag);
}
}
let text = parts.join(" ");
if !text.is_empty() {
return Some(text);
}
}
fig.select(cached_selector!("img"))
.next()
.and_then(|img| img.value().attr("alt"))
.filter(|a| !a.is_empty())
.map(str::to_string)
}
fn normalize_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut pending_space = false;
for ch in s.chars() {
match ch {
'\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{00ad}' | '\u{feff}' | '\u{2060}' => {}
c if c.is_whitespace() => {
pending_space = !out.is_empty();
}
c => {
if pending_space {
out.push(' ');
pending_space = false;
}
match c {
'\u{2010}'..='\u{2015}' => out.push('-'), '\u{2018}' | '\u{2019}' => out.push('\''), '\u{201c}' | '\u{201d}' => out.push('"'), '\u{2026}' => out.push_str("..."), _ => out.push(c),
}
}
}
}
out
}
fn checkbox_label_text(input: ElementRef) -> String {
let mut texts: Vec<String> = Vec::new();
if let Some(id) = input.value().attr("id").filter(|i| !i.is_empty()) {
let root = root_of(input);
for label in root.select(cached_selector!("label")) {
if label.value().attr("for") == Some(id) {
let t = normalize_ws(&label.text().collect::<String>());
if !t.is_empty() {
texts.push(t);
}
}
}
}
if texts.is_empty() {
let mut cur = input.parent();
while let Some(node) = cur {
if let Some(el) = ElementRef::wrap(node) {
if el.value().name() == "label" {
let t = normalize_ws(&el.text().collect::<String>());
if !t.is_empty() {
texts.push(t);
}
break;
}
}
cur = node.parent();
}
}
if texts.is_empty() {
if let Some(aria) = input.value().attr("aria-label") {
let t = normalize_ws(aria);
if !t.is_empty() {
texts.push(t);
}
}
}
texts.join(" ")
}
fn label_feeds_checkbox(label: ElementRef) -> bool {
let is_checkbox = |el: ElementRef| {
el.value().name() == "input"
&& matches!(
el.value()
.attr("type")
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"checkbox" | "radio"
)
};
if let Some(target) = label.value().attr("for").filter(|f| !f.is_empty()) {
let root = root_of(label);
for input in root.select(cached_selector!("input")) {
if input.value().attr("id") == Some(target) {
return is_checkbox(input);
}
}
return false;
}
label.select(cached_selector!("input")).any(is_checkbox)
}
fn root_of(el: ElementRef) -> ElementRef {
let mut cur = el;
while let Some(parent) = cur.parent().and_then(ElementRef::wrap) {
cur = parent;
}
cur
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
fn convert(html: &str) -> DoclingDocument {
let src = SourceDocument::from_bytes("t", InputFormat::Html, html.as_bytes().to_vec());
HtmlBackend.convert(&src).unwrap()
}
#[test]
fn deeply_nested_html_does_not_overflow_the_stack() {
std::env::set_var("DOCLING_RS_MAX_HTML_DEPTH", "200");
let depth = 4_000;
let html = format!(
"<html><body>{}<p>deep text</p>{}</body></html>",
"<div>".repeat(depth),
"</div>".repeat(depth),
);
let doc = convert(&html); std::env::remove_var("DOCLING_RS_MAX_HTML_DEPTH");
assert!(
doc.export_to_markdown().contains("deep text"),
"flattened fallback should preserve the text content"
);
}
#[test]
fn shallow_html_still_walks_structurally() {
let doc = convert("<h1>Title</h1><ul><li>a</li><li>b</li></ul>");
let md = doc.export_to_markdown();
assert!(md.contains("# Title"));
assert!(md.contains("- a"));
}
#[test]
fn headings_paragraphs_and_inline_formatting() {
let doc = convert(
"<h1>Title</h1><p>Hello <strong>bold</strong> and <em>italic</em> and \
<a href=\"https://x.com\">link</a>.</p>",
);
assert_eq!(
doc.export_to_markdown(),
"# Title\n\nHello **bold** and *italic* and [link](https://x.com/) .\n"
);
}
#[test]
fn nested_lists() {
let doc = convert("<ul><li>one<ul><li>one-a</li></ul></li><li>two</li></ul>");
assert_eq!(doc.export_to_markdown(), "- one\n - one-a\n- two\n");
}
#[test]
fn inline_images_produce_no_marker_but_anchor_wrapped_images_stay_pictures() {
let inline = convert("<p>before <img src=\"x.png\" alt=\"logo\"> after</p>");
assert_eq!(inline.export_to_markdown(), "before after\n");
let span = convert("<span><img src=\"x.png\" alt=\"logo\"></span><h2>Home</h2>");
assert_eq!(span.export_to_markdown(), "## Home\n");
let anchor = convert("<a href=\"/l\"><img src=\"x.png\" alt=\"cap\"></a>");
assert_eq!(anchor.export_to_markdown(), "cap\n\n<!-- image -->\n");
}
#[test]
fn hidden_inline_styles_are_suppressed_but_aria_hidden_is_kept() {
let hidden = convert(
"<p>keep</p>\
<p style=\"display:none\">gone</p>\
<p style=\"visibility: hidden\">gone2</p>\
<p hidden>gone3</p>",
);
assert_eq!(hidden.export_to_markdown(), "keep\n");
let aria = convert("<p aria-hidden=\"true\">still shown</p>");
assert_eq!(aria.export_to_markdown(), "still shown\n");
}
#[test]
fn nested_table_flattens_with_docling_spacing() {
let doc = convert(
"<table><tr><td><table><tr><td>P</td><td>Q</td></tr>\n\
<tr><td>R</td><td><table><tr><td>a</td></tr>\n\
<tr><td>b</td></tr></table></td></tr></table></td><td>Z</td></tr></table>",
);
let table = doc
.nodes
.iter()
.find_map(|n| match n {
Node::Table(t) => Some(t),
_ => None,
})
.expect("outer table parsed");
assert_eq!(table.rows[0][0], "P Q R a \nb");
assert_eq!(table.rows[0][1], "Z");
assert!(
doc.export_to_markdown().contains("P Q R a b"),
"newline flattened to space in markdown: {}",
doc.export_to_markdown()
);
}
#[test]
fn form_region_becomes_key_value_fields() {
let doc = convert(
"<div class=\"form_region\">\
<div class=\"field\">\
<div id=\"key1_marker\">1</div>\
<span id=\"key1\">Restaurant</span>\
<span id=\"key1_value1\">Docling</span>\
</div>\
<div class=\"field\">\
<div id=\"key2_marker\">2</div>\
<span id=\"key2\">Telephone</span>\
<span id=\"key2_value1\">123</span>\
</div>\
</div>",
);
assert_eq!(
doc.export_to_markdown(),
"<!-- missing-text -->\n\n\
<!-- missing-text -->\n\n1\n\nRestaurant\n\nDocling\n\n\
<!-- missing-text -->\n\n2\n\nTelephone\n\n123\n",
);
let plain = convert("<div class=\"form_region\"><p>just text</p></div>");
assert_eq!(plain.export_to_markdown(), "just text\n");
}
#[test]
fn ordered_list_is_numbered_sequentially() {
let doc = convert("<ol><li>first</li><li>second</li></ol>");
assert_eq!(doc.export_to_markdown(), "1. first\n2. second\n");
}
#[test]
fn block_image_becomes_picture() {
let doc = convert("<img src=\"x.png\" alt=\"A cat\"/>");
assert_eq!(doc.export_to_markdown(), "A cat\n\n<!-- image -->\n");
}
#[test]
fn table_with_header() {
let doc = convert(
"<table><thead><tr><th>Name</th><th>Age</th></tr></thead>\
<tbody><tr><td>Ada</td><td>36</td></tr></tbody></table>",
);
assert_eq!(
doc.export_to_markdown(),
"| Name | Age |\n|--------|-------|\n| Ada | 36 |\n"
);
}
#[test]
fn code_block_with_language() {
let doc = convert("<pre><code class=\"language-rust\">let x = 1;</code></pre>");
assert_eq!(
doc.nodes,
vec![Node::Code {
language: Some("rust".into()),
text: "let x = 1;".into(),
orig: None,
}]
);
}
#[test]
fn skips_script_and_style() {
let doc = convert("<style>.a{}</style><p>visible</p><script>x()</script>");
assert_eq!(doc.export_to_markdown(), "visible\n");
}
fn tiny_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([1, 2, 3]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn image_is_placeholder_by_default_but_extracted_with_a_resolver() {
use crate::backend::images::FsImageResolver;
use docling_core::base64::encode;
let uri = format!("data:image/png;base64,{}", encode(&tiny_png(2, 3)));
let html = format!("<img src=\"{uri}\" alt=\"k\"/>");
let plain = convert(&html);
assert!(matches!(plain.nodes[0], Node::Picture { image: None, .. }));
let doc = convert_html("t", &html, &FsImageResolver::new(None, None));
match &doc.nodes[0] {
Node::Picture {
image: Some(img),
caption,
..
} => {
assert_eq!(caption.as_deref(), Some("k"));
assert_eq!(img.mimetype, "image/png");
assert_eq!((img.width, img.height), (2, 3));
}
other => panic!("expected an embedded image, got {other:?}"),
}
}
#[test]
fn lazy_loaded_img_src_falls_back_to_data_src_and_srcset() {
use super::img_src;
use scraper::Html;
let pick = |html: &str| {
let frag = Html::parse_fragment(html);
let img = frag.select(cached_selector!("img")).next().unwrap();
img_src(img.value())
};
assert_eq!(pick(r#"<img src="/a.png">"#).as_deref(), Some("/a.png"));
assert_eq!(
pick(r#"<img data-src="/b.png" class="lazyload">"#).as_deref(),
Some("/b.png")
);
assert_eq!(
pick(r#"<img src="data:image/gif;base64,R0lGOD" data-src="/c.png">"#).as_deref(),
Some("/c.png")
);
assert_eq!(
pick(r#"<img srcset="/d-1x.png 1x, /d-2x.png 2x">"#).as_deref(),
Some("/d-1x.png")
);
}
}