From a2737739cc53836f941bc61b7d080ceb2e2434ed Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:26:24 -0500 Subject: [PATCH] Charts: one point budget per chart and a byte budget for web-page SVG A chart resolved every series reference into its own vector of up to 100,000 points, so 255 short whole-column series retained and drew 25.5 million points, and Save as Web Page wrote one SVG element per mark without any output limit. - resolve() shares MAX_CHART_POINTS (1,000,000) across the values, X values and bubble sizes of all series; a series past the budget gets what is left. - chart_svg() stops writing once a chart passes the bytes left of a 64 MiB per-page budget and leaves that chart out. --- crates/chart/src/resolve.rs | 43 +++++++++++++++++++++++++--------- crates/chart/src/tests.rs | 18 ++++++++++++++ crates/engine/src/chart_svg.rs | 33 ++++++++++++++++++++++---- crates/engine/src/io.rs | 4 +++- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 6eee201ae..6c35b073b 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -11,6 +11,10 @@ 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 the points of all series of one chart together (values, X values and bubble sizes): +/// [`MAX_SERIES`] × [`MAX_POINTS`] would let a few short whole-column references retain and +/// draw tens of millions of points. Series past it get only what is left, or no points. +pub(crate) const MAX_CHART_POINTS: usize = 1_000_000; /// Cap on trendlines drawn per series. pub(crate) const MAX_TRENDLINES: usize = 16; @@ -77,10 +81,15 @@ fn union_parts(f: &str) -> Option> { /// 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 { - eval_list_in(wb, sheet, formula, 0) + eval_list_in(wb, sheet, formula, 0, MAX_POINTS) } -fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec { +/// [`eval_list`] keeping at most `cap` values (and never more than [`MAX_POINTS`]). +fn eval_list_capped(wb: &Workbook, sheet: usize, formula: &str, cap: usize) -> Vec { + eval_list_in(wb, sheet, formula, 0, cap.min(MAX_POINTS)) +} + +fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32, cap: usize) -> Vec { let f = formula.trim(); if f.is_empty() { return vec![]; @@ -90,16 +99,19 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec= MAX_POINTS { + if out.len() >= cap { break; } - let rest = MAX_POINTS - out.len(); - out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest)); + let rest = cap - out.len(); + out.extend(eval_list_in(wb, sheet, p, depth + 1, rest).into_iter().take(rest)); } return out; } + if cap == 0 { + return vec![]; + } match evaluate(wb, sheet, CellRef::default(), f) { - Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(), + Value::Array(a) => a.iter().take(cap).cloned().collect(), v => vec![v], } } @@ -111,8 +123,11 @@ fn to_num(v: &Value) -> Option { } } -fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec> { - eval_list(wb, sheet, formula).iter().map(to_num).collect() +/// A series formula's numbers, at most `cap` of them; `cap` is reduced by what was taken. +fn nums(wb: &Workbook, sheet: usize, formula: &str, cap: &mut usize) -> Vec> { + let out: Vec> = eval_list_capped(wb, sheet, formula, *cap).iter().map(to_num).collect(); + *cap -= out.len().min(*cap); + out } /// Number format code of the first cell of a reference ("General" when not a reference). @@ -228,15 +243,21 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { let mut series = Vec::new(); let mut categories: Option> = None; let mut category_levels = Vec::new(); + // One point budget for the whole chart, not one per series. + let mut budget = MAX_CHART_POINTS; 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, &mut budget); 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) => { + let xs = eval_list_capped(wb, sheet, f, budget); + budget -= xs.len().min(budget); + Some(xs).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()) + } _ => None, }; if categories.is_none() @@ -248,7 +269,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { 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, &mut budget)); let trendlines = s .trendlines .iter() diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 46d047afc..14c8e483a 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1258,3 +1258,21 @@ fn box_whisker_draws_each_series_quartile_method() { // The boxes span 3.5 and 2.5 data units on the same value axis. assert!((heights[0] / heights[1] - 1.4).abs() < 0.001); } + +#[test] +fn series_share_one_point_budget() { + // Many series over the same long column each get a share of one chart-wide budget, not + // `MAX_POINTS` apiece. + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.set_value(CellRef::new(0, 0), Value::Number(1.0)); + s.set_value(CellRef::new(59_999, 0), Value::Number(2.0)); + let mut c = chart(ChartKind::ColumnClustered); + c.series = (0..40).map(|_| Series { values: "Sheet1!$A$1:$A$60000".into(), ..Default::default() }).collect(); + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.len(), 40); + assert_eq!(d.series[0].values.len(), 60_000); + let total: usize = d.series.iter().map(|s| s.values.len()).sum(); + assert_eq!(total, crate::resolve::MAX_CHART_POINTS); + assert!(d.series.last().is_some_and(|s| s.values.is_empty())); +} diff --git a/crates/engine/src/chart_svg.rs b/crates/engine/src/chart_svg.rs index be9e6d227..fccf3165f 100644 --- a/crates/engine/src/chart_svg.rs +++ b/crates/engine/src/chart_svg.rs @@ -10,6 +10,10 @@ use crate::io::esc; /// Widest or tallest chart written; larger anchors are clamped. const MAX_SIDE: f32 = 16_384.0; +/// Most bytes of SVG written for the charts of one web page together. A chart past what is +/// left is left out of the page rather than grown without bound. +pub(crate) const MAX_PAGE_SVG_BYTES: usize = 64 * 1024 * 1024; + struct HelveticaMeasure; impl gridcraft_chart::Measure for HelveticaMeasure { @@ -18,24 +22,34 @@ impl gridcraft_chart::Measure for HelveticaMeasure { } } -/// `chart` of sheet `sheet` as an `` element placed by `style`, or `None` for a degenerate size. -pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option { +/// `chart` of sheet `sheet` as an `` element placed by `style`, or `None` for a degenerate +/// size or an element longer than `budget` bytes. +pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str, budget: usize) -> Option { let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE)); if !(w >= 1.0 && h >= 1.0) { return None; } let data = gridcraft_chart::resolve(wb, sheet, chart); let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure); - Some(prims_svg(&prims, w, h, style)) + prims_svg_within(&prims, w, h, style, budget) } +#[cfg(test)] fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String { + prims_svg_within(prims, w, h, style, usize::MAX).unwrap_or_default() +} + +/// The `` element, or `None` as soon as it grows past `budget` bytes. +fn prims_svg_within(prims: &[Prim], w: f32, h: f32, style: &str, budget: usize) -> Option { let mut out = String::new(); let _ = write!( out, "" ); for p in prims { + if out.len() > budget { + return None; + } match p { Prim::Rect { x, y, w, h, fill, stroke, radius } => { let _ = write!(out, " String { } } out.push_str(""); - out + (out.len() <= budget).then_some(out) } /// Fill and stroke attributes of an open shape element; the caller closes it. @@ -149,6 +163,17 @@ fn num(v: f32) -> String { mod tests { use super::*; + #[test] + fn an_svg_past_its_byte_budget_is_left_out() { + let rect = Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some([255, 0, 0, 255]), stroke: None, radius: 0.0 }; + let prims = vec![rect; 10_000]; + let full = prims_svg(&prims, 100.0, 50.0, ""); + assert!(full.len() > 100_000); + assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len()), Some(full.clone())); + assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len() - 1), None); + assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", 1_000), None); + } + #[test] fn every_primitive_becomes_an_svg_element() { let red = [255, 0, 0, 255]; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..af78d9712 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -736,8 +736,10 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { place(&im.anchor) ); } + let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES; for ch in &sh.charts { - if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) { + if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget) { + svg_budget -= svg.len(); out.push_str(&svg); } }