diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 3c011dc4..65e352cb 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -343,6 +343,15 @@ fn matrix_val(m: Affine) -> V { array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) } +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} + +/// The most turns one `arc` or `arcn` draws. +const MAX_ARC_TURNS: f64 = 1024.0; + /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; @@ -502,20 +511,33 @@ impl Interp { } fn arc(&mut self, neg: bool) { - let a2 = self.popn().to_radians(); - let a1 = self.popn().to_radians(); + let a2 = self.popn(); + let a1 = self.popn(); let r = self.popn(); let c = self.user_point(); - let mut sweep = a2 - a1; - if neg { - while sweep > 0.0 { - sweep -= std::f64::consts::TAU; - } + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PostScript Language Reference §8.2, `arc` and `arcn`). Those + // sweeps, and the first endpoint, are worked out from what is left of each angle after + // whole turns. + let sweep = if !neg && a2 < a1 { + ccw_turn(a1, a2) + } else if neg && a2 > a1 { + -ccw_turn(a2, a1) } else { - while sweep < 0.0 { - sweep += std::f64::consts::TAU; - } - } + a2 - a1 + }; + // A sweep of more than `MAX_ARC_TURNS` turns is shortened by whole pairs of turns to + // between `MAX_ARC_TURNS - 2` and `MAX_ARC_TURNS` turns, with a skip note. The arc still + // ends at `a2`, and an even-odd fill or clip of it covers the same area as the full sweep. + let max = MAX_ARC_TURNS * 360.0; + let sweep = if sweep.abs() > max { + self.b.skip("arc (too many turns)"); + let s = sweep.signum(); + s * (max - 720.0 + (s * (a2 % 720.0 - a1 % 720.0)).rem_euclid(720.0)) + } else { + sweep + }; + let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { self.line_to(start); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 8314c7d1..532e32cb 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -296,6 +296,69 @@ fn truncated_and_corrupt_eps_never_panic() { } } +/// Parses `body` after the header [`eps`] adds, on a thread of its own, and fails the test when +/// parsing takes more than 20 s. +fn eps_soon(body: &str) -> Doc { + let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec(); + b.extend_from_slice(body.as_bytes()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(parse(&b)).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("parsing {body} did not finish")).unwrap() +} + +#[test] +fn arcs_with_huge_angles_end_at_their_second_angle() { + use kurbo::{Point, Rect, Shape as _}; + // `arc` raises its second angle by multiples of 360 until it is at least the first, and + // `arcn` lowers it until it is at most the first (PostScript Language Reference §8.2), + // however large the angles: the arc starts at the first angle's remainder after whole turns + // and ends at the second angle, the new current point. 10^18 and 10^308 are written in + // digits: the tokenizer reads no exponents. + let (e18, e308) = (format!("1{}", "0".repeat(18)), format!("1{}", "0".repeat(308))); + // `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in + // document pixels. + let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin()); + let near = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05); + // Each program is listed with the remainders of its first and second angles after whole + // turns, and whether its sweep is more than 1,024 turns (a skip note is recorded, and the arc + // still goes around the whole circle). + for (body, first, second, whole) in [ + (format!("50 50 10 {e18} 0 arc"), 280.0, 0.0, false), + (format!("50 50 10 {e308} 0 arc"), 296.0, 0.0, false), + (format!("50 50 10 0 {e18} arcn"), 0.0, 280.0, false), + (format!("50 50 10 0 {e308} arcn"), 0.0, 296.0, false), + (format!("50 50 10 0 {e18} arc"), 0.0, 280.0, true), + (format!("50 50 10 0 {e308} arc"), 0.0, 296.0, true), + (format!("50 50 10 {e18} 0 arcn"), 280.0, 0.0, true), + (format!("50 50 10 {e308} 0 arcn"), 296.0, 0.0, true), + ] { + let doc = eps_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill")); + let skipped: &[&str] = if whole { &["arc (too many turns)"] } else { &[] }; + assert_eq!(doc.skipped, skipped, "{body}"); + let shapes = doc.flatten(); + assert_eq!(shapes.len(), 2, "{body}"); + let bounds = |i: usize| (shapes[i].0 * shapes[i].2.geom.to_path()).bounding_box(); + // An arc within one quarter of the circle is spanned by its endpoints. + let (a, b) = (at(first), at(second)); + let arc = if whole { Rect::new(40.0, 40.0, 60.0, 60.0) } else { Rect::from_points(a, b) }; + assert!(near(bounds(0), arc), "{body}: {:?}", bounds(0)); + // The second shape is the square drawn at the current point. + assert!(near(bounds(1), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(1)); + } + // Sweeps of up to 1,024 turns are drawn whole: five turns counterclockwise and three + // clockwise wind around the disk twice, and a nonzero fill covers it. + let doc = eps_soon("50 50 10 0 1800 arc 50 50 10 0 -1080 arcn fill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); + // A longer sweep keeps the parity of its turns: 5 × 10^18 degrees is an even number of whole + // turns and 320 degrees, so an even-odd fill covers the center of the disk. + let doc = eps_soon("50 50 10 0 5000000000000000000 arc eofill"); + assert_eq!(doc.skipped, ["arc (too many turns)"]); + let img = render(&doc); + assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).