Never crash: PDF close-path operators and CFF outlines without a current point

The s, b and b* operators closed the path even when there was no current point, which kurbo
asserts against (a panic in debug builds; found by fuzzing). Close only when a subpath is open.
CFF charstrings that draw before their first rmoveto now start the subpath at the current
point, as Type 1 charstrings already did. Denies the panic lints in the crate (it had no panic
sites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 12:15:02 -04:00
parent 10644a83aa
commit 8e4e29844e
5 changed files with 34 additions and 3 deletions

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -646,12 +646,21 @@ impl T2<'_> {
self.path.move_to((self.x, self.y));
self.open = true;
}
/// A segment before the first moveto starts at the current point.
fn ensure_open(&mut self) {
if !self.open {
self.path.move_to((self.x, self.y));
self.open = true;
}
}
fn line_to(&mut self, dx: f64, dy: f64) {
self.ensure_open();
self.x += dx;
self.y += dy;
self.path.line_to((self.x, self.y));
}
fn curve(&mut self, d: [f64; 6]) {
self.ensure_open();
let (x1, y1) = (self.x + d[0], self.y + d[1]);
let (x2, y2) = (x1 + d[2], y1 + d[3]);
self.x = x2 + d[4];

View File

@ -7,6 +7,8 @@
//! children are the document's **layers**: one group per top-level optional-content group
//! (Illustrator layers), with content outside them gathered into groups of its own.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod build;
mod ccitt;
mod cff;

View File

@ -332,7 +332,7 @@ impl<'a> Interp<'a> {
// Painting.
"S" => self.paint(false, None, true, depth),
"s" => {
self.path.close_path();
self.close_subpath();
self.paint(false, None, true, depth)
}
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth),
@ -340,11 +340,11 @@ impl<'a> Interp<'a> {
"B" => self.paint(true, Some(FillRule::NonZero), true, depth),
"B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth),
"b" => {
self.path.close_path();
self.close_subpath();
self.paint(true, Some(FillRule::NonZero), true, depth)
}
"b*" => {
self.path.close_path();
self.close_subpath();
self.paint(true, Some(FillRule::EvenOdd), true, depth)
}
"n" => self.paint(false, None, false, depth),
@ -549,6 +549,13 @@ impl<'a> Interp<'a> {
}
}
/// Close the current subpath; nothing to close without a current point.
fn close_subpath(&mut self) {
if self.cur.is_some() {
self.path.close_path();
}
}
fn ensure_start(&mut self) {
if self.cur.is_none() {
self.path.move_to((0.0, 0.0));

View File

@ -295,3 +295,12 @@ fn truncated_and_corrupt_eps_never_panic() {
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
}
}
#[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).
for ops in ["s", "b", "b*", "0 0 m 50 0 l 50 50 l b s b*", "f s 10 10 m 20 20 l s"] {
let doc = parse(&page_pdf(ops, "", vec![])).unwrap();
let _ = render(&doc);
}
}