use std::collections::HashMap;
use std::sync::OnceLock;
use crate::doclang::{code_lang_label, is_rtl, sanitize_xml_illegal, Pictures};
use crate::tree::{ItemTree, TreeKind};
use crate::ContentLayer;
const VERSION: &str = "0.7";
const DELIM: &str = "\n";
const NON_SELFCLOSING: &[&str] = &[
"bold",
"caption",
"code",
"content",
"custom",
"description",
"doclang",
"field_heading",
"field_item",
"field_region",
"footnote",
"formula",
"group",
"handwriting",
"head",
"heading",
"hint",
"index",
"italic",
"key",
"list",
"marker",
"page_footer",
"page_header",
"picture",
"rtl",
"strikethrough",
"subscript",
"summary",
"superscript",
"table",
"tabular",
"text",
"underline",
"value",
"xref",
];
pub(crate) type Export = (String, Vec<(String, Vec<u8>)>);
pub(crate) fn export(tree: &ItemTree, pictures: Pictures) -> Option<Export> {
if tree
.items
.iter()
.any(|it| !it.deleted && (it.prov.is_some() || unsupported(&it.kind)))
{
return None;
}
let mut ser = Ser::new(tree, pictures);
let parts = ser.get_parts(None, false);
let body = parts.join(DELIM);
let xml = format!("<doclang version=\"{VERSION}\">{body}</doclang>");
Some((pretty(&xml), ser.assets.unwrap_or_default()))
}
fn unsupported(kind: &TreeKind) -> bool {
match kind {
TreeKind::Picture {
classification,
description,
chart,
..
} => classification.is_some() || description.is_some() || chart.is_some(),
TreeKind::FieldRegion { .. } => true,
TreeKind::Text { label, .. } => text_token(label).is_none(),
_ => false,
}
}
fn text_token(label: &str) -> Option<&'static str> {
Some(match label {
"title" | "section_header" => "heading",
"list_item" => "text",
"caption" | "checkbox_selected" | "checkbox_unselected" => "text",
"empty_value" | "handwritten_text" | "paragraph" | "reference" | "grading_scale" => "text",
"text" => "text",
"footnote" => "footnote",
"formula" => "formula",
"page_header" => "page_header",
"page_footer" => "page_footer",
_ => return None,
})
}
fn escape(text: &str) -> String {
let text = sanitize_xml_illegal(text);
let text = text.as_ref();
let wrap = text != text.trim() || text.contains('\n');
let mut t = if text.contains(['"', '\'', '&', '<', '>']) {
format!("<![CDATA[{}]]>", text.replace("]]>", "]]]]><![CDATA[>"))
} else {
text.to_string()
};
if wrap {
t = format!("<content>{t}</content>");
}
t
}
fn escape_attr(v: &str) -> String {
v.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn wrap(text: &str, tag: &str) -> String {
format!("<{tag}>{text}</{tag}>")
}
struct Ser<'t> {
tree: &'t ItemTree,
visited: Vec<bool>,
caption: Vec<bool>,
in_caption: Vec<bool>,
pic_index: HashMap<usize, usize>,
pictures: Pictures,
assets: Option<Vec<(String, Vec<u8>)>>,
}
impl<'t> Ser<'t> {
fn new(tree: &'t ItemTree, pictures: Pictures) -> Self {
let n = tree.items.len();
let mut caption = vec![false; n];
for it in &tree.items {
for &c in captions_of(&it.kind) {
caption[c] = true;
}
}
let mut in_caption = vec![false; n];
for c in (0..n).filter(|&c| caption[c]) {
let mut stack: Vec<usize> = tree.items[c].children.clone();
while let Some(d) = stack.pop() {
in_caption[d] = true;
stack.extend(tree.items[d].children.iter().copied());
}
}
let mut pic_index = HashMap::new();
let mut order = Vec::new();
for &b in &tree.body {
pre_order(tree, b, &mut order);
}
for id in order {
let it = &tree.items[id];
if matches!(it.kind, TreeKind::Picture { .. }) && it.layer.is_none() {
let next = pic_index.len();
pic_index.insert(id, next);
}
}
Ser {
tree,
visited: vec![false; n],
caption,
in_caption,
pic_index,
pictures,
assets: matches!(pictures, Pictures::Referenced { collect: true }).then(Vec::new),
}
}
fn get_parts(&mut self, root: Option<usize>, inline: bool) -> Vec<String> {
let mut order = Vec::new();
match root {
Some(r) => pre_order(self.tree, r, &mut order),
None => {
for &b in &self.tree.body {
pre_order(self.tree, b, &mut order);
}
}
}
let skip_satellites = root.is_none_or(|r| !self.in_caption[r] && !self.caption[r]);
let mut parts = Vec::new();
for id in order {
if self.visited[id] || (skip_satellites && self.in_caption[id]) {
continue;
}
self.visited[id] = true;
let part = self.serialize(id, inline);
if !part.is_empty() {
parts.push(part);
}
}
parts
}
fn serialize(&mut self, id: usize, inline: bool) -> String {
self.visited[id] = true;
let tree = self.tree;
let item = &tree.items[id];
match &item.kind {
TreeKind::Group { label, .. } if is_list_label(label) => self.list(id, inline),
TreeKind::Group { label, .. } if label == "inline" => self.inline_group(id),
TreeKind::Group { .. } => self.get_parts(Some(id), false).join(DELIM),
TreeKind::Text { .. } | TreeKind::Code { .. } => {
if self.caption[id] {
String::new()
} else {
self.text(id, inline)
}
}
TreeKind::Table { .. } => self.table(id),
TreeKind::Picture { .. } => self.picture(id),
TreeKind::KeyValueGraph { .. } | TreeKind::FieldRegion { .. } => String::new(),
}
}
fn is_list_item(&self, id: usize) -> bool {
matches!(&self.tree.items[id].kind, TreeKind::Text { label, .. } if label == "list_item")
}
fn is_list_group(&self, id: usize) -> bool {
matches!(&self.tree.items[id].kind, TreeKind::Group { label, .. } if is_list_label(label))
}
fn is_inline_group(&self, id: usize) -> bool {
matches!(&self.tree.items[id].kind, TreeKind::Group { label, .. } if label == "inline")
}
fn segment_sibling(&self, id: usize) -> bool {
self.is_list_group(id) || matches!(self.tree.items[id].kind, TreeKind::Picture { .. })
}
fn has_segment_siblings(&self, id: usize) -> bool {
let item = &self.tree.items[id];
if item.children.iter().any(|&c| self.segment_sibling(c)) {
return true;
}
let Some(parent) = item.parent.filter(|&p| self.is_list_group(p)) else {
return false;
};
let siblings = &self.tree.items[parent].children;
siblings
.iter()
.skip_while(|&&c| c != id)
.skip(1)
.any(|&c| self.is_list_group(c))
}
fn list(&mut self, id: usize, inline: bool) -> String {
let tree = self.tree;
let mut segments = Vec::new();
let children = &tree.items[id].children;
for &c in children {
if self.is_list_group(c) {
if self.visited[c] {
continue;
}
self.visited[c] = true;
let s = self.serialize(c, inline);
if !s.is_empty() {
segments.push(s);
}
continue;
}
if !self.is_list_item(c) || self.visited[c] {
continue;
}
self.visited[c] = true;
let s = self.serialize(c, inline);
if !s.is_empty() {
segments.push(s);
}
for &sub in &tree.items[c].children {
if !self.segment_sibling(sub) || self.visited[sub] {
continue;
}
self.visited[sub] = true;
let s = self.serialize(sub, inline);
if !s.is_empty() {
segments.push(s);
}
}
}
if segments.is_empty() {
return String::new();
}
let ordered = children.first().is_some_and(|&c| {
matches!(&tree.items[c].kind, TreeKind::Text { label, list: Some(m), .. }
if label == "list_item" && m.enumerated)
});
let open = if ordered {
"<list class=\"ordered\">"
} else {
"<list>"
};
format!("{open}{}{DELIM}</list>", segments.join(DELIM))
}
fn inline_group(&mut self, id: usize) -> String {
let tree = self.tree;
let item = &tree.items[id];
let should_wrap = match item.parent {
None => true,
Some(p) if self.is_list_item(p) => self.has_segment_siblings(p),
Some(p) => !matches!(
tree.items[p].kind,
TreeKind::Text { .. } | TreeKind::Code { .. }
),
};
let mut parts = Vec::new();
if should_wrap {
if let Some(layer) = item.layer {
parts.push(layer_token(layer));
}
}
parts.extend(self.get_parts(Some(id), true));
let mut text = parts.join(DELIM);
if !text.is_empty() {
text.push_str(DELIM);
}
if should_wrap {
text = wrap(&text, "text");
}
text
}
fn head(&self, id: usize, label: Option<&str>, caption: &str, inline: bool) -> String {
let item = &self.tree.items[id];
let mut s = String::new();
if let Some(l) = label {
s.push_str(&format!("<label value=\"{}\"/>", escape_attr(l)));
}
if !inline {
if let TreeKind::Text {
hyperlink: Some(uri),
..
}
| TreeKind::Code {
hyperlink: Some(uri),
..
} = &item.kind
{
s.push_str(&format!("<href uri=\"{}\"/>", escape_attr(uri)));
}
if let Some(layer) = item.layer {
s.push_str(&layer_token(layer));
}
}
s.push_str(caption);
s
}
fn captions(&mut self, id: usize) -> String {
let tree = self.tree;
let mut out = String::new();
for &c in captions_of(&tree.items[id].kind) {
let TreeKind::Text { text, .. } = &tree.items[c].kind else {
continue;
};
let mut s = self.head(c, None, "", false);
if !text.is_empty() {
s.push_str(&escape(text));
}
for &child in &tree.items[c].children {
s.push_str(&self.serialize(child, false));
}
if !s.is_empty() {
out.push_str(&wrap(&s, "caption"));
}
}
out
}
fn text(&mut self, id: usize, inline: bool) -> String {
let tree = self.tree;
let item = &tree.items[id];
let (label, text, formatting) = match &item.kind {
TreeKind::Text {
label,
text,
formatting,
..
} => (label.as_str(), text.as_str(), *formatting),
TreeKind::Code {
text, formatting, ..
} => ("code", text.as_str(), *formatting),
_ => unreachable!("text() on a non-text item"),
};
let is_list_item = label == "list_item";
let open: Option<String> = match (&item.kind, label) {
(TreeKind::Code { .. }, _) => Some("<code>".into()),
(_, "title") => Some("<heading>".into()),
(TreeKind::Text { level, .. }, "section_header") => {
let level = (level.unwrap_or(1) as usize + 1).min(6);
Some(if level == 1 {
"<heading>".into()
} else {
format!("<heading level=\"{level}\">")
})
}
(_, "list_item") => {
(!text.is_empty() && self.has_segment_siblings(id)).then(|| "<text>".into())
}
(_, "checkbox_selected" | "checkbox_unselected") => {
let in_text = item.parent.is_some_and(|p| {
matches!(&tree.items[p].kind,
TreeKind::Text { text, .. } | TreeKind::Code { text, .. } if text.is_empty())
});
(!in_text).then(|| "<text>".into())
}
_ => Some(format!("<{}>", text_token(label).unwrap_or("text"))),
};
let ldiv = match &item.kind {
TreeKind::Text { list: Some(m), .. } if is_list_item && !m.marker.is_empty() => {
format!("<ldiv><marker>{}</marker></ldiv>", escape(&m.marker))
}
_ if is_list_item => "<ldiv/>".to_string(),
_ => String::new(),
};
let code_label = match &item.kind {
TreeKind::Code { language, .. } => language.as_deref().and_then(code_lang_label),
_ => None,
};
let caption = match &item.kind {
TreeKind::Code { .. } => self.captions(id),
_ => String::new(),
};
let mut res = self.head(id, code_label, &caption, inline);
let children = &item.children;
let checkbox = match label {
"checkbox_selected" => Some("<checkbox class=\"selected\"/>"),
"checkbox_unselected" => Some("<checkbox class=\"unselected\"/>"),
_ => None,
};
let own = |ser: &Self| -> String {
let mut t = ser.post_process(&escape(text), formatting);
if label == "handwritten_text" {
t = wrap(&t, "handwriting");
} else if let Some(cb) = checkbox {
t = format!("{cb}{t}");
}
t
};
let text_part = match children.first() {
Some(&first) if self.is_inline_group(first) => {
let runs = self.serialize(first, false);
if text.is_empty() {
runs
} else {
[own(self), runs]
.into_iter()
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
Some(_) if text.is_empty() => {
let mut subs = Vec::new();
for &c in children {
if is_list_item && self.segment_sibling(c) {
continue;
}
subs.push(self.serialize(c, false));
}
subs.join(DELIM)
}
_ => own(self),
};
res.push_str(&text_part);
let bare_in_inline = inline
&& matches!(
label,
"text" | "handwritten_text" | "checkbox_selected" | "checkbox_unselected"
);
if let Some(open) = open.filter(|_| !bare_in_inline) {
let tag = open[1..]
.split([' ', '>'])
.next()
.unwrap_or("text")
.to_string();
res = format!("{open}{res}</{tag}>");
}
format!("{ldiv}{res}")
}
fn post_process(&self, text: &str, formatting: Option<crate::tree::Formatting>) -> String {
let mut res = text.to_string();
if let Some(f) = formatting {
if f.bold {
res = wrap(&res, "bold");
}
if f.italic {
res = wrap(&res, "italic");
}
if f.underline {
res = wrap(&res, "underline");
}
if f.strikethrough {
res = wrap(&res, "strikethrough");
}
match f.script {
crate::Script::Sub => res = wrap(&res, "subscript"),
crate::Script::Super => res = wrap(&res, "superscript"),
crate::Script::Baseline => {}
}
}
if is_rtl(text) {
res = wrap(&res, "rtl");
}
res
}
fn table(&mut self, id: usize) -> String {
let tree = self.tree;
let TreeKind::Table {
table, rich_cells, ..
} = &tree.items[id].kind
else {
unreachable!("table() on a non-table item");
};
let rich: HashMap<(usize, usize), usize> =
rich_cells.iter().map(|&(r, c, g)| ((r, c), g)).collect();
let data = crate::json::raw_table_data(table);
let caption = self.captions(id);
let mut otsl = String::new();
if data.has_cells() {
let (rows, cols) = data.dims();
for i in 0..rows {
for j in 0..cols {
let cell = data.at(i, j);
let (r0, c0) = (cell.start_row, cell.start_col);
if r0 != i || c0 != j {
otsl.push_str(match (r0 != i, c0 != j) {
(true, true) => "<xcel/>",
(true, false) => "<ucel/>",
_ => "<lcel/>",
});
continue;
}
let rich_group = rich.get(&(i, j)).copied();
let content = match rich_group {
Some(g) => self.serialize(g, false).trim().to_string(),
None => cell.text.trim().to_string(),
};
let token = match (cell.column_header, cell.row_header) {
(true, true) => "corn",
_ if content.is_empty() => "ecel",
(true, false) => "ched",
(false, true) => "rhed",
_ if cell.row_section => "srow",
_ => "fcel",
};
otsl.push_str(&format!("<{token}/>"));
if !content.is_empty() && token != "ecel" {
otsl.push_str(&match rich_group {
Some(_) => content,
None => escape(&content),
});
}
}
otsl.push_str("<nl/>");
}
}
let head = self.head(id, None, &caption, false);
wrap(&format!("{head}{otsl}"), "table")
}
fn picture(&mut self, id: usize) -> String {
let tree = self.tree;
let caption = self.captions(id);
let mut body = String::new();
if let TreeKind::Picture {
image: Some(img), ..
} = &tree.items[id].kind
{
let uri = match self.pictures {
Pictures::Placeholder => None,
Pictures::Embedded => Some(crate::pixel_digest::docling_data_uri(img).1),
Pictures::Referenced { .. } => self.pic_index.get(&id).map(|&idx| {
let path = format!(
"assets/image_{idx:06}_{}.png",
crate::pixel_digest::image_digest(&img.data)
);
if let Some(assets) = self.assets.as_mut() {
let bytes = crate::pixel_digest::asset_png(&img.data)
.unwrap_or_else(|| img.data.clone());
assets.push((path.clone(), bytes));
}
path
}),
};
if let Some(uri) = uri {
body.push_str(&format!("<src uri=\"{}\"/>", escape_attr(&uri)));
}
}
let caps = captions_of(&tree.items[id].kind);
for &c in &tree.items[id].children {
if caps.contains(&c) {
continue;
}
body.push_str(&self.serialize(c, false));
}
let head = self.head(id, None, &caption, false);
wrap(&format!("{head}{body}"), "picture")
}
}
fn is_list_label(label: &str) -> bool {
matches!(label, "list" | "ordered_list")
}
fn captions_of(kind: &TreeKind) -> &[usize] {
match kind {
TreeKind::Table { captions, .. }
| TreeKind::Picture { captions, .. }
| TreeKind::Code { captions, .. } => captions,
_ => &[],
}
}
fn layer_token(layer: ContentLayer) -> String {
format!("<layer value=\"{}\"/>", layer.value())
}
fn pre_order(tree: &ItemTree, id: usize, out: &mut Vec<usize>) {
let item = &tree.items[id];
if item.deleted {
return;
}
out.push(id);
let caps = match &item.kind {
TreeKind::Picture { captions, .. } => Some(captions),
_ => None,
};
for &c in &item.children {
if caps.is_some_and(|caps| !caps.contains(&c)) {
continue;
}
pre_order(tree, c, out);
}
}
enum XNode {
Elem {
name: String,
attrs: Vec<(String, String)>,
children: Vec<XNode>,
},
Text(String),
Cdata(String),
}
fn pretty(xml: &str) -> String {
let mut pos = 0;
let root = parse_children(xml, &mut pos).into_iter().next();
let mut out = String::new();
if let Some(root) = root {
write_node(&root, "", &mut out);
}
let mut lines = Vec::new();
let mut inside_content = false;
for line in out.split('\n') {
if line.contains("<content>") || line.contains("<content ") {
inside_content = true;
}
if line.contains("</content>") {
lines.push(line);
inside_content = false;
continue;
}
if inside_content || !line.trim().is_empty() {
lines.push(line);
}
}
let text = lines.join("\n");
static EXPAND: OnceLock<regex::Regex> = OnceLock::new();
let re = EXPAND.get_or_init(|| {
regex::Regex::new(&format!(
r"<\s*({})(\s[^>]*)?/\s*>",
NON_SELFCLOSING.join("|")
))
.expect("static pattern")
});
re.replace_all(&text, "<${1}${2}></${1}>").into_owned()
}
fn parse_children(s: &str, pos: &mut usize) -> Vec<XNode> {
let mut nodes: Vec<XNode> = Vec::new();
while *pos < s.len() {
let rest = &s[*pos..];
if let Some(body) = rest.strip_prefix("<![CDATA[") {
let end = body.find("]]>").unwrap_or(body.len());
nodes.push(XNode::Cdata(body[..end].to_string()));
*pos += "<![CDATA[".len() + end + "]]>".len();
} else if rest.starts_with("</") {
*pos += rest.find('>').map_or(rest.len(), |e| e + 1);
return nodes;
} else if rest.starts_with('<') {
let end = tag_end(rest);
let tag = &rest[1..end];
*pos += end + 1;
let (tag, self_closed) = match tag.strip_suffix('/') {
Some(t) => (t, true),
None => (tag, false),
};
let (name, attrs) = parse_tag(tag);
let children = if self_closed {
Vec::new()
} else {
parse_children(s, pos)
};
nodes.push(XNode::Elem {
name,
attrs,
children,
});
} else {
let end = rest.find('<').unwrap_or(rest.len());
let text = decode_entities(&rest[..end]);
*pos += end;
if let Some(XNode::Text(prev)) = nodes.last_mut() {
prev.push_str(&text);
} else {
nodes.push(XNode::Text(text));
}
}
}
nodes
}
fn tag_end(s: &str) -> usize {
let mut quoted = false;
for (i, c) in s.char_indices() {
match c {
'"' => quoted = !quoted,
'>' if !quoted => return i,
_ => {}
}
}
s.len()
}
fn parse_tag(tag: &str) -> (String, Vec<(String, String)>) {
let tag = tag.trim();
let (name, mut rest) = tag.split_once(char::is_whitespace).unwrap_or((tag, ""));
let mut attrs = Vec::new();
while let Some(eq) = rest.find('=') {
let key = rest[..eq].trim().to_string();
let after = &rest[eq + 1..];
let Some(open) = after.find('"') else { break };
let value_and_rest = &after[open + 1..];
let close = value_and_rest.find('"').unwrap_or(value_and_rest.len());
let value = decode_entities(&value_and_rest[..close]).replace(['\t', '\n', '\r'], " ");
attrs.push((key, value));
rest = value_and_rest.get(close + 1..).unwrap_or("");
}
(name.to_string(), attrs)
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
let Some(semi) = tail.find(';') else {
out.push_str(tail);
return out;
};
let entity = &tail[1..semi];
let decoded = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => entity
.strip_prefix("#x")
.and_then(|h| u32::from_str_radix(h, 16).ok())
.or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
.and_then(char::from_u32),
};
match decoded {
Some(c) => out.push(c),
None => out.push_str(&tail[..=semi]),
}
rest = &tail[semi + 1..];
}
out.push_str(rest);
out
}
fn write_data(out: &mut String, text: &str, attr: bool) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' if attr => out.push_str("""),
'\r' if attr => out.push_str(" "),
'\n' if attr => out.push_str(" "),
'\t' if attr => out.push_str("	"),
c => out.push(c),
}
}
}
fn write_node(node: &XNode, indent: &str, out: &mut String) {
match node {
XNode::Elem {
name,
attrs,
children,
} => {
out.push_str(indent);
out.push('<');
out.push_str(name);
for (k, v) in attrs {
out.push(' ');
out.push_str(k);
out.push_str("=\"");
write_data(out, v, true);
out.push('"');
}
if children.is_empty() {
out.push_str("/>\n");
return;
}
out.push('>');
match children.as_slice() {
[XNode::Text(t)] => write_data(out, t, false),
[XNode::Cdata(t)] => {
out.push_str("<![CDATA[");
out.push_str(t);
out.push_str("]]>");
}
_ => {
out.push('\n');
let inner = format!("{indent} ");
for c in children {
write_node(c, &inner, out);
}
out.push_str(indent);
}
}
out.push_str("</");
out.push_str(name);
out.push_str(">\n");
}
XNode::Text(t) => {
write_data(out, indent, false);
write_data(out, t, false);
out.push('\n');
}
XNode::Cdata(t) => {
out.push_str("<![CDATA[");
out.push_str(t);
out.push_str("]]>");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pretty_follows_minidom() {
let xml = "<doclang><list><ldiv/><![CDATA[a < b]]>\n<ldiv/>plain\n</list>\
<text><formula>x</formula>\n</text><picture></picture></doclang>";
assert_eq!(
pretty(xml),
"<doclang>\n <list>\n <ldiv/>\n<![CDATA[a < b]]> \n <ldiv/>\n plain\n </list>\n \
<text>\n <formula>x</formula>\n </text>\n <picture></picture>\n</doclang>"
);
}
#[test]
fn content_keeps_blank_lines() {
let xml = "<doclang><text><content>a\n\nb</content></text></doclang>";
assert_eq!(
pretty(xml),
"<doclang>\n <text>\n <content>a\n\nb</content>\n </text>\n</doclang>"
);
}
fn text(label: &str, text: &str) -> TreeKind {
TreeKind::Text {
label: label.into(),
text: text.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: (label == "list_item").then(Default::default),
}
}
fn group(label: &str) -> TreeKind {
TreeKind::Group {
label: label.into(),
name: "group".into(),
}
}
#[test]
fn tree_shapes_follow_docling_core() {
let mut t = ItemTree::default();
let list = t.add(None, None, group("list"));
t.add(Some(list), None, text("list_item", "\"C\" cell"));
t.add(Some(list), None, text("list_item", "plain"));
let pic = t.add(
None,
None,
TreeKind::Picture {
captions: Vec::new(),
image: None,
classification: None,
extra_classes: Vec::new(),
description: None,
confidence: None,
chart: None,
dpi: None,
},
);
let cap = t.add(None, None, text("caption", ""));
let runs = t.add(Some(cap), None, group("inline"));
t.add(Some(runs), None, text("text", "size "));
t.add(Some(runs), None, text("formula", "k"));
if let TreeKind::Picture { captions, .. } = &mut t.items[pic].kind {
captions.push(cap);
}
t.add(None, None, text("footnote", "A note."));
t.add(None, None, text("text", "Zhang* and Liu*"));
let (xml, _) = export(&t, Pictures::Placeholder).expect("covered tree");
assert_eq!(
xml,
"<doclang version=\"0.7\">\n <list>\n <ldiv/>\n<![CDATA[\"C\" cell]]> \n \
<ldiv/>\n plain\n </list>\n <picture>\n <caption>\n <content>size </content>\n \
<formula>k</formula>\n </caption>\n </picture>\n <footnote>A note.</footnote>\n \
<text>Zhang* and Liu*</text>\n</doclang>"
);
}
#[test]
fn uncovered_tree_falls_back() {
let mut t = ItemTree::default();
let id = t.add(None, None, text("text", "x"));
t.items[id].prov = Some(crate::tree::TreeProv {
page_no: 1,
bbox: [0.0, 0.0, 1.0, 1.0],
bottom_left: false,
charspan: [0, 1],
});
assert!(export(&t, Pictures::Placeholder).is_none());
}
#[test]
fn escape_matches_docling_core() {
assert_eq!(escape("a"), "a");
assert_eq!(escape("a & b"), "<![CDATA[a & b]]>");
assert_eq!(escape(" a"), "<content> a</content>");
assert_eq!(escape("x]]>y"), "<![CDATA[x]]]]><![CDATA[>y]]>");
assert_eq!(escape("a\u{1}"), "a[U+0001]");
}
}