use crate::format::DocumentFormat;
use crate::ir::*;
const MAX_ROWS_PER_SHEET: usize = 10_000;
fn parse_hex_rgb(s: &str) -> Option<[u8; 3]> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
pub(crate) fn xlsx_to_ir(doc: &crate::xlsx::XlsxDocument) -> DocumentIR {
let date_indices = doc.date_style_indices();
let mut buf = String::new();
let mut sections = Vec::new();
for (ws_idx, ws) in doc.worksheets.iter().enumerate() {
let total_rows = ws.rows.len();
let mut parsed_rows: Vec<Vec<CellData>> =
Vec::with_capacity(total_rows.min(MAX_ROWS_PER_SHEET));
for row in ws.rows.iter().take(MAX_ROWS_PER_SHEET) {
let mut cells: Vec<CellData> = Vec::with_capacity(row.cells.len());
for cell in &row.cells {
buf.clear();
doc.write_cell_value_fast(cell, &mut buf, &date_indices);
let text = if buf.is_empty() {
String::new()
} else {
std::mem::take(&mut buf)
};
let (data_type, raw_number, number_format, number_format_id) =
cell_semantics(doc, cell, &date_indices);
cells.push(CellData {
text,
style_index: cell.style_index,
data_type,
raw_number,
number_format,
number_format_id,
});
}
while cells.last().is_some_and(|cd| cd.text.is_empty()) {
cells.pop();
}
parsed_rows.push(cells);
}
let mut prose_score = 0usize;
let mut nonempty_rows = 0usize;
for cells in &parsed_rows {
let nc = cells.iter().filter(|cd| !cd.text.is_empty()).count();
if nc == 0 {
continue;
}
nonempty_rows += 1;
if nc <= 1 {
prose_score += 1;
}
}
let prose_mode = nonempty_rows >= 3 && prose_score * 100 >= nonempty_rows * 80;
let mut image_elements: Vec<Element> =
Vec::with_capacity(ws.images.len() + ws.text_shapes.len());
for ts in &ws.text_shapes {
let mut span = TextSpan::plain(ts.text.clone());
if let Some(sz) = ts.font_size_pt {
span.font_size_half_pt =
Some(crate::core::units::HalfPoint::from_points_rounded(sz as f64).0);
}
if ts.bold {
span.bold = true;
}
if ts.italic {
span.italic = true;
}
if let Some(ref hex) = ts.color_hex {
if let Some(rgb) = parse_hex_rgb(hex) {
span.color = Some(rgb);
}
}
if let Some(ref f) = ts.font_name {
span.font_name = Some(f.clone());
}
let para = Element::Paragraph(Paragraph {
content: vec![InlineContent::Text(span)],
..Default::default()
});
image_elements.push(Element::TextBox(TextBox {
content: vec![para],
x_emu: Some(ts.x_emu),
y_emu: Some(ts.y_emu),
width_emu: Some(ts.cx_emu.max(0) as u64),
height_emu: Some(ts.cy_emu.max(0) as u64),
..Default::default()
}));
}
for pic in &ws.images {
let format = image_format_from_ext(&pic.format);
let img = Image {
alt_text: pic.alt_text.clone(),
data: Some(pic.data.clone()),
format,
display_width_emu: Some(pic.cx_emu.max(0) as u64),
display_height_emu: Some(pic.cy_emu.max(0) as u64),
..Default::default()
};
if pic.cx_emu > 0 && pic.cy_emu > 0 {
image_elements.push(Element::TextBox(TextBox {
content: vec![Element::Image(img)],
x_emu: Some(pic.x_emu),
y_emu: Some(pic.y_emu),
width_emu: Some(pic.cx_emu.max(0) as u64),
height_emu: Some(pic.cy_emu.max(0) as u64),
..Default::default()
}));
} else {
image_elements.push(Element::Image(img));
}
}
let elements = if prose_mode {
let mut out: Vec<Element> = Vec::new();
for cells in &parsed_rows {
let Some(cd) = cells.iter().find(|cd| !cd.text.is_empty()) else {
continue;
};
let mut span = TextSpan::plain(cd.text.clone());
if let Some(idx) = cd.style_index {
if let Some(font) = font_for(doc, idx) {
if let Some(size_pt) = font.size {
span.font_size_half_pt =
Some(crate::core::units::HalfPoint::from_points_rounded(size_pt).0);
}
if font.bold {
span.bold = true;
}
if font.italic {
span.italic = true;
}
}
}
out.push(Element::Paragraph(Paragraph {
content: vec![InlineContent::Text(span)],
..Default::default()
}));
}
out
} else {
let mut rows: Vec<TableRow> = Vec::with_capacity(parsed_rows.len());
for (row_idx, cells) in parsed_rows.iter().enumerate() {
let mut tcells: Vec<TableCell> = Vec::with_capacity(cells.len());
for cd in cells {
let content = if cd.text.is_empty() {
Vec::new()
} else {
vec![InlineContent::Text(TextSpan::plain(cd.text.clone()))]
};
tcells.push(TableCell {
content: vec![Element::Paragraph(Paragraph {
content,
..Default::default()
})],
col_span: 1,
row_span: 1,
data_type: cd.data_type,
raw_number: cd.raw_number,
number_format: cd.number_format.clone(),
number_format_id: cd.number_format_id,
..Default::default()
});
}
rows.push(TableRow {
cells: tcells,
is_header: row_idx == 0,
..Default::default()
});
}
if rows.is_empty() {
Vec::new()
} else {
vec![Element::Table(Table {
rows,
..Default::default()
})]
}
};
let page_setup = ws.page_setup.map(|wsp| {
let default = PageSetup::default();
PageSetup {
width_twips: if wsp.width_twips == 0 {
default.width_twips
} else {
wsp.width_twips
},
height_twips: if wsp.height_twips == 0 {
default.height_twips
} else {
wsp.height_twips
},
margin_top_twips: wsp.margin_top_twips,
margin_bottom_twips: wsp.margin_bottom_twips,
margin_left_twips: wsp.margin_left_twips,
margin_right_twips: wsp.margin_right_twips,
header_distance_twips: wsp.header_distance_twips,
footer_distance_twips: wsp.footer_distance_twips,
landscape: wsp.landscape,
}
});
let break_type = if ws_idx == 0 {
SectionBreakType::Continuous
} else {
SectionBreakType::NextPage
};
let mut combined: Vec<Element> = image_elements;
combined.extend(elements);
if total_rows > MAX_ROWS_PER_SHEET {
let omitted = total_rows - MAX_ROWS_PER_SHEET;
combined.push(Element::Paragraph(Paragraph {
content: vec![InlineContent::Text(TextSpan::plain(format!(
"[{omitted} of {total_rows} rows not shown — worksheet truncated at {MAX_ROWS_PER_SHEET} rows]"
)))],
..Default::default()
}));
}
sections.push(Section {
title: Some(ws.name.clone()),
elements: combined,
page_setup,
break_type,
..Default::default()
});
}
if !doc.chart_text.is_empty() {
let mut chart_elements: Vec<Element> = Vec::new();
for (i, text) in doc.chart_text.iter().enumerate() {
chart_elements.push(Element::Heading(Heading {
level: 3,
content: vec![InlineContent::Text(TextSpan::plain(format!(
"Chart {}",
i + 1
)))],
..Default::default()
}));
chart_elements.push(Element::Paragraph(Paragraph {
content: vec![InlineContent::Text(TextSpan::plain(text.clone()))],
..Default::default()
}));
}
sections.push(Section {
title: Some("Charts".to_string()),
elements: chart_elements,
..Default::default()
});
}
let title = sections.first().and_then(|s| s.title.clone());
DocumentIR {
metadata: Metadata {
format: DocumentFormat::Xlsx,
title,
..Default::default()
},
sections,
}
}
struct CellData {
text: String,
style_index: Option<u32>,
data_type: Option<CellDataType>,
raw_number: Option<f64>,
number_format: Option<String>,
number_format_id: Option<u32>,
}
fn cell_semantics(
doc: &crate::xlsx::XlsxDocument,
cell: &crate::xlsx::Cell,
date_indices: &std::collections::HashSet<u32>,
) -> (Option<CellDataType>, Option<f64>, Option<String>, Option<u32>) {
use crate::xlsx::CellValue;
if matches!(cell.value, CellValue::Empty) {
return (None, None, None, None);
}
let fmt_id = cell
.style_index
.and_then(|idx| doc.styles.as_ref()?.number_format_id_for(idx))
.filter(|&id| id != 0);
let fmt_str = cell
.style_index
.and_then(|idx| doc.styles.as_ref()?.number_format_for(idx))
.map(|s| s.to_string())
.or_else(|| {
fmt_id.and_then(|id| crate::xlsx::numfmt::builtin_format_code(id).map(str::to_string))
});
let (data_type, raw_number) = match &cell.value {
CellValue::Empty => unreachable!("handled above"),
CellValue::Number(n) => {
let is_date = cell.style_index.is_some_and(|i| date_indices.contains(&i));
let ty = if is_date {
CellDataType::Date
} else {
CellDataType::Number
};
(Some(ty), Some(*n))
},
CellValue::Date(_) => (Some(CellDataType::Date), None),
CellValue::Boolean(b) => (Some(CellDataType::Boolean), Some(if *b { 1.0 } else { 0.0 })),
CellValue::Error(_) => (Some(CellDataType::Error), None),
CellValue::String(_) | CellValue::SharedString(_) => (Some(CellDataType::Text), None),
};
(data_type, raw_number, fmt_str, fmt_id)
}
fn font_for(
doc: &crate::xlsx::XlsxDocument,
style_index: u32,
) -> Option<&crate::xlsx::styles::Font> {
doc.styles.as_ref()?.font_for(style_index)
}
fn image_format_from_ext(ext: &str) -> Option<ImageFormat> {
match ext {
"png" => Some(ImageFormat::Png),
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
"gif" => Some(ImageFormat::Gif),
"tif" | "tiff" => Some(ImageFormat::Tiff),
"bmp" => Some(ImageFormat::Bmp),
"emf" => Some(ImageFormat::Emf),
"wmf" => Some(ImageFormat::Wmf),
_ => None,
}
}