From 767120f6cae6b3a6e87552143051135c9800d2dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 00:31:58 -0700 Subject: [PATCH] EPS: arc and arcn with huge angles no longer hang the import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arc added 2π to its sweep in a loop until the sweep was no longer negative, and arcn subtracted 2π the same way. For an angle of 10^18 degrees the loop took about 3e15 passes, and from 2^56 radians (about 4.1e18 degrees) up, adding 2π left the sweep unchanged and the loop never ended. A sweep that needed no loop, as in 0 10^18 arc, was passed unchanged to kurbo, which divides it into more than 1e16 curves (about four per turn at a radius of 10), and arc appended each curve to the path. Importing an EPS file probes it with effectcraft_pdf::parse, which has no time limit, so importing such a file did not finish. As in storytold/vectorcraft#1062, every arc now starts at the first angle's remainder after whole turns, and when the second angle is below the first (arc) or above it (arcn), the sweep is worked out from the two angles' remainders. A sweep of up to 1,024 turns is drawn whole. For a longer one, the import records a skip note ("arc (too many turns)") and leaves out whole pairs of turns, so that between 1,022 and 1,024 turns remain. The arc still ends at the second angle, and an even-odd fill covers the same area as the full sweep. Arcs of up to 1,024 turns whose angles are within about 10^6 degrees are unchanged apart from rounding, except some whose angles are a whole number of turns apart or within rounding of that. For larger angles, the old conversion to radians lost precision: at 10^15 degrees, a quarter-turn arc swept 1.5723 radians instead of 1.5708. When raising (arc) or lowering (arcn) the second angle by whole turns makes it equal to the first, as in 7 -353 arc or 2 362 arcn, rounding sometimes made the old loop add a turn and draw a full circle. Those arcs are now empty, as the PostScript Language Reference defines them. When the raised or lowered angle falls just short of the first instead, the reference defines a full circle, and each version leaves some of these arcs empty: the old code drew nothing for 125.00000000000001 125 arc, which the new code draws as a full circle, and the new code draws nothing for 1.0000000000000002 -719 arc, which the old code drew as a full circle. arcs_with_huge_angles_end_at_their_second_angle parses each program on a thread of its own and fails when no result arrives within 20 seconds. --- crates/pdf/src/ps.rs | 44 ++++++++++++++++++------ crates/pdf/src/tests_import.rs | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) 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).