use docling_core::{DoclingDocument, Node};
use roxmltree::{Document, Node as XmlNode};
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
pub struct SvgBackend;
#[derive(Clone, Copy)]
struct Xf([f32; 6]);
impl Xf {
const IDENTITY: Xf = Xf([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
fn apply(&self, x: f32, y: f32) -> (f32, f32) {
let m = self.0;
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn then(&self, o: Xf) -> Xf {
let (a, b) = (self.0, o.0);
Xf([
a[0] * b[0] + a[2] * b[1],
a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3],
a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4],
a[1] * b[4] + a[3] * b[5] + a[5],
])
}
}
fn parse_transform(s: &str) -> Xf {
let mut xf = Xf::IDENTITY;
let mut rest = s;
while let Some(open) = rest.find('(') {
let name = rest[..open].trim().trim_start_matches(',').trim();
let Some(close) = rest[open..].find(')') else {
break;
};
let args: Vec<f32> = rest[open + 1..open + close]
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|t| !t.is_empty())
.filter_map(|t| t.parse().ok())
.collect();
rest = &rest[open + close + 1..];
let step = match (name, args.as_slice()) {
("translate", [tx]) => Xf([1.0, 0.0, 0.0, 1.0, *tx, 0.0]),
("translate", [tx, ty]) => Xf([1.0, 0.0, 0.0, 1.0, *tx, *ty]),
("scale", [k]) => Xf([*k, 0.0, 0.0, *k, 0.0, 0.0]),
("scale", [kx, ky]) => Xf([*kx, 0.0, 0.0, *ky, 0.0, 0.0]),
("rotate", [deg]) => rotation(*deg, 0.0, 0.0),
("rotate", [deg, cx, cy]) => rotation(*deg, *cx, *cy),
("matrix", [a, b, c, d, e, f]) => Xf([*a, *b, *c, *d, *e, *f]),
_ => continue,
};
xf = xf.then(step);
}
xf
}
fn rotation(deg: f32, cx: f32, cy: f32) -> Xf {
let (s, c) = deg.to_radians().sin_cos();
Xf([c, s, -s, c, cx - c * cx + s * cy, cy - s * cx - c * cy])
}
struct Label {
x: f32,
y: f32,
fs: f32,
text: String,
}
fn first_coord(node: XmlNode, attr: &str) -> Option<f32> {
parse_len(node.attribute(attr)?)
}
fn parse_len(s: &str) -> Option<f32> {
let t = s.trim().trim_end_matches("px").trim_end_matches("pt");
t.trim().parse().ok()
}
fn own_font_size(node: XmlNode) -> Option<f32> {
if let Some(v) = node.attribute("font-size").and_then(parse_len) {
return Some(v);
}
let style = node.attribute("style")?;
style.split(';').find_map(|decl| {
let (k, v) = decl.split_once(':')?;
(k.trim() == "font-size").then(|| parse_len(v)).flatten()
})
}
fn is_hidden(node: XmlNode) -> bool {
if node.attribute("display") == Some("none") || node.attribute("visibility") == Some("hidden") {
return true;
}
node.attribute("style").is_some_and(|s| {
s.split(';').any(|decl| {
matches!(
decl.split_once(':').map(|(k, v)| (k.trim(), v.trim())),
Some(("display", "none")) | Some(("visibility", "hidden"))
)
})
})
}
const UNRENDERED: &[&str] = &[
"defs", "clipPath", "mask", "pattern", "symbol", "marker", "filter", "style", "script",
"metadata", "title", "desc",
];
fn walk(node: XmlNode, xf: Xf, fs: f32, labels: &mut Vec<Label>) {
for child in node.children().filter(XmlNode::is_element) {
let tag = child.tag_name().name();
if UNRENDERED.contains(&tag) || is_hidden(child) {
continue;
}
let cxf = match child.attribute("transform") {
Some(t) => xf.then(parse_transform(t)),
None => xf,
};
let cfs = own_font_size(child).unwrap_or(fs);
if tag == "text" {
collect_text(child, cxf, cfs, labels);
} else {
walk(child, cxf, cfs, labels);
}
}
}
fn collect_text(text: XmlNode, xf: Xf, fs: f32, labels: &mut Vec<Label>) {
let mut walk = TextWalk {
xf,
pen: (
first_coord(text, "x").unwrap_or(0.0),
first_coord(text, "y").unwrap_or(0.0),
),
run: String::new(),
run_pos: (0.0, 0.0),
run_fs: fs,
labels,
};
walk.run_pos = walk.pen;
walk.rec(text, fs);
walk.flush();
}
struct TextWalk<'a> {
xf: Xf,
pen: (f32, f32),
run: String,
run_pos: (f32, f32),
run_fs: f32,
labels: &'a mut Vec<Label>,
}
impl TextWalk<'_> {
fn flush(&mut self) {
let t = normalize_ws(&self.run);
self.run.clear();
if !t.is_empty() {
let (x, y) = self.xf.apply(self.run_pos.0, self.run_pos.1);
self.labels.push(Label {
x,
y,
fs: self.run_fs,
text: t,
});
}
}
fn rec(&mut self, node: XmlNode, fs: f32) {
for child in node.children() {
if child.is_text() {
if self.run.is_empty() {
self.run_pos = self.pen;
self.run_fs = fs;
}
self.run.push_str(child.text().unwrap_or(""));
continue;
}
if !child.is_element() || is_hidden(child) {
continue;
}
let cfs = own_font_size(child).unwrap_or(fs);
if child.has_attribute("x") || child.has_attribute("y") {
self.flush();
if let Some(x) = first_coord(child, "x") {
self.pen.0 = x;
}
if let Some(y) = first_coord(child, "y") {
self.pen.1 = y;
}
}
for (attr, axis) in [("dx", 0usize), ("dy", 1usize)] {
if let Some(raw) = child.attribute(attr) {
let delta = match raw.trim().strip_suffix("em") {
Some(n) => n.trim().parse::<f32>().ok().map(|v| v * cfs),
None => parse_len(raw),
};
if let Some(d) = delta {
if d != 0.0 {
self.flush();
if axis == 0 {
self.pen.0 += d;
} else {
self.pen.1 += d;
}
}
}
}
}
self.rec(child, cfs);
}
}
}
fn normalize_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn extract_text(source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let xml = source.text()?;
let tree = Document::parse(xml).map_err(|e| ConversionError::Parse(format!("svg: {e}")))?;
let root = tree.root_element();
if root.tag_name().name() != "svg" {
return Err(ConversionError::Parse(format!(
"svg: root element is <{}>, not <svg>",
root.tag_name().name()
)));
}
let mut doc = DoclingDocument::new(&source.name);
for child in root.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"title" => {
let t = normalize_ws(child.text().unwrap_or(""));
if !t.is_empty() {
doc.push(Node::Heading { level: 1, text: t });
}
}
"desc" => {
let t = normalize_ws(child.text().unwrap_or(""));
if !t.is_empty() {
doc.push(Node::Paragraph { text: t });
}
}
_ => {}
}
}
let mut labels = Vec::new();
walk(root, Xf::IDENTITY, 16.0, &mut labels);
labels.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)));
let mut i = 0;
while i < labels.len() {
let mut j = i + 1;
let mut band_y = labels[i].y;
let mut band_fs = labels[i].fs;
while j < labels.len() && labels[j].y - band_y <= 0.6 * band_fs.max(labels[j].fs) {
band_y = labels[j].y;
band_fs = band_fs.max(labels[j].fs);
j += 1;
}
let mut line: Vec<&Label> = labels[i..j].iter().collect();
line.sort_by(|a, b| a.x.total_cmp(&b.x));
let text = line
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join(" ");
doc.push(Node::Paragraph { text });
i = j;
}
if doc.nodes.is_empty() {
return Err(ConversionError::Parse(
"SVG contains no text elements; graphical content needs the ML image pipeline \
(a build with the `pdf` feature, converting without --no-ocr)"
.into(),
));
}
Ok(doc)
}
impl DeclarativeBackend for SvgBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
extract_text(source)
}
}
#[cfg(feature = "pdf")]
pub(crate) fn rasterize_png(bytes: &[u8]) -> Result<Vec<u8>, ConversionError> {
use resvg::{tiny_skia, usvg};
let mut opt = usvg::Options::default();
opt.fontdb_mut().load_system_fonts();
if opt.fontdb.is_empty() {
eprintln!("docling: warning: no system fonts found; SVG text will not rasterize");
}
let tree = usvg::Tree::from_data(bytes, &opt)
.map_err(|e| ConversionError::Parse(format!("svg: {e}")))?;
let (w, h) = (tree.size().width(), tree.size().height());
let long = w.max(h).max(1.0);
let scale = (2048.0 / long).clamp(0.05, 8.0);
let (pw, ph) = (
(w * scale).round().max(1.0) as u32,
(h * scale).round().max(1.0) as u32,
);
let mut pixmap = tiny_skia::Pixmap::new(pw, ph)
.ok_or_else(|| ConversionError::Parse(format!("svg: bad raster size {pw}x{ph}")))?;
pixmap.fill(tiny_skia::Color::WHITE);
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
pixmap
.encode_png()
.map_err(|e| ConversionError::Parse(format!("svg: png encode: {e}")))
}