From 43d16ef315acb2b0ab710bef9a5e2d68cd828b95 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:16:00 +0700 Subject: [PATCH] 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 --- crates/measure/src/snap.rs | 45 +++++++++++++++++++++++++++++++++++-- crates/measure/src/tests.rs | 33 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 46455dcf..71ba78f4 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -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, start: Option) -> 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; } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 1348a3ec..20a730be 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -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";