From 2fe5de8f5f8b9bfdc933cde91aa93b749919bd5b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:03:18 +0200 Subject: [PATCH 1/5] fix(util): check rectangle arithmetic --- src/util/geometry.rs | 64 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/util/geometry.rs b/src/util/geometry.rs index 15e47a1c7..1880fdcee 100644 --- a/src/util/geometry.rs +++ b/src/util/geometry.rs @@ -38,8 +38,8 @@ impl Rect { /// Builds a rectangle from min/max bounds (inclusive min, exclusive max). pub fn from_min_max(min_x: i32, min_y: i32, max_x: i32, max_y: i32) -> Option { - let width = max_x - min_x; - let height = max_y - min_y; + let width = i32::try_from(i64::from(max_x) - i64::from(min_x)).ok()?; + let height = i32::try_from(i64::from(max_y) - i64::from(min_y)).ok()?; Self::new(min_x, min_y, width, height) } @@ -50,18 +50,29 @@ impl Rect { /// Returns true if the point lies within the rectangle (inclusive of min, exclusive of max). pub fn contains(&self, x: i32, y: i32) -> bool { - x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height + let x = i64::from(x); + let y = i64::from(y); + let min_x = i64::from(self.x); + let min_y = i64::from(self.y); + let max_x = min_x + i64::from(self.width); + let max_y = min_y + i64::from(self.height); + + self.is_valid() && x >= min_x && x < max_x && y >= min_y && y < max_y } /// Returns a new rectangle inflated by `amount` in all directions. pub fn inflated(&self, amount: i32) -> Option { + if !self.is_valid() { + return None; + } if amount == 0 { return Some(*self); } - let new_x = self.x - amount; - let new_y = self.y - amount; - let new_width = self.width + amount * 2; - let new_height = self.height + amount * 2; + let amount = i64::from(amount); + let new_x = i32::try_from(i64::from(self.x) - amount).ok()?; + let new_y = i32::try_from(i64::from(self.y) - amount).ok()?; + let new_width = i32::try_from(i64::from(self.width) + amount * 2).ok()?; + let new_height = i32::try_from(i64::from(self.height) + amount * 2).ok()?; Rect::new(new_x, new_y, new_width, new_height) } } @@ -124,6 +135,45 @@ mod tests { assert_eq!(rect.inflated(-3), None); } + #[test] + fn rect_from_min_max_rejects_unrepresentable_dimensions() { + assert_eq!( + Rect::from_min_max(i32::MIN, 0, i32::MAX, 1), + None, + "the full i32 coordinate span cannot fit in an i32 width" + ); + assert_eq!(Rect::from_min_max(0, i32::MIN, 1, i32::MAX), None); + } + + #[test] + fn rect_contains_handles_endpoints_beyond_i32_range() { + let near_max = Rect::new(i32::MAX - 1, i32::MAX - 1, 2, 2).unwrap(); + assert!(near_max.contains(i32::MAX, i32::MAX)); + + let near_min = Rect::new(i32::MIN, i32::MIN, 2, 2).unwrap(); + assert!(near_min.contains(i32::MIN + 1, i32::MIN + 1)); + } + + #[test] + fn rect_inflated_rejects_coordinate_and_dimension_overflow() { + let at_min = Rect::new(i32::MIN, 0, 1, 1).unwrap(); + assert_eq!(at_min.inflated(1), None); + + let widest = Rect::new(0, 0, i32::MAX, 1).unwrap(); + assert_eq!(widest.inflated(1), None); + + let ordinary = Rect::new(0, 0, 10, 10).unwrap(); + assert_eq!(ordinary.inflated(i32::MIN), None); + + let invalid = Rect { + x: 0, + y: 0, + width: -1, + height: 10, + }; + assert_eq!(invalid.inflated(0), None); + } + #[test] fn ellipse_bounds_are_order_independent() { assert_eq!(ellipse_bounds(0, 0, 10, 20), ellipse_bounds(10, 20, 0, 0)); From b6333ed5a9079400578c74801951fa4411293fce Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:06:28 +0200 Subject: [PATCH 2/5] fix(util): harden ellipse bounds arithmetic --- src/util/geometry.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/util/geometry.rs b/src/util/geometry.rs index 1880fdcee..ac55a2edd 100644 --- a/src/util/geometry.rs +++ b/src/util/geometry.rs @@ -94,13 +94,21 @@ impl Rect { /// - `rx` = horizontal radius (half width) /// - `ry` = vertical radius (half height) pub fn ellipse_bounds(x1: i32, y1: i32, x2: i32, y2: i32) -> (i32, i32, i32, i32) { - let cx = (x1 + x2) / 2; - let cy = (y1 + y2) / 2; - let rx = ((x2 - x1).abs()) / 2; - let ry = ((y2 - y1).abs()) / 2; + let (cx, rx) = ellipse_axis_bounds(x1, x2); + let (cy, ry) = ellipse_axis_bounds(y1, y2); (cx, cy, rx, ry) } +fn ellipse_axis_bounds(first: i32, second: i32) -> (i32, i32) { + let first = i64::from(first); + let second = i64::from(second); + let center = (first + second) / 2; + let radius = (second - first).abs() / 2; + + // An average of two i32 values and half their distance always fit in i32. + (center as i32, radius as i32) +} + #[cfg(test)] mod tests { use super::*; @@ -183,4 +191,18 @@ mod tests { fn ellipse_bounds_compute_center_and_radii_from_drag_corners() { assert_eq!(ellipse_bounds(4, 6, 14, 18), (9, 12, 5, 6)); } + + #[test] + fn ellipse_bounds_handle_the_full_coordinate_span() { + let expected = (0, 0, i32::MAX, i32::MAX); + + assert_eq!( + ellipse_bounds(i32::MIN, i32::MIN, i32::MAX, i32::MAX), + expected + ); + assert_eq!( + ellipse_bounds(i32::MAX, i32::MAX, i32::MIN, i32::MIN), + expected + ); + } } From ef3a339bb06ba82746f082f1ec47ff1836994bea Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:10:23 +0200 Subject: [PATCH 3/5] fix(input): reject unrepresentable box drags --- src/input/tool/drawing.rs | 82 +++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index f089c466f..18f24333f 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -128,10 +128,12 @@ impl Tool { color: snapshot.color, thick: snapshot.size, }), - ToolDrawingBehavior::Rect => finish_shape(snapshot, usage, |snapshot| { - let (x, w) = normalized_axis(snapshot.start.0, snapshot.end.0); - let (y, h) = normalized_axis(snapshot.start.1, snapshot.end.1); - Shape::Rect { + ToolDrawingBehavior::Rect => { + let Some((x, y, w, h)) = normalized_drag_bounds(snapshot.start, snapshot.end) + else { + return FinishedToolStroke::Noop; + }; + finish_shape(snapshot, usage, |snapshot| Shape::Rect { x, y, w, @@ -139,8 +141,8 @@ impl Tool { fill: snapshot.fill_enabled, color: snapshot.color, thick: snapshot.size, - } - }), + }) + } ToolDrawingBehavior::Ellipse => finish_shape(snapshot, usage, |snapshot| { let (cx, cy, rx, ry) = util::ellipse_bounds( snapshot.start.0, @@ -180,18 +182,20 @@ impl Tool { label: snapshot.arrow_label, }) } - ToolDrawingBehavior::BlurRect => finish_shape(snapshot, usage, |snapshot| { - let (x, w) = normalized_axis(snapshot.start.0, snapshot.end.0); - let (y, h) = normalized_axis(snapshot.start.1, snapshot.end.1); - Shape::BlurRect { + ToolDrawingBehavior::BlurRect => { + let Some((x, y, w, h)) = normalized_drag_bounds(snapshot.start, snapshot.end) + else { + return FinishedToolStroke::Noop; + }; + finish_shape(snapshot, usage, |snapshot| Shape::BlurRect { x, y, w, h, strength: snapshot.size, style: snapshot.blur_style, - } - }), + }) + } ToolDrawingBehavior::Spotlight => finish_shape(snapshot, usage, |snapshot| { let (cx, cy, rx, ry) = util::ellipse_bounds( snapshot.start.0, @@ -269,8 +273,10 @@ impl Tool { thick: snapshot.size, }), ToolDrawingBehavior::Rect => { - let (x, w) = normalized_axis(snapshot.start.0, snapshot.current.0); - let (y, h) = normalized_axis(snapshot.start.1, snapshot.current.1); + let Some((x, y, w, h)) = normalized_drag_bounds(snapshot.start, snapshot.current) + else { + return ProvisionalToolStroke::None; + }; ProvisionalToolStroke::Shape(Shape::Rect { x, y, @@ -315,8 +321,10 @@ impl Tool { label: snapshot.arrow_label, }), ToolDrawingBehavior::BlurRect => { - let (x, w) = normalized_axis(snapshot.start.0, snapshot.current.0); - let (y, h) = normalized_axis(snapshot.start.1, snapshot.current.1); + let Some((x, y, w, h)) = normalized_drag_bounds(snapshot.start, snapshot.current) + else { + return ProvisionalToolStroke::None; + }; ProvisionalToolStroke::BlurReplayPreview(BlurRectParams { x, y, @@ -486,10 +494,42 @@ fn pressure_data_varies(point_thicknesses: &[f32], point_count: usize, threshold (max_t - min_t).abs() > threshold as f32 } -fn normalized_axis(start: i32, end: i32) -> (i32, i32) { - if end >= start { - (start, end - start) - } else { - (end, start - end) +fn normalized_axis(start: i32, end: i32) -> Option<(i32, i32)> { + let length = i32::try_from(start.abs_diff(end)).ok()?; + Some((start.min(end), length)) +} + +fn normalized_drag_bounds(start: (i32, i32), end: (i32, i32)) -> Option<(i32, i32, i32, i32)> { + let (x, width) = normalized_axis(start.0, end.0)?; + let (y, height) = normalized_axis(start.1, end.1)?; + Some((x, y, width, height)) +} + +#[cfg(test)] +mod tests { + use super::{normalized_axis, normalized_drag_bounds}; + + #[test] + fn normalized_axis_is_order_independent() { + assert_eq!(normalized_axis(10, 25), Some((10, 15))); + assert_eq!(normalized_axis(25, 10), Some((10, 15))); + } + + #[test] + fn normalized_axis_accepts_the_largest_representable_span() { + assert_eq!(normalized_axis(i32::MIN, -1), Some((i32::MIN, i32::MAX))); + assert_eq!(normalized_axis(-1, i32::MIN), Some((i32::MIN, i32::MAX))); + } + + #[test] + fn normalized_axis_rejects_unrepresentable_spans() { + assert_eq!(normalized_axis(i32::MIN, i32::MAX), None); + assert_eq!(normalized_axis(i32::MAX, i32::MIN), None); + } + + #[test] + fn normalized_drag_bounds_reject_either_unrepresentable_axis() { + assert_eq!(normalized_drag_bounds((i32::MIN, 0), (i32::MAX, 10)), None); + assert_eq!(normalized_drag_bounds((0, i32::MIN), (10, i32::MAX)), None); } } From 48f3df3c3c3b251dd74a2a5a8ade938e0d794e5b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:18:40 +0200 Subject: [PATCH 4/5] fix(draw): harden shape bounds arithmetic --- src/draw/shape/arrow_label.rs | 4 +- src/draw/shape/bounds.rs | 247 ++++++++++++++++++++++++++-------- src/draw/shape/polygon.rs | 16 +-- src/draw/shape/step_marker.rs | 23 +++- src/draw/shape/tests.rs | 47 +++++++ src/draw/shape/types.rs | 46 ++----- 6 files changed, 271 insertions(+), 112 deletions(-) diff --git a/src/draw/shape/arrow_label.rs b/src/draw/shape/arrow_label.rs index de1c8ebe1..8b90bf906 100644 --- a/src/draw/shape/arrow_label.rs +++ b/src/draw/shape/arrow_label.rs @@ -31,8 +31,8 @@ pub(crate) fn arrow_label_layout( return None; } - let dx = (tip_x - tail_x) as f64; - let dy = (tip_y - tail_y) as f64; + let dx = f64::from(tip_x) - f64::from(tail_x); + let dy = f64::from(tip_y) - f64::from(tail_y); let len = (dx * dx + dy * dy).sqrt(); if len <= f64::EPSILON { return None; diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs index ef13b6883..172338857 100644 --- a/src/draw/shape/bounds.rs +++ b/src/draw/shape/bounds.rs @@ -3,6 +3,9 @@ use crate::util::{self, Rect}; use super::arrow_label::arrow_label_layout; use super::types::ArrowLabel; +const MIN_COORDINATE: i64 = i32::MIN as i64; +const MAX_COORDINATE_EXCLUSIVE: i64 = i32::MAX as i64 + 1; + pub(crate) fn bounding_box_for_points(points: &[(i32, i32)], thick: f64) -> Option { if points.is_empty() { return None; @@ -19,13 +22,27 @@ pub(crate) fn bounding_box_for_points(points: &[(i32, i32)], thick: f64) -> Opti max_y = max_y.max(y); } - let padding = stroke_padding(thick); - min_x -= padding; - max_x += padding; - min_y -= padding; - max_y += padding; + padded_extrema_rect(min_x, min_y, max_x, max_y, stroke_padding(thick)) +} + +pub(super) fn bounding_box_for_pressure_points(points: &[(i32, i32, f32)]) -> Option { + let &(first_x, first_y, _) = points.first()?; + let mut min_x = first_x; + let mut max_x = first_x; + let mut min_y = first_y; + let mut max_y = first_y; + let mut max_thick = 0.0f32; + + for &(x, y, thickness) in points { + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + max_thick = max_thick.max(thickness); + } - ensure_positive_rect(min_x, min_y, max_x, max_y) + let padding = i64::from((max_thick as i32 / 2).max(1)); + padded_extrema_rect(min_x, min_y, max_x, max_y, padding) } pub(crate) fn bounding_box_for_line( @@ -35,35 +52,37 @@ pub(crate) fn bounding_box_for_line( y2: i32, thick: f64, ) -> Option { - let padding = stroke_padding(thick); - - let min_x = x1.min(x2) - padding; - let max_x = x1.max(x2) + padding; - let min_y = y1.min(y2) - padding; - let max_y = y1.max(y2) + padding; - - ensure_positive_rect(min_x, min_y, max_x, max_y) + padded_extrema_rect( + x1.min(x2), + y1.min(y2), + x1.max(x2), + y1.max(y2), + stroke_padding(thick), + ) } pub(crate) fn bounding_box_for_rect(x: i32, y: i32, w: i32, h: i32, thick: f64) -> Option { let padding = stroke_padding(thick); + let x = i64::from(x); + let y = i64::from(y); + let x2 = x + i64::from(w); + let y2 = y + i64::from(h); - let x2 = x + w; - let y2 = y + h; - - let min_x = x.min(x2) - padding; - let max_x = x.max(x2) + padding; - let min_y = y.min(y2) - padding; - let max_y = y.max(y2) + padding; - - ensure_positive_rect(min_x, min_y, max_x, max_y) + ensure_positive_rect_i64( + x.min(x2) - padding, + y.min(y2) - padding, + x.max(x2) + padding, + y.max(y2) + padding, + ) } pub(crate) fn bounding_box_for_blur(x: i32, y: i32, w: i32, h: i32) -> Option { - let x2 = x + w; - let y2 = y + h; - let padding = 1; - ensure_positive_rect( + let x = i64::from(x); + let y = i64::from(y); + let x2 = x + i64::from(w); + let y2 = y + i64::from(h); + let padding = 1_i64; + ensure_positive_rect_i64( x.min(x2) - padding, y.min(y2) - padding, x.max(x2) + padding, @@ -78,13 +97,22 @@ pub(crate) fn bounding_box_for_ellipse( ry: i32, thick: f64, ) -> Option { - let padding = stroke_padding(thick); - let min_x = (cx - rx) - padding; - let max_x = (cx + rx) + padding; - let min_y = (cy - ry) - padding; - let max_y = (cy + ry) + padding; + if rx < 0 || ry < 0 { + return None; + } - ensure_positive_rect(min_x, min_y, max_x, max_y) + let padding = stroke_padding(thick); + let cx = i64::from(cx); + let cy = i64::from(cy); + let rx = i64::from(rx); + let ry = i64::from(ry); + + ensure_positive_rect_i64( + cx - rx - padding, + cy - ry - padding, + cx + rx + padding, + cy + ry + padding, + ) } #[allow(clippy::too_many_arguments)] @@ -141,8 +169,9 @@ pub(crate) fn bounding_box_for_arrow( ) { min_x = min_x.min(layout.bounds.x as f64); min_y = min_y.min(layout.bounds.y as f64); - max_x = max_x.max((layout.bounds.x + layout.bounds.width) as f64); - max_y = max_y.max((layout.bounds.y + layout.bounds.height) as f64); + max_x = max_x.max((i64::from(layout.bounds.x) + i64::from(layout.bounds.width)) as f64); + max_y = + max_y.max((i64::from(layout.bounds.y) + i64::from(layout.bounds.height)) as f64); } } @@ -171,31 +200,73 @@ pub(crate) fn bounding_box_for_eraser(points: &[(i32, i32)], diameter: f64) -> O max_y = max_y.max(y); } - min_x -= padding; - max_x += padding; - min_y -= padding; - max_y += padding; - - ensure_positive_rect(min_x, min_y, max_x, max_y) + padded_extrema_rect(min_x, min_y, max_x, max_y, padding) } -fn stroke_padding(thick: f64) -> i32 { - let padding = (thick / 2.0).ceil() as i32; - padding.max(1) +fn stroke_padding(thick: f64) -> i64 { + ((thick / 2.0).ceil() as i64).clamp(1, i64::from(i32::MAX)) } +#[cfg(test)] fn ensure_positive_rect(min_x: i32, min_y: i32, max_x: i32, max_y: i32) -> Option { - let (min_x, max_x) = if min_x == max_x { - (min_x, max_x + 1) + ensure_positive_rect_i64( + i64::from(min_x), + i64::from(min_y), + i64::from(max_x), + i64::from(max_y), + ) +} + +fn padded_extrema_rect( + min_x: i32, + min_y: i32, + max_x: i32, + max_y: i32, + padding: i64, +) -> Option { + ensure_positive_rect_i64( + i64::from(min_x) - padding, + i64::from(min_y) - padding, + i64::from(max_x) + padding, + i64::from(max_y) + padding, + ) +} + +pub(super) fn ensure_positive_rect_i64( + min_x: i64, + min_y: i64, + max_x: i64, + max_y: i64, +) -> Option { + if min_x > max_x || min_y > max_y { + return None; + } + + let max_x = if min_x == max_x { + max_x.checked_add(1)? } else { - (min_x, max_x) + max_x }; - let (min_y, max_y) = if min_y == max_y { - (min_y, max_y + 1) + let max_y = if min_y == max_y { + max_y.checked_add(1)? } else { - (min_y, max_y) + max_y }; - Rect::from_min_max(min_x, min_y, max_x, max_y) + + let min_x = min_x.clamp(MIN_COORDINATE, MAX_COORDINATE_EXCLUSIVE); + let min_y = min_y.clamp(MIN_COORDINATE, MAX_COORDINATE_EXCLUSIVE); + let max_x = max_x.clamp(MIN_COORDINATE, MAX_COORDINATE_EXCLUSIVE); + let max_y = max_y.clamp(MIN_COORDINATE, MAX_COORDINATE_EXCLUSIVE); + if min_x >= max_x || min_y >= max_y { + return None; + } + + Rect::new( + i32::try_from(min_x).ok()?, + i32::try_from(min_y).ok()?, + i32::try_from(max_x - min_x).ok()?, + i32::try_from(max_y - min_y).ok()?, + ) } pub(crate) fn ensure_positive_rect_f64( @@ -204,11 +275,16 @@ pub(crate) fn ensure_positive_rect_f64( max_x: f64, max_y: f64, ) -> Option { - let min_x = min_x.floor() as i32; - let min_y = min_y.floor() as i32; - let max_x = max_x.ceil() as i32; - let max_y = max_y.ceil() as i32; - ensure_positive_rect(min_x, min_y, max_x, max_y) + if ![min_x, min_y, max_x, max_y].into_iter().all(f64::is_finite) { + return None; + } + + ensure_positive_rect_i64( + min_x.floor() as i64, + min_y.floor() as i64, + max_x.ceil() as i64, + max_y.ceil() as i64, + ) } #[cfg(test)] @@ -259,4 +335,63 @@ mod tests { Rect::new(5, 13, 6, 8) ); } + + #[test] + fn point_bounds_clip_padding_at_coordinate_edges() { + let at_min = bounding_box_for_points(&[(i32::MIN, i32::MIN)], 2.0) + .expect("minimum coordinate should retain in-domain bounds"); + assert!(at_min.contains(i32::MIN, i32::MIN)); + + let at_max = bounding_box_for_points(&[(i32::MAX, i32::MAX)], 2.0) + .expect("maximum coordinate should retain in-domain bounds"); + assert!(at_max.contains(i32::MAX, i32::MAX)); + } + + #[test] + fn unrepresentable_full_span_bounds_fail_closed() { + assert_eq!(bounding_box_for_line(i32::MIN, 0, i32::MAX, 0, 1.0), None); + assert_eq!( + bounding_box_for_points(&[(i32::MIN, 0), (i32::MAX, 0)], 1.0), + None + ); + } + + #[test] + fn rectangle_like_bounds_use_checked_endpoint_arithmetic() { + let rect = bounding_box_for_rect(i32::MAX, i32::MAX, i32::MAX, i32::MAX, 1.0) + .expect("the visible clipped edge should remain representable"); + assert!(rect.contains(i32::MAX, i32::MAX)); + + let blur = bounding_box_for_blur(i32::MAX, i32::MAX, i32::MAX, i32::MAX) + .expect("the visible clipped blur edge should remain representable"); + assert!(blur.contains(i32::MAX, i32::MAX)); + } + + #[test] + fn ellipse_bounds_validate_radii_and_clip_coordinate_edges() { + assert_eq!(bounding_box_for_ellipse(0, 0, -1, 1, 1.0), None); + + let bounds = bounding_box_for_ellipse(i32::MAX, i32::MAX, 1, 1, 1.0) + .expect("the visible clipped ellipse edge should remain representable"); + assert!(bounds.contains(i32::MAX, i32::MAX)); + } + + #[test] + fn floating_bounds_reject_non_finite_values() { + assert_eq!(ensure_positive_rect_f64(f64::NAN, 0.0, 1.0, 1.0), None); + assert_eq!(ensure_positive_rect_f64(0.0, 0.0, f64::INFINITY, 1.0), None); + } + + #[test] + fn degenerate_bounds_at_maximum_coordinate_remain_visible() { + assert_eq!( + ensure_positive_rect_i64( + i64::from(i32::MAX), + i64::from(i32::MAX), + i64::from(i32::MAX), + i64::from(i32::MAX), + ), + Rect::new(i32::MAX, i32::MAX, 1, 1) + ); + } } diff --git a/src/draw/shape/polygon.rs b/src/draw/shape/polygon.rs index be49e55cc..64e0cbfd1 100644 --- a/src/draw/shape/polygon.rs +++ b/src/draw/shape/polygon.rs @@ -1,6 +1,8 @@ use crate::util::Rect; use serde::{Deserialize, Serialize}; +use super::bounds::bounding_box_for_points; + pub const REGULAR_POLYGON_MIN_SIDES: u8 = 3; pub const REGULAR_POLYGON_MAX_SIDES: u8 = 12; pub const REGULAR_POLYGON_DEFAULT_SIDES: u8 = 5; @@ -89,19 +91,7 @@ pub(crate) fn bounding_box_for_polygon(points: &[(i32, i32)], thick: f64) -> Opt return None; } - let mut min_x = points[0].0; - let mut min_y = points[0].1; - let mut max_x = points[0].0; - let mut max_y = points[0].1; - for &(x, y) in &points[1..] { - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - } - - let pad = ((thick / 2.0).ceil() as i32).max(1); - Rect::from_min_max(min_x - pad, min_y - pad, max_x + pad, max_y + pad) + bounding_box_for_points(points, thick) } fn triangle_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> { diff --git a/src/draw/shape/step_marker.rs b/src/draw/shape/step_marker.rs index d599046f2..9f297c842 100644 --- a/src/draw/shape/step_marker.rs +++ b/src/draw/shape/step_marker.rs @@ -1,6 +1,7 @@ use crate::draw::FontDescriptor; use crate::util::Rect; +use super::bounds::ensure_positive_rect_f64; use super::text_cache::measure_text_cached; const STEP_MARKER_PADDING_RATIO: f64 = 0.45; @@ -31,11 +32,12 @@ pub(crate) fn step_marker_bounds( let radius = step_marker_radius(value, size, font_descriptor); let outline = step_marker_outline_thickness(size); let total = radius + (outline / 2.0); - let min_x = (x as f64 - total).floor() as i32; - let max_x = (x as f64 + total).ceil() as i32; - let min_y = (y as f64 - total).floor() as i32; - let max_y = (y as f64 + total).ceil() as i32; - Rect::from_min_max(min_x, min_y, max_x, max_y) + ensure_positive_rect_f64( + x as f64 - total, + y as f64 - total, + x as f64 + total, + y as f64 + total, + ) } #[cfg(test)] @@ -69,4 +71,15 @@ mod tests { assert!(bounds.width > 0); assert!(bounds.height > 0); } + + #[test] + fn step_marker_bounds_clip_at_coordinate_edges() { + let font = FontDescriptor::default(); + + for coordinate in [i32::MIN, i32::MAX] { + let bounds = step_marker_bounds(coordinate, coordinate, 3, 18.0, &font) + .expect("edge marker should retain visible bounds"); + assert!(bounds.contains(coordinate, coordinate)); + } + } } diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index e9ee6f9bd..f3c4aac2d 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -289,6 +289,53 @@ fn image_bounding_box_and_kind_name_use_display_bounds() { assert_eq!(shape.kind_name(), "Image"); } +#[test] +fn pressure_and_image_bounds_handle_extreme_coordinates() { + let pressure = Shape::FreehandPressure { + points: vec![(i32::MAX, i32::MAX, 2.0)], + color: WHITE, + }; + let pressure_bounds = pressure + .bounding_box() + .expect("edge pressure point should retain visible bounds"); + assert!(pressure_bounds.contains(i32::MAX, i32::MAX)); + + let image = Shape::Image { + x: i32::MAX, + y: i32::MAX, + w: i32::MAX, + h: i32::MAX, + data: EmbeddedImage { + mime_type: "image/png".to_string(), + width: 1, + height: 1, + bytes: vec![1], + }, + }; + let image_bounds = image + .bounding_box() + .expect("edge image should retain visible bounds"); + assert!(image_bounds.contains(i32::MAX, i32::MAX)); +} + +#[test] +fn arrow_label_layout_handles_full_span_endpoints() { + let font = FontDescriptor::default(); + let layout = super::arrow_label_layout( + i32::MAX, + i32::MAX, + i32::MIN, + i32::MIN, + 2.0, + "1", + 12.0, + &font, + ) + .expect("extreme arrow endpoints should not overflow label geometry"); + + assert!(layout.bounds.is_valid()); +} + #[test] fn image_serialization_uses_base64_bytes() { let shape = Shape::Image { diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index 6726c73f7..873678e44 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -1,6 +1,7 @@ use super::bounds::{ bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse, - bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect, + bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, + bounding_box_for_pressure_points, bounding_box_for_rect, ensure_positive_rect_i64, }; use super::polygon::{PolygonKind, bounding_box_for_polygon}; use super::step_marker::step_marker_bounds; @@ -334,36 +335,12 @@ impl Shape { /// Returns the axis-aligned bounding box for this shape, expanded to cover stroke width. /// /// The returned rectangle is suitable for dirty region tracking and damage hints. - /// Returns `None` only when the shape has no drawable area (e.g., degenerate data). + /// Returns `None` when the shape has no drawable area or its full bounds cannot be + /// represented safely by [`Rect`]. pub fn bounding_box(&self) -> Option { match self { Shape::Freehand { points, thick, .. } => bounding_box_for_points(points, *thick), - Shape::FreehandPressure { points, .. } => { - if points.is_empty() { - return None; - } - let mut min_x = points[0].0; - let mut min_y = points[0].1; - let mut max_x = points[0].0; - let mut max_y = points[0].1; - let mut max_thick = 0.0f32; - - for &(x, y, t) in points { - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - max_thick = max_thick.max(t); - } - - let pad = (max_thick as i32 / 2).max(1); - Some(Rect { - x: min_x - pad, - y: min_y - pad, - width: (max_x - min_x + 2 * pad).max(1), - height: (max_y - min_y + 2 * pad).max(1), - }) - } + Shape::FreehandPressure { points, .. } => bounding_box_for_pressure_points(points), Shape::Line { x1, y1, @@ -475,14 +452,11 @@ const fn default_arrow_head_at_end() -> bool { } fn normalized_rect(x: i32, y: i32, w: i32, h: i32) -> Option { - let min_x = if w < 0 { x.saturating_add(w) } else { x }; - let min_y = if h < 0 { y.saturating_add(h) } else { y }; - Rect::new( - min_x, - min_y, - w.saturating_abs().max(1), - h.saturating_abs().max(1), - ) + let x = i64::from(x); + let y = i64::from(y); + let x2 = x + i64::from(w); + let y2 = y + i64::from(h); + ensure_positive_rect_i64(x.min(x2), y.min(y2), x.max(x2), y.max(y2)) } mod base64_bytes { From 27947567fb08da25ab0a6dcf809b1756751eda18 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:20:50 +0200 Subject: [PATCH 5/5] fix(draw): harden polygon drag geometry --- src/draw/shape/polygon.rs | 45 +++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/draw/shape/polygon.rs b/src/draw/shape/polygon.rs index 64e0cbfd1..7b2444645 100644 --- a/src/draw/shape/polygon.rs +++ b/src/draw/shape/polygon.rs @@ -109,8 +109,8 @@ fn triangle_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> { fn parallelogram_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> { let (min_x, max_x) = sorted_pair(start.0, end.0); let (min_y, max_y) = sorted_pair(start.1, end.1); - let width = max_x - min_x; - let skew = (width.abs() / 4).max(1); + let width = i64::from(max_x) - i64::from(min_x); + let skew = (width / 4).clamp(1, i64::from(i32::MAX)) as i32; if end.0 >= start.0 { vec![ @@ -146,8 +146,8 @@ fn regular_polygon_points(start: (i32, i32), end: (i32, i32), sides: u8) -> Vec< let sides = clamp_regular_sides(sides); let center_x = (start.0 as f64 + end.0 as f64) / 2.0; let center_y = (start.1 as f64 + end.1 as f64) / 2.0; - let radius_x = (end.0 - start.0).abs() as f64 / 2.0; - let radius_y = (end.1 - start.1).abs() as f64 / 2.0; + let radius_x = f64::from(end.0.abs_diff(start.0)) / 2.0; + let radius_y = f64::from(end.1.abs_diff(start.1)) / 2.0; let radius = radius_x.min(radius_y); let start_angle = -std::f64::consts::FRAC_PI_2; @@ -203,4 +203,41 @@ mod tests { assert_eq!(clamp_regular_sides(9), 9); assert_eq!(clamp_regular_sides(80), 12); } + + #[test] + fn polygon_templates_handle_full_coordinate_span_drags() { + let start = (i32::MIN, i32::MIN); + let end = (i32::MAX, i32::MAX); + + for template in [ + PolygonTemplate::Triangle, + PolygonTemplate::Parallelogram, + PolygonTemplate::Rhombus, + PolygonTemplate::Regular, + ] { + let points = generated_points(template, start, end, 5); + assert!( + has_minimum_distinct_points(&points), + "{template:?} should retain distinct points" + ); + } + } + + #[test] + fn regular_polygon_extreme_drag_is_order_independent() { + assert_eq!( + generated_points( + PolygonTemplate::Regular, + (i32::MIN, i32::MIN), + (i32::MAX, i32::MAX), + 6, + ), + generated_points( + PolygonTemplate::Regular, + (i32::MAX, i32::MAX), + (i32::MIN, i32::MIN), + 6, + ) + ); + } }