From 7f1ccf2e81b5f736069adc139ef754709e22ced4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:17:05 +0000 Subject: [PATCH] refactor(xlsx): move sheet_read.rs items to sheet_read/ --- crates/xlsx/src/sheet_read.rs | 453 +--------------------------- crates/xlsx/src/sheet_read/parts.rs | 270 +++++++++++++++++ crates/xlsx/src/sheet_read/rules.rs | 178 +++++++++++ 3 files changed, 455 insertions(+), 446 deletions(-) create mode 100644 crates/xlsx/src/sheet_read/parts.rs create mode 100644 crates/xlsx/src/sheet_read/rules.rs diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index aadd1141e..57b5ac7e3 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -20,6 +20,13 @@ use crate::styles::read_color; use crate::tables::{col_width_to_px, paper_name, pt_to_px}; use crate::xml::{self, El}; +mod parts; +mod rules; + +pub use parts::{read_autofilter, read_table}; +use parts::{read_comments, read_hyperlinks, read_note_shapes, read_threaded}; +use rules::{read_cf, read_validation}; + struct Shared { anchor: CellRef, expr: Option, @@ -615,452 +622,6 @@ pub fn parse_sqref(s: Option<&str>) -> Vec { s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } -fn cf_op(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greaterThan" => CfOperator::Greater, - "lessThan" => CfOperator::Less, - "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -fn cfvo(e: &El) -> CfValueKind { - let val = e.attr("val").unwrap_or(""); - let n = val.trim().parse::().ok().filter(|v| v.is_finite()); - match e.attr("type").unwrap_or("num") { - "min" | "autoMin" => CfValueKind::Min, - "max" | "autoMax" => CfValueKind::Max, - "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "formula" => CfValueKind::Formula(from_file(val)), - _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - } -} - -/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. -fn cfvos(e: &El) -> Vec { - e.kids("cfvo") - .map(|c| { - if c.attr("val").is_none() - && let Some(f) = c.child("f") - { - let mut c2 = c.clone(); - c2.attrs.push(("val".into(), f.text.clone())); - return cfvo(&c2); - } - cfvo(c) - }) - .collect() -} - -fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { - let ranges = parse_sqref(e.attr("sqref")); - if ranges.is_empty() { - return; - } - for r in e.kids("cfRule") { - let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); - let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); - let f0 = formulas.first().cloned().unwrap_or_default(); - let text = r.attr("text").unwrap_or("").to_string(); - // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). - let ext_id = - r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); - let rule = match r.attr("type").unwrap_or("") { - "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, - "expression" => CfRule::Expression { formula: f0, style }, - "containsText" => CfRule::ContainsText { text, style }, - "notContainsText" => CfRule::NotContainsText { text, style }, - "beginsWith" => CfRule::BeginsWith { text, style }, - "endsWith" => CfRule::EndsWith { text, style }, - "containsBlanks" => CfRule::Blanks { style }, - "notContainsBlanks" => CfRule::NoBlanks { style }, - "containsErrors" => CfRule::Errors { style }, - "notContainsErrors" => CfRule::NoErrors { style }, - "duplicateValues" => CfRule::Duplicate { style }, - "uniqueValues" => CfRule::Unique { style }, - "top10" => { - CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } - } - "aboveAverage" => CfRule::AboveAverage { - below: !r.flag("aboveAverage", true), - equal: r.flag("equalAverage", false), - std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, - style, - }, - "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, - "colorScale" => { - let Some(cs) = r.child("colorScale") else { continue }; - let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); - CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } - } - "dataBar" => { - let Some(db) = r.child("dataBar") else { continue }; - let v = cfvos(db); - CfRule::DataBar { - min: v.first().cloned().unwrap_or(CfValueKind::Min), - max: v.get(1).cloned().unwrap_or(CfValueKind::Max), - color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), - gradient: true, - show_value: db.flag("showValue", true), - } - } - "iconSet" => { - let Some(is) = r.child("iconSet") else { continue }; - CfRule::IconSet { - set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), - thresholds: cfvos(is), - reverse: is.flag("reverse", false), - show_value: is.flag("showValue", true), - } - } - other => { - cx.warn(format!("conditional format type \"{other}\" is not supported")); - continue; - } - }; - if let Some(id) = ext_id { - cx.cf_ext_ids.push((id, sheet.cond_formats.len())); - } - let rules = sheet.cond_formats.len(); - let area = ranges.iter().map(RangeRef::count).sum::().saturating_mul(1 + rules as u64); - if area > MAX_COND_CELLS { - cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped")); - break; - } - sheet.cond_formats.push(CondFormat { - ranges: ranges.clone(), - rule, - priority: r.attr_u32("priority").unwrap_or(0), - stop_if_true: r.flag("stopIfTrue", false), - }); - } -} - -/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). -fn read_validation(v: &El) -> Option { - let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); - let ranges = parse_sqref(sq.as_deref()); - if ranges.is_empty() { - return None; - } - let formula = |n: &str| -> Option { - let f = v.child(n)?; - let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); - if t.is_empty() { None } else { Some(t.to_string()) } - }; - let kind = match v.attr("type").unwrap_or("none") { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - _ => ValidationKind::Any, - }; - let mut f1 = formula("formula1").unwrap_or_default(); - if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { - f1 = format!("={}", from_file(&f1)); - } else if !f1.starts_with('"') && !f1.is_empty() { - f1 = from_file(&f1); - } - Some(Validation { - ranges, - kind, - op: cf_op(v.attr("operator")), - f1, - f2: formula("formula2").map(|f| from_file(&f)), - allow_blank: v.flag("allowBlank", false), - // `showDropDown="1"` *hides* the in-cell list arrow. - in_cell_dropdown: !v.flag("showDropDown", false), - input_title: v.attr("promptTitle").unwrap_or("").to_string(), - input_message: v.attr("prompt").unwrap_or("").to_string(), - show_input: v.flag("showInputMessage", false), - error_title: v.attr("errorTitle").unwrap_or("").to_string(), - error_message: v.attr("error").unwrap_or("").to_string(), - error_style: match v.attr("errorStyle") { - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - _ => ErrorStyle::Stop, - }, - show_error: v.flag("showErrorMessage", false), - }) -} - -fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { - let mut cells = 0u64; - let mut bytes = 0u64; - for h in e.kids("hyperlink") { - let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; - let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); - // A relationship is a file or web page; a bare `location` is a place in this workbook. - let external = !target.is_empty(); - if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { - if target.is_empty() { - target = loc.trim_start_matches('#').to_string(); - } else { - target = format!("{target}#{loc}"); - } - } - if target.is_empty() { - continue; - } - let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external }; - let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64; - for c in range.iter() { - if cells >= MAX_HYPERLINK_CELLS { - cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); - return; - } - if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES { - cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped")); - return; - } - cells += 1; - bytes += link_bytes; - sheet.hyperlinks.insert(c, link.clone()); - } - } -} - -fn filter_op(s: Option<&str>) -> &'static str { - match s.unwrap_or("equal") { - "lessThan" => "<", - "lessThanOrEqual" => "<=", - "notEqual" => "<>", - "greaterThanOrEqual" => ">=", - "greaterThan" => ">", - _ => "=", - } -} - -pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { - let range = e.attr("ref").and_then(RangeRef::parse)?; - let mut criteria = vec![]; - for fc in e.kids("filterColumn") { - let Some(col) = fc.attr_u32("colId") else { continue }; - let crit = if let Some(f) = fc.child("filters") { - FilterCriterion::Values { - values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), - blanks: f.flag("blank", false), - } - } else if let Some(cf) = fc.child("customFilters") { - let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); - let Some(a) = it.next() else { continue }; - FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } - } else if let Some(t) = fc.child("top10") { - FilterCriterion::Top10 { - bottom: !t.flag("top", true), - percent: t.flag("percent", false), - count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, - } - } else if let Some(d) = fc.child("dynamicFilter") { - match d.attr("type") { - Some("aboveAverage") => FilterCriterion::AboveAverage(true), - Some("belowAverage") => FilterCriterion::AboveAverage(false), - _ => { - cx.warn("date filters in AutoFilters are not supported"); - continue; - } - } - } else if let Some(c) = fc.child("colorFilter") { - let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; - if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } - } else if let Some(i) = fc.child("iconFilter") { - let Some(set) = i.attr("iconSet") else { continue }; - FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } - } else { - continue; - }; - criteria.push((col, crit)); - } - Some(AutoFilter { range, criteria }) -} - -pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option { - let range = x.attr("ref").and_then(RangeRef::parse)?; - let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); - let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; - let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; - let columns = x - .child("tableColumns") - .map(|tc| { - tc.kids("tableColumn") - .map(|c| { - let totals = match c.attr("totalsRowFunction").unwrap_or("none") { - "average" => TotalsFn::Average, - "count" => TotalsFn::Count, - "countNums" => TotalsFn::CountNums, - "max" => TotalsFn::Max, - "min" => TotalsFn::Min, - "sum" => TotalsFn::Sum, - "stdDev" => TotalsFn::StdDev, - "var" => TotalsFn::Var, - "custom" => TotalsFn::Custom, - _ => TotalsFn::None, - }; - let totals_label = if totals == TotalsFn::Custom { - c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) - } else { - c.attr("totalsRowLabel").map(str::to_string) - }; - TableColumn { - name: xml::decode_xstring(c.attr("name").unwrap_or("")), - totals, - totals_label, - formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), - unique_name: c.attr("uniqueName").map(str::to_string), - xml: c.child("xmlColumnPr").and_then(|x| { - Some(XmlColumnPr { - map_id: x.attr_u32("mapId")?, - xpath: x.attr("xpath")?.to_string(), - data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), - denormalized: x.flag("denormalized", false), - }) - }), - } - }) - .collect() - }) - .unwrap_or_default(); - let si = x.child("tableStyleInfo"); - // Only XML tables are kept as such: a query table's definition hangs off the table part, which - // is rewritten, so it would lose it. - let xml_table = x.attr("tableType") == Some("xml"); - Some(Table { - id: x.attr_u32("id").unwrap_or(0), - name, - range, - header_row, - totals_row, - columns, - style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), - banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), - banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), - first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), - last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), - filter_button: x.child("autoFilter").is_some(), - filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), - table_type: xml_table.then(|| "xml".to_string()), - connection_id: x.attr_u32("connectionId").filter(|_| xml_table), - }) -} - -fn read_comments(x: &El, sheet: &mut Sheet) { - let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); - let Some(list) = x.child("commentList") else { return }; - for c in list.kids("comment") { - let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; - let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); - let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); - // Excel starts a note with a bold "Author:" line. - if !author.is_empty() - && let Some(rest) = text.strip_prefix(&format!("{author}:")) - { - text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); - } - sheet - .comments - .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); - } -} - -/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its -/// `` and style size become the note's box. Shapes may sit in groups. -fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { - if depth > xml::MAX_DEPTH { - return; - } - for e in &x.children { - if e.name != "shape" { - read_note_shapes(e, sheet, depth + 1); - continue; - } - let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; - let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); - let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; - let cell = CellRef::new(row, col); - let Some(c) = sheet.comments.get_mut(&cell) else { continue }; - if cd.child("Visible").is_some() && !c.threaded { - c.visible = true; - } - if let Some(anchor) = cd.child("Anchor") { - c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); - } - } -} - -/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, -/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, -/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet -/// numbers. -fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { - let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; - let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; - if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { - return None; - } - let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { - col: i64::from(c) - i64::from(cell.col), - col_px: dx, - row: i64::from(r) - i64::from(cell.row), - row_px: dy, - }; - let pt = |key: &str| { - style - .split(';') - .filter_map(|d| d.split_once(':')) - .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) - .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) - }; - Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) -} - -fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { - let mut by_id: HashMap = HashMap::new(); - let mut replies: Vec<(String, String, String, String)> = vec![]; - for t in x.kids("threadedComment") { - let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; - let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); - let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); - let date = t.attr("dT").unwrap_or("").to_string(); - match t.attr("parentId") { - Some(p) => replies.push((p.to_string(), author, text, date)), - None => { - if let Some(id) = t.attr("id") { - by_id.insert(id.to_string(), cell); - } - let dates = if date.is_empty() { vec![] } else { vec![date] }; - sheet.comments.insert( - cell, - Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, - ); - } - } - } - // Replies in file order; ones whose thread is missing are dropped. - for (parent, author, text, date) in replies { - if let Some(c) = by_id.get(&parent) - && let Some(cm) = sheet.comments.get_mut(c) - { - if !date.is_empty() { - // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. - let at = cm.replies.len() + 1; - if cm.dates.len() < at { - cm.dates.resize(at, String::new()); - } - cm.dates.push(date); - } - cm.replies.push((author, text)); - } - } -} - /// Worksheet extensions: x14 data validations and sparklines. fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { for ext in e.kids("ext") { diff --git a/crates/xlsx/src/sheet_read/parts.rs b/crates/xlsx/src/sheet_read/parts.rs new file mode 100644 index 000000000..c7626e6e9 --- /dev/null +++ b/crates/xlsx/src/sheet_read/parts.rs @@ -0,0 +1,270 @@ +use super::*; + +pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { + let mut cells = 0u64; + let mut bytes = 0u64; + for h in e.kids("hyperlink") { + let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; + let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); + // A relationship is a file or web page; a bare `location` is a place in this workbook. + let external = !target.is_empty(); + if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { + if target.is_empty() { + target = loc.trim_start_matches('#').to_string(); + } else { + target = format!("{target}#{loc}"); + } + } + if target.is_empty() { + continue; + } + let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external }; + let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64; + for c in range.iter() { + if cells >= MAX_HYPERLINK_CELLS { + cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); + return; + } + if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES { + cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped")); + return; + } + cells += 1; + bytes += link_bytes; + sheet.hyperlinks.insert(c, link.clone()); + } + } +} + +fn filter_op(s: Option<&str>) -> &'static str { + match s.unwrap_or("equal") { + "lessThan" => "<", + "lessThanOrEqual" => "<=", + "notEqual" => "<>", + "greaterThanOrEqual" => ">=", + "greaterThan" => ">", + _ => "=", + } +} + +pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { + let range = e.attr("ref").and_then(RangeRef::parse)?; + let mut criteria = vec![]; + for fc in e.kids("filterColumn") { + let Some(col) = fc.attr_u32("colId") else { continue }; + let crit = if let Some(f) = fc.child("filters") { + FilterCriterion::Values { + values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), + blanks: f.flag("blank", false), + } + } else if let Some(cf) = fc.child("customFilters") { + let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); + let Some(a) = it.next() else { continue }; + FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } + } else if let Some(t) = fc.child("top10") { + FilterCriterion::Top10 { + bottom: !t.flag("top", true), + percent: t.flag("percent", false), + count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, + } + } else if let Some(d) = fc.child("dynamicFilter") { + match d.attr("type") { + Some("aboveAverage") => FilterCriterion::AboveAverage(true), + Some("belowAverage") => FilterCriterion::AboveAverage(false), + _ => { + cx.warn("date filters in AutoFilters are not supported"); + continue; + } + } + } else if let Some(c) = fc.child("colorFilter") { + let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; + if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } + } else if let Some(i) = fc.child("iconFilter") { + let Some(set) = i.attr("iconSet") else { continue }; + FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } + } else { + continue; + }; + criteria.push((col, crit)); + } + Some(AutoFilter { range, criteria }) +} + +pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
{ + let range = x.attr("ref").and_then(RangeRef::parse)?; + let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); + let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; + let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; + let columns = x + .child("tableColumns") + .map(|tc| { + tc.kids("tableColumn") + .map(|c| { + let totals = match c.attr("totalsRowFunction").unwrap_or("none") { + "average" => TotalsFn::Average, + "count" => TotalsFn::Count, + "countNums" => TotalsFn::CountNums, + "max" => TotalsFn::Max, + "min" => TotalsFn::Min, + "sum" => TotalsFn::Sum, + "stdDev" => TotalsFn::StdDev, + "var" => TotalsFn::Var, + "custom" => TotalsFn::Custom, + _ => TotalsFn::None, + }; + let totals_label = if totals == TotalsFn::Custom { + c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) + } else { + c.attr("totalsRowLabel").map(str::to_string) + }; + TableColumn { + name: xml::decode_xstring(c.attr("name").unwrap_or("")), + totals, + totals_label, + formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), + unique_name: c.attr("uniqueName").map(str::to_string), + xml: c.child("xmlColumnPr").and_then(|x| { + Some(XmlColumnPr { + map_id: x.attr_u32("mapId")?, + xpath: x.attr("xpath")?.to_string(), + data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), + denormalized: x.flag("denormalized", false), + }) + }), + } + }) + .collect() + }) + .unwrap_or_default(); + let si = x.child("tableStyleInfo"); + // Only XML tables are kept as such: a query table's definition hangs off the table part, which + // is rewritten, so it would lose it. + let xml_table = x.attr("tableType") == Some("xml"); + Some(Table { + id: x.attr_u32("id").unwrap_or(0), + name, + range, + header_row, + totals_row, + columns, + style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), + banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), + banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), + first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), + last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), + filter_button: x.child("autoFilter").is_some(), + filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), + table_type: xml_table.then(|| "xml".to_string()), + connection_id: x.attr_u32("connectionId").filter(|_| xml_table), + }) +} + +pub(super) fn read_comments(x: &El, sheet: &mut Sheet) { + let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); + let Some(list) = x.child("commentList") else { return }; + for c in list.kids("comment") { + let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; + let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); + let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); + // Excel starts a note with a bold "Author:" line. + if !author.is_empty() + && let Some(rest) = text.strip_prefix(&format!("{author}:")) + { + text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); + } + sheet + .comments + .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); + } +} + +/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its +/// `` and style size become the note's box. Shapes may sit in groups. +pub(super) fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { + if depth > xml::MAX_DEPTH { + return; + } + for e in &x.children { + if e.name != "shape" { + read_note_shapes(e, sheet, depth + 1); + continue; + } + let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; + let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); + let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; + let cell = CellRef::new(row, col); + let Some(c) = sheet.comments.get_mut(&cell) else { continue }; + if cd.child("Visible").is_some() && !c.threaded { + c.visible = true; + } + if let Some(anchor) = cd.child("Anchor") { + c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); + } + } +} + +/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, +/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, +/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet +/// numbers. +fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { + let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; + if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { + return None; + } + let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { + col: i64::from(c) - i64::from(cell.col), + col_px: dx, + row: i64::from(r) - i64::from(cell.row), + row_px: dy, + }; + let pt = |key: &str| { + style + .split(';') + .filter_map(|d| d.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) + }; + Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) +} + +pub(super) fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { + let mut by_id: HashMap = HashMap::new(); + let mut replies: Vec<(String, String, String, String)> = vec![]; + for t in x.kids("threadedComment") { + let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; + let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); + let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); + let date = t.attr("dT").unwrap_or("").to_string(); + match t.attr("parentId") { + Some(p) => replies.push((p.to_string(), author, text, date)), + None => { + if let Some(id) = t.attr("id") { + by_id.insert(id.to_string(), cell); + } + let dates = if date.is_empty() { vec![] } else { vec![date] }; + sheet.comments.insert( + cell, + Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, + ); + } + } + } + // Replies in file order; ones whose thread is missing are dropped. + for (parent, author, text, date) in replies { + if let Some(c) = by_id.get(&parent) + && let Some(cm) = sheet.comments.get_mut(c) + { + if !date.is_empty() { + // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. + let at = cm.replies.len() + 1; + if cm.dates.len() < at { + cm.dates.resize(at, String::new()); + } + cm.dates.push(date); + } + cm.replies.push((author, text)); + } + } +} diff --git a/crates/xlsx/src/sheet_read/rules.rs b/crates/xlsx/src/sheet_read/rules.rs new file mode 100644 index 000000000..e0ad3129f --- /dev/null +++ b/crates/xlsx/src/sheet_read/rules.rs @@ -0,0 +1,178 @@ +use super::*; + +fn cf_op(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greaterThan" => CfOperator::Greater, + "lessThan" => CfOperator::Less, + "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +fn cfvo(e: &El) -> CfValueKind { + let val = e.attr("val").unwrap_or(""); + let n = val.trim().parse::().ok().filter(|v| v.is_finite()); + match e.attr("type").unwrap_or("num") { + "min" | "autoMin" => CfValueKind::Min, + "max" | "autoMax" => CfValueKind::Max, + "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "formula" => CfValueKind::Formula(from_file(val)), + _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + } +} + +/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. +fn cfvos(e: &El) -> Vec { + e.kids("cfvo") + .map(|c| { + if c.attr("val").is_none() + && let Some(f) = c.child("f") + { + let mut c2 = c.clone(); + c2.attrs.push(("val".into(), f.text.clone())); + return cfvo(&c2); + } + cfvo(c) + }) + .collect() +} + +pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { + let ranges = parse_sqref(e.attr("sqref")); + if ranges.is_empty() { + return; + } + for r in e.kids("cfRule") { + let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); + let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); + let f0 = formulas.first().cloned().unwrap_or_default(); + let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = + r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); + let rule = match r.attr("type").unwrap_or("") { + "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, + "expression" => CfRule::Expression { formula: f0, style }, + "containsText" => CfRule::ContainsText { text, style }, + "notContainsText" => CfRule::NotContainsText { text, style }, + "beginsWith" => CfRule::BeginsWith { text, style }, + "endsWith" => CfRule::EndsWith { text, style }, + "containsBlanks" => CfRule::Blanks { style }, + "notContainsBlanks" => CfRule::NoBlanks { style }, + "containsErrors" => CfRule::Errors { style }, + "notContainsErrors" => CfRule::NoErrors { style }, + "duplicateValues" => CfRule::Duplicate { style }, + "uniqueValues" => CfRule::Unique { style }, + "top10" => { + CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } + } + "aboveAverage" => CfRule::AboveAverage { + below: !r.flag("aboveAverage", true), + equal: r.flag("equalAverage", false), + std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, + style, + }, + "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, + "colorScale" => { + let Some(cs) = r.child("colorScale") else { continue }; + let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); + CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } + } + "dataBar" => { + let Some(db) = r.child("dataBar") else { continue }; + let v = cfvos(db); + CfRule::DataBar { + min: v.first().cloned().unwrap_or(CfValueKind::Min), + max: v.get(1).cloned().unwrap_or(CfValueKind::Max), + color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), + gradient: true, + show_value: db.flag("showValue", true), + } + } + "iconSet" => { + let Some(is) = r.child("iconSet") else { continue }; + CfRule::IconSet { + set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), + thresholds: cfvos(is), + reverse: is.flag("reverse", false), + show_value: is.flag("showValue", true), + } + } + other => { + cx.warn(format!("conditional format type \"{other}\" is not supported")); + continue; + } + }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } + let rules = sheet.cond_formats.len(); + let area = ranges.iter().map(RangeRef::count).sum::().saturating_mul(1 + rules as u64); + if area > MAX_COND_CELLS { + cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped")); + break; + } + sheet.cond_formats.push(CondFormat { + ranges: ranges.clone(), + rule, + priority: r.attr_u32("priority").unwrap_or(0), + stop_if_true: r.flag("stopIfTrue", false), + }); + } +} + +/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). +pub(super) fn read_validation(v: &El) -> Option { + let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); + let ranges = parse_sqref(sq.as_deref()); + if ranges.is_empty() { + return None; + } + let formula = |n: &str| -> Option { + let f = v.child(n)?; + let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + let kind = match v.attr("type").unwrap_or("none") { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + _ => ValidationKind::Any, + }; + let mut f1 = formula("formula1").unwrap_or_default(); + if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { + f1 = format!("={}", from_file(&f1)); + } else if !f1.starts_with('"') && !f1.is_empty() { + f1 = from_file(&f1); + } + Some(Validation { + ranges, + kind, + op: cf_op(v.attr("operator")), + f1, + f2: formula("formula2").map(|f| from_file(&f)), + allow_blank: v.flag("allowBlank", false), + // `showDropDown="1"` *hides* the in-cell list arrow. + in_cell_dropdown: !v.flag("showDropDown", false), + input_title: v.attr("promptTitle").unwrap_or("").to_string(), + input_message: v.attr("prompt").unwrap_or("").to_string(), + show_input: v.flag("showInputMessage", false), + error_title: v.attr("errorTitle").unwrap_or("").to_string(), + error_message: v.attr("error").unwrap_or("").to_string(), + error_style: match v.attr("errorStyle") { + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + _ => ErrorStyle::Stop, + }, + show_error: v.flag("showErrorMessage", false), + }) +}