use std::collections::HashMap;
use roxmltree::{Document, Node as XmlNode};
pub struct DrawingItem {
pub bbox: (usize, usize, usize, usize),
pub kind: DrawingKind,
}
pub enum DrawingKind {
Image(String),
Chart(String),
}
pub fn parse_drawing(xml: &str) -> Vec<DrawingItem> {
let Ok(dom) = Document::parse(xml) else {
return Vec::new();
};
let mut out = Vec::new();
for anchor in dom
.root_element()
.children()
.filter(|n| matches!(n.tag_name().name(), "twoCellAnchor" | "oneCellAnchor"))
{
let cell = |tag: &str| -> Option<(usize, usize)> {
let n = anchor.children().find(|c| c.has_tag_name(tag))?;
let num = |t: &str| {
n.children()
.find(|c| c.has_tag_name(t))
.and_then(|c| c.text())
.and_then(|s| s.trim().parse::<usize>().ok())
};
Some((num("col")?, num("row")?))
};
let Some((fc, fr)) = cell("from") else {
continue;
};
let bbox = match cell("to") {
Some((tc, tr)) => (fc, fr, tc + 1, tr + 1),
None => (fc, fr, fc + 1, fr + 1),
};
let kind = if let Some(blip) = anchor.descendants().find(|n| {
n.has_tag_name("blip") && !n.ancestors().any(|a| a.has_tag_name("graphicFrame"))
}) {
match blip.attributes().find(|a| a.name() == "embed") {
Some(a) => DrawingKind::Image(a.value().to_string()),
None => continue,
}
} else if let Some(chart) = anchor.descendants().find(|n| n.has_tag_name("chart")) {
match chart.attributes().find(|a| a.name() == "id") {
Some(a) => DrawingKind::Chart(a.value().to_string()),
None => continue,
}
} else {
continue;
};
out.push(DrawingItem { bbox, kind });
}
out
}
pub struct ChartSpec {
pub kind: &'static str,
pub title: Option<String>,
pub series: Vec<SeriesSpec>,
}
pub struct SeriesSpec {
pub name_ref: Option<String>,
pub name_lit: Option<String>,
pub cat_ref: Option<String>,
pub val_ref: Option<String>,
pub cat_cache: Vec<String>,
pub val_cache: Vec<String>,
pub name_cache: Option<String>,
}
fn classification(tag: &str) -> Option<&'static str> {
Some(match tag {
"barChart" | "bar3DChart" => "bar_chart",
"lineChart" | "line3DChart" => "line_chart",
"pieChart" | "pie3DChart" | "doughnutChart" => "pie_chart",
"scatterChart" => "scatter_chart",
"areaChart" | "area3DChart" => "other_chart",
_ => return None,
})
}
pub fn parse_chart(xml: &str) -> Option<ChartSpec> {
let dom = Document::parse(xml).ok()?;
let plot = dom.descendants().find(|n| n.has_tag_name("plotArea"))?;
let chart_el = plot
.children()
.find(|n| n.tag_name().name().ends_with("Chart"))?;
let kind = classification(chart_el.tag_name().name()).unwrap_or("other_chart");
let title = dom
.descendants()
.find(|n| n.has_tag_name("title"))
.map(|t| {
t.descendants()
.filter(|n| n.has_tag_name("t"))
.filter_map(|n| n.text())
.collect::<String>()
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let ref_formula = |node: XmlNode| -> Option<String> {
node.children()
.find(|c| matches!(c.tag_name().name(), "numRef" | "strRef"))
.and_then(|r| r.children().find(|c| c.has_tag_name("f")))
.and_then(|f| f.text())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let mut series = Vec::new();
for ser in chart_el.children().filter(|n| n.has_tag_name("ser")) {
let child = |tag: &str| ser.children().find(|c| c.has_tag_name(tag));
let name_ref = child("tx").and_then(ref_formula);
let name_lit = child("tx")
.and_then(|tx| tx.children().find(|c| c.has_tag_name("v")))
.and_then(|v| v.text())
.map(str::to_string);
let cat_ref = child("cat")
.and_then(ref_formula)
.or_else(|| child("xVal").and_then(ref_formula));
let val_ref = child("val")
.and_then(ref_formula)
.or_else(|| child("yVal").and_then(ref_formula));
let cat_cache = child("cat")
.map(cache_points)
.filter(|v| !v.is_empty())
.or_else(|| child("xVal").map(cache_points))
.unwrap_or_default();
let val_cache = child("val")
.map(cache_points)
.filter(|v| !v.is_empty())
.or_else(|| child("yVal").map(cache_points))
.unwrap_or_default();
let name_cache = child("tx")
.map(cache_points)
.and_then(|v| v.into_iter().next());
series.push(SeriesSpec {
name_ref,
name_lit,
cat_ref,
val_ref,
cat_cache,
val_cache,
name_cache,
});
}
Some(ChartSpec {
kind,
title,
series,
})
}
fn cache_points(node: XmlNode) -> Vec<String> {
let Some(cache) = node
.descendants()
.find(|c| matches!(c.tag_name().name(), "strCache" | "numCache"))
else {
return Vec::new();
};
let mut pts: Vec<(usize, String)> = cache
.children()
.filter(|c| c.has_tag_name("pt"))
.filter_map(|pt| {
let idx: usize = pt.attribute("idx")?.parse().ok()?;
let v = pt.children().find(|c| c.has_tag_name("v"))?.text()?;
Some((idx, format_cached_value(v)))
})
.collect();
pts.sort_by_key(|(i, _)| *i);
pts.into_iter().map(|(_, v)| v).collect()
}
fn format_cached_value(v: &str) -> String {
match v.trim().parse::<f64>() {
Ok(f) if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e15 => {
format!("{}", f as i64)
}
Ok(f) => format!("{f}"),
Err(_) => v.to_string(),
}
}
pub fn chart_table_from_columns(
categories: Vec<String>,
columns: Vec<(String, Vec<String>)>,
) -> Option<docling_core::Table> {
let num_data_rows = columns
.iter()
.map(|(_, v)| v.len())
.chain([categories.len()])
.max()
.unwrap_or(0);
if num_data_rows == 0 || columns.is_empty() {
return None;
}
let mut rows: Vec<Vec<String>> = Vec::new();
let mut header = vec![String::new()];
header.extend(columns.iter().map(|(n, _)| n.clone()));
rows.push(header);
for i in 0..num_data_rows {
let mut row = vec![categories.get(i).cloned().unwrap_or_default()];
for (_, values) in &columns {
row.push(values.get(i).cloned().unwrap_or_default());
}
rows.push(row);
}
let nrows = rows.len();
let ncols = rows[0].len();
let mut header_row = vec![false; nrows];
header_row[0] = true;
let mut row_header = vec![vec![false; ncols]; nrows];
for r in row_header.iter_mut().skip(1) {
r[0] = true;
}
Some(docling_core::Table {
rows,
location: None,
structure: Some(docling_core::TableStructure {
header_row,
col_continuation: Vec::new(),
row_continuation: Vec::new(),
row_header,
col_header: Vec::new(),
}),
cell_blocks: None,
})
}
pub fn chart_table_from_caches(spec: &ChartSpec) -> Option<docling_core::Table> {
if spec.series.is_empty() {
return None;
}
let categories = spec
.series
.iter()
.map(|s| s.cat_cache.clone())
.find(|c| !c.is_empty())
.unwrap_or_default();
let columns: Vec<(String, Vec<String>)> = spec
.series
.iter()
.map(|s| {
let name = s
.name_cache
.clone()
.or_else(|| s.name_lit.clone())
.unwrap_or_default();
(name, s.val_cache.clone())
})
.collect();
chart_table_from_columns(categories, columns)
}
pub type RangeBounds = (usize, usize, usize, usize);
pub fn parse_range_ref(reference: &str) -> Option<(Option<String>, RangeBounds)> {
let (sheet, cells) = match reference.rsplit_once('!') {
Some((s, c)) => {
let s = s.trim();
let name = if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
s[1..s.len() - 1].replace("''", "'")
} else {
s.to_string()
};
(Some(name), c)
}
None => (None, reference),
};
let mut corners = cells.split(':');
let a = cell_ref(corners.next()?)?;
let b = match corners.next() {
Some(c) => cell_ref(c)?,
None => a,
};
Some((
sheet,
(a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)),
))
}
fn cell_ref(s: &str) -> Option<(usize, usize)> {
let s = s.replace('$', "");
let letters: String = s.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let digits: String = s.chars().skip_while(|c| c.is_ascii_alphabetic()).collect();
if letters.is_empty() || digits.is_empty() {
return None;
}
let col = letters.chars().fold(0usize, |acc, c| {
acc * 26 + (c.to_ascii_uppercase() as usize - 'A' as usize + 1)
});
Some((col - 1, digits.parse::<usize>().ok()? - 1))
}
pub fn parse_legacy_comments(xml: &str) -> Vec<(String, String, String)> {
let Ok(dom) = Document::parse(xml) else {
return Vec::new();
};
let authors: Vec<String> = dom
.descendants()
.find(|n| n.has_tag_name("authors"))
.map(|a| {
a.children()
.filter(|c| c.has_tag_name("author"))
.map(|c| c.text().unwrap_or("").to_string())
.collect()
})
.unwrap_or_default();
let mut out = Vec::new();
for c in dom.descendants().filter(|n| n.has_tag_name("comment")) {
let cell = c
.attributes()
.find(|a| a.name() == "ref")
.map(|a| a.value().to_string())
.unwrap_or_default();
let author = c
.attributes()
.find(|a| a.name() == "authorId")
.and_then(|a| a.value().parse::<usize>().ok())
.and_then(|i| authors.get(i).cloned())
.unwrap_or_default();
let text: String = c
.descendants()
.filter(|n| n.has_tag_name("t"))
.filter_map(|n| n.text())
.collect();
out.push((cell, author, text.trim().to_string()));
}
out
}
pub fn parse_threaded_comments(
xml: &str,
persons: &HashMap<String, String>,
) -> HashMap<String, (String, String, Option<String>)> {
let Ok(dom) = Document::parse(xml) else {
return HashMap::new();
};
let mut out = HashMap::new();
for c in dom
.descendants()
.filter(|n| n.has_tag_name("threadedComment"))
{
let attr = |name: &str| {
c.attributes()
.find(|a| a.name() == name)
.map(|a| a.value().to_string())
};
let Some(cell) = attr("ref") else { continue };
let author = attr("personId")
.and_then(|id| persons.get(&id).cloned())
.unwrap_or_else(|| "Unknown".to_string());
let text = c
.children()
.find(|n| n.has_tag_name("text"))
.and_then(|t| t.text())
.unwrap_or("")
.to_string();
let time = attr("dT").map(|t| format_comment_time(&t));
out.insert(cell, (author, text, time));
}
out
}
pub fn parse_persons(xml: &str) -> HashMap<String, String> {
let Ok(dom) = Document::parse(xml) else {
return HashMap::new();
};
dom.descendants()
.filter(|n| n.has_tag_name("person"))
.filter_map(|p| {
let get = |name: &str| {
p.attributes()
.find(|a| a.name() == name)
.map(|a| a.value().to_string())
};
Some((get("id")?, get("displayName")?))
})
.collect()
}
fn format_comment_time(raw: &str) -> String {
let (base, tz) = match raw.strip_suffix('Z') {
Some(b) => (b, "+00:00"),
None => (raw, ""),
};
let (secs, frac) = match base.split_once('.') {
Some((s, f)) => (s, f),
None => (base, ""),
};
let mut ms = frac.to_string();
ms.truncate(3);
while ms.len() < 3 {
ms.push('0');
}
format!("{secs}.{ms}{tz}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_refs() {
assert_eq!(
parse_range_ref("'Duck Observations'!$B$2:$B$7"),
Some((Some("Duck Observations".to_string()), (1, 1, 1, 6)))
);
assert_eq!(
parse_range_ref("Sheet1!$A$1"),
Some((Some("Sheet1".to_string()), (0, 0, 0, 0)))
);
assert_eq!(cell_ref("$AB$10"), Some((27, 9)));
}
#[test]
fn comment_time() {
assert_eq!(
format_comment_time("2026-06-18T17:15:52.31"),
"2026-06-18T17:15:52.310"
);
assert_eq!(
format_comment_time("2026-06-18T17:15:52"),
"2026-06-18T17:15:52.000"
);
assert_eq!(
format_comment_time("2026-06-18T17:15:52.3123Z"),
"2026-06-18T17:15:52.312+00:00"
);
}
}