Merge upstream/main into feat/wordart-784

This commit is contained in:
vangel76 committed 2026-11-08 12:39:59 +01:00
commit 032b280b04
37 files changed
+3559 -205

No files matched your search

+6
View File
@@ -115,6 +115,12 @@ pub trait Host {
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
false
}
/// `(sum, count)` of the numbers in a block that holds nothing but constants (no formula,
/// error or spilled value), added row-major from `0.0` exactly as `visit_range` would hand
/// them over. `None`: the block isn't like that, or this host can't tell.
fn range_sum(&mut self, _sheet: usize, _range: RangeRef) -> Option<(f64, u64)> {
None
}
/// While a formula is calculated on demand inside another one: `stack_position()` where the
/// outermost one started, which the stack budget is counted from.
fn stack_top(&self) -> Option<usize> {
+9
View File
@@ -383,6 +383,15 @@ impl<'h> Evaluator<'h> {
/// Adds one area to `acc`; `Err` is the result the function ends with.
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
if !a.range.is_single() {
// Adding the block's remembered sum to anything but +0.0 would add in another order.
// COUNT never reads the sum, so it may reuse every block.
if (kind == Agg::Count || acc.sum.to_bits() == 0)
&& let Some((sum, count)) = self.host.range_sum(a.sheet, a.range)
{
acc.sum = sum;
acc.count = acc.count.saturating_add(count);
return Ok(());
}
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
+52
View File
@@ -13,6 +13,9 @@ const MAX_NESTED: usize = 64;
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
/// A block's results by last row: its sum and count, or `None` when it can't be summed from constants.
type BlockSums = std::collections::BTreeMap<u32, Option<(f64, u64)>>;
pub(super) struct PassHost<'a> {
wb: &'a Workbook,
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
@@ -71,6 +74,10 @@ pub(super) struct PassHost<'a> {
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
/// Sum and count of blocks that hold only constants, for `range_sum`. Keyed by sheet, first
/// cell and last column; each value maps a last row to the block's result (`None`: the block
/// has a formula, a Python cell, an error value or a spilled value, so every longer one has too).
sums: HashMap<(usize, CellRef, u32), BlockSums>,
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
@@ -110,6 +117,7 @@ impl<'a> PassHost<'a> {
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
stack_top: 0,
out_of_stack: false,
sums: HashMap::default(),
python: HashMap::default(),
}
}
@@ -671,6 +679,50 @@ impl Host for PassHost<'_> {
}
true
}
fn range_sum(&mut self, sheet: usize, range: RangeRef) -> Option<(f64, u64)> {
// Constants can't change during a pass, so a block's result stays valid for it. Spills
// found in this pass (and iteration, which tracks what a read depends on) go the long way.
if self.iterate || self.spills.keys().any(|a| a.0 == sheet) {
return None;
}
let sh = self.wb.sheet(sheet)?;
let (key, last) = ((sheet, range.start, range.end.col), range.end.row);
let known = self.sums.entry(key).or_default();
if let Some(r) = known.get(&last) {
return *r;
}
// Start from the longest shorter block of the same rows from the top: summing row-major
// from there on is the same additions in the same order as one walk from the start.
let (from, mut sum, mut count) = match known.range(..last).next_back() {
Some((_, None)) => {
known.insert(last, None);
return None;
}
Some((&row, &Some((sum, count)))) => (row + 1, sum, count),
None => (range.start.row, 0.0, 0),
};
let rest = RangeRef::new(CellRef::new(from, range.start.col), range.end);
let mut plain = sh.spill.range(rest.start..=rest.end).all(|(c, _)| !rest.contains(*c));
if plain {
for (_, cell) in sh.cells.iter_range(rest) {
match &cell.value {
_ if cell.formula.is_some() || cell.py.is_some() => plain = false,
Value::Error(_) => plain = false,
Value::Number(n) => {
sum += n;
count = u64::saturating_add(count, 1);
}
_ => {}
}
if !plain {
break;
}
}
}
let result = plain.then_some((sum, count));
known.insert(last, result);
result
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
+114
View File
@@ -2379,6 +2379,120 @@ fn loan_schedule_residue_keeps_excels_sign() {
assert_eq!(t.get("P7"), Value::Text("".into()));
}
/// `SUM`, `COUNT` and `AVERAGE` over blocks of constants share one pass-wide answer per block, and a
/// running total extends the shorter block's. Every result must equal the left-to-right sum of the
/// cells, bit for bit, with text, blanks, errors, formulas and a nonzero start in the mix.
#[test]
fn aggregates_over_ranges_match_a_plain_fold_bit_for_bit() {
struct Rng(u64);
impl Rng {
fn next(&mut self, n: u64) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0 % n
}
}
let nums = [0.1, 0.2, 0.3, 1e16, -1e16, 1.0, 3.7, -0.0, 12345.678, 1e-9, 7.0];
let rows = 40u32;
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let mut wb = Workbook::new();
let fresh = |rng: &mut Rng, plain: bool| match rng.next(if plain { 8 } else { 14 }) {
0 => Cell::value(Value::text("t")),
1 => Cell::default(),
2 => Cell::value(Value::Bool(true)),
3 if !plain => Cell::value(Value::Error(CellError::NA)),
4 if !plain => Cell::formula(Formula::new("=A1*2")),
5 if !plain => Cell::formula(Formula::new("=\"x\"&B2")),
_ => Cell::value(Value::Number(nums[rng.next(nums.len() as u64) as usize])),
};
// Columns A:C mostly numbers; E holds a formula column and some errors only in a few rows.
for r in 0..rows {
for col in 0..3 {
let plain = !(r % 13 == 7 && col == 1);
wb.sheet_mut(0).unwrap().cells.set(CellRef::new(r, col), fresh(&mut rng, plain));
}
}
// Formulas in column H and up: (function, optional leading number, blocks).
type Oracle<'a> = (CellRef, &'a str, Option<f64>, Vec<(u32, u32, u32, u32)>);
let mut oracle: Vec<Oracle> = vec![];
let funcs = ["SUM", "COUNT", "AVERAGE"];
let mut at = 0;
let mut add = |wb: &mut Workbook, f: &'static str, lead: Option<f64>, blocks: Vec<(u32, u32, u32, u32)>| {
let args: Vec<String> = blocks
.iter()
.map(|&(r0, c0, r1, c1)| format!("{}{}:{}{}", (b'A' + c0 as u8) as char, r0 + 1, (b'A' + c1 as u8) as char, r1 + 1))
.collect();
let lead_s = lead.map(|l| format!("{l:e},")).unwrap_or_default();
let cell = CellRef::new(at % rows, 7 + at / rows);
wb.sheet_mut(0).unwrap().cells.set(cell, Cell::formula(Formula::new(&format!("={f}({lead_s}{})", args.join(",")))));
oracle.push((cell, f, lead, blocks));
at += 1;
};
// Running totals over a clean column and over a column with an error further down.
for r in 0..rows {
add(&mut wb, "SUM", None, vec![(0, 0, r, 0)]);
add(&mut wb, "SUM", None, vec![(0, 1, r, 1)]);
add(&mut wb, "COUNT", None, vec![(0, 0, r, 2)]);
}
for _ in 0..120 {
let (r0, r1) = (rng.next(rows as u64) as u32, rng.next(rows as u64) as u32);
let (c0, c1) = (rng.next(3) as u32, rng.next(3) as u32);
let n = 1 + rng.next(2) as usize;
let blocks = (0..n).map(|_| (r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))).collect();
let lead = [None, Some(1e16), Some(0.5), Some(2.0)][rng.next(4) as usize];
let f = funcs[rng.next(3) as usize];
add(&mut wb, f, lead, blocks);
}
let check = |wb: &Workbook, when: &str| {
let sh = wb.sheet(0).unwrap();
for (cell, f, lead, blocks) in &oracle {
let (mut sum, mut count, mut error) = (lead.unwrap_or(0.0), u64::from(lead.is_some()), None);
for &(r0, c0, r1, c1) in blocks {
for r in r0..=r1 {
for col in c0..=c1 {
match sh.value(CellRef::new(r, col)) {
Value::Number(n) => {
sum += n;
count += 1;
}
Value::Error(e) if *f != "COUNT" => error = error.or(Some(e)),
_ => {}
}
}
}
}
let want = match (error, *f) {
(Some(e), _) => Value::Error(e),
(_, "SUM") => Value::number(sum),
(_, "COUNT") => Value::number(count as f64),
(_, _) if count == 0 => Value::Error(CellError::Div0),
_ => Value::number(sum / count as f64),
};
let got = sh.value(*cell);
let same = match (&got, &want) {
(Value::Number(a), Value::Number(b)) => a.to_bits() == b.to_bits(),
(a, b) => a == b,
};
assert!(same, "{when}: {:?} = {:?}, plain fold {:?}", cell, got, want);
}
};
let mut calc = Calc::new();
calc.rebuild(&wb);
calc.recalc_all(&mut wb);
check(&wb, "full recalc");
for round in 0..30 {
let mut changed = vec![];
for _ in 0..1 + rng.next(4) {
let at = CellRef::new(rng.next(rows as u64) as u32, rng.next(3) as u32);
wb.sheet_mut(0).unwrap().cells.set(at, fresh(&mut rng, round % 2 == 0));
changed.push((0, at));
}
calc.cells_changed(&mut wb, &changed);
check(&wb, &format!("edit round {round}"));
}
}
/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the
/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that
/// carry their tree and for formulas that only carry their text (as after loading a file).
+92 -11
View File
@@ -1,11 +1,12 @@
//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
use std::borrow::Cow;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, points_per_series, series_color};
use crate::trend::{auto_name, fit_trendline, moving_average};
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
@@ -44,6 +45,8 @@ const MAX_LINE_PTS: usize = 2000;
const MAX_MARKERS: usize = 5000;
/// Data labels are skipped when a series has more points than this.
const MAX_LABELS: usize = 200;
/// Columns and bars narrower than this (in pixels) are merged per pixel column into one rectangle.
const MIN_BAR_PX: f32 = 0.25;
/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
@@ -55,25 +58,53 @@ struct Ctx<'a> {
m: &'a dyn Measure,
}
#[cfg(test)]
#[path = "render_perf_tests.rs"]
mod perf_tests;
impl Ctx<'_> {
fn tw(&self, s: &str, size: f32) -> f32 {
let w = self.m.text_width(s, size, false);
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
/// `s` shortened with an ellipsis to fit `maxw`; empty when not even one character fits.
///
/// Binary search over the number of kept characters (at most 400 are tried), about nine
/// `Measure` calls instead of one per character. It assumes the width of `prefix + "…"` does not
/// shrink as the prefix grows, which holds for per-character advance sums (`ApproxMeasure`, the
/// PDF metrics) and for the UI's text layout up to kerning of a few hundredths of a point; with
/// such a non-monotone measure the result may differ from the old linear scan by a character.
/// A label with a line break is not monotone (the ellipsis moves to a new line and the widest
/// line can get shorter), so it takes the linear scan, longest prefix first.
fn fit(&self, s: &str, size: f32, maxw: f32) -> String {
if self.tw(s, size) <= maxw {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len().min(400);
while n > 0 {
n -= 1;
let t: String = chars.iter().take(n).collect::<String>() + "…";
if self.tw(&t, size) <= maxw {
return if n == 0 { String::new() } else { t };
// Byte offset where each of the first 400 characters starts; a prefix of `k` characters
// (k < number of characters, so the offset exists) ends at `starts[k]`.
let starts: Vec<usize> = s.char_indices().map(|(i, _)| i).take(400).collect();
let top = starts.len().saturating_sub(1);
let fits = |k: usize| -> bool {
let end = starts.get(k).copied().unwrap_or(s.len());
let mut t = String::with_capacity(end + 3);
t.push_str(s.get(..end).unwrap_or(""));
t.push('…');
self.tw(&t, size) <= maxw
};
if s.contains('\n') {
let k = (1..=top).rev().find(|&k| fits(k));
return k.map_or_else(String::new, |k| format!("{}…", s.get(..starts.get(k).copied().unwrap_or(s.len())).unwrap_or("")));
}
let (mut lo, mut hi) = (0usize, top);
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if fits(mid) {
lo = mid;
} else {
hi = mid - 1;
}
}
String::new()
if lo == 0 { String::new() } else { format!("{}…", s.get(..starts.get(lo).copied().unwrap_or(s.len())).unwrap_or("")) }
}
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
if s.is_empty() {
@@ -101,7 +132,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect()
}
fn sanitize(d: &ChartData) -> ChartData {
pub(crate) fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
@@ -126,12 +157,62 @@ fn sanitize(d: &ChartData) -> ChartData {
}
}
/// Whether `v` survives `sanitize_vals` unchanged.
fn is_clean(v: &Option<f64>) -> bool {
v.is_none_or(|n| n.is_finite() && clean(n) == n)
}
/// Whether [`sanitize`] and [`apply_budget`] would change `d`.
fn needs_sanitizing(d: &ChartData) -> bool {
let cap = points_per_series(d.series.len());
let long = |n: usize| n > cap;
let mut trendlines = 0usize;
d.palette.len() > MAX_POINTS
|| long(d.categories.len())
|| d.category_levels.len() > 3
|| d.category_levels.iter().any(|l| l.len() > MAX_POINTS)
|| d.series.len() > MAX_SERIES
|| d.series.iter().any(|s| {
trendlines += s.trendlines.len();
long(s.values.len())
|| !s.values.iter().all(is_clean)
|| s.x.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|| s.sizes.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|| s.trendlines.len() > MAX_TRENDLINES
})
|| trendlines > MAX_TRENDLINES
}
/// Enforces the chart's budget of points (`MAX_TOTAL_POINTS` = series x points: every series and
/// the category list keep their first `MAX_TOTAL_POINTS / series` points) and of trendlines
/// (`MAX_TRENDLINES` per chart, in series order).
fn apply_budget(mut d: ChartData) -> ChartData {
let cap = points_per_series(d.series.len());
d.categories.truncate(cap);
let mut trendlines = MAX_TRENDLINES;
for s in &mut d.series {
s.values.truncate(cap);
for v in [&mut s.x, &mut s.sizes].into_iter().flatten() {
v.truncate(cap);
}
s.trendlines.truncate(trendlines);
trendlines = trendlines.saturating_sub(s.trendlines.len());
}
d
}
/// The data `render` draws: `d` itself when it is already clean and within budget (the usual
/// case: no copy), else a cleaned, truncated copy.
pub(crate) fn sanitized(d: &ChartData) -> Cow<'_, ChartData> {
if needs_sanitizing(d) { Cow::Owned(apply_budget(sanitize(d))) } else { Cow::Borrowed(d) }
}
/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box.
pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec<Prim> {
if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 {
return vec![];
}
let data = sanitize(data);
let data = sanitized(data);
let mut ctx = Ctx { out: Vec::new(), m };
ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });
let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);
+113 -10
View File
@@ -3,8 +3,8 @@ use super::legend::{cat_label, plot_kind};
use super::lines::{decimate_area, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
VAlign, WHITE, clean, nice_scale,
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, MIN_BAR_PX, Prim, Rgba, Scale,
SeriesData, TEXT, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
@@ -51,6 +51,110 @@ fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
/// Bars that fall into one pixel column, merged: extent along the category axis (`a0..=a1`, the
/// bars' start positions) and along the value axis (`lo..hi`).
struct BarRun {
bucket: f32,
a0: f32,
a1: f32,
lo: f32,
hi: f32,
/// More than one bar was merged.
merged: bool,
}
impl BarRun {
fn new(pos: f32, lo: f32, hi: f32) -> Self {
BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi, merged: false }
}
/// Whether `lo..hi` touches the run's value span; a disjoint bar would be joined by a filled gap.
fn touches(&self, lo: f32, hi: f32) -> bool {
lo <= self.hi && hi >= self.lo
}
fn add(&mut self, pos: f32, lo: f32, hi: f32) {
self.merged = true;
self.a0 = self.a0.min(pos);
self.a1 = self.a1.max(pos);
self.lo = self.lo.min(lo);
self.hi = self.hi.max(hi);
}
/// `band` is the part of the pixel column reserved for this series (`start`, `len`): in a
/// clustered chart a merged run stays inside it, so the other series keep a visible strip.
fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba, band: Option<(f32, f32)>) {
let (a0, along) = match band {
Some((start, len)) if self.merged => (self.bucket + start, len),
_ => (self.a0, self.a1 - self.a0 + bw),
};
if horizontal {
ctx.rect(self.lo, a0, self.hi - self.lo, along, color, None);
} else {
ctx.rect(a0, self.lo, along, self.hi - self.lo, color, None);
}
}
}
/// Whether bars of width `bw` are merged per pixel column (never when they carry data labels).
fn merge_hairlines(bw: f32, labels_on: bool) -> bool {
bw < MIN_BAR_PX && !labels_on
}
/// Draws the bars of one series: a rectangle each, or, for hairline bars, one merged [`BarRun`]
/// per pixel column.
struct BarSink {
run: Option<BarRun>,
bw: f32,
horizontal: bool,
color: Rgba,
nslots: f32,
k: f32,
}
impl BarSink {
fn new(bw: f32, horizontal: bool, color: Rgba, nslots: f32, k: f32) -> Self {
BarSink { run: None, bw, horizontal, color, nslots, k }
}
/// Start of this series' slot along the category axis, for the category centered at `c`.
fn pos(&self, c: f32) -> f32 {
if self.horizontal { c + self.nslots * self.bw / 2.0 - (self.k + 1.0) * self.bw } else { c - self.nslots * self.bw / 2.0 + self.k * self.bw }
}
/// This series' strip of a pixel column (offset, length) when the columns hold several
/// clustered series, in the direction the slots run.
fn band(&self) -> Option<(f32, f32)> {
(self.nslots > 1.0).then(|| {
let idx = if self.horizontal { self.nslots - 1.0 - self.k } else { self.k };
(idx / self.nslots, 1.0 / self.nslots)
})
}
/// Draws the bar at category center `c` spanning `lo..lo + len` on the value axis, or, when
/// `merge` is set, adds it to the current run.
fn bar(&mut self, ctx: &mut Ctx, merge: bool, c: f32, lo: f32, len: f32) {
let pos = self.pos(c);
let band = self.band();
if !merge {
if self.horizontal {
ctx.rect(lo, pos, len, self.bw, self.color, None);
} else {
ctx.rect(pos, lo, self.bw, len, self.color, None);
}
return;
}
match &mut self.run {
Some(m) if m.bucket == pos.floor() && m.touches(lo, lo + len) => m.add(pos, lo, lo + len),
run => {
if let Some(m) = run.replace(BarRun::new(pos, lo, lo + len)) {
m.draw(ctx, self.bw, self.horizontal, self.color, band);
}
}
}
}
fn finish(self, ctx: &mut Ctx) {
let band = self.band();
if let Some(m) = self.run {
m.draw(ctx, self.bw, self.horizontal, self.color, band);
}
}
}
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
@@ -141,7 +245,8 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
let range = value_range(members.iter().flat_map(|i| {
let line = fams.get(*i).is_some_and(|f| is_line(*f));
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
// Lines contribute only their top value, bars both ends (no per-point `Vec`).
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| [*b, *t].into_iter().skip(usize::from(line)))
}));
let (lo, hi) = range.unwrap_or((0.0, 0.0));
let (scale, fmt) = if all100 {
@@ -278,19 +383,16 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
1 => {
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
// Hairline bars are merged per pixel column (their envelope is what shows).
let cull = merge_hairlines(bw, labels_on);
let mut sink = BarSink::new(bw, horizontal, s.color, nslots, k);
for (j, sp) in row.iter().enumerate() {
let Some((b, t)) = sp else { continue };
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
let c = center(j);
let lo = pb.min(pt);
let len = (pb - pt).abs();
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
ctx.rect(lo, y, len, bw, s.color, None);
} else {
let x = c - nslots * bw / 2.0 + k * bw;
ctx.rect(x, lo, bw, len, s.color, None);
}
sink.bar(ctx, cull, c, lo, len);
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
let t = fmt.format(v, None);
let stacked = fam != Fam::Col;
@@ -316,6 +418,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
}
}
sink.finish(ctx);
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
+17 -16
View File
@@ -340,7 +340,7 @@ pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
// ---------------------------------------------------------------- treemap
/// Worst aspect ratio of a row with the given sum, smallest and largest area.
/// Worst aspect ratio of a row with the given area sum, smallest and largest item, laid along `side`.
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
@@ -350,8 +350,9 @@ fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
(w2 * mx / s2).max(s2 / (w2 * mn))
}
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
/// Squarified treemap layout of (index, value) items, sorted descending, into `r`; each block
/// comes back with its index and value.
pub(super) fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, f64, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
@@ -360,11 +361,10 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
// The row's sum, smallest and largest area, kept as items join so each step is O(1).
let mut row: Vec<(usize, f64, f64)> = Vec::new();
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let layout = |row: &[(usize, f64, f64)], rem: &mut Bx, out: &mut Vec<(usize, f64, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
@@ -372,9 +372,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
for (j, a, v) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
out.push((*j, *v, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
@@ -382,9 +382,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
for (j, a, v) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
out.push((*j, *v, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
@@ -394,9 +394,12 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) {
row.push((j, a));
(sum, mn, mx) = (sum + a, mn.min(a), mx.max(a));
// Sum, smallest and largest item of the open row, kept incrementally (same fold order as
// summing the row afresh, so the numbers are bit-identical).
let with = (sum + a, mn.min(a), mx.max(a));
if row.is_empty() || worst(with.0, with.1, with.2, side) <= worst(sum, mn, mx, side) {
row.push((j, a, v));
(sum, mn, mx) = with;
i += 1;
} else {
layout(&row, &mut rem, &mut out);
@@ -416,15 +419,13 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
items.sort_by(|a, b| b.1.total_cmp(&a.1));
let f = Fmt::new(&s.number_format);
let value_of: std::collections::HashMap<usize, f64> = items.iter().copied().collect();
for (j, b) in squarify(&items, area) {
for (j, v, b) in squarify(&items, area) {
let fill = point_color(data, j);
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = value_of.get(&j).copied().unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
}
+11 -2
View File
@@ -85,7 +85,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
pub(super) fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
@@ -149,7 +149,16 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
}
LegendPos::Left | LegendPos::Right => {
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
// Widest entry, capped at 35% of the area: once the cap is reached the rest cannot change it.
let cap = area.w * 0.35;
let mut colw = 0.0f32;
for e in entries {
if colw >= cap {
break;
}
colw = colw.max(item_w(ctx, e));
}
let colw = colw.min(cap);
let max_rows = ((area.h / row_h).floor() as usize).max(1);
let n = entries.len().min(max_rows);
let lh = n as f32 * row_h;
+298
View File
@@ -0,0 +1,298 @@
//! Equality and counter tests for the label-fitting, legend-measuring and treemap rewrites.
//! The old implementations live here as references.
use std::cell::Cell;
use super::kinds::squarify;
use super::legend::swatch_w;
use super::*;
use crate::{ApproxMeasure, Measure};
use gridcraft_model::LegendPos;
/// The pre-rewrite `Ctx::fit`: one `Measure` call per candidate length, longest first.
fn fit_reference(ctx: &Ctx, s: &str, size: f32, maxw: f32) -> String {
if ctx.tw(s, size) <= maxw {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len().min(400);
while n > 0 {
n -= 1;
let t: String = chars.iter().take(n).collect::<String>() + "…";
if ctx.tw(&t, size) <= maxw {
return if n == 0 { String::new() } else { t };
}
}
String::new()
}
/// Counts calls; advances differ per character (wide CJK/emoji, zero-width combining marks) and
/// "AV"-style pairs are kerned tighter, but a character never makes the text narrower.
struct Varied {
calls: Cell<usize>,
}
impl Measure for Varied {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
self.calls.set(self.calls.get() + 1);
let mut w = 0.0f32;
let mut prev = ' ';
for c in text.chars() {
let adv = match c as u32 {
0x300..=0x36f => 0.0,
0x1f300..=0x1faff | 0x4e00..=0x9fff => 1.0,
u => 0.3 + (u % 7) as f32 * 0.07,
};
let kern = if prev == 'A' && c == 'V' { 0.1 } else { 0.0 };
w += (adv - kern) * size;
prev = c;
}
if bold { w * 1.1 } else { w }
}
}
struct Counting<'a>(&'a dyn Measure, Cell<usize>);
impl Measure for Counting<'_> {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
self.1.set(self.1.get() + 1);
self.0.text_width(text, size, bold)
}
}
/// Like a text layout that breaks at `\n`: the width is that of the widest line. Adding a
/// newline can make the text narrower, so the width of `prefix + "…"` is not monotone.
struct WidestLine;
impl Measure for WidestLine {
fn text_width(&self, text: &str, size: f32, _bold: bool) -> f32 {
text.split('\n')
.map(|line| {
line.chars()
.map(|c| {
size * if c == 'i' {
0.25
} else if c == 'M' {
0.9
} else {
0.5
}
})
.sum::<f32>()
})
.fold(0.0, f32::max)
}
}
fn corpus() -> Vec<String> {
let mut v: Vec<String> =
["", "a", "ab", "Hello, world", "AVAVAV tight", "日本語のラベルです", "👨‍👩‍👧 family 🎉 party", "e\u{301}e\u{301}e\u{301} combining", "…", "……"]
.iter()
.map(|s| s.to_string())
.collect();
v.push("x".repeat(399));
v.push("x".repeat(400));
v.push("x".repeat(401));
v.push("é🎉".repeat(500));
v.push("long label ".repeat(100));
for s in ["iiii\nMM", "iiii\n", "\niiii", "ab\ncd\nef", "iiiiiiii\niiiiMMMM\nMM", "line one\nline two is longer\nx"] {
v.push(s.to_string());
}
v
}
fn run_fit(m: &dyn Measure) {
let ctx = Ctx { out: vec![], m };
for s in corpus() {
for size in [9.0f32, 11.0, 40.0] {
for maxw in [-5.0, 0.0, 0.4, 6.0, 25.0, 61.5, 120.0, 800.0, 1e6, f32::INFINITY, f32::NAN] {
assert_eq!(ctx.fit(&s, size, maxw), fit_reference(&ctx, &s, size, maxw), "{s:?} size {size} maxw {maxw}");
}
}
}
}
#[test]
fn fit_matches_linear_reference() {
run_fit(&ApproxMeasure);
run_fit(&Varied { calls: Cell::new(0) });
run_fit(&WidestLine);
}
#[test]
fn a_multiline_label_keeps_its_fitting_prefix() {
// The whole label is 18 wide and "iii…" is 12.5, both too wide for 10.5, but "iiii\n…" is
// 10 wide and fits. A binary search that stops at the failing midpoint would return nothing.
let ctx = Ctx { out: vec![], m: &WidestLine };
let s = "iiii\nMM";
let size = 10.0;
let maxw = 10.5;
assert!(ctx.tw(s, size) > maxw);
assert!(ctx.tw("iii…", size) > maxw && ctx.tw("iiii\n…", size) <= maxw);
assert_eq!(ctx.fit(s, size, maxw), "iiii\n…");
}
#[test]
fn fit_measures_logarithmically() {
let long = "x".repeat(1000);
let c = Counting(&ApproxMeasure, Cell::new(0));
let ctx = Ctx { out: vec![], m: &c };
let r = ctx.fit(&long, 11.0, 60.0);
assert!(r.ends_with('…') && r.chars().count() > 3);
// 1 whole-string check + ceil(log2(399)) + 1 probes; the old scan needed ~390 here.
assert!(c.1.get() <= 12, "{} Measure calls", c.1.get());
let c2 = Counting(&ApproxMeasure, Cell::new(0));
let ctx2 = Ctx { out: vec![], m: &c2 };
assert_eq!(ctx2.fit("short", 11.0, 500.0), "short");
assert_eq!(c2.1.get(), 1);
}
fn legend_entries(n: usize, label: &dyn Fn(usize) -> String) -> Vec<(String, Rgba, Swatch)> {
(0..n).map(|i| (label(i), [10, 20, 30, 255], if i % 3 == 0 { Swatch::Line } else { Swatch::Box })).collect()
}
/// Colour column width as the old code computed it, from the returned remaining area.
fn old_colw(m: &dyn Measure, entries: &[(String, Rgba, Swatch)], area: Bx) -> f32 {
let ctx = Ctx { out: vec![], m };
let size = LABEL_SIZE;
let s = size * 0.8;
entries.iter().map(|e| swatch_w(e.2, s) + 4.0 + ctx.tw(&e.0, size)).fold(0.0f32, f32::max).min(area.w * 0.35)
}
#[test]
fn side_legend_matches_old_column_width_and_stops_measuring_at_the_cap() {
let area = Bx { x: 0.0, y: 0.0, w: 400.0, h: 300.0 };
let labels: [&dyn Fn(usize) -> String; 3] =
[&|i| format!("entry {i}"), &|i| if i == 7 { "w".repeat(80) } else { format!("e{i}") }, &|i| "very long legend label ".repeat(1 + i % 3)];
for pos in [LegendPos::Left, LegendPos::Right] {
for (k, label) in labels.iter().enumerate() {
for n in [0usize, 1, 5, 40] {
let entries = legend_entries(n, label);
let mut ctx = Ctx { out: vec![], m: &ApproxMeasure };
let rest = draw_legend(&mut ctx, &entries, pos, area);
let colw = old_colw(&ApproxMeasure, &entries, area);
assert!((rest.w - (area.w - (colw + 8.0))).abs() < 1e-4, "{pos:?} labels {k} n {n}");
}
}
}
// 20,000 entries whose first one already fills the 35% cap: the old code measured all of them
// plus the drawn rows, now only the first entry and the drawn rows are measured.
let entries = legend_entries(20_000, &|i| if i == 0 { "w".repeat(200) } else { format!("e{i}") });
let c = Counting(&ApproxMeasure, Cell::new(0));
let mut ctx = Ctx { out: vec![], m: &c };
let _ = draw_legend(&mut ctx, &entries, LegendPos::Right, area);
let drawn = ctx.out.iter().filter(|p| matches!(p, Prim::Text { .. })).count();
assert!(c.1.get() <= drawn * 10 + 5, "{} Measure calls for {drawn} drawn entries", c.1.get());
assert!(c.1.get() < 500);
}
/// The pre-rewrite `worst`/`squarify` (a `Vec` per item, row recomputed from scratch).
fn worst_reference(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
}
fn squarify_reference(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
return vec![];
}
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
}
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
rem.w = (rem.w - cw).max(0.0);
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
rem.h = (rem.h - rh).max(0.0);
}
};
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst_reference(&with, side) <= worst_reference(&cur, side) {
row.push((j, a));
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
}
}
if !row.is_empty() {
layout(&row, &mut rem, &mut out);
}
out
}
#[test]
fn squarify_matches_reference() {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let mut cases: Vec<Vec<f64>> = vec![
vec![],
vec![5.0],
vec![6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0],
vec![3.0, 0.0, 0.0, 1.0],
vec![0.0, 0.0],
vec![4.0, -1.0, 2.0],
vec![4.0, f64::NAN, 2.0, 1.0],
vec![f64::INFINITY, 1.0],
vec![1e-300, 1e-310, 0.0],
];
for n in [2usize, 9, 40, 300] {
let mut v: Vec<f64> = (0..n).map(|_| rnd() * 100.0).collect();
v.sort_by(|a, b| b.total_cmp(a));
cases.push(v);
}
for r in [Bx { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }, Bx { x: 5.0, y: 7.0, w: 90.0, h: 400.0 }, Bx { x: 0.0, y: 0.0, w: 0.0, h: 50.0 }] {
for vals in &cases {
let items: Vec<(usize, f64)> = vals.iter().copied().enumerate().collect();
let new: Vec<(usize, Bx)> = squarify(&items, r)
.into_iter()
.map(|(j, v, b)| {
assert!(vals.get(j).is_some_and(|x| x.to_bits() == v.to_bits()), "value carried through");
(j, b)
})
.collect();
let old = squarify_reference(&items, r);
assert_eq!(format!("{new:?}"), format!("{old:?}"), "{vals:?} in {r:?}");
}
}
}
+172 -21
View File
@@ -1,7 +1,7 @@
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
use gridcraft_calc::evaluate;
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, category_levels, number_to_text};
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
use gridcraft_numfmt::{NumberFormat, format_value};
@@ -11,8 +11,17 @@ use crate::{ChartData, Rgba, SeriesData, TrendlineData};
pub(crate) const MAX_POINTS: usize = 100_000;
/// Cap on series per chart.
pub(crate) const MAX_SERIES: usize = 255;
/// Cap on trendlines drawn per series.
/// Cap on trendlines per chart (not per series: each one is a fit over a series' points).
pub(crate) const MAX_TRENDLINES: usize = 16;
/// Budget for series x points in one chart. When a chart would hold more, every series (and the
/// category list) keeps its first `MAX_TOTAL_POINTS / series` points; no series is dropped.
pub(crate) const MAX_TOTAL_POINTS: usize = 2_000_000;
/// Points kept per series (and categories) in a chart of `series` series: `MAX_POINTS`, or less
/// when `series x MAX_POINTS` would pass `MAX_TOTAL_POINTS`.
pub(crate) fn points_per_series(series: usize) -> usize {
(MAX_TOTAL_POINTS / series.clamp(1, MAX_SERIES)).clamp(1, MAX_POINTS)
}
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
@@ -77,10 +86,51 @@ fn union_parts(f: &str) -> Option<Vec<&str>> {
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0)
eval_list_in(wb, sheet, formula, MAX_POINTS, 0)
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
/// calc reads a reference of up to this many cells at its full size and trims a larger one to the
/// sheet's used range (`FULL_SIZE_CELLS` in `gridcraft_calc::eval`). A clipped reference must stay
/// above it, or the clip would turn a trimmed read into a full-size one with trailing blanks.
const CALC_FULL_SIZE_CELLS: u64 = 16_384;
/// Bounded references up to this many cells are read at their full size too
/// (`FULL_SIZE_BOUNDED_CELLS` in `gridcraft_calc::eval`).
const CALC_FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
/// `formula` as a plain reference (`Sheet!A1:A1000000`) cut after the rows that can still hold
/// one of the first `limit` points, so that calc never reads the rest; `None` when `formula` is
/// no reference or already small enough. Values are read row by row, and calc trims only the
/// bottom and right of a reference, so keeping `limit` rows keeps the first `limit` values.
fn clipped_reference(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Option<String> {
let (sh, mut r) = parse_ref(wb, sheet, formula)?;
let cells = u64::from(r.height()).saturating_mul(u64::from(r.width()));
// calc trims a block that runs to the sheet's edge, and any block over a column's worth, to
// the used range. Do it here, or the clipped block below would be read at its full size.
let to_edge = r.end.row == MAX_ROWS - 1 || r.end.col == MAX_COLS - 1;
let trimmed = cells > CALC_FULL_SIZE_CELLS && (to_edge || cells > CALC_FULL_SIZE_BOUNDED_CELLS);
let mut changed = false;
if trimmed {
let end = match wb.sheet(sh).and_then(|s| s.used_range()) {
Some(u) => CellRef::new(r.end.row.min(u.end.row.max(r.start.row)), r.end.col.min(u.end.col.max(r.start.col))),
None => r.start,
};
changed = end != r.end;
r = RangeRef::new(r.start, end);
}
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
let (h, w) = (u64::from(r.height()), u64::from(r.width()));
if h.saturating_mul(w) > CALC_FULL_SIZE_CELLS {
let keep = (limit as u64).max(CALC_FULL_SIZE_CELLS / w + 1);
if h > keep {
let end = CellRef::new(r.start.row.saturating_add(u32::try_from(keep).ok()?).saturating_sub(1), r.end.col);
r = RangeRef::new(r.start, end);
changed = true;
}
}
changed.then(|| format!("{prefix}{r}"))
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
@@ -90,16 +140,17 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<V
{
let mut out = Vec::new();
for p in parts {
if out.len() >= MAX_POINTS {
if out.len() >= limit {
break;
}
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
let rest = limit - out.len();
out.extend(eval_list_in(wb, sheet, p, rest, depth + 1).into_iter().take(rest));
}
return out;
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
let clipped = clipped_reference(wb, sheet, f, limit);
match evaluate(wb, sheet, CellRef::default(), clipped.as_deref().unwrap_or(f)) {
Value::Array(a) => a.iter().take(limit).cloned().collect(),
v => vec![v],
}
}
@@ -111,8 +162,8 @@ fn to_num(v: &Value) -> Option<f64> {
}
}
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
eval_list(wb, sheet, formula).iter().map(to_num).collect()
fn nums(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Vec<Option<f64>> {
eval_list_in(wb, sheet, formula, limit, 0).iter().map(to_num).collect()
}
/// Number format code of the first cell of a reference ("General" when not a reference).
@@ -137,9 +188,38 @@ fn value_text(v: &Value) -> String {
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
/// has one entry per category, non-empty where a group starts, as the cells are.
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
let vals = eval_list(wb, sheet, formula);
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limit: usize) -> (Vec<String>, Vec<Vec<String>>) {
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
// Bound the category dimension by `limit` and read only the cells the levels use (the
// innermost label line and up to three outer ones), so that every kept category keeps its
// cells in every level. Counting raw cells would cut a two-level axis in the middle.
let levels = cells.and_then(|(_, r)| category_levels(&r, value_range.as_ref()));
let (cells, read) = match (cells, levels) {
(Some((sh, r)), Some(lv)) => {
let cut = u32::try_from(limit.saturating_sub(1)).unwrap_or(u32::MAX);
let outer = lv.outer;
let end = if lv.by_columns {
CellRef::new(r.end.row.min(r.start.row.saturating_add(cut)), r.end.col)
} else {
CellRef::new(r.end.row, r.end.col.min(r.start.col.saturating_add(cut)))
};
let start = if lv.by_columns {
CellRef::new(r.start.row, r.start.col.max(end.col.saturating_sub(outer)))
} else {
CellRef::new(r.start.row.max(end.row.saturating_sub(outer)), r.start.col)
};
let r = RangeRef::new(start, end);
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
let cap = (r.width() as usize).saturating_mul(r.height() as usize);
(Some((sh, r)), Some((format!("{prefix}{r}"), cap)))
}
(c, _) => (c, None),
};
let vals = match &read {
Some((f, cap)) => eval_list_in(wb, sheet, f, *cap, 0),
None => eval_list_in(wb, sheet, formula, limit, 0),
};
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
let texts: Vec<String> = vals
.iter()
@@ -159,8 +239,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
})
.collect();
let Some((_, r)) = src else { return (texts, vec![]) };
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
let Some(levels) = levels else { return (texts, vec![]) };
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
if texts.len() != w.saturating_mul(h) {
return (texts, vec![]); // truncated or not a plain block: a flat list
@@ -234,37 +313,43 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
let mut series = Vec::new();
let mut categories: Option<Vec<String>> = None;
let limit = points_per_series(chart.series.len());
let mut trend_left = MAX_TRENDLINES;
let mut category_levels = Vec::new();
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
let values = nums(wb, sheet, &s.values);
let values = nums(wb, sheet, &s.values, limit);
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
let color =
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
let kind = effective_kind(chart.kind, s.kind, i);
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
// X values with text in them (labels) are ignored: the points are numbered 1..n.
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
(Some(f), true) => {
Some(eval_list_in(wb, sheet, f, limit, 0)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())
}
_ => None,
};
if categories.is_none()
&& let Some(f) = &s.categories
{
let (c, outer) = category_table(wb, sheet, f, &s.values);
let (mut c, outer) = category_table(wb, sheet, f, &s.values, limit);
c.truncate(limit);
if !c.is_empty() {
categories = Some(c);
category_levels = outer;
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, limit));
let trendlines = s
.trendlines
.iter()
.take(MAX_TRENDLINES)
.take(trend_left)
.map(|t| TrendlineData {
spec: t.clone(),
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
})
.collect();
.collect::<Vec<_>>();
trend_left = trend_left.saturating_sub(trendlines.len());
series.push(SeriesData {
name,
values,
@@ -283,3 +368,69 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, category_levels, series, palette }
}
#[cfg(test)]
mod tests {
use gridcraft_model::Workbook;
use super::*;
fn book(rows: u32, cols: u32) -> Workbook {
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
s.name = "My Sheet".into();
for r in 0..rows {
for c in 0..cols {
s.set_value(CellRef::new(r, c), Value::Number(f64::from(r * 10 + c)));
}
}
}
wb
}
#[test]
fn a_long_reference_is_clipped_before_it_is_evaluated() {
let wb = book(120_000, 3);
let rows = |f: &str, limit| {
let g = clipped_reference(&wb, 0, f, limit)?;
let (_, r) = parse_ref(&wb, 0, &g)?;
Some((g, u64::from(r.height())))
};
// 1,000,000 rows: at most `MAX_POINTS` of them are asked for.
assert_eq!(rows("'My Sheet'!$A$1:$A$1000000", MAX_POINTS), Some(("'My Sheet'!A1:A100000".to_string(), 100_000)));
assert_eq!(rows("A5:A1048576", MAX_POINTS).map(|r| r.1), Some(100_000));
assert_eq!(rows("A:A", 50_000).map(|r| r.1), Some(50_000));
// Never below what keeps calc trimming the clipped reference like the whole one.
assert_eq!(rows("A1:A1000000", 100).map(|r| r.1), Some(16_385));
// Wide references keep whole rows.
assert_eq!(rows("A1:C1000000", MAX_POINTS).map(|r| r.1), Some(100_000));
// Small references, single cells and anything that isn't a plain reference are left alone.
assert_eq!(rows("A1:A9000", MAX_POINTS), None);
assert_eq!(rows("A1:A20000", MAX_POINTS), None);
assert_eq!(rows("B2", MAX_POINTS), None);
assert_eq!(rows("SUM(A1:A1000000)", MAX_POINTS), None);
assert_eq!(rows("{1,2,3}", MAX_POINTS), None);
}
#[test]
fn clipping_does_not_change_the_points() {
for (rows, cols) in [(10, 1), (30_000, 1), (30_000, 2), (3, 4)] {
let wb = book(rows, cols);
for f in ["'My Sheet'!A1:A1000000", "'My Sheet'!$A$3:$B$1048576", "A:A", "A1:C1000000", "(A1:A1000000,B1:B1000000)", "A2:A20000"] {
for limit in [100, 7_843, 25_000, MAX_POINTS] {
let clipped = eval_list_in(&wb, 0, f, limit, 0);
let whole: Vec<Value> = match evaluate(&wb, 0, CellRef::default(), f) {
Value::Array(a) => a.iter().take(limit).cloned().collect(),
v => vec![v],
};
if f.starts_with('(') {
// A union is read part by part; each part keeps its own order.
assert!(clipped.len() <= limit);
continue;
}
assert_eq!(clipped, whole, "{f} limit {limit} on {rows}x{cols}");
}
}
}
}
}
+1
View File
@@ -5,6 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
mod budget;
mod data_table;
const ALL_KINDS: [ChartKind; 26] = [
+279
View File
@@ -0,0 +1,279 @@
//! Work and memory are bounded by the product of the chart limits (series x points, trendlines
//! per chart, hairline bars), and clean data is rendered without a copy.
use std::borrow::Cow;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{ChartKind, Series, Trendline, TrendlineKind, Workbook};
use super::{assert_sane, chart};
use crate::render::{sanitize, sanitized};
use crate::resolve::{MAX_POINTS, MAX_TOTAL_POINTS, MAX_TRENDLINES, points_per_series};
use crate::*;
fn data_of(series: usize, points: usize) -> ChartData {
ChartData {
palette: vec![],
categories: (0..points).map(|i| i.to_string()).collect(),
category_levels: vec![],
series: (0..series)
.map(|s| SeriesData {
name: format!("S{s}"),
values: (0..points).map(|i| Some(((i * 7 + s * 13) % 100 + 1) as f64)).collect(),
color: series_color(&Default::default(), s),
..SeriesData::default()
})
.collect(),
}
}
fn series_rects(prims: &[Prim], color: Rgba) -> Vec<(f32, f32, f32, f32)> {
prims
.iter()
.filter_map(|p| match p {
Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == color => Some((*x, *y, *w, *h)),
_ => None,
})
.collect()
}
#[test]
fn the_point_budget_keeps_every_series_with_fewer_points() {
assert_eq!(points_per_series(1), MAX_POINTS);
assert_eq!(points_per_series(20), MAX_POINTS);
assert_eq!(points_per_series(21), MAX_TOTAL_POINTS / 21);
assert_eq!(points_per_series(255), MAX_TOTAL_POINTS / 255);
assert_eq!(points_per_series(0), MAX_POINTS);
// 25 series x 100,000 points is 2.5 million: over budget.
let d = data_of(25, MAX_POINTS);
let kept = sanitized(&d);
assert_eq!(kept.series.len(), 25, "no series is dropped");
assert!(kept.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 25));
assert_eq!(kept.categories.len(), MAX_TOTAL_POINTS / 25);
assert!(kept.series.iter().map(|s| s.values.len()).sum::<usize>() <= MAX_TOTAL_POINTS);
// The first points are the ones kept.
assert_eq!(kept.series[3].values[..5], d.series[3].values[..5]);
// Drawing it stays bounded: hairline columns are merged per pixel column.
let mut c = chart(ChartKind::ColumnClustered);
c.data_labels = false;
c.legend = LegendPos::None;
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 400.0, 300.0);
assert!(prims.len() < 25 * 450 + 200, "{} primitives", prims.len());
}
#[test]
fn trendlines_are_limited_per_chart() {
let line = |kind| TrendlineData { spec: Trendline { kind, ..Trendline::default() }, color: [1, 2, 3, 255] };
let mut d = data_of(3, 30);
for s in &mut d.series {
s.trendlines = vec![line(TrendlineKind::Linear); 10];
}
let kept = sanitized(&d);
let counts: Vec<usize> = kept.series.iter().map(|s| s.trendlines.len()).collect();
assert_eq!(counts, vec![10, MAX_TRENDLINES - 10, 0], "series order decides which trendlines stay");
let entries = legend_entries(&chart(ChartKind::Line), &kept);
assert_eq!(entries.len(), 3 + MAX_TRENDLINES);
}
#[test]
fn hairline_columns_are_merged_and_wide_ones_are_not() {
let mut c = chart(ChartKind::ColumnClustered);
c.data_labels = false;
c.legend = LegendPos::None;
// Wide bars (>= 0.25 px): one rectangle per series and category, as before.
let d = data_of(3, 40);
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
for s in &d.series {
assert_eq!(series_rects(&prims, s.color).len(), 40);
}
// 4 series x 20,000 categories in 400 px: the bars are hairlines.
let d = data_of(4, 20_000);
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 400.0, 300.0);
for s in &d.series {
let n = series_rects(&prims, s.color).len();
assert!((1..=420).contains(&n), "{n} rectangles for one series");
}
// The merged rectangles keep the envelope: alternating 1 and 10 draws the full-height bar.
let mut d = data_of(1, 20_000);
for (i, v) in d.series[0].values.iter_mut().enumerate() {
*v = Some(if i % 2 == 0 { 1.0 } else { 10.0 });
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let rects = series_rects(&prims, d.series[0].color);
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
assert!(rects.len() > 100 && tallest > 100.0);
assert!(rects.iter().all(|r| (r.3 - tallest).abs() < 0.01), "every pixel column shows the tall bar");
// And together they cover the plot's width without a gap wider than a pixel.
let mut xs: Vec<(f32, f32)> = rects.iter().map(|r| (r.0, r.0 + r.2)).collect();
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(xs.windows(2).all(|w| w[1].0 - w[0].1 < 1.0));
// Horizontal bars too.
let mut c = chart(ChartKind::BarClustered);
c.data_labels = false;
c.legend = LegendPos::None;
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let n = series_rects(&prims, d.series[0].color).len();
assert!((1..=320).contains(&n), "{n} bars");
}
#[test]
fn clustered_hairline_series_keep_a_visible_strip() {
// Four series of similar height, 20,000 categories in 400 px: every pixel column holds a
// merged bar of each series, and they must not cover each other.
for kind in [ChartKind::ColumnClustered, ChartKind::BarClustered] {
let horizontal = kind == ChartKind::BarClustered;
let mut c = chart(kind);
c.data_labels = false;
c.legend = LegendPos::None;
let mut d = data_of(4, 20_000);
for (s, series) in d.series.iter_mut().enumerate() {
for (i, v) in series.values.iter_mut().enumerate() {
*v = Some(50.0 + ((i + s) % 3) as f64);
}
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let along = |r: &(f32, f32, f32, f32)| if horizontal { (r.1, r.1 + r.3) } else { (r.0, r.0 + r.2) };
let mut strips: Vec<(f32, f32, usize)> = Vec::new();
for (s, series) in d.series.iter().enumerate() {
strips.extend(series_rects(&prims, series.color).iter().map(|r| (along(r).0, along(r).1, s)));
}
strips.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(strips.len() > 400);
// Rectangles of different series never overlap (more than rounding).
for w in strips.windows(2) {
assert!(w[0].2 == w[1].2 || w[1].0 >= w[0].1 - 1e-3, "series {} and {} overlap", w[0].2, w[1].2);
}
// And all four series show up in the same pixel column.
let col = strips[strips.len() / 2].0.floor();
let seen: std::collections::BTreeSet<usize> = strips.iter().filter(|s| s.0.floor() == col).map(|s| s.2).collect();
assert_eq!(seen.len(), 4, "series visible in pixel column {col}");
}
}
#[test]
fn stacked_hairline_bars_do_not_fill_the_gap_between_disjoint_spans() {
// Series 1 alternates +10 (stacked on the 100) and -10 (below zero): its two spans in one
// pixel column are 100..110 and -10..0, and the 100 units between them must stay empty.
let mut c = chart(ChartKind::ColumnStacked);
c.data_labels = false;
c.legend = LegendPos::None;
let mut d = data_of(2, 20_000);
for v in d.series[0].values.iter_mut() {
*v = Some(100.0);
}
for (i, v) in d.series[1].values.iter_mut().enumerate() {
*v = Some(if i % 2 == 0 { 10.0 } else { -10.0 });
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let rects = series_rects(&prims, d.series[1].color);
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
assert!(!rects.is_empty() && tallest < 60.0, "tallest merged bar is {tallest} px");
}
#[test]
fn clean_data_is_rendered_without_a_copy() {
let d = data_of(3, 50);
assert!(matches!(sanitized(&d), Cow::Borrowed(_)));
// Dirty data takes the copying path and comes out as `sanitize` has always produced it.
let mut dirty = data_of(3, 50);
dirty.series[0].values[4] = Some(f64::NAN);
dirty.series[1].values[5] = Some(f64::INFINITY);
dirty.series[2].values[6] = Some(1e301);
dirty.series[2].x = Some(vec![Some(f64::NEG_INFINITY), Some(2.0)]);
dirty.series[1].sizes = Some(vec![Some(-1e305)]);
let got = sanitized(&dirty);
assert!(matches!(got, Cow::Owned(_)));
assert_eq!(*got, sanitize(&dirty));
assert_eq!(got.series[0].values[4], None);
assert_eq!(got.series[2].values[6], Some(1e300));
// Over the series cap (within the point budget) the same.
let many = data_of(300, 3);
let got = sanitized(&many);
assert_eq!(got.series.len(), 255);
assert_eq!(*got, sanitize(&many));
// Too many category levels take the copying path too.
let mut levels = data_of(3, 50);
levels.category_levels = vec![vec!["a".into()]; 5];
let got = sanitized(&levels);
assert!(matches!(got, Cow::Owned(_)));
assert_eq!(got.category_levels.len(), 3);
}
#[test]
fn resolve_keeps_every_series_within_the_point_budget() {
// 40 series x 60,000 points is 2.4 million: over the 2 million budget.
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
for r in 0..60_000u32 {
s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r)));
}
}
let mut c = chart(ChartKind::ColumnClustered);
c.series =
(0..40).map(|_| Series { values: "Sheet1!$A$1:$A$1000000".into(), categories: Some("A1:A1000000".into()), ..Series::default() }).collect();
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.len(), 40);
assert!(d.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 40));
assert_eq!(d.categories.len(), MAX_TOTAL_POINTS / 40);
assert_eq!(d.series[7].values.first(), Some(&Some(0.0)));
// Trendlines: 16 per chart, in series order.
c.series = (0..4).map(|_| Series { values: "A1:A5".into(), trendlines: vec![Default::default(); 9], ..Series::default() }).collect();
let counts: Vec<usize> = resolve(&wb, 0, &c).series.iter().map(|s| s.trendlines.len()).collect();
assert_eq!(counts, vec![9, 7, 0, 0]);
}
/// 100 series lower the per-series cap to 20,000, below the number of category cells of a
/// two-level axis (#1155). Each kept category keeps its inner label and its outer level.
fn two_level_chart(n: u32, vertical: bool) -> ChartData {
let at = |i: u32, level: u32| if vertical { CellRef::new(i, level) } else { CellRef::new(level, i) };
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
for i in 0..n {
s.set_value(at(i, 0), Value::Text(format!("Y{}", i / 3).into()));
s.set_value(at(i, 1), Value::Text(format!("C{i}").into()));
s.set_value(at(i, 2), Value::Number(f64::from(i)));
}
}
let cats = format!("{}:{}", at(0, 0), at(n - 1, 1));
let values = format!("{}:{}", at(0, 2), at(n - 1, 2));
let mut c = chart(ChartKind::ColumnClustered);
c.series = (0..100).map(|_| Series { values: values.clone(), categories: Some(cats.clone()), ..Series::default() }).collect();
resolve(&wb, 0, &c)
}
#[test]
fn multi_level_categories_keep_their_levels_under_the_point_cap() {
for vertical in [true, false] {
let d = two_level_chart(15_000, vertical);
assert_eq!(d.series[0].values.len(), 15_000, "vertical {vertical}");
assert_eq!(d.categories.len(), 15_000, "vertical {vertical}");
assert_eq!(d.categories[0], "C0");
assert_eq!(d.categories[14_999], "C14999");
assert_eq!(d.category_levels.len(), 1, "one outer level, vertical {vertical}");
assert_eq!(d.category_levels[0].len(), 15_000);
assert_eq!(d.category_levels[0][3], "Y1");
}
}
#[test]
fn multi_level_categories_longer_than_the_cap_are_cut_together_with_their_levels() {
// 25,000 categories against a cap of 20,000.
let d = two_level_chart(25_000, true);
let cap = points_per_series(100);
assert_eq!(d.categories.len(), cap);
assert_eq!(d.category_levels.len(), 1);
assert_eq!(d.category_levels[0].len(), cap);
assert_eq!(d.categories[cap - 1], format!("C{}", cap - 1));
assert_eq!(d.category_levels[0][cap - 3], format!("Y{}", (cap - 3) / 3));
}
+216 -2
View File
@@ -72,7 +72,12 @@ pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFi
let mut aty = vec![0.0f64; m];
for &(x, y) in &data {
let u = (x - center) / scale;
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
// `d` is at most 6, so the powers `first..=d` always fit the array (no allocation per point).
let mut buf = [0.0f64; 7];
for (slot, k) in buf.iter_mut().zip(first..=d) {
*slot = u.powi(k as i32);
}
let pows = buf.get(..m)?;
for (i, pi) in pows.iter().enumerate() {
if let Some(v) = aty.get_mut(i) {
*v += pi * (y - b0);
@@ -258,10 +263,45 @@ fn trim_zeros(s: &str) -> String {
/// Moving average of `period` consecutive points in plotted order: point `i` (from
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
///
/// Linear in the number of points: the points are cut into blocks of `period`, and a window is
/// the tail of one block plus the head of the next, taken from running sums that only ever add
/// (a running sum that also subtracts would lose small values after a huge one passed through).
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
let p = period.clamp(2, 255) as usize;
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
let n = pts.len();
if n < p {
return vec![];
}
// `head[i]`: sum of the block's points up to and including `i`; `tail[i]`: from `i` to the block's end.
let mut head = vec![0.0f64; n];
let mut tail = vec![0.0f64; n];
for (b, block) in pts.chunks(p).enumerate() {
let (mut h, mut t) = (0.0f64, 0.0f64);
for k in 0..block.len() {
h += block.get(k).map_or(0.0, |q| q.1);
if let Some(v) = head.get_mut(b * p + k) {
*v = h;
}
let r = block.len() - 1 - k;
t += block.get(r).map_or(0.0, |q| q.1);
if let Some(v) = tail.get_mut(b * p + r) {
*v = t;
}
}
}
(p - 1..n)
.filter_map(|i| {
let s = i + 1 - p;
let mut sum = tail.get(s)? + if s.is_multiple_of(p) { 0.0 } else { *head.get(i)? };
if !sum.is_finite() {
// A block sum overflowed although the window itself may not: sum it directly.
sum = pts.get(s..=i)?.iter().map(|q| q.1).sum();
}
Some((pts.get(i)?.0, sum / p as f64))
})
.collect()
}
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
@@ -276,3 +316,177 @@ pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
};
format!("{k} ({series})")
}
#[cfg(test)]
mod tests {
use super::*;
/// xorshift64 for repeatable pseudo-random data.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn unit(&mut self) -> f64 {
(self.next() % 1_000_000) as f64 / 1_000_000.0 - 0.5
}
}
/// The window-by-window moving average this module used to compute.
fn moving_average_naive(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
let p = period.clamp(2, 255) as usize;
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
}
#[test]
fn linear_moving_average_matches_the_window_sums() {
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
for case in 0..60 {
let n = (r.next() % 700) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let y = match case % 5 {
0 => r.unit() * 100.0,
1 => 1e9 + r.unit(),
// A huge value passing through must not leave rounding residue behind.
2 => {
if r.next().is_multiple_of(50) {
1e300 * r.unit().signum()
} else {
r.unit()
}
}
3 => {
if i % 2 == 0 {
1e15
} else {
-1e15 + r.unit()
}
}
_ => {
if r.next().is_multiple_of(9) {
f64::NAN
} else {
r.unit() * 1e-3
}
}
};
(i as f64, y)
})
.collect();
for period in [0, 2, 3, 7, 50, 255, 400] {
let fast = moving_average(&pts, period);
let slow = moving_average_naive(&pts, period);
assert_eq!(fast.len(), slow.len(), "case {case} period {period}");
let p = period.clamp(2, 255) as usize;
let finite: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
for (i, ((fx, fy), (sx, sy))) in fast.iter().zip(&slow).enumerate() {
assert_eq!(fx, sx);
let mass: f64 = finite.iter().skip(i).take(p).map(|q| q.1.abs()).sum::<f64>() / p as f64;
assert!((fy - sy).abs() <= 1e-12 * mass, "case {case} period {period} point {i}: {fy} vs {sy}");
}
}
}
}
#[test]
fn moving_average_recomputes_a_window_whose_block_sums_overflow() {
let pts: Vec<(f64, f64)> = [0.0, -1e308, 1e308, 1e308, -1e308].iter().enumerate().map(|(i, y)| (i as f64, *y)).collect();
let fast = moving_average(&pts, 4);
let slow = moving_average_naive(&pts, 4);
assert_eq!(fast, slow);
assert!(fast.iter().all(|q| q.1.is_finite()));
}
/// The trendline fit as it was written with a `Vec` of powers per data point.
fn fit_reference(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
let kind = t.kind;
let d = degree(kind)?;
let data: Vec<(f64, f64)> = pts
.iter()
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
.collect();
if data.len() < 2 || data.len() <= d {
return None;
}
let fixed = match kind {
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
_ => None,
}
.filter(|b| b.is_finite());
let n = data.len() as f64;
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let first = usize::from(fixed.is_some());
let b0 = fixed.unwrap_or(0.0);
let m = d + 1 - first;
let mut ata = vec![vec![0.0f64; m]; m];
let mut aty = vec![0.0f64; m];
for &(x, y) in &data {
let u = (x - center) / scale;
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
for (i, pi) in pows.iter().enumerate() {
aty[i] += pi * (y - b0);
for (j, pj) in pows.iter().enumerate() {
ata[i][j] += pi * pj;
}
}
}
let sol = solve(ata, aty)?;
let mut a = vec![b0; d + 1];
for (k, v) in sol.into_iter().enumerate() {
a[k + first] = v;
}
let coef = expand(&a, center, scale)?;
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
for &(x, y) in &data {
ss_res += (y - fit.poly(x)).powi(2);
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
}
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
Some(TrendFit { r2, ..fit })
}
#[test]
fn fit_without_a_vec_per_point_is_bit_identical() {
let mut r = Rng(0x1234_5678_9ABC_DEF1);
let kinds = [
TrendlineKind::Linear,
TrendlineKind::Exponential,
TrendlineKind::Logarithmic,
TrendlineKind::Power,
TrendlineKind::Polynomial(2),
TrendlineKind::Polynomial(3),
TrendlineKind::Polynomial(6),
TrendlineKind::MovingAverage(3),
];
for case in 0..40 {
let n = 2 + (r.next() % 60) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let x = i as f64 * (1.0 + r.unit()) + 1.0;
let y = if case % 4 == 0 { f64::NAN } else { (r.unit() + 0.6) * 10f64.powi((case % 7) - 2) };
(x, if case % 4 == 0 && i % 3 != 0 { r.unit() + 1.0 } else { y })
})
.collect();
for kind in kinds {
for intercept in [None, Some(0.5), Some(-1.0)] {
let t = Trendline { kind, intercept, ..Trendline::default() };
assert_eq!(fit_trendline(&t, &pts), fit_reference(&t, &pts), "case {case} {kind:?} {intercept:?}");
}
}
}
}
}
+6 -1
View File
@@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
let spans = sh.filter_row_spans();
if spans.is_empty() {
return Default::default();
}
let filtered = |row: u32| spans.iter().any(|&(first, last)| row >= first && row <= last);
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
+4
View File
@@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
let skip = cx.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, *r)).unwrap_or_default();
for at in r.iter() {
if skip.contains(&at.row) {
continue;
}
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
+126 -16
View File
@@ -1,8 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
target_sheet,
Cell, CellRef, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param,
target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
@@ -15,7 +15,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let mut selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let trim = |range: RangeRef| {
@@ -31,7 +31,27 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
let range = trim(ranges.first().copied().unwrap_or_default());
// A copied range with rows a filter hides takes only the rows showing: they are copied
// like a multi-area selection stacked top to bottom (formulas as values), as in Excel.
// A cut still takes every row.
let hidden = if cut { Default::default() } else { super::filtered_rows(sh, range) };
let visible = if hidden.is_empty() { Vec::new() } else { visible_row_runs(range, &hidden) };
let any_visible = !visible.is_empty();
let packed = match visible.as_slice() {
[] => (d.wb.clone(), range, Vec::new()),
[only] => (d.wb.clone(), *only, Vec::new()),
_ => {
let (wb, block) = slide_together(&d.wb, sheet, &visible).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, visible)
}
};
// Whole rows copied with some hidden by a filter come as the block of rows showing, so
// Insert Copied Cells makes room for those rows, not for the hidden ones too.
if !hidden.is_empty() && any_visible && selected.is_full_rows() {
selected = RangeRef::rows(packed.1.start.row, packed.1.end.row);
}
packed
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
@@ -46,6 +66,56 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// The runs of consecutive rows of `range` not in `hidden`, as ranges of `range`'s columns.
fn visible_row_runs(range: RangeRef, hidden: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut runs = Vec::new();
let mut start = None;
for row in range.start.row..=range.end.row {
match (hidden.contains(&row), start) {
(false, None) => start = Some(row),
(true, Some(first)) => {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(row - 1, range.end.col)));
start = None;
}
_ => {}
}
}
if let Some(first) = start {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
runs
}
/// The validation rules of `src` cut into the areas of a packed block: one rule per area a rule
/// touches, over that area's part moved by the area's shift. `sorted` areas run in order.
fn pack_validations(src: &gridcraft_model::Sheet, sorted: &[RangeRef], shifts: &[(i64, i64)], across: bool) -> Vec<gridcraft_model::Validation> {
let key = |c: CellRef| if across { c.col } else { c.row };
let mut packed_rules = Vec::new();
for dv in &src.validations {
let Some(from) = dv.ranges.first().map(|r| r.start) else { continue };
// The areas each of the rule's ranges touches lie side by side in the sorted list.
let end_key = |a: &RangeRef| if across { a.end.col } else { a.end.row };
let mut by_area: std::collections::BTreeMap<usize, Vec<RangeRef>> = Default::default();
for r in &dv.ranges {
let first = sorted.partition_point(|a| end_key(a) < key(r.start));
for (i, a) in sorted.iter().enumerate().skip(first).take_while(|(_, a)| key(a.start) <= end_key(r)) {
let (dr, dc) = shifts[i];
let moved = r.intersection(a).and_then(|x| Some(RangeRef::new(x.start.offset(dr, dc)?, x.end.offset(dr, dc)?)));
by_area.entry(i).or_default().extend(moved);
}
}
for ranges in by_area.into_values() {
let Some(to) = ranges.first().map(|r| r.start) else { continue };
let mut rule = dv.clone();
rule.f1 = super::super::data::move_rule_formula(&dv.f1, from, to);
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, from, to));
rule.ranges = ranges;
packed_rules.push(rule);
}
}
packed_rules
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
@@ -80,17 +150,35 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.hyperlinks.retain(|c, _| !block.contains(*c));
super::super::data::remove_validation_cells(&mut dst.validations, &[block]);
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
// Where each area lands: the shift of its cells, and its first row or column in the block.
let key = |c: CellRef| if across { c.col } else { c.row };
let mut next = if across { anchor.col } else { anchor.row };
let mut shifts: Vec<(i64, i64)> = Vec::with_capacity(sorted.len());
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let to = i64::from(next);
shifts.push(if across { (0, to - i64::from(a.start.col)) } else { (to - i64::from(a.start.row), 0) });
next = next.saturating_add(if across { a.width() } else { a.height() });
}
// The areas never overlap and run in order, so the area holding a cell is found by search:
// each map below is read once, whatever the number of areas.
let area_of = |c: CellRef| -> Option<usize> {
let i = sorted.partition_point(|a| key(a.start) <= key(c)).checked_sub(1)?;
sorted.get(i)?.contains(c).then_some(i)
};
let moved = |c: CellRef| -> Option<CellRef> {
let (dr, dc) = shifts[area_of(c)?];
c.offset(dr, dc)
};
for (a, &(dr, dc)) in sorted.iter().zip(&shifts) {
for (c, _) in src.cells.iter_range(*a) {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
@@ -98,17 +186,38 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for c in src.spill.keys().copied() {
let Some(to) = moved(c) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Links and validation rules follow their cells too, so a row the filter hides leaves its own
// behind.
for (c, link) in &src.hyperlinks {
if let Some(to) = moved(*c) {
dst.hyperlinks.insert(to, link.clone());
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
}
let packed_rules = pack_validations(src, &sorted, &shifts, across);
for (c, comment) in &src.comments {
if let Some(to) = moved(*c) {
dst.comments.insert(to, comment.clone());
}
}
for m in &src.merges {
let Some(i) = area_of(m.start).filter(|&i| sorted[i].contains_range(m)) else { continue };
let (dr, dc) = shifts[i];
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
@@ -120,6 +229,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
dst.validations.extend(packed_rules);
Some((out, block))
}
+2 -1
View File
@@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Js
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
let skip = s.doc()?.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, r)).unwrap_or_default();
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
crate::fill::fill_except(cx, sheet, src, r, crate::fill::FillMode::Copy, &skip)?;
Ok(Json::Null)
})
}
+52 -14
View File
@@ -45,6 +45,12 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
// A single cell or row repeated down a filtered selection skips the rows the filter hides; a
// taller block is pasted straight down, hidden rows included, as in Excel.
let skip_rows = match s.doc()?.wb.sheet(dest_sheet) {
Some(sh) if tile && h == 1 => super::filtered_rows(sh, sel),
_ => Default::default(),
};
// Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole
// rows right of column A, a whole column transposed) instead of pasting part of it.
if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() {
@@ -134,6 +140,9 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
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 };
if skip_rows.contains(&dest.row) {
continue;
}
let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } 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();
@@ -326,7 +335,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?;
paste_validations(cx, src_sheet, src, dest_sheet, pasted, PasteMode { transpose, skip_blanks, skip_rows: &skip_rows })?;
}
if what == What::ColumnWidths {
// Each pasted column takes the width of the column copied to it (transposed, the
@@ -394,6 +403,31 @@ const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
/// selection makes one range per tile).
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
/// How a validation paste lays the rules out: transposed or not, keeping the destination's rule under
/// blank source cells, and leaving the rows a filter hides as they are.
#[derive(Clone, Copy)]
struct PasteMode<'a> {
transpose: bool,
skip_blanks: bool,
skip_rows: &'a std::collections::BTreeSet<u32>,
}
/// `range` less the rows in `skip`: the runs of rows between them, as ranges of its columns.
fn without_rows(range: RangeRef, skip: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut out = Vec::new();
let mut next = range.start.row;
for &row in skip.range(range.start.row..=range.end.row) {
if row > next {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(row - 1, range.end.col)));
}
next = row + 1;
}
if next <= range.end.row {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
out
}
/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the
/// destination's rules (cells whose source had no rule end up with none), then each source rule
/// that covers part of the copied block `src` comes along as one rule over the translated parts.
@@ -404,9 +438,9 @@ fn paste_validations(
src: RangeRef,
dest_sheet: usize,
pasted: RangeRef,
transpose: bool,
skip_blanks: bool,
mode: PasteMode<'_>,
) -> Result<()> {
let PasteMode { transpose, skip_blanks, skip_rows } = mode;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1));
let mut rules = Vec::new();
@@ -426,7 +460,9 @@ fn paste_validations(
let (r, c) = if transpose { (c, r) } else { (r, c) };
for tr in 0..tiles_r {
for tc in 0..tiles_c {
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) {
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c))
&& !skip_rows.contains(&d.row)
{
cells.push((d.row, d.col));
}
}
@@ -490,6 +526,8 @@ fn paste_validations(
}
}
}
// The rows a filter hides keep what they have.
let ranges: Vec<RangeRef> = ranges.into_iter().flat_map(|r| without_rows(r, skip_rows)).collect();
let Some(anchor) = ranges.first().map(|r| r.start) else { continue };
let mut rule = dv.clone();
// Relative references move with the rule, as pasted formulas do: they are written for the
@@ -507,7 +545,7 @@ fn paste_validations(
cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied()));
super::super::data::remove_validation_cells(&mut sh.validations, &cleared);
} else {
super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]);
super::super::data::remove_validation_cells(&mut sh.validations, &without_rows(pasted, skip_rows));
}
sh.validations.extend(rules);
Ok(())
@@ -529,19 +567,19 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
let Some(sheet) = wb.sheet_mut(si) else { continue };
for (c, f) in keys {
let Some(expr) = f.expr() else { continue };
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
let text = gridcraft_formula::print(&new);
// Borrows the tree and copies and prints only the formulas the edit changes. A formula
// the edit leaves alone keeps its stored text, even if that text is not canonical.
let Some(new) = f.with_expr(|x| gridcraft_formula::adjust::adjust_if_changed(x, &host, target, e)) else { continue };
let array = match f.array {
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
a => a,
};
if (text != f.text || array != f.array)
&& let Some(cell) = sheet.cells.get_mut(c)
{
let mut nf = Formula::from_expr(new);
nf.array = array;
nf.dynamic = f.dynamic;
let nf = match new {
Some(new) => Formula { array, dynamic: f.dynamic, ..Formula::from_expr(new) },
None if array != f.array => Formula { array, ..(*f).clone() },
None => continue,
};
if let Some(cell) = sheet.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
+12 -13
View File
@@ -55,11 +55,12 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
for (c, f) in keys {
if let Some(e) = f.expr() {
let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
// Only formulas whose tree changes are rewritten; the others keep their text.
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order)).filter(|ne| ne != e));
if let Some(Some(ne)) = changed
&& let Some(cell) = sh.cells.get_mut(c)
{
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
}
}
@@ -109,14 +110,12 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
for (c, f) in keys {
if let Some(e) = f.expr() {
let ne = gridcraft_formula::adjust::rename_sheet(e, &old, &name);
let text = gridcraft_formula::print(&ne);
if text != f.text
&& let Some(cell) = sh.cells.get_mut(c)
{
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
// Compares trees, so unchanged formulas are not printed; they keep their text.
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::rename_sheet(e.clone(), &old, &name)).filter(|ne| ne != e));
if let Some(Some(ne)) = changed
&& let Some(cell) = sh.cells.get_mut(c)
{
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
}
for ch in sh.charts.iter_mut() {
+14 -1
View File
@@ -87,6 +87,18 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
fill_except(cx, sheet, src, target, mode, &std::collections::BTreeSet::new())
}
/// [`fill`], leaving the rows in `skip_rows` untouched (Fill Down over a filtered range).
pub fn fill_except(
cx: &mut Ctx,
sheet: usize,
src: RangeRef,
target: RangeRef,
mode: FillMode,
skip_rows: &std::collections::BTreeSet<u32>,
) -> Result<()> {
let custom = cx.wb.custom_lists.clone();
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
// Direction: down/up if target extends rows, else right/left.
@@ -119,7 +131,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
(c, sh.cell(c).cloned())
})
.collect();
let targets: Vec<CellRef> = if vertical {
let mut targets: Vec<CellRef> = if vertical {
let rows: Vec<u32> =
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
@@ -128,6 +140,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
targets.retain(|t| !skip_rows.contains(&t.row));
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
+266
View File
@@ -5090,6 +5090,246 @@ fn precision_as_displayed_rounds_numbers_written_to_a_new_sheet() {
assert!(rounded > 0, "the histogram's formatted cells should be rounded");
}
/// #1101: repeating one cell or row down a filtered selection (paste, Ctrl+Enter, Fill Down) skips
/// the rows the filter hides, as in Excel. A taller pasted block goes straight down, and rows
/// hidden by hand are written, also as in Excel (each case measured in Excel for Microsoft 365).
#[test]
fn fills_and_single_row_pastes_skip_filtered_rows() {
let fresh = s;
let filtered = || {
let mut s = fresh();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("range.setValues", json!({"range": "E1", "values": [["X", "W"], ["Y", ""]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
assert_eq!(s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap()["hiddenRows"], 4);
s
};
let col = |s: &Session, c: &str| -> Vec<Value> { (2..=11).map(|r| v(s, &format!("{c}{r}"))).collect() };
let skipped =
|x: &str| -> Vec<Value> { (2..=11).map(|r| Value::from(if [3, 5, 7, 9].contains(&r) { format!("v{r}") } else { x.to_string() })).collect() };
// One cell pasted over the filtered rows.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), skipped("X"));
// One row (two cells) pasted over the filtered rows.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1:F1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:B11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), skipped("X"));
assert_eq!(v(&s, "B3"), Value::from("hide"));
assert_eq!(v(&s, "B4"), Value::from("W"));
// A two-row block is pasted straight down, hidden rows included.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1:E2"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), ["X", "Y", "X", "Y", "X", "Y", "X", "Y", "X", "Y"].map(Value::from));
// Ctrl+Enter.
let mut s = filtered();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("range.fill", json!({"input": "Z"})).unwrap();
assert_eq!(col(&s, "A"), skipped("Z"));
// Fill Down, and a formula filled down still adjusts to the row it lands in.
let mut s = filtered();
s.execute("edit.fillDown", json!({"range": "A2:A11"})).unwrap();
assert_eq!(col(&s, "A"), skipped("v2"));
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
assert_eq!(v(&s, "C4"), Value::Number(4.0));
assert!(v(&s, "C3").is_empty());
// Fill Up and Fill Right skip them too.
let mut s = filtered();
s.execute("edit.fillUp", json!({"range": "A2:A11"})).unwrap();
assert_eq!(col(&s, "A"), skipped("v11"));
let mut s = filtered();
s.execute("range.setValues", json!({"range": "C2", "values": [["c2"], ["c3"], ["c4"]]})).unwrap();
s.execute("edit.fillRight", json!({"range": "A2:C4"})).unwrap();
assert_eq!([v(&s, "C2"), v(&s, "C3"), v(&s, "C4")], [Value::from("v2"), Value::from("c3"), Value::from("v4")]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "C4"), Value::from("c4"));
// Copying the filtered range takes only the rows showing, formulas as their values; a cut
// still moves every row.
let mut s = filtered();
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
let r = s.execute("edit.copy", json!({"range": "A1:C11"})).unwrap();
assert_eq!(
r["text"],
"Val Tag
v2 keep 2
v4 keep 4
v6 keep 6
v8 keep 8
v10 keep 10
v11 keep 11
"
);
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
let pasted: Vec<Value> = (20..=27).map(|r| v(&s, &format!("H{r}"))).collect();
assert_eq!(pasted, ["Val", "v2", "v4", "v6", "v8", "v10", "v11"].map(Value::from).into_iter().chain([Value::Empty]).collect::<Vec<_>>());
assert_eq!(v(&s, "J22"), Value::Number(4.0));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("J22").unwrap()).unwrap().formula.is_none());
let mut s = filtered();
s.execute("edit.cut", json!({"range": "A1:B11"})).unwrap();
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
assert_eq!(v(&s, "H22"), Value::from("v3"));
// Rows hidden by hand (no filter) are written, as in Excel.
let mut s = fresh();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
s.execute("home.hideRows", json!({"rows": "2:2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
s.execute("range.fill", json!({"input": "Z"})).unwrap();
assert_eq!(v(&s, "A2"), Value::from("Z"));
}
/// A table of ten rows (A1:B11) with a link on every value cell, four of its rows hidden by a
/// filter (3, 5, 7, 9), and a spare corner at E1 to copy from.
fn filtered_rows_with_links() -> Session {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("range.setValues", json!({"range": "E1", "values": [["X"]]})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for r in 2..=11u32 {
sh.hyperlinks
.insert(CellRef::new(r - 1, 0), gridcraft_model::Hyperlink { target: format!("https://example.com/{r}"), tooltip: None, external: true });
}
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s
}
fn cell_link_target(s: &Session, cell: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().hyperlinks.get(&CellRef::parse(cell).unwrap()).map(|h| h.target.clone())
}
fn cell_validation(s: &Session, cell: &str) -> Option<String> {
let c = CellRef::parse(cell).unwrap();
s.doc().unwrap().wb.active().unwrap().validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c))).map(|d| d.f1.clone())
}
/// Copying a filtered range packs the rows showing into one block; their links and validation
/// rules travel with them, and those of the hidden rows are left behind.
#[test]
fn filtered_copy_pastes_links_and_validation_of_the_rows_showing_only() {
let mut s = filtered_rows_with_links();
for (cell, source) in [("A3", "\"hidden\""), ("A4", "\"showing\""), ("A9", "\"hidden too\"")] {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
s.execute("edit.copy", json!({"range": "A1:B11"})).unwrap();
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
let links: Vec<Option<String>> = (20..=26).map(|r| cell_link_target(&s, &format!("H{r}"))).collect();
let expected: Vec<Option<String>> =
[None, Some(2u32), Some(4), Some(6), Some(8), Some(10), Some(11)].iter().map(|n| n.map(|n| format!("https://example.com/{n}"))).collect();
assert_eq!(links, expected);
let rules: Vec<Option<String>> = (20..=26).map(|r| cell_validation(&s, &format!("H{r}"))).collect();
assert_eq!(rules, [None, None, Some("\"showing\"".to_string()), None, None, None, None]);
// The source keeps its own links and rules where they were.
assert_eq!(cell_link_target(&s, "A3").as_deref(), Some("https://example.com/3"));
assert_eq!(cell_validation(&s, "A3").as_deref(), Some("\"hidden\""));
}
/// One cell pasted over a filtered column leaves the rules of the rows the filter hides alone, and
/// does not give them the copied rule, with or without Skip Blanks.
#[test]
fn single_row_paste_over_filtered_rows_leaves_their_validation_alone() {
for skip_blanks in [false, true] {
let mut s = filtered_rows_with_links();
s.execute("data.validation", json!({"range": "E1", "type": "list", "formula1": "\"copied\""})).unwrap();
s.execute("data.validation", json!({"range": "A2:A5", "type": "list", "formula1": "\"old\""})).unwrap();
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": skip_blanks})).unwrap();
let rules: Vec<Option<String>> = (2..=11).map(|r| cell_validation(&s, &format!("A{r}"))).collect();
let showing = Some("\"copied\"".to_string());
let old = Some("\"old\"".to_string());
assert_eq!(
rules,
[showing.clone(), old.clone(), showing.clone(), old, showing.clone(), None, showing.clone(), None, showing.clone(), showing],
"skip blanks: {skip_blanks}"
);
}
}
/// Insert Copied Cells after a filtered copy of whole rows makes room for the rows copied, not for
/// the rows that were selected.
#[test]
fn insert_copied_cells_after_a_filtered_row_copy_inserts_only_the_rows_showing() {
let mut s = filtered_rows_with_links();
s.execute("cell.set", json!({"cell": "A30", "input": "below"})).unwrap();
s.execute("edit.copy", json!({"range": "1:11"})).unwrap();
s.execute("selection.set", json!({"range": "A20"})).unwrap();
s.execute("home.insertCopiedCells", json!({})).unwrap();
// Seven rows were copied (the header and six rows showing).
assert_eq!(v(&s, "A37"), Value::from("below"));
assert_eq!(v(&s, "A20"), Value::from("Val"));
assert_eq!(v(&s, "A26"), Value::from("v11"));
assert_eq!(v(&s, "A27"), Value::Empty);
}
/// Copying a filtered range with many separate runs of rows showing moves each row's note, link and
/// merge with it, and leaves the hidden rows' behind.
#[test]
fn filtered_copy_with_many_runs_moves_notes_and_merges_with_their_rows() {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=2001 {
rows.push(json!([format!("v{i}"), if i % 2 == 1 { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for r in 2..=2001u32 {
let note = gridcraft_model::Comment {
author: "a".into(),
text: format!("note {r}"),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
sh.comments.insert(CellRef::new(r - 1, 0), note);
}
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(3, 1), CellRef::new(3, 2)));
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(2, 1), CellRef::new(2, 2)));
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s.execute("edit.copy", json!({"range": "A1:C2001"})).unwrap();
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let note = |row: u32| sh.comments.get(&CellRef::new(row - 1, 7)).map(|c| c.text.clone());
assert_eq!(note(1), None);
assert_eq!(note(2).as_deref(), Some("note 2"));
assert_eq!(note(3).as_deref(), Some("note 4"));
assert_eq!(note(1001).as_deref(), Some("note 2000"));
assert_eq!(sh.comments.keys().filter(|c| c.col == 7).count(), 1000);
// Only the merge on a row showing (row 4) came along, packed to the row it landed in (row 3).
assert!(sh.merges.contains(&gridcraft_core::RangeRef::new(CellRef::new(2, 8), CellRef::new(2, 9))));
assert_eq!(sh.merges.iter().filter(|m| m.start.col >= 7).count(), 1);
}
#[test]
fn editing_quote_prefixed_text_keeps_it_text() {
let mut s = s();
@@ -5350,6 +5590,32 @@ fn paste_validation_skip_blanks_large_populated_paste() {
assert!(validation_areas(&s).is_empty());
}
/// A whole-column copy of a filtered range takes only the rows showing, also when they form one
/// block (the hidden rows are all at the end) and the selection covers the whole column.
#[test]
fn whole_column_copy_of_a_filtered_range_leaves_the_hidden_rows_out() {
for hidden_tail in [false, true] {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
let hide = if hidden_tail { i >= 8 } else { [3, 5, 7, 9].contains(&i) };
rows.push(json!([format!("v{i}"), if hide { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s.execute("cell.set", json!({"cell": "H30", "input": "old"})).unwrap();
s.execute("edit.copy", json!({"range": "A:B"})).unwrap();
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
let col: Vec<Value> = (1..=11).map(|r| v(&s, &format!("H{r}"))).collect();
let want: Vec<&str> =
if hidden_tail { vec!["Val", "v2", "v3", "v4", "v5", "v6", "v7"] } else { vec!["Val", "v2", "v4", "v6", "v8", "v10", "v11"] };
let want: Vec<Value> = want.into_iter().map(Value::from).chain(std::iter::repeat(Value::Empty)).take(11).collect();
assert_eq!(col, want, "hidden rows stay out of a whole-column copy (hidden tail: {hidden_tail})");
}
}
/// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does;
/// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it.
#[test]
+1
View File
@@ -29,6 +29,7 @@ mod sheet_copy_spill;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod structural_edit_rewrite;
mod table_resize_overlap;
mod table_total_row_last_row;
mod trace_error;
@@ -0,0 +1,84 @@
//! Structural edits (insert and delete of lines, sheet rename and delete) rewrite only the formulas
//! whose tree they change. A formula they leave alone keeps its stored text and its flags.
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_engine::Session;
use gridcraft_model::{Cell, Formula};
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) {
s.execute(command, params).unwrap();
}
fn workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
run(&mut s, "home.insertSheet", json!({"name": "Other"}));
run(&mut s, "home.insertSheet", json!({"name": "Spare"}));
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
s
}
fn formula(s: &Session, sheet: &str, cell: &str) -> Arc<Formula> {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.clone().unwrap()
}
/// Stores a formula with this exact text and no cached tree, as a JSON workbook loads it.
fn put_raw(s: &mut Session, cell: &str, text: &str) {
let wb = Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let i = wb.sheet_index("Sheet1").unwrap();
let f = Formula { text: text.to_string(), expr: None, array: None, dynamic: false };
wb.sheet_mut(i).unwrap().cells.set(CellRef::parse(cell).unwrap(), Cell::formula(f));
}
#[test]
fn an_edit_that_leaves_a_formula_alone_keeps_its_stored_text() {
// The rewrite compares trees, not printed text, so a stored non-canonical text is not
// re-printed by an edit that does not change the formula. An edit that changes it prints it.
let mut s = workbook();
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
run(&mut s, "home.insertRows", json!({"rows": "5:5"}));
run(&mut s, "home.insertColumns", json!({"cols": "F:F"}));
run(&mut s, "sheet.rename", json!({"sheet": "Spare", "name": "Spare2"}));
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare2"}));
assert_eq!(formula(&s, "Sheet1", "C1").text, "sum(a1, Other!b2)");
run(&mut s, "home.insertRows", json!({"rows": "1:1"}));
assert_eq!(formula(&s, "Sheet1", "C2").text, "SUM(A2,Other!B2)");
}
#[test]
fn renaming_a_referenced_sheet_rewrites_the_formula() {
let mut s = workbook();
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
run(&mut s, "sheet.rename", json!({"sheet": "Other", "name": "Data"}));
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,Data!B2)");
run(&mut s, "home.deleteSheet", json!({"sheet": "Data"}));
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,#REF!)");
}
#[test]
fn inserting_rows_above_a_legacy_array_moves_its_range_when_the_formula_is_unchanged() {
let mut s = workbook();
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
run(&mut s, "selection.set", json!({"range": "C5:C6"}));
run(&mut s, "cell.set", json!({"cell": "C5", "input": "=B1:B2*2", "array": true}));
assert_eq!(formula(&s, "Sheet1", "C5").array, Some(RangeRef::parse("C5:C6").unwrap()));
run(&mut s, "home.insertRows", json!({"rows": "3:3"}));
let f = formula(&s, "Sheet1", "C6");
assert_eq!(f.text, "B1:B2*2");
assert_eq!(f.array, Some(RangeRef::parse("C6:C7").unwrap()));
}
#[test]
fn deleting_an_unrelated_sheet_keeps_legacy_arrays() {
let mut s = workbook();
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
run(&mut s, "selection.set", json!({"range": "C1:C2"}));
run(&mut s, "cell.set", json!({"cell": "C1", "input": "=B1:B2*2", "array": true}));
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare"}));
assert_eq!(formula(&s, "Sheet1", "C1").array, Some(RangeRef::parse("C1:C2").unwrap()));
}
+50 -23
View File
@@ -8,7 +8,7 @@ use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
e.map(&mut |x| match x {
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
Expr::Ref(r) => match shift_ref(r, dr, dc) {
Some(r) => Expr::Ref(r),
None => Expr::Error(CellError::Ref),
},
@@ -60,14 +60,14 @@ fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
}
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
fn shift_ref(r: Reference, dr: i64, dc: i64) -> Option<Reference> {
let kind = match &r.kind {
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
};
Some(Reference { sheet: r.sheet.clone(), kind })
Some(Reference { sheet: r.sheet, kind })
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -95,27 +95,54 @@ pub enum Edit {
MoveToSheet { from: RangeRef, to_sheet: String, to_row: u32, to_col: u32 },
}
/// Whether `r`, in a formula living on sheet `host`, points into sheet `target`.
fn on_target(r: &Reference, host: &str, target: &str) -> bool {
match &r.sheet {
SheetSel::Current => host.eq_ignore_ascii_case(target),
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
SheetSel::Span(..) => false,
}
}
/// Whether [`adjust`] would change `r`. It runs the same [`adjust_ref`] on a copy without the
/// sheet name, so no name is copied: the adjustment reads only the position, and the sheet a cut
/// to another sheet assigns is compared afterwards.
fn ref_changes(r: &Reference, edit: &Edit) -> bool {
let probe = Reference { sheet: SheetSel::Current, kind: r.kind.clone() };
match adjust_ref(probe, edit) {
None => true,
Some(p) => p.kind != r.kind || (p.sheet != SheetSel::Current && p.sheet != r.sheet),
}
}
/// Whether [`adjust`] would change the formula `e` at all, without copying or rebuilding it.
fn changes(e: &Expr, host: &str, target: &str, edit: &Edit) -> bool {
let mut changed = false;
e.walk(&mut |x| {
if !changed && let Expr::Ref(r) = x {
changed = on_target(r, host, target) && ref_changes(r, edit);
}
});
changed
}
/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`.
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
e.map(&mut |x| match x {
Expr::Ref(r) => {
let on_target = match &r.sheet {
SheetSel::Current => host.eq_ignore_ascii_case(target),
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
SheetSel::Span(..) => false,
};
if !on_target {
return Expr::Ref(r);
}
match adjust_ref(&r, edit) {
Some(r) => Expr::Ref(r),
None => Expr::Error(CellError::Ref),
}
}
Expr::Ref(r) if on_target(&r, host, target) => match adjust_ref(r, edit) {
Some(r) => Expr::Ref(r),
None => Expr::Error(CellError::Ref),
},
other => other,
})
}
/// [`adjust`] for a borrowed formula: `None` when the edit leaves it exactly as it is, so a
/// caller can skip unaffected formulas without copying, rebuilding or printing them.
pub fn adjust_if_changed(e: &Expr, host: &str, target: &str, edit: &Edit) -> Option<Expr> {
changes(e, host, target, edit).then(|| adjust(e.clone(), host, target, edit))
}
/// New position of an index after an insert (`None` never) or delete (`None` = deleted).
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
if insert {
@@ -168,7 +195,7 @@ fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Op
}
}
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
fn adjust_ref(r: Reference, edit: &Edit) -> Option<Reference> {
match edit {
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
let insert = matches!(edit, Edit::Insert { .. });
@@ -193,7 +220,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
}
(k, _) => k.clone(),
};
Some(Reference { sheet: r.sheet.clone(), kind })
Some(Reference { sheet: r.sheet, kind })
}
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
// Like inserting/deleting lines, for references that lie within the shifted
@@ -204,7 +231,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
};
if !inside {
return Some(r.clone());
return Some(r);
}
let axis = *axis;
let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
@@ -215,7 +242,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
let dc = *to_col as i64 - from.start.col as i64;
let range = r.range();
if !from.contains_range(&range) {
return Some(r.clone());
return Some(r);
}
let mv = |a: &Anchor| -> Option<Anchor> {
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
@@ -233,11 +260,11 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
}
};
Some(Reference { sheet: r.sheet.clone(), kind })
Some(Reference { sheet: r.sheet, kind })
}
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
if !from.contains_range(&r.range()) {
return Some(r.clone());
return Some(r);
}
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
moved.sheet = SheetSel::Named(to_sheet.clone());
+494
View File
@@ -0,0 +1,494 @@
//! `adjust`, `adjust_if_changed` and `shift_relative` against a verbatim copy of the previous
//! implementation, which cloned and rebuilt every formula. The rewrite must give the same tree
//! (and so the same text) for every formula and edit, and `adjust_if_changed` must say
//! "unchanged" exactly when the old code returned the formula as it was.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit, adjust, adjust_if_changed, shift_relative};
use gridcraft_formula::{Expr, parse, print};
/// The implementation before the borrowed check, kept as the test oracle.
mod old {
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_formula::{Anchor, Expr, RefKind, Reference, SheetSel};
fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> {
if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None }
}
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
e.map(&mut |x| match x {
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
Some(r) => Expr::Ref(r),
None => Expr::Error(CellError::Ref),
},
other => other,
})
}
fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option<Anchor> {
let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr };
let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc };
let (row, col) = clamp_anchor(row, col)?;
Some(Anchor { row, col, ..*a })
}
fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
if abs {
return Some(v);
}
let n = v as i64 + d;
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
}
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
let kind = match &r.kind {
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
e.map(&mut |x| match x {
Expr::Ref(r) => {
let on_target = match &r.sheet {
SheetSel::Current => host.eq_ignore_ascii_case(target),
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
SheetSel::Span(..) => false,
};
if !on_target {
return Expr::Ref(r);
}
match adjust_ref(&r, edit) {
Some(r) => Expr::Ref(r),
None => Expr::Error(CellError::Ref),
}
}
other => other,
})
}
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
if insert {
if v >= at {
let n = v as u64 + count as u64;
if n >= max as u64 { None } else { Some(n as u32) }
} else {
Some(v)
}
} else if v < at {
Some(v)
} else if v >= at.saturating_add(count) {
Some(v - count)
} else {
None
}
}
fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<(u32, u32)> {
if insert {
let lo2 = if lo >= at { lo as u64 + count as u64 } else { lo as u64 };
let hi2 = if hi >= at { hi as u64 + count as u64 } else { hi as u64 };
let hi2 = hi2.min(max as u64 - 1);
if lo2 > hi2 {
return None;
}
Some((lo2 as u32, hi2 as u32))
} else {
let end = at.saturating_add(count);
if lo >= at && hi < end {
return None;
}
let lo2 = if lo < at {
lo
} else if lo >= end {
lo - count
} else {
at
};
let hi2 = if hi < at {
hi
} else if hi >= end {
hi - count
} else {
at.saturating_sub(1)
};
Some((lo2, hi2.max(lo2)))
}
}
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
match edit {
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
let insert = matches!(edit, Edit::Insert { .. });
let kind = match (&r.kind, axis) {
(RefKind::Cell(a), Axis::Rows) => RefKind::Cell(Anchor { row: map_index(a.row, *at, *count, insert, MAX_ROWS)?, ..*a }),
(RefKind::Cell(a), Axis::Cols) => RefKind::Cell(Anchor { col: map_index(a.col, *at, *count, insert, MAX_COLS)?, ..*a }),
(RefKind::Range(a, b), Axis::Rows) => {
let (lo, hi) = map_span(a.row.min(b.row), a.row.max(b.row), *at, *count, insert, MAX_ROWS)?;
RefKind::Range(Anchor { row: lo, ..*a }, Anchor { row: hi, ..*b })
}
(RefKind::Range(a, b), Axis::Cols) => {
let (lo, hi) = map_span(a.col.min(b.col), a.col.max(b.col), *at, *count, insert, MAX_COLS)?;
RefKind::Range(Anchor { col: lo, ..*a }, Anchor { col: hi, ..*b })
}
(RefKind::Rows(r0, a0, r1, a1), Axis::Rows) => {
let (lo, hi) = map_span(*r0.min(r1), *r0.max(r1), *at, *count, insert, MAX_ROWS)?;
RefKind::Rows(lo, *a0, hi, *a1)
}
(RefKind::Cols(c0, a0, c1, a1), Axis::Cols) => {
let (lo, hi) = map_span(*c0.min(c1), *c0.max(c1), *at, *count, insert, MAX_COLS)?;
RefKind::Cols(lo, *a0, hi, *a1)
}
(k, _) => k.clone(),
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
let span = r.range();
let (inside, at, count) = match axis {
Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()),
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
};
if !inside {
return Some(r.clone());
}
let axis = *axis;
let lines =
if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
adjust_ref(r, &lines)
}
Edit::Move { from, to_row, to_col } => {
let dr = *to_row as i64 - from.start.row as i64;
let dc = *to_col as i64 - from.start.col as i64;
let range = r.range();
if !from.contains_range(&range) {
return Some(r.clone());
}
let mv = |a: &Anchor| -> Option<Anchor> {
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
Some(Anchor { row, col, ..*a })
};
let kind = match &r.kind {
RefKind::Cell(a) => RefKind::Cell(mv(a)?),
RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?),
RefKind::Rows(a, aa, b, ba) => {
let dr = *to_row as i64 - from.start.row as i64;
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
}
RefKind::Cols(a, aa, b, ba) => {
let dc = *to_col as i64 - from.start.col as i64;
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
}
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
if !from.contains_range(&r.range()) {
return Some(r.clone());
}
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
moved.sheet = SheetSel::Named(to_sheet.clone());
Some(moved)
}
}
}
}
/// Formulas with every reference shape the adjustment distinguishes.
const FORMULAS: &[&str] = &[
"A1",
"B2+C3*D4",
"$B$2+B$2+$B2",
"SUM(A1:A10)+SUM(B2:C3)+SUM($B$2:$C$3)",
"SUM(C3:B2)",
"SUM(A:A)+SUM($B:$C)+SUM(2:2)+SUM($3:$5)",
"SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)+SUM(A1.:B1)",
"S!B2+s!C3+'S'!A1:B9",
"Other!B2+Other!A:A+Other!2:3",
"'My Sheet'!B2+'my sheet'!C3:D4",
"Dest!B2+S!B2",
"SUM(S:Other!B2)+SUM(Other:S!A1:C3)",
"XFD1048576+XFD1+A1048576+SUM(XFC1048575:XFD1048576)",
"SUM(1048576:1048576)+SUM(XFD:XFD)",
"VLOOKUP($B2,Lookup!$A$1:$D$500,3,FALSE)",
"IF(AND(C2>0,D2<100),C2*1.07,IF(E2=\"x\",\"yes\",\"no\"))",
"Table1[@Qty]*Table1[@Price]+SUM(Table1[[#Data],[Qty]])",
"SUMPRODUCT(A2:C2,{1,2,3})",
"LET(x,A2*2,y,B2+x,x*y)",
"LAMBDA(x,x+B2)(C3)",
"IFERROR(INDEX(Lookup!$A:$A,MATCH(F2,Lookup!$B:$B,0)),\"\")",
"-B2%+(C3)^2&\"a\"",
"A1#+S!B2#",
"S!MyName+MyName+#REF!+S!#REF!",
"COUNTIFS(Data!$A:$A,A2,Data!$C:$C,\">=\"&$G$2)",
"1+2",
"\"text\"",
"B2:C3 B3:D4",
"(B2:C3,E5)",
"INDIRECT(\"B2\")+OFFSET(B2,1,1)",
// From the parser edge cases (prefixes and LET parameter names).
"LET(_xlpm.TRUE,1,_xlpm.TRUE+1)",
"LET(_xlpm.A1,1,_xlpm.A1)",
"LAMBDA(_xlpm.x,_xlpm.x+1)(2)",
"_xlfn.LET(_xlpm.R1C1,1,_xlpm.R1C1*2)",
"LET(_xlpm.FALSE,2,_xlpm.FALSE)",
"_xlpm.SUM(1)",
"LET(_xlpm.x_1,1,_xlpm.x_1)",
"_xlfn.ANCHORARRAY(A1)+_xlfn.SINGLE(B1:B3)",
"SUM(_xlfn._TRO_ALL(A1:E10))",
];
fn col(c: u32) -> String {
gridcraft_core::addr::col_to_letters(c)
}
/// The realistic mix the structural-edit benchmark uses, row-dependent.
fn mix(i: usize, r: u32) -> String {
let r1 = r + 1;
match i % 12 {
0 => format!("SUM(A{r1}:A{})", r1 + 9),
1 => format!("VLOOKUP($B{r1},Lookup!$A$1:$D$500,3,FALSE)"),
2 => format!("IF(AND(C{r1}>0,D{r1}<100),C{r1}*1.07,IF(E{r1}=\"x\",\"yes\",\"no\"))"),
3 => format!("'My Sheet'!B{r1}+Data!C{r1}*2-1"),
4 => "Table1[@Qty]*Table1[@Price]".to_string(),
5 => format!("SUMPRODUCT(A{r1}:C{r1},{{1,2,3}})+SUM(Table1[[#Data],[Qty]])"),
6 => format!("\"Lorem ipsum\"&A{r1}&\" units\""),
7 => format!("IFERROR(INDEX(Lookup!$A:$A,MATCH(F{r1},Lookup!$B:$B,0)),\"\")"),
8 => (0..20).map(|k| format!("{}{r1}", col(k))).collect::<Vec<_>>().join("+"),
9 => format!("LET(x,A{r1}*2,y,B{r1}+x,x*y)"),
10 => format!("ROUND(AVERAGE(B{r1}:F{r1})*(1+$H$1)^2,2)"),
_ => format!("COUNTIFS(Data!$A:$A,A{r1},Data!$C:$C,\">=\"&$G$2)"),
}
}
fn range(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
}
/// Every edit kind, at the sheet's start, middle and edges, with counts that run past the end.
fn edits() -> Vec<Edit> {
let mut v = Vec::new();
for axis in [Axis::Rows, Axis::Cols] {
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
for at in [0, 1, 2, 3, 9, max - 2, max - 1] {
for count in [0, 1, 2, 5, max - 1, max, u32::MAX] {
v.push(Edit::Insert { axis, at, count });
v.push(Edit::Delete { axis, at, count });
}
}
for r in [
range(1, 1, 2, 2),
range(0, 0, 0, 0),
range(1, 0, 1, MAX_COLS - 1),
range(0, 1, MAX_ROWS - 1, 1),
range(MAX_ROWS - 2, MAX_COLS - 2, MAX_ROWS - 1, MAX_COLS - 1),
] {
v.push(Edit::InsertCells { axis, range: r });
v.push(Edit::DeleteCells { axis, range: r });
}
}
let blocks = [
range(1, 1, 2, 2),
range(0, 0, 0, 0),
range(0, 0, MAX_ROWS - 1, MAX_COLS - 1),
range(0, 0, MAX_ROWS - 1, 2),
range(1, 0, 4, MAX_COLS - 1),
range(0, 0, 20, 10),
];
let targets = [(9, 3), (0, 0), (1, 1), (MAX_ROWS - 1, MAX_COLS - 1), (MAX_ROWS - 2, 0)];
for from in blocks {
for (to_row, to_col) in targets {
v.push(Edit::Move { from, to_row, to_col });
for to_sheet in ["Dest", "S", "s", "Other"] {
v.push(Edit::MoveToSheet { from, to_sheet: to_sheet.to_string(), to_row, to_col });
}
}
}
v
}
/// Compares the new and the old adjustment of `src` for every host and edit; returns the
/// number of (formula, host, edit) cases checked, or 0 when `src` does not parse.
fn check(src: &str, edits: &[Edit]) -> usize {
let Ok(e) = parse(src) else { return 0 };
let mut n = 0;
for host in ["S", "s", "Other", "My Sheet"] {
for edit in edits {
let want = old::adjust(e.clone(), host, "S", edit);
let got = adjust(e.clone(), host, "S", edit);
assert!(got == want, "{src} on {host} after {edit:?}: {} vs {}", print(&got), print(&want));
assert_eq!(print(&got), print(&want), "{src} on {host} after {edit:?}");
match adjust_if_changed(&e, host, "S", edit) {
None => assert!(want == e, "{src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want)),
Some(x) => {
assert!(want != e, "{src} on {host} after {edit:?}: reported changed but the old code keeps it");
assert!(x == want, "{src} on {host} after {edit:?}: {} vs {}", print(&x), print(&want));
}
}
n += 1;
}
}
for (dr, dc) in [(0, 0), (1, 0), (0, 1), (-1, -1), (5, 3), (-3, 2), (MAX_ROWS as i64, 0), (0, -(MAX_COLS as i64)), (1_048_575, 16_383)] {
let want = old::shift_relative(e.clone(), dr, dc);
let got = shift_relative(e.clone(), dr, dc);
assert!(got == want, "{src} shifted by ({dr}, {dc}): {} vs {}", print(&got), print(&want));
}
n
}
#[test]
fn adjust_matches_the_previous_implementation_for_every_edit_kind() {
let edits = edits();
let mut cases = 0;
for src in FORMULAS {
let n = check(src, &edits);
assert!(n > 0, "{src} does not parse");
cases += n;
}
for i in 0..48 {
// Rows 1-2 and 1,048,565-1,048,566 of the mix (its longest range reaches 9 rows further
// down, to the last row): near both edges of the sheet.
let row = if i < 24 { (i / 12) as u32 } else { MAX_ROWS - 12 + (i / 12 - 2) as u32 };
let src = mix(i, row);
let n = check(&src, &edits);
assert!(n > 0, "{src} does not parse");
cases += n;
}
assert!(cases > 100_000, "{cases}");
}
/// A small deterministic generator: a failing case is reproduced by rerunning the test, and
/// its message names the case's index in the stream.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
fn line(&mut self, max: u32) -> u32 {
match self.below(3) {
0 => self.below(12) as u32,
1 => max - 1 - self.below(12) as u32,
_ => self.below(max as u64) as u32,
}
}
}
fn random_ref(rng: &mut Rng) -> String {
let sheet = ["", "", "S!", "s!", "Other!", "'My Sheet'!", "S:Other!"][rng.below(7) as usize];
let dollar = |rng: &mut Rng| if rng.below(3) == 0 { "$" } else { "" };
let cell = |rng: &mut Rng| format!("{}{}{}{}", dollar(rng), col(rng.line(MAX_COLS)), dollar(rng), rng.line(MAX_ROWS) + 1);
let body = match rng.below(4) {
0 => cell(rng),
1 => format!("{}:{}", cell(rng), cell(rng)),
2 => {
let (a, b) = (rng.line(MAX_ROWS) + 1, rng.line(MAX_ROWS) + 1);
format!("{}{a}:{}{b}", dollar(rng), dollar(rng))
}
_ => {
let (a, b) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
format!("{}{}:{}{}", dollar(rng), col(a), dollar(rng), col(b))
}
};
format!("{sheet}{body}")
}
fn random_edit(rng: &mut Rng) -> Edit {
let axis = if rng.below(2) == 0 { Axis::Rows } else { Axis::Cols };
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
let block = |rng: &mut Rng| {
let (r0, r1) = (rng.line(MAX_ROWS), rng.line(MAX_ROWS));
let (c0, c1) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
range(r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))
};
let count = if rng.below(8) == 0 { rng.below(u32::MAX as u64) as u32 } else { rng.below(20) as u32 };
match rng.below(7) {
0 => Edit::Insert { axis, at: rng.line(max), count },
1 => Edit::Delete { axis, at: rng.line(max), count },
2 => Edit::InsertCells { axis, range: block(rng) },
3 => Edit::DeleteCells { axis, range: block(rng) },
4 => Edit::Move { from: block(rng), to_row: rng.line(MAX_ROWS), to_col: rng.line(MAX_COLS) },
_ => Edit::MoveToSheet {
from: block(rng),
to_sheet: ["Dest", "S", "other"][rng.below(3) as usize].to_string(),
to_row: rng.line(MAX_ROWS),
to_col: rng.line(MAX_COLS),
},
}
}
/// Random references near the sheet's edges and random edits: "unchanged" is reported exactly
/// when the previous implementation leaves the formula as it was, and a reported change is the
/// old result.
#[test]
fn adjust_if_changed_reports_unchanged_exactly_when_nothing_moves() {
let mut rng = Rng(0x5eed);
let (mut unchanged, mut changed) = (0, 0);
for case in 0..20_000 {
let refs: Vec<String> = (0..1 + rng.below(4)).map(|_| random_ref(&mut rng)).collect();
let src = format!("SUM({})", refs.join(","));
let Ok(e) = parse(&src) else { panic!("case {case}: {src} does not parse") };
let edit = random_edit(&mut rng);
let host = ["S", "Other"][rng.below(2) as usize];
let want = old::adjust(e.clone(), host, "S", &edit);
assert!(adjust(e.clone(), host, "S", &edit) == want, "case {case}: {src} on {host} after {edit:?}");
match adjust_if_changed(&e, host, "S", &edit) {
None => {
assert!(want == e, "case {case}: {src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want));
unchanged += 1;
}
Some(x) => {
assert!(want != e && x == want, "case {case}: {src} on {host} after {edit:?}");
changed += 1;
}
}
}
// Both outcomes are exercised in quantity.
assert!(unchanged > 2_000 && changed > 2_000, "unchanged {unchanged}, changed {changed}");
}
/// The whole formula corpus (one formula per line, with or without `=`) when
/// `GRIDCRAFT_ADJUST_CORPUS` names a file: `cargo test -p gridcraft-formula --test it -- --ignored adjust_corpus`.
#[test]
#[ignore = "needs GRIDCRAFT_ADJUST_CORPUS"]
fn adjust_corpus_matches_the_previous_implementation() {
let Ok(path) = std::env::var("GRIDCRAFT_ADJUST_CORPUS") else { return };
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
let edits = edits();
let (mut parsed, mut skipped, mut cases) = (0, 0, 0);
for line in text.lines().filter(|l| !l.trim().is_empty()) {
match check(line, &edits) {
0 => skipped += 1,
n => {
parsed += 1;
cases += n;
}
}
}
eprintln!("corpus: {parsed} formulas parsed, {skipped} skipped (do not parse), {cases} cases");
}
#[test]
fn unaffected_formula_is_returned_as_is() {
let e: Expr = parse("Other!B2+SUM(A1:A3)").unwrap();
let ins = Edit::Insert { axis: Axis::Rows, at: 5, count: 1 };
// Row 6 inserted: A1:A3 lies above it and Other!B2 is not on S, so nothing moves, whichever
// sheet the formula lives on (on Other, A1:A3 is on Other too).
assert!(adjust_if_changed(&e, "S", "S", &ins).is_none());
assert!(adjust_if_changed(&e, "Other", "S", &ins).is_none());
// Row 1 inserted: A1:A3 on S moves, Other!B2 does not.
let top = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 };
assert_eq!(adjust_if_changed(&e, "S", "S", &top).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(A2:A4)"));
// A cut that moves a block onto the same place still qualifies its references with the sheet.
let same_place = Edit::MoveToSheet { from: range(0, 0, 2, 0), to_sheet: "S".into(), to_row: 0, to_col: 0 };
assert_eq!(adjust_if_changed(&e, "S", "S", &same_place).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(S!A1:A3)"));
}
+1
View File
@@ -1,4 +1,5 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod adjust_oracle;
mod printer_roundtrip;
mod spanish_input;
+28 -8
View File
@@ -88,11 +88,17 @@ pub(crate) fn reg_gamma_p(a: f64, x: f64) -> f64 {
if x.is_infinite() {
return 1.0;
}
if x < a + 1.0 { gamma_series(a, x) } else { 1.0 - gamma_cf(a, x) }
if x < a + 1.0 { gamma_series(a, x, front_of(a, x)) } else { 1.0 - gamma_cf(a, x, front_of(a, x)) }
}
/// Regularised upper incomplete gamma Q(a, x) = 1 − P(a, x).
pub(crate) fn reg_gamma_q(a: f64, x: f64) -> f64 {
let front = if a > 0.0 && x > 0.0 && x.is_finite() { (-x + a * x.ln() - ln_gamma(a)).exp() } else { 0.0 };
reg_gamma_q_front(a, x, front)
}
/// Q(a, x) given the prefactor `e^-x x^a / Γ(a)` (the Poisson mass at a - 1, times x).
pub(crate) fn reg_gamma_q_front(a: f64, x: f64, front: f64) -> f64 {
if a.is_nan() || a <= 0.0 || x.is_nan() || x < 0.0 || !a.is_finite() {
return f64::NAN;
}
@@ -102,10 +108,15 @@ pub(crate) fn reg_gamma_q(a: f64, x: f64) -> f64 {
if x.is_infinite() {
return 0.0;
}
if x < a + 1.0 { 1.0 - gamma_series(a, x) } else { gamma_cf(a, x) }
if x < a + 1.0 { 1.0 - gamma_series(a, x, front) } else { gamma_cf(a, x, front) }
}
fn gamma_series(a: f64, x: f64) -> f64 {
/// `e^-x x^a / Γ(a)`.
fn front_of(a: f64, x: f64) -> f64 {
(-x + a * x.ln() - ln_gamma(a)).exp()
}
fn gamma_series(a: f64, x: f64, front: f64) -> f64 {
let mut ap = a;
let mut sum = 1.0 / a;
let mut del = sum;
@@ -117,10 +128,10 @@ fn gamma_series(a: f64, x: f64) -> f64 {
break;
}
}
sum * (-x + a * x.ln() - ln_gamma(a)).exp()
sum * front
}
fn gamma_cf(a: f64, x: f64) -> f64 {
fn gamma_cf(a: f64, x: f64, front: f64) -> f64 {
let tiny = 1e-300;
let mut b = x + 1.0 - a;
let mut c = 1.0 / tiny;
@@ -144,7 +155,7 @@ fn gamma_cf(a: f64, x: f64) -> f64 {
break;
}
}
(-x + a * x.ln() - ln_gamma(a)).exp() * h
front * h
}
/// Error function.
@@ -187,6 +198,17 @@ pub(crate) fn erfc(x: f64) -> f64 {
/// Regularised incomplete beta I_x(a, b).
pub(crate) fn reg_inc_beta(x: f64, a: f64, b: f64) -> f64 {
if x > 0.0 && x < 1.0 && a > 0.0 && b > 0.0 {
let ln_bt = -ln_beta(a, b) + a * x.ln() + b * (1.0 - x).ln();
return reg_inc_beta_front(x, a, b, ln_bt.exp());
}
reg_inc_beta_front(x, a, b, 0.0)
}
/// I_x(a, b) given the prefactor `bt = x^a (1-x)^b / B(a, b)` (symmetric in the two sides). A
/// caller that knows `bt` more accurately than the log-gamma route gives (the binomial mass, for
/// instance) passes it here.
pub(crate) fn reg_inc_beta_front(x: f64, a: f64, b: f64, bt: f64) -> f64 {
if a.is_nan() || a <= 0.0 || b.is_nan() || b <= 0.0 || !(0.0..=1.0).contains(&x) {
return f64::NAN;
}
@@ -196,8 +218,6 @@ pub(crate) fn reg_inc_beta(x: f64, a: f64, b: f64) -> f64 {
if x == 1.0 {
return 1.0;
}
let ln_bt = -ln_beta(a, b) + a * x.ln() + b * (1.0 - x).ln();
let bt = ln_bt.exp();
if x < (a + 1.0) / (a + b + 2.0) { bt * beta_cf(a, b, x) / a } else { 1.0 - bt * beta_cf(b, a, 1.0 - x) / b }
}
+4 -1
View File
@@ -3,7 +3,10 @@
use gridcraft_core::{CellError, Value};
use crate::special::{erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, reg_gamma_p, reg_gamma_q, reg_inc_beta};
use crate::special::{
erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, reg_gamma_p, reg_gamma_q, reg_gamma_q_front, reg_inc_beta,
reg_inc_beta_front,
};
use crate::util::{A, R, S, arg, array_numbers, array_val, as_array, boolean, has, num, num_val, numbers, numbers_a, opt_num};
use crate::{Arg, Ctx, FnSpec, VAR};
+183 -17
View File
@@ -1,7 +1,7 @@
use super::descriptive::{arg_nums, arg_pairs, mean, var_s};
use super::{
Arg, C, CellError, R, Value, arg, as_array, boolean, erf, gamma, has, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, num, num_val,
reg_gamma_p, reg_gamma_q, reg_inc_beta,
reg_gamma_p, reg_gamma_q, reg_gamma_q_front, reg_inc_beta, reg_inc_beta_front,
};
// ---------------------------------------------------------------------------------------------
@@ -93,6 +93,97 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
/// Stirling-series error `ln n! - ((n + 1/2) ln n - n + ln(2π)/2)` for integer `n >= 1`: exact
/// table (computed with 60-digit Decimal arithmetic) up to 30, then the series, whose first
/// omitted term is below 1e-19 there.
fn stirlerr(n: f64) -> f64 {
const TABLE: [f64; 31] = [
0.0,
0.08106146679532726,
0.0413406959554093,
0.02767792568499834,
0.020790672103765093,
0.016644691189821193,
0.013876128823070748,
0.01189670994589177,
0.010411265261972096,
0.009255462182712733,
0.00833056343336287,
0.007573675487951841,
0.00694284010720953,
0.006408994188004207,
0.0059513701127588475,
0.005554733551962801,
0.0052076559196096404,
0.004901395948434738,
0.004629153749334028,
0.004385560249232324,
0.004166319691996922,
0.00396795421864086,
0.0037876180684444346,
0.0036229602246830948,
0.003472021382978767,
0.003333155636728093,
0.003204970228055038,
0.0030862786826087773,
0.002976063983550409,
0.0028734493623524663,
0.0027776749297526936,
];
if n <= 30.0 {
return TABLE.get(n as usize).copied().unwrap_or(0.0);
}
let r = 1.0 / n;
let r2 = r * r;
r * (1.0 / 12.0 - r2 * (1.0 / 360.0 - r2 * (1.0 / 1260.0 - r2 * (1.0 / 1680.0 - r2 * (1.0 / 1188.0)))))
}
/// `x ln(x/np) + np - x` without the cancellation of the direct formula when `x` is near `np`
/// (Loader, 2000).
fn bd0(x: f64, np: f64) -> f64 {
if (x - np).abs() < 0.1 * (x + np) {
let v = (x - np) / (x + np);
let mut s = (x - np) * v;
if s.abs() < f64::MIN_POSITIVE {
return s;
}
let mut ej = 2.0 * x * v;
let v2 = v * v;
for j in 1..1000 {
ej *= v2;
let s1 = s + ej / f64::from(2 * j + 1);
if s1 == s {
return s1;
}
s = s1;
}
}
x * (x / np).ln() + np - x
}
/// `(1 - p)^e` with the rounding error of `1 - p` (it is not exact for `p < 1/2`) folded back in,
/// which `e` would otherwise multiply.
fn pow_one_minus(p: f64, e: f64) -> f64 {
let hi = 1.0 - p;
let lo = (1.0 - hi) - p;
let r = hi.powf(e) * (e * (lo / hi).ln_1p()).exp();
// An exponent so large that the two factors under- and overflow: the plain form is enough.
if r.is_nan() { (e * (-p).ln_1p()).exp() } else { r }
}
/// `C(n, k)` for a small `k` as a product of `k` ratios.
fn choose_small(n: f64, k: f64) -> f64 {
let mut c = 1.0;
let mut j = 0.0;
while j < k {
c *= (n - j) / (j + 1.0);
j += 1.0;
}
c
}
/// Binomial mass at integer `k`, `0 <= k <= n`, by Loader's saddle-point formula: no log-gamma
/// of large numbers, so the relative error stays near 1e-15 for any `n`.
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if k < 0.0 || k > n {
return 0.0;
@@ -103,7 +194,67 @@ fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if p == 1.0 {
return if k == n { 1.0 } else { 0.0 };
}
(ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp()
if k == 0.0 {
return if n == 0.0 { 1.0 } else { pow_one_minus(p, n) };
}
if k == n {
return p.powf(n);
}
// A few successes or a few failures: the product form has no large cancelling terms.
// (Not for astronomically large n, or when either power underflows: the
// saddle-point form below handles those through logs.)
if n < 1e30 && p.powf(k) > 1e-280 {
if k < 16.0 {
let rest = pow_one_minus(p, n - k);
if rest >= f64::MIN_POSITIVE {
return choose_small(n, k) * p.powi(k as i32) * rest;
}
}
if n - k < 16.0 {
return choose_small(n, n - k) * p.powf(k) * pow_one_minus(p, n - k);
}
}
let q = 1.0 - p;
let lc = stirlerr(n) - stirlerr(k) - stirlerr(n - k) - bd0(k, n * p) - bd0(n - k, n * q);
let lf = std::f64::consts::TAU.ln() + k.ln() + (-k / n).ln_1p();
(lc - 0.5 * lf).exp()
}
/// Sum of the binomial masses 0..=k for `n <= 10_000`: starts at the term nearest the mode
/// (accurate by the formula above) and walks away from it by the ratio of neighbouring masses,
/// one multiply per term, stopping once the terms no longer matter. Above the mode it sums the
/// upper tail instead and subtracts it from 1.
fn binom_cdf_sum(k: f64, n: f64, p: f64) -> f64 {
let q = 1.0 - p;
if p == 0.0 {
return 1.0;
}
if p == 1.0 {
return 0.0;
}
if k <= n * p {
// mass(i - 1) = mass(i) * i / (n - i + 1) * q / p
let mut t = binom_pmf(k, n, p);
let mut s = t;
let mut i = k;
while i >= 1.0 && t > s * 1e-18 {
t *= i / (n - i + 1.0) * (q / p);
s += t;
i -= 1.0;
}
s.min(1.0)
} else {
// mass(i + 1) = mass(i) * (n - i) / (i + 1) * p / q
let mut t = binom_pmf(k + 1.0, n, p);
let mut s = t;
let mut i = k + 1.0;
while i < n && t > s * 1e-18 {
t *= (n - i) / (i + 1.0) * (p / q);
s += t;
i += 1.0;
}
(1.0 - s).max(0.0)
}
}
fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
@@ -114,13 +265,7 @@ fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
return 1.0;
}
if n <= 10_000.0 {
let mut s = 0.0;
let mut i = 0.0;
while i <= k {
s += binom_pmf(i, n, p);
i += 1.0;
}
return s.min(1.0);
return binom_cdf_sum(k, n, p);
}
if p == 0.0 {
return 1.0;
@@ -128,14 +273,19 @@ fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
if p == 1.0 {
return 0.0;
}
reg_inc_beta(1.0 - p, n - k, k + 1.0)
// I_(1-p)(n-k, k+1) has prefactor (n-k) p mass(k).
reg_inc_beta_front(1.0 - p, n - k, k + 1.0, (n - k) * p * binom_pmf(k, n, p))
}
/// Poisson mass at integer `x >= 0` by Loader's formula.
fn poisson_pmf(x: f64, m: f64) -> f64 {
if m == 0.0 {
return if x == 0.0 { 1.0 } else { 0.0 };
}
(-m + x * m.ln() - ln_gamma(x + 1.0)).exp()
if x == 0.0 {
return (-m).exp();
}
(-stirlerr(x) - bd0(x, m)).exp() / (std::f64::consts::TAU * x).sqrt()
}
fn poisson_cdf(x: f64, m: f64) -> f64 {
@@ -143,15 +293,31 @@ fn poisson_cdf(x: f64, m: f64) -> f64 {
return 1.0;
}
if x <= 1000.0 {
let mut s = 0.0;
let mut i = 0.0;
while i <= x {
s += poisson_pmf(i, m);
if x <= m {
// mass(i - 1) = mass(i) * i / m, downwards from x while the terms matter
let mut t = poisson_pmf(x, m);
let mut s = t;
let mut i = x;
while i >= 1.0 && t > s * 1e-18 {
t *= i / m;
s += t;
i -= 1.0;
}
return s.min(1.0);
}
// Above the mean: 1 minus the upper tail, mass(i + 1) = mass(i) * m / (i + 1).
let mut t = poisson_pmf(x + 1.0, m);
let mut s = t;
let mut i = x + 1.0;
while t > s * 1e-18 {
t *= m / (i + 1.0);
s += t;
i += 1.0;
}
return s.min(1.0);
return (1.0 - s).max(0.0);
}
reg_gamma_q(x + 1.0, m)
// Q(x+1, m) has prefactor m mass(x).
reg_gamma_q_front(x + 1.0, m, m * poisson_pmf(x, m))
}
pub(super) fn norm_dist(a: &[Arg], _: C) -> R<Value> {
+242
View File
@@ -740,6 +740,248 @@ fn aggregate_helper() {
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
}
// B1: BINOM.DIST and POISSON.DIST went through ln_gamma differences (relative error 1.1e-13 at
// n = 100, 1.5e-11 at n = 10,000). Reference values: 70-digit Decimal arithmetic on the exact
// double `p` (mass by the neighbour-ratio recurrence from q^n, cdf as the running sum), rounded
// once to f64. Columns: k, n, p, mass, cdf.
#[rustfmt::skip]
const BINOM_REF: &[(f64, f64, f64, f64, f64)] = &[
(0.0, 10.0, 0.5, 0.0009765625, 0.0009765625),
(1.0, 10.0, 0.5, 0.009765625, 0.0107421875),
(3.0, 10.0, 0.5, 0.1171875, 0.171875),
(5.0, 10.0, 0.5, 0.24609375, 0.623046875),
(7.0, 10.0, 0.5, 0.1171875, 0.9453125),
(9.0, 10.0, 0.5, 0.009765625, 0.9990234375),
(0.0, 10.0, 0.3, 0.028247524900000005, 0.028247524900000005),
(1.0, 10.0, 0.3, 0.12106082100000001, 0.14930834590000003),
(2.0, 10.0, 0.3, 0.23347444050000002, 0.3827827864),
(3.0, 10.0, 0.3, 0.266827932, 0.6496107184000001),
(4.0, 10.0, 0.3, 0.200120949, 0.8497316674000001),
(5.0, 10.0, 0.3, 0.10291934519999998, 0.9526510126),
(7.0, 10.0, 0.3, 0.009001691999999999, 0.9984096136),
(9.0, 10.0, 0.3, 0.00013778099999999996, 0.9999940951),
(0.0, 10.0, 0.999, 1.0000000000000088e-30, 1.0000000000000088e-30),
(1.0, 10.0, 0.999, 9.99000000000008e-27, 9.99100000000008e-27),
(5.0, 10.0, 0.999, 2.507425174812609e-13, 2.50951798425701e-13),
(9.0, 10.0, 0.999, 0.00991035916125875, 0.009955119790251798),
(0.0, 100.0, 0.5, 7.888609052210118e-31, 7.888609052210118e-31),
(1.0, 100.0, 0.5, 7.888609052210118e-29, 7.967495142732219e-29),
(35.0, 100.0, 0.5, 0.0008638556657416528, 0.0017588208614850792),
(45.0, 100.0, 0.5, 0.048474296626430755, 0.18410080866334813),
(50.0, 100.0, 0.5, 0.07958923738717877, 0.5397946186935894),
(55.0, 100.0, 0.5, 0.048474296626430755, 0.8643734879630827),
(65.0, 100.0, 0.5, 0.0008638556657416528, 0.9991050348042566),
(99.0, 100.0, 0.5, 7.888609052210118e-29, 1.0),
(0.0, 100.0, 0.01, 0.3660323412732295, 0.3660323412732295),
(1.0, 100.0, 0.01, 0.3697296376497268, 0.7357619789229563),
(2.0, 100.0, 0.01, 0.1848648188248634, 0.9206267977478196),
(4.0, 100.0, 0.01, 0.014941714856895142, 0.9965676784122455),
(50.0, 100.0, 0.01, 6.103987557173001e-72, 1.0),
(99.0, 100.0, 0.01, 9.900000000000021e-197, 1.0),
(0.0, 100.0, 0.7, 5.15377520732019e-53, 5.15377520732019e-53),
(1.0, 100.0, 0.7, 1.2025475483747105e-50, 1.207701323582031e-50),
(50.0, 100.0, 0.7, 1.302622713144541e-05, 2.2060913327166086e-05),
(56.0, 100.0, 0.7, 0.001028871216633018, 0.0021146172813184595),
(65.0, 100.0, 0.7, 0.04677968235272982, 0.16285828837178737),
(70.0, 100.0, 0.7, 0.08678386475342809, 0.5376602639846381),
(75.0, 100.0, 0.7, 0.04955992276217012, 0.8864298182958186),
(84.0, 100.0, 0.7, 0.0005638655029474704, 0.9995950004580563),
(99.0, 100.0, 0.7, 1.3862042184106021e-14, 0.9999999999999997),
(0.0, 1000.0, 0.5, 9.332636185032189e-302, 9.332636185032189e-302),
(1.0, 1000.0, 0.5, 9.332636185032189e-299, 9.341968821217221e-299),
(453.0, 1000.0, 0.5, 0.00030353498700958767, 0.00162629990658169),
(484.0, 1000.0, 0.5, 0.015123665829158384, 0.1634683412157977),
(500.0, 1000.0, 0.5, 0.0252250181783608, 0.5126125090891804),
(516.0, 1000.0, 0.5, 0.015123665829158384, 0.8516553246133607),
(547.0, 1000.0, 0.5, 0.00030353498700958767, 0.9986772350804279),
(999.0, 1000.0, 0.5, 9.332636185032189e-299, 1.0),
(0.0, 1000.0, 0.3, 1.2532566399657381e-155, 1.2532566399657381e-155),
(1.0, 1000.0, 0.3, 5.371099885567449e-153, 5.383632451967106e-153),
(257.0, 1000.0, 0.3, 0.00030959977077928017, 0.0014835731103312242),
(286.0, 1000.0, 0.3, 0.0174263984921581, 0.1759288949498673),
(300.0, 1000.0, 0.3, 0.027521003821268385, 0.5155935198141203),
(314.0, 1000.0, 0.3, 0.017109129819617666, 0.8414952343056957),
(343.0, 1000.0, 0.3, 0.00036310112605077133, 0.9985016810663147),
(500.0, 1000.0, 0.3, 3.4791609391626546e-40, 1.0),
(999.0, 1000.0, 0.3, 0.0, 1.0),
(0.0, 1000.0, 0.001, 0.36769542477096406, 0.36769542477096406),
(1.0, 1000.0, 0.001, 0.36806348825922325, 0.7357589130301873),
(2.0, 1000.0, 0.001, 0.18403174412961162, 0.9197906571597989),
(4.0, 1000.0, 0.001, 0.01528995542083862, 0.9963631219704782),
(500.0, 1000.0, 0.001, 0.0, 1.0),
(999.0, 1000.0, 0.001, 0.0, 1.0),
(0.0, 10000.0, 0.5, 0.0, 0.0),
(1.0, 10000.0, 0.5, 0.0, 0.0),
(4850.0, 10000.0, 0.5, 8.861480551702583e-05, 0.0013939837588563594),
(4950.0, 10000.0, 0.5, 0.004839495142316416, 0.161087099897656),
(5000.0, 10000.0, 0.5, 0.007978646139382154, 0.5039893230696911),
(5050.0, 10000.0, 0.5, 0.004839495142316416, 0.8437523952446604),
(5150.0, 10000.0, 0.5, 8.861480551702583e-05, 0.9986946310466607),
(9999.0, 10000.0, 0.5, 0.0, 1.0),
(0.0, 10000.0, 0.3, 0.0, 0.0),
(1.0, 10000.0, 0.3, 0.0, 0.0),
(2863.0, 10000.0, 0.3, 9.71726742023389e-05, 0.0013927715709303387),
(2954.0, 10000.0, 0.3, 0.0052756073908582085, 0.16038574844431697),
(3000.0, 10000.0, 0.3, 0.008705361365067056, 0.504932983819298),
(3046.0, 10000.0, 0.3, 0.005244996814931252, 0.8448693061622872),
(3137.0, 10000.0, 0.3, 0.00010232641238302388, 0.9986013755801396),
(5000.0, 10000.0, 0.3, 0.0, 1.0),
(9999.0, 10000.0, 0.3, 0.0, 1.0),
(0.0, 10000.0, 0.0001, 0.3678610464329299, 0.3678610464329299),
(1.0, 10000.0, 0.0001, 0.3678978362165516, 0.7357588826494815),
(2.0, 10000.0, 0.0001, 0.1839489181082758, 0.9197078007577573),
(4.0, 10000.0, 0.0001, 0.015324477632794846, 0.996342452182816),
(5000.0, 10000.0, 0.0001, 0.0, 1.0),
(9999.0, 10000.0, 0.0001, 0.0, 1.0),
(0.0, 10000.0, 0.999, 0.0, 0.0),
(1.0, 10000.0, 0.999, 0.0, 0.0),
(5000.0, 10000.0, 0.999, 0.0, 0.0),
(9981.0, 10000.0, 0.999, 0.0037205945263045544, 0.007158161342006954),
(9987.0, 10000.0, 0.999, 0.07292252318084555, 0.20834867055478343),
(9990.0, 10000.0, 0.999, 0.12517263665023892, 0.5421328760209826),
(9993.0, 10000.0, 0.999, 0.09007019224850273, 0.8699847268232276),
(9999.0, 10000.0, 0.999, 0.00045218564541589875, 0.9999548266540229),
(0.0, 100000.0, 0.5, 0.0, 0.0),
(1.0, 100000.0, 0.5, 0.0, 0.0),
(49526.0, 100000.0, 0.5, 2.821098952769392e-05, 0.00137358872189177),
(49842.0, 100000.0, 0.5, 0.0015314620321580088, 0.15959653953914368),
(50000.0, 100000.0, 0.5, 0.00252312621419674, 0.5012615631070984),
(50158.0, 100000.0, 0.5, 0.0015314620321580088, 0.8419349224930143),
(50474.0, 100000.0, 0.5, 2.821098952769392e-05, 0.9986546222676359),
(99999.0, 100000.0, 0.5, 0.0, 1.0),
(0.0, 100000.0, 0.3, 0.0, 0.0),
(1.0, 100000.0, 0.3, 0.0, 0.0),
(29565.0, 100000.0, 0.3, 3.016598258904529e-05, 0.0013408650955754554),
(29855.0, 100000.0, 0.3, 0.0016703029966719045, 0.15934769308464344),
(30000.0, 100000.0, 0.3, 0.002752954648397428, 0.5015600059172672),
(30145.0, 100000.0, 0.3, 0.0016672321665529399, 0.8423208126454166),
(30435.0, 100000.0, 0.3, 3.067097885268188e-05, 0.9986570625478413),
(50000.0, 100000.0, 0.3, 0.0, 1.0),
(99999.0, 100000.0, 0.3, 0.0, 1.0),
(0.0, 100000.0, 0.99, 0.0, 0.0),
(1.0, 100000.0, 0.99, 0.0, 0.0),
(50000.0, 100000.0, 0.99, 0.0, 0.0),
(98906.0, 100000.0, 0.99, 0.00015962501729101418, 0.001680685850467731),
(98969.0, 100000.0, 0.99, 0.007723834924565287, 0.1660975153530963),
(99000.0, 100000.0, 0.99, 0.012678161323544589, 0.5042686879721229),
(99031.0, 100000.0, 0.99, 0.007885853372194001, 0.8416321140066325),
(99094.0, 100000.0, 0.99, 0.00013276363518518057, 0.9988439452168577),
(99999.0, 100000.0, 0.99, 0.0, 1.0),
// Small k where (1 - p)^(n - k) underflows although the mass is a normal number.
(10.0, 1100.0, 0.5, 5.050689010687844e-308, 5.097367491037505e-308),
(14.0, 1100.0, 0.5, 2.9513324041838846e-300, 2.989803284040754e-300),
(15.0, 1100.0, 0.5, 2.1367646606291324e-298, 2.16666269346954e-298),
(16.0, 1100.0, 0.5, 1.4489935354891303e-296, 1.4706601624238258e-296),
(20.0, 1100.0, 0.5, 1.711087104904223e-289, 1.7433098849775326e-289),
(25.0, 1100.0, 0.5, 3.906996229178926e-281, 3.999839082741416e-281),
];
// Columns: x, mean, mass, cdf. The last block has x > 1000, which takes the incomplete-gamma branch.
#[rustfmt::skip]
const POISSON_REF: &[(f64, f64, f64, f64)] = &[
(0.0, 0.5, 0.6065306597126334, 0.6065306597126334),
(1.0, 0.5, 0.3032653298563167, 0.9097959895689501),
(3.0, 0.5, 0.012636055410679864, 0.9982483774437092),
(0.0, 5.0, 0.006737946999085467, 0.006737946999085467),
(2.0, 5.0, 0.08422433748856833, 0.12465201948308115),
(5.0, 5.0, 0.1754673697678507, 0.6159606548330632),
(10.0, 5.0, 0.018132788707821874, 0.986304731401617),
(25.0, 5.0, 1.294589320478486e-10, 0.9999999999695003),
(70.0, 100.0, 0.00031056089812001654, 0.0009714440282773882),
(100.0, 100.0, 0.039860996809147134, 0.5265621985299984),
(130.0, 100.0, 0.000575252683815463, 0.9982931596294985),
(900.0, 1000.0, 7.516954352125952e-05, 0.0006977673277963068),
(1000.0, 1000.0, 0.012614611348721499, 0.508409367168506),
(1100.0, 1000.0, 9.498944242299507e-05, 0.9991323590365564),
(1050.0, 1000.0, 0.003599490404803648, 0.9439711616363267),
(1200.0, 1000.0, 7.992642848843571e-11, 0.9999999996115061),
(1500.0, 2000.0, 1.878037542328086e-32, 7.468034373687017e-32),
(2000.0, 2000.0, 0.00892024889598624, 0.5059467004517837),
(2100.0, 2000.0, 0.0007442234118916615, 0.9872092799080792),
(2300.0, 2000.0, 4.011826753210622e-12, 0.999999999973895),
(4800.0, 5000.0, 9.987733294132511e-05, 0.002269403878343364),
(5000.0, 5000.0, 0.005641801804664022, 0.5037611677708467),
];
fn rel_err(got: Value, want: f64) -> f64 {
// An error or a non-finite result is the worst possible miss, not a skipped one.
let Some(g) = got.as_f64().filter(|g| g.is_finite()) else { return f64::INFINITY };
if want == 0.0 { g.abs() } else { ((g - want) / want).abs() }
}
/// Error bound the grid below must meet: 1e-13, plus 2e-16 per unit of |ln value| for far-tail
/// results (an exp of a sum of large logs cannot do better in double precision). Measured: at
/// most 9e-14 on the grid (the n = 100,000 cumulative cases), 2e-14 for n <= 10,000 away from the
/// far tails, 2e-15 on the Excel-sized central cases. The log-gamma code was off by 1.5e-11.
fn allowed(want: f64) -> f64 {
// A reference that underflows to 0 must come back as (almost) 0, not as any finite value.
if want == 0.0 {
return 1e-300;
}
1e-13 + 2e-16 * want.abs().ln().abs()
}
#[test]
fn binom_and_poisson_match_exact_decimal_references() {
// Worst error relative to the allowance (above 1 is a failure) and plain worst error.
let (mut worst, mut worst_plain) = (0.0f64, 0.0f64);
let mut check = |what: &str, got: Value, want: f64| {
let e = rel_err(got, want);
let ratio = e / allowed(want);
if ratio > 1.0 {
println!("{what}: error {e:e}, allowed {:e}", allowed(want));
}
worst = worst.max(ratio);
if want > 1e-3 {
worst_plain = worst_plain.max(e);
}
};
for &(k, nn, p, pmf, cdf) in BINOM_REF {
check(&format!("BINOM.DIST({k},{nn},{p},FALSE)"), ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(false)]), pmf);
check(&format!("BINOM.DIST({k},{nn},{p},TRUE)"), ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(true)]), cdf);
}
for &(x, m, pmf, cdf) in POISSON_REF {
check(&format!("POISSON.DIST({x},{m},FALSE)"), ev("POISSON.DIST", vec![n(x), n(m), b(false)]), pmf);
check(&format!("POISSON.DIST({x},{m},TRUE)"), ev("POISSON.DIST", vec![n(x), n(m), b(true)]), cdf);
}
println!("worst error / allowance {worst:.3}; worst relative error where the result is above 1e-3: {worst_plain:e}");
assert!(worst <= 1.0);
assert!(worst_plain < 1e-13);
}
#[test]
fn binom_and_poisson_edge_cases() {
for cum in [false, true] {
let pm = |k: f64, nn: f64, p: f64| ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(cum)]);
// p = 0 and p = 1
close(pm(0.0, 50.0, 0.0), 1.0);
close(pm(3.0, 50.0, 1.0), 0.0);
close(pm(50.0, 50.0, 1.0), 1.0);
// n = 0
close(pm(0.0, 0.0, 0.3), 1.0);
// k = n is certain when cumulative and p^n otherwise
close(pm(20.0, 20.0, 0.5), if cum { 1.0 } else { 0.5f64.powi(20) });
close_tol(pm(10_000.0, 10_000.0, 0.999), if cum { 1.0 } else { 0.999f64.powi(10_000) }, 1e-12);
// k = 0 is q^n
close_tol(pm(0.0, 1000.0, 0.001), 0.999f64.powi(1000), 1e-12);
close_tol(pm(0.0, 100_000.0, 0.00001), (1.0f64 - 1e-5).powi(100_000), 1e-9);
}
close(ev("POISSON.DIST", vec![n(0.0), n(0.0), b(false)]), 1.0);
close(ev("POISSON.DIST", vec![n(4.0), n(0.0), b(false)]), 0.0);
close(ev("POISSON.DIST", vec![n(4.0), n(0.0), b(true)]), 1.0);
close_tol(ev("POISSON.DIST", vec![n(0.0), n(3.0), b(true)]), (-3.0f64).exp(), 1e-15);
// Fractions truncate as before.
close(ev("BINOM.DIST", vec![n(6.9), n(10.9), n(0.5), b(false)]), 0.205078125);
// The 10,000 trial switch of the method no longer shows: neighbours agree to 1e-14.
let lo = ev("BINOM.DIST", vec![n(5000.0), n(10_000.0), n(0.5), b(true)]).as_f64().unwrap();
let hi = ev("BINOM.DIST", vec![n(5000.0), n(10_001.0), n(0.5), b(true)]).as_f64().unwrap();
let pmf = ev("BINOM.DIST", vec![n(5000.0), n(10_000.0), n(0.5), b(false)]).as_f64().unwrap();
// cdf(5000; 10,001) = cdf(5000; 10,000) - p * mass(5000; 10,000) by the recurrence on n
assert!(((hi - (lo - 0.5 * pmf)) / hi).abs() < 1e-13, "{lo} {hi}");
// Inverse stays consistent: the smallest k with cdf >= alpha.
close(ev("BINOM.INV", vec![n(10_000.0), n(0.5), n(0.5)]), 5000.0);
}
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
+79 -3
View File
@@ -11,6 +11,7 @@ use crate::style::StyleId;
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "SavedFormula")]
pub struct Formula {
pub text: String,
#[serde(skip)]
@@ -39,15 +40,15 @@ impl Formula {
pub fn from_expr(e: Expr) -> Formula {
Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }
}
/// The parsed expression, re-parsing after deserialization.
/// The parsed expression, re-parsing when no tree is cached.
pub fn expr(&self) -> Option<Expr> {
self.expr.as_deref().cloned().or_else(|| gridcraft_formula::parse(&self.text).ok())
}
/// The parsed expression without copying it (re-parses after deserialization).
/// The parsed expression without copying it (re-parses when no tree is cached).
pub fn expr_arc(&self) -> Option<Arc<Expr>> {
self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new))
}
/// Runs `f` on the parsed expression, borrowing the cached tree or, after deserialization,
/// Runs `f` on the parsed expression, borrowing the cached tree or, when none is cached,
/// a freshly parsed one that is dropped afterwards. Unlike [`expr`](Self::expr) it never copies
/// the tree, and unlike [`expr_arc`](Self::expr_arc) it needs no reference count or `Arc`
/// allocation. `None` when the text does not parse.
@@ -59,6 +60,25 @@ impl Formula {
}
}
/// A formula as a saved workbook holds it: the tree is not saved. Reading one parses its text
/// once and caches the tree, so a workbook opened from JSON does not parse every formula again on
/// each recalculation. The text is kept as saved, and text that doesn't parse caches nothing, as
/// [`Formula::new`] does.
#[derive(Deserialize)]
struct SavedFormula {
text: String,
array: Option<RangeRef>,
#[serde(default)]
dynamic: bool,
}
impl From<SavedFormula> for Formula {
fn from(f: SavedFormula) -> Formula {
let expr = gridcraft_formula::parse(&f.text).ok().map(Arc::new);
Formula { text: f.text, expr, array: f.array, dynamic: f.dynamic }
}
}
/// A static picture stored as cell content, independent of floating drawing anchors.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellPicture {
@@ -131,3 +151,59 @@ impl Cell {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_core::CellRef;
#[test]
fn formulas_read_from_json_keep_their_text_and_cache_the_tree() -> Result<(), serde_json::Error> {
let mut array = Formula::new("=A1:B2*2");
array.array = Some(RangeRef::new(CellRef::new(0, 2), CellRef::new(1, 3)));
array.dynamic = true;
for f in [Formula::new("=SUM(A1:A3)+'My Sheet'!B2"), Formula::new("=LET(x,A1*2,x+1)"), array] {
let back: Formula = serde_json::from_str(&serde_json::to_string(&f)?)?;
assert!(back.expr.is_some(), "{}: the tree is cached after reading", f.text);
assert_eq!(back, f);
}
// Saved text is kept as written, even when the canonical print differs, and its tree is
// the one the text parses to.
let back: Formula = serde_json::from_str(r#"{"text": "sum( a1 , 2 )", "array": null}"#)?;
assert_eq!(back.text, "sum( a1 , 2 )");
assert!(!back.dynamic);
assert_eq!(back.expr.as_deref(), gridcraft_formula::parse("sum( a1 , 2 )").ok().as_ref());
assert!(back.expr.is_some());
// Text that doesn't parse caches nothing, as `Formula::new` does.
let bad = Formula::new("=SUM(");
let back: Formula = serde_json::from_str(&serde_json::to_string(&bad)?)?;
assert_eq!(back, bad);
assert!(back.expr.is_none());
Ok(())
}
#[test]
fn a_workbook_read_from_json_has_every_formula_tree() -> Result<(), Box<dyn std::error::Error>> {
let mut wb = crate::Workbook::new();
let mut array = Formula::new("=A1:A3*2");
array.array = Some(RangeRef::new(CellRef::new(0, 3), CellRef::new(2, 3)));
let sheet = wb.sheet_mut(0).ok_or("no sheet")?;
sheet.cells.set(CellRef::new(0, 0), Cell::value(Value::Number(1.0)));
sheet.cells.set(CellRef::new(0, 1), Cell::formula(Formula::new("=SUM(A1:A3)")));
sheet.cells.set(CellRef::new(1, 1), Cell::formula(Formula::new("=IF(A1>0,\"yes\",\"no\")")));
sheet.cells.set(CellRef::new(2, 1), Cell::formula(Formula::new("=SUM(")));
sheet.cells.set(CellRef::new(0, 3), Cell::formula(array));
let json = serde_json::to_string(&wb)?;
let back: crate::Workbook = serde_json::from_str(&json)?;
let (before, after) = (wb.sheet(0).ok_or("no sheet")?, back.sheet(0).ok_or("no sheet")?);
let mut formulas = 0;
for (c, cell) in before.cells.iter() {
let (Some(f), Some(read)) = (&cell.formula, after.cell(c).and_then(|cell| cell.formula.as_ref())) else { continue };
assert_eq!(read, f, "{c:?}: text, tree and flags as saved");
assert_eq!(read.expr.is_some(), gridcraft_formula::parse(&f.text).is_ok(), "{c:?}: every formula that parses has its tree");
formulas += 1;
}
assert_eq!(formulas, 4);
Ok(())
}
}
+8 -6
View File
@@ -491,12 +491,14 @@ impl Sheet {
/// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table.
/// Other hidden rows were hidden by hand.
pub fn is_row_filtered(&self, row: u32) -> bool {
if !self.is_row_hidden(row) {
return false;
}
let in_sheet_filter =
self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row);
in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row))
self.is_row_hidden(row) && self.filter_row_spans().iter().any(|&(first, last)| row >= first && row <= last)
}
/// The row spans (first, last) an applied filter can hide: the data rows of the sheet
/// AutoFilter while it has criteria, and of each filtered table.
pub fn filter_row_spans(&self) -> Vec<(u32, u32)> {
let sheet = self.autofilter.as_ref().filter(|af| !af.criteria.is_empty()).map(|af| (af.range.start.row.saturating_add(1), af.range.end.row));
let tables = self.tables.iter().filter(|t| !t.filter.is_empty()).filter_map(|t| t.data_range()).map(|r| (r.start.row, r.end.row));
sheet.into_iter().chain(tables).collect()
}
pub fn is_col_hidden(&self, col: u32) -> bool {
self.cols.get(&col).is_some_and(|r| r.hidden)
+367 -35
View File
@@ -4,6 +4,8 @@
//! copies only the band pointers; editing a cell copies just its band.
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::ops::RangeInclusive;
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
@@ -266,64 +268,187 @@ impl CellStore {
}
bytes
}
/// Removes every cell in `r` and returns them.
/// Removes every cell in `r` and returns them, row-major.
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect()
if r.end.col < gridcraft_core::MAX_COLS - 1 {
// A block with columns to its right: cutting it out of each row map measured slower
// than removing its cells one by one (#3575).
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
return keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect();
}
let rows = self.take_rows(r.start.row..=r.end.row, r.start.col..=r.end.col);
rows.into_iter().flat_map(|(row, cols)| cols.into_iter().map(move |(col, cell)| (CellRef::new(row, col), cell))).collect()
}
/// Rows strictly greater than or equal to `from` moved by `delta` (positive = down). Rows that
/// would land before `from`... callers delete the vacated block first.
pub fn shift_rows(&mut self, from: u32, delta: i64) {
let moved: Vec<(CellRef, Cell)> = {
let keys: Vec<CellRef> = self.iter().filter(|(c, _)| c.row >= from).map(|(c, _)| c).collect();
keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect()
};
for (c, cell) in moved {
let nr = c.row as i64 + delta;
if (0..gridcraft_core::MAX_ROWS as i64).contains(&nr) {
self.set(CellRef::new(nr as u32, c.col), cell);
}
}
let moved = self.take_rows(from..=u32::MAX, 0..=u32::MAX);
self.put_rows(moved.into_iter().filter_map(|(row, cols)| Some((shifted(row, delta, gridcraft_core::MAX_ROWS)?, cols))));
}
pub fn shift_cols(&mut self, from: u32, delta: i64) {
let keys: Vec<CellRef> = self.iter().filter(|(c, _)| c.col >= from).map(|(c, _)| c).collect();
let moved: Vec<(CellRef, Cell)> = keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect();
for (c, cell) in moved {
let nc = c.col as i64 + delta;
if (0..gridcraft_core::MAX_COLS as i64).contains(&nc) {
self.set(CellRef::new(c.row, nc as u32), cell);
}
}
let moved = self.take_rows(0..=u32::MAX, from..=u32::MAX);
self.put_rows(moved.into_iter().map(|(row, cols)| (row, shift_keys(cols, delta))));
}
/// Shifts cells within rows `r0..=r1` and columns `>= from` horizontally (Insert/Delete Cells
/// with shift right/left).
pub fn shift_cols_in_rows(&mut self, r0: u32, r1: u32, from: u32, delta: i64) {
let range = RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, gridcraft_core::MAX_COLS - 1));
let moved = self.take_range(range);
for (c, cell) in moved {
let nc = c.col as i64 + delta;
if (0..gridcraft_core::MAX_COLS as i64).contains(&nc) {
self.set(CellRef::new(c.row, nc as u32), cell);
}
}
let r = RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, gridcraft_core::MAX_COLS - 1));
let moved = self.take_rows(r.start.row..=r.end.row, r.start.col..=r.end.col);
self.put_rows(moved.into_iter().map(|(row, cols)| (row, shift_keys(cols, delta))));
}
/// Shifts cells within columns `c0..=c1` and rows `>= from` vertically.
pub fn shift_rows_in_cols(&mut self, c0: u32, c1: u32, from: u32, delta: i64) {
// Cell by cell: the moved cells sit between columns that stay, so moving row maps
// measured no faster here (#3575).
let range = RangeRef::new(CellRef::new(from, c0), CellRef::new(gridcraft_core::MAX_ROWS - 1, c1));
let moved = self.take_range(range);
for (c, cell) in moved {
let nr = c.row as i64 + delta;
if (0..gridcraft_core::MAX_ROWS as i64).contains(&nr) {
self.set(CellRef::new(nr as u32, c.col), cell);
for (c, cell) in self.take_range(range) {
if let Some(nr) = shifted(c.row, delta, gridcraft_core::MAX_ROWS) {
self.set(CellRef::new(nr, c.col), cell);
}
}
}
/// Removes the cells in `rows` x `cols` and returns them grouped by row, in row order. Moves
/// whole column maps (or the split-off part of one) instead of removing cell by cell, and only
/// copies bands that hold a cell in the block, so snapshots keep sharing the others.
fn take_rows(&mut self, rows: RangeInclusive<u32>, cols: RangeInclusive<u32>) -> Vec<(u32, BTreeMap<u32, Cell>)> {
let (r0, r1, c0, c1) = (*rows.start(), *rows.end(), *cols.start(), *cols.end());
let mut out = Vec::new();
if r0 > r1 || c0 > c1 {
return out;
}
let whole_rows = c0 == 0 && c1 == u32::MAX;
let keys: Vec<u32> = self
.bands
.range(r0 / BAND..=r1 / BAND)
.filter(|(_, band)| band.rows.range(r0..=r1).any(|(_, row)| row.range(c0..=c1).next().is_some()))
.map(|(k, _)| *k)
.collect();
for key in keys {
let Some(band) = self.bands.get_mut(&key) else { continue };
let inside = |row: Option<&u32>| row.is_some_and(|r| (r0..=r1).contains(r));
if whole_rows && inside(band.rows.keys().next()) && inside(band.rows.keys().next_back()) {
// The whole band moves: no splitting.
let Some(band) = self.bands.remove(&key) else { continue };
for (row, taken) in Arc::unwrap_or_clone(band).rows {
self.len -= taken.len();
out.push((row, taken));
}
continue;
}
let band = Arc::make_mut(band);
let mut block = band.rows.split_off(&r0);
let mut tail = match r1.checked_add(1) {
Some(next) => block.split_off(&next),
None => BTreeMap::new(),
};
for (row, mut kept) in block {
let taken = if whole_rows {
std::mem::take(&mut kept)
} else if c1.checked_add(1).is_none_or(|next| kept.range(next..).next().is_none()) {
// Nothing right of the block: one split.
kept.split_off(&c0)
} else {
let mut taken = BTreeMap::new();
for (col, cell) in kept.extract_if(c0..=c1, |_, _| true) {
taken.insert(col, cell);
}
taken
};
if !kept.is_empty() {
band.rows.insert(row, kept);
}
if !taken.is_empty() {
self.len -= taken.len();
out.push((row, taken));
}
}
band.rows.append(&mut tail);
if band.rows.is_empty() {
self.bands.remove(&key);
}
}
out
}
/// Stores rows of cells (each row at most once) the way `set` stores each cell: a cell
/// replaces what is there, and a blank cell clears its destination instead of being stored.
/// A row with nothing stored yet takes the whole map. Rows in order share one band lookup.
fn put_rows(&mut self, rows: impl Iterator<Item = (u32, BTreeMap<u32, Cell>)>) {
let mut rows = rows.peekable();
let mut group = Vec::new();
while let Some((first, cols)) = rows.next() {
let key = first / BAND;
group.clear();
group.push((first, cols));
while let Some(next) = rows.next_if(|(row, _)| row / BAND == key) {
group.push(next);
}
// Leave the band shared when only blank cells land on empty positions.
let existing = self.bands.get(&key);
let writes = group.iter().any(|(row, cols)| {
let dest = existing.and_then(|band| band.rows.get(row));
cols.iter().any(|(col, cell)| !cell.is_blank() || dest.is_some_and(|d| d.contains_key(col)))
});
if !writes {
continue;
}
let band = Arc::make_mut(self.bands.entry(key).or_default());
for (row, mut cols) in group.drain(..) {
match band.rows.entry(row) {
Entry::Vacant(slot) => {
if cols.values().any(Cell::is_blank) {
cols.retain(|_, cell| !cell.is_blank());
}
if !cols.is_empty() {
self.len += cols.len();
slot.insert(cols);
}
}
Entry::Occupied(mut slot) => {
let dest = slot.get_mut();
for (col, cell) in cols {
if cell.is_blank() {
if dest.remove(&col).is_some() {
self.len -= 1;
}
} else if dest.insert(col, cell).is_none() {
self.len += 1;
}
}
if dest.is_empty() {
slot.remove();
}
}
}
}
if band.rows.is_empty() {
self.bands.remove(&key);
}
}
}
}
/// `at + delta` when it lies in `0..limit`.
fn shifted(at: u32, delta: i64, limit: u32) -> Option<u32> {
let to = (at as i64).checked_add(delta)?;
if (0..limit as i64).contains(&to) { Some(to as u32) } else { None }
}
/// The cells of one row with their columns moved by `delta`; cells that would leave the sheet are
/// dropped.
fn shift_keys(cols: BTreeMap<u32, Cell>, delta: i64) -> BTreeMap<u32, Cell> {
let mut out = BTreeMap::new();
for (col, cell) in cols {
if let Some(c) = shifted(col, delta, gridcraft_core::MAX_COLS) {
out.insert(c, cell);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_core::Value;
use gridcraft_core::{MAX_COLS, MAX_ROWS, Value};
fn c(r: u32, col: u32) -> CellRef {
CellRef::new(r, col)
@@ -396,4 +521,211 @@ mod tests {
s.shift_cols(0, 1);
assert!(s.get(c(7, 1)).is_some());
}
/// The per-cell algorithms the shifts used before they moved whole rows (#3575), kept as the
/// reference the structural versions must match cell for cell.
mod per_cell {
use super::*;
fn reset(s: &mut CellStore, moved: Vec<(CellRef, Cell)>, to: impl Fn(CellRef) -> Option<CellRef>) {
for (at, cell) in moved {
if let Some(at) = to(at) {
s.set(at, cell);
}
}
}
fn row_by(delta: i64) -> impl Fn(CellRef) -> Option<CellRef> {
move |at| {
let r = at.row as i64 + delta;
(0..MAX_ROWS as i64).contains(&r).then(|| CellRef::new(r as u32, at.col))
}
}
fn col_by(delta: i64) -> impl Fn(CellRef) -> Option<CellRef> {
move |at| {
let c = at.col as i64 + delta;
(0..MAX_COLS as i64).contains(&c).then(|| CellRef::new(at.row, c as u32))
}
}
pub fn take_range(s: &mut CellStore, r: RangeRef) -> Vec<(CellRef, Cell)> {
let keys: Vec<CellRef> = s.iter_range(r).map(|(c, _)| c).collect();
keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect()
}
pub fn shift_rows(s: &mut CellStore, from: u32, delta: i64) {
let keys: Vec<CellRef> = s.iter().filter(|(c, _)| c.row >= from).map(|(c, _)| c).collect();
let moved = keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect();
reset(s, moved, row_by(delta));
}
pub fn shift_cols(s: &mut CellStore, from: u32, delta: i64) {
let keys: Vec<CellRef> = s.iter().filter(|(c, _)| c.col >= from).map(|(c, _)| c).collect();
let moved = keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect();
reset(s, moved, col_by(delta));
}
pub fn shift_cols_in_rows(s: &mut CellStore, r0: u32, r1: u32, from: u32, delta: i64) {
let moved = take_range(s, RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, MAX_COLS - 1)));
reset(s, moved, col_by(delta));
}
pub fn shift_rows_in_cols(s: &mut CellStore, c0: u32, c1: u32, from: u32, delta: i64) {
let moved = take_range(s, RangeRef::new(CellRef::new(from, c0), CellRef::new(MAX_ROWS - 1, c1)));
reset(s, moved, row_by(delta));
}
}
struct Lcg(u64);
impl Lcg {
fn below(&mut self, n: u64) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 33) % n.max(1)
}
fn pick<T: Copy>(&mut self, xs: &[T]) -> T {
xs[self.below(xs.len() as u64) as usize]
}
}
/// A position near a band edge, the sheet edge, or anywhere below `max`.
fn coord(rng: &mut Lcg, max: u32, edges: &[u32]) -> u32 {
match rng.below(3) {
0 => rng.pick(edges).min(max - 1),
1 => max - 1 - rng.below(70.min(max as u64)) as u32,
_ => rng.below(max as u64) as u32,
}
}
/// Random store with values, styled-only cells, a few cells off the grid, and blank cells left
/// behind by `get_mut` (which `set` never stores, but the old shifts dropped and let clear
/// their destination).
fn random_store(rng: &mut Lcg, max_r: u32, max_c: u32) -> CellStore {
let row_edges = [0, 1, 62, 63, 64, 65, 127, 128, 129, 191, 192, MAX_ROWS - 64, MAX_ROWS - 1];
let col_edges = [0, 1, 2, 63, 64, 65, MAX_COLS - 2, MAX_COLS - 1];
let mut s = CellStore::new();
for _ in 0..rng.below(300) {
let mut at = CellRef::new(coord(rng, max_r, &row_edges), coord(rng, max_c, &col_edges));
// Now and then a cell outside the grid (`set` does not check): the shifts must treat
// it as the old code did.
match rng.below(60) {
0 => at.row = MAX_ROWS + rng.below(3) as u32,
1 => at.col = MAX_COLS + rng.below(3) as u32,
_ => {}
}
let cell = match rng.below(5) {
0 => Cell { style: crate::style::StyleId(1 + rng.below(3) as u32), ..Cell::default() },
1 => Cell::value(Value::from("t")),
_ => Cell::value(Value::Number(rng.below(1000) as f64)),
};
s.set(at, cell);
if rng.below(8) == 0
&& let Some(cell) = s.get_mut(at)
{
*cell = Cell::default();
}
}
s
}
fn assert_same(new: &CellStore, old: &CellStore, base: &CellStore, base_copy: &CellStore, what: &str) {
assert_eq!(new, old, "{what}");
assert_eq!(new.len(), new.iter().count(), "{what}: len");
assert!(new.iter().map(|(c, x)| (c, x.clone())).eq(old.iter().map(|(c, x)| (c, x.clone()))), "{what}: iteration");
assert!(new.bands.values().all(|b| !b.rows.is_empty() && b.rows.values().all(|r| !r.is_empty())), "{what}: empty band or row");
// Copy-on-write: every band the old code left shared with the snapshot stays shared.
for (k, b) in &base.bands {
if old.bands.get(k).is_some_and(|o| Arc::ptr_eq(o, b)) {
assert!(new.bands.get(k).is_some_and(|n| Arc::ptr_eq(n, b)), "{what}: band {k} copied");
}
}
assert_eq!(base, base_copy, "{what}: snapshot changed");
}
#[test]
fn structural_shifts_match_per_cell_shifts() {
let mut rng = Lcg(3575);
let deltas = [0i64, 1, -1, 2, -2, 63, -63, 64, -64, 65, -65, 128, -128, 1000, -1000, MAX_ROWS as i64, -(MAX_ROWS as i64)];
let row_edges = [0, 1, 63, 64, 65, 127, 128, MAX_ROWS - 1, MAX_ROWS];
let col_edges = [0, 1, 63, 64, 65, MAX_COLS - 1, MAX_COLS];
for trial in 0..3000 {
let max_r = [200, 5000, MAX_ROWS][trial % 3];
let max_c = [30, MAX_COLS][trial / 3 % 2];
let mut base = random_store(&mut rng, max_r, max_c);
for step in 0..4 {
let snapshot = base.clone();
let (mut new, mut old) = (base.clone(), base.clone());
let delta = if rng.below(2) == 0 { rng.pick(&deltas) } else { rng.below(301) as i64 - 150 };
let row = if rng.below(2) == 0 { rng.pick(&row_edges) } else { rng.below(max_r as u64 + 1) as u32 };
let col = if rng.below(2) == 0 { rng.pick(&col_edges) } else { rng.below(max_c as u64 + 1) as u32 };
let (row2, col2) = (rng.below(max_r as u64 + 1) as u32, rng.below(max_c as u64 + 1) as u32);
let op = rng.below(5);
match op {
0 => {
new.shift_rows(row, delta);
per_cell::shift_rows(&mut old, row, delta);
}
1 => {
new.shift_cols(col, delta);
per_cell::shift_cols(&mut old, col, delta);
}
2 => {
new.shift_cols_in_rows(row, row2, col, delta);
per_cell::shift_cols_in_rows(&mut old, row, row2, col, delta);
}
3 => {
new.shift_rows_in_cols(col, col2, row, delta);
per_cell::shift_rows_in_cols(&mut old, col, col2, row, delta);
}
_ => {
let r = RangeRef::new(CellRef::new(row, col), CellRef::new(row2, col2));
assert_eq!(new.take_range(r), per_cell::take_range(&mut old, r), "trial {trial} step {step}: taken cells");
}
}
let what = format!("trial {trial} step {step} op {op} row {row}..{row2} col {col}..{col2} delta {delta}");
assert_same(&new, &old, &base, &snapshot, &what);
base = new;
}
}
}
#[test]
fn off_grid_cells_right_of_a_block_stay_put() {
let mut s = CellStore::new();
for col in [0, 5, MAX_COLS - 1, MAX_COLS + 1] {
s.set(c(3, col), Cell::value((col as f64).into()));
}
let mut old = s.clone();
s.shift_cols_in_rows(3, 3, 0, 1);
per_cell::shift_cols_in_rows(&mut old, 3, 3, 0, 1);
assert_eq!(s, old);
assert_eq!(s.get(c(3, MAX_COLS + 1)).map(|x| x.value.clone()), Some(Value::Number((MAX_COLS + 1) as f64)));
let r = RangeRef::rows(3, 3);
assert_eq!(s.take_range(r), per_cell::take_range(&mut old, r));
assert_eq!(s, old);
}
#[test]
fn moved_blank_cell_clears_its_destination() {
let mut s = CellStore::new();
s.set(c(3, 0), Cell::value(1.0.into()));
s.set(c(5, 0), Cell::value(2.0.into()));
s.set(c(5, 1), Cell::value(3.0.into()));
if let Some(cell) = s.get_mut(c(5, 0)) {
*cell = Cell::default();
}
s.shift_rows(5, -2);
assert!(s.get(c(3, 0)).is_none());
assert_eq!(s.get(c(3, 1)).map(|x| x.value.clone()), Some(Value::Number(3.0)));
assert_eq!(s.len(), 1);
}
#[test]
fn shifting_keeps_untouched_bands_shared() {
let mut s = CellStore::new();
for r in 0..256 {
s.set(c(r, 0), Cell::value((r as f64).into()));
}
s.set(c(200, 5), Cell::value("x".into()));
let snap = s.clone();
s.shift_rows(130, 1);
assert!(Arc::ptr_eq(&s.bands[&0], &snap.bands[&0]));
assert!(Arc::ptr_eq(&s.bands[&1], &snap.bands[&1]));
s.shift_cols(5, 1);
assert!(Arc::ptr_eq(&s.bands[&0], &snap.bands[&0]));
assert_eq!(s.get(c(201, 6)).map(|x| x.value.clone()), Some(Value::from("x")));
}
}
+27
View File
@@ -589,3 +589,30 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line }
}
#[cfg(test)]
mod tests {
use gridcraft_chart::Measure;
use super::*;
/// The production measure is not monotone for labels with a line break: egui lays the text out
/// line by line, so ending a prefix with `\n…` can be narrower than the shorter `iii…`. The
/// chart's label fitting must not assume otherwise (#1152).
#[test]
fn egui_measure_shrinks_when_a_prefix_ends_in_a_line_break() {
let ctx = egui::Context::default();
ctx.set_fonts(theme::pinned_font_definitions());
let mut checked = false;
// Fonts are ready from the second pass on.
for _ in 0..2 {
let mut out = ctx.run_ui(Default::default(), |ui| {
let m = EguiMeasure { painter: ui.painter(), z: 1.0 };
let (short, long) = (m.text_width("iii…", 11.0, false), m.text_width("iiii\n…", 11.0, false));
checked = long < short;
});
out.textures_delta.clear();
}
assert!(checked);
}
}
+54 -4
View File
@@ -22,6 +22,12 @@ fn navigator_items(sheets: &[Arc<Sheet>]) -> Vec<(String, String)> {
sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, s)| (i.to_string(), s.name.clone())).collect()
}
/// Workbook index of the first (or, with `last`, the last) visible sheet: where Ctrl-clicking a tab arrow lands.
fn edge_sheet(sheets: &[Arc<Sheet>], last: bool) -> Option<usize> {
let mut visible = sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, _)| i);
if last { visible.next_back() } else { visible.next() }
}
/// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the
/// other sheet; otherwise the edit is committed first.
fn activate_sheet(app: &mut SheetApp, i: usize) {
@@ -55,6 +61,18 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
Some(json!({"sheet": i, "to": to}))
}
/// Where a request to scroll the active tab into view is kept until the tabs are drawn.
fn reveal_request_id(scope: egui::Id) -> egui::Id {
scope.with("worksheet_tabs_reveal_request")
}
/// Asks the tab strip to bring the active tab into view in this frame, even when the active sheet
/// did not change (a Ctrl-click on an arrow whose edge sheet is already active).
fn request_reveal(ui: &Ui) {
let id = reveal_request_id(ui.scope_id());
ui.ctx().data_mut(|data| data.insert_temp(id, true));
}
/// The New sheet (+) button and the gap before it.
const NEW_SHEET_WIDTH: f32 = 32.0;
/// The handle between the tabs and the horizontal scrollbar.
@@ -117,10 +135,28 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
ui.add_space(8.0);
if view.show_sheet_tabs {
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.previous", json!({}));
// Ctrl-click jumps to the first visible sheet, like Excel's tab scroll buttons.
let edge =
if ui.input(|i| i.modifiers.command) { app.session.active().and_then(|d| edge_sheet(&d.wb.sheets, false)) } else { None };
match edge {
Some(i) => {
activate_sheet(app, i);
request_reveal(ui);
}
None => app.run_or_alert("sheet.previous", json!({})),
}
}
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
app.run_or_alert("sheet.next", json!({}));
// Ctrl-click jumps to the last visible sheet.
let edge =
if ui.input(|i| i.modifiers.command) { app.session.active().and_then(|d| edge_sheet(&d.wb.sheets, true)) } else { None };
match edge {
Some(i) => {
activate_sheet(app, i);
request_reveal(ui);
}
None => app.run_or_alert("sheet.next", json!({})),
}
}
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
@@ -163,10 +199,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
// allow browsing other tabs without snapping back on every frame.
let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits()));
let reveal_id = ui.scope_id().with("worksheet_tabs_reveal");
let request_id = reveal_request_id(ui.scope_id());
let reveal_active = ui.ctx().data_mut(|data| {
let changed = data.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
data.insert_temp(reveal_id, reveal_key);
changed
// A Ctrl-click on an arrow whose edge sheet is already active changes nothing above,
// yet asks to bring that tab into view.
changed | data.remove_temp::<bool>(request_id).unwrap_or(false)
});
if view.show_sheet_tabs {
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
@@ -522,7 +561,8 @@ fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::{
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop,
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, edge_sheet, navigator_items, rename_needs_focus,
tab_drop,
};
use crate::SheetApp;
use egui::Modifiers;
@@ -614,6 +654,16 @@ mod tests {
assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]);
}
#[test]
fn ctrl_click_on_the_tab_arrows_lands_on_the_first_and_last_visible_sheet() {
let mut hidden = Sheet::new("Hidden");
hidden.visibility = Visibility::Hidden;
let sheets = [hidden.clone(), Sheet::new("A"), Sheet::new("B"), Sheet::new("C"), hidden].map(Arc::new);
assert_eq!(edge_sheet(&sheets, false), Some(1));
assert_eq!(edge_sheet(&sheets, true), Some(3));
assert_eq!(edge_sheet(&[], true), None);
}
#[test]
fn a_rejected_commit_keeps_the_sheet_and_the_edit() {
let mut session = gridcraft_engine::Session::new();
+73
View File
@@ -58,6 +58,20 @@ fn click(h: &mut Harness, at: egui::Pos2) {
h.run_steps(20);
}
fn ctrl_click(h: &mut Harness, at: egui::Pos2) {
let m = Modifiers::COMMAND;
h.input_mut().events.extend([
Event::ModifiersChanged(m),
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: m },
]);
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m });
h.run_steps(20);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
#[test]
fn opening_and_activating_late_sheets_reveals_their_tabs() {
for width in [500.0, 1000.0] {
@@ -85,6 +99,65 @@ fn navigation_arrows_reveal_the_selected_tab() {
assert_visible(&h, "Tab29");
}
/// A Ctrl/Cmd-click on the Next arrow jumps to the last sheet; repeated after the strip was scrolled
/// away from it, the already active edge tab is brought back into view.
#[test]
fn repeated_ctrl_click_on_the_arrow_reveals_the_active_edge_tab() {
let mut h = harness(700.0, 0);
let (_, clip) = tab(&h, "Tab00").unwrap();
let next = pos2(clip.left() - 44.0, clip.center().y);
ctrl_click(&mut h, next);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
assert_visible(&h, "Tab29");
// Browse the strip back to the start without changing the selection.
h.input_mut().events.extend([Event::PointerMoved(clip.center())]);
for _ in 0..3 {
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
source: egui::MouseWheelSource::Unknown,
phase: egui::TouchPhase::Move,
delta: vec2(0.0, 600.0),
modifiers: Modifiers::NONE,
});
h.run_steps(10);
}
h.run_steps(40);
assert!(!tab(&h, "Tab29").is_some_and(|(r, c)| c.contains_rect(r)), "the strip was scrolled away from the active tab");
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
ctrl_click(&mut h, next);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
assert_visible(&h, "Tab29");
}
/// The same for the Previous arrow and the first sheet.
#[test]
fn repeated_ctrl_click_on_the_previous_arrow_reveals_the_first_tab() {
let mut h = harness(700.0, 29);
let (_, clip) = tab(&h, "Tab29").unwrap();
let prev = pos2(clip.left() - 68.0, clip.center().y);
ctrl_click(&mut h, prev);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
assert_visible(&h, "Tab00");
// Browse the strip to its end without changing the selection.
h.input_mut().events.extend([Event::PointerMoved(clip.center())]);
for _ in 0..3 {
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
source: egui::MouseWheelSource::Unknown,
phase: egui::TouchPhase::Move,
delta: vec2(0.0, -600.0),
modifiers: Modifiers::NONE,
});
h.run_steps(10);
}
h.run_steps(40);
assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "the strip was scrolled away from the active tab");
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
ctrl_click(&mut h, prev);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
assert_visible(&h, "Tab00");
}
#[test]
/// Horizontal wheel/trackpad browsing leaves the workbook unchanged, does not snap back on idle frames, and activating a sheet still reveals its tab.
fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() {