M5: ink strokes are drawn as smooth curves through their points (#374)

#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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
cyfung1031 2026-10-09 07:58:17 +09:00 committed by GitHub
parent 4a50222258
commit 5ef27ff678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 56 additions and 8 deletions

View File

@ -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<Stream> {
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");
}

View File

@ -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())

View File

@ -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();

View File

@ -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"