Refactor engine for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 10:52:53 +00:00
parent 287acf7ad6
commit 9717ccbac1
74 changed files with 1404 additions and 997 deletions
+95 -77
View File
@@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
pub use gridcraft_model::icon_count;
struct RuleEval<'a> {
wb: &'a Workbook,
sheet: usize,
cell: CellRef,
first: CellRef,
value: &'a Value,
text: &'a str,
stats: &'a RuleStats,
}
fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> {
let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx;
match rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell));
let av = eval(a);
let matches = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { within } else { !within }
}
CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater,
};
(matches && !value.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
CfRule::ContainsText { text: needle, style } => {
(!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: needle, style } => {
(value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => value.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
_ => None,
}
}
fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let day = serial.floor();
let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial);
let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0);
let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1));
let year_month = date(day).map(|value| (value.year, value.month));
let month_matches = |offset: i32| {
let shifted_month = month as i32 + offset;
let shifted_year = year + (shifted_month - 1).div_euclid(12);
let shifted_month = (shifted_month - 1).rem_euclid(12) + 1;
year_month == Some((shifted_year, shifted_month as u32))
};
match period {
"yesterday" => day == today - 1.0,
"today" => day == today,
"tomorrow" => day == today + 1.0,
"last7Days" => day > today - 7.0 && day <= today,
"lastWeek" => day >= week_start - 7.0 && day < week_start,
"thisWeek" => day >= week_start && day < week_start + 7.0,
"nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0,
"lastMonth" => month_matches(-1),
"thisMonth" => month_matches(0),
"nextMonth" => month_matches(1),
_ => false,
}
}
impl CfCache {
pub fn new() -> Self {
Self::default()
@@ -161,52 +250,8 @@ impl CfCache {
None => &no_stats,
};
let text = v.display();
let matched_style: Option<&Style> = match &cf.rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c));
let av = eval(a);
let ok = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { inside } else { !inside }
}
CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
};
// 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())
}
// As in Excel, an error value never contains, begins or ends with anything (its rule
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
CfRule::ContainsText { text: t, style } => {
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: t, style } => {
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: t, style } => {
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: t, style } => (!v.is_error() && 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() && !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 } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st };
let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule {
CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() {
Some(n) if !st.sorted.is_empty() => {
let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize };
@@ -229,36 +274,8 @@ impl CfCache {
None => None,
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) => {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;
let (ty, tm) =
gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1));
let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month));
let month_off = |k: i32| {
let m = tm as i32 + k;
let y = ty + (m - 1).div_euclid(12);
let m = (m - 1).rem_euclid(12) + 1;
ym == Some((y, m as u32))
};
let ok = match period.as_str() {
"yesterday" => d == today - 1.0,
"today" => d == today,
"tomorrow" => d == today + 1.0,
"last7Days" => d > today - 7.0 && d <= today,
"lastWeek" => d >= week_start - 7.0 && d < week_start,
"thisWeek" => d >= week_start && d < week_start + 7.0,
"nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0,
"lastMonth" => month_off(-1),
"thisMonth" => month_off(0),
"nextMonth" => month_off(1),
_ => false,
};
ok.then_some(style.as_ref())
}
None => None,
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
_ => None,
},
CfRule::ColorScale { stops } => {
if let Some(n) = v.as_f64() {
@@ -329,7 +346,8 @@ impl CfCache {
}
None
}
};
_ => None,
});
if let Some(st) = matched_style {
any = true;
// Every matching rule applies; where two set the same part, the rule with the
+11 -8
View File
@@ -205,7 +205,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
@@ -349,11 +348,15 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
})
}
fn wall_clock() -> std::time::Duration {
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
// `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch
// reads the wall clock without the (disallowed) `now`.
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let r = f(&mut ctx)?;
@@ -368,7 +371,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
}
if structural {
d.calc.recalc_all(&mut wb);
@@ -376,7 +379,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
d.calc.cells_changed(&mut wb, &changed);
}
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", t0.elapsed());
eprintln!("commit: + recalc {:?}", since(t0));
}
fit_rows.sort_unstable();
fit_rows.dedup();
@@ -384,7 +387,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
auto_row_height(&mut wb, si, row);
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", t0.elapsed());
eprintln!("commit: + rows {:?}", since(t0));
}
d.wb = std::sync::Arc::new(wb);
d.selection = sel;
+1 -1
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
use serde_json::{Value as Json, json};
use super::whatif::parse_range;
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
const CMD: &str = "data.analysisToolPak";
/// Most input cells a tool reads.
+8 -2
View File
@@ -4,10 +4,16 @@ use std::cmp::Ordering;
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
ValidationKind, Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet,
str_param, target_range, target_ranges, target_sheet,
};
use crate::outline;
use crate::selection::current_region;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::sort::sort_key;
use super::*;
use super::{
AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param,
current_region, data_target, edit, json,
};
// ---------------------------------------------------------------- filter
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines,
str_param, target_sheet,
};
// ---------------------------------------------------------------- outline
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet,
Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet,
};
#[derive(Clone)]
struct SortKey {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json,
ok, str_param, target_range, target_sheet,
};
// ---------------------------------------------------------------- tools
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad,
bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- validation
+2 -2
View File
@@ -1,10 +1,10 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
+4 -1
View File
@@ -4,7 +4,10 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc,
locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::{current_region, jump};
use crate::{Clipboard, Session};
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json,
locked_cell_error, str_param, target_range, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- entry
+3 -1
View File
@@ -1,6 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::*;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
+2 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
// ---------------------------------------------------------------- fill
@@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let stop = f64_param(p, "stop");
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
edit(s, |cx| {
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
Ok(Json::Null)
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
use super::cell::input_to_cell;
use super::*;
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
// ---------------------------------------------------------------- find & replace
+1 -1
View File
@@ -1,5 +1,5 @@
use super::selection::{select_region, selection_set};
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
+202 -195
View File
@@ -1,5 +1,76 @@
use super::clipboard::What;
use super::*;
use super::{
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
target_sheet,
};
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
}
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
let operand = new.value.clone();
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
&& (operand.is_number() || operand.is_empty())
{
new.value = match op {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
"divide" => Value::number(x / y),
_ => operand.clone(),
};
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
new.value = old.value.clone();
}
if let Some(formula) = &old.formula
&& let Ok(y) = operand.to_number()
&& (operand.is_number() || operand.is_empty())
&& let Some(symbol) = operation_symbol(op)
{
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
fn operation_symbol(op: &str) -> Option<char> {
match op {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
}
fn copy_merges(
cx: &mut crate::cmd::Ctx,
dest_sheet: usize,
src_sheet: &gridcraft_model::Sheet,
src: RangeRef,
at: CellRef,
what: What,
transpose: bool,
) -> Result<()> {
if !matches!(what, What::All | What::Formats) || transpose {
return Ok(());
}
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
for merge in merges {
let row_offset = at.row as i64 - src.start.row as i64;
let col_offset = at.col as i64 - src.start.col as i64;
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
let moved = RangeRef::new(start, end);
let sheet = cx.sheet_mut(dest_sheet)?;
sheet.merges.retain(|existing| !existing.intersects(&moved));
sheet.merges.push(moved);
}
}
Ok(())
}
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
@@ -74,209 +145,145 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
moved_formulas = taken;
}
for tr in 0..tiles_r {
for tc in 0..tiles_c {
for r in 0..src.height() {
for c in 0..src.width() {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move {
moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone())
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
src_sheet.cell(sc).cloned()
st
};
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" => {
if y == 0.0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(x / y)
}
}
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
// Numbers stored as text participate; other text and errors stay as they are.
apply_operation(&op, &old, &mut new);
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
// Merged areas inside the copied block travel with it.
if matches!(what, What::All | What::Formats) && !transpose {
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
for m in merges {
let dr = at.row as i64 - src.start.row as i64;
let dc = at.col as i64 - src.start.col as i64;
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
let nm = RangeRef::new(a, b);
let sh = cx.sheet_mut(dest_sheet)?;
sh.merges.retain(|x| !x.intersects(&nm));
sh.merges.push(nm);
}
}
}
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
target_sheet,
};
// ---------------------------------------------------------------- selection
+8 -2
View File
@@ -2,10 +2,16 @@
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc,
ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
mod cells;
mod chart;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok,
str_param, target_ranges, target_sheet,
};
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
f64_param, json, split_sheet, str_param, u32_param,
};
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
+3 -1
View File
@@ -1,5 +1,7 @@
use super::chart::format_series;
use super::*;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
};
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet,
stash_get, stash_map, stash_set, str_param, target_sheet, u32_param,
};
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
+1 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -11,7 +11,7 @@ use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
+5 -2
View File
@@ -1,10 +1,13 @@
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param,
target_ranges, target_sheet,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
};
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
};
// ---------------------------------------------------------------- sizes
+4 -1
View File
@@ -4,7 +4,10 @@ use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
target_range, target_ranges, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
+9 -2
View File
@@ -3,11 +3,18 @@
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
Workbook,
};
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::*;
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::current_region;
mod chart;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
};
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
};
// ---------------------------------------------------------------- conditional formatting
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
split_sheet, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- links & comments
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
};
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
};
// ---------------------------------------------------------------- tables
+1 -1
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
pub fn specs() -> Vec<CommandSpec> {
vec![
+8 -2
View File
@@ -7,10 +7,16 @@
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, Cell, Chart, ChartKind, LegendPos, NumFmt, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Sheet, StyleId, Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, str_param,
u32_param,
};
use crate::pivot as pv;
use crate::selection::current_region;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{insert_pivot, source_param};
use super::*;
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, PivotDateGroup, Result, Session, Workbook, anchor_mode_param, bad, cell_param,
edit, f64_param, find_pivot, json, other, pv, str_param,
};
// ---------------------------------------------------------------- PivotChart
+13 -4
View File
@@ -1,6 +1,9 @@
use super::fields::{add_to_area, in_axes};
use super::fields::{AreaPlacement, add_to_area, in_axes};
use super::params::{func_name, func_param, group_name, group_param, layout_name, show_as_name, sort_name};
use super::*;
use super::{
Arc, Cell, CellRef, Ctx, EngineError, Json, NumFmt, PivotDateGroup, PivotTable, RangeRef, Result, Session, Sheet, StyleId, Workbook, bad,
bool_param, cell_param, current_region, edit, find_pivot, json, other, pv, str_param, with,
};
// ---------------------------------------------------------------- insert
@@ -115,7 +118,9 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> {
for f in list {
match f {
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
Json::String(n) => {
add_to_area(pt, &src, AreaPlacement { cmd: "insert.pivotTable", field: n, area, func: None, name: None, position: None })?
}
Json::Object(_) => {
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
let func = match str_param(f, "func") {
@@ -124,7 +129,11 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
}
None => None,
};
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
add_to_area(
pt,
&src,
AreaPlacement { cmd: "insert.pivotTable", field: n, area, func, name: str_param(f, "name"), position: None },
)?;
let by = group_param(str_param(f, "group"));
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
&& matches!(area, "rows" | "columns")
+18 -14
View File
@@ -1,6 +1,9 @@
use super::create::pivot_json;
use super::params::{area_param, func_param, position_param, show_as_param};
use super::*;
use super::{
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
};
// ---------------------------------------------------------------- layout editing (pure)
@@ -104,16 +107,17 @@ fn auto_name(f: PivotFunc, field: &str) -> String {
}
/// Adds `field` to an area (moving it out of the other axis areas).
pub(super) fn add_to_area(
cmd: &str,
pt: &mut PivotTable,
src: &pv::Source,
field: &str,
area: &str,
func: Option<PivotFunc>,
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
pub(super) struct AreaPlacement<'a> {
pub cmd: &'a str,
pub field: &'a str,
pub area: &'a str,
pub func: Option<PivotFunc>,
pub name: Option<&'a str>,
pub position: Option<usize>,
}
pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: AreaPlacement<'_>) -> Result<()> {
let AreaPlacement { cmd, field, area, func, name, position } = placement;
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."));
@@ -300,7 +304,7 @@ pub(super) fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").map(str::to_string);
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &field, area, func, name: name.as_deref(), position: pos })?;
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
@@ -364,7 +368,7 @@ pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
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)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &canon, area: "values", func: Some(PivotFunc::Sum), name: None, position: None })?;
}
Ok(json!({
"name": canon,
@@ -452,7 +456,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let src_field = remove_from_area(C, pt, &field, from)?;
add_to_area(C, pt, src, &src_field, to, None, None, pos)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: pos })?;
if matches!(to, "rows" | "columns") {
let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns };
if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) {
+4 -1
View File
@@ -1,7 +1,10 @@
use super::create::source_param;
use super::fields::{canonical, in_axes, shift_values, update};
use super::params::{group_param, layout_name};
use super::*;
use super::{
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
json, other, pv, str_param,
};
pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.filter";
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Json, PivotDateGroup, PivotFunc, PivotLayout, PivotShowAs, PivotSort, Result, bad, u32_param};
pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(str::to_ascii_lowercase) {
+121 -97
View File
@@ -243,6 +243,70 @@ fn compact_vs_tabular_shapes() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_on_rows_default(s: &Session) {
let p = pt(s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(s, "A4"), "East");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A6"), "Sum of Units");
assert_eq!(val(s, "B6"), Value::Number(33.0));
assert_eq!(text(s, "A13"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(TOTAL));
assert_eq!(text(s, "A14"), "Total Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(72.0));
}
fn assert_values_on_rows_tabular(s: &mut Session) {
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Total");
}
fn assert_values_on_rows_position(s: &mut Session) {
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "East");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A8"), "Sum of Units");
assert_eq!(text(s, "A9"), "East");
assert_eq!(val(s, "B9"), Value::Number(33.0));
assert_eq!(text(s, "A12"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Number(TOTAL));
assert_eq!(text(s, "A5"), "Sum of Units");
assert_eq!(val(s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(s).values_on_rows);
assert_eq!(text(s, "A3"), "Sum of Sales");
assert_eq!(text(s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
@@ -250,61 +314,9 @@ fn values_on_rows_layouts() {
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
assert_values_on_rows_default(&s);
assert_values_on_rows_tabular(&mut s);
assert_values_on_rows_position(&mut s);
}
#[test]
@@ -355,53 +367,65 @@ fn change_source_keeps_values_ahead_of_its_field() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_between_compact_rows(s: &Session) {
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(text(s, "A6"), "Apples");
assert_eq!(val(s, "B6"), Value::Number(160.0));
assert_eq!(text(s, "A9"), "Sum of Units");
assert_eq!(text(s, "A10"), "Apples");
assert_eq!(val(s, "B10"), Value::Number(16.0));
assert_eq!(text(s, "A13"), "East Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(415.0));
assert_eq!(text(s, "A14"), "East Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
}
fn assert_values_between_outline(s: &mut Session) {
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(s).last_range.unwrap().width(), 4);
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Product");
assert_eq!(text(s, "B5"), "Sum of Sales");
assert_eq!(text(s, "C6"), "Apples");
assert_eq!(val(s, "D6"), Value::Number(160.0));
}
fn assert_values_between_tabular(s: &mut Session) {
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "B4"), "Sum of Sales");
assert_eq!(text(s, "C4"), "Apples");
assert_eq!(val(s, "D4"), Value::Number(160.0));
assert_eq!(text(s, "B7"), "Sum of Units");
assert_eq!(text(s, "C7"), "Apples");
assert_eq!(val(s, "D7"), Value::Number(16.0));
assert_eq!(text(s, "A10"), "East Sum of Sales");
assert_eq!(val(s, "D10"), Value::Number(415.0));
assert_eq!(text(s, "A11"), "East Sum of Units");
let r = pt(s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
assert_values_between_compact_rows(&s);
assert_values_between_outline(&mut s);
assert_values_between_tabular(&mut s);
}
#[test]
+14 -6
View File
@@ -10,11 +10,16 @@
//! codes.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::*;
use gridcraft_pdf::{Font, Page, PdfDoc, Rgb};
#[cfg(test)]
use gridcraft_model::{Alignment, Image, SheetViewMode};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
Underline, VAlign, Visibility, Workbook,
};
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param};
mod draw;
mod header_footer;
@@ -27,6 +32,9 @@ use header_footer::draw_header_footer;
pub use header_footer::{
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
};
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
#[cfg(test)]
use layout::bands;
pub(crate) use layout::split_bands;
@@ -94,7 +102,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
let mut pics = PicCache::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -116,7 +124,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (rsi, rs) in rsegs.iter().enumerate() {
let mut x = ox + lay.heading_w * k;
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
let b = make_block(wb, lay.sheet, sh, *rs, *cs, (x, y), lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
@@ -155,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(lx, ly, w, h, target_page, x, y);
page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y));
}
}
let n = doc.page_count();
+18 -14
View File
@@ -1,7 +1,10 @@
use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::draw_text;
use super::*;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -151,7 +154,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf)
};
let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) };
let (ax, ay) = xf.p(*x, *y);
pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation);
pg.text_rotated(Point::new(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs), size, font, blend(*color), text, *rotation);
}
}
}
@@ -207,10 +210,11 @@ fn border_dash(style: BorderStyle, k: f32) -> Vec<f32> {
}
}
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) {
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end: Point, k: f32) {
if b.is_none() {
return;
}
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]);
if b.style == BorderStyle::Double {
let off = 1.0 * k;
@@ -221,7 +225,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x
return;
}
let lw = (b.style.width() * k).clamp(0.25, 6.0);
pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k));
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
@@ -324,12 +328,12 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
),
None => (bd.right, bd.bottom),
};
draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k);
draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k);
draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k);
draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k);
draw_border(pg, &bd.top, b.wb, Point::new(x, y), Point::new(x + w, y), b.k);
draw_border(pg, &bottom, b.wb, Point::new(x, y + h), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.left, b.wb, Point::new(x, y), Point::new(x, y + h), b.k);
draw_border(pg, &right, b.wb, Point::new(x + w, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_down, b.wb, Point::new(x, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_up, b.wb, Point::new(x, y + h), Point::new(x + w, y), b.k);
}
// Text.
for (c, l) in &looks {
@@ -340,7 +344,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
}
pg.restore();
@@ -444,10 +448,10 @@ pub(super) fn make_block<'a>(
sh: &'a Sheet,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
origin: (f32, f32),
lay: &SheetLayout,
) -> Block<'a> {
let (x, y) = origin;
let k = lay.k();
let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize);
let mut acc = x;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::SheetLayout;
use super::*;
use super::{Font, Page, Value, Workbook};
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
+8 -2
View File
@@ -1,5 +1,8 @@
use super::header_footer::HF_FIXED_SIDE_MARGIN;
use super::*;
use super::{
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
bool_param, split_sheet, str_param,
};
/// Cells in a page block above which only stored cells are visited (not every grid position).
pub(super) const DENSE_BLOCK: u64 = 60_000;
@@ -287,10 +290,13 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// Inclusive-start/end index spans of the pages along one axis.
pub(crate) type Bands = Vec<(u32, u32)>;
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Bands, Bands) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
+4 -11
View File
@@ -1,6 +1,6 @@
use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of};
use super::text::wrap_lines;
use super::*;
use super::{Anchor, Font, Page, RangeRef, Rect, ShapeKind, Sheet, Value, Workbook};
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
@@ -25,7 +25,7 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
let fit = (w / iw as f32).min(h / ih as f32);
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
pg.image_rgba(Rect::new(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph), image.as_raw(), [image.width(), image.height()]);
}
/// Sheet pixel rectangle of an anchor.
@@ -49,14 +49,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
pub(super) fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut super::PicCache) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
@@ -77,7 +70,7 @@ pub(super) fn draw_objects(
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(x, y, w, h, px, *pw, *ph);
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
}
}
for shp in &sh.shapes {
+59 -52
View File
@@ -1,5 +1,5 @@
use super::draw::{Block, Look, rgb_of};
use super::*;
use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value};
const PAD_PX: f32 = 2.0;
@@ -51,11 +51,55 @@ fn font_for(style: &Style) -> Font {
Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic)
}
fn text_align(align: HAlign, value: &Value) -> HAlign {
match align {
HAlign::General => match value {
Value::Number(_) => HAlign::Right,
Value::Bool(_) | Value::Error(_) => HAlign::Center,
_ => HAlign::Left,
},
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
}
}
/// Is a cell free for text to overflow into?
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
}
fn extend_text_bounds(sh: &Sheet, c: CellRef, merges: &[RangeRef], k: f32, need: f32, align: HAlign, bounds: (f32, f32)) -> (f32, f32) {
let (mut clip_l, mut clip_r) = bounds;
let grow = |dir: i64, mut remaining: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while remaining > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let width = sh.col_width(nc.col) * k;
ext += width;
remaining -= width;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
(clip_l, clip_r)
}
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
let mut ext = 0.0;
@@ -71,16 +115,17 @@ pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, m
ext
}
pub(super) fn draw_text(
pg: &mut Page,
b: &Block,
c: CellRef,
l: &Look,
rect: (f32, f32, f32, f32),
merged: bool,
merges: &[RangeRef],
limits: (f32, f32),
) {
pub(super) struct TextPlacement<'a> {
pub cell: CellRef,
pub look: &'a Look,
pub rect: (f32, f32, f32, f32),
pub merged: bool,
pub merges: &'a [RangeRef],
pub limits: (f32, f32),
}
pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) {
let TextPlacement { cell: c, look: l, rect, merged, merges, limits } = placement;
let (x, y, w, h) = rect;
let sh = b.sh;
let wb = b.wb;
@@ -133,20 +178,7 @@ pub(super) fn draw_text(
text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255));
tw = gridcraft_pdf::text_width(font, size, &text);
}
let h_align = match st.align.h {
HAlign::General => {
if is_num {
HAlign::Right
} else if matches!(v, Value::Bool(_) | Value::Error(_)) {
HAlign::Center
} else {
HAlign::Left
}
}
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
};
let h_align = text_align(st.align.h, &v);
let rot = st.align.rotation;
let rotated = rot != 0 && rot != 255;
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
@@ -170,32 +202,7 @@ pub(super) fn draw_text(
let (mut clip_l, mut clip_r) = (x, x + w + across);
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
let need = tw + 2.0 * pad + indent - w;
let grow = |dir: i64, mut need: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while need > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let cw = sh.col_width(nc.col) * b.k;
ext += cw;
need -= cw;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match h_align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
(clip_l, clip_r) = extend_text_bounds(sh, c, merges, b.k, need, h_align, (clip_l, clip_r));
}
let clip_l = clip_l.max(limits.0);
let clip_r = clip_r.min(limits.1);
@@ -215,7 +222,7 @@ pub(super) fn draw_text(
} else {
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
};
pg.text_rotated(ox, oy, size, font, color, t, angle);
pg.text_rotated(Point::new(ox, oy), size, font, color, t, angle);
}
pg.restore();
return;
+1 -1
View File
@@ -5,7 +5,7 @@ use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param, target_ranges, target_sheet};
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, CellRef, Ctx, EngineError, Json, Result, Session, Sheet, Visibility, bad, bool_param, edit, json, ok, str_param, target_sheet};
pub(super) fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
+4 -2
View File
@@ -6,10 +6,12 @@
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, Workbook};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
+12 -3
View File
@@ -97,9 +97,17 @@ struct Solve {
evo_end: Option<EvoEnd>,
}
/// Variable bounds and integrality flags.
#[derive(Clone, Copy)]
struct Bounds<'a> {
lo: &'a [f64],
hi: &'a [f64],
integer: &'a [bool],
}
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
#[allow(clippy::too_many_arguments)]
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve {
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bounds, gap: f64) -> Solve {
let Bounds { lo, hi, integer } = bounds;
let any_int = integer.iter().any(|b| *b);
let mut not_linear = false;
let mut evo_end = None;
@@ -203,7 +211,8 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
.into(),
));
}
let Solve { outcome, not_linear, evo_end } = optimise(&mut ev, &model, model.goal, &x0, &prob.lo, &prob.hi, &prob.integer, gap);
let Solve { outcome, not_linear, evo_end } =
optimise(&mut ev, &model, model.goal, &x0, Bounds { lo: &prob.lo, hi: &prob.hi, integer: &prob.integer }, gap);
let (status, message, solution) = match outcome {
_ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None),
Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end {
+1 -1
View File
@@ -2,7 +2,7 @@ use super::lp::{Outcome, Row, Sense};
use super::model::{EvoOptions, Goal};
use super::nlp::{project, shape, violation};
use super::problem::Evaluator;
use super::*;
use super::{MAX_POPULATION, MIN_POPULATION, PRECISION};
// ---------------------------------------------------------------- evolutionary
+199 -155
View File
@@ -1,8 +1,13 @@
use super::problem::Evaluator;
use super::*;
use super::{CellRef, EPS};
// ---------------------------------------------------------------- evaluation
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
/// Objective (gradient, constant) and per-row (gradient, constant).
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Sense {
Le,
@@ -29,6 +34,188 @@ pub(super) enum Outcome {
Limit,
}
struct Map {
var: usize,
sign: f64,
}
struct VariableMap {
columns: Vec<Map>,
offset: Vec<f64>,
bound_rows: Vec<BoundRow>,
}
struct Tableau {
rows: Vec<Vec<f64>>,
basis: Vec<usize>,
width: usize,
decision_cols: usize,
slack_cols: usize,
artificial_cols: usize,
}
fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
let mut columns = Vec::new();
let mut offset = vec![0.0; n];
let mut bound_rows = Vec::new();
for j in 0..n {
let lower = lo.get(j).copied().unwrap_or(f64::NEG_INFINITY);
let upper = hi.get(j).copied().unwrap_or(f64::INFINITY);
if lower.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = lower;
}
columns.push(Map { var: j, sign: 1.0 });
if upper.is_finite() {
bound_rows.push((vec![(columns.len() - 1, 1.0)], Sense::Le, upper - lower));
}
} else if upper.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = upper;
}
columns.push(Map { var: j, sign: -1.0 });
} else {
columns.push(Map { var: j, sign: 1.0 });
columns.push(Map { var: j, sign: -1.0 });
}
}
VariableMap { columns, offset, bound_rows }
}
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
let width = columns.len();
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
for (coefficients, sense, rhs) in rows {
let mut row = vec![0.0; width];
for (index, mapping) in columns.iter().enumerate() {
if let Some(value) = row.get_mut(index) {
*value = coefficients.get(mapping.var).copied().unwrap_or(0.0) * mapping.sign;
}
}
let shift: f64 = coefficients.iter().zip(offset).map(|(coefficient, base)| coefficient * base).sum();
transformed.push((row, *sense, rhs - shift));
}
for (entries, sense, rhs) in bound_rows {
let mut row = vec![0.0; width];
for (index, value) in entries {
if let Some(slot) = row.get_mut(index) {
*slot = value;
}
}
transformed.push((row, sense, rhs));
}
// Rows with a negative right side are negated so every right side is >= 0.
transformed
.into_iter()
.map(|(row, sense, rhs)| {
if rhs < 0.0 {
let sense = match sense {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(row.iter().map(|v| -v).collect(), sense, -rhs)
} else {
(row, sense, rhs)
}
})
.collect()
}
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
let width = decision_cols + slack_cols + artificial_cols;
let mut rows = Vec::with_capacity(normalized.len());
let mut basis = vec![0usize; normalized.len()];
let (mut slack, mut artificial) = (decision_cols, decision_cols + slack_cols);
for (i, (coefficients, sense, rhs)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (value, coefficient) in row.iter_mut().zip(coefficients) {
*value = *coefficient;
}
let basic = match sense {
Sense::Le => {
if let Some(value) = row.get_mut(slack) {
*value = 1.0;
}
let basic = slack;
slack += 1;
basic
}
Sense::Ge => {
if let Some(value) = row.get_mut(slack) {
*value = -1.0;
}
slack += 1;
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
Sense::Eq => {
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
};
if let Some(value) = row.get_mut(width) {
*value = *rhs;
}
if let Some(slot) = basis.get_mut(i) {
*slot = basic;
}
rows.push(row);
}
Tableau { rows, basis, width, decision_cols, slack_cols, artificial_cols }
}
fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budget: &mut u64) -> Result<(), Outcome> {
if tableau.artificial_cols == 0 {
return Ok(());
}
let mut objective = vec![0.0; tableau.width + 1];
for column in tableau.decision_cols + tableau.slack_cols..tableau.width {
if let Some(value) = objective.get_mut(column) {
*value = 1.0;
}
}
for (i, row) in tableau.rows.iter().enumerate() {
if tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
for (value, coefficient) in objective.iter_mut().zip(row) {
*value -= coefficient;
}
}
}
let all = vec![true; tableau.width];
if matches!(simplex(&mut tableau.rows, &mut tableau.basis, &mut objective, &all, budget), Step::Limit) {
return Err(Outcome::Limit);
}
let scale = normalized.iter().map(|row| row.2.abs()).fold(1.0f64, f64::max);
if -objective.get(tableau.width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
.rows
.get(i)
.and_then(|row| (0..tableau.decision_cols + tableau.slack_cols).find(|&column| row.get(column).is_some_and(|value| value.abs() > EPS)));
if let Some(column) = pivot_col {
pivot(&mut tableau.rows, &mut objective, i, column);
if let Some(basic) = tableau.basis.get_mut(i) {
*basic = column;
}
}
}
Ok(())
}
/// Depth-first branch and bound over a relaxation solver; returns the best integer point.
pub(super) fn branch_and_bound(
lo: &[f64],
@@ -201,165 +388,23 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
let n = c.len();
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
struct Map {
var: usize,
sign: f64,
}
let mut cols: Vec<Map> = Vec::new();
let mut offset = vec![0.0; n];
let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new();
for j in 0..n {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
if l.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = l;
}
cols.push(Map { var: j, sign: 1.0 });
if h.is_finite() {
extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l));
}
} else if h.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = h;
}
cols.push(Map { var: j, sign: -1.0 });
} else {
cols.push(Map { var: j, sign: 1.0 });
cols.push(Map { var: j, sign: -1.0 });
}
}
let mapping = map_variables(c.len(), lo, hi);
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let mut yrows: Vec<(Vec<f64>, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len());
for (a, sense, b) in rows {
let mut coef = vec![0.0; ny];
for (k, m) in cols.iter().enumerate() {
if let Some(v) = coef.get_mut(k) {
*v = a.get(m.var).copied().unwrap_or(0.0) * m.sign;
}
}
let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum();
yrows.push((coef, *sense, b - shift));
}
for (entries, sense, b) in extra {
let mut coef = vec![0.0; ny];
for (k, v) in entries {
if let Some(e) = coef.get_mut(k) {
*e = v;
}
}
yrows.push((coef, sense, b));
}
let m = yrows.len();
let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count();
// Rows with b < 0 are negated so every right side is ≥ 0.
let normalized: Vec<(Vec<f64>, Sense, f64)> = yrows
.into_iter()
.map(|(a, s, b)| {
if b < 0.0 {
let s = match s {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(a.iter().map(|v| -v).collect(), s, -b)
} else {
(a, s, b)
}
})
.collect();
let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count();
let width = ny + n_slack + n_art;
let mut t: Vec<Vec<f64>> = Vec::with_capacity(m);
let mut basis = vec![0usize; m];
let (mut si, mut ai) = (ny, ny + n_slack);
for (i, (a, s, b)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (v, x) in row.iter_mut().zip(a) {
*v = *x;
}
match s {
Sense::Le => {
if let Some(v) = row.get_mut(si) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = si;
}
si += 1;
}
Sense::Ge => {
if let Some(v) = row.get_mut(si) {
*v = -1.0;
}
si += 1;
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
Sense::Eq => {
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
}
if let Some(v) = row.get_mut(width) {
*v = *b;
}
t.push(row);
}
let is_art = |j: usize| j >= ny + n_slack && j < width;
// Phase 1: minimise the sum of the artificials.
let mut obj = vec![0.0; width + 1];
for j in (ny + n_slack)..width {
if let Some(v) = obj.get_mut(j) {
*v = 1.0;
}
}
for (i, row) in t.iter().enumerate() {
if basis.get(i).is_some_and(|b| is_art(*b)) {
for (v, x) in obj.iter_mut().zip(row) {
*v -= x;
}
}
}
let all: Vec<bool> = vec![true; width];
if n_art > 0 {
match simplex(&mut t, &mut basis, &mut obj, &all, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded | Step::Optimal => {}
}
let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max);
if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Outcome::Infeasible;
}
// Drive zero-valued artificials out of the basis where possible.
for i in 0..m {
if !basis.get(i).is_some_and(|b| is_art(*b)) {
continue;
}
let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS)));
if let Some(j) = j {
pivot(&mut t, &mut obj, i, j);
if let Some(b) = basis.get_mut(i) {
*b = j;
}
}
}
let normalized = normalize_rows(rows, extra, &cols, &offset);
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
return outcome;
}
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
let is_art = |column: usize| column >= ny + n_slack && column < width;
// Phase 2: the real costs; artificials may not re-enter.
let mut cost = vec![0.0; width + 1];
for (k, mp) in cols.iter().enumerate() {
@@ -402,8 +447,7 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`.
#[allow(clippy::type_complexity)]
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec<f64>, f64), Vec<(Vec<f64>, f64)>), &'static str> {
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<Linearized, &'static str> {
let n = x0.len();
let p0 = ev.eval(x0).ok_or("budget")?;
if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
bad, bool_param, f64_param, json, split_sheet, str_param,
};
// ---------------------------------------------------------------- model
+1 -1
View File
@@ -1,7 +1,7 @@
use super::PRECISION;
use super::lp::{Outcome, Row, Sense};
use super::model::Goal;
use super::problem::{Evaluator, Point};
use super::*;
// ---------------------------------------------------------------- nonlinear
+1 -1
View File
@@ -1,6 +1,6 @@
use super::lp::{Row, Sense};
use super::model::{Model, Rel, Rhs};
use super::*;
use super::{CellRef, Trial, Value};
/// The values the solvers see at a point: the objective and `lhs − rhs` per row.
#[derive(Clone, Debug)]
+5 -2
View File
@@ -1,7 +1,10 @@
use super::lp::Outcome;
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
use super::problem::{Evaluator, Problem, as_num, build_problem};
use super::*;
use super::{
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
edit, json, optimise, str_param,
};
// ---------------------------------------------------------------- reports
@@ -226,7 +229,7 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
ev.target = Some(j);
ev.evals = 0;
ev.budget = per;
let solve = optimise(&mut ev, m, goal, x, &lo, &hi, &inp.prob.integer, 0.0);
let solve = optimise(&mut ev, m, goal, x, super::Bounds { lo: &lo, hi: &hi, integer: &inp.prob.integer }, 0.0);
ends.push(match solve.outcome {
Outcome::Solved(p, _, _) => {
let v = p.get(j).copied().unwrap_or(f64::NAN);
+1 -1
View File
@@ -7,7 +7,7 @@ use std::sync::OnceLock;
use gridcraft_core::{RangeRef, Value};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cmd, has_doc, ok, str_param, target_range, target_sheet};
pub fn specs() -> Vec<CommandSpec> {
vec![
+5 -5
View File
@@ -1,10 +1,13 @@
//! View tab (sheet view state that's saved with the file) and Page Layout settings.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_core::RangeRef;
use gridcraft_model::Orientation;
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param,
target_range, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -493,9 +496,6 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
})
}
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{CalcMode, Cell, DefinedName, Sheet, StyleId, Workbook};
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param};
mod advanced_filter;
mod consolidate;
@@ -1,4 +1,4 @@
use super::*;
use super::{Cell, CellRef, EngineError, HashSet, Json, RangeRef, Result, Session, Value, bad, bool_param, edit, json, parse_range, str_param};
// ---------------------------------------------------------------- advanced filter
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Cell, CellRef, EngineError, HashMap, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, edit, json, parse_range, str_param,
};
// ---------------------------------------------------------------- consolidate
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, DefinedName, Deserialize, EngineError, HashMap, Json, Result, Serialize, Session, Sheet, StyleId, Trial, Value, Workbook, bad,
bool_param, cell_list, edit, json, json_value, str_param, value_json,
};
// ---------------------------------------------------------------- scenarios
+12 -1
View File
@@ -540,8 +540,19 @@ fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
Some((prefix, n, suffix, width))
}
/// Options for filling a numeric or date series.
pub struct SeriesOptions<'a> {
pub range: RangeRef,
pub rows: bool,
pub kind: &'a str,
pub step: f64,
pub stop: Option<f64>,
pub unit: &'a str,
}
/// Home › Fill › Series.
pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, step: f64, stop: Option<f64>, unit: &str) -> Result<()> {
pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<()> {
let SeriesOptions { range: r, rows, kind, step, stop, unit } = options;
let sys = cx.wb.date_system;
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let lanes: Vec<u32> = if rows { (r.start.row..=r.end.row).collect() } else { (r.start.col..=r.end.col).collect() };
+1
View File
@@ -606,6 +606,7 @@ fn script_sandbox_check(path: &str) -> Result<()> {
/// The largest native file the reader still touches: its own cap plus one, used to stop
/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
#[cfg(not(target_arch = "wasm32"))]
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::output::Output;
use super::*;
use super::{CellRef, Chart, Series, Workbook, quote_sheet};
// ---------------------------------------------------------------- PivotCharts
+423 -272
View File
@@ -1,6 +1,9 @@
use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label};
use super::output::{AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, data_cols_exceed_sheet, flatten_cols};
use super::*;
use super::{
CalcSet, CellError, CellRef, HashMap, HashSet, MAX_AXIS_FIELDS, MAX_COLS, MAX_VALUE_FIELDS, PivotDateGroup, PivotField, PivotFilter, PivotFunc,
PivotLayout, PivotShowAs, PivotTable, PivotValue, Source, Value, Workbook, read_source,
};
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
/// edits that cell instead).
@@ -52,16 +55,388 @@ fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Outpu
Ok(())
}
type ProbePath = (usize, Vec<u32>, Vec<u32>);
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
/// `paths` is set), and every value cell (when `cells` is set).
pub(super) struct Probe {
at: Option<(usize, usize)>,
hit: Option<(Vec<u32>, Vec<u32>)>,
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
paths: Option<Vec<ProbePath>>,
cells: Option<HashSet<(usize, usize)>>,
}
struct AggregateInput<'a> {
wb: &'a Workbook,
pt: &'a PivotTable,
src: &'a Source,
rows: &'a [(usize, &'a PivotField)],
cols: &'a [(usize, &'a PivotField)],
vals: &'a [(ValSrc, &'a PivotValue)],
extra: &'a [usize],
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
filter_fmts: &'a [String],
row_fmts: &'a [String],
col_fmts: &'a [String],
empty: bool,
}
#[derive(Default)]
struct AggregateData {
row_items: Vec<FieldItems>,
col_items: Vec<FieldItems>,
aggs: AggMap,
row_set: HashSet<Vec<u32>>,
col_set: HashSet<Vec<u32>>,
col_leaves: HashSet<Vec<u32>>,
}
fn aggregate_record(
input: &AggregateInput<'_>,
data: &mut AggregateData,
probe: &mut Option<&mut Probe>,
ri: usize,
rec: &[Value],
) -> Result<(), String> {
for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) {
if let Some(selected) = &field.selected {
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) {
return Ok(());
}
}
}
if input.empty {
return Ok(());
}
let mut row_path = Vec::with_capacity(input.rows.len());
for (((column, field), items), fmt) in input.rows.iter().zip(data.row_items.iter_mut()).zip(input.row_fmts) {
row_path.push(items.intern(input.wb, rec.get(*column).unwrap_or(&Value::Empty), field.date_group, fmt, ri));
}
let mut col_path = Vec::with_capacity(input.cols.len());
for (((column, field), items), fmt) in input.cols.iter().zip(data.col_items.iter_mut()).zip(input.col_fmts) {
col_path.push(items.intern(input.wb, rec.get(*column).unwrap_or(&Value::Empty), field.date_group, fmt, ri));
}
for row_depth in 0..=row_path.len() {
for col_depth in 0..=col_path.len() {
let accs = data
.aggs
.entry((row_path[..row_depth].to_vec(), col_path[..col_depth].to_vec()))
.or_insert_with(|| vec![Acc::default(); input.vals.len() + input.extra.len()]);
for (acc, (source, _)) in accs.iter_mut().zip(input.vals) {
if let ValSrc::Col(column) = source {
acc.add(rec.get(*column).unwrap_or(&Value::Empty));
}
}
for (acc, column) in accs.iter_mut().skip(input.vals.len()).zip(input.extra) {
acc.add(rec.get(*column).unwrap_or(&Value::Empty));
}
}
}
let shown_depth = input
.cols
.iter()
.zip(data.col_items.iter())
.zip(col_path.iter())
.position(|(((_, field), items), &item)| !field.collapsed_items.is_empty() && is_collapsed(field, &items.label(item)))
.map_or(col_path.len(), |depth| depth + 1);
data.col_leaves.insert(col_path[..shown_depth].to_vec());
if let Some(paths) = probe.as_deref_mut().and_then(|value| value.paths.as_mut()) {
paths.push((ri, row_path.clone(), col_path.clone()));
}
data.row_set.insert(row_path);
data.col_set.insert(col_path);
if !input.cols.is_empty() && data_cols_exceed_sheet(data.col_leaves.len(), input.pt.grand_totals_rows, input.vals.len()) {
return Err("The PivotTable report will not fit on the sheet.".into());
}
if data.aggs.len() > 5_000_000 {
return Err("The PivotTable report is too large.".into());
}
Ok(())
}
fn aggregate_source(input: AggregateInput<'_>, probe: &mut Option<&mut Probe>) -> Result<AggregateData, String> {
let mut data = AggregateData {
row_items: input.rows.iter().map(|_| FieldItems::default()).collect(),
col_items: input.cols.iter().map(|_| FieldItems::default()).collect(),
..AggregateData::default()
};
for (ri, record) in input.src.rows.iter().enumerate() {
aggregate_record(&input, &mut data, probe, ri, record)?;
}
Ok(data)
}
struct HeaderInput<'a> {
vals: &'a [(ValSrc, &'a PivotValue)],
row_fields: &'a [&'a PivotField],
col_fields: &'a [&'a PivotField],
col_items: &'a [FieldItems],
col_fmts: &'a [String],
vpos: Option<usize>,
data_cols: &'a [(usize, Option<usize>)],
specs: &'a [ColSpec],
row_levels: usize,
compact: bool,
on_rows: bool,
k: usize,
l: usize,
headers_on: bool,
}
fn write_headers(g: &mut Grid, input: HeaderInput<'_>) -> Result<(), String> {
let HeaderInput { vals, row_fields, col_fields, col_items, col_fmts, vpos, data_cols, specs, row_levels, compact, on_rows, k, l, headers_on } =
input;
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let row_header = |g: &mut Grid, r: usize| {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
if let Some(p) = vpos {
names.insert(p.min(names.len()), "Values");
}
for (i, name) in names.into_iter().enumerate() {
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
}
}
};
// ---- header rows
if col_fields.is_empty() {
let r = g.push_row(RowKind::Header)?;
row_header(g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
match vi {
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
// Values on rows, no column field: the one data column is the grand total.
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
None => {}
}
}
} else {
let cap = g.push_row(RowKind::Header)?;
if l > 0 && k == 1 {
g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None);
}
if headers_on {
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = col_fields.len() + usize::from(k > 1 && !on_rows);
let first_level = g.rows.len();
for _ in 0..levels {
g.push_row(RowKind::Header)?;
}
let mut prev: Option<&ColSpec> = None;
for (j, (si, vi)) in data_cols.iter().enumerate() {
let Some(spec) = specs.get(*si) else { continue };
let c = l + j;
match spec.kind {
ColKind::Leaf => {
for (lv, &it) in spec.path.iter().enumerate() {
let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv));
if !same {
let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue };
let fmt = if item.formatted { col_fmts.get(lv).cloned() } else { None };
g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt);
}
}
if k > 1
&& let Some(vi) = vi
{
g.set(first_level + col_fields.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None);
}
}
ColKind::Subtotal => {
let lv = spec.path.len().saturating_sub(1);
let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default();
let text = match vi {
Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)),
_ => format!("{label} Total"),
};
g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None);
}
ColKind::Grand => {
let text = match vi {
Some(vi) if k > 1 => format!("Total {}", val_name(*vi)),
_ => "Grand Total".to_string(),
};
g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None);
}
}
prev = Some(spec);
}
let last = g.rows.len() - 1;
row_header(g, last);
}
Ok(())
}
struct ValueLookup<'a> {
wb: &'a Workbook,
source: &'a Source,
calc: &'a CalcSet,
vals: &'a [(ValSrc, &'a PivotValue)],
extra: &'a [usize],
aggs: &'a AggMap,
value_count: usize,
}
impl ValueLookup<'_> {
fn get(&self, row_path: &[u32], col_path: &[u32], value_index: usize) -> Option<Value> {
let (source, definition) = self.vals.get(value_index)?;
let accumulators = self.aggs.get(&(row_path.to_vec(), col_path.to_vec()))?;
match source {
ValSrc::Col(_) => accumulators.get(value_index).map(|acc| acc.result(definition.func)),
ValSrc::Calc(index) => {
let sum = |name: &str| -> Value {
self.source
.col(name)
.and_then(|column| self.extra.iter().position(|extra_column| *extra_column == column))
.and_then(|index| accumulators.get(self.value_count + index))
.map(|acc| acc.result(PivotFunc::Sum))
.unwrap_or(Value::Number(0.0))
};
Some(self.calc.value(self.wb, *index, &sum))
}
}
}
}
struct ShowContext<'a> {
row_path: &'a [u32],
col_path: &'a [u32],
value_index: usize,
level0: &'a [u32],
siblings: &'a HashMap<Vec<u32>, Vec<u32>>,
}
fn number(value: &Option<Value>) -> Option<f64> {
value.as_ref().and_then(Value::as_f64)
}
fn ratio(a: Value, b: Option<Value>) -> Value {
match (a, b) {
(Value::Error(error), _) => Value::Error(error),
(_, Some(Value::Error(error))) => Value::Error(error),
(Value::Number(_), Some(Value::Number(0.0))) => Value::Error(CellError::Div0),
(Value::Number(x), Some(Value::Number(y))) => Value::number(x / y),
(value, _) => value,
}
}
fn running_total(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
let Some((first, _)) = context.row_path.split_first() else { return raw };
if !matches!(raw, Value::Number(_)) {
return raw;
}
let mut total = 0.0;
for item in context.level0 {
let mut path = context.row_path.to_vec();
if let Some(first_item) = path.first_mut() {
*first_item = *item;
}
total += number(&lookup.get(&path, context.col_path, context.value_index)).unwrap_or(0.0);
if item == first {
break;
}
}
Value::number(total)
}
fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty };
let Some(me) = raw.as_f64() else { return raw };
let siblings = context.siblings.get(parent).cloned().unwrap_or_default();
let mut rank = 1u64;
for sibling in siblings {
let mut path = parent.to_vec();
path.push(sibling);
if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) {
rank += 1;
}
}
Value::number(rank as f64)
}
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
match definition.show_as {
PivotShowAs::Normal => raw,
PivotShowAs::PercentOfGrandTotal => ratio(raw, lookup.get(&[], &[], context.value_index)),
PivotShowAs::PercentOfColumnTotal => ratio(raw, lookup.get(&[], context.col_path, context.value_index)),
PivotShowAs::PercentOfRowTotal => ratio(raw, lookup.get(context.row_path, &[], context.value_index)),
PivotShowAs::RunningTotal => running_total(raw, lookup, context),
PivotShowAs::Rank => ranked_value(raw, lookup, context),
}
}
type ReportFilter<'a> = (usize, &'a PivotFilter, PivotDateGroup, bool);
fn report_filters<'a>(pt: &'a PivotTable, src: &Source) -> (Vec<ReportFilter<'a>>, Vec<ReportFilter<'a>>) {
let axis_group =
|name: &str| pt.rows.iter().chain(pt.columns.iter()).find(|field| field.source_col.eq_ignore_ascii_case(name)).map(|field| field.date_group);
let filters: Vec<_> = pt
.filters
.iter()
.filter_map(|filter| {
let column = src.col(&filter.source_col)?;
let group = axis_group(&filter.source_col);
Some((column, filter, group.unwrap_or_default(), group.is_none()))
})
.collect();
let shown = filters.iter().copied().filter(|filter| filter.3).collect();
(filters, shown)
}
fn values_position(pt: &PivotTable, src: &Source, value_count: usize) -> Option<usize> {
if !pt.values_on_rows || value_count <= 1 {
return None;
}
let position = pt.values_position.unwrap_or(pt.rows.len());
Some(pt.rows.iter().take(position).filter(|field| src.col(&field.source_col).is_some()).count())
}
fn calculated_source_columns(vals: &[(ValSrc, &PivotValue)], calc: &CalcSet, src: &Source) -> Vec<usize> {
let mut columns = Vec::new();
for (source, _) in vals {
let ValSrc::Calc(index) = source else { continue };
for name in calc.source_refs(*index) {
if let Some(column) = src.col(&name)
&& !columns.contains(&column)
{
columns.push(column);
}
}
}
columns
}
fn data_columns(specs: &[ColSpec], has_column_fields: bool, on_rows: bool, value_count: usize) -> Result<Vec<(usize, Option<usize>)>, String> {
let mut columns = Vec::new();
for (spec_index, _) in specs.iter().enumerate() {
if on_rows {
columns.push((spec_index, None));
} else if value_count == 0 {
if has_column_fields {
columns.push((spec_index, None));
}
} else {
columns.extend((0..value_count).map(|value_index| (spec_index, Some(value_index))));
}
if columns.len() > MAX_COLS as usize {
return Err("The PivotTable report will not fit on the sheet.".into());
}
}
Ok(columns)
}
pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
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();
@@ -77,116 +452,45 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
})
.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);
}
}
}
}
let extra = calculated_source_columns(&vals, &calc, src);
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
}
if vals.len() > MAX_VALUE_FIELDS {
return Err(format!("A PivotTable can have at most {MAX_VALUE_FIELDS} value fields."));
}
let axis_group = |name: &str| -> Option<PivotDateGroup> {
pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group)
};
// (column, filter, grouping used for labels, shown as a report filter)
let filters: Vec<(usize, &PivotFilter, PivotDateGroup, bool)> = pt
.filters
.iter()
.filter_map(|f| {
let c = src.col(&f.source_col)?;
let g = axis_group(&f.source_col);
Some((c, f, g.unwrap_or_default(), g.is_none()))
})
.collect();
let shown_filters: Vec<&(usize, &PivotFilter, PivotDateGroup, bool)> = filters.iter().filter(|f| f.3).collect();
let (filters, shown_filters) = report_filters(pt, src);
let k = vals.len();
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
// found in the source.
let vpos: Option<usize> = (pt.values_on_rows && k > 1).then(|| {
let p = pt.values_position.unwrap_or(pt.rows.len());
pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count()
});
let vpos = values_position(pt, src, k);
let on_rows = vpos.is_some();
let row_levels = rows.len() + usize::from(on_rows);
// ---- aggregate
let mut row_items: Vec<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
let mut col_items: Vec<FieldItems> = cols.iter().map(|_| FieldItems::default()).collect();
let mut aggs: AggMap = HashMap::new();
let mut row_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_leaves: HashSet<Vec<u32>> = HashSet::new();
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
'rec: for (ri, rec) in src.rows.iter().enumerate() {
for ((c, f, g, _), fmt) in filters.iter().zip(filter_fmts.iter()) {
if let Some(sel) = &f.selected {
let label = item_label(wb, rec.get(*c).unwrap_or(&Value::Empty), *g, fmt);
if !sel.iter().any(|s| s.eq_ignore_ascii_case(&label)) {
continue 'rec;
}
}
}
if empty {
continue;
}
let mut rp = Vec::with_capacity(rows.len());
for (((c, f), fi), fmt) in rows.iter().zip(row_items.iter_mut()).zip(row_fmts.iter()) {
rp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri));
}
let mut cp = Vec::with_capacity(cols.len());
for (((c, f), fi), fmt) in cols.iter().zip(col_items.iter_mut()).zip(col_fmts.iter()) {
cp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri));
}
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 + 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));
}
}
}
// The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`).
let shown = cols
.iter()
.zip(col_items.iter())
.zip(cp.iter())
.position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it)))
.map_or(cp.len(), |d| d + 1);
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
paths.push((ri, rp.clone(), cp.clone()));
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) {
return Err("The PivotTable report will not fit on the sheet.".into());
}
if aggs.len() > 5_000_000 {
return Err("The PivotTable report is too large.".into());
}
}
let aggregate = aggregate_source(
AggregateInput {
wb,
pt,
src,
rows: &rows,
cols: &cols,
vals: &vals,
extra: &extra,
filters: &filters,
filter_fmts: &filter_fmts,
row_fmts: &row_fmts,
col_fmts: &col_fmts,
empty,
},
&mut probe,
)?;
let AggregateData { row_items, col_items, aggs, row_set, col_set, col_leaves: _ } = aggregate;
// ---- axes
let row_fields: Vec<&PivotField> = rows.iter().map(|(_, f)| *f).collect();
@@ -236,24 +540,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
specs.push(ColSpec { kind: ColKind::Grand, path: vec![] });
}
}
let mut data_cols: Vec<(usize, Option<usize>)> = Vec::new();
for (si, _) in specs.iter().enumerate() {
if on_rows {
// Each line names its value field; one data column per column item.
data_cols.push((si, None));
} else if k == 0 {
if !cols.is_empty() {
data_cols.push((si, None));
}
} else {
for vi in 0..k {
data_cols.push((si, Some(vi)));
}
}
if data_cols.len() > MAX_COLS as usize {
return Err("The PivotTable report will not fit on the sheet.".into());
}
}
let data_cols = data_columns(&specs, !cols.is_empty(), on_rows, k)?;
let compact = pt.layout == PivotLayout::Compact;
let l = if row_levels == 0 && cols.is_empty() {
@@ -304,128 +591,28 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
});
}
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let headers_on = pt.show_headers;
let row_header = |g: &mut Grid, r: usize| {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
if let Some(p) = vpos {
names.insert(p.min(names.len()), "Values");
}
for (i, name) in names.into_iter().enumerate() {
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
}
}
};
// ---- header rows
if cols.is_empty() {
let r = g.push_row(RowKind::Header)?;
row_header(&mut g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
match vi {
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
// Values on rows, no column field: the one data column is the grand total.
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
None => {}
}
}
} else {
let cap = g.push_row(RowKind::Header)?;
if l > 0 && k == 1 {
g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None);
}
if headers_on {
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = cols.len() + usize::from(k > 1 && !on_rows);
let first_level = g.rows.len();
for _ in 0..levels {
g.push_row(RowKind::Header)?;
}
let mut prev: Option<&ColSpec> = None;
for (j, (si, vi)) in data_cols.iter().enumerate() {
let Some(spec) = specs.get(*si) else { continue };
let c = l + j;
match spec.kind {
ColKind::Leaf => {
for (lv, &it) in spec.path.iter().enumerate() {
let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv));
if !same {
let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue };
let fmt = if item.formatted { cols.get(lv).map(|(cc, _)| fmt_of(*cc)) } else { None };
g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt);
}
}
if k > 1
&& let Some(vi) = vi
{
g.set(first_level + cols.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None);
}
}
ColKind::Subtotal => {
let lv = spec.path.len().saturating_sub(1);
let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default();
let text = match vi {
Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)),
_ => format!("{label} Total"),
};
g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None);
}
ColKind::Grand => {
let text = match vi {
Some(vi) if k > 1 => format!("Total {}", val_name(*vi)),
_ => "Grand Total".to_string(),
};
g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None);
}
}
prev = Some(spec);
}
let last = g.rows.len() - 1;
row_header(&mut g, last);
}
write_headers(
&mut g,
HeaderInput {
vals: &vals,
row_fields: &row_fields,
col_fields: &col_fields,
col_items: &col_items,
col_fmts: &col_fmts,
vpos,
data_cols: &data_cols,
specs: &specs,
row_levels,
compact,
on_rows,
k,
l,
headers_on: pt.show_headers,
},
)?;
let val_name = |vi: usize| vals.get(vi).map(|(_, value)| value.name.clone()).unwrap_or_default();
// ---- body
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
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 {
match (a, b) {
(Value::Error(e), _) => Value::Error(e),
(_, Some(Value::Error(e))) => Value::Error(e),
(Value::Number(x), Some(Value::Number(y))) => {
if y == 0.0 {
Value::Error(CellError::Div0)
} else {
Value::number(x / y)
}
}
(v, _) => v,
}
};
let data_fmt: Vec<Option<String>> = vals
.iter()
.map(|(vs, v)| match v.show_as {
@@ -445,6 +632,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
}),
})
.collect();
let lookup = ValueLookup { wb, source: src, calc: &calc, vals: &vals, extra: &extra, aggs: &aggs, value_count: k };
let mut chart_rows: Vec<u32> = Vec::new();
let chart_cols: Vec<(u32, String)> = data_cols
.iter()
@@ -501,46 +689,9 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
let Some((_, vdef)) = vals.get(*vi) else { continue };
let cp = spec.path.as_slice();
let Some(raw) = get(rp, cp, *vi) else { continue };
let shown = match vdef.show_as {
PivotShowAs::Normal => raw,
PivotShowAs::PercentOfGrandTotal => ratio(raw, get(&[], &[], *vi)),
PivotShowAs::PercentOfColumnTotal => ratio(raw, get(&[], cp, *vi)),
PivotShowAs::PercentOfRowTotal => ratio(raw, get(rp, &[], *vi)),
PivotShowAs::RunningTotal => match (rp.first(), &raw) {
(Some(first), Value::Number(_)) => {
let mut acc = 0.0;
for it in &level0 {
let mut p = rp.to_vec();
if let Some(x) = p.first_mut() {
*x = *it;
}
acc += num(&get(&p, cp, *vi)).unwrap_or(0.0);
if it == first {
break;
}
}
Value::number(acc)
}
_ => raw,
},
PivotShowAs::Rank => match (rp.split_last(), raw.as_f64()) {
(Some((_, parent)), Some(me)) => {
let sibs = siblings.get(parent).cloned().unwrap_or_default();
let mut rank = 1u64;
for sib in sibs {
let mut p = parent.to_vec();
p.push(sib);
if num(&get(&p, cp, *vi)).is_some_and(|x| x > me) {
rank += 1;
}
}
Value::number(rank as f64)
}
(None, _) => Value::Empty,
_ => raw,
},
};
let Some(raw) = lookup.get(rp, cp, *vi) else { continue };
let context = ShowContext { row_path: rp, col_path: cp, value_index: *vi, level0: &level0, siblings: &siblings };
let shown = show_value(vdef, raw, &lookup, &context);
if let Some(pr) = probe.as_deref_mut() {
if pr.at == Some((r, l + j)) {
pr.hit = Some((rp.to_vec(), cp.to_vec()));
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CellError, HashMap, MONTHS, Ordering, PivotDateGroup, PivotField, PivotFunc, PivotSort, Source, Value, Workbook, datetime_from_serial};
// ---------------------------------------------------------------- items
+4 -1
View File
@@ -1,5 +1,8 @@
use super::items::{Acc, FieldItems, Node, is_collapsed};
use super::*;
use super::{
BorderLine, BorderStyle, Color, Fill, HAlign, HashMap, MAX_COLS, MAX_OUTPUT_CELLS, MAX_ROWS, NumFmt, PivotField, PivotLayout, PivotTable, Style,
Value,
};
// ---------------------------------------------------------------- output
+15 -16
View File
@@ -2406,8 +2406,7 @@ fn ods_import_keeps_cached_values_and_reports_limits() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn ods_import_never_reuses_source_as_save_target() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let bytes = ods_fixture();
@@ -2437,8 +2436,7 @@ fn ods_import_never_reuses_source_as_save_target() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_an_opened_semicolon_csv_keeps_semicolons() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-csv-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("cut list.csv");
@@ -2512,8 +2510,7 @@ fn link_target(path: &std::path::Path) -> String {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id()));
let b = a.join("b");
std::fs::create_dir_all(&b).unwrap();
@@ -2541,8 +2538,7 @@ fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_to_an_unknown_extension_is_refused() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let mut s = s();
@@ -2566,8 +2562,7 @@ fn saving_to_an_unknown_extension_is_refused() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("budget.dat");
@@ -3240,8 +3235,7 @@ fn xlsb_import_keeps_cached_values_and_reports_limits() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn xlsb_import_never_reuses_source_as_save_target() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let bytes = xlsb_fixture();
@@ -4410,8 +4404,7 @@ fn encrypted_workbooks_open_with_their_password() {
#[cfg(not(target_arch = "wasm32"))]
fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
const PW: &str = "synthetic-pw-5522";
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("Secret.xlsx");
@@ -4544,8 +4537,7 @@ fn csv_as_text_keeps_identifier_columns_as_written() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cell_filename_follows_the_file_the_workbook_lives_in() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let nonce = unique_nonce();
let dir = std::env::temp_dir().join(format!("gridcraft-cellname-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let name = "=CELL(\"filename\",A1)";
@@ -5117,3 +5109,10 @@ fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
}
/// A per-test unique suffix for temp dir names.
#[cfg(not(target_arch = "wasm32"))]
fn unique_nonce() -> u64 {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}