From 37995765a1ee3237f342f48eb5e1cb32bdc84c5d Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 25 Oct 2026 09:56:10 +0300 Subject: [PATCH] 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")