use std::collections::{HashMap, HashSet};
use crate::ir::{Block, Paragraph, ParagraphStyle, Run, TableRow};
use crate::parser::cond_fmt::build_cond_fmt_overrides;
use super::xlsx_style::{
apply_rich_run_font, extract_cell_alignment, extract_cell_background, extract_cell_borders,
extract_cell_text_style,
};
use crate::ir::TableCell;
#[derive(Debug, Clone, Copy)]
pub(crate) struct CellRange {
pub(crate) start_col: u32,
pub(crate) start_row: u32,
pub(crate) end_col: u32,
pub(crate) end_row: u32,
}
pub(crate) type CellPos = (u32, u32);
pub(super) struct MergeInfo {
pub(super) col_span: u32,
pub(super) row_span: u32,
}
pub(super) const DEFAULT_COLUMN_WIDTH: f64 = 8.43;
const DEFAULT_MAX_DIGIT_WIDTH_PX: f64 = 7.0;
pub(super) fn column_width_to_pt(char_width: f64, max_digit_width_px: f64) -> f64 {
char_width * max_digit_width_px * 0.75
}
pub(super) fn extract_normal_font(data: &[u8]) -> Option<(String, f64)> {
use quick_xml::events::Event;
use std::io::Read;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).ok()?;
let mut file = archive.by_name("xl/styles.xml").ok()?;
let mut xml = String::new();
file.read_to_string(&mut xml).ok()?;
let mut reader = quick_xml::Reader::from_str(&xml);
let mut in_first_font = false;
let mut name: Option<String> = None;
let mut size: Option<f64> = None;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) if e.local_name().as_ref() == b"font" => {
in_first_font = true;
}
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"font" => break,
Ok(Event::Empty(ref e)) if in_first_font => {
let val = e
.try_get_attribute("val")
.ok()
.flatten()
.and_then(|a| String::from_utf8(a.value.into_owned()).ok());
match e.local_name().as_ref() {
b"name" => name = val,
b"sz" => size = val.and_then(|v| v.parse::<f64>().ok()),
_ => {}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
Some((name?, size.unwrap_or(11.0)))
}
pub(super) fn max_digit_width_px_for_normal_font(family: &str, size_pt: f64) -> f64 {
let digit_em: f64 = match family.to_ascii_lowercase().as_str() {
"arial" | "helvetica" | "liberation sans" => 0.556,
"malgun gothic" | "맑은 고딕" => 0.529,
_ => 0.5066,
};
(digit_em * size_pt * (96.0 / 72.0)).ceil()
}
pub(super) fn sheet_max_digit_width_px(sheet: &umya_spreadsheet::Worksheet) -> f64 {
let mut family_counts: HashMap<String, usize> = HashMap::new();
for cell in sheet.get_cell_collection() {
let Some(font) = cell.get_style().get_font() else {
continue;
};
let family = font.get_name().trim();
if !family.is_empty() {
*family_counts
.entry(family.to_ascii_lowercase())
.or_default() += 1;
}
}
let dominant_family = family_counts
.into_iter()
.max_by(|(family_a, count_a), (family_b, count_b)| {
count_a.cmp(count_b).then_with(|| family_b.cmp(family_a))
})
.map(|(family, _)| family);
match dominant_family.as_deref() {
Some("carlito") => 8.0,
_ => DEFAULT_MAX_DIGIT_WIDTH_PX,
}
}
pub(super) fn parse_column_letters(s: &str) -> Option<u32> {
if s.is_empty() {
return None;
}
let mut col: u32 = 0;
for c in s.chars() {
if !c.is_ascii_uppercase() {
return None;
}
col = col * 26 + (c as u32 - b'A' as u32 + 1);
}
Some(col)
}
pub(crate) fn parse_cell_ref(s: &str) -> Option<(u32, u32)> {
let s = s.replace('$', "");
let split_pos = s.find(|c: char| c.is_ascii_digit())?;
let col_str = &s[..split_pos];
let row_str = &s[split_pos..];
let col = parse_column_letters(col_str)?;
let row: u32 = row_str.parse().ok()?;
Some((col, row))
}
pub(super) fn parse_print_area_range(address: &str) -> Option<CellRange> {
let range_part = if let Some(pos) = address.rfind('!') {
&address[pos + 1..]
} else {
address
};
let (start_str, end_str) = range_part.split_once(':')?;
let (start_col, start_row) = parse_cell_ref(start_str)?;
let (end_col, end_row) = parse_cell_ref(end_str)?;
Some(CellRange {
start_col,
start_row,
end_col,
end_row,
})
}
pub(super) fn find_print_area(sheet: &umya_spreadsheet::Worksheet) -> Option<CellRange> {
for dn in sheet.get_defined_names() {
if dn.get_name() == "_xlnm.Print_Area" {
let addr = dn.get_address();
if let Some(range) = parse_print_area_range(&addr) {
return Some(range);
}
}
}
None
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct PrintTitles {
pub(super) rows: Option<(u32, u32)>,
pub(super) cols: Option<(u32, u32)>,
}
pub(super) fn find_print_titles(
book: &umya_spreadsheet::Spreadsheet,
sheet: &umya_spreadsheet::Worksheet,
) -> PrintTitles {
let mut titles = PrintTitles::default();
for dn in sheet.get_defined_names() {
if dn.get_name() == "_xlnm.Print_Titles" {
parse_print_title_address(&dn.get_address(), &mut titles);
}
}
if titles.rows.is_none() && titles.cols.is_none() {
let plain_prefix: String = format!("{}!", sheet.get_name());
let quoted_prefix: String = format!("'{}'!", sheet.get_name());
for dn in book.get_defined_names() {
let address: String = dn.get_address();
if dn.get_name() == "_xlnm.Print_Titles"
&& (address.contains(&plain_prefix) || address.contains("ed_prefix))
{
parse_print_title_address(&address, &mut titles);
}
}
}
titles
}
fn parse_print_title_address(address: &str, titles: &mut PrintTitles) {
for part in address.split(',') {
let range_part: String = part
.rsplit('!')
.next()
.unwrap_or(part)
.replace('$', "")
.trim()
.to_string();
let Some((start_str, end_str)) = range_part.split_once(':') else {
continue;
};
if let (Ok(row_start), Ok(row_end)) = (start_str.parse::<u32>(), end_str.parse::<u32>()) {
titles.rows = Some((row_start.min(row_end), row_start.max(row_end)));
} else if let (Some(col_start), Some(col_end)) = (
parse_column_letters(start_str),
parse_column_letters(end_str),
) {
titles.cols = Some((col_start.min(col_end), col_start.max(col_end)));
}
}
}
pub(super) fn collect_row_breaks(sheet: &umya_spreadsheet::Worksheet) -> Vec<u32> {
let mut breaks: Vec<u32> = sheet
.get_row_breaks()
.get_break_list()
.iter()
.filter(|b| *b.get_manual_page_break())
.map(|b| *b.get_id())
.collect();
breaks.sort_unstable();
breaks.dedup();
breaks
}
pub(super) fn build_merge_maps(
sheet: &umya_spreadsheet::Worksheet,
) -> (HashMap<CellPos, MergeInfo>, HashSet<CellPos>) {
let mut top_left_map: HashMap<CellPos, MergeInfo> = HashMap::new();
let mut skip_set: HashSet<CellPos> = HashSet::new();
for range in sheet.get_merge_cells() {
let start_col = range
.get_coordinate_start_col()
.map(|c| *c.get_num())
.unwrap_or(1);
let start_row = range
.get_coordinate_start_row()
.map(|r| *r.get_num())
.unwrap_or(1);
let end_col = range
.get_coordinate_end_col()
.map(|c| *c.get_num())
.unwrap_or(start_col);
let end_row = range
.get_coordinate_end_row()
.map(|r| *r.get_num())
.unwrap_or(start_row);
let col_span = end_col.saturating_sub(start_col) + 1;
let row_span = end_row.saturating_sub(start_row) + 1;
top_left_map.insert((start_col, start_row), MergeInfo { col_span, row_span });
for r in start_row..=end_row {
for c in start_col..=end_col {
if r != start_row || c != start_col {
skip_set.insert((c, r));
}
}
}
}
(top_left_map, skip_set)
}
pub(super) struct SheetContext {
pub(super) col_start: u32,
pub(super) col_end: u32,
pub(super) num_cols: usize,
pub(super) column_widths: Vec<f64>,
pub(super) max_digit_width_px: f64,
pub(super) merge_tops: HashMap<(u32, u32), MergeInfo>,
pub(super) merge_skips: HashSet<(u32, u32)>,
pub(super) cond_fmt_overrides: HashMap<(u32, u32), crate::parser::cond_fmt::CondFmtOverride>,
}
fn strong_direction(c: char) -> Option<bool> {
match c as u32 {
0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF => Some(true),
_ if c.is_alphabetic() => Some(false),
_ => None,
}
}
fn to_arabic_indic_digits(value: &str) -> String {
value
.chars()
.map(|c| match c {
'0'..='9' => char::from_u32(0x0660 + (c as u32 - '0' as u32)).unwrap_or(c),
'.' => '\u{066B}',
',' => '\u{066C}',
_ => c,
})
.collect()
}
fn uses_native_arabic_digits(format_code: &str) -> bool {
let Some(rest) = format_code.strip_prefix("[$-") else {
return false;
};
let Some(end) = rest.find(']') else {
return false;
};
let Ok(locale) = u64::from_str_radix(&rest[..end], 16) else {
return false;
};
let digit_substitution: u64 = locale >> 24;
let language_id: u64 = locale & 0xFF;
digit_substitution >= 2 && language_id == 0x01
}
fn estimate_text_width_pt(runs: &[Run]) -> f64 {
runs.iter()
.map(|run| {
let font_size: f64 = run.style.font_size.unwrap_or(11.0);
run.text
.chars()
.map(|c| {
if c.is_ascii() {
0.55 * font_size
} else {
1.05 * font_size
}
})
.sum::<f64>()
})
.sum()
}
#[allow(clippy::too_many_arguments)]
fn compute_spill_width(
sheet: &umya_spreadsheet::Worksheet,
ctx: &SheetContext,
col_idx: u32,
row_idx: u32,
runs: &[Run],
cell_alignment: Option<crate::ir::Alignment>,
col_span: u32,
umya_cell: Option<&umya_spreadsheet::Cell>,
) -> Option<f64> {
if runs.is_empty() {
return None;
}
if !matches!(cell_alignment, None | Some(crate::ir::Alignment::Left)) {
return None;
}
let has_wrap_text: bool = umya_cell
.and_then(|cell| cell.get_style().get_alignment().cloned())
.map(|alignment| *alignment.get_wrap_text())
.unwrap_or(false);
if has_wrap_text {
return None;
}
if runs.iter().any(|run| run.text.contains('\n')) {
return None;
}
if col_span > 1 {
let merged_width: f64 = (col_idx..col_idx + col_span)
.map(|c| {
ctx.column_widths
.get((c - ctx.col_start) as usize)
.copied()
.unwrap_or(0.0)
})
.sum();
return Some(merged_width);
}
let own_width: f64 = *ctx.column_widths.get((col_idx - ctx.col_start) as usize)?;
if estimate_text_width_pt(runs) <= own_width - 4.0 {
return None;
}
let mut total_width: f64 = own_width;
let mut has_empty_neighbor = false;
let mut blocked = false;
for neighbor_col in (col_idx + 1)..=ctx.col_end {
if ctx.merge_skips.contains(&(neighbor_col, row_idx))
|| ctx.merge_tops.contains_key(&(neighbor_col, row_idx))
{
blocked = true;
break;
}
let neighbor_is_empty: bool = sheet
.get_cell((neighbor_col, row_idx))
.map(|cell| cell.get_formatted_value().is_empty())
.unwrap_or(true);
if !neighbor_is_empty {
blocked = true;
break;
}
total_width += *ctx
.column_widths
.get((neighbor_col - ctx.col_start) as usize)
.unwrap_or(&0.0);
has_empty_neighbor = true;
}
if !blocked {
let needed_width: f64 = estimate_text_width_pt(runs) + 4.0;
if needed_width > total_width {
total_width = needed_width;
has_empty_neighbor = true;
}
}
has_empty_neighbor.then_some(total_width)
}
const EXCEL_DEFAULT_ROW_HEIGHT_PT: f64 = 15.0;
fn printed_row_height(
sheet: &umya_spreadsheet::Worksheet,
row_idx: u32,
row_has_wrapping_cell: &dyn Fn() -> bool,
) -> Option<f64> {
let row_dimension = sheet.get_row_dimension(&row_idx);
let is_custom_height: bool = row_dimension
.map(|row| *row.get_custom_height())
.unwrap_or(false);
if !is_custom_height && row_has_wrapping_cell() {
return None;
}
let declared_height: Option<f64> = row_dimension
.map(|row| *row.get_height())
.filter(|height| *height > 0.0);
declared_height.or_else(|| {
let sheet_default: f64 = *sheet.get_sheet_format_properties().get_default_row_height();
if sheet_default > 0.0 {
Some(sheet_default)
} else {
Some(EXCEL_DEFAULT_ROW_HEIGHT_PT)
}
})
}
pub(super) fn build_rows_for_range(
sheet: &umya_spreadsheet::Worksheet,
ctx: &SheetContext,
row_start: u32,
row_end: u32,
) -> Vec<TableRow> {
let num_rows = (row_end - row_start + 1) as usize;
let mut rows = Vec::with_capacity(num_rows);
for row_idx in row_start..=row_end {
let mut cells = Vec::with_capacity(ctx.num_cols);
for col_idx in ctx.col_start..=ctx.col_end {
if ctx.merge_skips.contains(&(col_idx, row_idx)) {
continue;
}
let umya_cell = sheet.get_cell((col_idx, row_idx));
let mut value = umya_cell
.map(|cell| cell.get_formatted_value())
.unwrap_or_default();
if let Some(cell) = umya_cell
&& let Some(number_format) = cell.get_style().get_number_format()
&& uses_native_arabic_digits(number_format.get_format_code())
{
value = to_arabic_indic_digits(&value);
}
let mut text_style = umya_cell.map(extract_cell_text_style).unwrap_or_default();
let (cell_alignment, cell_vertical_align) = umya_cell
.map(extract_cell_alignment)
.unwrap_or((None, None));
let mut background = umya_cell.and_then(extract_cell_background);
let border = umya_cell.and_then(extract_cell_borders);
let mut data_bar = None;
let mut icon_text = None;
let mut icon_color = None;
if let Some(ovr) = ctx.cond_fmt_overrides.get(&(col_idx, row_idx)) {
if ovr.background.is_some() {
background = ovr.background;
}
if ovr.font_color.is_some() {
text_style.color = ovr.font_color;
}
if let Some(bold) = ovr.bold {
text_style.bold = Some(bold);
}
data_bar = ovr.data_bar.clone();
icon_text = ovr.icon_text.clone();
icon_color = ovr.icon_color;
}
let rich_text: Option<umya_spreadsheet::RichText> =
umya_cell.and_then(|cell| cell.get_cell_value().get_raw_value().get_rich_text());
let runs: Vec<Run> = if let Some(rich_text) = rich_text {
rich_text
.get_rich_text_elements()
.iter()
.filter(|element| !element.get_text().is_empty())
.map(|element| Run {
text: element.get_text().to_string(),
style: element
.get_run_properties()
.map(|font| apply_rich_run_font(&text_style, font))
.unwrap_or_else(|| text_style.clone()),
href: None,
footnote: None,
})
.collect()
} else if value.is_empty() {
Vec::new()
} else {
vec![Run {
text: value,
style: text_style,
href: None,
footnote: None,
}]
};
let cell_alignment: Option<crate::ir::Alignment> = cell_alignment.or_else(|| {
runs.iter()
.flat_map(|run| run.text.chars())
.find_map(strong_direction)
.filter(|is_rtl| *is_rtl)
.map(|_| crate::ir::Alignment::Right)
});
let paragraph_alignment = cell_alignment.or_else(|| {
umya_cell
.and_then(|cell| cell.get_value_number())
.map(|_| crate::ir::Alignment::Right)
});
let (col_span, row_span) = if let Some(info) = ctx.merge_tops.get(&(col_idx, row_idx)) {
(info.col_span, info.row_span)
} else {
(1, 1)
};
let spill_width: Option<f64> = compute_spill_width(
sheet,
ctx,
col_idx,
row_idx,
&runs,
paragraph_alignment,
col_span,
umya_cell,
);
let content = if runs.is_empty() {
Vec::new()
} else {
vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
alignment: paragraph_alignment,
..ParagraphStyle::default()
},
runs,
})]
};
cells.push(TableCell {
content,
col_span,
row_span,
border,
background,
data_bar,
icon_text,
icon_color,
spill_width,
vertical_align: cell_vertical_align,
padding: None,
});
}
let row_has_wrapping_cell = || {
(ctx.col_start..=ctx.col_end).any(|col| {
sheet
.get_cell((col, row_idx))
.and_then(|cell| cell.get_style().get_alignment().cloned())
.map(|alignment| *alignment.get_wrap_text())
.unwrap_or(false)
})
};
let height: Option<f64> = printed_row_height(sheet, row_idx, &row_has_wrapping_cell);
rows.push(TableRow { cells, height });
}
rows
}
pub(super) fn prepare_sheet_context(
sheet: &umya_spreadsheet::Worksheet,
normal_font_mdw: Option<f64>,
raw_cond_fmt_hints: Option<&super::cond_fmt_raw::RawCondFmtHints>,
) -> Option<(SheetContext, u32, u32)> {
let (mut max_col, mut max_row) = sheet.get_highest_column_and_row();
if max_col == 0 || max_row == 0 {
return None;
}
for range in sheet.get_merge_cells() {
if let Some(c) = range.get_coordinate_end_col() {
max_col = max_col.max(*c.get_num());
}
if let Some(r) = range.get_coordinate_end_row() {
max_row = max_row.max(*r.get_num());
}
}
let print_area = find_print_area(sheet);
let (col_start, col_end, row_start, row_end) = if let Some(pa) = print_area {
(pa.start_col, pa.end_col, pa.start_row, pa.end_row)
} else {
(1, max_col, 1, max_row)
};
let max_digit_width_px = normal_font_mdw.unwrap_or_else(|| sheet_max_digit_width_px(sheet));
let column_widths: Vec<f64> = (col_start..=col_end)
.map(|col| {
sheet
.get_column_dimension_by_number(&col)
.map(|c| column_width_to_pt(*c.get_width(), max_digit_width_px))
.unwrap_or_else(|| column_width_to_pt(DEFAULT_COLUMN_WIDTH, max_digit_width_px))
})
.collect();
let (merge_tops, merge_skips) = build_merge_maps(sheet);
let cond_fmt_overrides = build_cond_fmt_overrides(sheet, raw_cond_fmt_hints);
let num_cols = (col_end - col_start + 1) as usize;
Some((
SheetContext {
col_start,
col_end,
num_cols,
column_widths,
max_digit_width_px,
merge_tops,
merge_skips,
cond_fmt_overrides,
},
row_start,
row_end,
))
}