Merge pull request #610 from MarekSpodBiedry:issue-550

This commit is contained in:
Brandon Thomas 2026-10-13 00:07:30 -04:00
commit 94ccbcd048
4 changed files with 116 additions and 7 deletions

View File

@ -28,6 +28,8 @@ pub struct Ctx<'a> {
pub num_fmts: std::collections::HashMap<u32, String>,
/// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute.
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
/// 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());

View File

@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
drop(bytes);
let mut fit_to_page = false;
cx.cf_ext_ids.clear();
for e in &root.children {
match e.name.as_str() {
"sheetPr" => {
@ -596,6 +597,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
let formulas: Vec<String> = 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,
@ -956,7 +966,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");
}
}
_ => {}
}
}

View File

@ -576,9 +576,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
}
let _ = write!(s, "<tableParts count=\"{}\">{parts}</tableParts>", 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("<extLst>");
}
if !solid_bars.is_empty() {
let _ = write!(
s,
"<ext uri=\"{{78C0D931-6437-407d-A8A1-0F90B4350D7D}}\" xmlns:x14=\"{X14_NS}\" xmlns:xm=\"{XM_NS}\"><x14:conditionalFormattings>"
);
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, "<x14:conditionalFormatting><x14:cfRule type=\"dataBar\" id=\"{id}\"><x14:dataBar gradient=\"0\">{lo}{hi}");
let _ = write!(s, "</x14:dataBar></x14:cfRule><xm:sqref>{}</xm:sqref></x14:conditionalFormatting>", sqref(&cf.ranges));
}
}
s.push_str("</x14:conditionalFormattings></ext>");
}
if !sheet.sparklines.is_empty() {
s.push_str("<extLst><ext uri=\"{05C60535-1F16-4fd2-B633-F4F36F0B64E0}\" xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\"><x14:sparklineGroups xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">");
let _ = write!(s, "<ext uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\" xmlns:x14=\"{X14_NS}\"><x14:sparklineGroups xmlns:xm=\"{XM_NS}\">");
for sp in &sheet.sparklines {
let ty = match sp.kind {
SparklineKind::Line => "",
@ -596,7 +620,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
sp.cell.a1()
);
}
s.push_str("</x14:sparklineGroups></ext></extLst>");
s.push_str("</x14:sparklineGroups></ext>");
}
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
s.push_str("</extLst>");
}
s.push_str("</worksheet>");
(s, rels)
@ -717,6 +744,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!("<extLst><ext uri=\"{{B025F937-C7B1-47D3-B67F-A62EFF666E3E}}\" xmlns:x14=\"{X14_NS}\"><x14:id>{id}</x14:id></ext></extLst>")
}
/// 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 "<x14:cfvo type=\"min\"/>".into(),
CfValueKind::Max => return "<x14:cfvo type=\"max\"/>".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!("<x14:cfvo type=\"{ty}\"><xm:f>{}</xm:f></x14:cfvo>", 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 { "" };
@ -812,12 +866,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String {
b.push_str("</colorScale>");
format!("<cfRule type=\"colorScale\" priority=\"{priority}\"{stop}>{b}</cfRule>")
}
CfRule::DataBar { min, max, color, show_value, .. } => format!(
"<cfRule type=\"dataBar\" priority=\"{priority}\"{stop}><dataBar{}>{}{}<color {}/></dataBar></cfRule>",
CfRule::DataBar { min, max, color, show_value, gradient } => format!(
"<cfRule type=\"dataBar\" priority=\"{priority}\"{stop}><dataBar{}>{}{}<color {}/></dataBar>{}</cfRule>",
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!("<iconSet iconSet=\"{}\"", esc_attr(set));

View File

@ -1052,6 +1052,31 @@ fn package_kind_sets_workbook_content_type() {
}
#[test]
fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() {
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
s.set_value(at("A1"), Value::Number(1.0));
let bar = |gradient: bool, max: CfValueKind| CfRule::DataBar {
min: CfValueKind::Min,
max,
color: Color::Rgb(0x638EC6),
gradient,
show_value: true,
};
let rules = [(true, "A1", CfValueKind::Max), (false, "B1", CfValueKind::Formula("$C$1".into())), (false, "D1:D3", CfValueKind::Number(9.0))];
for (i, (gradient, range, max)) in rules.into_iter().enumerate() {
s.cond_formats.push(CondFormat { ranges: vec![rr(range)], rule: bar(gradient, max), priority: i as u32 + 1, stop_if_true: false });
}
s.sparklines.push(Sparkline { cell: at("F1"), source: "A1:C1".into(), kind: SparklineKind::Line, color: Color::Rgb(0x376092), markers: false });
let bytes = write_xlsx(&wb).unwrap();
// x14 cfvo values are `xm:f` children; a `val` attribute there is not valid x14.
let (xml, _) = sheet1_xml(&bytes);
assert!(xml.contains("<x14:cfvo type=\"num\"><xm:f>9</xm:f></x14:cfvo>"), "{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);
fn doc_props_keep_boundary_whitespace() {
let mut wb = Workbook::new();
wb.props = DocProps {