Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions crates/annot/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
Some(form(rect, c.as_bytes(), res))
}

/// User space into the form frame of [`pdfcraft_model::view_matrix_for`]. Those rotation
/// matrices have determinant 1, so the inverse needs no division.
fn user_to_form([a, b, c, d, e, f]: [f64; 6], p: (f64, f64)) -> (f64, f64) {
(d * (p.0 - e) - c * (p.1 - f), a * (p.1 - f) - b * (p.0 - e))
}

fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
let d = context.dict;
let rect = context.rect;
Expand All @@ -902,8 +908,10 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
// A callout: the leader line (arrowhead at its first point), and the text box inside
// `/Rect` by `/RD`.
// Text added to a turned page is drawn upright in its displayed box, which the form
// `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
// `/Matrix` then maps into `/Rect` (as for stamps). A callout's leader and box are
// stored in user space and mapped into that displayed frame.
let turn = text_turn(d);
let user = rect;
let (rect, matrix) = if turn == 0 {
(rect, None)
} else {
Expand All @@ -919,7 +927,18 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
if d.contains(b"CL") {
let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
// User space to the form's displayed frame (the inverse of `/Matrix`).
let to_form = |p: (f64, f64)| match matrix {
None => p,
Some(m) => user_to_form(m, p),
};
let tb = [user[0] + rd[0], user[1] + rd[1], user[2] - rd[2], user[3] - rd[3]];
rect = if matrix.is_some() {
let (p, q) = (to_form((tb[0], tb[1])), to_form((tb[2], tb[3])));
[p.0.min(q.0), p.1.min(q.1), p.0.max(q.0), p.1.max(q.1)]
} else {
[full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]
};
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return None;
}
Expand All @@ -935,6 +954,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
let pts: Vec<(f64, f64)> = pts.into_iter().map(to_form).collect();
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
Expand Down
8 changes: 3 additions & 5 deletions crates/annot/src/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,9 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
border(&mut d);
rich_text(&mut d);
// The text is laid out for the page as displayed, like a stamp's label.
if !matches!(new.shape, Shape::Callout { .. }) {
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
}
_ => {}
Expand Down
35 changes: 35 additions & 0 deletions crates/annot/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2166,3 +2166,38 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
assert!(!long.contains(&"w".repeat(257)));
}

#[test]
fn callout_on_a_turned_page_is_drawn_upright_as_displayed() {
let mut doc = fixture();
let page = page_refs(&doc).unwrap()[0];
doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(90))).unwrap();
let co = add_annotation(
&mut doc,
&new(
0,
Shape::Callout {
rect: [300.0, 500.0, 340.0, 620.0],
knee: [260.0, 520.0],
point: [220.0, 460.0],
font_size: 10.0,
ending: LineEnding::OpenArrow,
},
),
&meta("co"),
)
.unwrap();
let doc = reopen(&doc);
let callout = &list(&doc, 0)[co];
assert_eq!(callout.get(b"PCTextRotate").and_then(Object::as_int), Some(90));
let r = rect(callout);
let nref = callout.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).unwrap();
let obj = doc.get(nref);
let Object::Stream(s) = &*obj else { panic!("AP is not a stream") };
let m: Vec<f64> = s.dict.get(b"Matrix").expect("form is turned by a /Matrix").as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(m, vec![0.0, 1.0, -1.0, 0.0, r[2], r[1]]);
// The leader starts at the pointed-at spot, mapped from user space into the displayed frame.
let ap = ap_content(&doc, callout);
let start = format!("{} {} m", n(460.0 - r[1]), n(r[2] - 220.0));
assert!(ap.contains(&start), "{start} in {ap}");
}
23 changes: 15 additions & 8 deletions crates/ui-egui/src/tools/comments/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,23 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
}
ComposerKind::Callout { point } => {
view.comments.tool_done = true;
// A callout's leader and box stay in user-space axes (its appearance isn't turned).
let rect = text_box_rect(c.at, &text, 10.0, 0);
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
// The box and leader are laid out as the page is displayed, like a text box's.
let rect = text_box_rect(c.at, &text, 10.0, page_rotation(info, page));
let page_info = info.pages.get(page);
let to_view = |p: [f64; 2]| page_info.map_or(p, |pg| pg.user_to_view(p[0] as f32, p[1] as f32).map(f64::from));
let to_user = |p: [f64; 2]| page_info.map_or(p, |pg| pg.view_to_user(p[0] as f32, p[1] as f32).map(f64::from));
let (a, b) = (to_view([rect[0], rect[1]]), to_view([rect[2], rect[3]]));
let (left, right) = (a[0].min(b[0]), a[0].max(b[0]));
let mid_y = (a[1] + b[1]) / 2.0;
let pv = to_view(point);
let side = if pv[0] < left {
left
} else if pv[0] > right {
right
} else {
(rect[0] + rect[2]) / 2.0
(left + right) / 2.0
};
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
let knee = to_user([(pv[0] + side) / 2.0, mid_y]);
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text))
}
ComposerKind::Replace => {
Expand Down
2 changes: 1 addition & 1 deletion parity/acrobat-features.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3728,7 +3728,7 @@ 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"
notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text. A callout added on a page with /Rotate 90, 180 or 270 is drawn upright as displayed; one saved without /PCTextRotate keeps its previous look. The arrow point and knee are not yet separate handles."

[[feature]]
id = "comment.pencil"
Expand Down