From 5ef27ff6783ae3943d56d75577551947b916bb1e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:58:17 +0900 Subject: [PATCH] M5: ink strokes are drawn as smooth curves through their points (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #260 (page 7) compares PdfCraft with Acrobat, which draws an Ink annotation's /InkList as a smooth curve through the points; pdfcraft-annot drew straight segments between them (as MuPDF, Poppler and PDFium do). A stroke of three or more points is now drawn as a Catmull-Rom spline through every point, written as cubic Béziers: the curve leaves each point parallel to the chord between its neighbours, and an end uses itself as the missing neighbour. Two points stay a line and one point a dot. This is the appearance PdfCraft writes for its own ink, signatures drawn with Fill & Sign included, and, with the display-only appearances for comments that have none, how ink without /AP is shown. A curve can swing beyond its points, so a new ink annotation's /Rect now also covers the curve's control points (the curve stays within them); before, only the points plus the line-width margin. Test: annot tests::ink_strokes_are_smooth_curves_through_their_points checks the exact curve for a three-point stroke, a line for two points, a dot for one, and a /Rect that holds a stroke whose curve rises above its highest point (22 on main, 23.667 now). The comment.pencil parity entry cites it. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/annot/src/appearance.rs | 30 +++++++++++++++++++++++++----- crates/annot/src/lib.rs | 8 +++++++- crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index ecb8549b..6045743f 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -45,6 +45,27 @@ fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1))); } +/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve +/// through every point of `pts`, from the first: a Catmull-Rom spline, so the curve leaves each +/// point parallel to the chord between its neighbours (an end uses itself as the missing +/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve +/// stays within its points and these control points. +pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> { + let Some(&last) = pts.last() else { return Vec::new() }; + let at = |i: usize| pts.get(i).copied().unwrap_or(last); + (0..pts.len().saturating_sub(1)) + .map(|i| { + let (p0, p1, p2, p3) = (at(i.saturating_sub(1)), at(i), at(i + 1), at(i + 2)); + [(p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0), (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0), p2] + }) + .collect() +} + +/// `c` operators for [`smooth_segments`] (the path already starts at the first point). +fn smooth_curve(pts: &[(f64, f64)]) -> String { + smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() +} + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { @@ -396,11 +417,10 @@ pub fn build(d: &Dict) -> Option { let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); let Some(first) = pts.first() else { continue }; c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - if pts.len() == 1 { - c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))); - } - for p in &pts[1..] { - c.push_str(&format!("{} {} l\n", n(p.0), n(p.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), } c.push_str("S\n"); } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index d735603c..b570c0e7 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -766,7 +766,13 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } - grow(bounds(strokes.iter().flatten().copied()).unwrap_or_default(), half + 1.0) + // Strokes of three or more points are drawn as curves, which stay within their + // points and control points. + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) } Shape::Polygon { vertices, cloud } => { let b = bounds(vertices.iter().copied()) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index afcee4bb..55541988 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -141,6 +141,28 @@ fn every_tool_creates_a_drawable_comment() { assert_eq!(all[1].reference(b"Popup"), entries[2].as_ref()); } +/// #260 (page 7): Acrobat draws an Ink annotation as a smooth curve through its `/InkList` +/// points, where PdfCraft drew straight segments between them. Three or more points now make a +/// Catmull-Rom spline through every point (as cubic Béziers); two points stay a line, one a dot. +#[test] +fn ink_strokes_are_smooth_curves_through_their_points() { + let mut doc = fixture(); + let strokes = vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 0.0]], vec![[30.0, 0.0], [40.0, 10.0]], vec![[50.0, 50.0]]]; + let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("ink")).unwrap(); + let ap = ap_content(&doc, &list(&doc, 0)[i]); + // Tangents at each point run parallel to the chord between its neighbours (the ends use + // their own point): (0,0)→(10,10) leaves along (10,10) and arrives along (20,0). + assert!(ap.contains("0 0 m\n1.667 1.667 6.667 10 10 10 c\n13.333 10 18.333 1.667 20 0 c\nS\n"), "{ap}"); + assert!(ap.contains("30 0 m\n40 10 l\nS\n"), "{ap}"); + assert!(ap.contains("50 50 m\n50.01 50 l\nS\n"), "{ap}"); + // /Rect holds the whole curve: rising to (20, 20) and dropping to (21, 0), it swings above + // y 20 (its control point is at y 21.667), beyond the points' own bounds plus the margin. + let rise = Shape::Ink { strokes: vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 20.0], [21.0, 0.0]]] }; + let j = add_annotation(&mut doc, &new(0, rise), &meta("rise")).unwrap(); + let top = rect(&list(&doc, 0)[j])[3]; + assert!(top >= 21.667 + 2.0 - 0.01, "/Rect top {top}"); +} + #[test] fn indirect_annots_array_is_updated_in_place() { let mut doc = fixture(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1c9f0454..b765ac0c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3602,8 +3602,8 @@ milestone = "M5" status = "shipped" commands = ["comment.ink"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"] -notes = "one ink annotation per stroke" +evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points"] +notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat" [[feature]] id = "comment.eraser"