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::tree::{ItemTree, TreeKind};
use docling_core::{DoclingDocument, Node, Table, TableCell, TableStructure};
pub fn looks_like_aps(text: &str) -> bool {
text.lines()
.find(|l| !l.trim().is_empty())
.is_some_and(|l| l.trim_end() == "PATN")
}
pub fn convert_aps(source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let raw = source.text()?;
let mut aps = PatentTree::default();
let (mut section, mut key, mut value) = (String::new(), String::new(), String::new());
for line in raw.lines() {
let cols = split_on_double_space(line);
let starts_new = match cols {
(_, None) => true,
(k, Some(_)) => !k.is_empty(),
};
if !key.is_empty() && !value.is_empty() && starts_new {
aps.store_content(§ion, &key, &value);
key.clear();
value.clear();
}
match cols {
(title, None) => {
section = title.to_string();
aps.store_section(§ion);
}
(k, Some(v)) if !k.is_empty() => {
key = k.to_string();
value = v.to_string();
}
(_, Some(v)) if !is_structure_placeholder(v) => {
value.push(' ');
value.push_str(v);
}
_ => {}
}
}
if !key.is_empty() && !value.is_empty() {
aps.store_content(§ion, &key, &value);
}
Ok(aps.finish(&source.name))
}
fn split_on_double_space(line: &str) -> (&str, Option<&str>) {
let mut run_start: Option<usize> = None;
for (i, ch) in line.char_indices() {
if ch.is_whitespace() {
let start = *run_start.get_or_insert(i);
if i > start {
let after = line[i..]
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(line.len(), |(j, _)| i + j);
return (&line[..start], Some(&line[after..]));
}
} else {
run_start = None;
}
}
(line, None)
}
fn is_structure_placeholder(s: &str) -> bool {
s.strip_prefix("##STR")
.and_then(|r| r.strip_suffix("##"))
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
}
const APS_FIELDS: &[&str] = &[
"WKU", "TTL", "PAR", "PA1", "PA2", "PA3", "PAL", "PAC", "NUM", "NAM", "ICL", "ISD", "APD",
"PNO", "APN", "APT", "CNT",
];
struct PatentTree {
tree: ItemTree,
level: i32,
parents: std::collections::BTreeMap<i32, Option<usize>>,
tables_broken: bool,
}
impl Default for PatentTree {
fn default() -> Self {
Self {
tree: ItemTree::default(),
level: 1,
parents: [(1, None)].into_iter().collect(),
tables_broken: false,
}
}
}
impl PatentTree {
fn parent(&self) -> Option<usize> {
self.parents.get(&self.level).copied().flatten()
}
fn add_text(&mut self, label: &str, text: &str, level: Option<u8>) -> usize {
let parent = self.parent();
self.add_text_under(parent, label, text, level)
}
fn add_text_under(
&mut self,
parent: Option<usize>,
label: &str,
text: &str,
level: Option<u8>,
) -> usize {
self.tree.add(
parent,
None,
TreeKind::Text {
label: label.into(),
text: text.into(),
orig: Some(text.into()),
formatting: None,
hyperlink: None,
level,
list: None,
},
)
}
fn add_title(&mut self, text: &str) {
let id = self.add_text("title", text, None);
self.parents.insert(self.level + 1, Some(id));
self.level += 1;
}
fn add_heading(&mut self, text: &str, attr_level: i64) {
let cand = i32::try_from(attr_level.clamp(0, 1 << 20)).unwrap_or(1) + 1;
self.level = if self.parents.contains_key(&cand) {
cand
} else {
*self.parents.keys().next().unwrap_or(&1)
};
let id = self.add_text("section_header", text, Some(self.level.min(255) as u8));
self.parents.insert(self.level + 1, Some(id));
self.level += 1;
}
fn add_section(&mut self, heading: &str, paragraphs: &[String]) {
let lvl = if self.parents.contains_key(&2) { 2 } else { 1 };
let parent = self.parents.get(&lvl).copied().flatten();
let id = self.add_text_under(parent, "section_header", heading, Some(lvl as u8));
for text in paragraphs {
self.add_text_under(Some(id), "paragraph", text, None);
}
}
fn add_paragraph(&mut self, text: &str) {
self.add_text("paragraph", text, None);
}
fn add_table(&mut self, node: XmlNode) {
let (cells, num_rows, num_cols) = if self.tables_broken {
(Vec::new(), 0, 0)
} else {
xml_table_cells(node)
};
let table = super::doclang_tree::table_from_cells(cells, num_rows, num_cols);
let parent = self.parent();
self.tree.add(
parent,
None,
TreeKind::Table {
table,
rich_cells: Vec::new(),
captions: Vec::new(),
},
);
}
fn add_heading_at_base(&mut self, text: &str, base: i32) {
self.level = if self.parents.contains_key(&base) {
base
} else {
1
};
let id = self.add_text("section_header", text, Some(self.level as u8));
self.parents.insert(self.level + 1, Some(id));
self.level += 1;
}
fn last_text_item(&self) -> Option<usize> {
let parent = self.parent()?;
self.tree.items[parent]
.children
.iter()
.rev()
.find(|&&c| matches!(self.tree.items[c].kind, TreeKind::Text { .. }))
.copied()
}
fn append_text(&mut self, id: usize, extra: &str) {
if let TreeKind::Text { text, .. } = &mut self.tree.items[id].kind {
text.push_str(extra);
}
}
fn text_of(&self, id: usize) -> &str {
match &self.tree.items[id].kind {
TreeKind::Text { text, .. } => text,
_ => "",
}
}
fn store_section(&mut self, section: &str) {
let heading = match section {
"ABST" => "ABSTRACT",
"CLMS" => "CLAIMS",
_ => return,
};
self.add_heading_at_base(heading, 2);
}
fn store_content(&mut self, section: &str, field: &str, value: &str) {
if !APS_FIELDS.contains(&field) {
return;
}
let paragraph_field = matches!(field, "PAR" | "PA1" | "PA2" | "PA3");
let body_section = matches!(section, "BSUM" | "DETD" | "DRWD");
if field == "TTL" {
let id = self.add_text("title", value, None);
self.parents.insert(self.level + 1, Some(id));
self.level += 1;
} else if field == "PAL" && section == "ABST" {
match self.last_text_item() {
Some(id) => self.append_text(id, &format!(" {value}")),
None => {
self.add_text("paragraph", value, None);
}
}
} else if field == "NUM" && section == "CLMS" {
self.add_text("paragraph", "", None);
} else if paragraph_field && section == "CLMS" {
let id = self
.last_text_item()
.unwrap_or_else(|| self.add_text("paragraph", "", None));
let extra = if self.text_of(id).is_empty() {
value.trim().to_string()
} else {
format!(" {}", value.trim())
};
self.append_text(id, &extra);
} else if field == "PAC" && body_section {
self.add_heading_at_base(value, 2);
} else if paragraph_field && body_section {
self.add_text("paragraph", value, None);
}
}
fn finish(self, name: &str) -> DoclingDocument {
let mut doc = DoclingDocument::new(name);
for item in &self.tree.items {
let TreeKind::Text {
label, text, level, ..
} = &item.kind
else {
continue;
};
match label.as_str() {
"title" => doc.push(Node::Heading {
level: 1,
text: escape_text(text),
}),
"section_header" => doc.push(Node::Heading {
level: level.unwrap_or(1) + 1,
text: escape_text(text),
}),
_ if !text.is_empty() => doc.push(Node::Paragraph {
text: escape_text(text),
}),
_ => {}
}
}
doc.tree = Some(self.tree);
doc
}
}
pub struct UsptoBackend;
impl DeclarativeBackend for UsptoBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let raw = source.text()?;
super::xml_depth::check(&raw, "uspto")?;
let mut doc = DoclingDocument::new(&source.name);
let tables_stripped = drop_table_entities(&raw);
let xml = resolve_named_entities(&tables_stripped);
let opts = ParsingOptions {
allow_dtd: true,
..Default::default()
};
let dom = Document::parse_with_options(&xml, opts)
.map_err(|e| ConversionError::with_source("uspto", e))?;
let mut tree = PatentTree {
tables_broken: regex_table_count(&raw)
!= dom
.descendants()
.filter(|n| n.has_tag_name("table"))
.count(),
..PatentTree::default()
};
match dom.root_element().tag_name().name() {
"patent-application-publication" => parse_app_v1(&dom, &mut doc, &mut tree),
root if root.eq_ignore_ascii_case("PATDOC") => {
parse_grant_v2(&dom, &mut doc, &mut tree)
}
_ => parse_ice(&dom, &mut doc, &mut tree), }
doc.tree = Some(tree.tree);
Ok(doc)
}
}
fn parse_ice(dom: &Document, doc: &mut DoclingDocument, tree: &mut PatentTree) {
if let Some(title) = dom
.descendants()
.find(|n| n.has_tag_name("invention-title"))
.map(node_text)
.filter(|s| !s.is_empty())
{
tree.add_title(&title);
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(),
});
let joined = paras.join(" ");
tree.add_section("ABSTRACT", std::slice::from_ref(&joined));
doc.push(Node::Paragraph {
text: escape_text(&joined),
});
}
}
if let Some(desc) = dom.descendants().find(|n| n.has_tag_name("description")) {
walk_description(desc, doc, tree);
}
if let Some(claims) = dom.descendants().find(|n| n.has_tag_name("claims")) {
push_tables(claims, doc, tree);
let texts: Vec<String> = claims
.children()
.filter(|c| c.has_tag_name("claim"))
.map(claim_text)
.filter(|t| !t.is_empty())
.collect();
if !texts.is_empty() {
tree.add_section("CLAIMS", &texts);
}
doc.push(Node::Heading {
level: 3,
text: "CLAIMS".into(),
});
for t in texts {
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
}
fn claim_text(claim: XmlNode) -> String {
fn walk(node: XmlNode, in_text: bool, cur: &mut String, segments: &mut Vec<String>) {
let flush = |cur: &mut String, segments: &mut Vec<String>| {
let piece = cur.split_whitespace().collect::<Vec<_>>().join(" ");
if !piece.is_empty() {
segments.push(piece);
}
cur.clear();
};
for child in node.children() {
if child.is_text() {
if in_text {
if let Some(t) = child.text() {
cur.push_str(t);
}
}
} else if child.is_element() {
if child.has_tag_name("claim-text") {
flush(cur, segments);
walk(child, true, cur, segments);
flush(cur, segments);
} else if in_text {
raw_node(child, cur);
} else {
walk(child, false, cur, segments);
}
}
}
}
let mut segments = Vec::new();
let mut cur = String::new();
walk(claim, false, &mut cur, &mut segments);
let tail = cur.split_whitespace().collect::<Vec<_>>().join(" ");
if !tail.is_empty() {
segments.push(tail);
}
segments.join(" ")
}
struct HeadingLevels {
level: i32,
present: std::collections::BTreeSet<i32>,
}
impl HeadingLevels {
fn new() -> Self {
let mut present = std::collections::BTreeSet::new();
present.insert(1);
Self { level: 1, present }
}
fn tagged_section_level(&self, base: i32) -> u8 {
let lvl = if self.present.contains(&base) {
base
} else {
1
};
(lvl + 1) as u8
}
fn heading_level(&mut self, lvl_attr: i32) -> u8 {
let cand = lvl_attr + 1;
self.level = if self.present.contains(&cand) {
cand
} else {
*self.present.iter().next().unwrap()
};
let dclx = (self.level + 1) as u8;
self.present.insert(self.level + 1);
self.level += 1;
dclx
}
}
fn styled_raw(node: XmlNode) -> String {
let mut s = String::new();
raw_text(node, &mut s);
s
}
fn parse_app_v1(dom: &Document, doc: &mut DoclingDocument, tree: &mut PatentTree) {
let mut lv = HeadingLevels::new();
walk_app_v1(dom.root_element(), doc, &mut lv, tree);
}
fn walk_app_v1(
node: XmlNode,
doc: &mut DoclingDocument,
lv: &mut HeadingLevels,
tree: &mut PatentTree,
) {
for child in node.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"title-of-invention" => {
let t = node_text(child);
if !t.is_empty() {
tree.add_title(&t);
doc.push(Node::Heading {
level: 1,
text: escape_text(&t),
});
lv.level += 1;
lv.present.insert(lv.level);
}
}
"subdoc-abstract" => {
let abstract_text: String = child
.descendants()
.filter(|n| n.has_tag_name("paragraph"))
.map(styled_raw)
.collect();
if !abstract_text.trim().is_empty() {
tree.add_section("ABSTRACT", std::slice::from_ref(&abstract_text));
doc.push(Node::Heading {
level: lv.tagged_section_level(2),
text: "ABSTRACT".into(),
});
doc.push(Node::Paragraph {
text: escape_text(&abstract_text),
});
}
}
"heading" => {
let lvl_attr = child.attribute("lvl").and_then(as_index).unwrap_or(1);
let t = node_text(child);
if !t.is_empty() {
tree.add_heading(&t, lvl_attr);
let level = lv.heading_level(lvl_attr as i32);
doc.push(Node::Heading {
level,
text: escape_text(&t),
});
}
}
"paragraph" => {
if child.descendants().any(|n| n.has_tag_name("table")) {
push_tables(child, doc, tree);
}
let t = node_text(child);
if !t.is_empty() {
tree.add_paragraph(&t);
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
"subdoc-claims" => {
let claims: Vec<String> = child
.descendants()
.filter(|n| n.has_tag_name("claim"))
.map(node_text)
.filter(|s| !s.is_empty())
.collect();
if !claims.is_empty() {
tree.add_section("CLAIMS", &claims);
doc.push(Node::Heading {
level: lv.tagged_section_level(2),
text: "CLAIMS".into(),
});
for claim in claims {
doc.push(Node::Paragraph {
text: escape_text(&claim),
});
}
}
}
"tables" | "table" => push_tables(child, doc, tree),
"math-cwu" | "math" | "maths" => {}
_ => walk_app_v1(child, doc, lv, tree),
}
}
}
fn grant_text(node: XmlNode) -> String {
node_text(node)
}
fn patdoc_claim_text(clm: XmlNode) -> String {
const REGISTERED: &[&str] = &[
"PDAT", "SDOAB", "SDOCL", "B540", "CL", "CLM", "PARA", "H", "DRWDESC", "SP", "SB",
"ITALIC", "CWU", "table",
];
fn walk(node: XmlNode, stack: &mut Vec<&'static str>, text: &mut String, claim: &mut String) {
for child in node.children() {
if child.is_text() {
if matches!(stack.last(), Some(&"PDAT" | &"PARA" | &"H")) {
text.push_str(child.text().unwrap_or(""));
}
} else if child.is_element() {
let name = child.tag_name().name();
let registered = REGISTERED.iter().copied().find(|r| *r == name);
if let Some(r) = registered {
stack.push(r);
}
walk(child, stack, text, claim);
if let Some(r) = registered {
stack.pop();
match r {
"PDAT" if !text.is_empty() => {
match stack.last() {
Some(&"SP") => {
claim.extend(text.chars().map(|c| script_char(c, true)))
}
Some(&"SB") => {
claim.extend(text.chars().map(|c| script_char(c, false)))
}
Some(&"ITALIC") => claim.extend(text.chars().map(math_italic_char)),
_ => claim.push_str(text),
}
text.clear();
}
"PARA" => claim.push(' '),
_ => {}
}
}
}
}
}
let mut stack = vec!["CLM"];
let mut text = String::new();
let mut claim = String::new();
walk(clm, &mut stack, &mut text, &mut claim);
claim.trim().to_string()
}
fn parse_grant_v2(dom: &Document, doc: &mut DoclingDocument, tree: &mut PatentTree) {
let mut lv = HeadingLevels::new();
walk_grant_v2(dom.root_element(), doc, &mut lv, tree);
}
fn walk_grant_v2(
node: XmlNode,
doc: &mut DoclingDocument,
lv: &mut HeadingLevels,
tree: &mut PatentTree,
) {
for child in node.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"B540" => {
let t = grant_text(child);
if !t.is_empty() {
tree.add_title(&t);
doc.push(Node::Heading {
level: 1,
text: escape_text(&t),
});
lv.level += 1;
lv.present.insert(lv.level);
}
}
"SDOAB" => {
let t = grant_text(child);
if !t.is_empty() {
tree.add_section("ABSTRACT", std::slice::from_ref(&t));
doc.push(Node::Heading {
level: lv.tagged_section_level(2),
text: "ABSTRACT".into(),
});
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
"H" => {
if child.ancestors().any(|a| a.has_tag_name("SDOCL")) {
continue;
}
let lvl_attr = child.attribute("LVL").and_then(as_index).unwrap_or(1);
let t = grant_text(child);
if !t.is_empty() {
tree.add_heading(&t, lvl_attr);
let level = lv.heading_level(lvl_attr as i32);
doc.push(Node::Heading {
level,
text: escape_text(&t),
});
}
}
"PARA" => {
if child.descendants().any(|n| n.has_tag_name("table")) {
push_tables(child, doc, tree);
}
let t = grant_text(child);
if !t.is_empty() {
tree.add_paragraph(&t);
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
"CL" => {
let claims: Vec<String> = child
.children()
.filter(|c| c.has_tag_name("CLM"))
.map(patdoc_claim_text)
.filter(|s| !s.is_empty())
.collect();
if !claims.is_empty() {
tree.add_section("CLAIMS", &claims);
doc.push(Node::Heading {
level: lv.tagged_section_level(2),
text: "CLAIMS".into(),
});
for claim in claims {
doc.push(Node::Paragraph {
text: escape_text(&claim),
});
}
}
}
"tables" | "table" => push_tables(child, doc, tree),
"CWU" => {}
_ => walk_grant_v2(child, doc, lv, tree),
}
}
}
fn walk_description(node: XmlNode, doc: &mut DoclingDocument, tree: &mut PatentTree) {
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() {
tree.add_heading(&t, child.attribute("level").and_then(as_index).unwrap_or(1));
doc.push(Node::Heading {
level: level + 2,
text: escape_text(&t),
});
}
}
"p" => {
if child.descendants().any(|n| n.has_tag_name("table")) {
push_tables(child, doc, tree);
}
let t = node_text(child);
if !t.is_empty() {
tree.add_paragraph(&t);
doc.push(Node::Paragraph {
text: escape_text(&t),
});
}
}
"maths" => {}
"tables" | "table" => push_tables(child, doc, tree),
_ => walk_description(child, doc, tree),
}
}
}
fn push_tables(node: XmlNode, doc: &mut DoclingDocument, tree: &mut PatentTree) {
let tables: Vec<XmlNode> = if node.has_tag_name("table") {
vec![node]
} else {
node.descendants()
.filter(|n| n.has_tag_name("table"))
.collect()
};
for tn in tables {
tree.add_table(tn);
if let Some(t) = parse_table(tn) {
doc.push(Node::Table(t));
}
}
}
fn regex_table_count(raw: &str) -> usize {
let mut count = 0;
let mut pos = 0;
while pos <= raw.len() {
let rest = &raw[pos..];
let start = if rest.starts_with("<table ") && (pos == 0 || raw[..pos].ends_with('\n')) {
0
} else {
match rest.find("\n<table ") {
Some(i) => i + 1,
None => break,
}
};
let Some(end) = rest[start..].find("</table>") else {
break;
};
count += 1;
pos += start + end + "</table>".len();
}
count
}
fn xml_table_cells(table: XmlNode) -> (Vec<TableCell>, usize, usize) {
let tgroups: Vec<XmlNode> = table
.children()
.filter(|n| n.has_tag_name("tgroup"))
.collect();
let tgs_cw: Vec<Vec<f64>> = tgroups
.iter()
.map(|tg| {
tg.children()
.filter(|n| n.has_tag_name("colspec"))
.map(|cs| cs.attribute("colwidth").map(parse_colwidth).unwrap_or(0.0))
.collect()
})
.collect();
let Some(tgs_range) = create_tg_range(&tgs_cw).filter(|r| !r.is_empty()) else {
return (Vec::new(), 0, 0);
};
let ncols_max = tgs_cw.iter().map(Vec::len).max().unwrap_or(0);
let cell = |text: &str, row: usize, col: i64, span: i64, header: bool| TableCell {
text: text.to_string(),
bbox: None,
start_row: row,
start_col: col.max(0) as usize,
row_span: 1,
col_span: span.max(1) as usize,
column_header: header,
row_header: false,
row_section: false,
};
let mut cells = Vec::new();
let mut i_row = 0usize;
for (itg, tg) in tgroups.iter().enumerate() {
let cell_offst = &tgs_range[itg];
let rows: Vec<XmlNode> = tg
.descendants()
.filter(|n| n.has_tag_name("row") || n.has_tag_name("tr"))
.collect();
for row_sec in rows {
let is_header = row_sec.parent().is_some_and(|p| p.has_tag_name("thead"));
let entries: Vec<XmlNode> = row_sec
.children()
.filter(|n| n.has_tag_name("entry") || n.has_tag_name("td"))
.collect();
let mut local: Vec<TableCell> = Vec::new();
let mut ncols = 0usize;
let mut is_row_empty = true;
let mut wrong_nbr_cols = false;
for (ientry, entry) in entries.iter().enumerate() {
let text = cell_text(*entry);
let start = entry
.attribute("namest")
.and_then(as_index)
.unwrap_or(ientry as i64 + 1);
let (end, shift) = match entry.attribute("nameend").and_then(as_index) {
Some(e) => (e, 0),
None => (ientry as i64 + 2, 1),
};
let n_offst = cell_offst.len() as i64;
if start < 1 || start > n_offst || end > n_offst {
wrong_nbr_cols = true;
break;
}
let r0 = cell_offst[(start - 1) as usize];
let r1 = cell_offst[(end - 1) as usize] - shift;
if !text.is_empty() {
is_row_empty = false;
}
for _ in r0..=r1 {
ncols += 1;
local.push(cell(&text, i_row, r0, r1 - r0 + 1, is_header));
}
}
if wrong_nbr_cols {
local.clear();
ncols = 0;
}
for irep in ncols..ncols_max {
local.push(cell("", i_row, irep as i64, 1, is_header));
}
if !is_row_empty {
cells.extend(local);
i_row += 1;
}
}
}
(cells, i_row, ncols_max)
}
fn parse_colwidth(s: &str) -> f64 {
let cleaned: String = s
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();
cleaned
.parse::<f64>()
.ok()
.filter(|w| w.is_finite())
.unwrap_or(0.0)
}
fn as_index(s: &str) -> Option<i64> {
if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
s.parse::<i64>().ok()
} else {
None
}
}
fn sorted_unique(mut v: Vec<f64>) -> Vec<f64> {
v.sort_by(f64::total_cmp);
v.dedup();
v
}
fn create_tg_range(tgs: &[Vec<f64>]) -> Option<Vec<Vec<i64>>> {
if tgs.is_empty() {
return Some(Vec::new());
}
let mut offsets: Vec<Vec<f64>> = Vec::with_capacity(tgs.len());
for cws in tgs {
let mut off = Vec::with_capacity(cws.len() + 1);
let mut o = 0.0;
for &cw in cws {
off.push(o);
o += cw;
}
off.push(o);
offsets.push(off);
}
let mut min_off: Vec<f64> = offsets[0].clone();
let mut offset_w0: Vec<f64> = Vec::new();
for (itg, cws) in tgs.iter().enumerate() {
for (ic, &cw) in cws.iter().enumerate() {
if cw == 0.0 {
offset_w0.push(offsets[itg][ic]);
}
}
let union: Vec<f64> = offsets[itg].iter().chain(min_off.iter()).copied().collect();
min_off = sorted_unique(union);
}
offset_w0 = sorted_unique(offset_w0);
let mut combined: Vec<f64> = min_off.iter().chain(offset_w0.iter()).copied().collect();
combined.sort_by(f64::total_cmp);
min_off = combined;
let mut result: Vec<Vec<i64>> = Vec::with_capacity(tgs.len());
for col_offset in &offsets {
let mut cell_offst: Vec<i64> = vec![0];
let mut i = 1usize;
let mut range_: i64 = 1;
for min_i in 1..min_off.len() {
let min_offst = min_off[min_i];
let offst = *col_offset.get(i)?;
if min_offst == offst {
if col_offset.len() == i + 1 && min_off.len() > min_i + 1 {
range_ += 1;
} else {
cell_offst.push(cell_offst[cell_offst.len() - 1] + range_);
range_ = 1;
i += 1;
}
} else if min_offst < offst {
range_ += 1;
} else {
return None;
}
}
result.push(cell_offst);
}
Some(result)
}
fn cell_text(node: XmlNode) -> String {
let mut s = String::new();
for d in node.descendants() {
if d.is_text() {
if let Some(t) = d.text() {
s.push_str(t);
}
}
}
s.trim().to_string()
}
fn parse_table(table: XmlNode) -> Option<Table> {
let tgroups: Vec<XmlNode> = table
.children()
.filter(|n| n.has_tag_name("tgroup"))
.collect();
let tgs_cw: Vec<Vec<f64>> = tgroups
.iter()
.map(|tg| {
tg.children()
.filter(|n| n.has_tag_name("colspec"))
.map(|cs| cs.attribute("colwidth").map(parse_colwidth).unwrap_or(0.0))
.collect()
})
.collect();
let tgs_range = create_tg_range(&tgs_cw)?;
if tgs_range.is_empty() {
return None;
}
let ncols_max = tgs_cw.iter().map(Vec::len).max().unwrap_or(0);
if ncols_max == 0 {
return None;
}
let mut rows: Vec<Vec<String>> = Vec::new();
let mut header_row: Vec<bool> = Vec::new();
let mut col_continuation: Vec<Vec<bool>> = Vec::new();
for (itg, tg) in tgroups.iter().enumerate() {
let cell_offst = &tgs_range[itg];
let row_nodes: Vec<XmlNode> = tg
.descendants()
.filter(|n| n.has_tag_name("row") || n.has_tag_name("tr"))
.collect();
for row_sec in row_nodes {
let is_header = row_sec.parent().is_some_and(|p| p.has_tag_name("thead"));
let entries: Vec<XmlNode> = row_sec
.children()
.filter(|n| n.has_tag_name("entry") || n.has_tag_name("td"))
.collect();
let mut row_text: Vec<String> = vec![String::new(); ncols_max];
let mut row_cont: Vec<bool> = vec![false; ncols_max];
let mut is_row_empty = true;
let mut wrong_nbr_cols = false;
for (ientry, entry) in entries.iter().enumerate() {
let text = cell_text(*entry);
let start = entry
.attribute("namest")
.and_then(as_index)
.unwrap_or(ientry as i64 + 1);
let (end, shift) = match entry.attribute("nameend").and_then(as_index) {
Some(e) => (e, 0),
None => (ientry as i64 + 2, 1),
};
let n_offst = cell_offst.len() as i64;
if start < 1 || start > n_offst || end > n_offst {
wrong_nbr_cols = true;
break;
}
let r0 = cell_offst[(start - 1) as usize];
let r1 = cell_offst[(end - 1) as usize] - shift;
if !text.is_empty() {
is_row_empty = false;
}
let mut irep = r0;
while irep <= r1 {
if let Some(idx) = usize::try_from(irep).ok().filter(|&i| i < ncols_max) {
row_text[idx] = text.clone();
row_cont[idx] = irep != r0;
}
irep += 1;
}
}
if wrong_nbr_cols {
row_text = vec![String::new(); ncols_max];
row_cont = vec![false; ncols_max];
}
if !is_row_empty {
rows.push(row_text);
col_continuation.push(row_cont);
header_row.push(is_header);
}
}
}
if rows.is_empty() {
return None;
}
Some(Table {
rows,
location: None,
structure: Some(TableStructure {
header_row,
col_continuation,
row_continuation: Vec::new(),
row_header: Vec::new(),
col_header: Vec::new(),
}),
cell_blocks: None,
cells: None,
caption: None,
caption_parent: Default::default(),
})
}
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() {
raw_node(child, out);
}
}
fn raw_node(child: XmlNode, out: &mut String) {
{
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() {
tag @ ("sup" | "sub" | "superscript" | "subscript" | "SP" | "SB") => {
let mut inner = String::new();
raw_text(child, &mut inner);
let sup = matches!(tag, "sup" | "superscript" | "SP");
out.extend(inner.chars().map(|c| script_char(c, sup)));
}
"ITALIC" => {
let mut inner = String::new();
raw_text(child, &mut inner);
out.extend(inner.chars().map(math_italic_char));
}
"maths" | "math-cwu" | "table" | "tables" | "number" | "CWU" => {}
_ => raw_text(child, out),
}
}
}
}
fn math_italic_char(c: char) -> char {
match c {
'A' => '\u{1D434}',
'B' => '\u{1D435}',
'C' => '\u{1D436}',
'D' => '\u{1D437}',
'E' => '\u{1D438}',
'F' => '\u{1D439}',
'G' => '\u{1D43A}',
'H' => '\u{1D43B}',
'I' => '\u{1D43C}',
'J' => '\u{1D43D}',
'K' => '\u{1D43E}',
'L' => '\u{1D43F}',
'M' => '\u{1D440}',
'N' => '\u{1D441}',
'O' => '\u{1D442}',
'P' => '\u{1D443}',
'Q' => '\u{1D444}',
'R' => '\u{1D445}',
'S' => '\u{1D446}',
'T' => '\u{1D447}',
'U' => '\u{1D448}',
'V' => '\u{1D449}',
'W' => '\u{1D44A}',
'Y' => '\u{1D44C}',
'Z' => '\u{1D44D}',
'a' => '\u{1D44E}',
'b' => '\u{1D44F}',
'c' => '\u{1D450}',
'd' => '\u{1D451}',
'e' => '\u{1D452}',
'f' => '\u{1D453}',
'g' => '\u{1D454}',
'h' => '\u{1D455}',
'i' => '\u{1D456}',
'j' => '\u{1D457}',
'k' => '\u{1D458}',
'l' => '\u{1D459}',
'm' => '\u{1D45A}',
'n' => '\u{1D45B}',
'o' => '\u{1D45C}',
'p' => '\u{1D45D}',
'q' => '\u{1D45E}',
'r' => '\u{1D45F}',
's' => '\u{1D460}',
't' => '\u{1D461}',
'u' => '\u{1D462}',
'v' => '\u{1D463}',
'w' => '\u{1D464}',
'x' => '\u{1D465}',
'y' => '\u{1D466}',
'z' => '\u{1D467}',
_ => c,
}
}
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 drop_table_entities(xml: &str) -> Cow<'_, str> {
if !xml.contains('&') || !xml.contains("<table ") {
return Cow::Borrowed(xml);
}
let mut out = String::with_capacity(xml.len());
let mut i = 0;
while i < xml.len() {
let at_line_start = |pos: usize| pos == 0 || xml.as_bytes()[pos - 1] == b'\n';
let mut search = i;
let start = loop {
match xml[search..].find("<table ") {
Some(rel) if at_line_start(search + rel) => break Some(search + rel),
Some(rel) => search += rel + 1,
None => break None,
}
};
let Some(start) = start else {
out.push_str(&xml[i..]);
break;
};
let end = match xml[start..].find("</table>") {
Some(rel) => start + rel + "</table>".len(),
None => {
out.push_str(&xml[i..]);
break;
}
};
out.push_str(&xml[i..start]);
strip_undefined_entities(&xml[start..end], &mut out);
i = end;
}
Cow::Owned(out)
}
fn strip_undefined_entities(span: &str, out: &mut String) {
let mut poisoned = false;
let mut i = 0;
while i < span.len() {
let c = span.as_bytes()[i];
if c == b'<' {
let close = span[i..].find('>').map_or(span.len(), |r| i + r + 1);
out.push_str(&span[i..close]);
i = close;
poisoned = false;
continue;
}
if c == b'&' {
let semi = span[i + 1..]
.char_indices()
.take(64)
.find(|&(_, ch)| ch == ';')
.map(|(off, _)| i + 1 + off);
if let Some(semi) = semi {
let name = &span[i + 1..semi];
let numeric = name.starts_with('#');
let builtin = matches!(name, "amp" | "lt" | "gt" | "quot" | "apos");
if numeric || (builtin && !poisoned) {
out.push_str(&span[i..=semi]);
} else {
poisoned = true;
}
i = semi + 1;
continue;
}
}
let ch_len = span[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&span[i..i + ch_len]);
i += ch_len;
}
}
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 aps_plain_text_patent() {
let txt = "PATN\nWKU 012345678\nTTL Widget with a double space\nINVT\nNAM Doe; Jane\n\
ABST\nPAL First part of the\n abstract.\nPAL Second part.\n ##STR1##\n\
BSUM\nPAC BACKGROUND\nPAR Para one\n continues.\nPA1 Para two.\n\
CLMS\nSTM What is claimed is:\nNUM 1.\nPAR 1. A widget\n comprising a knob.\n\
PA1 wherein the knob is red.\nNUM 2.\nPAR 2. The widget of claim 1.\n";
assert!(looks_like_aps(txt));
let src = SourceDocument::from_bytes("t", InputFormat::Md, txt.as_bytes().to_vec());
let doc = convert_aps(&src).unwrap();
assert_eq!(
doc.export_to_markdown().trim_end(),
"# Widget with a double space\n\n### ABSTRACT\n\n\
First part of the abstract. Second part.\n\n### BACKGROUND\n\n\
Para one continues.\n\nPara two.\n\n### CLAIMS\n\n\
1. A widget comprising a knob. wherein the knob is red.\n\n2. The widget of claim 1."
);
let v: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts = v["texts"].as_array().unwrap();
assert_eq!(texts[0]["label"], "title");
assert_eq!(texts[0]["children"].as_array().unwrap().len(), 3);
assert_eq!(texts[1]["label"], "section_header");
assert_eq!(texts[1]["level"], 2);
assert_eq!(texts[1]["parent"]["$ref"], "#/texts/0");
assert_eq!(texts[2]["orig"], "First part of the abstract.");
assert_eq!(texts[2]["text"], "First part of the abstract. Second part.");
assert_eq!(texts[7]["orig"], "");
assert_eq!(
texts[7]["text"],
"1. A widget comprising a knob. wherein the knob is red."
);
assert_eq!(texts[7]["parent"]["$ref"], "#/texts/6");
}
#[test]
fn ice_claims_join_and_in_claim_tables_lead() {
let xml = "<us-patent-application><description>\n<p>Intro <tables>\n<table frame=\"none\">\n\
<tgroup cols=\"2\"><colspec colname=\"1\" colwidth=\"10pt\"/><colspec colname=\"2\" colwidth=\"10pt\"/>\
<tbody><row><entry>a</entry><entry>b</entry></row></tbody></tgroup></table></tables> Table 2: X<sub>2</sub></p>\n\
</description><claims><claim id=\"1\"><claim-text><b>1</b>. A compound\n<claim-text>in which R<sup>1</sup> is H,</claim-text>\n\
<claim-text>and</claim-text></claim-text></claim><claim id=\"2\"><claim-text>2. Uses\n<table frame=\"none\">\n\
<tgroup cols=\"1\"><colspec colname=\"1\" colwidth=\"10pt\"/><tbody><row><entry>T</entry></row></tbody></tgroup></table>\
</claim-text></claim></claims></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();
let pos = |s: &str| {
md.find(s)
.unwrap_or_else(|| panic!("missing {s:?} in:\n{md}"))
};
assert!(pos("| a ") < pos("Intro Table 2: Xā"));
assert!(pos("| T ") < pos("### CLAIMS"));
assert!(
md.contains("### CLAIMS\n\n1. A compound in which R¹ is H, and\n\n2. Uses"),
"{md}"
);
}
#[test]
fn cals_grid_clips_to_the_widest_tgroup_and_cells_drop_named_entities() {
let xml = "<us-patent-application><description>\n<table frame=\"none\">\n\
<tgroup cols=\"3\"><colspec colname=\"1\" colwidth=\"91PT\"/><colspec colname=\"2\" colwidth=\"119PT\"/><colspec colname=\"3\" colwidth=\"7PT\"/>\
<tbody><row><entry></entry><entry>SENS</entry><entry></entry></row></tbody></tgroup>\
<tgroup cols=\"3\"><colspec colname=\"1\" colwidth=\"91PT\"/><colspec colname=\"2\" colwidth=\"63PT\"/><colspec colname=\"3\" colwidth=\"63PT\"/>\
<tbody><row><entry>G</entry><entry>ZEOCIN ™</entry><entry>C & D ™ E & F</entry></row></tbody></tgroup>\
</table>\n<p>Mark ™ stays</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();
let table_line = md
.lines()
.find(|l| l.contains("SENS"))
.unwrap_or_else(|| panic!("{md}"));
assert_eq!(
table_line.matches('|').count(),
4,
"3 columns expected: {table_line}"
);
assert!(md.contains("| ZEOCIN "), "{md}");
assert!(md.contains("| C & D E F "), "{md}");
assert!(md.contains("Mark ⢠stays"), "{md}");
}
#[test]
fn patdoc_claims_keep_the_handler_spacing() {
let xml = "<PATDOC><SDOCL><CL><CLM ID=\"1\"><PARA ID=\"p1\" LVL=\"0\"><PTEXT><PDAT>1. A method comprising;</PDAT></PTEXT></PARA>\n\
<CLMSTEP LVL=\"2\"><PTEXT><PDAT>N</PDAT><HIL><SB><PDAT>1</PDAT></SB></HIL><PDAT> rows </PDAT></PTEXT></CLMSTEP>\n\
<PARA ID=\"p2\" LVL=\"0\"><PTEXT><F><PTEXT><PDAT>D(</PDAT><HIL><ITALIC><PDAT>j</PDAT></ITALIC></HIL><PDAT>)</PDAT></PTEXT></F> </PTEXT></PARA>\n\
<PARA ID=\"p3\" LVL=\"7\"><PTEXT><PDAT>wherein</PDAT></PTEXT></PARA>\n\
<CLMSTEP LVL=\"2\"><PTEXT><PDAT>j is an index</PDAT></PTEXT></CLMSTEP></CLM></CL></SDOCL></PATDOC>";
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("1. A method comprising; Nā rows D(š) wherein j is an index"),
"{md}"
);
}
#[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 tree_mirrors_the_sax_handler() {
let xml = "<us-patent-application>\n<us-bibliographic-data-application>\n\
<invention-title>A Device</invention-title>\n</us-bibliographic-data-application>\n\
<abstract><p>First.</p><p>Second.</p></abstract>\n<description>\n\
<heading level=\"1\">BACKGROUND</heading>\n<p>Body.</p>\n\
<heading level=\"2\">Detail</heading>\n<p>Deep.</p>\n\
<heading level=\"1\">SUMMARY</heading>\n<p>Before\n<tables>\n\
<table frame=\"none\">\n<tgroup cols=\"3\"><colspec colname=\"1\" colwidth=\"10pt\"/>\
<colspec colname=\"2\" colwidth=\"10pt\"/><colspec colname=\"3\" colwidth=\"10pt\"/>\
<thead><row><entry namest=\"1\" nameend=\"3\">TABLE 1</entry></row></thead>\
<tbody><row><entry>a</entry><entry>b</entry></row></tbody></tgroup></table>\n\
</tables>\nafter</p>\n</description>\n<claims>\n\
<claim><claim-text>1. A thing.</claim-text></claim>\n</claims>\n</us-patent-application>";
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.as_bytes().to_vec());
let json: serde_json::Value =
serde_json::from_str(&UsptoBackend.convert(&src).unwrap().export_to_json()).unwrap();
let texts = json["texts"].as_array().unwrap();
let items: Vec<(&str, &str, &str)> = texts
.iter()
.map(|t| {
(
t["label"].as_str().unwrap(),
t["text"].as_str().unwrap(),
t["parent"]["$ref"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
items,
[
("title", "A Device", "#/body"),
("section_header", "ABSTRACT", "#/texts/0"),
("paragraph", "First. Second.", "#/texts/1"),
("section_header", "BACKGROUND", "#/texts/0"),
("paragraph", "Body.", "#/texts/3"),
("section_header", "Detail", "#/texts/3"),
("paragraph", "Deep.", "#/texts/5"),
("section_header", "SUMMARY", "#/texts/0"),
("paragraph", "Before after", "#/texts/7"),
("section_header", "CLAIMS", "#/texts/0"),
("paragraph", "1. A thing.", "#/texts/9"),
]
);
assert_eq!(texts[1]["level"], 2);
assert_eq!(texts[5]["level"], 3);
assert_eq!(json["body"]["children"].as_array().unwrap().len(), 1);
let summary_children: Vec<&str> = texts[7]["children"]
.as_array()
.unwrap()
.iter()
.map(|c| c["$ref"].as_str().unwrap())
.collect();
assert_eq!(summary_children, ["#/tables/0", "#/texts/8"]);
let data = &json["tables"][0]["data"];
assert_eq!(data["num_rows"], 2);
assert_eq!(data["num_cols"], 3);
let cells = data["table_cells"].as_array().unwrap();
assert_eq!(cells.len(), 6);
assert!(cells[..3].iter().all(|c| c["text"] == "TABLE 1"
&& c["col_span"] == 3
&& c["start_col_offset_idx"] == 0
&& c["column_header"] == true));
assert_eq!(cells[5]["text"], "");
assert_eq!(cells[5]["start_col_offset_idx"], 2);
}
#[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}");
}
fn dclx_of(xml: &str) -> String {
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.as_bytes().to_vec());
UsptoBackend.convert(&src).unwrap().export_to_doclang()
}
#[test]
fn app_v1_title_abstract_heading_levels() {
let xml = r#"<patent-application-publication>
<subdoc-bibliographic-information>
<title-of-invention>My Widget</title-of-invention>
</subdoc-bibliographic-information>
<subdoc-abstract><paragraph>An abstract about H<subscript>2</subscript>O.</paragraph></subdoc-abstract>
<subdoc-description>
<section><heading lvl="1">EXAMPLE 1</heading>
<paragraph id="P-1" lvl="0"><number>[0001]</number> Body text here.</paragraph>
</section>
</subdoc-description>
</patent-application-publication>"#;
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("# My Widget\n\n### ABSTRACT\n"),
"got:\n{md}"
);
assert!(md.contains("An abstract about HāO."), "got:\n{md}");
assert!(md.contains("EXAMPLE 1"), "got:\n{md}");
assert!(
md.contains("Body text here.") && !md.contains("[0001]"),
"got:\n{md}"
);
}
#[test]
fn grant_v2_patdoc_pdat_and_styles() {
let xml = r#"<PATDOC><SDOBI><B540><PTEXT><PDAT>Turbo Code</PDAT></PTEXT></B540></SDOBI>
<SDODE>
<H LVL="1"><PTEXT><PDAT>FIELD</PDAT></PTEXT></H>
<PARA><PTEXT><PDAT>Array N</PDAT><SB><PDAT>1</PDAT></SB><PDAT> uses </PDAT><ITALIC><PDAT>x</PDAT></ITALIC><CWU><PDAT>DROP</PDAT></CWU></PTEXT></PARA>
</SDODE>
</PATDOC>"#;
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("# Turbo Code\n"), "got:\n{md}");
assert!(md.contains("Array Nā uses"), "subscript wrong:\n{md}");
assert!(md.contains('\u{1D465}'), "italic x not mapped:\n{md}"); assert!(!md.contains("DROP"), "CWU not skipped:\n{md}");
}
#[test]
fn cals_table_spans_and_header() {
let xml = r#"<us-patent-application><description><p><tables><table>
<tgroup cols="3">
<colspec colname="1" colwidth="40pt"/>
<colspec colname="2" colwidth="40pt"/>
<colspec colname="3" colwidth="40pt"/>
<thead>
<row><entry namest="1" nameend="3">Title</entry></row>
</thead>
<tbody>
<row><entry>a</entry><entry>b</entry><entry>c</entry></row>
</tbody>
</tgroup>
</table></tables></p></description></us-patent-application>"#;
let dclx = dclx_of(xml);
let toks: Vec<&str> = dclx.split_whitespace().collect();
let joined = toks.join(" ");
assert!(
joined.contains("<ched/> Title <lcel/> <lcel/> <nl/>"),
"spanning header not <ched/>+<lcel/>Ć2:\n{dclx}"
);
assert!(
joined.contains("<fcel/> a <fcel/> b <fcel/> c <nl/>"),
"data row wrong:\n{dclx}"
);
}
#[test]
fn cals_table_out_of_range_span_degrades() {
let malformed = r#"<us-patent-application><description><p><tables><table>
<tgroup cols="2">
<colspec colname="1" colwidth="40pt"/>
<colspec colname="2" colwidth="40pt"/>
<tbody>
<row><entry namest="9">a</entry></row>
<row><entry>x</entry><entry>y</entry></row>
</tbody>
</tgroup>
</table></tables></p></description></us-patent-application>"#;
let src =
SourceDocument::from_bytes("p", InputFormat::XmlUspto, malformed.as_bytes().to_vec());
let md = UsptoBackend.convert(&src).unwrap().export_to_markdown();
assert!(md.contains("| x"), "well-formed row lost:\n{md}");
assert!(!md.contains("a"), "out-of-range cell must not land:\n{md}");
for span in [
r#"namest="1" nameend="9""#,
r#"namest="2" nameend="1""#,
r#"namest="0""#,
] {
let xml = format!(
r#"<us-patent-application><description><p><tables><table>
<tgroup cols="2">
<colspec colname="1" colwidth="40pt"/>
<colspec colname="2" colwidth="40pt"/>
<tbody>
<row><entry {span}>bad</entry></row>
<row><entry>x</entry><entry>y</entry></row>
</tbody>
</tgroup>
</table></tables></p></description></us-patent-application>"#
);
let src = SourceDocument::from_bytes("p", InputFormat::XmlUspto, xml.into_bytes());
let md = UsptoBackend.convert(&src).unwrap().export_to_markdown();
assert!(md.contains("| x"), "span {span:?} lost the good row:\n{md}");
assert!(!md.contains("bad"), "span {span:?} emitted a cell:\n{md}");
}
}
#[test]
fn cals_table_drops_empty_rows_and_reads_scripts_as_plain() {
let xml = r#"<us-patent-application><description><p><tables><table>
<tgroup cols="2">
<colspec colname="1" colwidth="40pt"/>
<colspec colname="2" colwidth="40pt"/>
<tbody>
<row><entry/><entry/></row>
<row><entry>m<sup>2</sup></entry><entry>x</entry></row>
</tbody>
</tgroup>
</table></tables></p></description></us-patent-application>"#;
let dclx = dclx_of(xml);
let table = dclx.split("<table>").nth(1).unwrap();
assert_eq!(
table.matches("<nl/>").count(),
1,
"empty row not dropped:\n{dclx}"
);
assert!(
dclx.contains("m2"),
"cell script converted (should be plain):\n{dclx}"
);
}
#[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}"
);
}
}