EPS: arc and arcn with huge angles no longer hang the import

arc raised its second angle by 2π in a loop until it was no longer
below the first, and arcn lowered it the same way. For an angle of
1e18 degrees that loop takes about 3e15 steps: vectorcraft-cli info
was still reading an EPS file with "newpath 100 100 50 1e18 0 arc
stroke" when "timeout 20" stopped it. For 1e308 the loop never ends:
from 2^56 radians (about 4.1e18 degrees) up, adding 2π leaves a double
unchanged. The import fuzz test eps_hostile_programs_never_panic builds
programs from tokens that include arc, arcn, 1e308 and 1e15.

arc now raises the second angle by multiples of 360 until it is at
least the first, and arcn lowers it until it is at most the first, as
the PostScript Language Reference defines them. The code works out both
sweeps from the remainders of the two angles after whole turns, and it
places the first endpoint at the remainder of the first angle. Ordinary
angles give the same arcs as before, apart from rounding in the last
digits. One case changes: when raising (arc) or lowering (arcn) the
second angle by whole turns makes it equal to the first, as in
60 -300 arc or -60 300 arcn, rounding sometimes made the old loop add
another turn and draw a full circle. Those arcs are now empty, as the
PostScript Language Reference defines them.

Tests: 1e18 0 arc, 1e308 0 arc, 0 1e18 arcn and 0 1e308 arcn draw the
arc between the angles' remainders and leave the current point at the
second angle. The test reads each program on a thread of its own and
fails when no result arrives within 20 seconds.
This commit is contained in:
dotCooCoo 2026-10-10 12:39:14 -07:00 committed by bflatastic
parent b19731b307
commit 2d95ac1694
2 changed files with 54 additions and 11 deletions

View File

@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
}
/// 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)
}
impl Interp<'_> {
/// User space → document space.
pub fn xf(&self) -> Affine {
@ -436,18 +442,19 @@ impl Interp<'_> {
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
let tau = std::f64::consts::TAU;
if ccw {
while a2 < a1 {
a2 += tau;
}
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
// and the first endpoint, are worked out from what is left of each angle after whole turns.
let sweep = if ccw && a2 < a1 {
ccw_turn(a1, a2)
} else if !ccw && a2 > a1 {
-ccw_turn(a2, a1)
} else {
while a2 > a1 {
a2 -= tau;
}
}
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
a2 - a1
};
let tau = std::f64::consts::TAU;
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
let a1 = (a1 % 360.0).to_radians();
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);

View File

@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
}
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
/// more than 20 s.
fn read_soon(body: &str) -> crate::Imported {
let (tx, rx) = std::sync::mpsc::channel();
let ps = eps(body);
std::thread::spawn(move || tx.send(import(&ps)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
}
/// `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 (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
/// the second angle, the new current point.
#[test]
fn arcs_with_huge_angles_end_at_their_second_angle() {
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
// document space.
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
// Each program is listed with the remainders of its first and second angles after whole turns.
for (body, first, second) in [
("50 50 10 1e18 0 arc", 280.0, 0.0),
("50 50 10 1e308 0 arc", 296.0, 0.0),
("50 50 10 0 1e18 arcn", 0.0, 280.0),
("50 50 10 0 1e308 arcn", 0.0, 296.0),
] {
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
let o = objects(&r.document);
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
let (a, b) = (at(first), at(second));
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
// The second object is the square drawn at the current point.
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
}
}
#[test]
fn cshow_runs_its_procedure_for_each_character() {
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");