use std::borrow::Cow;
use roxmltree::{Document, Node as XmlNode, ParsingOptions};
use crate::backend::markdown::escape_text;
use crate::backend::uspto_entities::NAMED_ENTITIES;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node};
pub struct UsptoBackend;
impl DeclarativeBackend for UsptoBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let raw = source.text()?;
let xml = resolve_named_entities(raw);
let opts = ParsingOptions {
allow_dtd: true,
..Default::default()
};
let dom = Document::parse_with_options(&xml, opts)
.map_err(|e| ConversionError::Parse(format!("uspto: {e}")))?;
let mut doc = DoclingDocument::new(&source.name);
if let Some(title) = dom
.descendants()
.find(|n| n.has_tag_name("invention-title"))
.map(node_text)
.filter(|s| !s.is_empty())
{
doc.push(Node::Heading {
level: 1,
text: escape_text(&title),
});
}
if let Some(abs) = dom.descendants().find(|n| n.has_tag_name("abstract")) {
let paras = paragraphs(abs);
if !paras.is_empty() {
doc.push(Node::Heading {
level: 3,
text: "ABSTRACT".into(),
});
doc.push(Node::Paragraph {
text: escape_text(¶s.join(" ")),
});
}
}
if let Some(desc) = dom.descendants().find(|n| n.has_tag_name("description")) {
walk_description(desc, &mut doc);
}
if let Some(claims) = dom.descendants().find(|n| n.has_tag_name("claims")) {
doc.push(Node::Heading {
level: 3,
text: "CLAIMS".into(),
});
for claim in claims.children().filter(|c| c.has_tag_name("claim")) {
for ct in claim.children().filter(|c| c.has_tag_name("claim-text")) {
let t = node_text(ct);
if !t.is_empty() {
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
}
}
Ok(doc)
}
}
fn walk_description(node: XmlNode, doc: &mut DoclingDocument) {
for child in node.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"heading" => {
let level = child
.attribute("level")
.and_then(|v| v.parse::<u8>().ok())
.unwrap_or(1);
let t = node_text(child);
if !t.is_empty() {
doc.push(Node::Heading {
level: level + 2,
text: escape_text(&t),
});
}
}
"p" => {
let t = node_text(child);
if !t.is_empty() {
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
"maths" | "tables" | "table" => {}
_ => walk_description(child, doc),
}
}
}
fn paragraphs(node: XmlNode) -> Vec<String> {
node.descendants()
.filter(|n| n.has_tag_name("p"))
.map(node_text)
.filter(|s| !s.is_empty())
.collect()
}
fn node_text(node: XmlNode) -> String {
let mut s = String::new();
raw_text(node, &mut s);
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn raw_text(node: XmlNode, out: &mut String) {
for child in node.children() {
if child.is_text() {
if let Some(t) = child.text() {
out.push_str(&t.replace('\n', " "));
}
} else if child.is_element() {
match child.tag_name().name() {
"sup" | "sub" => {
let mut inner = String::new();
raw_text(child, &mut inner);
let sup = child.tag_name().name() == "sup";
out.extend(inner.chars().map(|c| script_char(c, sup)));
}
"maths" => {}
_ => raw_text(child, out),
}
}
}
}
fn script_char(c: char, sup: bool) -> char {
if sup {
match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'+' => '⁺',
'-' | '−' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'a' => 'ª',
'o' => 'º',
'i' => 'ⁱ',
'n' => 'ⁿ',
_ => c,
}
} else {
match c {
'0' => '₀',
'1' => '₁',
'2' => '₂',
'3' => '₃',
'4' => '₄',
'5' => '₅',
'6' => '₆',
'7' => '₇',
'8' => '₈',
'9' => '₉',
'+' => '₊',
'-' | '−' => '₋',
'=' => '₌',
'(' => '₍',
')' => '₎',
'a' => 'ₐ',
'e' => 'ₑ',
'o' => 'ₒ',
'x' => 'ₓ',
_ => c,
}
}
}
fn resolve_named_entities(xml: &str) -> Cow<'_, str> {
if !xml.contains('&') {
return Cow::Borrowed(xml);
}
let mut out = String::with_capacity(xml.len());
let mut i = 0;
while let Some(rel) = xml[i..].find('&') {
let amp = i + rel;
out.push_str(&xml[i..amp]);
let end = xml[amp + 1..]
.char_indices()
.take(64)
.find(|&(_, c)| c == ';')
.map(|(off, _)| amp + 1 + off);
let Some(semi) = end else {
out.push('&');
i = amp + 1;
continue;
};
let name = &xml[amp + 1..semi];
i = semi + 1;
if name.starts_with('#') || matches!(name, "amp" | "lt" | "gt" | "quot" | "apos") {
out.push('&');
out.push_str(name);
out.push(';');
continue;
}
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric()) {
continue;
}
if let Ok(idx) = NAMED_ENTITIES.binary_search_by(|&(n, _)| n.cmp(name)) {
push_xml_escaped(&mut out, NAMED_ENTITIES[idx].1);
}
}
out.push_str(&xml[i..]);
Cow::Owned(out)
}
fn push_xml_escaped(out: &mut String, s: &str) {
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
#[test]
fn resolves_iso_and_html_entities_and_drops_unknown() {
assert_eq!(resolve_named_entities("a™b"), "a\u{2122}b");
assert_eq!(resolve_named_entities("x&agr;y"), "x\u{3b1}y"); assert_eq!(resolve_named_entities("p[q]"), "p[q]");
assert_eq!(
resolve_named_entities("keep & and A"),
"keep & and A"
);
assert_eq!(resolve_named_entities("drop&zzznope;it"), "dropit");
assert_eq!(resolve_named_entities("amp&ersand"), "amp&ersand");
assert_eq!(resolve_named_entities("g&US001.TIF;h"), "gh");
assert_eq!(
resolve_named_entities("no entities here"),
"no entities here"
);
}
#[test]
fn title_abstract_headings_and_scripts() {
let xml = r#"<us-patent-application>
<us-bibliographic-data-application>
<invention-title>A Device</invention-title>
</us-bibliographic-data-application>
<abstract><p>An H<sub>2</sub>O cell at 10<sup>-3</sup>.</p></abstract>
<description>
<heading level="1">BACKGROUND</heading>
<p>Body of NO<sub>3</sub><sup>-</sup>.</p>
<heading level="2">Detail</heading>
</description>
</us-patent-application>"#;
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.as_bytes().to_vec());
let md = UsptoBackend.convert(&src).unwrap().export_to_markdown();
assert!(
md.starts_with(
"# A Device\n\n### ABSTRACT\n\nAn H₂O cell at 10⁻³.\n\n### BACKGROUND\n\nBody of NO₃⁻.\n\n#### Detail"
),
"got:\n{md}"
);
}
#[test]
fn keeps_text_following_a_processing_instruction() {
let xml = r#"<us-patent-application>
<description>
<p><?in-line-formulae description="In-line Formulae" end="lead"?>R<sup>1</sup>—CO</p>
</description>
</us-patent-application>"#;
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.as_bytes().to_vec());
let md = UsptoBackend.convert(&src).unwrap().export_to_markdown();
assert!(md.contains("R¹—CO"), "got:\n{md}");
}
#[test]
fn abstract_paragraphs_join_into_one_text() {
let xml = r#"<us-patent-application>
<abstract>
<p>The invention relates to compounds of the formula (I)</p>
<p><chemistry><img file="C00001.TIF"/></chemistry></p>
<p>in which X has the meaning given above.</p>
</abstract>
</us-patent-application>"#;
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.as_bytes().to_vec());
let md = UsptoBackend.convert(&src).unwrap().export_to_markdown();
assert!(
md.contains(
"### ABSTRACT\n\nThe invention relates to compounds of the formula (I) in which X has the meaning given above."
),
"got:\n{md}"
);
}
}