Merge #4094: M5: a callout's arrow point, knee and text box change on their own (#1152)

# Conflicts:
#	crates/annot/src/info.rs
#	crates/annot/src/modify.rs
#	crates/annot/src/tests.rs
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/ui-egui/tests/it/comments.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:19:26 +07:00
commit 88a2ab97ec
21 files changed
+1028 -103

No files matched your search

+103 -7
View File
@@ -204,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str {
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
match callout_side(rect, knee) {
Side::Left => [x0, cy],
Side::Right => [x1, cy],
Side::Top => [cx, y1],
Side::Bottom => [cx, y0],
}
}
/// A side of a callout's text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Top,
Bottom,
}
impl Side {
/// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat
/// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019,
/// p. 357).
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
}
/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to.
///
/// Every position has one side (review on #4094):
/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a
/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it;
/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the
/// left or right edge past the box is above or below it;
/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands
/// positions back as `f32` values: the side whose edge it lies on, the left or right edge
/// first, so a corner takes the left or right side as a knee beside the box does. The line
/// stays out of the box (it was sent across the box to the bottom side);
/// - inside the box, or with a coordinate that isn't a number: the bottom side.
pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side {
let [x0, y0, x1, y1] = normalize(rect);
let [x, y] = knee;
// Before any comparison: one with NaN is false, so the other coordinate alone would decide.
if x.is_nan() || y.is_nan() {
return Side::Bottom;
}
if x < x0 {
Side::Left
} else if x > x1 {
Side::Right
} else if y > y1 {
Side::Top
} else if y < y0 {
Side::Bottom
} else if x - x0 < CALLOUT_SLACK {
Side::Left
} else if x1 - x < CALLOUT_SLACK {
Side::Right
} else if y1 - y < CALLOUT_SLACK {
Side::Top
} else {
[cx, y0]
Side::Bottom
}
}
/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in
/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again
/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the
/// knee can't come nearer the box than that.
pub const CALLOUT_KNEE_LINE: f64 = 12.0;
/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle
/// of that side, so the knee line is level (left, right) or upright (top, bottom).
pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
match side {
Side::Left => [x0 - CALLOUT_KNEE_LINE, cy],
Side::Right => [x1 + CALLOUT_KNEE_LINE, cy],
Side::Top => [cx, y1 + CALLOUT_KNEE_LINE],
Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE],
}
}
/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point`
/// ([`callout_knee_on`]).
pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] {
callout_knee_on(rect, callout_side(rect, point))
}
/// How far apart, in points, two positions of a callout's box or line may be and still count as the
/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few
/// hundred-thousandths of a point (review on #4094).
pub(crate) const CALLOUT_SLACK: f64 = 0.01;
/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it.
pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool {
let [x0, y0, x1, y1] = normalize(rect);
let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK;
let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK;
match side {
Side::Left => near(end[0], x0) && within(end[1], y0, y1),
Side::Right => near(end[0], x1) && within(end[1], y0, y1),
Side::Top => near(end[1], y1) && within(end[0], x0, x1),
Side::Bottom => near(end[1], y0) && within(end[0], x0, x1),
}
}
+16 -4
View File
@@ -1,7 +1,7 @@
use super::create::finite;
use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked};
use super::text_style;
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
@@ -37,9 +37,12 @@ pub struct Summary {
pub fill_sign: bool,
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
pub no_rotate: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
/// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows
/// the text box), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
pub text_box: Option<[f32; 4]>,
}
impl Summary {
@@ -150,12 +153,20 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
})
.unwrap_or_default();
// Keep in step with `pdfcraft_render::inspect`.
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
// readable text box, without which they can't be changed either.
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
"FreeText" => callout
.as_ref()
.and_then(|(line, _)| line.split_last())
.map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
let text_box = callout.map(|(_, t)| t.map(|v| v as f32));
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
@@ -193,6 +204,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
fill_sign: is_fill_sign(doc, d),
no_rotate: drawn_upright(doc, d),
points,
text_box,
});
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ mod types;
#[cfg(test)]
use create::num_array;
pub use create::{add_annotation, callout_attach, orient_image_stamp};
pub use create::{CALLOUT_KNEE_LINE, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, orient_image_stamp};
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
pub use info::{Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
#[cfg(test)]
+109 -16
View File
@@ -1,6 +1,6 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect,
rect_for, rgb,
CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow,
ink_bounds, normalize, num_array, on_side, rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{is_group_subordinate, line_endings_of, text_value};
@@ -722,10 +722,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a
/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as
/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's
/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's
/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it
/// measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
@@ -736,6 +738,15 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() {
// A callout's points: its arrow point, then optionally its knee (a four-number `/CL`
// gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee.
if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) {
return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into()));
}
let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?;
return place_text_box(doc, r, text_box, Some(points), meta, false);
}
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
let style = Style { width: appearance::border_width(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
@@ -810,6 +821,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
place_text_box(doc, r, rect, None, meta, new_text)
}
/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six
/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`.
pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option<Vec<[f64; 2]>> {
// All or nothing: a non-number would shift every later number into the wrong point.
let l: Vec<f64> = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::<Option<_>>()?;
(matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec())
}
/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both.
pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> {
let nums = |key: &[u8]| -> Option<Vec<f64>> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() };
let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?;
let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?;
let r = normalize([r[0], r[1], r[2], r[3]]);
let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]];
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
}
/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by
/// their handles) replaces the leader line's own points. The arrow point never moves with the box
/// ("The text box moves around a stationary anchor point—the arrow on the end-point line",
/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021
/// Continuous release) was seen to:
/// - the box moved without changing size: the knee and the end move with it (also seen in
/// Acrobat Pro 2026.002.22031);
/// - the box resized with the side the line meets left where it was (the end still on it): the
/// line doesn't change;
/// - otherwise (that side moved): the line meets the middle of that side, the knee
/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to
/// the arrow point then; PdfCraft leaves the box where it was put.
///
/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the
/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side.
fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
@@ -818,17 +866,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
if !new_text {
refuse_foreign_text(doc, &d)?;
}
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
Some(mut l) => {
let attach = callout_attach(rect, [l[2], l[3]]);
(l[4], l[5]) = (attach[0], attach[1]);
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
// A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number
// `/CL` (no knee) stays four numbers unless `lead` adds a knee.
let old_line = callout_line(doc, &d);
let old_box = callout_text_box(doc, &d);
let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied();
// An arrow point alone moves the arrow: a knee the line has stays where it is.
let kept: [[f64; 2]; 2];
let lead = match (lead, old_line.as_deref()) {
(Some([point]), Some([_, knee, _])) => {
kept = [*point, *knee];
Some(&kept[..])
}
None => (rect, None),
_ => lead,
};
let line: Option<Vec<[f64; 2]>> = match (lead, old_line.as_deref()) {
(Some(lead @ [.., toward]), _) => {
let side = callout_side(rect, *toward);
let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward));
Some(lead.iter().copied().chain([end]).collect())
}
(Some(_), _) => None,
(None, Some(old @ [start, .., end])) => {
let (start, end) = (*start, *end);
let knee = old.get(1).filter(|_| old.len() == 3).copied();
let toward = knee.unwrap_or(start);
Some(match old_box {
// Moved, not resized: the knee and the end come along.
Some(b)
if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK =>
{
let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]);
let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy];
[start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect()
}
// Resized, the side the line meets still under its end: nothing changes.
Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(),
// That side moved: laid out again as a new callout is, from the side facing the
// arrow point (the box may now be on the knee's other side).
Some(_) => {
let new_knee = callout_knee(rect, start);
let end = callout_attach(rect, new_knee);
[start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect()
}
None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(),
})
}
(None, _) => None,
};
let (outer, cl) = match line.as_deref() {
Some(line @ [lead @ .., _]) => {
// The end is on the box, so only the arrow point and the knee need room around them.
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad);
([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat()))
}
_ => (rect, None),
};
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
let free_text = d.name(b"Subtype") == Some(b"FreeText");
+435 -3
View File
@@ -885,10 +885,10 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
let outer = rect(d);
let f = |k: &[u8]| -> Vec<f64> { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
let cl = f(b"CL");
// The leader line keeps its start and knee; the attach point follows the re-fitted box.
assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]);
// The box grows down from its top; the side the line meets stays where it was, so the line
// doesn't change (as Acrobat keeps it while a box is resized from its other corner).
let want = expected_box(612.0, 200.0, long, 12.0, 2.0);
assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into());
assert_eq!(cl, [200.0, 520.0, 260.0, 560.0, 300.0, 606.0]);
// The text box (recovered from /Rect and /RD) keeps its width and top edge.
let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]];
assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0));
@@ -4130,6 +4130,438 @@ fn text_boxes_draw_from_any_da_string() {
}
}
/// A callout with its text box at [300 600 500 640], knee [260 620] and arrow point [200 520]:
/// the knee line runs level from the box's left side, as Acrobat draws it.
fn callout_fixture() -> (Document, usize) {
let mut doc = fixture();
let c = add_annotation(
&mut doc,
&new(
0,
Shape::Callout {
rect: [300.0, 600.0, 500.0, 640.0],
knee: [260.0, 620.0],
point: [200.0, 520.0],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
),
&meta("c"),
)
.unwrap();
(doc, c)
}
fn nums(d: &Dict, k: &[u8]) -> Vec<f64> {
d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect()
}
/// The text box a callout's `/Rect` and `/RD` describe.
fn callout_box_of(d: &Dict) -> [f64; 4] {
let (r, rd) = (rect(d), nums(d, b"RD"));
[r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]]
}
/// #1152: a callout's arrow point and knee move on their own, as its handles drag them (Acrobat:
/// "Callout text boxes have three parts: a text box, a knee line, and an end-point line. You can
/// resize each part by dragging a handle", Acrobat DC Help, 11/28/2019, p. 357). The text box
/// stays and the line re-attaches to it.
#[test]
fn a_callouts_arrow_point_and_knee_move_on_their_own() {
let (mut doc, c) = callout_fixture();
// The arrow point alone: the knee and the box stay.
set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 620.0]], &meta("p")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[c];
assert_eq!(nums(d, b"CL"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]);
assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]);
// /Rect holds the line (padded) and the box.
let r = rect(d);
assert!(r[0] < 150.0 && r[1] < 400.0 && r[2] >= 500.0 && r[3] >= 640.0, "{r:?}");
assert!(ap_content(&doc, d).contains("150 400 m"), "the line starts at the new arrow point");
// The knee alone, to the right of the box: the line now meets the right side.
let mut doc = doc;
set_points(&mut doc, 0, c, &[[150.0, 400.0], [560.0, 620.0]], &meta("k")).unwrap();
let d = &list(&doc, 0)[c];
assert_eq!(nums(d, b"CL"), [150.0, 400.0, 560.0, 620.0, 500.0, 620.0]);
assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]);
assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[150.0, 400.0], [560.0, 620.0]]));
}
/// A callout's leader line as its box moves or is resized, as Acrobat Pro DC (a 2021 Continuous
/// release) was seen to keep it (#1152): the arrow point never moves ("The text box moves around
/// a stationary anchor point", Acrobat DC Help, 11/28/2019, p. 357); moved, the knee and the end
/// come along;
/// resized with the side the line meets left in place, nothing changes; that side moved, the line
/// is laid out again from the side facing the arrow point with a 12 pt knee line, and the box stays
/// where it was put (Acrobat moves it next to the arrow point).
#[test]
fn a_callouts_box_moves_around_its_arrow_and_keeps_the_knee_beside_it() {
let (mut doc, c) = callout_fixture();
let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL");
// Wider and taller, its left side where it was: the line stays.
set_rect(&mut doc, 0, c, [300.0, 580.0, 540.0, 640.0], &meta("r")).unwrap();
assert_eq!(cl(&doc), [200.0, 520.0, 260.0, 620.0, 300.0, 620.0]);
// Moved 40 right and 10 up, same size: the knee and the end come along.
set_rect(&mut doc, 0, c, [340.0, 590.0, 580.0, 650.0], &meta("m")).unwrap();
assert_eq!(cl(&doc), [200.0, 520.0, 300.0, 630.0, 340.0, 630.0]);
// The left side moved: the line meets its middle, the knee 12 pt out.
set_rect(&mut doc, 0, c, [380.0, 590.0, 580.0, 650.0], &meta("s")).unwrap();
assert_eq!(cl(&doc), [200.0, 520.0, 368.0, 620.0, 380.0, 620.0]);
assert_eq!(callout_box_of(&list(&doc, 0)[c]), [380.0, 590.0, 580.0, 650.0], "the box stays where it was put");
// Past the arrow point: the line leaves from the side facing it.
set_rect(&mut doc, 0, c, [100.0, 600.0, 180.0, 640.0], &meta("p")).unwrap();
assert_eq!(cl(&doc), [200.0, 520.0, 192.0, 620.0, 180.0, 620.0]);
}
/// The line is laid out again when the end no longer lies on the side it met (the box shrank past
/// it), and from the side facing the arrow point, not the old knee, which can end up over the box.
#[test]
fn a_callout_is_laid_out_again_when_its_end_leaves_the_box() {
let (mut doc, c) = callout_fixture();
// Left side unmoved, but the box now starts above the end (300, 620).
set_rect(&mut doc, 0, c, [300.0, 625.0, 500.0, 640.0], &meta("a")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 288.0, 632.5, 300.0, 632.5]);
let (mut doc, c) = callout_fixture();
// Over the old knee (260, 620), which would face the bottom; the arrow faces the left side.
set_rect(&mut doc, 0, c, [250.0, 590.0, 450.0, 640.0], &meta("b")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 238.0, 615.0, 250.0, 615.0]);
}
/// Review on #4094: the page sends a callout's text box back as `f32` values, so a box moved or
/// resized on screen comes back a few hundred-thousandths of a point off. That still counts as a
/// move (the knee comes along) and as the same side (the line stays), not as a new layout.
#[test]
fn a_callout_box_sent_back_as_f32_still_moves_and_keeps_its_line() {
let mut doc = fixture();
let c = add_annotation(
&mut doc,
&new(
0,
Shape::Callout {
rect: [323.05, 292.14, 506.98, 374.91],
knee: [311.05, 333.53],
point: [299.05, 274.83],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
),
&meta("c"),
)
.unwrap();
let f32s = |r: [f64; 4]| r.map(|v| f64::from(v as f32));
let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL");
let before = cl(&doc);
// Resized from the bottom right, its left side where it was.
set_rect(&mut doc, 0, c, f32s([323.05, 250.14, 540.98, 374.91]), &meta("r")).unwrap();
assert_eq!(cl(&doc), before, "the line stays");
// Moved by (47.62, 26.13), same size.
let tb = callout_box_of(&list(&doc, 0)[c]);
set_rect(&mut doc, 0, c, f32s([tb[0] + 47.62, tb[1] + 26.13, tb[2] + 47.62, tb[3] + 26.13]), &meta("m")).unwrap();
let after = cl(&doc);
assert!((after[2] - (before[2] + 47.62)).abs() < 0.01 && (after[3] - (before[3] + 26.13)).abs() < 0.01, "the knee came along: {after:?}");
assert_eq!((after[0], after[1]), (before[0], before[1]), "the arrow point stayed");
}
/// Review on #4094: a `/CL` with something other than a number in it is not read at all, rather
/// than read with its numbers shifted into the wrong points.
#[test]
fn a_callout_line_with_a_non_number_is_not_read() {
let (mut doc, c) = callout_fixture();
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| {
d.set(
b"CL".to_vec(),
Object::Array(vec![Object::name("x"), Object::Real(200.0), Object::Real(520.0), Object::Real(260.0), Object::Real(620.0)]),
)
})
.unwrap();
assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "not a callout line it can reshape");
assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).map(|s| (s.points.len(), s.text_box.is_some())), Some((0, false)));
}
/// A new callout's knee is Acrobat's: 12 pt out from the middle of the side facing the arrow.
#[test]
fn a_new_callouts_knee_line_is_twelve_points() {
let tb = [300.0, 600.0, 500.0, 640.0];
assert_eq!(callout_knee(tb, [200.0, 520.0]), [288.0, 620.0]);
assert_eq!(callout_knee(tb, [600.0, 700.0]), [512.0, 620.0]);
assert_eq!(callout_knee(tb, [400.0, 700.0]), [400.0, 652.0]);
assert_eq!(callout_knee(tb, [400.0, 500.0]), [400.0, 588.0]);
}
/// ISO 32000-2 Table 177: `/CL` may have four numbers (no knee). Resizing such a callout kept its
/// line only with six, so the box lost `/RD` and the line was left outside `/Rect`.
#[test]
fn a_callout_without_a_knee_keeps_its_line() {
let (mut doc, c) = callout_fixture();
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap();
set_rect(&mut doc, 0, c, [300.0, 580.0, 500.0, 640.0], &meta("r")).unwrap();
let d = &list(&doc, 0)[c];
assert_eq!(nums(d, b"CL"), [200.0, 520.0, 300.0, 620.0], "still four numbers, the left side unmoved");
assert_eq!(callout_box_of(d), [300.0, 580.0, 500.0, 640.0]);
assert!(rect(d)[1] < 520.0, "/Rect holds the line");
// Its one point is the arrow; a second adds a knee.
assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[200.0, 520.0]]));
set_points(&mut doc, 0, c, &[[210.0, 500.0]], &meta("p")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 300.0, 620.0]);
set_points(&mut doc, 0, c, &[[210.0, 500.0], [250.0, 610.0]], &meta("k")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 250.0, 610.0, 300.0, 620.0]);
// Back to no knee, then the left side moved: the end meets its middle.
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[210.0, 500.0, 300.0, 620.0]))).unwrap();
set_rect(&mut doc, 0, c, [280.0, 590.0, 500.0, 640.0], &meta("s")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 280.0, 615.0]);
}
#[test]
fn a_callout_refuses_points_it_cant_have() {
let (mut doc, c) = callout_fixture();
let before = list(&doc, 0)[c].clone();
for bad in [&[][..], &[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]][..], &[[f64::NAN, 1.0]][..], &[[1e9, 1.0]][..]] {
assert!(matches!(set_points(&mut doc, 0, c, bad, &meta("x")), Err(AnnotError::Invalid(_))), "{bad:?}");
}
assert_eq!(list(&doc, 0)[c], before, "left as it was");
// Without a readable /RD there is no box to keep, so nothing moves.
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| {
d.remove(b"RD");
})
.unwrap();
assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))));
// Nor with an /RD that puts the box outside /Rect (negative) or leaves no box (under 1 pt):
// set_points refuses, and summaries offer no text box to drag.
let outer = rect(&list(&doc, 0)[c]);
for rd in [[-5.0, 50.0, 0.0, 0.0], [0.0, 0.0, outer[2] - outer[0] - 0.5, 0.0]] {
doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&rd))).unwrap();
assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "{rd:?}");
assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).and_then(|s| s.text_box), None, "{rd:?}");
}
}
/// The side a knee faces: left or right of the box first, then above or below. A knee on the line
/// of the left or right edge past the box is above or below it; the ties are in
/// `a_callouts_knee_has_one_side_for_every_tie`.
#[test]
fn a_callouts_knee_faces_the_side_it_is_beyond() {
let tb = [100.0, 100.0, 200.0, 150.0];
assert_eq!(callout_side(tb, [99.0, 125.0]), Side::Left);
assert_eq!(callout_side(tb, [201.0, 125.0]), Side::Right);
assert_eq!(callout_side(tb, [100.0, 160.0]), Side::Top, "on the left edge's line");
assert_eq!(callout_side(tb, [200.0, 90.0]), Side::Bottom, "on the right edge's line");
assert_eq!(callout_side(tb, [150.0, 125.0]), Side::Bottom, "inside");
assert!(Side::Left.horizontal() && Side::Right.horizontal() && !Side::Top.horizontal() && !Side::Bottom.horizontal());
}
#[test]
fn summaries_give_a_callouts_points_and_text_box() {
let (doc, c) = callout_fixture();
let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap();
assert_eq!(s.points, vec![[200.0, 520.0], [260.0, 620.0]]);
assert_eq!(s.text_box, Some([300.0, 600.0, 500.0, 640.0]));
let square = summaries(&doc).into_iter().find(|s| s.subtype == "Square").unwrap();
assert_eq!((square.points.len(), square.text_box), (0, None));
}
/// A callout whose text box can't be read (no `/RD`, a negative inset, or no box left inside
/// `/Rect`) lists no points either, as `pdfcraft_render::inspect` does: `set_points` refuses it.
#[test]
fn summaries_give_no_callout_points_without_a_text_box() {
for rd in [None, Some([-1.0, 0.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0, 1000.0])] {
let (mut doc, c) = callout_fixture();
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| match rd {
Some(rd) => d.set(b"RD".to_vec(), num_array(&rd)),
None => {
d.remove(b"RD");
}
})
.unwrap();
let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap();
assert_eq!((s.points.len(), s.text_box), (0, None), "/RD {rd:?}");
}
}
/// Review on #4094: a callout given its arrow point alone keeps its knee. The new line was built
/// from the one point and the end, so a six-number `/CL` silently became four.
#[test]
fn a_callouts_arrow_point_alone_keeps_its_knee() {
let (mut doc, c) = callout_fixture();
let before = nums(&list(&doc, 0)[c], b"CL");
assert_eq!(before.len(), 6, "{before:?}");
set_points(&mut doc, 0, c, &[[190.0, 500.0]], &meta("p")).unwrap();
let after = nums(&list(&doc, 0)[c], b"CL");
assert_eq!(after.len(), 6, "the knee stays: {after:?}");
assert_eq!(after[..2], [190.0, 500.0], "the arrow point moved");
assert_eq!(after[2..], before[2..], "the knee and the end are where they were");
}
/// Review on #4094: every tie has one defined side. Beside the box (left or right of it) wins over
/// above or below it, so a knee off a corner diagonally, or on the line of the top or bottom edge
/// past the box, takes the left or right side. On the box's outline a knee takes the side whose
/// edge it lies on (within 0.01 pt), and a corner the left or right side, as beside the box.
/// Inside the box, or with a coordinate that isn't a number, it takes the bottom side.
#[test]
fn a_callouts_knee_has_one_side_for_every_tie() {
let tb = [100.0, 100.0, 200.0, 150.0];
let cases: [([f64; 2], Side, &str); 30] = [
([100.004, 125.0], Side::Left, "on the left edge, give or take an f32"),
([199.996, 125.0], Side::Right, "on the right edge, give or take an f32"),
([150.0, 149.996], Side::Top, "on the top edge, give or take an f32"),
([100.02, 125.0], Side::Bottom, "inside, past the slack"),
([150.0, 125.0], Side::Bottom, "inside, at the middle"),
([110.0, 145.0], Side::Bottom, "inside, near the top-left corner"),
([100.0, 125.0], Side::Left, "on the left edge"),
([200.0, 125.0], Side::Right, "on the right edge"),
([150.0, 150.0], Side::Top, "on the top edge"),
([150.0, 100.0], Side::Bottom, "on the bottom edge"),
([100.0, 150.0], Side::Left, "the top-left corner"),
([200.0, 150.0], Side::Right, "the top-right corner"),
([100.0, 100.0], Side::Left, "the bottom-left corner"),
([200.0, 100.0], Side::Right, "the bottom-right corner"),
([90.0, 150.0], Side::Left, "the top edge's line, left of the box"),
([210.0, 150.0], Side::Right, "the top edge's line, right of the box"),
([90.0, 100.0], Side::Left, "the bottom edge's line, left of the box"),
([100.0, 160.0], Side::Top, "the left edge's line, above the box"),
([100.0, 90.0], Side::Bottom, "the left edge's line, below the box"),
([200.0, 160.0], Side::Top, "the right edge's line, above the box"),
([90.0, 160.0], Side::Left, "diagonally off the top-left corner"),
([210.0, 90.0], Side::Right, "diagonally off the bottom-right corner"),
([f64::NAN, 125.0], Side::Bottom, "x not a number"),
([150.0, f64::NAN], Side::Bottom, "y not a number"),
([f64::NAN, 160.0], Side::Bottom, "x not a number, y above the box"),
([f64::NAN, 90.0], Side::Bottom, "x not a number, y below the box"),
([90.0, f64::NAN], Side::Bottom, "y not a number, x left of the box"),
([210.0, f64::NAN], Side::Bottom, "y not a number, x right of the box"),
([f64::NEG_INFINITY, 125.0], Side::Left, "infinitely far left"),
([150.0, f64::INFINITY], Side::Top, "infinitely far up"),
];
for (knee, side, what) in cases {
assert_eq!(callout_side(tb, knee), side, "{what}: {knee:?}");
// Written corners first, the box is the same box.
assert_eq!(callout_side([200.0, 150.0, 100.0, 100.0], knee), side, "{what}, corners first");
// The line meets the middle of that side.
let want = match side {
Side::Left => [100.0, 125.0],
Side::Right => [200.0, 125.0],
Side::Top => [150.0, 150.0],
Side::Bottom => [150.0, 100.0],
};
assert_eq!(callout_attach(tb, knee), want, "{what}");
}
}
/// A knee dragged exactly onto the box's left edge keeps the line on the left side, where its end
/// already is, instead of sending it across the box to the bottom; a knee dragged inside the box
/// meets the bottom side.
#[test]
fn a_callouts_knee_on_or_inside_the_box_meets_a_defined_side() {
let (mut doc, c) = callout_fixture();
set_points(&mut doc, 0, c, &[[200.0, 520.0], [300.0, 630.0]], &meta("edge")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 300.0, 630.0, 300.0, 620.0]);
set_points(&mut doc, 0, c, &[[200.0, 520.0], [350.0, 620.0]], &meta("inside")).unwrap();
assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 350.0, 620.0, 400.0, 600.0]);
assert_eq!(callout_box_of(&list(&doc, 0)[c]), [300.0, 600.0, 500.0, 640.0], "the box stays");
}
/// A callout's `/CL` read as numbers, and the check every edit must pass: four numbers (arrow
/// point, end) or six (arrow point, knee, end), all finite (ISO 32000-2 Table 177), every point
/// inside `/Rect`, and a text box (`/Rect` inset by `/RD`) at least 1 pt a side.
fn assert_callout_line(d: &Dict, len: usize, what: &str) -> Vec<f64> {
let cl = nums(d, b"CL");
assert_eq!(cl.len(), len, "{what}: {cl:?}");
assert!(cl.iter().all(|v| v.is_finite()), "{what}: {cl:?}");
let r = rect(d);
for p in cl.chunks(2) {
assert!(p[0] >= r[0] - 1e-6 && p[0] <= r[2] + 1e-6 && p[1] >= r[1] - 1e-6 && p[1] <= r[3] + 1e-6, "{what}: {p:?} outside {r:?}");
}
let tb = callout_box_of(d);
assert!(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0, "{what}: text box {tb:?}");
cl
}
/// Review on #4094: every edit of a callout keeps `/CL` four numbers when it had four and six when
/// it had six: the arrow point dragged, the knee dragged, the box moved, the box resized, the whole
/// callout moved, and the file saved and opened again.
#[test]
fn every_callout_edit_keeps_four_or_six_numbers() {
for len in [4usize, 6] {
let (mut doc, c) = callout_fixture();
if len == 4 {
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap();
}
let cl = |doc: &Document, what: &str| assert_callout_line(&list(doc, 0)[c], len, &format!("{len}: {what}"));
set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("tip")).unwrap();
assert_eq!(cl(&doc, "the arrow point")[..2], [150.0, 400.0]);
if len == 6 {
set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 630.0]], &meta("knee")).unwrap();
assert_eq!(cl(&doc, "the knee")[2..4], [260.0, 630.0]);
}
let tb = callout_box_of(&list(&doc, 0)[c]);
set_rect(&mut doc, 0, c, [tb[0] + 40.0, tb[1] + 10.0, tb[2] + 40.0, tb[3] + 10.0], &meta("box")).unwrap();
assert_eq!(cl(&doc, "the box moved")[..2], [150.0, 400.0], "the arrow point stays");
let tb = callout_box_of(&list(&doc, 0)[c]);
set_rect(&mut doc, 0, c, [tb[0] + 30.0, tb[1] - 20.0, tb[2] + 10.0, tb[3]], &meta("size")).unwrap();
let before = cl(&doc, "the box resized");
move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("move")).unwrap();
let after = cl(&doc, "the callout moved");
let shifted: Vec<f64> = before.iter().enumerate().map(|(i, v)| v + if i % 2 == 0 { 20.0 } else { -15.0 }).collect();
assert_eq!(after, shifted, "{len}: every point moves with it");
let doc = reopen(&doc);
cl(&doc, "saved and opened again");
}
}
/// Review on #4094: a `/CL` that is neither four nor six finite numbers (two, three, five or eight
/// numbers, an infinite or not-a-number value, an empty array, a nested array, a name or a string)
/// is not a callout line PdfCraft can reshape. No edit panics, the arrow point and knee are
/// refused, and none writes a `/CL` of another length: resizing leaves it as it was and moving
/// shifts its numbers in place. A `/CL` held as an indirect object is read as the line it is.
#[test]
fn a_hostile_callout_line_is_never_rewritten_into_another_shape() {
let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
let hostile: Vec<(&str, Object)> = vec![
("two numbers", reals(&[200.0, 520.0])),
("three numbers", reals(&[200.0, 520.0, 260.0])),
("five numbers", reals(&[200.0, 520.0, 260.0, 620.0, 300.0])),
("eight numbers", reals(&[200.0, 520.0, 260.0, 620.0, 280.0, 620.0, 300.0, 620.0])),
("not a number", reals(&[200.0, f64::NAN, 260.0, 620.0, 300.0, 620.0])),
("infinite", reals(&[f64::INFINITY, 520.0, 260.0, 620.0])),
("empty", Object::Array(Vec::new())),
("nested", Object::Array(vec![reals(&[200.0, 520.0]), reals(&[300.0, 620.0])])),
("a name", Object::name("CL")),
("a string", PdfString::text("200 520 300 620").into()),
];
for (what, cl) in hostile {
let (mut doc, c) = callout_fixture();
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| d.set(b"CL".to_vec(), cl.clone())).unwrap();
let len = cl.as_array().map(Vec::len);
for points in [&[[150.0, 400.0]][..], &[[150.0, 400.0], [260.0, 630.0]][..]] {
assert!(set_points(&mut doc, 0, c, points, &meta("p")).is_err(), "{what}: {points:?}");
}
// Compared as written: a not-a-number value is never equal to itself.
let same = |doc: &Document| format!("{:?}", list(doc, 0)[c].get(b"CL")) == format!("{:?}", Some(&cl));
assert!(same(&doc), "{what}: refused, so unchanged");
let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap();
assert_eq!((s.points.len(), s.text_box), (0, None), "{what}: nothing to drag");
let _ = set_rect(&mut doc, 0, c, [300.0, 580.0, 520.0, 640.0], &meta("r"));
assert!(same(&doc), "{what}: resizing leaves it as it was");
let _ = move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("m"));
assert_eq!(list(&doc, 0)[c].get(b"CL").and_then(Object::as_array).map(Vec::len), len, "{what}: moved in place");
reopen(&doc);
}
// Held as an indirect object, a six-number line is a callout line like any other.
let (mut doc, c) = callout_fixture();
let line = doc.add(reals(&[200.0, 520.0, 260.0, 620.0, 300.0, 620.0]));
let (_, r) = annot_ref(&mut doc, 0, c).unwrap();
doc.update_dict(r, |d| d.set(b"CL".to_vec(), Object::Ref(line))).unwrap();
set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("p")).unwrap();
assert_eq!(assert_callout_line(&list(&doc, 0)[c], 6, "indirect"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]);
}
#[test]
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
+8 -14
View File
@@ -226,8 +226,8 @@ impl Automation {
v["text_opacity"] = json!(t);
}
}
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's
// arrow point and knee, as comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
@@ -235,6 +235,10 @@ impl Automation {
v["points"] =
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
}
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
if let Some(tb) = c.text_box {
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
@@ -424,18 +428,8 @@ impl Automation {
let point = to_user(&info, t[0], t[1]);
let knee = match a.nums::<2>("knee")? {
Some(k) => to_user(&info, k[0], k[1]),
// Halfway between the point and the box, level with the box's middle.
None => {
let mid = (rect[1] + rect[3]) / 2.0;
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
[(point[0] + side) / 2.0, mid]
}
// Acrobat's: 12 pt out from the middle of the side facing the point.
None => pdfcraft_engine::callout_knee(rect, point),
};
Shape::Callout {
rect,
+3 -3
View File
@@ -880,7 +880,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark). Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).")
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
t(
@@ -1051,7 +1051,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -1065,7 +1065,7 @@ pub fn tools() -> Vec<ToolDef> {
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
"points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." },
})),
&["doc"],
)),
+46
View File
@@ -12426,6 +12426,52 @@ fn a_group_member_on_another_page_is_a_comment_of_its_own() {
assert!(left.to_string().contains("circ-other-page"), "deleting the primary leaves the Circle on page 2: {left}");
}
/// #1152 headless: comment_list gives a callout's arrow point, knee and text box; comment_edit's
/// `points` moves the arrow point and knee, and `rect` resizes the text box with the arrow left
/// where it is.
#[test]
fn a_callouts_parts_are_listed_and_changed_through_the_tools() {
let dir = workdir("callout-parts");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(
&mut a,
"comment_add",
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "knee": [80, 220], "contents": "Look" }),
);
let first = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc }))["comments"][0].clone();
let near = |v: &Value, want: &[f64]| v.as_array().unwrap().iter().zip(want).all(|(x, w)| (x.as_f64().unwrap() - w).abs() < 0.6);
let c = first(&mut a);
assert!(near(&c["points"][0], &[40.0, 160.0]) && near(&c["points"][1], &[80.0, 220.0]), "{c}");
assert!(near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}");
let (page, index) = (c["page"].clone(), c["index"].clone());
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "points": [[30, 150], [80, 220]] }));
let c = first(&mut a);
assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}");
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "rect": [110, 200, 230, 260] }));
let c = first(&mut a);
assert!(near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]) && near(&c["points"][0], &[30.0, 150.0]), "{c}");
// Three points, or none, aren't a callout's.
assert!(matches!(
a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "points": [[1, 1], [2, 2], [3, 3]] })),
Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))
));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "callout.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "callout.pdf" }))["doc"].as_u64().unwrap();
let c = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"][0].clone();
assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]), "after reopening: {c}");
// Without a knee, comment_add puts it where Acrobat does: 12 pt out from the middle of the side
// facing the point.
ok(
&mut a,
"comment_add",
json!({ "doc": saved, "page": 1, "type": "callout", "rect": [300, 200, 380, 240], "to": [250, 160], "contents": "Auto" }),
);
let list = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"].clone();
let auto = list.as_array().unwrap().iter().find(|c| c["contents"] == "Auto").unwrap();
assert!(near(&auto["points"][1], &[288.0, 220.0]), "{auto}");
}
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
+1
View File
@@ -24,6 +24,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render:
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
text_box: s.text_box,
})
.collect();
pdfcraft_render::link_replies(&mut list);
+3 -2
View File
@@ -120,8 +120,9 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom};
pub use pdfcraft_annot::{
Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont,
OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
Activation3d, AppearanceCache, AttachIcon, BorderShape, CALLOUT_KNEE_LINE, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon,
OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, Side as CalloutSide, StampGroup, StampKind, Style, callout_attach,
callout_knee, callout_side, rect_quad,
};
pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
pub use pdfcraft_forms::detect;
+27
View File
@@ -203,6 +203,12 @@ impl<'a> Inspector<'a> {
}
_ => Vec::new(),
};
// A callout: its arrow point and knee, and its text box (keep in step with
// `pdfcraft_annot::summaries`).
let (points, text_box) = match self.callout(d, rect) {
Some((lead, text_box)) if subtype == "FreeText" => (lead, Some(text_box)),
_ => (points, None),
};
let annotation = Annotation {
page,
subtype,
@@ -225,6 +231,7 @@ impl<'a> Inspector<'a> {
fill_sign,
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
points,
text_box,
};
kept = kept.saturating_add(retained(&annotation));
if kept > MAX_ANNOTATION_BYTES {
@@ -238,6 +245,26 @@ impl<'a> Inspector<'a> {
fill_group_text(&mut info.annotations);
}
/// A callout's arrow point and knee (`/CL`, four or six numbers, without the end that meets
/// the text box) and its text box (`/Rect`, here `rect`, inset by `/RD`); ISO 32000-2
/// Table 177. `None` when either is missing or unusable.
fn callout(&self, d: &Dictionary, rect: [f32; 4]) -> Option<(Vec<[f32; 2]>, [f32; 4])> {
let nums = |key: &[u8]| -> Option<Vec<f32>> {
match self.resolve(d.get(key).ok()?) {
Object::Array(v) => v.iter().map(|o| self.resolve(o).as_float().ok()).collect(),
_ => None,
}
};
let cl = nums(b"CL").filter(|l| matches!(l.len(), 4 | 6) && l.iter().all(|x| x.is_finite()))?;
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite() && *x >= 0.0))?;
let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]];
if tb[2] - tb[0] < 1.0 || tb[3] - tb[1] < 1.0 {
return None;
}
let (_, lead) = cl.as_chunks::<2>().0.split_last()?;
Some((lead.to_vec(), tb))
}
/// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest`
/// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no
/// rectangle, or a destination or action that leads nowhere.
+5 -2
View File
@@ -344,9 +344,12 @@ pub struct Annotation {
/// appearance stays upright, turned about the upper-left corner of `rect`
/// ([`Self::shown_rect`]).
pub no_rotate: bool,
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
/// The points that shape a line (its two ends, `/L`), connected lines, a polygon or a cloud
/// (their corners, `/Vertices`), or a callout (its arrow point and knee: `/CL` without its
/// end, which follows the text box), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
pub text_box: Option<[f32; 4]>,
}
/// How far [`group_primaries`] follows `/IRT` from a group subordinate to its primary.
+1
View File
@@ -304,6 +304,7 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() {
fill_sign: false,
no_rotate: true,
points: Vec::new(),
text_box: None,
};
let rect = note.rect.map(f64::from);
for rotation in [0u16, 90, 180, 270, 45] {
@@ -137,14 +137,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
// A callout's leader and box stay in user-space axes (its appearance isn't turned).
let font_size = prefs.font_size(CommentTool::Callout).unwrap_or(10.0);
let rect = text_box_rect(c.at, &text, font_size, 0);
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
let knee = pdfcraft_engine::callout_knee(rect, point);
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size, ending: LineEnding::OpenArrow }, text))
}
ComposerKind::Replace => {
+57 -6
View File
@@ -12,21 +12,72 @@ pub(super) fn on_text(a: &Annotation) -> bool {
is_markup(&a.subtype) || a.subtype == "Caret" && a.intent.as_deref() == Some("Replace")
}
/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's
/// `/Rect` also holds its leader line, so it only moves.
/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout
/// resizes its text box ([`frame`]); one whose box can't be read (no `/RD`) only moves, since its
/// `/Rect` also holds its leader line.
pub(super) fn resizable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout")
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink")
&& (a.intent.as_deref() != Some("FreeTextCallout") || a.text_box.is_some())
}
/// Lines, connected lines, polygons and clouds: their ends and corners have handles of their own
/// (measurements keep the points they measured, and a locked comment its shape).
/// Lines, connected lines, polygons, clouds and callouts: their ends, corners, or a callout's
/// arrow point and knee have handles of their own (measurements keep the points they measured,
/// and a locked comment its shape).
pub(super) fn reshapable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon")
(matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon") || is_callout(a))
&& !a.points.is_empty()
&& !a.locked
&& !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension"))
}
/// A callout whose text box and leader line PdfCraft can read.
pub(super) fn is_callout(a: &Annotation) -> bool {
a.subtype == "FreeText" && a.text_box.is_some()
}
/// The rectangle a comment's selection frame and resize handles go around, in user space: a
/// callout's text box, otherwise where the comment is drawn (`/Rect`, or a sticky note kept
/// upright on a page turned `rotation` degrees).
pub(super) fn frame(a: &Annotation, rotation: u16) -> [f32; 4] {
a.text_box.unwrap_or_else(|| a.shown_rect(rotation))
}
/// A callout's points while one is dragged to `to`. Its knee moves along its knee line only
/// (Acrobat: "The knee line can be resized in one direction only; horizontal knee lines can be
/// resized horizontally only; vertical knee lines can be resized vertically only", Acrobat DC Help,
/// 11/28/2019, p. 357), and no nearer the box than [`pdfcraft_engine::CALLOUT_KNEE_LINE`]
/// (Acrobat Pro DC, a 2021 Continuous release, lays a knee dragged into the box out again at that
/// distance). Its arrow point moves freely, with Shift held to 45° from the knee.
pub(super) fn callout_reshaped(points: &[[f64; 2]], text_box: [f32; 4], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
let tb = text_box.map(f64::from);
let gap = pdfcraft_engine::CALLOUT_KNEE_LINE;
match (i, points.get(1)) {
(1, Some(knee)) => {
let to = match pdfcraft_engine::callout_side(tb, *knee) {
pdfcraft_engine::CalloutSide::Left => [to[0].min(tb[0] - gap), knee[1]],
pdfcraft_engine::CalloutSide::Right => [to[0].max(tb[2] + gap), knee[1]],
pdfcraft_engine::CalloutSide::Top => [knee[0], to[1].max(tb[3] + gap)],
pdfcraft_engine::CalloutSide::Bottom => [knee[0], to[1].min(tb[1] - gap)],
};
let mut out = points.to_vec();
if let Some(k) = out.get_mut(1) {
*k = to;
}
out
}
_ => reshaped(points, i, to, shift),
}
}
/// A selected comment's points moved as handle `i` is dragged to `to` ([`reshaped`], or
/// [`callout_reshaped`] for a callout).
pub(super) fn reshape_for(a: &Annotation, points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
match a.text_box.filter(|_| is_callout(a)) {
Some(tb) => callout_reshaped(points, tb, i, to, shift),
None => reshaped(points, i, to, shift),
}
}
/// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools.
/// The drawing tools draw from where they are pressed, so a new line starts at the end of the
/// one just drawn rather than dragging it.
+29 -9
View File
@@ -1,8 +1,8 @@
use super::geometry::{
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, on_text, point_handle, reaches_points, reshapable, reshaped,
resizable, resized, snapped_caret,
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, frame, handle_pos, held, is_callout, is_markup, on_text, point_handle, reaches_points,
reshapable, reshape_for, resizable, resized, snapped_caret,
};
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment};
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, moves_box_only, new_comment};
use super::state::{Composer, ComposerKind, Gesture, PageCx};
use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use crate::page_cursor::{Layer, Subject};
@@ -118,10 +118,10 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op
&& reshapable(a)
&& let Some(point) = point_handle(cx, a, p)
{
return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Point(point) }, Layer::Handle, egui::CursorIcon::Crosshair));
return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Point(point) }, Layer::Handle, point_cursor(Some(a), point)));
}
if resizable(a)
&& let Some((x, y)) = HANDLES.into_iter().find(|h| handle_pos(cx.screen_rect(a), *h).distance(p) <= 7.0)
&& let Some((x, y)) = HANDLES.into_iter().find(|h| handle_pos(cx.frame_rect(a), *h).distance(p) <= 7.0)
{
let icon = match (x, y) {
(0, _) => egui::CursorIcon::ResizeVertical,
@@ -262,7 +262,7 @@ fn select_input(
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
// A drawn signature keeps its shape from the corners, as image signatures do.
let aspect_ratio = aspect_ratio.or_else(|| {
let r = cx.screen_rect(a);
let r = cx.frame_rect(a);
(a.fill_sign && a.subtype == "Ink" && r.height() > 0.0).then(|| r.width() / r.height())
});
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: (x, y), from: o, aspect_ratio });
@@ -360,6 +360,12 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p:
let (a, b) = (cx.to_user(from), cx.to_user(p));
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
if from.distance(p) >= 2.0 {
// A callout dragged by its text box moves the box alone: its arrow point stays.
if let Some(a) = cx.get(index).filter(|a| moves_box_only(cx, a, from)) {
let f = frame(a, cx.rotation()).map(f64::from);
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect: [f[0] + dx, f[1] + dy, f[2] + dx, f[3] + dy] });
return;
}
// The other selected comments on this page (not text markup or a Replace Text
// caret, which are where their text is, nor a locked one, which would refuse the
// whole step) move with the dragged one, as one undo step.
@@ -381,7 +387,7 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p:
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
view.comments.gesture = None;
if let Some(a) = cx.get(index) {
let from_rect = cx.screen_rect(a);
let from_rect = cx.frame_rect(a);
let shift = ui.input(|i| i.modifiers.shift);
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
@@ -394,8 +400,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p:
view.comments.gesture = None;
let shift = ui.input(|i| i.modifiers.shift);
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
if moved {
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
if moved && let Some(a) = cx.get(index) {
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshape_for(a, &points, point, cx.to_user(p), shift) });
}
}
_ => {}
@@ -823,3 +829,17 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo
view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
}
}
/// The pointer over point handle `i` of `selected`: a callout's knee moves along its knee line only
/// (see `callout_reshaped`), so it shows that direction; other points move freely.
fn point_cursor(selected: Option<&Annotation>, i: usize) -> egui::CursorIcon {
let knee_side = selected.filter(|a| i == 1 && is_callout(a)).and_then(|a| {
let (tb, knee) = (a.text_box?, a.points.get(1)?);
Some(pdfcraft_engine::callout_side(tb.map(f64::from), [f64::from(knee[0]), f64::from(knee[1])]))
});
match knee_side {
Some(side) if side.horizontal() => egui::CursorIcon::ResizeHorizontal,
Some(_) => egui::CursorIcon::ResizeVertical,
None => egui::CursorIcon::Crosshair,
}
}
+59 -14
View File
@@ -1,4 +1,4 @@
use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable};
use super::geometry::{HANDLES, handle_pos, held, is_callout, is_markup, reaches_points, reshapable, reshape_for, resizable};
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
use super::{
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
@@ -65,7 +65,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
return paint_gesture(painter, cx, view);
}
let shift = ui.input(|i| i.modifiers.shift);
if cx.allowed && reaches_points(cx.tool) && reshapable(a) {
let points = cx.allowed && reaches_points(cx.tool) && reshapable(a);
if points && !is_callout(a) {
return paint_points(painter, cx, view, a, pointer, shift);
}
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0);
@@ -76,15 +77,29 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
}
}
// A callout's arrow point and knee, over its text box's frame.
if points {
return paint_points(painter, cx, view, a, pointer, shift);
}
}
paint_gesture(painter, cx, view);
}
/// Whether dragging `a` from `from` moves a callout's text box alone: a press inside the box. Its
/// arrow point stays and its knee comes along, as seen in Acrobat Pro DC (a 2021 Continuous
/// release) and Acrobat Pro 2026.002.22031 (#1152), and "The text box moves around a stationary
/// anchor point" (Acrobat DC Help, 11/28/2019, p. 357). A press on the leader line moves the whole
/// callout (seen in the 2021 release).
pub(super) fn moves_box_only(cx: &PageCx<'_>, a: &Annotation, from: Pos2) -> bool {
is_callout(a) && resizable(a) && cx.frame_rect(a).contains(from)
}
/// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while
/// one is dragged (or its edit is on its way), the new shape over the old.
/// The points a selected line, polygon or cloud is drawn with while it changes: an end or corner
/// being dragged, the whole comment being moved by its body, or the edit just released (until the
/// page is drawn again). `None` while it isn't changing.
/// The points a selected line, polygon, cloud or callout is drawn with while it changes: an end,
/// corner, arrow point or knee being dragged (moved by `reshape`: [`reshaped`], or a callout's
/// [`callout_reshaped`]), the whole comment being moved by its body, or the edit just released
/// (until the page is drawn again). `None` while it isn't changing.
pub(super) fn changing_points(
gesture: Option<&Gesture>,
pending: Option<&Edit>,
@@ -92,12 +107,12 @@ pub(super) fn changing_points(
points: &[[f64; 2]],
to_user: impl Fn(Pos2) -> [f64; 2],
pointer: Option<Pos2>,
shift: bool,
reshape: impl Fn(&[[f64; 2]], usize, [f64; 2]) -> Vec<[f64; 2]>,
) -> Option<Vec<[f64; 2]>> {
let shifted = |dx: f64, dy: f64| points.iter().map(|q| [q[0] + dx, q[1] + dy]).collect();
match (gesture, pending) {
(Some(Gesture::Reshape { page: p, index: i, point, points: from }), _) if (*p, *i) == (page, index) => {
pointer.map(|at| reshaped(from, *point, to_user(at), shift))
pointer.map(|at| reshape(from, *point, to_user(at)))
}
(Some(Gesture::Move { page: p, index: i, from }), _) if (*p, *i) == (page, index) => pointer.map(|at| {
let (a, b) = (to_user(*from), to_user(at));
@@ -110,6 +125,10 @@ pub(super) fn changing_points(
Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
_ => None,
}),
// A callout's text box resized (or moved alone): its points stay, and its line is drawn to
// the box where it is going (see `paint_points`).
(Some(Gesture::Resize { page: p, index: i, .. }), _) if (*p, *i) == (page, index) => pointer.map(|_| points.to_vec()),
(_, Some(Edit::ResizeAnnotation { page: p, index: i, .. })) if (*p, *i) == (page, index) => Some(points.to_vec()),
_ => None,
}
}
@@ -117,17 +136,43 @@ pub(super) fn changing_points(
fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option<Pos2>, shift: bool) {
let cv = &view.comments;
let points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect();
let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, shift);
let reshape = |from: &[[f64; 2]], i: usize, to: [f64; 2]| reshape_for(a, from, i, to, shift);
let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, reshape);
let changed = changing.is_some();
let points = changing.unwrap_or(points);
let mut points = changing.unwrap_or(points);
// A callout's box moved alone leaves its arrow point where it is.
if let (Some(Gesture::Move { page, index, from }), Some(first)) = (cv.gesture.as_ref(), a.points.first())
&& (*page, *index) == (cx.page, a.index)
&& moves_box_only(cx, a, *from)
&& let Some(p) = points.first_mut()
{
*p = [f64::from(first[0]), f64::from(first[1])];
}
// A callout's box moved alone and released: its knee came along, as while it was dragged.
if let (Some(Edit::ResizeAnnotation { page, index, rect }), Some(tb)) = (view.pending_edit.as_ref(), a.text_box.filter(|_| is_callout(a)))
&& (*page, *index) == (cx.page, a.index)
{
let tb = tb.map(f64::from);
let same = |a: f64, b: f64| (a - b).abs() < 0.01;
if same(rect[2] - rect[0], tb[2] - tb[0]) && same(rect[3] - rect[1], tb[3] - tb[1]) {
let (dx, dy) = (rect[0] - tb[0], rect[1] - tb[1]);
for q in points.iter_mut().skip(1) {
*q = [q[0] + dx, q[1] + dy];
}
}
}
let screen: Vec<Pos2> = points.iter().map(|q| cx.to_screen(*q)).collect();
if changed {
let stroke = Stroke::new(1.5, SELECT_BLUE);
painter.add(if a.subtype == "Polygon" {
egui::Shape::closed_line(screen.clone(), stroke)
} else {
egui::Shape::line(screen.clone(), stroke)
});
// A callout's line ends where it meets the (possibly moving) text box.
let mut line = screen.clone();
if is_callout(a)
&& let Some(toward) = points.last()
{
let tb = cx.rect_to_user(cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()));
line.push(cx.to_screen(pdfcraft_engine::callout_attach(tb, *toward)));
}
painter.add(if a.subtype == "Polygon" { egui::Shape::closed_line(line, stroke) } else { egui::Shape::line(line, stroke) });
}
for c in screen {
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
+9 -4
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use super::geometry::{corner_ratio, is_markup, resized};
use super::geometry::{corner_ratio, frame, is_markup, resized};
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect};
/// The user's login name, which Acrobat uses as the default comment author.
@@ -446,6 +446,11 @@ impl PageCx<'_> {
self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation()))
}
/// Where a comment's selection frame and resize handles go on screen ([`frame`]).
pub(super) fn frame_rect(&self, a: &Annotation) -> Rect {
self.xf.user_rect(self.info, self.page, frame(a, self.rotation()))
}
/// Top-level comments on this page, in paint order.
pub(super) fn comments(&self) -> impl Iterator<Item = &Annotation> {
self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.is_comment() && a.subtype != "Popup")
@@ -558,7 +563,7 @@ impl PageCx<'_> {
shift: bool,
pending: Option<&Edit>,
) -> Rect {
let r = self.screen_rect(a);
let r = self.frame_rect(a);
if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending
&& (*page, *index) == (self.page, a.index)
{
@@ -567,8 +572,8 @@ impl PageCx<'_> {
if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending
&& (*page, *index) == (self.page, a.index)
{
let r = a.shown_rect(self.rotation());
return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]);
let f = frame(a, self.rotation());
return self.xf.user_rect(self.info, self.page, [f[0] + *dx as f32, f[1] + *dy as f32, f[2] + *dx as f32, f[3] + *dy as f32]);
}
match (gesture, pointer) {
(Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from),
+31 -7
View File
@@ -1,5 +1,11 @@
use super::geometry::reshaped;
use super::*;
/// [`reshaped`] without Shift, as `changing_points` takes it.
fn straight(points: &[[f64; 2]], i: usize, to: [f64; 2]) -> Vec<[f64; 2]> {
reshaped(points, i, to, false)
}
#[test]
fn freehand_highlight_and_draw_accept_widths_up_to_100() {
assert_eq!(CommentTool::FreehandHighlight.group(), CommentTool::Highlight.group());
@@ -25,21 +31,35 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() {
let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
let moving = Gesture::Move { page: 0, index: 3, from: pos2(20.0, 10.0) };
let at = Some(pos2(25.0, 30.0));
assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, false), None, "another comment's move");
assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, straight), None, "another comment's move");
let released = Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 };
assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
let dragging = Gesture::Reshape { page: 0, index: 3, point: 1, points: line.to_vec() };
let to = Some(pos2(60.0, 10.0));
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing");
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, straight), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, straight), None, "not changing");
// Released with the rest of the selection: its own move in the batch.
let together = Edit::Batch {
label: "Move comments".into(),
edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }],
};
assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch");
assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, straight), None, "not in the batch");
}
/// Review on #4094: a callout's text box being resized, and the resize just released, draw its
/// leader line too (to the box where it is going): its points are changing, though they stay.
#[test]
fn a_callout_being_resized_draws_its_leader_line() {
let lead = [[10.0, 10.0], [40.0, 30.0]];
let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
let resizing = Gesture::Resize { page: 0, index: 3, handle: (1, 1), from: pos2(80.0, 60.0), aspect_ratio: None };
let at = Some(pos2(95.0, 70.0));
assert_eq!(changing_points(Some(&resizing), None, (0, 3), &lead, to_user, at, straight), Some(lead.to_vec()));
assert_eq!(changing_points(Some(&resizing), None, (0, 4), &lead, to_user, at, straight), None, "another comment's resize");
let released = Edit::ResizeAnnotation { page: 0, index: 3, rect: [50.0, 20.0, 110.0, 70.0] };
assert_eq!(changing_points(None, Some(&released), (0, 3), &lead, to_user, None, straight), Some(lead.to_vec()));
}
/// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit
@@ -66,6 +86,7 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() {
fill_sign: false,
no_rotate,
points: Vec::new(),
text_box: None,
};
let page = PageInfo { width: 140.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation: 90 };
let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)), rot: 0, pw: 140.0, ph: 200.0 };
@@ -111,6 +132,7 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz
fill_sign: false,
no_rotate: false,
points: Vec::new(),
text_box: None,
}
}
@@ -200,6 +222,7 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat
fill_sign,
no_rotate: false,
points: Vec::new(),
text_box: None,
}
}
@@ -404,6 +427,7 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men
intent: None,
fill_sign: false,
points: Vec::new(),
text_box: None,
no_rotate: false,
}],
..Default::default()
+81
View File
@@ -2240,6 +2240,9 @@ fn polygons_clouds_connected_lines_callouts_and_inserted_text() {
let callout = comments(&h).into_iter().find(|a| a.subtype == "FreeText").expect("a callout");
assert_eq!(callout.contents.as_deref(), Some("Why?"));
assert!(callout.rect[0] <= 60.0 && callout.rect[1] <= 160.0, "holds the leader: {:?}", callout.rect);
// Its knee line is Acrobat's: level, 12 pt out from the middle of the box's left side.
let (tb, knee) = (callout.text_box.expect("a text box"), callout.points[1]);
assert!((knee[0] - (tb[0] - 12.0)).abs() < 0.01 && (knee[1] - (tb[1] + tb[3]) / 2.0).abs() < 0.01, "{tb:?} {knee:?}");
// Insert text at a click.
h.state_mut().set_option("quick", "caret").unwrap();
@@ -3664,6 +3667,84 @@ fn choosing_the_groups_card_makes_delete_act_on_the_card_not_the_member_clicked(
assert_eq!(shapes(&h), ["Circle".to_string()], "the card's comment, the Square, goes; the Circle stays");
}
/// One 300×200 page with a callout: text box [150 100 250 140], knee (120, 120) on the box's
/// left, arrow point (60, 60).
const CALLOUT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [50 50 250 140] /RD [100 50 0 0]
/CL [60 60 120 120 150 120] /LE /OpenArrow /DA (/Helv 10 Tf 0 g) /Contents (Note) /F 4 >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn callout(h: &Harness<'static, PdfCraftApp>) -> Annotation {
comments(h).into_iter().find(|a| a.subtype == "FreeText").expect("the callout")
}
fn near_pt(p: [f32; 2], x: f32, y: f32) -> bool {
(p[0] - x).abs() < 1.5 && (p[1] - y).abs() < 1.5
}
fn last_edit(h: &Harness<'static, PdfCraftApp>) -> Option<String> {
h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned)
}
/// #1152: a selected callout's three parts change on their own, as in Acrobat ("Callout text
/// boxes have three parts: a text box, a knee line, and an end-point line. You can resize each
/// part by dragging a handle. The knee line can be resized in one direction only", Acrobat DC
/// Help, 11/28/2019, p. 357).
#[test]
fn a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own() {
let mut h = harness_with_bytes(CALLOUT_FIXTURE.to_vec(), |app| app.set_option("quick", "select").unwrap());
let c = callout(&h);
assert_eq!((c.points.clone(), c.text_box), (vec![[60.0, 60.0], [120.0, 120.0]], Some([150.0, 100.0, 250.0, 140.0])));
click_pt(&mut h, (200.0, 120.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, c.index)));
// The knee line is level, so the knee moves sideways only, and not into the box.
drag_pt(&mut h, (120.0, 120.0), (95.0, 160.0));
let c = callout(&h);
assert!(near_pt(c.points[1], 95.0, 120.0) && near_pt(c.points[0], 60.0, 60.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Reshape comment"));
drag_pt(&mut h, (95.0, 120.0), (200.0, 120.0));
assert!(near_pt(callout(&h).points[1], 138.0, 120.0), "stopped 12 pt short of the box: {:?}", callout(&h).points);
drag_pt(&mut h, (138.0, 120.0), (110.0, 120.0));
// The arrow point moves alone.
drag_pt(&mut h, (60.0, 60.0), (40.0, 30.0));
let c = callout(&h);
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points);
assert_eq!(c.text_box, Some([150.0, 100.0, 250.0, 140.0]));
// The text box resizes from its own handles (not /Rect's); its left side, where the line meets
// it, stays, so the line doesn't change.
drag_pt(&mut h, (250.0, 100.0), (270.0, 80.0));
let c = callout(&h);
let tb = c.text_box.expect("a text box");
assert!((tb[0] - 150.0).abs() < 1.5 && (tb[1] - 80.0).abs() < 1.5 && (tb[2] - 270.0).abs() < 1.5 && (tb[3] - 140.0).abs() < 1.5, "{tb:?}");
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Resize comment"));
// Dragged from inside, the box moves alone: the arrow stays, the knee comes along.
drag_pt(&mut h, (200.0, 120.0), (220.0, 130.0));
let c = callout(&h);
let tb = c.text_box.expect("a text box");
assert!((tb[0] - 170.0).abs() < 1.5 && (tb[3] - 150.0).abs() < 1.5, "{tb:?}");
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 130.0, 130.0), "{:?}", c.points);
// Dragged by its leader line, the whole callout moves.
drag_pt(&mut h, (85.0, 75.0), (95.0, 75.0));
let c = callout(&h);
assert!(near_pt(c.points[0], 50.0, 30.0) && near_pt(c.points[1], 140.0, 130.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Move comment"));
// Each drag was one undo step.
h.state_mut().undo();
h.run_steps(2);
assert!(near_pt(callout(&h).points[0], 40.0, 30.0));
}
/// `n` pages with one comment each, "Card 1" to "Card n".
fn one_comment_per_page(n: usize) -> Vec<u8> {
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()];
+3 -3
View File
@@ -3805,9 +3805,9 @@ tier = "P1"
milestone = "M5"
status = "shipped"
commands = ["comment.callout"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text"
tools = ["comment_add", "comment_edit", "comment_list"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/annot/src/tests.rs::a_callouts_arrow_point_and_knee_move_on_their_own", "crates/annot/src/tests.rs::a_callouts_box_moves_around_its_arrow_and_keeps_the_knee_beside_it", "crates/annot/src/tests.rs::a_callout_without_a_knee_keeps_its_line", "crates/annot/src/tests.rs::a_callout_is_laid_out_again_when_its_end_leaves_the_box", "crates/annot/src/tests.rs::a_new_callouts_knee_line_is_twelve_points", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/automation/tests/it/automation.rs::a_callouts_parts_are_listed_and_changed_through_the_tools"]
notes = "drag from the target to the box; a new callout's knee line is 12 pt and level (or upright), out from the middle of the side facing the arrow point. Selected, its three parts change on their own (#1152), as seen in Acrobat Pro DC (a 2021 Continuous release) with synthetic files: the arrow point and the knee have handles, the knee moving along its knee line only and no nearer the box than 12 pt; the text box has its own resize handles, and resizing it with the side the line meets left in place keeps the line as it is (editing the text too); dragged from inside, the box moves alone and the knee comes along, the arrow point staying (also seen in Acrobat Pro 2026.002.22031; Acrobat DC Help 11/28/2019 p. 357: 'The text box moves around a stationary anchor point'); dragged by its leader line, the whole callout moves. When the side the line meets is moved, or the knee is dragged into the box, Acrobat lays the line out again with a 12 pt knee line and also moves the box next to the arrow point; PdfCraft lays the line out the same way but leaves the box where it was put. Only the page handles hold the knee line to one direction; comment_edit takes any knee, as ISO 32000-2 Table 177 allows"
[[feature]]
id = "comment.pencil"