//! Writing worksheet parts and the parts they own (tables, comments, drawings, charts). use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt::Write as _; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{ Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet, SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count, }; use crate::drawing::{Obj, image_content_type, image_ext}; use crate::fmla::text_to_file; use crate::styles::{color_attrs, dxf_id}; use crate::tables::{paper_id, px_to_col_width, px_to_pt}; use crate::write::{ CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL, }; use crate::xml::{encode_xstring, esc, esc_attr, num}; fn sqref(ranges: &[RangeRef]) -> String { ranges.iter().map(|r| r.a1()).collect::>().join(" ") } fn cf_op_name(op: CfOperator) -> &'static str { match op { CfOperator::Between => "between", CfOperator::NotBetween => "notBetween", CfOperator::Equal => "equal", CfOperator::NotEqual => "notEqual", CfOperator::Greater => "greaterThan", CfOperator::Less => "lessThan", CfOperator::GreaterOrEqual => "greaterThanOrEqual", CfOperator::LessOrEqual => "lessThanOrEqual", } } /// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` /// (Microsoft "Names in formulas" rules). fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { let mut s: String = name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { s = format!("Table{n}"); } let mut out = s.clone(); let mut k = 2; while !used.insert(out.to_ascii_lowercase()) { out = format!("{s}{k}"); k += 1; } out } /// Column names for a table: one per column of its range, unique and non-empty. fn table_columns(t: &Table) -> Vec { let w = t.range.width() as usize; let mut used = HashSet::new(); (0..w) .map(|i| { let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); let mut name = base.clone(); let mut k = 2; while !used.insert(name.to_lowercase()) { name = format!("{base}{k}"); k += 1; } name }) .collect() } pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (String, Rels) { let mut rels = Rels::default(); let Some(sheet) = wb.sheet(si) else { return (String::new(), rels) }; let nstyles = wb.styles.len() as u32; let sid = |s: gridcraft_model::StyleId| if s.0 < nstyles { s.0 } else { 0 }; // A table's dropdown filter is kept in `sheet.autofilter`, not in `Table::filter` (the // engine's only live filter state, which the reader also fills). Where the two coincide, // fold the sheet filter's criteria into the table so they reach the file: the stored // `colId` offsets are relative to the range's first column, and both ranges start at the // same column, so they carry over verbatim. A filter that only partially covers a table // has nowhere valid to go, so it keeps being suppressed below. let folded: Vec = match &sheet.autofilter { Some(af) if !af.criteria.is_empty() => sheet .tables .iter() .cloned() .map(|mut t| { if t.header_row && t.filter_button && t.filter.is_empty() && t.range.contains_range(&af.range) && t.range.start.col == af.range.start.col { t.filter = af.criteria.clone(); } t }) .collect(), _ => sheet.tables.clone(), }; // Table header cells must hold the column names. let mut overrides: BTreeMap = BTreeMap::new(); let tables: Vec<(&Table, Vec)> = folded .iter() .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32)) .map(|t| (t, table_columns(t))) .collect(); for (t, cols) in &tables { if t.header_row { for (i, name) in cols.iter().enumerate() { overrides.insert(CellRef::new(t.range.start.row, t.range.start.col + i as u32), name.clone()); } } } // Dynamic-array spills: anchor → range, and the spilled values to cache. let dynamic: BTreeMap = sheet .spill_ranges .iter() .filter(|(a, r)| !r.is_single() && sheet.cells.get(**a).is_some_and(|c| c.formula.as_ref().is_some_and(|f| f.array.is_none()))) .map(|(a, r)| (*a, *r)) .collect(); let mut extra: BTreeMap = BTreeMap::new(); // Values inside spills and legacy array ranges. for (a, r) in sheet.spill_ranges.iter().filter(|(_, r)| !r.is_single()) { for c in r.iter().take(1_000_000) { if c != *a && let Some(v) = sheet.spill.get(&c) && sheet.cells.get(c).is_none_or(|x| x.formula.is_none() && x.value.is_empty()) { extra.insert(c, v.clone()); } } } let mut s = format!("{XML_DECL}"); // sheetPr let fit = sheet.print.fit_to.is_some(); let summary = !sheet.summary_below || !sheet.summary_right; if sheet.tab_color.is_some() || fit || summary { s.push_str(""); if let Some(c) = &sheet.tab_color { let _ = write!(s, "", color_attrs(c)); } if summary { s.push_str(""); } if fit { s.push_str(""); } s.push_str(""); } let mut used = sheet.cells.used_range(); for c in extra.keys().chain(overrides.keys()) { let r = RangeRef::cell(*c); used = Some(used.map_or(r, |u| u.union(&r))); } let _ = write!(s, "", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into())); // sheetViews s.push_str(""); let active = sheet.view_active.a1(); match sheet.freeze { Some((rows, cols)) if rows > 0 || cols > 0 => { let pane = match (rows > 0, cols > 0) { (true, true) => "bottomRight", (true, false) => "bottomLeft", _ => "topRight", }; s.push_str(" 0 { let _ = write!(s, " xSplit=\"{cols}\""); } if rows > 0 { let _ = write!(s, " ySplit=\"{rows}\""); } let _ = write!( s, " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", CellRef::new( rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) ) .a1() ); let _ = write!(s, ""); } _ => { let _ = write!(s, ""); } } s.push_str(""); // sheetFormatPr let max_row_outline = sheet.rows.values().map(|r| r.outline).max().unwrap_or(0); let max_col_outline = sheet.cols.values().map(|r| r.outline).max().unwrap_or(0); s.push_str(" 0.01 { let _ = write!(s, " defaultColWidth=\"{}\"", num(px_to_col_width(sheet.default_col_width))); } let _ = write!(s, " defaultRowHeight=\"{}\"", num(px_to_pt(sheet.default_row_height))); if (sheet.default_row_height - gridcraft_model::DEFAULT_ROW_HEIGHT).abs() > 0.01 { s.push_str(" customHeight=\"1\""); } if max_row_outline > 0 { let _ = write!(s, " outlineLevelRow=\"{max_row_outline}\""); } if max_col_outline > 0 { let _ = write!(s, " outlineLevelCol=\"{max_col_outline}\""); } s.push_str("/>"); // cols: runs of identical settings. if !sheet.cols.is_empty() { s.push_str(""); let mut run: Option<(u32, u32, LineInfo)> = None; let flush = |s: &mut String, r: (u32, u32, LineInfo)| { let (a, b, i) = r; let w = i.size.unwrap_or(sheet.default_col_width); let _ = write!(s, "0 { let _ = write!(s, " outlineLevel=\"{}\"", i.outline.min(7)); } if i.collapsed { s.push_str(" collapsed=\"1\""); } s.push_str("/>"); }; for (&c, info) in &sheet.cols { if c >= gridcraft_core::MAX_COLS { continue; } run = match run { Some((a, b, i)) if b + 1 == c && i == *info => Some((a, c, i)), Some(r) => { flush(&mut s, r); Some((c, c, *info)) } None => Some((c, c, *info)), }; } if let Some(r) = run { flush(&mut s, r); } s.push_str(""); } // sheetData let mut keys: BTreeSet = sheet.cells.iter().map(|(c, _)| c).collect(); keys.extend(sheet.cell_pictures.keys().copied()); keys.extend(extra.keys().copied()); keys.extend(overrides.keys().copied()); let mut rows: BTreeMap> = BTreeMap::new(); for k in keys { rows.entry(k.row).or_default().push(k); } for r in sheet.rows.keys() { rows.entry(*r).or_default(); } s.push_str(""); for (r, cells) in &rows { let info = sheet.rows.get(r).copied().unwrap_or_default(); if cells.is_empty() && info == LineInfo::default() { continue; } let _ = write!(s, ""); } s.push_str(""); // sheetProtection if let Some(p) = &sheet.protection { s.push_str(""); } s.push_str(&protected_ranges_xml(sheet)); // autoFilter (a sheet filter overlapping a table would be invalid). if let Some(af) = &sheet.autofilter && !sheet.tables.iter().any(|t| t.range.intersects(&af.range)) { s.push_str(&autofilter_xml(af.range, &af.criteria, out)); } if !sheet.merges.is_empty() { let merges: Vec<&RangeRef> = sheet.merges.iter().filter(|m| !m.is_single()).collect(); let _ = write!(s, "", merges.len()); for m in merges { let _ = write!(s, "", m.a1()); } s.push_str(""); } // Conditional formats, priorities renumbered 1..n in priority order. let mut order: Vec = (0..sheet.cond_formats.len()).collect(); order.sort_by_key(|&i| sheet.cond_formats.get(i).map(|c| c.priority).unwrap_or(0)); let mut prio = vec![0u32; sheet.cond_formats.len()]; for (rank, &i) in order.iter().enumerate() { if let Some(p) = prio.get_mut(i) { *p = rank as u32 + 1; } } for (i, cf) in sheet.cond_formats.iter().enumerate() { if cf.ranges.is_empty() { continue; } let _ = write!( s, "{}", sqref(&cf.ranges), cf_rule_xml(cf, prio.get(i).copied().unwrap_or(1), out) ); } let vals: Vec<&Validation> = sheet.validations.iter().filter(|v| !v.ranges.is_empty()).collect(); if !vals.is_empty() { let _ = write!(s, "", vals.len()); for v in vals { s.push_str(&validation_xml(v)); } s.push_str(""); } if !sheet.hyperlinks.is_empty() { s.push_str(""); for (c, h) in &sheet.hyperlinks { let t = h.target.trim(); if t.is_empty() { continue; } // `external` is what the file said, or what Insert › Link chose; the scheme test covers // links made before the flag existed. let external = h.external || t.contains("://") || t.to_ascii_lowercase().starts_with("mailto:") || t.starts_with("\\\\") || t.to_ascii_lowercase().starts_with("file:"); let _ = write!(s, " (u, Some(l)), _ => (t, None), }; let id = rels.add_external("hyperlink", url); let _ = write!(s, " r:id=\"{id}\""); if let Some(l) = loc { let _ = write!(s, " location=\"{}\"", esc_attr(l)); } } else { let _ = write!(s, " location=\"{}\" display=\"{}\"", esc_attr(t.trim_start_matches('#')), esc_attr(t.trim_start_matches('#'))); } if let Some(tt) = &h.tooltip { let _ = write!(s, " tooltip=\"{}\"", esc_attr(tt)); } s.push_str("/>"); } s.push_str(""); } // Printing. let p = &sheet.print; if p.gridlines || p.headings || p.center_h || p.center_v { s.push_str(""); } // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out); let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); s.push_str(""); if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { s.push_str("'); // CT_HeaderFooter's element order. for (tag, text) in [ ("oddHeader", &p.header), ("oddFooter", &p.footer), ("evenHeader", &p.even_header), ("evenFooter", &p.even_footer), ("firstHeader", &p.first_header), ("firstFooter", &p.first_footer), ] { if !text.is_empty() { let _ = write!(s, "<{tag}>{}", esc(text)); } } s.push_str(""); } for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { let list: Vec = list.iter().copied().filter(|&b| b > 0).collect(); if !list.is_empty() { let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len()); for b in list { let _ = write!(s, ""); } let _ = write!(s, ""); } } // Drawing: pictures, charts, shapes. if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { out.drawings += 1; let dn = out.drawings; let mut drels = Rels::default(); let mut objs: Vec> = vec![]; for img in &sheet.images { if img.data.is_empty() { continue; } let ext = image_ext(&img.mime, &img.data); out.default_type(ext, image_content_type(ext)); let file = out.image_part(ext, &img.data); let rid = drels.add("image", &format!("../media/{file}")); objs.push(Obj::Image(img, rid)); } for ch in &sheet.charts { out.charts += 1; let rid = if crate::chartex::is_chartex(ch.kind) { let n = out.charts; let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names); out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes()); // Excel expects a chartex part's style and colour parts. out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes()); out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes()); let mut crels = Rels::default(); crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml")); crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml")); out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes()); drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml")) } else { let name = format!("xl/charts/chart{}.xml", out.charts); out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes()); drels.add("chart", &format!("../charts/chart{}.xml", out.charts)) }; objs.push(Obj::Chart(ch, rid)); } for sh in &sheet.shapes { objs.push(Obj::Shape(sh)); } out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); if !drels.is_empty() { out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes()); } let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml")); let _ = write!(s, ""); } // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note // (its text with the replies as lines, for readers without threaded comments) linked by id. if !sheet.comments.is_empty() { out.comments += 1; let n = out.comments; let ids = thread_ids(sheet, si); out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); if !ids.is_empty() { let x = threaded_xml(sheet, &ids, &mut out.persons); out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); } out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); out.part(&vml_name, None, vml.into_bytes()); rels.add("comments", &format!("../comments{n}.xml")); let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); let _ = write!(s, ""); } else if let Some(rid) = kept.lone_vml(&mut rels, out) { let _ = write!(s, ""); } s.push_str(&kept.elements()); // Tables. if !tables.is_empty() { let mut parts = String::new(); let mut used_names: HashSet = HashSet::new(); for (t, cols) in &tables { out.tables += 1; out.table_id += 1; let n = out.tables; let name = table_name(&t.name, &mut used_names, out.table_id); let xml = table_xml(t, cols, &name, out.table_id, out); out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes()); let rid = rels.add("table", &format!("../tables/table{n}.xml")); let _ = write!(parts, ""); } let _ = write!(s, "{parts}", tables.len()); } // 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() { let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", SparklineKind::Column => " type=\"column\"", SparklineKind::WinLoss => " type=\"stacked\"", }; let src = if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) }; let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) }; let _ = write!( s, "{}{}", if sp.markers { " markers=\"1\"" } else { "" }, esc(&src), sp.cell.a1() ); } s.push_str(""); } if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { s.push_str(""); } s.push_str(""); (s, rels) } fn write_cell( s: &mut String, c: CellRef, cell: Option<&Cell>, picture: Option<&std::sync::Arc>, ov: Option<&String>, extra: Option<&Value>, dynamic: Option<&RangeRef>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out, ) { let style = cell.map(|x| sid(x.style)).unwrap_or(0); let _ = write!(s, "#VALUE!"); return; } let formula = if ov.is_some() { None } else { cell.and_then(|x| x.formula.as_ref()) }; let value: Value = match (ov, extra) { (Some(t), _) => Value::text(t.as_str()), (None, Some(v)) => v.clone(), _ => cell.map(|x| x.value.scalar()).unwrap_or(Value::Empty), }; let value = match value { Value::Array(a) => a.get(0, 0).cloned().unwrap_or(Value::Empty), v => v, }; let value = match value { Value::Error(CellError::Circ) => Value::Number(0.0), // #SPILL! and #CALC! are not error codes of the file format (Excel refuses a file that // caches them); Excel itself caches them as #VALUE!. Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), v => v, }; // A formula read with dynamic-array metadata keeps it, even where the shape analysis would // not need it: Excel decided the formula is evaluated as an array. let own = formula.filter(|f| f.dynamic && f.array.is_none()).map(|_| RangeRef::cell(c)); let dynamic = dynamic.or(own.as_ref()); // A non-spilling formula may still need array evaluation to avoid implicit intersection. if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) { s.push_str(" cm=\"1\""); out.dynamic_arrays = true; } if let Some(f) = formula { let t = match &value { Value::Text(_) => " t=\"str\"", Value::Bool(_) => " t=\"b\"", Value::Error(_) => " t=\"e\"", _ => "", }; s.push_str(t); s.push('>'); let text = esc(&crate::fmla::to_file(f)); match (f.array, dynamic) { (Some(r), _) => { let _ = write!(s, "{text}", r.a1()); } (None, Some(r)) => { let _ = write!(s, "{text}", r.a1()); } _ => { let _ = write!(s, "{text}"); } } match &value { Value::Number(n) => { let _ = write!(s, "{}", num(*n)); } Value::Text(t) => { let _ = write!(s, "{}", esc(&encode_xstring(t))); } Value::Bool(b) => { let _ = write!(s, "{}", *b as u8); } Value::Error(e) => { let _ = write!(s, "{}", e.as_str()); } _ => out.needs_calc = true, } s.push_str(""); return; } match &value { Value::Empty | Value::Array(_) => s.push_str("/>"), Value::Number(n) => { let _ = write!(s, ">{}", num(*n)); } Value::Text(t) => { let i = out.sst.index(t); let _ = write!(s, " t=\"s\">{i}"); } Value::Bool(b) => { let _ = write!(s, " t=\"b\">{}", *b as u8); } Value::Error(e) => { let _ = write!(s, " t=\"e\">{}", e.as_str()); } } } fn cfvo_xml(v: &CfValueKind) -> String { match v { CfValueKind::Min => "".into(), CfValueKind::Max => "".into(), CfValueKind::Number(n) => format!("", num(*n)), CfValueKind::Percent(n) => format!("", num(*n)), CfValueKind::Percentile(n) => format!("", num(*n)), CfValueKind::Formula(f) => format!("", esc_attr(&text_to_file(f))), } } 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 { "" }; let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); let f = |x: &str| format!("{}", esc(&text_to_file(x))); let simple = |ty: &str, st: &Style, extra: &str, body: String, dxf: &mut dyn FnMut(&Style) -> u32| { format!("{body}", dxf(st)) }; match &cf.rule { CfRule::CellIs { op, a, b, style } => { let mut body = f(a); if let Some(b) = b { body.push_str(&f(b)); } simple("cellIs", style, &format!(" operator=\"{}\"", cf_op_name(*op)), body, &mut dxf) } CfRule::Expression { formula, style } => simple("expression", style, "", f(formula), &mut dxf), CfRule::ContainsText { text, style } => simple( "containsText", style, &format!(" operator=\"containsText\" text=\"{}\"", esc_attr(text)), f(&format!("NOT(ISERROR(SEARCH({},{tl})))", quote(text))), &mut dxf, ), CfRule::NotContainsText { text, style } => simple( "notContainsText", style, &format!(" operator=\"notContains\" text=\"{}\"", esc_attr(text)), f(&format!("ISERROR(SEARCH({},{tl}))", quote(text))), &mut dxf, ), CfRule::BeginsWith { text, style } => simple( "beginsWith", style, &format!(" operator=\"beginsWith\" text=\"{}\"", esc_attr(text)), f(&format!("LEFT({tl},LEN({0}))={0}", quote(text))), &mut dxf, ), CfRule::EndsWith { text, style } => simple( "endsWith", style, &format!(" operator=\"endsWith\" text=\"{}\"", esc_attr(text)), f(&format!("RIGHT({tl},LEN({0}))={0}", quote(text))), &mut dxf, ), CfRule::Blanks { style } => simple("containsBlanks", style, "", f(&format!("LEN(TRIM({tl}))=0")), &mut dxf), CfRule::NoBlanks { style } => simple("notContainsBlanks", style, "", f(&format!("LEN(TRIM({tl}))>0")), &mut dxf), CfRule::Errors { style } => simple("containsErrors", style, "", f(&format!("ISERROR({tl})")), &mut dxf), CfRule::NoErrors { style } => simple("notContainsErrors", style, "", f(&format!("NOT(ISERROR({tl}))")), &mut dxf), CfRule::Duplicate { style } => simple("duplicateValues", style, "", String::new(), &mut dxf), CfRule::Unique { style } => simple("uniqueValues", style, "", String::new(), &mut dxf), CfRule::Top10 { bottom, percent, rank, style } => { let extra = format!("{}{} rank=\"{}\"", if *percent { " percent=\"1\"" } else { "" }, if *bottom { " bottom=\"1\"" } else { "" }, rank); simple("top10", style, &extra, String::new(), &mut dxf) } CfRule::AboveAverage { below, equal, std_dev, style } => { let mut extra = String::new(); if *below { extra.push_str(" aboveAverage=\"0\""); } if *equal { extra.push_str(" equalAverage=\"1\""); } if *std_dev > 0 { let _ = write!(extra, " stdDev=\"{std_dev}\""); } simple("aboveAverage", style, &extra, String::new(), &mut dxf) } CfRule::TimePeriod { period, style } => { let d = format!("FLOOR({tl},1)"); let body = match period.as_str() { "yesterday" => format!("{d}=TODAY()-1"), "tomorrow" => format!("{d}=TODAY()+1"), "last7Days" => format!("AND(TODAY()-{d}<=6,{d}<=TODAY())"), "thisMonth" => format!("AND(MONTH({tl})=MONTH(TODAY()),YEAR({tl})=YEAR(TODAY()))"), "lastMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0-1)),YEAR({tl})=YEAR(EDATE(TODAY(),0-1)))"), "nextMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0+1)),YEAR({tl})=YEAR(EDATE(TODAY(),0+1)))"), "thisWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)<=WEEKDAY(TODAY())-1,ROUNDDOWN({tl},0)-TODAY()<=7-WEEKDAY(TODAY()))"), "lastWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN({tl},0)<(WEEKDAY(TODAY())+7))"), "nextWeek" => format!("AND(ROUNDDOWN({tl},0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN({tl},0)-TODAY()<(15-WEEKDAY(TODAY())))"), _ => format!("{d}=TODAY()"), }; simple("timePeriod", style, &format!(" timePeriod=\"{}\"", esc_attr(period)), f(&body), &mut dxf) } CfRule::ColorScale { stops } => { let mut b = String::from(""); for (v, _) in stops { b.push_str(&cfvo_xml(v)); } for (_, c) in stops { let _ = write!(b, "", color_attrs(c)); } b.push_str(""); format!("{b}") } 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 *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!("'); // CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound. let k = icon_count(set); let defaults = default_icon_thresholds(set); let cfvos: Vec<&CfValueKind> = match thresholds.len() { n if n == k => thresholds.iter().collect(), n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(), _ => defaults.iter().collect(), }; for t in cfvos { b.push_str(&cfvo_xml(t)); } b.push_str(""); format!("{b}") } } } fn validation_formula(f: &str) -> String { let f = f.trim(); if f.starts_with('"') { return esc(f); } esc(&text_to_file(f.strip_prefix('=').unwrap_or(f))) } fn validation_xml(v: &Validation) -> String { let ty = match v.kind { ValidationKind::Any => "none", ValidationKind::Whole => "whole", ValidationKind::Decimal => "decimal", ValidationKind::List => "list", ValidationKind::Date => "date", ValidationKind::Time => "time", ValidationKind::TextLength => "textLength", ValidationKind::Custom => "custom", }; let mut s = format!(" {} ErrorStyle::Warning => s.push_str(" errorStyle=\"warning\""), ErrorStyle::Information => s.push_str(" errorStyle=\"information\""), } if v.op != CfOperator::Between && !matches!(v.kind, ValidationKind::List | ValidationKind::Custom | ValidationKind::Any) { let _ = write!(s, " operator=\"{}\"", cf_op_name(v.op)); } if v.allow_blank { s.push_str(" allowBlank=\"1\""); } if !v.in_cell_dropdown { s.push_str(" showDropDown=\"1\""); } if v.show_input { s.push_str(" showInputMessage=\"1\""); } if v.show_error { s.push_str(" showErrorMessage=\"1\""); } for (a, t) in [("errorTitle", &v.error_title), ("error", &v.error_message), ("promptTitle", &v.input_title), ("prompt", &v.input_message)] { if !t.is_empty() { let _ = write!(s, " {a}=\"{}\"", esc_attr(t)); } } let _ = write!(s, " sqref=\"{}\">", sqref(&v.ranges)); if !v.f1.is_empty() { let _ = write!(s, "{}", validation_formula(&v.f1)); } if let Some(f2) = v.f2.as_ref().filter(|f| !f.is_empty()) { let _ = write!(s, "{}", validation_formula(f2)); } s.push_str(""); s } fn filter_operator(op: &str) -> &'static str { match op.trim() { "<" => " operator=\"lessThan\"", "<=" => " operator=\"lessThanOrEqual\"", "<>" => " operator=\"notEqual\"", ">=" => " operator=\"greaterThanOrEqual\"", ">" => " operator=\"greaterThan\"", _ => "", } } fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String { let mut s = format!(""); return s; } s.push('>'); for (col, c) in criteria { let _ = write!(s, ""); match c { FilterCriterion::Values { values, blanks } => { let _ = write!(s, "", if *blanks { " blank=\"1\"" } else { "" }); for v in values { let _ = write!(s, "", esc_attr(v)); } s.push_str(""); } FilterCriterion::Custom { a, b, and } => { let _ = write!(s, "", if *and { " and=\"1\"" } else { "" }); for (op, v) in std::iter::once(a).chain(b.iter()) { let _ = write!(s, "", filter_operator(op), esc_attr(v)); } s.push_str(""); } FilterCriterion::Top10 { bottom, percent, count } => { let _ = write!(s, "", if *bottom { " top=\"0\"" } else { "" }, if *percent { " percent=\"1\"" } else { "" }); } FilterCriterion::AboveAverage(above) => { let _ = write!(s, "", if *above { "aboveAverage" } else { "belowAverage" }); } FilterCriterion::FillColor(c) => { let st = Style { fill: gridcraft_model::Fill::solid(*c), ..Default::default() }; let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); } FilterCriterion::FontColor(c) => { let mut st = Style::default(); st.font.color = *c; let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); } FilterCriterion::Icon { set, id } => { let _ = write!(s, ""); } } s.push_str(""); } s.push_str(""); s } fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String { let mut s = format!("{XML_DECL}
'); if t.header_row && t.filter_button { let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col)); s.push_str(&autofilter_xml(r, &t.filter, out)); } let _ = write!(s, "", cols.len()); for (i, name) in cols.iter().enumerate() { let col = t.columns.get(i); let _ = write!(s, " None, TotalsFn::Average => Some("average"), TotalsFn::Count => Some("count"), TotalsFn::CountNums => Some("countNums"), TotalsFn::Max => Some("max"), TotalsFn::Min => Some("min"), TotalsFn::Sum => Some("sum"), TotalsFn::StdDev => Some("stdDev"), TotalsFn::Var => Some("var"), TotalsFn::Custom => Some("custom"), }; if let Some(f) = f { let _ = write!(s, " totalsRowFunction=\"{f}\""); } else if let Some(l) = col.and_then(|c| c.totals_label.as_ref()) { let _ = write!(s, " totalsRowLabel=\"{}\"", esc_attr(l)); } } let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; let xml = col.and_then(|c| c.xml.as_ref()); if calc.is_none() && custom.is_none() && xml.is_none() { s.push_str("/>"); continue; } s.push('>'); if let Some(f) = calc { let _ = write!(s, "{}", esc(&text_to_file(f))); } if let Some(f) = custom { let _ = write!(s, "{}", esc(&text_to_file(f))); } if let Some(x) = xml { let _ = write!(s, "", esc_attr(&x.data_type)); } s.push_str(""); } s.push_str(""); let style = if t.style.is_empty() { "TableStyleMedium2" } else { &t.style }; let _ = write!( s, "
", esc_attr(style), t.first_col as u8, t.last_col as u8, t.banded_rows as u8, t.banded_cols as u8 ); s } /// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped /// and deterministic; they only need to be unique within the file. fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap> { let mut seq: u64 = 0; let mut ids = BTreeMap::new(); for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { let v: Vec = (0..=c.replies.len()) .map(|_| { seq += 1; format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF) }) .collect(); ids.insert(*cell, v); } ids } fn comments_xml(sheet: &Sheet, ids: &BTreeMap>) -> String { // A threaded comment's legacy note is authored by `tc={thread id}`. let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) { Some(id) => format!("tc={id}"), None => c.author.clone(), }; let mut authors: Vec = vec![]; for (cell, c) in &sheet.comments { let a = author_of(cell, c); if !authors.contains(&a) { authors.push(a); } } let mut s = if ids.is_empty() { format!("{XML_DECL}") } else { format!( "{XML_DECL}" ) }; for a in &authors { let _ = write!(s, "{}", esc(a)); } s.push_str(""); for (cell, c) in &sheet.comments { let a = author_of(cell, c); let aid = authors.iter().position(|x| *x == a).unwrap_or(0); let mut text = c.text.clone(); // Threaded replies are kept as text lines. for (who, reply) in &c.replies { let _ = write!(text, "\n{who}: {reply}"); } let link = match ids.get(cell).and_then(|v| v.first()) { Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""), None => String::new(), }; let _ = write!(s, "{}", cell.a1(), crate::xml::t_el(&text)); } s.push_str(""); s } /// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts. /// Anything else is left out rather than written into the file. fn iso_datetime(d: &str) -> bool { let b = d.as_bytes(); let digits = |r: std::ops::Range| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit)); d.len() <= 40 && digits(0..4) && b.get(4) == Some(&b'-') && digits(5..7) && b.get(7) == Some(&b'-') && digits(8..10) && b.get(10) == Some(&b'T') && digits(11..13) && b.get(13) == Some(&b':') && digits(14..16) && b.get(16) == Some(&b':') && digits(17..19) && b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c))) } /// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`). fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &mut Vec) -> String { let mut person = |name: &str| { let i = persons.iter().position(|p| p == name).unwrap_or_else(|| { persons.push(name.to_string()); persons.len() - 1 }); crate::write::person_guid(i) }; let mut s = format!("{XML_DECL}"); for (cell, id) in ids { let Some(c) = sheet.comments.get(cell) else { continue }; let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t))); for (k, ((author, text), own)) in entries.zip(id).enumerate() { let date = match c.dates.get(k).filter(|d| iso_datetime(d)) { Some(d) => format!(" dT=\"{}\"", esc_attr(d)), None => String::new(), }; let tail = match (k, id.first()) { (0, _) if c.resolved => " done=\"1\"".to_string(), (0, _) | (_, None) => String::new(), (_, Some(parent)) => format!(" parentId=\"{parent}\""), }; let _ = write!( s, "{}", cell.a1(), person(author), esc(text) ); } } s.push_str(""); s } /// A protection password hash as attributes, each name preceded by `prefix` (see /// [`crate::sheet_read::read_password`]). pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { let name = |base: &str| crate::sheet_read::password_attr(prefix, base); match h { gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", name("algorithmName"), esc_attr(algorithm), name("hashValue"), esc_attr(hash), name("saltValue"), esc_attr(salt), name("spinCount") ), } } /// Allow Edit Ranges: `` (after `sheetProtection`). fn protected_ranges_xml(sheet: &Sheet) -> String { let mut s = String::new(); for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); let _ = write!(s, ""); } if s.is_empty() { s } else { format!("{s}") } } /// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt. /// A box read from a file that equals it is kept as `None`, so a note's model survives a save. pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox { from: NoteCorner { col: 1, col_px: 15, row: 0, row_px: 2 }, to: NoteCorner { col: 3, col_px: 15, row: 4, row_px: 16 }, size_pt: Some(DEFAULT_NOTE_SIZE_PT), }; const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25); /// A note box's `` text and its width and height in points: the box read from the file /// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet. fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { let b = b.unwrap_or(&DEFAULT_NOTE_BOX); let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1); let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px); (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) } /// Legacy VML shapes Excel uses to display notes. fn vml_xml(sheet: &Sheet, n: u32) -> String { let mut s = format!( "" ); for (i, (cell, c)) in sheet.comments.iter().enumerate() { let id = n as u64 * 1024 + 1 + i as u64; let vis = if c.visible { "visible" } else { "hidden" }; let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); let _ = write!( s, "
{anchor}False{}{}{}
", num(w), num(h), i + 1, cell.row, cell.col, if c.visible { "" } else { "" } ); } s.push_str("
"); s }