From 37995765a1ee3237f342f48eb5e1cb32bdc84c5d Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 25 Oct 2026 09:56:10 +0300 Subject: [PATCH 1/2] G8: AI art style and gradient import regression tests for hostile values ## What and why Hostile `.ai` art styles and gradients (out-of-range caps, joins, miter limits, dash arrays, stroke widths, width profiles, blend opacity, paint flags, operand counts, too many gradient stops) must be clamped or ignored on import. The tests only: the limits and checks they cover are already on main, so the tests pin that behaviour so a later change can't silently drop it. ## How it was tested The new tests pass, along with the full `cargo xtask ci` on the branch. Combines the tests from: stream/vc-featD-g8-art-style-blend-opacity, stream/vc-featD-g8-art-style-cap-join, stream/vc-featD-g8-art-style-miter-cap, stream/vc-featD-g8-art-style-operands-cap, stream/vc-featD-g8-art-style-paint-flags, stream/vc-featD-g8-art-style-stroke-width, stream/vc-featD-g8-gradient-stops-cap, stream/vc-featD-g8-art-style-dash-cap, stream/vc-featD-g8-art-style-width-profile-cap --- crates/eps/src/import/ai/paint.rs | 12 +++++ crates/eps/src/import/ai/style.rs | 83 ++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 94c65ef89..b624a2247 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -422,6 +422,18 @@ mod tests { assert_eq!((geom.start, geom.end), (Point::new(10.0, 10.0), Point::new(50.0, 10.0))); } + #[test] + fn gradient_stops_beyond_the_cap_are_ignored() { + let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)]).unwrap(); + let mut spots = vec![]; + for i in 0..257 { + let ramp = i as f64 / 256.0 * 100.0; + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots); + } + let (_, g) = d.finish().unwrap(); + assert_eq!(g.stops.len(), MAX_STOPS); + } + /// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in /// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops. #[test] diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 68739fb05..91aa02475 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -764,6 +764,20 @@ mod tests { assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); } + #[test] + fn art_styles_clear_operands_beyond_the_cap() { + let mut data = Vec::from( + b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", + ); + for _ in 0..80 { + data.extend_from_slice(b"1 "); + } + data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); + let styles = art_styles(&data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); + } + #[test] fn art_styles_ignore_filters_beyond_the_cap() { const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; @@ -781,14 +795,81 @@ mod tests { fn hostile_dash_offsets_and_width_profiles_are_tamed() { let mut f = Filter::default(); paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]); - assert_eq!(f.dash.map(|d| d.offset), Some(0.0)); + assert_eq!(f.dash.as_ref().map(|d| d.offset), Some(0.0)); // Positions outside 0..=1 and out of order, a negative width, a non-finite one. let arrays = HashMap::from([ ("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]), ("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]), ]); assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); + let ok = (0..64).map(|_| V::Num(2.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(ok), V::Num(0.0)]); + assert_eq!(f.dash.as_ref().map(|d| d.pattern.len()), Some(64)); + let huge = (0..65).map(|_| V::Num(1.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(huge), V::Num(0.0)]); + assert_eq!(f.dash, None); + let long = (0..1025).map(|i| i as f64 / 1024.0).collect::>(); + let wide = vec![1.0; 1025]; + let huge = HashMap::from([("BSVariableWidthTValuesArray".to_string(), long), ("BSCenteredWidthsArray".to_string(), wide)]); + assert_eq!(width_profile(&huge), None); } + + #[test] + fn hostile_stroke_widths_are_sanitized() { + let mut f = Filter::default(); + paint_op(&mut f, "w", &[V::Num(12.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(-1.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(f64::INFINITY)]); + assert_eq!(f.width, 12.0); + } + + #[test] + fn hostile_stroke_paint_flags_ignore_short_operands() { + let mut f = Filter::default(); + f.paints = (true, true); + paint_op(&mut f, "Xd", &[V::Num(1.0)]); + assert_eq!(f.paints, (true, true)); + paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); + assert_eq!(f.paints, (false, true)); + paint_op(&mut f, "Xd", &[V::Num(2.0), V::Num(0.0)]); + assert_eq!(f.paints, (true, false)); + } + + #[test] + fn hostile_stroke_miter_limits_are_capped() { + let mut f = Filter::default(); + paint_op(&mut f, "M", &[V::Num(900.0)]); + assert_eq!(f.miter, 500.0); + paint_op(&mut f, "M", &[V::Num(0.5)]); + assert_eq!(f.miter, 500.0); + } + + #[test] + fn hostile_stroke_caps_and_joins_ignore_invalid_values() { + let mut f = Filter::default(); + paint_op(&mut f, "J", &[V::Num(99.0)]); + assert_eq!(f.cap, LineCap::Butt); + paint_op(&mut f, "J", &[V::Num(2.0)]); + assert_eq!(f.cap, LineCap::Square); + paint_op(&mut f, "j", &[V::Num(99.0)]); + assert_eq!(f.join, LineJoin::Miter); + paint_op(&mut f, "j", &[V::Num(1.0)]); + assert_eq!(f.join, LineJoin::Round); + } + + #[test] + fn hostile_blend_opacities_are_clamped() { + let mut f = Filter::default(); + paint_op(&mut f, "Xy", &[V::Num(3.0), V::Num(1.5)]); + assert_eq!(f.xy, Some((3, 1.0))); + paint_op(&mut f, "Xy", &[V::Num(-1.0), V::Num(0.25)]); + assert_eq!(f.xy, Some((0, 0.25))); + paint_op(&mut f, "Xy", &[V::Num(0.0), V::Num(f64::NAN)]); + assert_eq!(f.xy, Some((0, 0.25))); + } + /// A basic filter `name` whose dictionary holds `body`. fn basic(name: &str, body: &str) -> String { format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") From a0b038e9e2c9b1d39528bb6fb060ebaba422c460 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 26 Oct 2026 06:55:12 +0000 Subject: [PATCH 2/2] Fix clippy after upstream merge (art style test, Fenwick step, pencil bool) Co-authored-by: nrq6993-a11y --- crates/eps/src/import/ai/style.rs | 3 +-- crates/pathops/src/budget.rs | 12 ++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 91aa02475..389b0657e 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -827,8 +827,7 @@ mod tests { #[test] fn hostile_stroke_paint_flags_ignore_short_operands() { - let mut f = Filter::default(); - f.paints = (true, true); + let mut f = Filter { paints: (true, true), ..Filter::default() }; paint_op(&mut f, "Xd", &[V::Num(1.0)]); assert_eq!(f.paints, (true, true)); paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); diff --git a/crates/pathops/src/budget.rs b/crates/pathops/src/budget.rs index ab7ead8a6..01559ce8a 100644 --- a/crates/pathops/src/budget.rs +++ b/crates/pathops/src/budget.rs @@ -15,14 +15,18 @@ pub const MAX_SEGMENT_PAIRS: usize = 1 << 22; /// Fenwick tree of counts over its slots. struct Counts(Vec); -// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version). -#[allow(clippy::manual_isolate_lowest_one)] +#[inline] +fn fenwick_step(i: usize) -> usize { + // Same as `i.isolate_lowest_one()`; that API is still unstable on the workspace MSRV. + 1 << i.trailing_zeros() +} + impl Counts { fn add(&mut self, slot: usize, d: isize) { let mut i = slot + 1; while let Some(c) = self.0.get_mut(i) { *c += d; - i += i & i.wrapping_neg(); + i += fenwick_step(i); } } @@ -31,7 +35,7 @@ impl Counts { let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0); while i > 0 { s += self.0.get(i).copied().unwrap_or(0); - i -= i & i.wrapping_neg(); + i -= fenwick_step(i); } s }