use docling_core::{
inline_paragraph_node, ContentLayer, DoclingDocument, InlineRun, Node, Script, Table,
};
use crate::backend::markdown::escape_text;
const OBJECT_REPLACEMENT: char = '\u{FFFC}';
pub(crate) fn script_of(value: u64) -> Option<Script> {
match value {
1 => Some(Script::Super),
2 => Some(Script::Sub),
_ => None,
}
}
pub(crate) const LABEL_TYPE_NONE: u64 = 0;
pub(crate) const LABEL_TYPE_STRING: u64 = 2;
pub(crate) const LABEL_TYPE_NUMBER: u64 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub(crate) struct Formatting {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub script: Script,
}
impl Formatting {
pub(crate) fn build(
bold: bool,
italic: bool,
underline: bool,
strike: bool,
script: Option<Script>,
) -> Option<Formatting> {
if !(bold || italic || underline || strike) && script.is_none() {
return None;
}
Some(Formatting {
bold,
italic,
underline,
strike,
script: script.unwrap_or_default(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Run {
pub text: String,
pub fmt: Option<Formatting>,
pub link: Option<String>,
}
impl Run {
pub(crate) fn plain(text: impl Into<String>) -> Run {
Run {
text: text.into(),
fmt: None,
link: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ListLabel {
pub depth: usize,
pub enumerated: bool,
pub marker: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub(crate) struct ListStyle {
pub label_types: Vec<u64>,
pub strings: Vec<String>,
}
impl ListStyle {
pub(crate) fn label(&self, depth: usize) -> Option<ListLabel> {
let label_type = *self.label_types.get(depth)?;
if label_type == LABEL_TYPE_NONE {
return None;
}
if label_type == LABEL_TYPE_NUMBER {
return Some(ListLabel {
depth,
enumerated: true,
marker: String::new(),
});
}
let marker = self.strings.get(depth).cloned().unwrap_or_default();
Some(ListLabel {
depth,
enumerated: false,
marker: if marker.is_empty() {
"-".to_string()
} else {
marker
},
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Label {
Text,
Title,
Heading(u8),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Paragraph {
pub runs: Vec<Run>,
pub label: Label,
pub list: Option<ListLabel>,
pub anchors: Vec<String>,
}
impl Paragraph {
pub(crate) fn new(runs: Vec<Run>, label: Label) -> Paragraph {
Paragraph {
runs,
label,
list: None,
anchors: Vec::new(),
}
}
pub(crate) fn text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Picture {
pub data: Option<Vec<u8>>,
pub name: String,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Block {
Paragraph(Paragraph),
Picture(Picture),
Table(Table),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Comment {
pub text: String,
pub anchor: String,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct Content {
pub blocks: Vec<Block>,
pub headers: Vec<Paragraph>,
pub footers: Vec<Paragraph>,
pub footnotes: Vec<Paragraph>,
pub comments: Vec<Comment>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct StorageRuns {
pub styles: Vec<(usize, Option<String>)>,
pub characters: Vec<(usize, Option<Formatting>)>,
pub lists: Vec<(usize, Option<ListStyle>)>,
pub depths: Vec<(usize, usize)>,
pub links: Vec<(usize, Option<String>)>,
}
pub(crate) fn clean(text: &str) -> String {
text.replace(OBJECT_REPLACEMENT, "")
}
pub(crate) fn trim(runs: Vec<Run>) -> Vec<Run> {
let mut kept: Vec<Run> = runs.into_iter().filter(|r| !r.text.is_empty()).collect();
while let Some(head) = kept.first_mut() {
let trimmed = head.text.trim_start().to_string();
if trimmed.is_empty() {
kept.remove(0);
} else {
head.text = trimmed;
break;
}
}
while let Some(tail) = kept.last_mut() {
let trimmed = tail.text.trim_end().to_string();
if trimmed.is_empty() {
kept.pop();
} else {
tail.text = trimmed;
break;
}
}
kept
}
pub(crate) fn value_at<T: Clone>(table: &[(usize, T)], index: usize) -> Option<T> {
let mut current = None;
for (position, value) in table {
if *position > index {
break;
}
current = Some(value.clone());
}
current
}
pub(crate) fn runs_for(line: &[char], start: usize, runs: &StorageRuns) -> Vec<Run> {
let text_of = |from: usize, to: usize| clean(&line[from..to].iter().collect::<String>());
if runs.characters.is_empty() && runs.links.is_empty() {
return trim(vec![Run::plain(text_of(0, line.len()))]);
}
let end = start + line.len();
let mut inside: Vec<usize> = runs
.characters
.iter()
.map(|(i, _)| *i)
.chain(runs.links.iter().map(|(i, _)| *i))
.filter(|&i| start < i && i < end)
.collect();
inside.sort_unstable();
inside.dedup();
let mut boundaries = vec![start];
boundaries.extend(inside);
let mut pieces = Vec::new();
for (position, &begin) in boundaries.iter().enumerate() {
let stop = boundaries.get(position + 1).copied().unwrap_or(end);
let text = text_of(begin - start, stop - start);
if !text.is_empty() {
pieces.push(Run {
text,
fmt: value_at(&runs.characters, begin).flatten(),
link: value_at(&runs.links, begin).flatten(),
});
}
}
trim(pieces)
}
pub(crate) fn list_label_at(runs: &StorageRuns, offset: usize) -> Option<ListLabel> {
let style = value_at(&runs.lists, offset).flatten()?;
style.label(value_at(&runs.depths, offset).unwrap_or(0))
}
pub(crate) fn split_paragraphs(text: &[char], runs: &StorageRuns) -> Vec<Paragraph> {
let mut paragraphs = Vec::new();
let mut offset = 0usize;
for line in text.split(|&c| c == '\n') {
let pieces = runs_for(line, offset, runs);
if !pieces.is_empty() {
let label = label_for_style(value_at(&runs.styles, offset).flatten().as_deref());
paragraphs.push(Paragraph {
runs: pieces,
label,
list: list_label_at(runs, offset),
anchors: Vec::new(),
});
}
offset += line.len() + 1; }
paragraphs
}
pub(crate) fn label_for_style(style_name: Option<&str>) -> Label {
let Some(name) = style_name else {
return Label::Text;
};
let name = name.trim();
if name.is_empty() {
return Label::Text;
}
let lowered = name.to_lowercase();
if lowered == "title" {
return Label::Title;
}
if lowered == "subtitle" || lowered == "subheading" {
return Label::Heading(2);
}
if let Some(rest) = lowered.strip_prefix("heading") {
let digits = rest.trim_start();
if digits.is_empty() {
return Label::Heading(1);
}
if digits.chars().all(|c| c.is_ascii_digit()) {
let level = digits.parse::<u64>().unwrap_or(u64::MAX).min(6) as u8;
return Label::Heading(level);
}
}
Label::Text
}
pub(crate) fn authored(author: Option<&str>, text: &str) -> String {
match author {
Some(a) => format!("[author: {a}]: {text}"),
None => text.to_string(),
}
}
pub(crate) fn unique_paragraphs(paragraphs: Vec<Paragraph>) -> Vec<Paragraph> {
let mut seen = std::collections::HashSet::new();
paragraphs
.into_iter()
.filter(|p| seen.insert(p.text()))
.collect()
}
pub(crate) fn emit(content: Content, doc: &mut DoclingDocument) {
let mut anchored: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for (i, block) in content.blocks.iter().enumerate() {
if let Block::Paragraph(p) = block {
for a in &p.anchors {
anchored.entry(a.as_str()).or_insert(i);
}
}
}
let mut targets: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (ci, c) in content.comments.iter().enumerate() {
if let Some(&block) = anchored.get(c.anchor.as_str()) {
targets.entry(block).or_default().push(ci);
}
}
let mut lists = ListStack::default();
for (i, block) in content.blocks.iter().enumerate() {
let node = match block {
Block::Paragraph(p) => match &p.list {
Some(label) => lists.item(p, label),
None => {
lists.close();
paragraph_node(p)
}
},
Block::Picture(pic) => {
lists.close();
Node::Picture {
caption: None,
caption_href: None,
image: pic
.data
.as_ref()
.and_then(|d| crate::backend::ooxml::picture_image(&pic.name, d.clone())),
classification: None,
caption_parent: Default::default(),
}
}
Block::Table(t) => {
lists.close();
Node::Table(t.clone())
}
};
match targets.remove(&i) {
Some(comments) => doc.push(Node::Commented {
comments,
inner: Box::new(node),
}),
None => doc.push(node),
}
}
for paragraphs in [&content.headers, &content.footers, &content.footnotes] {
for p in paragraphs {
doc.push(Node::Furniture {
layer: ContentLayer::Furniture,
inner: Box::new(paragraph_node(p)),
});
}
}
for c in &content.comments {
doc.push(Node::CommentSection {
name: String::new(),
text: escape_text(&c.text),
refs_note_text: true,
grouped: false,
});
}
}
#[derive(Default)]
struct ListStack {
open: bool,
counts: Vec<u64>,
}
impl ListStack {
fn close(&mut self) {
self.open = false;
self.counts.clear();
}
fn item(&mut self, p: &Paragraph, label: &ListLabel) -> Node {
let first_in_list = !self.open;
self.open = true;
self.counts.truncate(label.depth + 1);
while self.counts.len() <= label.depth {
self.counts.push(0);
}
self.counts[label.depth] += 1;
let (uniform, _) = uniform_run(&p.runs);
let text = match uniform {
Some(run) => serialize_run(&p.text(), run.fmt, None),
None => escape_text(&p.text()),
};
Node::ListItem {
ordered: label.enumerated,
number: self.counts[label.depth],
first_in_list,
text,
level: label.depth.min(u8::MAX as usize) as u8,
marker: Some(label.marker.clone()),
location: None,
dclx: None,
href: uniform.and_then(|r| r.link.clone()),
layer: None,
}
}
}
fn uniform_run(runs: &[Run]) -> (Option<&Run>, bool) {
let runs: Vec<&Run> = runs.iter().filter(|r| !r.text.is_empty()).collect();
let Some(first) = runs.first() else {
return (None, true);
};
let uniform = runs
.iter()
.all(|r| r.fmt == first.fmt && r.link == first.link);
(uniform.then_some(*first), uniform)
}
fn paragraph_node(p: &Paragraph) -> Node {
match p.label {
Label::Title => Node::Heading {
level: 1,
text: escape_text(&p.text()),
},
Label::Heading(level) => Node::Heading {
level: level.saturating_add(1),
text: escape_text(&p.text()),
},
Label::Text => {
let runs: Vec<&Run> = p.runs.iter().filter(|r| !r.text.is_empty()).collect();
let (uniform, is_uniform) = uniform_run(&p.runs);
if is_uniform {
let fmt = uniform.and_then(|r| r.fmt);
let link = uniform.and_then(|r| r.link.as_deref());
let md = serialize_run(&p.text(), fmt, link);
let inline: Vec<InlineRun> = uniform
.map(|r| vec![inline_run(&p.text(), r.fmt)])
.unwrap_or_default();
inline_paragraph_node(md, inline, false)
} else {
let md = runs
.iter()
.map(|r| serialize_run(&r.text, r.fmt, r.link.as_deref()))
.collect::<Vec<_>>()
.join(" ");
let inline = runs.iter().map(|r| inline_run(&r.text, r.fmt)).collect();
inline_paragraph_node(md, inline, false)
}
}
}
}
fn inline_run(text: &str, fmt: Option<Formatting>) -> InlineRun {
let f = fmt.unwrap_or_default();
InlineRun {
text: text.to_string(),
bold: f.bold,
italic: f.italic,
underline: f.underline,
strike: f.strike,
script: f.script,
code: false,
formula: false,
}
}
fn serialize_run(text: &str, fmt: Option<Formatting>, link: Option<&str>) -> String {
let mut s = escape_text(text);
if let Some(f) = fmt {
if f.bold {
s = format!("**{s}**");
}
if f.italic {
s = format!("*{s}*");
}
if f.strike {
s = format!("~~{s}~~");
}
}
if let Some(url) = link {
s = format!("[{s}]({url})");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn chars(s: &str) -> Vec<char> {
s.chars().collect()
}
#[test]
fn style_names_map_to_labels_like_docling() {
use Label::*;
for (name, want) in [
(Some("Title"), Title),
(Some("Heading 1"), Heading(1)),
(Some("Heading 2"), Heading(2)),
(Some("Heading"), Heading(1)),
(Some("heading 9"), Heading(6)),
(Some("Subheading"), Heading(2)),
(Some("Subtitle"), Heading(2)),
(Some("Body"), Text),
(Some("Free Form"), Text),
(Some("Footnote Text"), Text),
(Some("Heading one"), Text),
(None, Text),
] {
assert_eq!(label_for_style(name), want, "{name:?}");
}
}
#[test]
fn paragraph_split_follows_style_runs() {
let runs = StorageRuns {
styles: vec![(0, Some("Title".into())), (6, Some("Body".into()))],
..Default::default()
};
let paras = split_paragraphs(&chars("Titl\u{FFFC}e\n\nBödy one\nBody two"), &runs);
let got: Vec<(String, Label)> = paras.iter().map(|p| (p.text(), p.label)).collect();
assert_eq!(
got,
vec![
("Title".to_string(), Label::Title),
("Bödy one".to_string(), Label::Text),
("Body two".to_string(), Label::Text),
]
);
}
#[test]
fn runs_keep_their_boundary_spaces_and_trim_only_the_ends() {
let bold = Formatting::build(true, false, false, false, None);
let runs = StorageRuns {
characters: vec![(0, None), (4, bold), (8, None)],
links: vec![(14, Some("https://x.y/".into())), (18, None)],
..Default::default()
};
let got = runs_for(&chars("see bold word here! "), 0, &runs);
assert_eq!(
got,
vec![
Run::plain("see "),
Run {
text: "bold".into(),
fmt: bold,
link: None
},
Run::plain(" word "),
Run {
text: "here".into(),
fmt: None,
link: Some("https://x.y/".into())
},
Run::plain("!"),
]
);
assert!(runs_for(&chars(" "), 0, &runs).is_empty());
}
#[test]
fn list_styles_label_each_depth() {
let style = ListStyle {
label_types: vec![LABEL_TYPE_STRING, LABEL_TYPE_NUMBER, 1, LABEL_TYPE_NONE],
strings: vec!["•".into(), String::new(), String::new()],
};
assert_eq!(
style.label(0),
Some(ListLabel {
depth: 0,
enumerated: false,
marker: "•".into()
})
);
assert_eq!(
style.label(1),
Some(ListLabel {
depth: 1,
enumerated: true,
marker: String::new()
})
);
assert_eq!(style.label(2).map(|l| l.marker), Some("-".to_string()));
assert_eq!(style.label(3), None);
assert_eq!(style.label(9), None);
}
#[test]
fn emission_mirrors_the_upstream_convert() {
let bold = Formatting::build(true, false, false, false, None);
let mut para = Paragraph::new(
vec![
Run::plain("Plain "),
Run {
text: "bold".into(),
fmt: bold,
link: None,
},
],
Label::Text,
);
para.anchors = vec!["a1".into()];
let item = |text: &str, depth: usize, enumerated: bool| {
let mut p = Paragraph::new(vec![Run::plain(text)], Label::Text);
p.list = Some(ListLabel {
depth,
enumerated,
marker: if enumerated {
String::new()
} else {
"•".into()
},
});
p
};
let content = Content {
blocks: vec![
Block::Paragraph(Paragraph::new(vec![Run::plain("T")], Label::Title)),
Block::Paragraph(para),
Block::Paragraph(Paragraph::new(
vec![Run {
text: "all bold".into(),
fmt: bold,
link: None,
}],
Label::Text,
)),
Block::Paragraph(item("one", 0, true)),
Block::Paragraph(item("two", 0, true)),
Block::Paragraph(item("deep", 1, false)),
Block::Table(Table {
rows: vec![vec!["c".into()]],
..Default::default()
}),
Block::Paragraph(item("again", 0, false)),
],
headers: vec![Paragraph::new(vec![Run::plain("HEAD")], Label::Text)],
comments: vec![Comment {
text: "note".into(),
anchor: "a1".into(),
}],
..Default::default()
};
let mut doc = DoclingDocument::new("t");
emit(content, &mut doc);
let md = doc.export_to_markdown();
assert_eq!(
md,
"# T\n\nPlain **bold**\n\n**all bold**\n\n1. one\n2. two\n - deep\n\n| c |\n|-----|\n\n- again\n"
);
assert!(matches!(&doc.nodes[1], Node::Commented { comments, .. } if comments == &[0]));
assert!(matches!(
&doc.nodes[2],
Node::InlineGroup { runs, .. } if runs.len() == 1 && runs[0].bold
));
assert!(matches!(
doc.nodes.last(),
Some(Node::CommentSection { grouped: false, text, .. }) if text == "note"
));
let v: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let notes: Vec<&serde_json::Value> = v["texts"]
.as_array()
.unwrap()
.iter()
.filter(|t| t["content_layer"] == "notes")
.collect();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0]["parent"]["$ref"], "#/body");
assert_eq!(notes[0]["label"], "text");
let annotated = &v["texts"][1];
assert_eq!(annotated["comments"][0]["$ref"], notes[0]["self_ref"]);
assert!(v["groups"]
.as_array()
.unwrap()
.iter()
.all(|g| g["label"] != "comment_section"));
}
}