diff --git a/crates/chart/src/raster.rs b/crates/chart/src/raster.rs index 0de2450b5..80042b970 100644 --- a/crates/chart/src/raster.rs +++ b/crates/chart/src/raster.rs @@ -7,6 +7,20 @@ use crate::{Prim, Rgba}; /// Largest image we allocate (pixels). const MAX_PIXELS: u64 = 64 * 1024 * 1024; const SUB: usize = 4; +/// Scan-conversion work one [`rasterize`] call may do: edge tests (an active edge at one +/// sub-sample) plus covered pixels blended. Edges are only tested on the rows they cross, but a +/// caller can still pass enough geometry to keep the rasteriser busy for minutes on a small +/// image; once the allowance is spent the remaining fills are skipped. 2^28 is about a second, +/// far beyond any chart. +const MAX_SCAN_WORK: u64 = 1 << 28; +/// Most points one filled path may have: more is skipped before any edge is built. Chart +/// renderers cap and decimate their paths far below this; it only bounds direct callers. +const MAX_FILL_POINTS: usize = 1 << 22; + +/// Most points one stroked polyline may have: more is skipped before any segment path is built +/// (each segment becomes its own quad). Charts hold at most 100,000 points per series and draw far +/// fewer; it only bounds direct callers. +const MAX_STROKE_POINTS: usize = 1 << 17; type Path = Vec>; @@ -15,6 +29,8 @@ struct Canvas { h: usize, buf: Vec, acc: Vec, + /// Scan-conversion work left (see [`MAX_SCAN_WORK`]). + work: u64, } impl Canvas { @@ -41,7 +57,12 @@ impl Canvas { /// Fills a multi-contour path with the even-odd rule. fn fill(&mut self, path: &Path, color: Rgba) { - let mut edges: Vec<[f32; 4]> = Vec::new(); + // Out of work, or more geometry than the allowance could ever scan: no edge list is built. + if self.work == 0 || path.iter().map(Vec::len).fold(0usize, usize::saturating_add) > MAX_FILL_POINTS { + return; + } + // Each edge as [top y, bottom y, x0, y0, x1, y1]. + let mut edges: Vec<[f32; 6]> = Vec::new(); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); for c in path { if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) { @@ -54,7 +75,7 @@ impl Canvas { miny = miny.min(p[1]); maxy = maxy.max(p[1]); if p[1] != q[1] { - edges.push([p[0], p[1], q[0], q[1]]); + edges.push([p[1].min(q[1]), p[1].max(q[1]), p[0], p[1], q[0], q[1]]); } } } @@ -72,8 +93,28 @@ impl Canvas { if self.acc.len() < bw + 1 { self.acc.resize(bw + 1, 0.0); } + // Active-edge list: edges join when the scan reaches their top and leave past their + // bottom, so each row tests only the edges crossing it instead of every edge. + edges.sort_by(|a, b| a[0].total_cmp(&b[0])); + let mut pending = edges.iter().peekable(); + let mut active: Vec<[f32; 6]> = Vec::new(); let mut xs: Vec = Vec::new(); for py in y0..y1 { + while let Some(e) = pending.next_if(|e| e[0] < py as f32 + 1.0) { + active.push(*e); + } + active.retain(|e| e[1] > py as f32); + if active.is_empty() { + continue; + } + // The row's edge tests and its `bw` accumulator clears and blends, charged before any of + // them, so rows with no coverage still count. + let cost = (active.len() * SUB) as u64 + bw as u64; + if cost > self.work { + self.work = 0; + return; + } + self.work -= cost; for v in self.acc.iter_mut().take(bw) { *v = 0.0; } @@ -81,10 +122,9 @@ impl Canvas { for s in 0..SUB { let sy = py as f32 + (s as f32 + 0.5) / SUB as f32; xs.clear(); - for e in &edges { - let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) }; - if sy >= ya && sy < yb { - xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1])); + for e in &active { + if sy >= e[0] && sy < e[1] { + xs.push(e[2] + (sy - e[3]) * (e[4] - e[2]) / (e[5] - e[3])); } } xs.sort_by(f32::total_cmp); @@ -154,6 +194,9 @@ fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> { /// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel). fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec { + if pts.len() > MAX_STROKE_POINTS { + return Vec::new(); + } let width = width.clamp(1.0, 50.0); let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) { @@ -212,7 +255,7 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { for _ in 0..w * h { buf.extend_from_slice(&background); } - let mut cv = Canvas { w, h, buf, acc: Vec::new() }; + let mut cv = Canvas { w, h, buf, acc: Vec::new(), work: MAX_SCAN_WORK }; for p in prims { match p { Prim::Rect { x, y, w, h, fill, stroke, radius } => { @@ -274,6 +317,40 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { mod tests { use super::*; + /// Security: each row tests only the edges crossing it. A polygon of 100,000 tiny edges over + /// 4,000 rows tested every edge at every sub-sample of every row (1.6 billion tests). + #[test] + fn many_short_edges_scan_only_their_rows() { + let mut pts: Vec<[f32; 2]> = (0..100_000).map(|i| [if i % 2 == 0 { 0.0 } else { 0.5 }, i as f32 * 0.04]).collect(); + pts.push([2.0, 4_000.0]); + pts.push([2.0, 0.0]); + let img = rasterize(&[Prim::Polygon { pts, fill: [255, 0, 0, 255], stroke: None }], 2, 4_000, [0, 0, 0, 0]); + // The right-hand pixel of every row is inside the polygon. + assert_eq!(img.get(4..8), Some(&[255, 0, 0, 255][..])); + assert_eq!(img.get(4 * (2 * 3_999 + 1)..4 * (2 * 3_999 + 2)), Some(&[255, 0, 0, 255][..])); + } + + /// Security: scan conversion stops once the call's work allowance is spent. + #[test] + fn fills_stop_when_the_work_allowance_is_spent() { + let square = vec![vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]]; + let mut cv = Canvas { w: 4, h: 4, buf: vec![0; 64], acc: Vec::new(), work: 2 * SUB as u64 * 4 + 16 }; + cv.fill(&square, [255; 4]); + assert!(cv.buf.iter().all(|&b| b == 255)); + cv.buf = vec![0; 64]; + cv.fill(&square, [255; 4]); + assert!(cv.buf.iter().all(|&b| b == 0)); + } + + /// Security: a polyline over the point cap builds no segment paths at all, solid or dashed. + #[test] + fn oversized_polylines_build_no_segment_paths() { + let line = |n: usize| -> Vec<[f32; 2]> { (0..n).map(|i| [i as f32, (i % 2) as f32]).collect() }; + assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, false, false).is_empty()); + assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, true, false).is_empty()); + assert_eq!(stroke_paths(&line(MAX_STROKE_POINTS), 1.0, false, false).len(), MAX_STROKE_POINTS - 1); + } + #[test] fn dashed_geometry_stays_within_the_dash_budget() { // Forty very long dashed lines, each allowed 10_000 dashes per segment: before the diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs index 770aabe02..678181326 100644 --- a/crates/chart/src/render/lines.rs +++ b/crates/chart/src/render/lines.rs @@ -136,7 +136,13 @@ pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) /// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), /// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. +/// +/// The points of the smoothed runs (data points included) are budgeted across all the runs of the line, not per run: +/// short runs between gaps escape decimation, and 100 sub-segments each would turn a +/// 100,000-point series of three-point runs into millions of points. Past the budget the +/// remaining runs are drawn through their data points only. pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { + let mut budget = MAX_LINE_PTS * 2; let mut run: Vec<[f32; 2]> = Vec::new(); for p in pts.iter().chain(std::iter::once(&None)) { match p { @@ -145,7 +151,11 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi if run.len() >= 2 { let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); let r = match smooth { - Some(plot) => smooth_run(r, plot), + Some(plot) => { + let r = smooth_run(r, plot, budget); + budget = budget.saturating_sub(r.len()); + r + } None => r, }; ctx.line(r, color, width); @@ -158,15 +168,15 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi } /// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to -/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are +/// 100), chosen so the whole curve stays within `cap` points (one per data point when `cap` is +/// smaller than that). Endpoints are /// duplicated so the curve passes through the first and last points, and the curve is clamped /// to the `plot` box (a spline overshoots its data near sharp turns). -fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { +fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx, cap: usize) -> Vec<[f32; 2]> { let n = pts.len(); if n < 3 { return pts; } - let cap = MAX_LINE_PTS * 2; let steps = (cap / n).clamp(1, 100); let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); let at = |i: isize| -> [f32; 2] { @@ -219,7 +229,7 @@ mod smooth_tests { fn long_runs_are_smoothed_uniformly_within_the_cap() { let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); - let out = smooth_run(pts, plot); + let out = smooth_run(pts, plot, MAX_LINE_PTS * 2); assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. assert_eq!(out.len(), 1499 * 2 + 1); @@ -231,11 +241,29 @@ mod smooth_tests { let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; // A step right at the plot edges: Catmull-Rom overshoots past both. let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; - let out = smooth_run(pts, plot); + let out = smooth_run(pts, plot, MAX_LINE_PTS * 2); assert!(out.len() > 5); assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); } + /// Security: smoothing budgets its added points across every run of a line. A series of + /// three-point runs between gaps used to get 100 sub-segments per run, 67 times its data. + #[test] + fn gap_separated_runs_share_one_smoothing_budget() { + let plot = Bx { x: 0.0, y: 0.0, w: 1e6, h: 100.0 }; + let pts: Vec> = + (0..100_000).map(|i| if i % 4 == 3 { None } else { Some([i as f32, if i % 2 == 0 { 20.0 } else { 80.0 }]) }).collect(); + let m = crate::ApproxMeasure; + let mut ctx = Ctx { out: Vec::new(), m: &m }; + draw_runs(&mut ctx, &pts, WHITE, 1.0, Some(plot)); + let total: usize = ctx.out.iter().map(|p| if let Prim::Line { pts, .. } = p { pts.len() } else { 0 }).sum(); + // 75,000 data points, plus at most the budget added by interpolation. + assert!(total <= 75_000 + MAX_LINE_PTS * 2, "{total}"); + assert_eq!(ctx.out.len(), 25_000); + // The first runs are still smoothed. + assert!(matches!(ctx.out.first(), Some(Prim::Line { pts, .. }) if pts.len() > 3)); + } + #[test] fn decimate_area_preserves_paired_vertical_strips() { let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect(); diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 11213a5e7..baa0730ad 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -184,6 +184,53 @@ fn value_text(v: &Value) -> String { } } +/// Characters kept of one category label: an axis label never shows more (labels are fitted to +/// the axis), and copying more only costs memory. +const MAX_LABEL_CHARS: usize = 255; +/// Bytes of category label text kept per series. One long text broadcast by a short formula +/// (`IF(SEQUENCE(100000), long_text)`) is a single shared value, but turning every element into +/// its own label copied it 100,000 times (gigabytes). Labels past the budget are empty. +const MAX_CATEGORY_BYTES: usize = 8 << 20; + +/// At most [`MAX_LABEL_CHARS`] characters and `room` bytes of `s`, cut at a character boundary. +fn clip_label(s: &str, room: usize) -> String { + let mut end = 0; + for (n, (i, c)) in s.char_indices().enumerate() { + if n >= MAX_LABEL_CHARS || i + c.len_utf8() > room { + break; + } + end = i + c.len_utf8(); + } + s.get(..end).unwrap_or_default().to_string() +} + +/// The label of category `i` (value `v`) in at most `room` bytes: formatted with its source +/// cell's number format when it is a number. +fn category_label(wb: &Workbook, src: Option<(&gridcraft_model::Sheet, RangeRef)>, i: usize, v: &Value, room: usize) -> String { + if room == 0 { + return String::new(); // nothing fits: skip the number formatting + } + if let (Some((s, r)), Value::Number(_)) = (src, v) { + let w = r.width().max(1) as usize; + let at = r.start.offset((i / w) as i64, (i % w) as i64); + if let Some(c) = at { + let code = &wb.styles.get(s.style_id(c)).num_fmt.0; + if !code.eq_ignore_ascii_case("general") { + return clip_label(&format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text, room); + } + } + } + // Text is clipped before it is copied: the source may be one long shared string. + let mut v = v; + while let Value::Array(a) = v { + v = a.get(0, 0).unwrap_or(&Value::Empty); + } + match v { + Value::Text(t) => clip_label(t, room), + _ => clip_label(&value_text(v), room), + } +} + /// Category labels, formatted with each source cell's number format (dates, currency…), and /// 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 @@ -221,29 +268,29 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limi None => eval_list_in(wb, sheet, formula, limit, 0), }; let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); + // The retained levels are known before any label is charged: a cell on a discarded level + // gets no label and spends none of the budget. `None`: a flat list (every cell is a label). + let layout = src.and_then(|(_, r)| { + let levels = levels?; + let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize); + (vals.len() == w.saturating_mul(h)).then_some((levels, w, h)) // else truncated or not a plain block + }); + let kept = + |i: usize| layout.is_none_or(|(l, w, h)| if l.by_columns { i % w + 1 + l.outer as usize >= w } else { i / w + 1 + l.outer as usize >= h }); + let mut room = MAX_CATEGORY_BYTES; let texts: Vec = vals .iter() .enumerate() .map(|(i, v)| { - if let (Some((s, r)), Value::Number(_)) = (src, v) { - let w = r.width().max(1) as usize; - let at = r.start.offset((i / w) as i64, (i % w) as i64); - if let Some(c) = at { - let code = &wb.styles.get(s.style_id(c)).num_fmt.0; - if !code.eq_ignore_ascii_case("general") { - return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text; - } - } + if !kept(i) { + return String::new(); } - value_text(v) + let label = category_label(wb, src, i, v, room); + room = room.saturating_sub(label.len()); + label }) .collect(); - let Some((_, r)) = src 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 - } + let Some((levels, w, h)) = layout else { return (texts, vec![]) }; let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default(); let column = |c: usize| (0..h).map(|row| at(row, c)).collect::>(); let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::>(); @@ -369,6 +416,46 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { ChartData { categories, category_levels, series, palette } } +#[cfg(test)] +mod label_budget_tests { + use gridcraft_model::Workbook; + + /// Security: category labels are clipped before they are copied, with one byte budget per + /// series. A formula broadcasting one long text to 100,000 categories used to copy it + /// 100,000 times (3.3 GB). + #[test] + fn category_labels_share_one_byte_budget() { + let wb = Workbook::new(); + let (texts, _) = super::category_table(&wb, 0, r#"=IF(SEQUENCE(100000),REPT("x",32767))"#, "", super::MAX_POINTS); + assert_eq!(texts.len(), 100_000); + assert_eq!(texts.first().map(String::len), Some(super::MAX_LABEL_CHARS)); + assert!(texts.iter().map(String::len).sum::() <= super::MAX_CATEGORY_BYTES); + // Labels are cut at character boundaries. + assert_eq!(super::clip_label("aé", 2), "a"); + assert_eq!(super::clip_label("aé", 3), "aé"); + } + + /// Cells on discarded outer levels (only the innermost labels and three outer levels are + /// kept) spend none of the byte budget. + #[test] + fn discarded_levels_do_not_spend_the_label_budget() { + let mut wb = Workbook::new(); + let rows = 8000u32; + if let Some(sh) = wb.sheet_mut(0) { + for r in 0..rows { + for c in 0..5 { + sh.set_value(gridcraft_core::CellRef::new(r, c), gridcraft_core::Value::text("x".repeat(255))); + } + } + } + // Five columns: column A is a fourth outer level and is discarded. + let (inner, outer) = super::category_table(&wb, 0, "=A1:E8000", "", super::MAX_POINTS); + assert_eq!(outer.len(), 3); + assert_eq!(inner.last().map(String::len), Some(super::MAX_LABEL_CHARS)); + assert_eq!(outer.first().and_then(|l| l.last()).map(String::len), Some(super::MAX_LABEL_CHARS)); + } +} + #[cfg(test)] mod tests { use gridcraft_model::Workbook;