Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor ead4ee778f PDF export: index merged areas when drawing a block
draw_block checked every cell against every merge meeting the block,
and the fill, border and text passes searched the merge list again
for each merge start, so an imported sheet with many merge ranges in
a print area cost cells x merges (and merges x merges) comparisons.

The merges meeting a block are now gathered once and swept down the
rows: whether a cell is covered is a BTreeMap lookup among the merges
active on its row, and merge starts are found through a HashMap. A
merge overlapping one kept before it (only possible in a damaged file)
is left out, as Excel does when it repairs the file. Hyperlink
rectangles use the same index instead of scanning all sheet merges.
2026-11-02 20:40:04 -05:00
5 changed files with 139 additions and 282 deletions
+8 -143
View File
@@ -39,15 +39,6 @@ const MAX_DEPTH: usize = 400;
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
const LAMBDA_FUEL: u64 = MAX_CELLS;
/// Work one cell's formula may do: one unit per expression evaluated, per cell read from a
/// reference, per array value handed to a function, per pair of areas intersected and per
/// character of a defined name's formula parsed where the name is used. Each limit
/// above bounds one kind of step, but a compact formula could still repeat bounded steps
/// exponentially often (defined names that each use the one before twice, a computed criterion
/// whose database function reruns it, an aggregate over a large array inside every LAMBDA call).
/// Past this the formula is `#CALC!`. It is far above what a formula that reads every cell of a
/// full-size array a few times needs.
const EVAL_WORK: u64 = 16 * MAX_CELLS;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Area {
@@ -148,12 +139,6 @@ pub struct Evaluator<'h> {
/// runs out of work after the same number of calls that a legitimately wide one could
/// make, instead of an amount of work that squares with array nesting (#2382).
fuel: u64,
/// Work this cell still has left (see [`EVAL_WORK`]), shared by every branch of the formula
/// and by the formulas it computes on the way (names, computed criteria).
work: u64,
/// Computed criteria being evaluated, outermost first: one that is reached again from inside
/// itself counts as not met instead of repeating its work at every level.
active_criteria: Vec<(usize, CellRef)>,
}
struct FnCtx<'a, 'h> {
@@ -183,9 +168,6 @@ impl Ctx for FnCtx<'_, '_> {
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
let area = self.criteria?;
let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;
if self.ev.active_criteria.contains(&(area.sheet, at)) {
return Some(false);
}
let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;
let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);
let env = (0..db.cols)
@@ -205,17 +187,10 @@ impl Ctx for FnCtx<'_, '_> {
calls: self.ev.calls,
stack_top: self.ev.stack_top,
fuel: self.ev.fuel,
work: self.ev.work,
active_criteria: std::mem::take(&mut self.ev.active_criteria),
};
ev.active_criteria.push((area.sheet, at));
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
// The child's spend counts against this cell's budget.
self.ev.fuel = ev.fuel;
self.ev.work = ev.work;
let mut active = std::mem::take(&mut ev.active_criteria);
active.pop();
self.ev.active_criteria = active;
Some(v)
}
fn has_lambda(&self) -> bool {
@@ -235,40 +210,7 @@ impl Ctx for FnCtx<'_, '_> {
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
Evaluator {
host,
sheet,
at,
env: Vec::new(),
depth: 0,
calls: 0,
stack_top: 0,
fuel: LAMBDA_FUEL,
work: EVAL_WORK,
active_criteria: Vec::new(),
}
}
/// Spends `units` of this cell's work; `false` (and nothing left) when there isn't enough.
pub(crate) fn spend(&mut self, units: u64) -> bool {
match self.work.checked_sub(units) {
Some(w) => {
self.work = w;
true
}
None => {
self.work = 0;
false
}
}
}
/// Charges the values of an array (one unit per value; one for anything else).
pub(crate) fn spend_on(&mut self, v: &Value) -> bool {
match v {
Value::Array(a) => self.spend(a.data.len() as u64),
_ => self.spend(1),
}
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -318,27 +260,18 @@ impl<'h> Evaluator<'h> {
}
// A reference keeps its size, so the array has its shape and its blanks count
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
let range = self.read_range(a);
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
if !self.spend((h as u64) * (w as u64)) {
return Value::Error(CellError::Calc);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
/// The block that reading `a` gives: `a` itself, or when it is large (or runs to the sheet's
/// edge) the part inside the sheet's used range.
pub(crate) fn read_range(&mut self, a: &Area) -> RangeRef {
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
// the column", not for a block of a chosen size.
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
if full_size { a.range } else { self.trim(a) }
let range = if full_size { a.range } else { self.trim(a) };
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
@@ -371,9 +304,6 @@ impl<'h> Evaluator<'h> {
self.host.stack_exhausted();
return err(CellError::Num);
}
if !self.spend(1) {
return err(CellError::Calc);
}
self.depth += 1;
if self.depth > MAX_DEPTH {
self.depth -= 1;
@@ -440,11 +370,6 @@ impl<'h> Evaluator<'h> {
if let Some(def) = wb.name(bare, scope_sheet) {
let text = def.formula.clone();
let scope = def.scope.unwrap_or(self.sheet);
// Each use parses and evaluates the definition again: names that each use the one
// before twice double the work per name, so it is charged.
if !self.spend(text.len() as u64) {
return err(CellError::Calc);
}
return match gridcraft_formula::parse_name(&text) {
Ok(expr) => {
// Relative references in a name are relative to A1: used here, they move by
@@ -637,10 +562,6 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(mut xa), Ev::R(ya)) => {
// A union reused through LET doubles at each step: its areas count.
if !self.spend((xa.len() as u64).saturating_add(ya.len() as u64)) {
return err(CellError::Calc);
}
xa.extend(ya);
Ev::R(xa)
}
@@ -652,11 +573,6 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(xa), Ev::R(ya)) => {
// Every pair is looked at, and each may be kept: charged before the loop,
// so unions squared through LET can't build an enormous list.
if !self.spend((xa.len() as u64).saturating_mul(ya.len() as u64)) {
return err(CellError::Calc);
}
let mut out = Vec::new();
for p in &xa {
for q in &ya {
@@ -750,57 +666,6 @@ mod fuel_tests {
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
}
/// Evaluates `src` in `wb` with `work` units of work, and the work left.
fn eval_work(wb: Workbook, work: u64, src: &str) -> (Value, u64) {
let mut host = Empty { wb };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
ev.work = work;
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, ev.work)
}
fn is_calc(v: &Value) -> bool {
matches!(v, Value::Error(CellError::Calc))
}
#[test]
fn names_that_double_at_each_step_run_out_of_work() {
// Step1 = Step0+Step0, Step2 = Step1+Step1, ...: Step60 would evaluate Step0 2^60 times.
let mut wb = Workbook::default();
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(name("Step0".into(), "1".into()));
for i in 1..=60 {
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
}
let (v, _) = eval_work(wb.clone(), EVAL_WORK, "=Step5");
assert_eq!(v, Value::number(32.0));
let (v, left) = eval_work(wb, 100_000, "=Step60");
assert!(is_calc(&v), "{v:?}");
assert_eq!(left, 0);
}
#[test]
fn intersections_of_unions_are_charged_per_pair() {
let wb = Workbook::default();
// 4 x 4 pairs, 4 of which meet.
let src = "=AREAS((A1,B1,C1,D1) (A1,B1,C1,D1))";
let (v, _) = eval_work(wb.clone(), EVAL_WORK, src);
assert_eq!(v, Value::number(4.0));
let (v, _) = eval_work(wb, 20, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn an_aggregate_inside_every_lambda_call_spends_work() {
let wb = Workbook::default();
// 100 calls, each summing the 10,000 values of a captured array: about a million units.
let src = "=LET(big,SEQUENCE(10000),SUM(MAP(SEQUENCE(100),LAMBDA(x,SUM(big)))))";
let (v, _) = eval_work(wb.clone(), 2_000_000, src);
assert_eq!(v, Value::number(100.0 * 50_005_000.0));
let (v, _) = eval_work(wb, 200_000, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn lambda_calls_spend_fuel_and_run_out_quietly() {
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
-15
View File
@@ -353,12 +353,6 @@ impl<'h> Evaluator<'h> {
};
evaluated.push(Arg { value, from_ref });
}
// The function may go through every value it is given (a SUM inside each call of a
// LAMBDA over a large array): the values count against the cell's work.
let values = evaluated.iter().fold(0u64, |n, a| n.saturating_add(if let Value::Array(x) = &a.value { x.data.len() as u64 } else { 1 }));
if !self.spend(values) {
return err(CellError::Calc);
}
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
@@ -389,12 +383,6 @@ 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() {
// Folded without building an array, but still walked: the part inside the used
// range counts against the cell's work.
let r = self.read_range(a);
if !self.spend(u64::from(r.height()) * u64::from(r.width())) {
return Err(err(CellError::Calc));
}
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
@@ -414,9 +402,6 @@ impl<'h> Evaluator<'h> {
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
if !from_ref && !self.spend_on(&value) {
return Err(err(CellError::Calc));
}
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
+3 -99
View File
@@ -118,29 +118,11 @@ impl<'h> Evaluator<'h> {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
// A position that picks several cells is #CALC!. That is decided from the
// shape: reading the block first, for every position, let a short formula
// read one large range over and over only to discard it.
(Ok(row), Ok(col)) if index_picks_several_values(&src, row, col, two) => Value::Error(CellError::Calc),
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
let cells = match &one {
Ev::R(areas) => match areas.as_slice() {
[a] => self.area_cells(a),
_ => 0,
},
_ => 0,
};
if cells > MAX_CELLS {
// As reading it would answer.
Value::Error(CellError::Num)
} else if cells > 1 {
Value::Error(CellError::Calc)
} else {
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
@@ -157,16 +139,6 @@ impl<'h> Evaluator<'h> {
self.index_one(src, row, col, area_n, two)
}
/// Cells that reading `a` gives: its own size, or for a large block the part inside the
/// sheet's used range (what [`Evaluator::area_value`] reads).
fn area_cells(&mut self, a: &Area) -> u64 {
if a.range.is_single() {
return 1;
}
let r = self.read_range(a);
u64::from(r.height()) * u64::from(r.width())
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
@@ -435,71 +407,3 @@ impl<'h> Evaluator<'h> {
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
}
/// Whether INDEX on an array picks a whole row or column of several values at position
/// (`row`, `col`), which the array-of-positions form answers with #CALC!.
fn index_picks_several_values(src: &Ev, row: i64, col: i64, two_args: bool) -> bool {
let Ev::V(Value::Array(arr)) = src else { return false };
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row < 0 || col < 0 || row as u64 > arr.rows as u64 || col as u64 > arr.cols as u64 {
return false;
}
(row == 0 && arr.rows > 1) || (col == 0 && arr.cols > 1)
}
#[cfg(test)]
mod tests {
use super::super::Host;
use super::*;
use gridcraft_model::Workbook;
/// A sheet of blanks that counts the blocks it is asked for.
struct Counting {
wb: Workbook,
cells_read: usize,
}
impl Host for Counting {
fn workbook(&self) -> &Workbook {
&self.wb
}
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
Value::Empty
}
fn now_serial(&self) -> f64 {
0.0
}
fn random(&mut self) -> f64 {
0.0
}
fn range_values(&mut self, _sheet: usize, range: RangeRef) -> Vec<Value> {
self.cells_read += range.count() as usize;
vec![Value::Empty; range.count() as usize]
}
}
fn eval(src: &str) -> (Value, usize) {
let mut host = Counting { wb: Workbook::default(), cells_read: 0 };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, host.cells_read)
}
fn all_calc(v: &Value, n: usize) -> bool {
matches!(v, Value::Array(a) if a.data.len() == n && a.data.iter().all(|x| *x == Value::Error(CellError::Calc)))
}
#[test]
fn index_positions_that_pick_several_cells_are_calc_without_reading_them() {
// Each zero row position picks the whole of A1:A50000: #CALC!, decided from the shape. It
// used to read the 50,000 cells once per position before discarding them.
let (v, read) = eval("=INDEX(A1:A50000,{0,0,0,0},1)");
assert!(all_calc(&v, 4), "{v:?}");
assert_eq!(read, 0);
// The same for an array source: a whole column of a 3x3 array.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{0,0},2)");
assert!(all_calc(&v, 2), "{v:?}");
// Single cells are still read.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{1,3},2)");
assert!(matches!(&v, Value::Array(a) if a.data == vec![Value::number(2.0), Value::number(8.0)]), "{v:?}");
}
}
-16
View File
@@ -2372,19 +2372,3 @@ fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
}
/// A computed criterion whose formula runs a database function over the same criteria counts as
/// not met inside itself: before, each record's check ran the function again for every record, at
/// every level, so the work grew exponentially with the nesting allowed.
#[test]
fn computed_criterion_that_reenters_itself_is_not_met() {
let mut t = T::new();
for (r, (name, age)) in [("name", "age"), ("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("e", "5")].iter().enumerate() {
t.set(&format!("F{}", r + 2), name);
t.set(&format!("G{}", r + 2), age);
}
t.set("N46", "Formula");
t.set("N47", "=DCOUNT($F$2:$G$7,\"age\",$N$46:$N$47)>0");
t.set("B60", "=DCOUNT($F$2:$G$7,\"age\",N46:N47)");
assert_eq!(t.num("B60"), 0.0);
}
+128 -9
View File
@@ -228,6 +228,82 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end:
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
}
/// Merged areas active at one row of a downward sweep, kept disjoint: by first column, their last
/// column and last row. Adding, dropping and looking up a merge costs log(active merges).
#[derive(Default)]
struct MergeSweep {
cols: std::collections::BTreeMap<u32, (u32, u32)>,
/// (last row, first column) of the active merges, soonest ending first.
ends: std::collections::BinaryHeap<std::cmp::Reverse<(u32, u32)>>,
}
impl MergeSweep {
/// Moves down to `row` (rows never decrease), dropping the merges that end above it.
fn advance(&mut self, row: u32) {
while let Some(std::cmp::Reverse((end, col))) = self.ends.peek().copied() {
if end >= row {
break;
}
self.ends.pop();
self.cols.remove(&col);
}
}
/// Does an active merge meet columns `c0..=c1`? The intervals are disjoint, so only the last
/// one starting at or before `c1` can.
fn meets(&self, c0: u32, c1: u32) -> bool {
self.cols.range(..=c1).next_back().is_some_and(|(_, (end, _))| *end >= c0)
}
/// Adds merge `m`, at row `row`, unless it overlaps an active one.
fn add(&mut self, m: RangeRef, row: u32) -> bool {
self.advance(row);
if self.meets(m.start.col, m.end.col) {
return false;
}
self.cols.insert(m.start.col, (m.end.col, m.end.row));
self.ends.push(std::cmp::Reverse((m.end.row, m.start.col)));
true
}
}
/// The merged areas that meet `block`, ordered by the row where they enter it, then by column,
/// leaving out any that overlaps one kept before it (only a damaged file has overlapping merges;
/// Excel drops them when it repairs one). One sweep down the rows instead of a scan of every
/// merge per cell.
fn block_merges(sh: &Sheet, block: RangeRef) -> Vec<RangeRef> {
let top = |m: &RangeRef| m.start.row.max(block.start.row);
let mut merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
merges.sort_by_key(|m| (top(m), m.start.col));
let mut sweep = MergeSweep::default();
merges.retain(|m| sweep.add(*m, top(m)));
merges
}
/// Answers "is this cell inside a merge?" for cells asked in row-major order, from the merges of
/// [`block_merges`].
struct Covered<'a> {
merges: &'a [RangeRef],
top: u32,
next: usize,
sweep: MergeSweep,
}
impl<'a> Covered<'a> {
fn new(merges: &'a [RangeRef], block: RangeRef) -> Self {
Self { merges, top: block.start.row, next: 0, sweep: MergeSweep::default() }
}
fn covers(&mut self, c: CellRef) -> bool {
while let Some(m) = self.merges.get(self.next).filter(|m| m.start.row.max(self.top) <= c.row) {
self.sweep.add(*m, m.start.row.max(self.top));
self.next += 1;
}
self.sweep.advance(c.row);
self.sweep.meets(c.col, c.col)
}
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>) {
let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0));
@@ -237,17 +313,18 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
let block = b.range();
let sh = b.sh;
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
let covered = |c: CellRef| merges.iter().any(|m| m.contains(c));
// Cells worth visiting.
let merges = block_merges(sh, block);
let by_start: std::collections::HashMap<CellRef, RangeRef> = merges.iter().map(|m| (m.start, *m)).collect();
// Cells worth visiting, in row-major order (which `Covered` needs).
let mut covered = Covered::new(&merges, block);
let cells: Vec<CellRef> = if block.count() <= DENSE_BLOCK {
block.iter().filter(|c| !covered(*c)).collect()
block.iter().filter(|c| !covered.covers(*c)).collect()
} else {
let mut set: Vec<CellRef> = sh.cells.iter_range(block).map(|(c, _)| c).collect();
set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c)));
set.sort_unstable();
set.dedup();
set.retain(|c| !covered(*c));
set.retain(|c| !covered.covers(*c));
set
};
pg.save();
@@ -277,14 +354,14 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
.filter(|(c, l)| {
l.fill.is_some()
|| l.cf.is_some()
|| merges.iter().any(|m| m.start == *c)
|| by_start.contains_key(c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
match merges.iter().find(|m| m.start == c) {
match by_start.get(&c) {
Some(m) => {
let (x, y, w, h) = b.rect(*m);
(x, y, w, h, true)
@@ -322,7 +399,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
continue;
}
let bd = &l.style.borders;
let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) {
let (right, bottom) = match by_start.get(c).filter(|_| merged) {
Some(m) => (
b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right,
b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom,
@@ -407,11 +484,12 @@ pub(super) struct PrintedBlock {
pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
let by_start: std::collections::HashMap<CellRef, RangeRef> = block_merges(b.sh, range).into_iter().map(|m| (m.start, m)).collect();
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let cell_range = by_start.get(cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
@@ -503,3 +581,44 @@ pub(super) fn make_block<'a>(
}
Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale }
}
#[cfg(test)]
mod merge_tests {
use super::{CellRef, Covered, RangeRef, Sheet, block_merges};
fn r(a: &str) -> RangeRef {
RangeRef::parse(a).unwrap()
}
#[test]
fn block_merges_drop_overlaps_and_cover_like_a_scan() {
let mut sh = Sheet::new("S");
sh.merges = vec![r("B2:C3"), r("E1:F8"), r("B2:B5"), r("A10:D10"), r("H3:H4"), r("Z1:Z2"), r("C3:D4"), r("A1:A1048576")];
let block = r("B1:H12");
let merges = block_merges(&sh, block);
// B2:B5 and C3:D4 overlap B2:C3, which comes first; Z1:Z2 is outside the block.
assert_eq!(merges, vec![r("E1:F8"), r("B2:C3"), r("H3:H4"), r("A10:D10")]);
let mut covered = Covered::new(&merges, block);
for c in block.iter() {
assert_eq!(covered.covers(c), merges.iter().any(|m| m.contains(c)), "{}", c.a1());
}
// Cells asked sparsely, still in order.
let mut covered = Covered::new(&merges, block);
for (a, want) in [("C1", false), ("F5", true), ("G5", false), ("D10", true), ("H12", false)] {
assert_eq!(covered.covers(CellRef::parse(a).unwrap()), want, "{a}");
}
}
#[test]
fn many_stacked_merges_are_swept_once() {
// 50,000 merges over the same cells: one is kept, and the block is checked against it
// without comparing every cell with every merge.
let mut sh = Sheet::new("S");
sh.merges = (0..50_000u32).map(|i| RangeRef::new(CellRef::new(0, 0), CellRef::new(199, 1 + i % 50))).collect();
let block = r("A1:AZ200");
let merges = block_merges(&sh, block);
assert_eq!(merges, vec![r("A1:B200")]);
let mut covered = Covered::new(&merges, block);
assert_eq!(block.iter().filter(|c| covered.covers(*c)).count(), 400);
}
}