fix(measure): include closing edges of every filled PDF subpath (#952)

* fix(measure): include closing edges of every filled subpath

* fix(measure): include closing edges of every filled subpath

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Nigel Ihza Firdausy 2026-10-12 00:16:00 +07:00 committed by GitHub
parent a4742ba911
commit 43d16ef315
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 76 additions and 2 deletions

View File

@ -74,6 +74,20 @@ fn nearest(p: Point, a: Point, b: Point) -> Point {
let t = (dot(sub(p, a), v) / length).clamp(0.0, 1.0);
[a[0] + v[0] * t, a[1] + v[1] * t]
}
/// Save a subpath's implicit closing edge for fill operations. An unpainted or
/// stroke-only open path must not gain that edge.
fn remember_close(closures: &mut Vec<[Point; 2]>, current: Option<Point>, start: Option<Point>) -> bool {
if let (Some(a), Some(b)) = (current, start)
&& distance(a, b) > 1e-9
{
if closures.len() >= MAX_SEGMENTS {
return false;
}
closures.push([a, b]);
}
true
}
impl Geometry {
/// Dense intersection searches are capped independently from path extraction.
pub fn intersection_limited(&self, at: Point, tolerance: f64) -> bool {
@ -233,6 +247,7 @@ impl Walker<'_> {
let mut stack = Vec::new();
let mut start = None;
let mut current = None;
let mut closures = Vec::new();
let mut path = Geometry::default();
let point = |m: Matrix, x: f64, y: f64| {
let (x, y) = m.apply(x, y);
@ -265,6 +280,10 @@ impl Walker<'_> {
}
"m" => {
if let Some([x, y]) = op.nums::<2>() {
if !remember_close(&mut closures, current, start) {
self.geometry.truncated = true;
break;
}
let p = point(matrix, x, y);
current = Some(p);
start = Some(p);
@ -309,6 +328,10 @@ impl Walker<'_> {
}
"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
if !remember_close(&mut closures, current, start) {
self.geometry.truncated = true;
break;
}
let a = point(matrix, x, y);
let b = point(matrix, x + w, y);
let c = point(matrix, x + w, y + h);
@ -321,8 +344,24 @@ impl Walker<'_> {
}
}
"S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
// Fills close open subpaths implicitly (ISO 32000-2 8.5.3.3); only a bare stroke leaves the path open.
if String::from_utf8_lossy(&op.op).as_ref() != "S"
let paint = String::from_utf8_lossy(&op.op);
if matches!(paint.as_ref(), "f" | "F" | "f*" | "B" | "B*" | "b" | "b*") {
if !remember_close(&mut closures, current, start) {
self.geometry.truncated = true;
break;
}
// Filling implicitly closes every open subpath, not just the last one.
// Stroke-only S leaves its open subpaths unchanged.
for [a, b] in closures.drain(..) {
if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS
|| self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS
{
path.truncated = true;
break;
}
path.edge(a, b);
}
} else if paint == "s"
&& let (Some(a), Some(b)) = (current, start)
{
path.edge(a, b);
@ -331,12 +370,14 @@ impl Walker<'_> {
self.geometry.endpoints.append(&mut path.endpoints);
self.geometry.midpoints.append(&mut path.midpoints);
self.geometry.truncated |= path.truncated;
closures.clear();
current = None;
start = None;
}
"n" => {
self.geometry.truncated |= path.truncated;
path = Geometry::default();
closures.clear();
current = None;
start = None;
}

View File

@ -150,6 +150,39 @@ fn snap_paths_endpoints_midpoints_and_intersections() {
assert!(paths.snap([200.0, 200.0], 3.0, opts).unwrap().is_none());
assert!(paths.snap([f64::NAN, 0.0], 3.0, opts).is_err());
}
#[test]
fn filled_open_subpath_offers_the_same_snap_edges_as_explicit_close() {
let open = fixture("40 50 m 160 50 l 100 170 l f", "", &[]);
let closed = fixture("40 50 m 160 50 l 100 170 l h f", "", &[]);
let stroke = fixture("40 50 m 160 50 l 100 170 l S", "", &[]);
let filled = snap::geometry(&open, 0).unwrap();
let explicit = snap::geometry(&closed, 0).unwrap();
let stroked = snap::geometry(&stroke, 0).unwrap();
assert_eq!(filled.segments, explicit.segments);
assert_eq!(filled.segments.len(), 3);
assert_eq!(stroked.segments.len(), 2);
let paths_only = snap::SnapOptions { endpoints: false, midpoints: false, intersections: false, paths: true };
let hit = filled.snap([55.0, 80.0], 5.0, paths_only).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Path);
assert_eq!(hit.point, [55.0, 80.0]);
assert!(stroked.snap([55.0, 80.0], 5.0, paths_only).unwrap().is_none());
}
#[test]
fn fills_close_all_open_subpaths_but_strokes_do_not() {
let triangles = "0 0 m 20 0 l 10 20 l 40 0 m 60 0 l 50 20 l";
for paint in ["f", "F", "f*", "B", "B*", "b", "b*"] {
let doc = fixture(&format!("{triangles} {paint}"), "", &[]);
let geometry = snap::geometry(&doc, 0).unwrap();
assert_eq!(geometry.segments.len(), 6, "{paint}");
assert!(geometry.segments.contains(&[[10.0, 20.0], [0.0, 0.0]]), "{paint}");
assert!(geometry.segments.contains(&[[50.0, 20.0], [40.0, 0.0]]), "{paint}");
}
let stroked = fixture(&format!("{triangles} S"), "", &[]);
assert_eq!(snap::geometry(&stroked, 0).unwrap().segments.len(), 4);
}
#[test]
fn nested_forms_transforms_curves_and_cycles_are_bounded() {
let body = "0 0 m 10 0 l S 0 0 m 0 10 10 10 10 0 c S /Nested Do";