Key tips HAT/HAM/HAB run Align Top/Middle/Bottom, with a test (review fix-up for #261)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-11 13:20:22 -04:00
co-authored by Claude Opus 5.5
144 changed files with 15694 additions and 1352 deletions
+119 -14
View File
@@ -90,10 +90,7 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
]
}
/// Number of icons in a set name like `3TrafficLights1`, `5Quarters`.
pub fn icon_count(set: &str) -> usize {
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3) as usize
}
pub use gridcraft_model::icon_count;
impl CfCache {
pub fn new() -> Self {
@@ -144,7 +141,8 @@ impl CfCache {
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
};
(ok && !v.is_empty()).then_some(style.as_ref())
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
(ok && !v.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
@@ -154,7 +152,7 @@ impl CfCache {
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
@@ -261,11 +259,18 @@ impl CfCache {
CfRule::IconSet { set, thresholds, reverse, show_value } => {
if let Some(n) = v.as_f64() {
let k = icon_count(set);
let cuts: Vec<f64> = if thresholds.len() + 1 == k {
thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect()
} else {
(1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect()
// One threshold per icon, the first being the lower bound (as in the file);
// also accept the lower bound left out, else Excel's defaults.
let defaults;
let lower = match thresholds.len() {
n if n == k => thresholds.get(1..).unwrap_or_default(),
n if n + 1 == k => thresholds.as_slice(),
_ => {
defaults = default_icon_thresholds(set);
defaults.get(1..).unwrap_or_default()
}
};
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
if *reverse {
idx = k - 1 - idx.min(k - 1);
@@ -281,10 +286,12 @@ impl CfCache {
};
if let Some(st) = matched_style {
any = true;
out.style = Some(match out.style.take() {
None => st.clone(),
Some(prev) => prev,
});
// Every matching rule applies; where two set the same part, the rule with the
// higher priority (seen first) wins.
match &mut out.style {
None => out.style = Some(st.clone()),
Some(prev) => layer_under(prev, st),
}
if cf.stop_if_true {
break;
}
@@ -294,6 +301,39 @@ impl CfCache {
}
}
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
/// are unset).
fn layer_under(style: &mut Style, lower: &Style) {
if style.fill.pattern == PatternType::None {
style.fill = lower.fill;
}
if style.font.color == Color::Auto {
style.font.color = lower.font.color;
}
style.font.bold |= lower.font.bold;
style.font.italic |= lower.font.italic;
style.font.strike |= lower.font.strike;
if style.font.underline == Underline::None {
style.font.underline = lower.font.underline;
}
let b = &mut style.borders;
for (dst, src) in [
(&mut b.left, lower.borders.left),
(&mut b.right, lower.borders.right),
(&mut b.top, lower.borders.top),
(&mut b.bottom, lower.borders.bottom),
(&mut b.diag_down, lower.borders.diag_down),
(&mut b.diag_up, lower.borders.diag_up),
] {
if dst.is_none() {
*dst = src;
}
}
if style.num_fmt.as_str() == "General" {
style.num_fmt = lower.num_fmt.clone();
}
}
/// Shifts a rule formula written for the top-left cell of the range to `c`.
fn relative(f: &str, first: CellRef, c: CellRef) -> String {
if first == c {
@@ -306,3 +346,68 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String {
Err(_) => f.to_string(),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn every_matching_rule_applies() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap();
let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}});
s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap();
s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
assert!(look("A1").is_none());
let only_fill = look("A2").unwrap();
assert_eq!(only_fill.fill.pattern, PatternType::Solid);
assert!(only_fill.borders.top.is_none());
let both = look("A3").unwrap();
assert_eq!(both.fill, only_fill.fill);
assert!(!both.borders.top.is_none());
}
#[test]
fn cell_value_rules_on_blanks_and_errors() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap();
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"})))
.unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap();
s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"})))
.unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
let fill = |st: Option<Style>| st.is_some_and(|st| st.fill.pattern == PatternType::Solid);
let border = |st: Option<Style>| st.is_some_and(|st| !st.borders.top.is_none());
// Less than 5: 3 and the blank cell (as 0), not #N/A.
assert_eq!([look("A1"), look("A2"), look("A3"), look("A5")].map(fill), [true, true, false, false]);
// Greater than 5: 7, not #N/A.
assert_eq!([look("B1"), look("B2")].map(fill), [false, true]);
// No blanks: neither the empty cell nor the one holding a space.
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
}
#[test]
fn cell_value_operator_names() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [7]]})).unwrap();
let rule = |op: &str| json!({"range": "A1:A2", "rule": {"type": "cellIs", "operator": op, "value": "5", "preset": "lightRedFill"}});
s.execute("home.conditionalFormat", rule("lessThan")).unwrap();
assert!(s.execute("home.conditionalFormat", rule("smaller")).is_err());
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
assert_eq!([look("A1"), look("A2")], [true, false]);
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Reading the HTML flavour of the system clipboard on paste.
//!
//! Spreadsheets put an HTML table on the clipboard next to the tab-separated text. The text holds
//! what each cell *shows* ("Rp10,000.00"), which en-US input parsing can't always turn back into a
//! number. The HTML of the common sources also carries the underlying number as a cell attribute:
//! `x:num="10000"` (Excel, Excel for the web) and `sdval="10000"` (LibreOffice Calc). Paste uses
//! it to keep such cells numeric (#257). Only the table structure, the cell text and these two
//! attributes are read; everything else in the markup is ignored.
/// Largest clipboard HTML we look at; past this the paste uses the plain text alone.
const MAX_HTML: usize = 16 * 1024 * 1024;
/// Widest `colspan` honoured (the sheet's column count).
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
}
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
if html.len() > MAX_HTML {
return rows;
}
let mut cell: Option<(HtmlCell, usize)> = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest);
}
break;
};
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("<!--") {
rest = after.find("-->").and_then(|e| after.get(e + 3..)).unwrap_or_default();
continue;
}
let Some(gt) = tag_end(rest) else { break };
let tag = rest.get(1..gt).unwrap_or_default();
rest = rest.get(gt + 1..).unwrap_or_default();
let closing = tag.starts_with('/');
let body = tag.trim_start_matches('/');
let name_end = body.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(body.len());
let name = body.get(..name_end).unwrap_or_default().to_ascii_lowercase();
let attrs = body.get(name_end..).unwrap_or_default();
match (name.as_str(), closing) {
("style" | "script", false) => {
// Raw text: skip to the closing tag.
let end = format!("</{name}");
rest = find_ci(rest, &end).and_then(|i| rest.get(i..)).unwrap_or_default();
}
("table", false) => {
if in_table {
break; // a nested table: the outer one is the clipboard grid, stop here
}
in_table = true;
}
("table", true) => {
finish(&mut rows, &mut cell);
if !rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
finish(&mut rows, &mut cell);
rows.push(Vec::new());
}
("td" | "th", false) if in_table => {
finish(&mut rows, &mut cell);
if rows.is_empty() {
rows.push(Vec::new());
}
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
cell = Some((HtmlCell { text: String::new(), num }, span));
}
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
("br", _) => {
if let Some((c, _)) = cell.as_mut() {
c.text.push(' ');
}
}
_ => {}
}
}
finish(&mut rows, &mut cell);
rows
}
/// Closes the open cell (if any) into the last row, decoding its text.
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
let Some((mut c, span)) = cell.take() else { return };
c.text = decode(&c.text);
if rows.is_empty() {
rows.push(Vec::new());
}
if let Some(row) = rows.last_mut() {
row.push(c);
let room = MAX_SPAN.saturating_sub(row.len());
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
}
}
/// Byte index of the `>` that closes the tag `rest` starts with; a `>` inside a quoted attribute
/// value (an inline `mso-number-format` condition such as `[>=1000]`) doesn't end it.
fn tag_end(rest: &str) -> Option<usize> {
let mut quote: Option<char> = None;
for (i, c) in rest.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
_ => {}
}
}
None
}
/// Case-insensitive ASCII search.
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let (h, n) = (hay.as_bytes(), needle.as_bytes());
if n.is_empty() || h.len() < n.len() {
return None;
}
(0..=h.len() - n.len()).find(|&i| h.get(i..i + n.len()).is_some_and(|w| w.eq_ignore_ascii_case(n)))
}
/// The value of attribute `name` in a tag's attribute text (quoted or bare). A bare attribute
/// without `=` has no value.
fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
let mut s = attrs;
loop {
s = s.trim_start_matches(|c: char| c.is_whitespace() || c == '/');
if s.is_empty() {
return None;
}
let key_end = s.find(|c: char| c.is_whitespace() || c == '=' || c == '/').unwrap_or(s.len());
let key = s.get(..key_end).unwrap_or_default();
s = s.get(key_end..).unwrap_or_default().trim_start();
let mut value = None;
if let Some(after) = s.strip_prefix('=') {
let after = after.trim_start();
let (v, next) = match after.chars().next() {
Some(q @ ('"' | '\'')) => {
let inner = after.get(1..).unwrap_or_default();
let end = inner.find(q).unwrap_or(inner.len());
(inner.get(..end).unwrap_or_default(), inner.get(end + 1..).unwrap_or_default())
}
_ => {
let end = after.find(char::is_whitespace).unwrap_or(after.len());
(after.get(..end).unwrap_or_default(), after.get(end..).unwrap_or_default())
}
};
value = Some(v);
s = next;
}
if key.eq_ignore_ascii_case(name) {
return value;
}
if key.is_empty() && value.is_none() {
// Stray character (e.g. a lone '='): skip it so the loop always advances.
let mut chars = s.chars();
chars.next();
s = chars.as_str();
}
}
}
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
let semi = rest.get(..12).unwrap_or(rest).find(';');
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => {
let n = e
.strip_prefix("#x")
.or_else(|| e.strip_prefix("#X"))
.map(|h| u32::from_str_radix(h, 16))
.or_else(|| e.strip_prefix('#').map(str::parse::<u32>));
n.and_then(Result::ok).and_then(char::from_u32)
}
});
match (ch, semi) {
(Some(c), Some(e)) => {
out.push(c);
rest = rest.get(e + 1..).unwrap_or_default();
}
_ => {
out.push('&');
rest = rest.get(1..).unwrap_or_default();
}
}
}
out.push_str(rest);
let mut text = String::with_capacity(out.len());
let mut space = false;
for c in out.chars() {
if c.is_whitespace() && c != '\u{a0}' {
space = true;
continue;
}
if space && !text.is_empty() {
text.push(' ');
}
space = false;
text.push(c);
}
text
}
/// Same-looking text: non-breaking spaces as spaces, whitespace runs collapsed, trimmed.
pub(crate) fn normalized(s: &str) -> String {
s.replace('\u{a0}', " ").split_whitespace().collect::<Vec<_>>().join(" ")
}
/// A number format that shows `n` exactly as `shown` ("Rp10,000.00" for 10000 →
/// `"Rp"#,##0.00`), inferred from the text around the digits. `None` unless the format renders
/// `shown` back character for character.
pub(crate) fn format_for(shown: &str, n: f64) -> Option<String> {
let shown = normalized(shown);
let start = shown.find(|c: char| c.is_ascii_digit())?;
let end = shown.rfind(|c: char| c.is_ascii_digit()).map(|i| i + 1)?;
let core = shown.get(start..end)?;
let mut prefix = shown.get(..start)?.to_string();
let suffix = shown.get(end..)?;
if core.chars().any(|c| !(c.is_ascii_digit() || c == ',' || c == '.')) {
return None;
}
if n < 0.0 {
// A one-section format shows the minus sign itself, in front of everything.
prefix = prefix.strip_prefix('-')?.to_string();
}
let decimals = core.split_once('.').map_or(0, |(_, f)| f.len());
let mut code = String::new();
let quote = |s: &str| if s.is_empty() { String::new() } else { format!("\"{}\"", s.replace('"', "")) };
code.push_str(&quote(&prefix));
code.push_str(if core.contains(',') { "#,##0" } else { "0" });
if decimals > 0 {
code.push('.');
code.push_str(&"0".repeat(decimals.min(30)));
}
code.push_str(&quote(suffix));
let rendered = gridcraft_numfmt::text_function(&gridcraft_core::Value::number(n), &code, gridcraft_core::date::DateSystem::D1900).ok()?;
(normalized(&rendered) == shown).then_some(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_excel_and_libreoffice_cell_values() {
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
assert_eq!(rows[1][0].text, "2.50 & up");
assert_eq!(rows[1][2].text, "x");
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
"",
"<",
"<td",
"<table><td x:num=>",
"<table><tr><td colspan=999999999>a",
"&#xFFFFFFFF;&",
"<table><td x:num='1e999'>1</td>",
"<!--",
"<style>",
"<table><table><td>",
] {
let _ = table_cells(html);
}
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
}
#[test]
fn infers_a_format_that_shows_the_same_text() {
assert_eq!(format_for("Rp10,000.00", 10000.0).as_deref(), Some("\"Rp\"#,##0.00"));
assert_eq!(format_for("1 234 kr", 1234.0), None, "space grouping is not reproduced");
assert_eq!(format_for("10.000,00 €", 10000.0), None, "comma decimals don't render back in en-US");
assert_eq!(format_for("-Rp5", -5.0).as_deref(), Some("\"Rp\"0"));
assert_eq!(format_for("12 EUR", 12.0).as_deref(), Some("0\" EUR\""));
}
}
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"data.filter",
"Filter",
["Data", "Sort & Filter"],
Some("Cmd+Shift+F"),
Some("Ctrl+Shift+L"),
"{range?} toggles AutoFilter on the current region",
has_doc,
toggle_filter
@@ -248,7 +248,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
pos(va).cmp(&pos(vb))
} else if case && va.is_text() && vb.is_text() {
va.as_text().unwrap_or("").cmp(vb.as_text().unwrap_or(""))
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
} else {
sort_compare(va, vb)
};
@@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"filter": false}))
});
}
let r = data_range(s, p)?;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
@@ -653,6 +662,12 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
/// before uppercase at the first letter that differs in case.
fn case_order(a: &str, b: &str) -> Ordering {
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
}
fn sort_key(v: &Value) -> (u8, i64, String) {
match v {
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
@@ -685,8 +700,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut seen = std::collections::HashSet::new();
let mut keep: Vec<u32> = Vec::new();
// Cells are compared by the text they show, as in Excel: the same date in two formats
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
for row in start..=r.end.row {
let key: Vec<String> = cols.iter().map(|c| sh.value(CellRef::new(row, *c)).display().to_lowercase()).collect();
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
if seen.insert(key) {
keep.push(row);
} else {
+175 -26
View File
@@ -1,6 +1,6 @@
//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
has_doc,
paste
),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
has_doc,
auto_fill
),
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
@@ -196,13 +196,19 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
Some(c) => c,
None => s.doc()?.selection.active,
};
let input =
str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default();
let input = str_param(p, "input")
.or_else(|| str_param(p, "value"))
.map(str::to_string)
.or_else(|| p.get("value").map(json_to_input))
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let protected =
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked));
if protected {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
edit(s, |cx| {
if array {
@@ -408,9 +414,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col);
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1));
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
@@ -604,25 +610,36 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
if ranges.len() > 1 && cut {
return Err(multiple());
}
let range = ranges.first().copied().unwrap_or_default();
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let range = if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
let trim = |range: RangeRef| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let text = crate::display::range_text(&d.wb, sheet, range);
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
@@ -631,6 +648,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
@@ -674,13 +768,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
@@ -700,6 +799,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
@@ -769,6 +889,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param(p, "at").unwrap_or(sel.start);
@@ -1050,6 +1173,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
// and only when every box in the selection is unchecked are the boxes removed.
let uncheck_only = what == "contents" && {
let d = s.doc()?;
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
};
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
@@ -1061,6 +1196,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
cell.value = Value::Empty;
if checkbox {
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
}
}
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
@@ -1068,8 +1218,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.remove_cell(c);
}
"contents" => {
cell.value = Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
"formats" => {
@@ -1139,6 +1287,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
_ => crate::fill::FillMode::Series,
+84 -20
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data…",
["Chart Design", "Data"],
None,
"{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}",
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
has_doc,
select_data
),
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Checkbox",
["Insert", "Cell Controls"],
None,
"{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)",
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
has_doc,
checkbox
),
@@ -350,22 +350,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
}
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string();
let (sheet_name, body) = split_sheet(&range);
let d = s.doc()?;
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1());
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
@@ -828,6 +876,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
@@ -843,9 +896,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
let v = sh.value(c);
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
sh.set_cell(c, Cell { value, formula: None, style: new });
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
@@ -862,12 +920,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& let Value::Bool(b) = cell.value
&& cell.formula.is_none()
{
cell.value = Value::Bool(!b);
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}
+26 -1
View File
@@ -1,4 +1,4 @@
//! File menu: new, open, save, close, export, properties.
//! File menu: new, open, save, close, export, properties, options.
use serde_json::{Value as Json, json};
@@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
query "file.options",
"Options",
["File"],
None,
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
]
}
@@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
/// page. Nothing changes here; every setting has its own command.
fn options(s: &mut Session, _: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
let calculation = s.active().map(|d| {
let c = &d.wb.calc;
let mode = match c.mode {
gridcraft_model::CalcMode::Automatic => "automatic",
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
gridcraft_model::CalcMode::Manual => "manual",
};
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
});
Ok(json!({"calculation": calculation}))
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
+11 -5
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
// Alignment
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], Some("Cmd+R"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
@@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Row Height…",
["Home", "Cells", "Format"],
None,
"{rows?: \"2:5\", height: 20 (points on screen)}",
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
has_doc,
row_height
),
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Column Width…",
["Home", "Cells", "Format"],
None,
"{cols?: \"B:D\", width: 64 (points) | chars: 8.43}",
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
has_doc,
col_width
),
@@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
let key = if rows { "rows" } else { "cols" };
if let Some(t) = str_param(p, key) {
let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?;
return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]);
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
return t
.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect();
}
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
}
+73 -2
View File
@@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{CalcMode, DefinedName};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use serde_json::{Value as Json, json};
use super::*;
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?}",
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
has_doc,
define_name
),
@@ -185,6 +185,7 @@ pub fn function_list() -> Vec<Json> {
("SHEET", "Information", "SHEET([value])", "Sheet number."),
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
("TRIMRANGE", "Lookup", "TRIMRANGE(range, [trim_rows], [trim_cols])", "A range or array without its blank outer rows and columns."),
("ISOMITTED", "Information", "ISOMITTED(argument)", "TRUE when a LAMBDA argument was left out."),
];
for (n, c, sig, d) in specials {
@@ -265,7 +266,32 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
// Editing an existing name (Name Manager › Edit…): it must exist, and its new name must not
// collide with another name in the target scope.
let old = match str_param(p, "oldName") {
Some(old_name) => {
let old_scope = match str_param(p, "oldScope") {
None | Some("Workbook") => None,
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
if !d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(old_name) && n.scope == old_scope) {
return Err(bad("formulas.defineName", "no such name to edit"));
}
let same = old_name.eq_ignore_ascii_case(&name) && old_scope == scope;
if !same && d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name) && n.scope == scope) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
Some((old_name.to_string(), old_scope))
}
None => None,
};
edit(s, |cx| {
if let Some((old_name, old_scope)) = &old {
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(old_name) && n.scope == *old_scope));
if !old_name.eq_ignore_ascii_case(&name) {
rename_name_in_formulas(&mut cx.wb, old_name, *old_scope, &name);
}
}
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && n.scope == scope));
cx.wb.names.push(DefinedName { name: name.clone(), scope, formula: refers.clone(), comment: comment.clone(), hidden: false });
cx.structural = true;
@@ -273,6 +299,51 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Formulas that use a renamed name follow it: cell formulas and other names' definitions, on
/// the sheets where `old` resolves to the renamed name (its own sheet for a sheet-scoped name;
/// every sheet without a same-named sheet-level name for a workbook-level one).
fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_scope: Option<usize>, new: &str) {
let shadowed: Vec<usize> = wb.names.iter().filter(|n| n.name.eq_ignore_ascii_case(old)).filter_map(|n| n.scope).collect();
let applies = |sheet: usize| match old_scope {
Some(s) => s == sheet,
None => !shadowed.contains(&sheet),
};
// The renamed expression, or `None` when the name doesn't occur in it.
let rename = |e: gridcraft_formula::Expr| {
let mut hit = false;
let out = e.map(&mut |x| match x {
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(old) => {
hit = true;
gridcraft_formula::Expr::Name(new.to_string())
}
o => o,
});
hit.then_some(out)
};
for i in 0..wb.sheets.len() {
if !applies(i) {
continue;
}
let Some(sh) = wb.sheet_mut(i) else { continue };
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> = sh.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect();
for (c, f) in formulas {
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
}
}
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
let scope = match str_param(p, "scope") {
+119 -20
View File
@@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_chart
),
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
s,
&with(p, "type", json!("auto"))
)),
cmd!(
"insert.recommendedCharts",
"Recommended Charts",
["Insert", "Charts"],
None,
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
has_doc,
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
),
cmd!(
query "insert.chartRecommendations",
"Chart Recommendations",
[],
None,
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
has_doc,
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
),
cmd!(
"chart.set",
"Chart Properties",
@@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_link
),
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
s, p, true
)),
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
@@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
// A blank top-left cell above a header row marks the first column as the category labels even
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
// plotted as a series of its own (#287).
let blank_corner = header_row && sh.value(r.start).is_empty();
let label_col =
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
@@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
@@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let by_rows = r.width() > r.height() + 1;
let series = series_from_range(sh, r, by_rows);
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
_ => None,
})
.collect();
let points = ranges.first().map_or(0, RangeRef::count);
let (mut any_number, mut non_negative) = (false, true);
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
any_number = true;
non_negative &= n >= 0.0;
}
if series.is_empty() || !any_number {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let labelled = series.first().is_some_and(|x| x.categories.is_some());
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
const LINE: (&str, &str, &str) = ("line", "", "Line");
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
const AREA: (&str, &str, &str) = ("area", "", "Area");
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
let mut out = Vec::new();
if series.len() == 2 && !labelled && !by_rows {
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
} else if series.len() == 1 {
if points > 12 {
out.extend([LINE, COLUMN, AREA]);
} else {
out.extend([COLUMN, BAR]);
if non_negative && points >= 2 {
out.extend([PIE, DOUGHNUT]);
}
out.push(LINE_MARKERS);
}
} else if points > 12 {
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
} else {
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
}
Ok(out)
}
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
if t == "auto" {
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
t = first.0.into();
sub = first.1.into();
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
if t == "auto" {
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
}
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
let kind = chart_kind(&t, &sub);
let series = series_from_range(sh, r, by_rows);
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
@@ -606,7 +695,14 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
by_rows,
pivot: None,
};
Ok((sheet, chart))
}
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, chart) = build_chart(s, p)?;
let id = chart.id;
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
@@ -782,7 +878,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
@@ -1036,9 +1132,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?
.comments
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
);
Ok(json!({"cell": at.a1()}))
})
}
@@ -1157,15 +1254,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let rule = match kind.as_str() {
"cellIs" => {
// Excel's file and VBA names (`lessThan`, …) are accepted too.
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" => CfOperator::Less,
"greaterOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Greater,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
"greater" | "greaterThan" => CfOperator::Greater,
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
};
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
+42 -4
View File
@@ -1,6 +1,7 @@
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
@@ -14,6 +15,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod solver;
pub mod spelling;
pub mod view;
pub mod whatif;
@@ -69,6 +71,21 @@ impl CommandSpec {
}
}
/// Params that hold a secret: `password`, or a name ending in `Password`.
pub fn is_secret_param(key: &str) -> bool {
key == "password" || key.ends_with("Password")
}
/// The params as the journal records them: secret params are left out, so a password never
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
/// Replaying such an entry runs the command without that param.
pub fn journal_params(params: &Json) -> Json {
match params {
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
other => other.clone(),
}
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@@ -118,8 +135,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
})
@@ -257,7 +276,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb)?;
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
@@ -292,7 +311,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
continue;
@@ -302,14 +321,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
continue;
}
for c in sh.cells.diff(&after.cells) {
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
return Err(EngineError::Other(PROTECTED.into()));
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
return Err(EngineError::Other(m));
}
}
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
None => PROTECTED.into(),
})
}
/// Two cells hold the same value or formula (formats aside).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
+328 -5
View File
@@ -43,6 +43,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
summarize_table
),
cmd!(
"pivot.calculatedField",
"Calculated Field",
["PivotTable Analyze", "Calculations"],
None,
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
has_doc,
calculated_field
),
cmd!(
"insert.pivotChart",
"PivotChart",
["Insert", "Charts"],
None,
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
has_doc,
insert_pivot_chart
),
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
@@ -320,6 +338,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
}
/// The calculated field named `field`, if any.
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
}
/// The canonical name of a source or calculated field.
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
match calc_field(pt, field) {
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
_ => canonical(cmd, src, field),
}
}
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
if !taken(base) {
@@ -343,7 +374,11 @@ fn add_to_area(
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
let field = canonical(cmd, src, field)?;
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
if is_calc && area != "values" {
return Err(other("A calculated field can only be placed in the Values area."));
}
let field = canonical_any(cmd, pt, src, field)?;
match area {
"rows" | "columns" => {
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
@@ -369,7 +404,7 @@ fn add_to_area(
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
}
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
let base = match name {
Some(n) if !n.trim().is_empty() => {
@@ -663,6 +698,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
})
}
@@ -702,6 +738,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
.collect(),
Err(_) => vec![],
};
let mut list = list;
for c in &pt.calculated_fields {
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
}
if let Some(m) = out.as_object_mut() {
m.insert("fields".into(), Json::Array(list));
if let Ok(src) = &src {
@@ -743,7 +784,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.calculatedField";
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
// Menu-style call: the Insert Calculated Field dialog.
let (si, pi) = find_pivot(s, p)?;
let d = s.doc()?;
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
let pivot = pt.name.clone();
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
return ok();
};
if name.is_empty() || name.chars().count() > 255 {
return Err(bad(C, "`name` must be 1 to 255 characters"));
}
let delete = bool_param(p, "delete").unwrap_or(false);
let formula = str_param(p, "formula").map(|f| {
let f = f.trim();
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
});
let to_values = bool_param(p, "values").unwrap_or(true);
if !delete && formula.is_none() {
return Err(bad(C, "missing `formula`"));
}
update(s, p, |pt, src, _| {
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
if delete {
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
let gone = pt.calculated_fields.remove(i);
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
return Ok(json!({"name": gone.name, "deleted": true}));
}
let formula = formula.clone().unwrap_or_default();
if src.col(&name).is_some() {
return Err(other("PivotTable field name already exists."));
}
let canon = match existing {
Some(i) => {
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
c.formula = formula.clone();
c.name.clone()
}
None => {
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
}
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
name.clone()
}
};
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
if let Some(e) = set.error(i) {
return Err(bad(C, e.to_string()));
}
if set.is_cyclic(i) {
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
}
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
}
Ok(json!({
"name": canon,
"formula": formula,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
}))
})
}
@@ -1171,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
c.pivot = None;
}
}
}
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
if pi < sh.pivots.len() {
@@ -1181,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
})
}
// ---------------------------------------------------------------- PivotChart
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(t) = str_param(p, "pivot") {
let d = s.doc()?;
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
}
}
find_pivot(s, p)
}
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = chart_pivot(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
}
let series = pv::chart_series(&sh.name, area.start, &out);
let t = str_param(p, "type").unwrap_or("column");
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let title = match p.get("title") {
Some(Json::String(t)) => Some(t.clone()),
Some(Json::Null) => None,
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
};
let id = d.wb.next_object_id();
let link = pv::chart_link(&sh.name, &pt.name);
let pivot_name = pt.name.clone();
let n = series.len();
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: Some(link),
};
edit(s, |cx| {
cx.sheet_mut(si)?.charts.push(chart);
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
})
}
// ---------------------------------------------------------------- recommended
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
@@ -1280,8 +1476,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use gridcraft_core::{CellError, CellRef, Value};
use serde_json::{Value as Json, json};
use crate::Session;
@@ -1550,6 +1746,133 @@ mod tests {
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
#[test]
fn pivot_chart_follows_the_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
// Any cell of the report picks the PivotTable.
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
assert_eq!(ch.title.as_deref(), Some("Total"));
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
// A column field: one series per item, rebound by the layout change.
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
let ch = pivot_chart(&s);
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
// Nested row fields: the innermost items only, as a union around the parent rows.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
let ch = pivot_chart(&s);
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
let pts = plotted(&s);
assert_eq!(pts[0].len(), 9);
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
// Survives an XLSX round trip as a PivotChart.
let wb = (*s.doc().unwrap().wb).clone();
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
let si = back.sheet_index("Sheet2").unwrap();
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
// Renaming the sheet keeps the link; a refresh still rebinds.
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
assert_eq!(pivot_chart(&s).series.len(), 3);
// Deleting the PivotTable leaves an ordinary chart.
s.execute("pivot.delete", json!({})).unwrap();
assert_eq!(pivot_chart(&s).pivot, None);
}
#[test]
fn pivot_chart_errors() {
let mut s = sales();
// No PivotTable yet.
assert!(s.execute("insert.pivotChart", json!({})).is_err());
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
// Not in the report; no values.
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
assert_eq!(ch.title, None);
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
}
#[test]
fn calculated_field_on_sums() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
assert_eq!(r["formula"], "=Sales / Units");
assert_eq!(text(&s, "C3"), "Sum of Price");
assert_eq!(text(&s, "A4"), "East");
// East: 415 sales over 33 units, from the sums.
let east = val(&s, "C4").as_f64().unwrap();
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
// The grand total applies the formula to the totals, not the sum of the rows.
assert_eq!(text(&s, "A7"), "Grand Total");
let total = val(&s, "C7").as_f64().unwrap();
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
// Quoted names, functions, and another calculated field.
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
assert_eq!(val(&s, "D4"), Value::Number(25.2));
// Division by zero shows #DIV/0! cells.
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
// Change and delete.
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Number(66.0));
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
assert_eq!(pt(&s).values.len(), 3);
assert_eq!(pt(&s).calculated_fields.len(), 2);
// The field list shows calculated fields.
let f = s.execute("pivot.fields", json!({})).unwrap();
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
}
#[test]
fn calculated_field_errors() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
assert!(e(json!({"name": "Bad"})).contains("formula"));
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
// A cycle is refused.
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
assert!(err.contains("itself"), "{err}");
// Calculated fields only go to Values; nothing changed after the failures.
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
assert_eq!(pt(&s).calculated_fields.len(), 2);
// Without a name the dialog opens (menu-style call).
s.ui_requests.clear();
s.execute("pivot.calculatedField", json!({})).unwrap();
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
}
#[test]
fn replace_protection() {
let mut s = sales();
+348 -2
View File
@@ -1,6 +1,8 @@
//! Review tab: protection, spelling (basic), workbook statistics.
//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the
//! Comments pane, Show All Notes and Convert to Comments.
use gridcraft_model::{SheetProtection, password_hash};
use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::*;
@@ -19,10 +21,107 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
// Not journaled: the params can hold a password.
CommandSpec {
id: "review.allowEditRanges",
label: "Allow Edit Ranges…",
menu: &["Review", "Protect"],
shortcut: None,
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
enabled: has_doc,
run: allow_edit_ranges,
journal: false,
undoable: true,
},
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| {
step_comment(s, false)
}),
cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment(
s, true
)),
cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| {
s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane);
Ok(json!({"on": s.comments_pane}))
}),
cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| {
s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes);
Ok(json!({"on": s.show_all_notes}))
}),
cmd!(
"review.convertToComments",
"Convert to Comments",
["Review", "Notes"],
None,
"{} (every note in the workbook) → {converted}",
has_doc,
convert_notes
),
]
}
/// Threaded comments in navigation order: visible sheets in tab order, then row by row.
fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> {
let Some(d) = s.active() else { return vec![] };
d.wb.sheets
.iter()
.enumerate()
.filter(|(_, sh)| sh.visibility == Visibility::Visible)
// `CellRef` orders by row, then column: reading order.
.flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at)))
.collect()
}
fn has_comments(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) }
}
/// Selects the next (or previous) threaded comment after the active cell, moving on to the next
/// sheet and wrapping around at the end of the workbook.
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
let list = comment_cells(s);
let d = s.doc()?;
let here = (d.wb.active_sheet, d.selection.active);
let target = if forward {
list.iter().find(|c| **c > here).or_else(|| list.first())
} else {
list.iter().rev().find(|c| **c < here).or_else(|| list.last())
};
let Some(&(sheet, at)) = target else {
return Err(EngineError::Other("There are no comments in this workbook.".into()));
};
let d = s.doc_mut()?;
d.wb_switch_sheet(sheet);
d.selection = crate::Selection::at(at);
if let Some(sh) = d.wb.active() {
d.selection.expand_merges(sh);
}
let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default();
Ok(json!({"sheet": name, "cell": at.a1()}))
}
/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment
/// with the same author and text.
fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
let sheets: Vec<usize> =
s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect();
if sheets.is_empty() {
return Ok(json!({"converted": 0}));
}
edit(s, |cx| {
let mut n = 0usize;
for i in &sheets {
for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) {
c.threaded = true;
c.visible = false;
n += 1;
}
}
Ok(json!({"converted": n}))
})
}
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let b = |k: &str| bool_param(p, k).unwrap_or(false);
@@ -60,6 +159,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
/// protected sheet, or unlock a password range for this session.
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let sheet = target_sheet(s, p)?;
let action = str_param(p, "action").unwrap_or("list");
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let sheet_name = sh.name.clone();
let find = |t: Option<&str>| -> Result<usize> {
let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
sh.edit_ranges
.iter()
.position(|r| r.title.eq_ignore_ascii_case(t))
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
};
let hash = |pw: &str, title: &str| {
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
};
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
match action {
"list" => {}
"unlock" => {
let i = find(title)?;
let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
if let Some(h) = &r.password {
let pw = str_param(p, "password").unwrap_or("");
if !h.verify(pw).map_err(EngineError::Other)? {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
let key = (sheet_name.clone(), r.title.clone());
let d = s.doc_mut()?;
if !d.unlocked_ranges.contains(&key) {
d.unlocked_ranges.push(key);
}
}
}
"add" => {
let t = match title {
Some(t) => t.to_string(),
None => {
(1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
}
};
if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
}
let ranges = target_ranges(s, p)?;
let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
let r = EditRange { title: t, ranges, password, security_descriptor: None };
edit(s, |cx| {
cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
Ok(())
})?;
}
"modify" => {
let i = find(title)?;
let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
}
r.title = n.to_string();
}
if let Some(pw) = str_param(p, "password") {
r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
}
if p.get("range").is_some() {
r.ranges = target_ranges(s, p)?;
}
edit(s, |cx| {
if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
*x = r.clone();
}
Ok(())
})?;
}
"delete" => {
let i = find(title)?;
edit(s, |cx| {
let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
if i < list.len() {
list.remove(i);
}
Ok(())
})?;
}
other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
}
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
Ok(Json::Array(
sh.edit_ranges
.iter()
.map(|r| {
let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
})
.collect(),
))
}
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
@@ -97,3 +307,139 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
}
Ok(Json::Array(issues))
}
#[cfg(test)]
mod tests {
use super::*;
fn book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] {
s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap();
}
s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap();
s
}
fn at(s: &Session) -> (usize, String) {
let d = s.doc().unwrap();
(d.wb.active_sheet, d.selection.active.a1())
}
#[test]
fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() {
let mut s = book();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
// Notes are skipped: A2 holds a note, not a comment.
let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")];
for (sheet, cell) in order {
s.execute("review.nextComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] {
s.execute("review.previousComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
}
#[test]
fn comment_navigation_is_disabled_without_comments() {
let mut s = Session::new();
s.new_workbook();
s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap();
assert!(s.execute("review.nextComment", json!({})).is_err());
assert!(s.execute("review.previousComment", json!({})).is_err());
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();
s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap();
s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap();
let r = s.execute("review.convertToComments", json!({})).unwrap();
assert_eq!(r["converted"], 2);
let wb = &s.doc().unwrap().wb;
let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()];
let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()];
assert!(a2.threaded && d4.threaded && !d4.visible);
assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second"));
// Nothing left to convert; one undo restores the notes.
assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded);
}
#[test]
fn show_comments_and_show_all_notes_toggle() {
let mut s = book();
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true);
assert!(s.comments_pane);
assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false);
assert!(!s.show_all_notes);
}
}
#[cfg(test)]
mod edit_range_tests {
use super::*;
fn protected_book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s
}
#[test]
fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
let mut s = protected_book();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
assert!(err.contains("Budget"), "{err}");
// A paste or fill is checked the same way.
assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
assert_eq!(list[1]["unlocked"], true);
s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
// Ranges can't change while the sheet is protected.
assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
}
#[test]
fn edit_ranges_add_modify_delete_list_and_undo() {
let mut s = Session::new();
s.new_workbook();
// Empty params list; nothing to list yet.
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
let list = s
.execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
.unwrap();
assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
// Inserting a row above moves the range with its cells.
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
}
}
+145
View File
@@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
Axis::Cols
)),
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
cmd!(
"home.insertCopiedCells",
"Insert Copied Cells",
["Home", "Cells", "Insert"],
None,
"{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)",
has_clipboard,
insert_copied_cells
),
cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells),
cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet),
cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet),
@@ -146,6 +155,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
for dv in sh.validations.iter_mut() {
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect();
@@ -381,6 +394,128 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
Ok(out)
}
/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a
/// cut, moves) the clipboard there, as one undo step.
fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let at = match str_param(p, "at") {
Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?,
None => d.selection.current().start,
};
let shift = str_param(p, "shift").unwrap_or("down");
if shift != "down" && shift != "right" {
return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)")));
}
let sel = clip.selected;
let rows = sel.is_full_rows();
let cols = sel.is_full_cols();
if rows && cols {
return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into()));
}
let same_doc = d.uid == clip.doc_uid;
let cut = clip.cut && same_doc;
if cut && !rows && !cols {
return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into()));
}
// A moved block can't be inserted inside itself.
if cut && clip.sheet == sheet {
let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col };
if inside {
return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into()));
}
}
let revision = d.revision;
let before = (d.wb.clone(), d.selection.clone());
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
let d = s.doc_mut()?;
// The nested steps collapse into this command's single undo step.
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
}
match result {
Ok((final_range, out)) => {
d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] };
// Like inserting cells, this ends copy mode (the copied block may have moved).
cancel_copy_mode(s);
Ok(out)
}
Err(e) => {
d.wb = before.0;
d.selection = before.1;
d.calc.rebuild(&d.wb);
s.clipboard = Some(clip);
Err(e)
}
}
}
fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> {
let sel = clip.selected;
let rows = sel.is_full_rows();
let cols = sel.is_full_cols();
// Where the copied block's top-left lands, relative to the (untrimmed) copied lines.
let (inserted, paste_at) = if rows {
let h = sel.height();
let start = at.row;
let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64);
s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?;
let dr = clip.range.start.row.saturating_sub(sel.start.row);
(RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col))
} else if cols {
let w = sel.width();
let start = at.col;
let end = start.saturating_add(w - 1).min(MAX_COLS - 1);
s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?;
let dc = clip.range.start.col.saturating_sub(sel.start.col);
(RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc)))
} else {
let end = at
.offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1)
.ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?;
let block = RangeRef::new(at, end);
s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?;
(block, at)
};
// Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live
// workbook, so it follows the source if the insert pushed it along.
let mut clip = clip.clone();
if cut {
let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col };
if pushed {
let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) };
clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc));
clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc));
}
clip.wb = s.doc()?.wb.clone();
}
let source_lines = clip.selected;
s.clipboard = Some(clip.clone());
let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?;
if !cut {
return Ok((inserted, out));
}
// Close the gap the moved lines left behind.
let (removed, before_dest) = if rows {
s.execute(
"home.deleteRows",
json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}),
)?;
(source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row)
} else {
s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?;
(source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col)
};
let moved = if before_dest {
let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) };
RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc))
} else {
inserted
};
Ok((moved, out))
}
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
@@ -538,6 +673,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
{
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
}
}
}
for n in cx.wb.names.iter_mut() {
@@ -693,6 +834,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
for dv in sh.validations.iter_mut() {
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
File diff suppressed because it is too large Load Diff
+30
View File
@@ -28,6 +28,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
cmd!(query "view.formulaBar", "Formula Bar", ["View", "Show"], None, "{on?: bool}", always, |s, p| {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_bar}))
}),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
@@ -336,3 +341,28 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_100_resets_the_active_sheet_zoom() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": 150})).unwrap();
assert_eq!(s.execute("view.zoom100", json!({})).unwrap()["zoom"], 100);
assert_eq!(s.doc().unwrap().wb.active().unwrap().zoom, 100);
}
#[test]
fn formula_bar_toggles_without_a_workbook_and_defaults_to_shown() {
let mut s = Session::new();
assert!(s.prefs.formula_bar);
assert_eq!(s.execute("view.formulaBar", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("view.formulaBar", json!({"on": true})).unwrap()["on"], true);
// A prefs.json saved before the option existed keeps the formula bar shown.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
/// are trimmed to the used range.
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
+21 -4
View File
@@ -13,6 +13,9 @@ use crate::cmd::Ctx;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMode {
Series,
/// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a
/// series stepping by 1; anything a plain drag would extend is copied.
Alternate,
Copy,
Formats,
ValuesOnly,
@@ -62,13 +65,14 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let pattern = if mode == FillMode::Series {
Pattern::detect(
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(
&src_cells,
&custom,
cx.wb.date_system,
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
)
);
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
@@ -84,7 +88,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Copy => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@@ -231,6 +235,19 @@ impl Pattern {
Pattern::Copy
}
/// The pattern of a Ctrl+drag, which swaps copying and extending: a single
/// number (which a plain drag copies) counts up by 1, and every series a
/// plain drag would extend (numbers, dates, lists, `Item 1`) is copied.
fn alternate(self, cells: &[(CellRef, Option<Cell>)]) -> Pattern {
match (self, cells) {
(Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() {
Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 },
_ => Pattern::Copy,
},
_ => Pattern::Copy,
}
}
/// The value `k` steps beyond the source (forward) or before it (backward).
fn value(&self, k: i64, forward: bool, _len: usize) -> Option<Value> {
let sign = if forward { 1 } else { -1 };
+21 -1
View File
@@ -8,11 +8,13 @@
pub mod catalog;
pub mod cf;
mod clip_html;
pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod pivot;
pub mod pivot_calc;
pub mod sample;
pub mod selection;
pub mod tables;
@@ -65,11 +67,17 @@ pub struct Clipboard {
pub wb: Arc<Workbook>,
pub sheet: usize,
pub range: RangeRef,
/// The range as copied, before whole rows/columns were trimmed to the used range
/// (Insert Copied Cells inserts whole lines for a row or column copy).
pub selected: RangeRef,
pub cut: bool,
/// Document uid of the source (a cut only moves within the same workbook).
pub doc_uid: u64,
/// Plain text (tab-separated) for the system clipboard.
pub text: String,
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
/// together into one block at `range`. Empty for a single-range copy.
pub areas: Vec<RangeRef>,
}
/// In-cell edit mode as shown in the status bar.
@@ -96,6 +104,8 @@ pub struct DocState {
pub title: String,
/// Per-sheet selection memory.
pub sheet_selections: Vec<Selection>,
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
pub unlocked_ranges: Vec<(String, String)>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -119,6 +129,7 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
title,
sheet_selections: vec![],
unlocked_ranges: vec![],
}
}
pub fn is_dirty(&self) -> bool {
@@ -150,6 +161,8 @@ pub struct Prefs {
pub r1c1: bool,
/// Words added with Add to Dictionary.
pub user_dictionary: Vec<String>,
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
pub formula_bar: bool,
}
impl Default for Prefs {
@@ -163,6 +176,7 @@ impl Default for Prefs {
sheets_in_new_workbook: 1,
r1c1: false,
user_dictionary: vec![],
formula_bar: true,
}
}
}
@@ -184,6 +198,7 @@ pub struct Session {
pub clipboard: Option<Clipboard>,
pub prefs: Prefs,
pub mode: Mode,
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
pub journal: Vec<(String, Json)>,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
@@ -200,6 +215,11 @@ pub struct Session {
pub draw_tool: String,
pub draw_color: String,
pub draw_width: f32,
/// Review › Show Comments: the Comments pane is open.
pub comments_pane: bool,
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
}
impl Session {
@@ -284,7 +304,7 @@ impl Session {
}
};
if spec.journal && result.is_ok() {
self.journal.push((id.to_string(), params.clone()));
self.journal.push((id.to_string(), cmd::journal_params(&params)));
if self.journal.len() > 10_000 {
self.journal.drain(..5_000);
}
+224 -14
View File
@@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::*;
use crate::pivot_calc::CalcSet;
/// Largest source block read (cells).
pub const MAX_SOURCE_CELLS: u64 = 10_000_000;
/// Largest report written (cells).
@@ -503,6 +505,12 @@ pub struct Output {
/// Rows above the body (report filters plus a blank row).
pub body_offset: u32,
pub width: u32,
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
pub chart_rows: Vec<u32>,
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
pub chart_cols: Vec<(u32, String)>,
/// Grid column holding the innermost row labels (`None` without row fields).
pub label_col: Option<u32>,
}
impl Output {
@@ -546,6 +554,8 @@ enum LineKind {
struct Line {
kind: LineKind,
/// A category of a PivotChart: an innermost (or collapsed) item.
leaf: bool,
path: Vec<u32>,
values: bool,
/// (column, value, indent, number format)
@@ -587,14 +597,21 @@ impl Ctx<'_> {
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
});
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
self.flatten(&node.children, depth + 1, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
@@ -621,12 +638,13 @@ impl Ctx<'_> {
labels.push((depth, v, 0, fmt));
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
@@ -666,13 +684,44 @@ fn flatten_cols(
type AggMap = HashMap<(Vec<u32>, Vec<u32>), Vec<Acc>>;
/// Where a value field's numbers come from.
#[derive(Clone, Copy)]
enum ValSrc {
/// A source column.
Col(usize),
/// A calculated field (index into the PivotTable's calculated fields).
Calc(usize),
}
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect();
// Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`).
let calc = CalcSet::new(pt, &src.headers);
let vals: Vec<(ValSrc, &PivotValue)> = pt
.values
.iter()
.filter_map(|v| match src.col(&v.source_col) {
Some(c) => Some((ValSrc::Col(c), v)),
None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)),
})
.collect();
// Source columns summed for the calculated fields, accumulated after the value fields.
let mut extra: Vec<usize> = Vec::new();
for (vs, _) in &vals {
if let ValSrc::Calc(i) = vs {
for name in calc.source_refs(*i) {
if let Some(c) = src.col(&name)
&& !extra.contains(&c)
{
extra.push(c);
}
}
}
}
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
}
@@ -728,8 +777,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
for a in 0..=rp.len() {
for b in 0..=cp.len() {
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k]);
for (acc, (c, _)) in e.iter_mut().zip(vals.iter()) {
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]);
for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) {
if let ValSrc::Col(c) = vs {
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
}
}
for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) {
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
}
}
@@ -755,7 +809,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
@@ -763,7 +817,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
lines.push(Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@@ -829,7 +889,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if empty {
let r = g.push_row(RowKind::Placeholder)?;
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
return Ok(Output {
width: g.width as u32,
grid: g.rows,
row_kinds: g.kinds,
body_offset,
chart_rows: vec![],
chart_cols: vec![],
label_col: None,
});
}
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
@@ -915,8 +983,21 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
// ---- body
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
let (_, v) = vals.get(vi)?;
aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func))
let (vs, v) = vals.get(vi)?;
let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?;
match vs {
ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)),
ValSrc::Calc(i) => {
let sum = |name: &str| -> Value {
src.col(name)
.and_then(|c| extra.iter().position(|x| *x == c))
.and_then(|j| accs.get(k + j))
.map(|a| a.result(PivotFunc::Sum))
.unwrap_or(Value::Number(0.0))
};
Some(calc.value(wb, *i, &sum))
}
}
};
let num = |v: &Option<Value>| v.as_ref().and_then(Value::as_f64);
let ratio = |a: Value, b: Option<Value>| -> Value {
@@ -935,7 +1016,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
};
let data_fmt: Vec<Option<String>> = vals
.iter()
.map(|(c, v)| match v.show_as {
.map(|(vs, v)| match v.show_as {
PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => {
Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into()))
}
@@ -943,12 +1024,42 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => v.number_format.clone().or_else(|| match v.func {
PivotFunc::Count | PivotFunc::CountNumbers => None,
_ => {
let f = fmt_of(*c);
let f = match vs {
ValSrc::Col(c) => fmt_of(*c),
ValSrc::Calc(_) => "General".to_string(),
};
(f != "General").then_some(f)
}
}),
})
.collect();
let mut chart_rows: Vec<u32> = Vec::new();
let chart_cols: Vec<(u32, String)> = data_cols
.iter()
.enumerate()
.filter_map(|(j, (si, vi))| {
let spec = specs.get(*si)?;
if spec.kind != ColKind::Leaf {
return None;
}
let mut parts: Vec<String> =
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
if let Some(vi) = vi
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
LineKind::Item(b) => RowKind::Item(b),
@@ -961,6 +1072,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
};
let r = g.push_row(rk)?;
if line.leaf && k > 0 {
chart_rows.push(r as u32);
}
for (c, v, indent, fmt) in &line.labels {
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
}
@@ -1015,7 +1129,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
}
// ---------------------------------------------------------------- styling and writing
@@ -1205,9 +1319,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
if let Some(p) = shm.pivots.get_mut(idx) {
p.last_range = Some(area);
}
let sheet_name = shm.name.clone();
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
Ok(area)
}
// ---------------------------------------------------------------- PivotCharts
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
pub fn chart_link(sheet: &str, pivot: &str) -> String {
format!("{}!{pivot}", quote_sheet(sheet))
}
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
let t = link.trim();
let t = match t.strip_prefix('[') {
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
None => t,
};
let (sheet, name) = t.rsplit_once('!')?;
let sheet = sheet.trim();
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
let name = name.trim();
if sheet.is_empty() || name.is_empty() {
return None;
}
Some((sheet, name.to_string()))
}
/// Whether a PivotChart link points at `pivot` on `sheet`.
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
}
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
let q = quote_sheet(sheet);
let c = origin.col.checked_add(col)?;
let letters = gridcraft_core::col_to_letters(c);
let mut parts: Vec<String> = Vec::new();
let mut i = 0;
while let Some(&start) = rows.get(i) {
let mut end = start;
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
end += 1;
i += 1;
}
i += 1;
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
}
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(format!("({})", parts.join(","))),
}
}
/// Most category rows a PivotChart plots.
const MAX_CHART_ROWS: usize = 10_000;
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
/// items, one series per item column (column items × value fields); totals are left out.
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
out.chart_cols
.iter()
.filter_map(|(c, name)| {
Some(Series {
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
categories: categories.clone(),
values: rows_ref(sheet, origin, *c, &rows)?,
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
})
})
.collect()
}
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
if hits.is_empty() {
return;
}
let series = chart_series(sheet, origin, out);
for i in hits {
let Some(sh) = wb.sheet_mut(i) else { continue };
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
ch.series = series.clone();
}
}
}
/// Clears a pivot's last output area.
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
let Some(shm) = wb.sheet_mut(sheet) else { return };
+362
View File
@@ -0,0 +1,362 @@
//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`,
//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`).
//!
//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names
//! for each report cell (items, subtotals and grand totals alike), not record by record: for
//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written
//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not
//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`.
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{PivotTable, Workbook};
/// Longest formula accepted.
pub const MAX_FORMULA_LEN: usize = 8_192;
/// Most calculated fields per PivotTable.
pub const MAX_CALC_FIELDS: usize = 256;
/// Deepest chain of calculated fields using calculated fields.
const MAX_DEPTH: usize = 64;
const PREFIX: &str = "__pcf";
fn placeholder(i: usize) -> String {
format!("{PREFIX}{i}")
}
/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the
/// order of `refs`.
#[derive(Clone, Debug)]
pub struct Compiled {
pub body: Expr,
/// Canonical names of the fields used.
pub refs: Vec<String>,
}
/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a
/// source or calculated field.
pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<Compiled, String> {
let text = formula.trim();
let text = text.strip_prefix('=').unwrap_or(text).trim();
if text.is_empty() {
return Err("The formula is empty.".into());
}
if text.len() > MAX_FORMULA_LEN {
return Err("The formula is too long.".into());
}
let chars: Vec<char> = text.chars().collect();
let at = |i: usize| chars.get(i).copied();
let mut out = String::with_capacity(text.len() + 16);
let mut refs: Vec<String> = Vec::new();
let slot = |name: String, refs: &mut Vec<String>| -> String {
let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| {
refs.push(name);
refs.len() - 1
});
placeholder(i)
};
let mut i = 0;
while let Some(c) = at(i) {
match c {
'"' => {
out.push('"');
i += 1;
loop {
match at(i) {
None => return Err("The formula has an unterminated text value.".into()),
Some('"') if at(i + 1) == Some('"') => {
out.push_str("\"\"");
i += 2;
}
Some('"') => {
out.push('"');
i += 1;
break;
}
Some(x) => {
out.push(x);
i += 1;
}
}
}
}
'\'' => {
let mut name = String::new();
i += 1;
loop {
match at(i) {
None => return Err("The formula has an unterminated field name.".into()),
Some('\'') if at(i + 1) == Some('\'') => {
name.push('\'');
i += 2;
}
Some('\'') => {
i += 1;
break;
}
Some(x) => {
name.push(x);
i += 1;
}
}
}
let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?;
out.push_str(&slot(canon, &mut refs));
}
c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') {
out.push(at(i).unwrap_or('0'));
i += 1;
}
if matches!(at(i), Some('e' | 'E')) {
let sign = usize::from(matches!(at(i + 1), Some('+' | '-')));
if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) {
for _ in 0..=sign {
out.push(at(i).unwrap_or('0'));
i += 1;
}
while at(i).is_some_and(|d| d.is_ascii_digit()) {
out.push(at(i).unwrap_or('0'));
i += 1;
}
}
}
}
c if c.is_alphabetic() || c == '_' => {
let start = i;
while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') {
i += 1;
}
let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default();
let mut j = i;
while at(j).is_some_and(char::is_whitespace) {
j += 1;
}
let is_call = at(j) == Some('(');
match field(&word) {
Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)),
_ => out.push_str(&word),
}
}
c => {
out.push(c);
i += 1;
}
}
}
let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?;
let mut problem: Option<String> = None;
body.walk(&mut |e| {
if problem.is_some() {
return;
}
match e {
Expr::Name(n) if n.starts_with(PREFIX) => {}
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
_ => {}
}
});
match problem {
Some(p) => Err(p),
None => Ok(Compiled { body, refs }),
}
}
fn literal(v: &Value) -> Expr {
match v {
Value::Number(n) => Expr::Number(*n),
Value::Error(e) => Expr::Error(*e),
Value::Text(t) => Expr::Text(t.clone()),
Value::Bool(b) => Expr::Bool(*b),
Value::Empty => Expr::Number(0.0),
Value::Array(_) => literal(&v.scalar()),
}
}
/// Evaluates a compiled formula with the given field values (in `refs` order).
pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value {
let expr = if c.refs.is_empty() {
c.body.clone()
} else {
let mut call = Vec::with_capacity(c.refs.len() * 2 + 1);
for i in 0..c.refs.len() {
call.push(Expr::Name(placeholder(i)));
call.push(literal(args.get(i).unwrap_or(&Value::Empty)));
}
call.push(c.body.clone());
Expr::Call("LET".into(), call)
};
match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() {
Value::Empty => Value::Number(0.0),
v => v,
}
}
/// A PivotTable's calculated fields, compiled against its source headers.
pub struct CalcSet {
names: Vec<String>,
compiled: Vec<Result<Compiled, String>>,
}
impl CalcSet {
pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet {
let names: Vec<String> = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect();
let lookup = |n: &str| -> Option<String> {
let n = n.trim();
headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned()
};
let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect();
CalcSet { names, compiled }
}
pub fn index(&self, name: &str) -> Option<usize> {
self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim()))
}
/// The compile error of a calculated field, if any.
pub fn error(&self, i: usize) -> Option<&str> {
self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str)
}
/// Source fields a calculated field needs, directly or through other calculated fields.
pub fn source_refs(&self, i: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = vec![false; self.names.len()];
let mut stack = vec![i];
while let Some(j) = stack.pop() {
if !seen.get(j).copied().unwrap_or(true) {
if let Some(s) = seen.get_mut(j) {
*s = true;
}
if let Some(Ok(c)) = self.compiled.get(j) {
for r in &c.refs {
match self.index(r) {
Some(k) => stack.push(k),
None => {
if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) {
out.push(r.clone());
}
}
}
}
}
}
}
out
}
/// Whether a calculated field reaches itself through the fields it uses.
pub fn is_cyclic(&self, i: usize) -> bool {
let mut seen = vec![false; self.names.len()];
let mut stack: Vec<usize> = match self.compiled.get(i) {
Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(),
_ => vec![],
};
while let Some(j) = stack.pop() {
if j == i {
return true;
}
if seen.get(j).copied().unwrap_or(true) {
continue;
}
if let Some(s) = seen.get_mut(j) {
*s = true;
}
if let Some(Ok(c)) = self.compiled.get(j) {
stack.extend(c.refs.iter().filter_map(|r| self.index(r)));
}
}
false
}
/// The value of calculated field `i` for one report cell; `sum` gives a source field's sum
/// over that cell's records.
pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value {
let mut memo: Vec<Option<Value>> = vec![None; self.names.len()];
let mut active = vec![false; self.names.len()];
self.value_in(wb, i, sum, &mut memo, &mut active, 0)
}
fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option<Value>], active: &mut [bool], depth: usize) -> Value {
if let Some(Some(v)) = memo.get(i) {
return v.clone();
}
if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) {
return Value::Error(CellError::Ref);
}
let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) };
if let Some(a) = active.get_mut(i) {
*a = true;
}
let args: Vec<Value> = c
.refs
.iter()
.map(|r| match self.index(r) {
Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1),
None => sum(r),
})
.collect();
let v = eval(wb, c, &args);
if let Some(a) = active.get_mut(i) {
*a = false;
}
if let Some(m) = memo.get_mut(i) {
*m = Some(v.clone());
}
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(n: &str) -> Option<String> {
["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string())
}
#[test]
fn compiles_field_names() {
let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap();
assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]);
// A field named like a cell reference is still a field; functions stay functions.
let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap();
assert_eq!(c.refs, vec!["Q1"]);
let wb = Workbook::new();
assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0));
assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus"));
assert!(compile("=A1*2", &fields).is_err());
assert!(compile("='Nope'*2", &fields).is_err());
assert!(compile("=Sales*(", &fields).is_err());
assert!(compile("=", &fields).is_err());
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
}
#[test]
fn deep_and_cyclic_chains_are_bounded() {
// 300 calculated fields, each using the previous one, and a two-field cycle.
let mut pt = PivotTable::default();
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() });
for i in 1..300 {
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) });
}
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
let set = CalcSet::new(&pt, &["Sales".to_string()]);
let wb = Workbook::new();
let sum = |_: &str| Value::Number(10.0);
assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0));
assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0));
// Past the depth bound (the list itself is capped at MAX_CALC_FIELDS).
assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref));
assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields");
// A small cycle within the cap.
let mut small = PivotTable::default();
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
let set = CalcSet::new(&small, &["Sales".to_string()]);
assert!(set.is_cyclic(0));
assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref));
assert_eq!(set.source_refs(0), Vec::<String>::new());
}
}
+462
View File
@@ -52,6 +52,110 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() {
}
}
#[test]
fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
// #257: "Rp10,000.00" copied from Excel for the web / LibreOffice Calc pasted as text. Their
// clipboard HTML records the number (x:num, sdval); paste keeps it and shows the same text.
let mut s = s();
let text = "Rp10,000.00\tRp2,500.50\tLabel\n";
let html =
r#"<table><tr><td x:num="10000">Rp10,000.00</td><td sdval="2500.5" sdnum="1033;0;x"><font>Rp2,500.50</font></td><td>Label</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A1", "text": text, "html": html})).unwrap();
assert_eq!(v(&s, "A1"), Value::number(10000.0));
assert_eq!(v(&s, "B1"), Value::number(2500.5));
assert_eq!(v(&s, "C1"), Value::text("Label"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "Rp10,000.00");
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "Rp2,500.50");
// HTML that doesn't show the same text (left over from another copy) changes nothing; without
// HTML the text stays text, as typing it would in an en-US locale.
let stale = r#"<table><tr><td x:num="7">Rp7.00</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A3", "text": "Rp10,000.00", "html": stale})).unwrap();
s.execute("edit.paste", json!({"at": "A4", "text": "Rp10,000.00"})).unwrap();
assert_eq!(v(&s, "A3"), Value::text("Rp10,000.00"));
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
}
#[test]
fn editing_a_name_renames_it_in_formulas() {
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=1"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=Price*2"})).unwrap();
let err = s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=Sheet1!$A$1", "oldName": "Price"}));
assert!(err.is_err(), "renaming onto another existing name is refused");
s.execute("formulas.defineName", json!({"name": "Cost", "refersTo": "=Sheet1!$A$1", "comment": "unit", "oldName": "Price"})).unwrap();
let d = s.doc().unwrap();
let names: Vec<&str> = d.wb.names.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["Other", "Cost"]);
let b1 = d.wb.active().unwrap().cell(CellRef::parse("B1").unwrap()).and_then(|c| c.formula.clone()).unwrap();
assert_eq!(b1.text, "Cost*2");
assert_eq!(v(&s, "B1"), Value::number(42.0));
assert!(s.execute("formulas.defineName", json!({"name": "X", "oldName": "Missing"})).is_err());
}
#[test]
fn chart_recommendations_follow_the_data_shape() {
// #215: Recommended Charts lists chart types for the data; programmatic insert stays dialog-free.
let mut s = s();
let kinds = |s: &mut Session, range: &str| -> Vec<String> {
let r = s.execute("insert.chartRecommendations", json!({"range": range})).unwrap();
r.as_array().unwrap().iter().map(|x| format!("{}{}", x["type"].as_str().unwrap(), x["subtype"].as_str().unwrap())).collect()
};
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
assert_eq!(kinds(&mut s, "A1:B4"), ["column", "bar", "pie", "doughnut", "linemarkers"]);
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2], [2, 4.5], [3, 5.5], [4, 9]]})).unwrap();
assert_eq!(kinds(&mut s, "D1:E4").first().map(String::as_str), Some("scatter"), "two numeric columns: scatter first");
s.execute("range.setValues", json!({"range": "G1", "values": [["Q", "North", "South"], ["Q1", 1, -2], ["Q2", 3, 4]]})).unwrap();
let multi = kinds(&mut s, "G1:I3");
assert_eq!(multi.first().map(String::as_str), Some("column"));
assert!(!multi.iter().any(|k| k == "pie"), "several series: no pie");
assert!(s.execute("insert.chartRecommendations", json!({"range": "K1:K3"})).is_err(), "no numbers, no chart");
// Without a type the command inserts the first recommendation; with one, that type.
s.execute("insert.recommendedCharts", json!({"range": "D1:E4"})).unwrap();
s.execute("insert.recommendedCharts", json!({"range": "A1:B4", "type": "pie"})).unwrap();
let d = s.doc().unwrap();
let kinds: Vec<_> = d.wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_model::ChartKind::Scatter, gridcraft_model::ChartKind::Pie]);
assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog");
}
#[test]
fn chart_category_axis_from_blank_corner_and_select_data() {
// #287: numeric X values must be the category axis, not a series of their own.
let mut s = s();
let series = |s: &Session| -> Vec<(Option<String>, String)> {
let d = s.doc().unwrap();
d.wb.active().unwrap().charts.last().unwrap().series.iter().map(|x| (x.categories.clone(), x.values.clone())).collect()
};
// A blank top-left cell marks the first column as category labels, numbers or not.
s.execute("range.setValues", json!({"range": "A1", "values": [[null, "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap();
assert_eq!(series(&s), [(Some("Sheet1!$A$2:$A$4".to_string()), "Sheet1!$B$2:$B$4".to_string())]);
// With a "Year" header the year column is a series, as before; Select Data fixes it:
// Horizontal (Category) Axis Labels and Remove on the year series.
s.execute("range.setValues", json!({"range": "D1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
s.execute("insert.chart", json!({"range": "D1:E4", "type": "line"})).unwrap();
assert_eq!(series(&s).len(), 2);
assert!(s.execute("chart.selectData", json!({})).is_err(), "nothing to change");
assert!(s.execute("chart.selectData", json!({"categories": "not a range"})).is_err());
s.execute("chart.selectData", json!({"categories": "=D2:D4", "removeSeries": [0, 7]})).unwrap();
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
assert_eq!(data.categories, ["2020", "2021", "2022"]);
// The last series can't be removed; "" clears the labels.
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]);
}
#[test]
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
let mut s = s();
@@ -417,6 +521,71 @@ fn insert_delete_rows_adjust() {
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn insert_copied_cells_inserts_rows_columns_and_blocks() {
// Issue #21: Insert Copied Cells.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=A2*10"})).unwrap();
s.execute("edit.copy", json!({"range": "2:2"})).unwrap();
s.execute("selection.set", json!({"range": "C4"})).unwrap();
s.execute("home.insertCopiedCells", json!({})).unwrap();
assert_eq!(v(&s, "A4"), Value::Number(2.0));
assert_eq!(v(&s, "B4"), Value::Number(20.0), "the formula follows its new row");
assert_eq!(v(&s, "A5"), Value::Number(4.0));
assert_eq!(v(&s, "A6"), Value::Number(5.0));
assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(3, 3));
// One undo step puts everything back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A4"), Value::Number(4.0));
assert_eq!(v(&s, "A6"), Value::Empty);
// Whole columns.
s.execute("edit.copy", json!({"range": "A:A"})).unwrap();
s.execute("home.insertCopiedCells", json!({"at": "A1"})).unwrap();
assert_eq!(v(&s, "A3"), Value::Number(3.0));
assert_eq!(v(&s, "B3"), Value::Number(3.0));
assert_eq!(v(&s, "C2"), Value::Number(20.0), "the old column B moved right");
// A block shifts cells right.
s.execute("cell.set", json!({"cell": "F1", "input": "x"})).unwrap();
s.execute("cell.set", json!({"cell": "G1", "input": "y"})).unwrap();
s.execute("edit.copy", json!({"range": "F1"})).unwrap();
s.execute("home.insertCopiedCells", json!({"at": "G1", "shift": "right"})).unwrap();
assert_eq!(v(&s, "G1"), Value::from("x"));
assert_eq!(v(&s, "H1"), Value::from("y"));
assert!(s.clipboard.is_none(), "inserting ends copy mode");
s.execute("edit.copy", json!({"range": "F1"})).unwrap();
assert!(s.execute("home.insertCopiedCells", json!({"shift": "up"})).is_err());
}
#[test]
fn insert_cut_cells_moves_whole_rows() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A5"})).unwrap();
// Up: row 5 goes above row 2.
s.execute("edit.cut", json!({"range": "5:5"})).unwrap();
s.execute("home.insertCopiedCells", json!({"at": "A2"})).unwrap();
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
assert_eq!(col, [1.0, 5.0, 2.0, 3.0, 4.0].map(Value::Number).into_iter().chain([Value::Empty]).collect::<Vec<_>>());
assert_eq!(v(&s, "C1"), Value::Number(5.0), "references follow the moved cells");
assert!(s.clipboard.is_none());
// Down: row 1 goes above row 4.
s.execute("edit.cut", json!({"range": "1:1"})).unwrap();
s.execute("home.insertCopiedCells", json!({"at": "A4"})).unwrap();
let col: Vec<Value> = (1..=5).map(|r| v(&s, &format!("A{r}"))).collect();
assert_eq!(col, [5.0, 2.0, 1.0, 3.0, 4.0].map(Value::Number).to_vec());
assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(2, 2));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(1.0));
// Inside itself, and a cut block, are refused without changing anything.
s.execute("edit.cut", json!({"range": "2:4"})).unwrap();
assert!(s.execute("home.insertCopiedCells", json!({"at": "A3"})).is_err());
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
assert!(s.execute("home.insertCopiedCells", json!({"at": "B5"})).is_err());
assert_eq!(v(&s, "A1"), Value::Number(1.0));
assert_eq!(v(&s, "B5"), Value::Empty);
}
#[test]
fn inserting_or_deleting_cells_cancels_copy_mode() {
let mut s = s();
@@ -452,6 +621,39 @@ fn fill_series() {
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
}
#[test]
fn ctrl_drag_fill_swaps_copy_and_series() {
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4"})).unwrap();
assert_eq!(v(&s, "A4"), Value::Number(7.0), "a plain drag copies a lone number");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "A2"), Value::Number(8.0));
assert_eq!(v(&s, "A4"), Value::Number(10.0));
// Upward too.
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B5", "target": "B3:B5", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "B3"), Value::Number(5.0));
// A two-number series, a list entry and text with a number are copied.
s.execute("range.setValues", json!({"range": "C1", "values": [[1], [3]]})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "C3"), Value::Number(1.0));
assert_eq!(v(&s, "C4"), Value::Number(3.0));
s.execute("cell.set", json!({"cell": "D1", "input": "Mon"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1", "target": "D1:D3", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "D3"), Value::from("Mon"));
s.execute("cell.set", json!({"cell": "E1", "input": "Item 9"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E1", "target": "E1:E2", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "E2"), Value::from("Item 9"));
// Plain text copies either way; an empty source clears.
s.execute("cell.set", json!({"cell": "F1", "input": "abc"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "F2"), Value::from("abc"));
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G3", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "G3"), Value::Empty);
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
@@ -512,6 +714,63 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Turning the filter on with only the header row selected filters the data below it (#201).
#[test]
fn filter_on_a_selected_header_row_covers_the_data_below() {
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}),
)
.unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
let r = s.execute("data.filter", json!({})).unwrap();
assert_eq!(r["range"], "A1:B6");
let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap();
assert_eq!(r["hiddenRows"], 3);
// An explicit range is taken as given.
s.execute("data.filter", json!({})).unwrap();
let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap();
assert_eq!(r["range"], "A1:B1");
}
#[test]
fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
const PW: &str = "synthetic-pw-7319";
let mut s = s();
s.execute("automate.recordActions", json!({})).unwrap();
s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap();
// The password still protects the sheet; the refused attempt isn't journaled.
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
s.execute("review.unprotectSheet", json!({"password": PW})).unwrap();
s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap();
let journal = serde_json::to_string(&s.journal).unwrap();
assert!(!journal.contains(PW), "{journal}");
let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone());
assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true})));
assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({})));
// The recorded script is stored in the workbook, so it is saved with the file.
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap();
assert!(script.contains("review.protectSheet"), "{script}");
assert!(!script.contains(PW), "{script}");
// The journal still replays: protection without a password, then unprotection.
let mut r = self::s();
for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) {
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
if id == "review.protectSheet" {
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
}
}
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
}
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
@@ -606,6 +865,27 @@ fn hidden_formulas_stay_hidden_on_protected_sheets() {
assert_eq!(r["results"][0]["value"], "7");
}
#[test]
fn case_sensitive_sort_puts_lowercase_first() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["b"], ["B"], ["Ab"], ["a"], ["aB"], ["A"]]})).unwrap();
s.execute("data.sort", json!({"range": "A1:A6", "header": false, "matchCase": true, "keys": [{"column": "A"}]})).unwrap();
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
assert_eq!(col, ["a", "A", "aB", "Ab", "b", "B"].map(Value::from));
}
#[test]
fn remove_duplicates_compares_shown_text() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Price"], ["2006-03-08", 1.01], ["2006-03-08", 1.02]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "mmm d, yyyy"})).unwrap();
s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0"})).unwrap();
let r = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "header": true})).unwrap();
assert_eq!(r["removed"], 0);
let r = s.execute("data.removeDuplicates", json!({"range": "B1:B3", "header": true})).unwrap();
assert_eq!(r["removed"], 1);
}
#[test]
fn formatting_and_styles() {
let mut s = s();
@@ -1036,6 +1316,74 @@ fn every_command_survives_empty_params() {
}
}
fn fmt_of(s: &Session, a: &str) -> String {
let wb = &s.doc().unwrap().wb;
let sh = wb.active().unwrap();
wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
}
#[test]
fn checkboxes_keep_values_toggle_and_delete_in_two_passes() {
// Issue #196.
let mut s = s();
s.execute("cell.set", json!({"cell": "B1", "input": "hi"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
s.execute("insert.checkbox", json!({"range": "A1:B2"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Bool(false), "an empty cell starts unchecked");
assert_eq!(v(&s, "B1"), Value::from("hi"), "other values stay");
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Bool(true));
// Space over a selection toggles only checkbox cells; an emptied checkbox counts as FALSE.
s.execute("cell.set", json!({"cell": "A2", "input": ""})).unwrap();
s.execute("selection.set", json!({"range": "A2:C2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({})).unwrap();
assert_eq!(v(&s, "A2"), Value::Bool(true));
s.execute("selection.set", json!({"range": "C1"})).unwrap();
s.execute("cell.toggleCheckbox", json!({})).unwrap();
assert_eq!(v(&s, "C1"), Value::Bool(true), "a plain TRUE is not a checkbox");
// Delete: checked boxes are unchecked first, then the boxes go.
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Bool(false), Value::Bool(false)));
assert_eq!(fmt_of(&s, "A1"), "checkbox");
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Empty);
assert_ne!(fmt_of(&s, "A1"), "checkbox");
// Clear Formats removes the box and keeps the value (B2 was checked by the selection toggle).
s.execute("edit.clearFormats", json!({"range": "B2"})).unwrap();
assert_eq!(v(&s, "B2"), Value::Bool(true));
assert_ne!(fmt_of(&s, "B2"), "checkbox");
}
#[test]
fn checkboxes_survive_xlsx_as_cell_controls() {
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "0.5"})).unwrap();
reopen(&mut s);
assert_eq!(fmt_of(&s, "A1"), "checkbox");
assert_eq!(fmt_of(&s, "A2"), "checkbox");
assert_eq!(fmt_of(&s, "B1"), "General");
assert_eq!(v(&s, "A1"), Value::Bool(true));
assert_eq!(v(&s, "A2"), Value::Bool(false));
}
#[test]
fn file_options_reports_settings_and_asks_for_the_options_page() {
let mut s = Session::new();
// No workbook: nothing to report, but still no error.
assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null}));
s.take_ui_requests();
s.new_workbook();
s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap();
let before = s.active().unwrap().wb.clone();
let r = s.execute("file.options", json!({})).unwrap();
assert_eq!(r["calculation"]["mode"], "manual");
assert_eq!(r["calculation"]["iterative"], true);
assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options"));
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
@@ -1667,3 +2015,117 @@ fn vertical_alignment_commands_and_roundtrip() {
assert_eq!(reopen_style("A2").align.v, gridcraft_model::VAlign::Center);
assert_eq!(reopen_style("A3").align.v, gridcraft_model::VAlign::Bottom);
}
/// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the
/// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235).
#[test]
fn icon_set_thresholds_from_files_pick_the_icon() {
use gridcraft_model::{CfRule, CfValueKind, CondFormat};
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[0], [10], [20], [30], [100]]})).unwrap();
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();
let icon = |s: &Session, a: &str| {
let wb = &s.doc().unwrap().wb;
crate::cf::CfCache::new().look(wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.icon).map(|(_, i)| i)
};
// Defaults: percent of the 0..100 span, so 30 is still the lowest icon.
assert_eq!([icon(&s, "A1"), icon(&s, "A4"), icon(&s, "A5")], [Some(0), Some(0), Some(2)]);
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.cond_formats = vec![CondFormat {
ranges: vec![gridcraft_core::RangeRef::parse("A1:A5").unwrap()],
rule: CfRule::IconSet {
set: "3Arrows".into(),
thresholds: vec![CfValueKind::Percent(0.0), CfValueKind::Number(10.0), CfValueKind::Number(25.0)],
reverse: false,
show_value: true,
},
priority: 1,
stop_if_true: false,
}];
assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]);
}
#[test]
fn row_height_and_column_width_take_a_list_of_areas() {
// Issue #341: header drags on a multi-area selection size every selected line in one step.
let mut s = s();
s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap();
s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap();
let widths: Vec<f32> = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect();
let heights: Vec<f32> = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect();
for c in [1, 3, 4] {
assert_eq!(widths[c], 120.0, "column {c}");
}
for c in [0, 2, 5] {
assert_ne!(widths[c], 120.0, "column {c}");
}
for r in [1, 3, 4] {
assert_eq!(heights[r], 40.0, "row {r}");
}
for r in [0, 2, 5] {
assert_ne!(heights[r], 40.0, "row {r}");
}
assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err());
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing");
}
#[test]
fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() {
// Issue #348: Excel slides lined-up areas together; formulas travel as their values.
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}),
)
.unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap();
// Same rows, selected right to left: pasted left to right, B left out.
s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap();
let copied = s.execute("edit.copy", json!({})).unwrap();
assert_eq!(copied["text"], "a1\tc1\n1\t3\n");
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
assert_eq!(v(&s, "F1"), Value::text("a1"));
assert_eq!(v(&s, "G1"), Value::text("c1"));
assert_eq!(v(&s, "F2"), Value::Number(1.0));
assert_eq!(v(&s, "G2"), Value::Number(3.0));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none());
assert!(v(&s, "H1").is_empty());
assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2");
// The source is untouched.
assert_eq!(v(&s, "B1"), Value::text("b1"));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some());
// Same columns: stacked, row 2 left out.
s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n");
s.execute("edit.paste", json!({"at": "F5"})).unwrap();
for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] {
assert_eq!(v(&s, cell), Value::text(want), "{cell}");
}
assert!(v(&s, "F8").is_empty());
// Whole columns are trimmed to the used rows like a single-area copy.
s.execute("edit.undo", json!({"steps": 2})).unwrap();
s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n");
}
#[test]
fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
s.execute("selection.set", json!({"range": "A1"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] {
s.execute("selection.set", json!({"range": range})).unwrap();
let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string();
assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}");
assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays");
}
// Paste Link can't point at a slid-together block.
s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
assert!(v(&s, "E1").is_empty());
}