From 3ceb50f4043a1a5a01a371b4d1c1b0b2134eb6ee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:34 +0200 Subject: [PATCH] Keep a solid data bar solid across an XLSX save and reopen (#550) --- crates/xlsx/src/read.rs | 3 ++ crates/xlsx/src/sheet_read.rs | 28 ++++++++++++- crates/xlsx/src/sheet_write.rs | 67 +++++++++++++++++++++++++++--- crates/xlsx/src/tests/roundtrip.rs | 28 +++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..9b16fef 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -28,6 +28,8 @@ pub struct Ctx<'a> { pub num_fmts: std::collections::HashMap, /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. pub cell_pictures: Vec>>, + /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. + pub cf_ext_ids: Vec<(String, usize)>, } impl Ctx<'_> { @@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { next_id: 1, num_fmts: Default::default(), cell_pictures: vec![], + cf_ext_ids: vec![], }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d9..a457ffd 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { @@ -596,6 +597,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { 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 }, @@ -649,6 +656,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { continue; } }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -953,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { } } } - "conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"), + "conditionalFormattings" => { + let mut skipped = false; + for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) { + let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n); + match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) { + (Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => { + *gradient = db.flag("gradient", true); + // Only the fill style is modelled; borders, lengths, axis or negative colours are lost. + skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo"); + } + _ => skipped = true, + } + } + if skipped { + cx.warn("some Excel 2010 conditional format extensions were skipped"); + } + } _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d..8637c43 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -573,9 +573,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "{parts}", tables.len()); } - // Sparklines (Excel 2010 extension). + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } + } + s.push_str(""); + } if !sheet.sparklines.is_empty() { - s.push_str(""); + let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", @@ -593,7 +617,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( sp.cell.a1() ); } - s.push_str(""); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } s.push_str(""); (s, rels) @@ -714,6 +741,33 @@ fn quote(t: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } +const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; +const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; @@ -809,12 +863,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { b.push_str(""); format!("{b}") } - CfRule::DataBar { min, max, color, show_value, .. } => format!( - "{}{}", + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", if *show_value { "" } else { " showValue=\"0\"" }, cfvo_xml(min), cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) } + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!("9"), "{xml}"); + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = &back.sheets[0]; + assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); + assert_eq!(b.sparklines.len(), 1); +}