use crate::document::{DoclingDocument, FieldItem, Node, Table};
const DOCUMENT_CLASS: &str = r"\documentclass[11pt,a4paper]{article}";
const PACKAGES: [&str; 11] = [
r"\usepackage[utf8]{inputenc} % allow utf-8 input",
r"\usepackage[T1]{fontenc} % use 8-bit T1 fonts",
r"\usepackage{hyperref} % hyperlinks",
r"\usepackage{url} % simple URL typesetting",
r"\usepackage{booktabs} % professional-quality tables",
r"\usepackage{amsfonts} % blackboard math symbols",
r"\usepackage{nicefrac} % compact symbols for 1/2, etc.",
r"\usepackage{microtype} % microtypography",
r"\usepackage{xcolor} % colors",
r"\usepackage{graphicx} % graphics",
r"\usepackage[normalem]{ulem} % strikethrough",
];
const IMAGE_PLACEHOLDER: &str = "% image";
const MISSING_TEXT: &str = "% missing-text";
const INDENT: usize = 2;
pub fn to_latex(doc: &DoclingDocument) -> String {
let parts = render_nodes(&doc.nodes, 1, false);
let mut body = parts.join("\n\n");
let mut title_cmd: Option<String> = None;
if let Some(first) = find_title(&body, 0) {
title_cmd = Some(format!("\\title{{{}}}", &body[first.1..first.2]));
let mut out = String::with_capacity(body.len());
let mut pos = 0;
let mut cur = Some(first);
while let Some((start, _, end)) = cur {
out.push_str(&body[pos..start]);
pos = end + 1;
cur = find_title(&body, pos);
}
out.push_str(&body[pos..]);
body = collapse_blank_runs(&out).trim().to_string();
}
let mut preamble: Vec<&str> = vec![DOCUMENT_CLASS, ""];
preamble.extend(PACKAGES);
let mut header = preamble.join("\n");
if let Some(t) = &title_cmd {
header.push('\n');
header.push_str(t);
}
header.push_str("\n\n\\begin{document}");
let mut blocks: Vec<&str> = Vec::new();
if title_cmd.is_some() {
blocks.push("\\maketitle");
}
if !body.is_empty() {
blocks.push(&body);
}
if blocks.is_empty() {
format!("{header}\n\n\\end{{document}}")
} else {
format!("{header}\n\n{}\n\n\\end{{document}}", blocks.join("\n\n"))
}
}
fn find_title(text: &str, from: usize) -> Option<(usize, usize, usize)> {
let mut search = from;
while let Some(rel) = text[search..].find("\\title") {
let start = search + rel;
let mut i = start + "\\title".len();
let bytes = text.as_bytes();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if bytes.get(i) == Some(&b'{') {
let content_start = i + 1;
if let Some(rel_end) = text[content_start..].find(['{', '}']) {
let end = content_start + rel_end;
if bytes[end] == b'}' {
return Some((start, content_start, end));
}
}
}
search = start + 1;
}
None
}
fn collapse_blank_runs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut newlines = 0;
for ch in text.chars() {
if ch == '\n' {
newlines += 1;
if newlines <= 2 {
out.push(ch);
}
} else {
newlines = 0;
out.push(ch);
}
}
out
}
pub fn escape_latex(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\\' => out.push_str(r"\textbackslash{}"),
'{' => out.push_str(r"\{"),
'}' => out.push_str(r"\}"),
'#' => out.push_str(r"\#"),
'$' => out.push_str(r"\$"),
'%' => out.push_str(r"\%"),
'&' => out.push_str(r"\&"),
'_' => out.push_str(r"\_"),
'~' => out.push_str(r"\textasciitilde{}"),
'^' => out.push_str(r"\textasciicircum{}"),
c => out.push(c),
}
}
out
}
fn unescape_text(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace("\\_", "_")
}
fn text_item(s: &str) -> String {
escape_latex(&unescape_text(s))
}
fn code_inline(raw: &str) -> String {
format!("\\texttt{{{}}}", raw.replace('#', "\\\\#"))
}
fn inline_md(md: &str) -> String {
let chars: Vec<char> = md.chars().collect();
render_inline(&chars, true)
}
fn inline_cell(md: &str) -> String {
let chars: Vec<char> = md.chars().collect();
render_inline(&chars, false)
}
fn starts_with(chars: &[char], at: usize, pat: &str) -> bool {
let mut it = chars[at..].iter();
pat.chars().all(|p| it.next() == Some(&p))
}
fn find(chars: &[char], from: usize, pat: &str) -> Option<usize> {
let plen = pat.chars().count();
(from..chars.len().saturating_sub(plen - 1)).find(|&i| starts_with(chars, i, pat))
}
fn render_inline(chars: &[char], formulas: bool) -> String {
let n = chars.len();
let mut out = String::new();
let mut plain = String::new();
fn flush(out: &mut String, plain: &mut String) {
if !plain.is_empty() {
out.push_str(&text_item(plain));
plain.clear();
}
}
let mut i = 0;
while i < n {
if chars[i] == '[' && !starts_with(chars, i, "[](") && !(i > 0 && chars[i - 1] == '!') {
if let Some((label_end, url_end)) = link_bounds(chars, i) {
flush(&mut out, &mut plain);
let url: String = chars[label_end + 2..url_end].iter().collect();
out.push_str(&hyperlink(&chars[i + 1..label_end], &url));
i = url_end + 1;
continue;
}
}
if starts_with(chars, i, "```") {
if let Some(end) = find(chars, i + 3, "```") {
flush(&mut out, &mut plain);
let raw: String = chars[i + 3..end].iter().collect();
let raw = unescape_text(raw.trim());
out.push_str(&format!("\\begin{{verbatim}}\n{raw}\n\\end{{verbatim}}"));
i = end + 3;
continue;
}
}
let mut matched = false;
for marker in ["***", "**", "*", "~~", "`"] {
if starts_with(chars, i, marker) {
let mlen = marker.chars().count();
if let Some(end) = find(chars, i + mlen, marker) {
let inner = &chars[i + mlen..end];
if !inner.is_empty() && !inner.iter().all(|c| c.is_whitespace()) {
flush(&mut out, &mut plain);
out.push_str(&span(marker, inner));
i = end + mlen;
matched = true;
}
}
break;
}
}
if matched {
continue;
}
if formulas && starts_with(chars, i, "$$") {
plain.push_str("$$");
i += 2;
continue;
}
if formulas && chars[i] == '$' {
if let Some(end) = find(chars, i + 1, "$") {
let non_space = |k: usize| chars.get(k).is_some_and(|c| !c.is_whitespace());
let digit_after = chars.get(end + 1).is_some_and(|c| c.is_ascii_digit());
if end > i + 1 && non_space(i + 1) && non_space(end - 1) && !digit_after {
flush(&mut out, &mut plain);
out.push('$');
out.extend(&chars[i + 1..end]);
out.push('$');
i = end + 1;
continue;
}
}
}
plain.push(chars[i]);
i += 1;
}
flush(&mut out, &mut plain);
out
}
fn link_bounds(chars: &[char], open: usize) -> Option<(usize, usize)> {
let mut depth = 0i32;
let mut label_end = None;
for k in open + 1..chars.len() {
match chars[k] {
'[' => depth += 1,
']' if depth > 0 => depth -= 1,
']' if starts_with(chars, k, "](") => {
label_end = Some(k);
break;
}
']' => return None,
_ => {}
}
}
let label_end = label_end?;
let mut depth = 0usize;
for (k, &c) in chars.iter().enumerate().skip(label_end + 2) {
match c {
'(' => depth += 1,
')' if depth == 0 => return Some((label_end, k)),
')' => depth -= 1,
_ => {}
}
}
None
}
fn is_bare_item(inner: &[char]) -> bool {
let n = inner.len();
if n < 3 {
return false;
}
let body = &inner[1..n - 1];
(inner[0] == '`' && inner[n - 1] == '`' && !body.contains(&'`'))
|| (inner[0] == '$' && inner[n - 1] == '$' && inner[1] != '$' && !body.contains(&'$'))
}
fn span(marker: &str, inner: &[char]) -> String {
if marker == "`" {
let raw: String = inner.iter().collect();
return code_inline(&unescape_text(&raw));
}
let content = render_inline(inner, true);
if is_bare_item(inner) {
return content;
}
match marker {
"***" => format!("\\textit{{\\textbf{{{content}}}}}"),
"**" => format!("\\textbf{{{content}}}"),
"*" => format!("\\textit{{{content}}}"),
_ => format!("\\sout{{{content}}}"),
}
}
fn hyperlink(label: &[char], url: &str) -> String {
let content = render_inline(label, true);
if is_bare_item(label) {
return content;
}
let url = unescape_text(url);
let url = match url.find("://") {
Some(p) => {
let (scheme, rest) = url.split_at(p + 3);
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
if rest[host_end..].starts_with('/') || rest.is_empty() {
url.clone()
} else {
format!("{scheme}{}/{}", &rest[..host_end], &rest[host_end..])
}
}
None => url,
};
format!("\\href{{{}}}{{{content}}}", escape_latex(&url))
}
fn render_nodes(nodes: &[Node], list_level: usize, inline: bool) -> Vec<String> {
let mut parts = Vec::new();
let mut i = 0;
while i < nodes.len() {
if matches!(nodes[i], Node::ListItem { .. }) {
let start = i;
i += 1;
loop {
match nodes.get(i) {
Some(Node::ListItem { .. }) => i += 1,
Some(Node::Paragraph { text })
if text.is_empty()
&& matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
{
i += 1
}
_ => break,
}
}
for group in sibling_lists(&nodes[start..i]) {
let text = render_list(group, list_level - 1);
if !text.is_empty() {
parts.push(text);
}
}
} else {
render_one(&nodes[i], list_level, inline, &mut parts);
i += 1;
}
}
parts
}
fn render_one(node: &Node, list_level: usize, inline: bool, parts: &mut Vec<String>) {
let push = |parts: &mut Vec<String>, s: String| {
if !s.is_empty() {
parts.push(s);
}
};
match node {
Node::Heading { level: 1, text } => push(parts, format!("\\title{{{}}}", inline_md(text))),
Node::Heading { level, text } => {
let cmd = match level {
2 => "section",
3 => "subsection",
4 => "subsubsection",
5 => "paragraph",
_ => "subparagraph",
};
push(parts, format!("\\{cmd}{{{}}}", inline_md(text)));
}
Node::Paragraph { text } => {
let t = text.trim();
match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
Some(inner) if !inner.is_empty() => push(parts, formula(inner, inline)),
_ => push(parts, inline_md(text)),
}
}
Node::CheckboxItem { text, .. } => push(parts, inline_md(text)),
Node::Code { text, .. } => {
let raw = unescape_text(text);
if inline {
push(parts, code_inline(&raw));
} else {
push(
parts,
format!("\\begin{{verbatim}}\n{raw}\n\\end{{verbatim}}"),
);
}
}
Node::Formula { latex, orig, .. } => {
if !latex.is_empty() {
push(parts, formula(latex, inline));
} else if !orig.is_empty() {
push(parts, "% formula-not-decoded".to_string());
}
}
Node::Table(t) => push(parts, render_table(t)),
Node::Picture {
caption,
classification,
..
} => {
let class = classification
.as_deref()
.and_then(|c| c.first())
.map(|c| c.class_name.replace('_', " "));
push(parts, render_figure(caption.as_deref(), class.as_deref()));
}
Node::Chart { caption, .. } => push(parts, render_figure(caption.as_deref(), None)),
Node::Group { label, children } => {
if label == "inline" {
push(parts, render_nodes(children, list_level, true).join(" "));
} else {
push(
parts,
render_nodes(children, list_level, inline).join("\n\n"),
);
}
}
Node::FieldRegion { items } => render_field_region(items, parts),
Node::InlineGroup { md_text, .. } => push(parts, inline_md(md_text)),
Node::TextDump(text) => push(parts, text_item(text)),
Node::Located { inner, .. } => render_one(inner, list_level, inline, parts),
Node::ListItem { .. } => {
let text = render_list(std::slice::from_ref(node), list_level - 1);
push(parts, text);
}
Node::DoclangOnly(_)
| Node::Furniture { .. }
| Node::PageFurniture { .. }
| Node::PageBreak
| Node::PageInfo { .. } => {}
}
}
fn formula(latex: &str, inline: bool) -> String {
if inline {
format!("${latex}$")
} else {
format!("$${latex}$$")
}
}
fn render_field_region(items: &[FieldItem], parts: &mut Vec<String>) {
parts.push(MISSING_TEXT.to_string());
for item in items {
parts.push(MISSING_TEXT.to_string());
for text in [&item.marker, &item.key, &item.value].into_iter().flatten() {
let t = text_item(text);
if !t.is_empty() {
parts.push(t);
}
}
}
}
fn render_figure(caption: Option<&str>, class: Option<&str>) -> String {
let mut lines = vec![
"\\begin{figure}[h]".to_string(),
IMAGE_PLACEHOLDER.to_string(),
];
if let Some(cap) = caption.filter(|c| !c.is_empty()) {
lines.push(format!("\\caption{{{}}}", text_item(cap)));
}
if let Some(class) = class {
lines.push(format!("% annotation[classification]: {class}"));
}
lines.push("\\end{figure}".to_string());
lines.join("\n")
}
fn render_table(t: &Table) -> String {
let num_rows = t.rows.len();
let num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
let mut grid: Vec<Vec<String>> = Vec::with_capacity(num_rows);
let cell = |r: usize, c: usize, flat: &str| -> String {
match t
.cell_blocks
.as_ref()
.and_then(|b| b.get(r))
.and_then(|row| row.get(c))
.filter(|b| !b.is_empty())
{
Some(blocks) => render_nodes(blocks, 1, false)
.join("\n\n")
.replace('\n', " "),
None => cell_text(flat),
}
};
match t.cells.as_ref().filter(|c| !c.is_empty()) {
Some(cells) => {
let mut by_pos: std::collections::HashMap<(usize, usize), &str> =
std::collections::HashMap::new();
for c in cells {
for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
by_pos.insert((r, k), c.text.as_str());
}
}
}
for r in 0..num_rows {
grid.push(
(0..num_cols)
.map(|c| cell(r, c, by_pos.get(&(r, c)).copied().unwrap_or("")))
.collect(),
);
}
}
None => {
for (r, row) in t.rows.iter().enumerate() {
grid.push(
(0..num_cols)
.map(|c| cell(r, c, row.get(c).map(String::as_str).unwrap_or("")))
.collect(),
);
}
}
}
let mut tabular = String::new();
if !grid.is_empty() {
let colspec = format!("|{}|", vec!["l"; num_cols].join("|"));
let mut lines = vec![
format!("\\begin{{tabular}}{{{colspec}}}"),
"\\hline".to_string(),
];
for row in &grid {
lines.push(format!("{} \\\\ \\hline", row.join(" & ")));
}
lines.push("\\end{tabular}".to_string());
tabular = lines.join("\n");
}
let caption = t
.caption
.as_deref()
.filter(|c| !c.is_empty())
.map(text_item)
.unwrap_or_default();
if tabular.is_empty() && caption.is_empty() {
return String::new();
}
let mut content = vec!["\\begin{table}[h]".to_string()];
if !caption.is_empty() {
content.push(format!("\\caption{{{caption}}}"));
}
if !tabular.is_empty() {
content.push(tabular);
}
content.push("\\end{table}".to_string());
content.join("\n")
}
fn cell_text(s: &str) -> String {
inline_cell(s).replace('\n', " ")
}
fn level_of(node: &Node) -> u8 {
match node {
Node::ListItem { level, .. } => *level,
_ => 0,
}
}
fn sibling_lists(run: &[Node]) -> Vec<&[Node]> {
let base = level_of(&run[0]);
let mut groups = Vec::new();
let mut seg = 0;
let mut prev: Option<(bool, u64)> = None;
let mut prev_projected = false;
for k in 0..run.len() {
let Node::ListItem {
ordered,
number,
first_in_list,
level,
dclx,
..
} = &run[k]
else {
continue;
};
if *level != base {
continue;
}
let eff_ordered = dclx.as_ref().map_or(*ordered, |d| d.ordered);
if k > seg {
if let Some((po, pn)) = prev {
let same_word_list = prev_projected && eff_ordered;
if *first_in_list
|| (!same_word_list && (po != *ordered || (*ordered && *number != pn + 1)))
{
groups.push(&run[seg..k]);
seg = k;
}
}
}
prev = Some((*ordered, *number));
prev_projected = eff_ordered && !*ordered;
}
groups.push(&run[seg..]);
groups
}
fn render_list(items: &[Node], depth: usize) -> String {
let base = level_of(&items[0]);
let enumerated = items.iter().find_map(|n| match n {
Node::ListItem {
level,
ordered,
dclx,
layer: None,
..
} if *level == base => Some(dclx.as_ref().map_or(*ordered, |d| d.ordered)),
_ => None,
});
let env = if enumerated.unwrap_or(false) {
"enumerate"
} else {
"itemize"
};
let mut lines: Vec<String> = Vec::new();
let mut i = 0;
while i < items.len() {
let Node::ListItem {
text, dclx, layer, ..
} = &items[i]
else {
i += 1;
continue;
};
if level_of(&items[i]) > base {
i += 1;
continue;
}
if layer.is_some() {
i += 1;
continue;
}
let text = dclx
.as_ref()
.filter(|d| !d.text.is_empty())
.map_or(text.as_str(), |d| d.text.as_str());
lines.push(format!("\\item {}", inline_md(text)));
let mut j = i + 1;
while j < items.len() && level_of(&items[j]) > base {
j += 1;
}
if j > i + 1 {
let nested: Vec<&Node> = items[i + 1..j]
.iter()
.filter(|n| matches!(n, Node::ListItem { .. }))
.collect();
if !nested.is_empty() {
let owned: Vec<Node> = nested.into_iter().cloned().collect();
for group in sibling_lists(&owned) {
let text = render_list(group, depth + 1);
if !text.is_empty() {
lines.push(text);
}
}
}
}
i = j;
}
if lines.is_empty() {
return String::new();
}
let indent = " ".repeat(depth * INDENT);
format!(
"{indent}\\begin{{{env}}}\n{}\n{indent}\\end{{{env}}}",
lines.join("\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_markers_become_latex_commands() {
assert_eq!(
inline_md("Foo *emphasis* **strong emphasis** ***both*** ~~gone~~ ."),
"Foo \\textit{emphasis} \\textbf{strong emphasis} \\textit{\\textbf{both}} \\sout{gone} ."
);
assert_eq!(inline_md("**50% & more**"), "\\textbf{50\\% \\& more}");
assert_eq!(inline_md("Brutto in *"), "Brutto in *");
assert_eq!(inline_md("x * * y"), "x * * y");
}
#[test]
fn inline_code_and_formulas() {
assert_eq!(inline_md("Run `a_b #1`"), "Run \\texttt{a_b \\\\#1}");
assert_eq!(
inline_md("Some *`formatted\\_code`*"),
"Some \\texttt{formatted_code}"
);
assert_eq!(
inline_md("area $A= \\pi r^{2}$ ."),
"area $A= \\pi r^{2}$ ."
);
assert_eq!(
inline_md("reached $9 million ($158 million today)"),
"reached \\$9 million (\\$158 million today)"
);
assert_eq!(
inline_md("[$$E=mc^2$$](https://x.org/a)"),
"\\href{https://x.org/a}{\\$\\$E=mc\\textasciicircum{}2\\$\\$}"
);
assert_eq!(
inline_cell("``` do_thing() ```"),
"\\begin{verbatim}\ndo_thing()\n\\end{verbatim}"
);
assert_eq!(inline_cell("$A=1$"), "\\$A=1\\$");
}
#[test]
fn located_pdf_footnote_link_renders_href() {
let mut doc = DoclingDocument::new("t");
doc.nodes.push(Node::Located {
location: [0, 0, 10, 10],
inner: Box::new(Node::Paragraph {
text: "[www.magenta.at/faq](https://www.magenta.at/faq)".into(),
}),
});
assert!(
to_latex(&doc).contains("\\href{https://www.magenta.at/faq}{www.magenta.at/faq}"),
"{}",
to_latex(&doc)
);
}
#[test]
fn hyperlinks() {
assert_eq!(
inline_md("Pull the [**repository**](https://github.com/x/y_z) ."),
"Pull the \\href{https://github.com/x/y\\_z}{\\textbf{repository}} ."
);
assert_eq!(inline_md("[top](#)"), "\\href{\\#}{top}");
assert_eq!(
inline_md("[E](https://example.com)"),
"\\href{https://example.com/}{E}"
);
assert_eq!(
inline_md("[E](https://example.com#s)"),
"\\href{https://example.com/\\#s}{E}"
);
assert_eq!(inline_md("[H](/home.html)"), "\\href{/home.html}{H}");
assert_eq!(
inline_md("[[ 1 ]](https://w.org/Duck_(film))"),
"\\href{https://w.org/Duck\\_(film)}{[ 1 ]}"
);
assert_eq!(inline_md(""), "");
assert_eq!(inline_md("[`code`](u)"), "\\texttt{code}");
}
#[test]
fn escapes_every_special_character_once() {
assert_eq!(
escape_latex(r"a\b{c}#$%&_~^"),
r"a\textbackslash{}b\{c\}\#\$\%\&\_\textasciitilde{}\textasciicircum{}"
);
}
#[test]
fn title_is_hoisted_and_maketitle_added() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Heading {
level: 1,
text: "Hello 100%".into(),
});
doc.push(Node::Paragraph {
text: "Body.".into(),
});
let tex = to_latex(&doc);
assert!(tex.contains("\\title{Hello 100\\%}\n\n\\begin{document}\n\n\\maketitle\n\nBody."));
assert!(!tex.ends_with('\n'));
}
#[test]
fn title_with_braces_stays_in_the_body() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Heading {
level: 1,
text: "a {b}".into(),
});
let tex = to_latex(&doc);
assert!(tex.contains("\\begin{document}\n\n\\title{a \\{b\\}}\n\n\\end{document}"));
assert!(!tex.contains("\\maketitle"));
}
#[test]
fn nested_lists_indent_their_environments_only() {
let item = |level: u8, text: &str, first: bool| Node::ListItem {
ordered: false,
number: 1,
first_in_list: first,
text: text.into(),
level,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
};
let mut doc = DoclingDocument::new("t");
doc.push(item(0, "a", true));
doc.push(item(1, "a1", true));
doc.push(item(0, "b", false));
let tex = to_latex(&doc);
assert!(
tex.contains(
"\\begin{itemize}\n\\item a\n \\begin{itemize}\n\\item a1\n \\end{itemize}\n\\item b\n\\end{itemize}"
),
"{tex}"
);
}
#[test]
fn empty_document_is_just_the_skeleton() {
let tex = to_latex(&DoclingDocument::new("t"));
assert!(tex.ends_with("\\begin{document}\n\n\\end{document}"));
}
}