From b387802f2dca1ec2b36c10601fafc2444a03282c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 01:19:57 +0200 Subject: [PATCH 01/17] fix(comments): Add a text box takes a drag that sizes the box (part of #2669) --- crates/ui-egui/src/comments.rs | 88 +++++++++++++++++++++--- crates/ui-egui/tests/comments.rs | 111 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f..ab2e438eb 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -385,7 +385,11 @@ pub enum Gesture { #[derive(Clone, Copy, Debug, PartialEq)] pub enum ComposerKind { Note, - TextBox, + /// A text box; `size` is the box dragged out (displayed width and height, in user space + /// units), `None` after a click: then the box fits its text. + TextBox { + size: Option<[f64; 2]>, + }, /// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left. Callout { point: [f64; 2], @@ -928,11 +932,43 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, if over_page { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } + // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its + // text wraps inside it (#2669). + if tool == CommentTool::TextBox { + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let (Some(first), Some(last)) = (points.first(), points.last()) { + let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); + // A drag of a few pixels is a click: the box fits its text. + let drawn = + (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); + let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); + cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); + } + } + } if resp.clicked() && over_page && let Some(p) = pointer { - let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox }; + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); cv.selected = None; } @@ -1274,6 +1310,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE)); } } + CommentTool::TextBox if pts.len() == 2 => { + painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside); + } CommentTool::Callout if pts.len() == 2 => { painter.line_segment([pts[0], pts[1]], stroke); painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside); @@ -1352,7 +1391,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let c = view.comments.composer.as_mut()?; let title = match c.kind { ComposerKind::Note => "Sticky note", - ComposerKind::TextBox => "Text box", + ComposerKind::TextBox { .. } => "Text box", ComposerKind::Callout { .. } => "Callout", ComposerKind::Caret { .. } => "Inserted text", ComposerKind::Replace => "Replacement text", @@ -1368,7 +1407,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, }); ui.add_space(6.0); let hint = match c.kind { - ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"), + ComposerKind::TextBox { .. } | ComposerKind::Callout { .. } => tl!("Type text"), ComposerKind::Caret { .. } => tl!("Text to insert"), ComposerKind::Replace => tl!("Replacement text"), _ => tl!("Add a comment"), @@ -1419,14 +1458,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, view.comments.tool_done = true; Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text)) } - ComposerKind::TextBox => { + ComposerKind::TextBox { size } => { view.comments.tool_done = true; - Some(new_comment( - &cx, - CommentTool::TextBox, - Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 }, - text, - )) + let rotation = page_rotation(info, page); + let border = prefs.style(CommentTool::TextBox).width; + let rect = match (size, info.pages.get(page)) { + (Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, 12.0, border), + _ => text_box_rect(c.at, &text, 12.0, rotation), + }; + Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size: 12.0 }, text)) } ComposerKind::Callout { point } => { view.comments.tool_done = true; @@ -1548,6 +1588,32 @@ pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64 pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) } +/// The box dragged between user-space points `a` and `b`: its displayed top-left corner (user +/// space) and its displayed width and height. Measured in the page's view space, so the view's +/// own rotation and zoom (View > Rotate View) do not turn or scale it. +fn drawn_box(page: &PageInfo, a: [f64; 2], b: [f64; 2]) -> ([f64; 2], [f64; 2]) { + let (a, b) = (page.user_to_view(a[0] as f32, a[1] as f32), page.user_to_view(b[0] as f32, b[1] as f32)); + let at = page.view_to_user(a[0].min(b[0]), a[1].min(b[1])); + ([f64::from(at[0]), f64::from(at[1])], [f64::from((a[0] - b[0]).abs()), f64::from((a[1] - b[1]).abs())]) +} + +/// A text box dragged out `size` (displayed width and height) from its displayed top-left corner +/// `at`, with a border `border` wide: the text wraps to its width inside the border, as its +/// appearance lays it out. When the text needs more lines the box grows down, and moves up to stay +/// on the page; text that cannot fit even so keeps the drawn height and is clipped. +pub fn drawn_text_box_rect(page: &PageInfo, at: [f64; 2], size: [f64; 2], text: &str, font_size: f64, border: f64) -> [f64; 4] { + use pdfcraft_engine::annot_text::wrap; + let pad = 2.0 + border.max(0.0); + let [w, drawn] = size; + let lines = wrap(text, font_size, (w - 2.0 * pad).max(1.0)).len().max(1); + let needed = lines as f64 * font_size * 1.2 + 2.0 * pad + 2.0; + let [vx, vy] = page.user_to_view(at[0] as f32, at[1] as f32).map(f64::from); + let bottom = f64::from(page.height); + let (top, h) = if needed <= drawn || needed > bottom { (vy, drawn) } else { (vy.min(bottom - needed).max(0.0), needed) }; + let at = page.view_to_user(vx as f32, top as f32).map(f64::from); + pdfcraft_engine::text_box_at(i64::from(page.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) +} + /// The `/Rotate` of `page` (0 when it is unknown). pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 { info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b..3443852b3 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -488,6 +488,117 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// The rectangle of the comment whose text starts with `text`. +fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { + let c = comments(h); + c.iter().find(|a| a.contents.as_deref().is_some_and(|t| t.starts_with(text))).unwrap_or_else(|| panic!("no comment {text:?}: {c:?}")).rect +} + +/// Post `text` in the open text box composer. +fn post_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + field(h, "Type text").type_text(text); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); +} + +fn near(r: [f32; 4], want: [f32; 4]) -> bool { + r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.5) +} + +/// #2669: a drag with Add a text box draws the box at that size, and the text wraps inside it; +/// a click (or a drag of a few pixels) still makes a box sized to its text. +#[test] +fn a_text_box_is_sized_by_dragging() { + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + let (from, to) = (at(&h, 40.0, 150.0), at(&h, 200.0, 100.0)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(to); + h.run_steps(2); + assert!( + matches!(&h.state().views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) if points.len() == 2), + "the box shows while it is dragged" + ); + h.drop_at(to); + h.run_steps(3); + post_text(&mut h, "Drawn"); + let r = rect_of(&h, "Drawn"); + assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "{r:?}"); + + // Too much text for the drawn box: it keeps its width and grows down, moving up so that it + // stays on the page (whose bottom is at 0). + h.state_mut().set_option("quick", "freetext").unwrap(); + drag_pt(&mut h, (20.0, 80.0), (120.0, 70.0)); + post_text(&mut h, &"word ".repeat(20)); + let r = rect_of(&h, "word word"); + assert!((r[0] - 20.0).abs() < 1.5 && (r[2] - 120.0).abs() < 1.5 && r[1].abs() < 0.5 && r[3] > 90.0, "{r:?}"); + + // More text than the page can hold: the box keeps the drawn size and the text is clipped. + h.state_mut().set_option("quick", "freetext").unwrap(); + drag_pt(&mut h, (150.0, 190.0), (250.0, 180.0)); + post_text(&mut h, &"longerwords ".repeat(16)); + let r = rect_of(&h, "longerwords"); + assert!(near(r, [150.0, 180.0, 250.0, 190.0]), "{r:?}"); + + // Drags of two and four pixels are clicks: the box fits its text. + let fitted = pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], "Hi", 12.0, 0); + for (text, x, d) in [("Hi", 200.0, 2.0), ("Hi again", 270.0, 4.0)] { + h.state_mut().set_option("quick", "freetext").unwrap(); + let p = at(&h, x, 40.0); + drag(&mut h, p, p + egui::vec2(d, d)); + post_text(&mut h, text); + let r = rect_of(&h, text).map(f64::from); + let fitted = if text == "Hi" { fitted } else { pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], text, 12.0, 0) }; + assert!((r[2] - r[0] - (fitted[2] - fitted[0])).abs() < 1.0 && (r[3] - r[1] - (fitted[3] - fitted[1])).abs() < 1.0, "{text}: {r:?}"); + } +} + +/// #2669: with the view turned (View > Rotate View) the box lands where it was drawn, not +/// transposed or scaled by the turned page's other side. +#[test] +fn a_dragged_text_box_follows_a_rotated_view() { + for rotation in [90u16, 270] { + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + while h.state().views[0].rotation != rotation { + h.state_mut().views[0].rotate_view(true); + } + h.state_mut().set_option("zoom", "150").unwrap(); + h.run_steps(3); + settle(&mut h); + let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let (u, v) = (x / 300.0, (200.0 - y) / 200.0); + let (a, b) = if rotation == 90 { (1.0 - v, u) } else { (v, 1.0 - u) }; + pos2(r.left() + a * r.width(), r.top() + b * r.height()) + }; + let (from, to) = (screen(&h, 40.0, 150.0), screen(&h, 200.0, 100.0)); + drag(&mut h, from, to); + post_text(&mut h, "Turned"); + let r = rect_of(&h, "Turned"); + assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "view turned {rotation}: {r:?}"); + } +} + +/// #2669: the text of a drawn box wraps inside its border, as its appearance draws it, so a thick +/// border does not cut off the last line. +#[test] +fn a_dragged_text_box_wraps_inside_its_border() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| { + app.set_option("quick", "freetext").unwrap(); + app.comment_prefs.set_width(CommentTool::TextBox, 3.0); + }); + // Wide enough for "word word" inside a 2 pt padding, not inside 2 + 3 pt. + let w = pdfcraft_engine::annot_text::text_width("word word", 12.0) as f32 + 6.0; + drag_pt(&mut h, (20.0, 190.0), (20.0 + w, 180.0)); + post_text(&mut h, "word word"); + let r = rect_of(&h, "word word"); + assert!((r[3] - r[1] - (2.0 * 14.4 + 2.0 * 5.0 + 2.0)).abs() < 1.0, "two lines inside the border: {r:?}"); +} + #[test] fn comments_are_selected_moved_and_deleted_with_the_select_tool() { let mut h = harness(|app| app.set_option("quick", "square").unwrap()); From 4f3f2fe1d227974a1570ad41cccafa21a54087f5 Mon Sep 17 00:00:00 2001 From: Ahmad Mujahid Date: Fri, 16 Oct 2026 12:03:40 +0400 Subject: [PATCH 02/17] M5: batch Comments panel thread and page lookups (#2669) --- ROADMAP.md | 3 + crates/ui-egui/src/comments_panel.rs | 24 +++-- crates/ui-egui/tests/comments.rs | 131 ++++++++++++++++++++++++++- crates/ui-egui/tests/perf.rs | 56 +++++++++++- docs/roadmap.md | 5 +- 5 files changed, 208 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..038baa6ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce..425b9898e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -4,6 +4,8 @@ //! current page; cards grouped by page. Selecting a card selects the comment on the page and //! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge. +use std::collections::HashMap; + use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; use pdfcraft_render::{Annotation, DocInfo}; @@ -154,12 +156,18 @@ pub(crate) fn show( return edit; } let query = view.comments.search.clone().filter(|q| !q.trim().is_empty()); - let replies_of = |a: &Annotation| -> Vec<&Annotation> { - match &a.name { - Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(), - None => Vec::new(), + let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new(); + let mut page_counts: HashMap = HashMap::new(); + // Keep global name matching and source reply order. Page badges count roots before filtering. + for a in &info.annotations { + if let Some(parent) = a.in_reply_to.as_deref() { + replies.entry(parent).or_default().push(a); + } else { + let count = page_counts.entry(a.page).or_default(); + *count = count.saturating_add(1); } - }; + } + let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; let status_of = |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); @@ -172,7 +180,7 @@ pub(crate) fn show( .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) .filter(|a| !cv.hidden_colors.contains(&color_key(a))) - .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(&replies_of(a))).to_string())) + .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string())) .collect(); let sort = cv.sort; match sort { @@ -221,7 +229,7 @@ pub(crate) fn show( } } else if a.page != page { page = a.page; - let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count(); + let n = page_counts.get(&page).copied().unwrap_or_default(); let open = !view.comments.collapsed_pages.contains(&page); ui.add_space(6.0); let clicked = ui @@ -255,7 +263,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, thread, view, prefs, allowed, nav) { edit = Some(e); } } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b..2a7624e0a 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -24,10 +24,14 @@ trailer << /Root 1 0 R >> %%EOF"; fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + harness_with_bytes(TEXT_FIXTURE.to_vec(), setup) +} + +fn harness_with_bytes(bytes: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.open_bytes("text.pdf", None, bytes).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); setup(&mut app); @@ -735,6 +739,131 @@ fn the_panel_filters_and_sorts() { assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } +// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on +// another page, with dates opposite to source order. An unnamed root has no thread. +const REVIEW_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 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (shared) /T (Alpha) /Contents (First root) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /NM (shared) /T (Beta) /Contents (Duplicate root) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 140 120 160] /T (Gamma) /Contents (Unnamed root) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (late) /T (Delta) /Contents (Late root) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (first-reply) /IRT 5 0 R /T (Reader) /Contents (Needle first reply) /M (D:20260102000000) >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Rejected /Contents (Old review) /M (D:20260102000000) >> endobj +11 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Marked /M (D:20260102000000) >> endobj +12 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (second-reply) /IRT 5 0 R /T (Reader) /Contents (Second reply) /M (D:20260101000000) >> endobj +13 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Accepted /Contents (Status-only token) /M (D:20260101000000) >> endobj +14 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Unmarked /M (D:20260101000000) >> endobj +15 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (late-reply) /IRT 8 0 R /T (Reader) /Contents (Late thread reply) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() { + let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("needle".into()); + app.views[0].comments.hidden_authors.push("Beta".into()); + app.views[0].comments.selected = Some((0, 0)); + }); + h.get_by_label("First root"); + h.get_by_label("Page 1"); + h.get_by_label("3"); // All three page roots count, including the two filtered out. + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); + assert_eq!(h.query_all_by_label("Unnamed root").count(), 0); + assert!( + h.get_by_label("Needle first reply").rect().top() < h.get_by_label("Second reply").rect().top(), + "replies follow source order, not dates" + ); + h.get_by_label("Accepted"); + h.get_by_label("Mark with checkmark"); // The later Unmarked reply wins independently. + assert_eq!(h.query_all_by_label("Status-only token").count(), 0, "status text is searchable but is not a displayed reply"); + + h.state_mut().views[0].comments.hidden_authors.clear(); + h.run_steps(2); + h.get_by_label("Duplicate root"); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread"); + h.state_mut().views[0].comments.search = Some("status-only token".into()); + h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into()); + h.state_mut().views[0].comments.hidden_checks.push("Checked".into()); + h.run_steps(2); + h.get_by_label("First root"); + h.get_by_label("Duplicate root"); + + h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into()); + h.run_steps(2); + h.get_by_label("No comments match."); + h.state_mut().views[0].comments.hidden_statuses.clear(); + h.state_mut().views[0].comments.hidden_checks.push("Unchecked".into()); + h.run_steps(2); + h.get_by_label("No comments match."); + + h.state_mut().views[0].comments.search = Some("unnamed root".into()); + h.state_mut().views[0].comments.hidden_checks.clear(); + h.run_steps(2); + h.get_by_label("Unnamed root"); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies"); +} + +#[test] +fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() { + let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.collapsed_pages = vec![0, 1]; + }); + assert_eq!(h.query_all_by_label("Late root").count(), 0); + h.state_mut().views[0].comments.selected = Some((1, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(4); + h.get_by_label("Late root"); + h.get_by_label("Late thread reply"); + assert_eq!(h.state().views[0].comments.collapsed_pages, vec![0]); + h.get_by_label("More").click(); + h.run_steps(2); + h.query_all_by_label("Edit").last().expect("comment menu edit, after the toolbar tab").click(); + h.run_steps(2); + let editor = h + .query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")) + .next() + .expect("note editor"); + editor.click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Late root edited exactly".into())); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((1, 0))); + let before = comments(&h); + let late = before.iter().find(|a| a.name.as_deref() == Some("late")).unwrap(); + assert_eq!(late.contents.as_deref(), Some("Late root edited exactly")); + let id = h.state().views[0].id; + let saved = h.state().session.save_bytes(id).unwrap().as_ref().clone(); + let mut reopened = harness_with_bytes(saved, |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("late thread reply".into()); + }); + h.get_by_label("Late root edited exactly"); + reopened.get_by_label("Late root edited exactly"); + reopened.get_by_label("Late thread reply"); + assert_eq!(reopened.query_all_by_label("First root").count(), 0); + let after = comments(&reopened); + assert_eq!(after.len(), before.len()); + for (original, saved) in before.iter().zip(&after) { + assert_eq!( + (&saved.name, &saved.contents, &saved.in_reply_to, &saved.state), + (&original.name, &original.contents, &original.in_reply_to, &original.state) + ); + } + assert_eq!(after.iter().find(|a| a.name.as_deref() == Some("late-reply")).unwrap().in_reply_to.as_deref(), Some("late")); + reopened.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Author; + reopened.run_steps(2); + assert_eq!(reopened.query_all_by_label("Delta").count(), 2, "the author group and its card remain visible after sorting"); + reopened.get_by_label("Late root edited exactly"); +} + /// #1554: a page group's chevron folds that page's cards away and back, and the choice holds. #[test] fn page_groups_collapse_and_expand() { diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index f30be048f..319edeffc 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -28,7 +28,7 @@ fn overloaded() -> bool { } } -/// The two budget tests take turns: each starts render workers, and measuring one while the other +/// The budget tests take turns: each starts render workers, and measuring one while another /// runs would charge it for the other's work on a small CI machine. static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -75,6 +75,10 @@ fn big(n: usize) -> Vec { objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); objs.push("<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /Contents (Check) >>".into()); } + pdf(objs) +} + +fn pdf(objs: Vec) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -90,6 +94,56 @@ fn big(n: usize) -> Vec { out } +/// Named roots exercise thread lookups even when none of the comments has replies. +fn named_comments(n: usize) -> Vec { + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Annots [{}] >>", annots.join(" ")), + ]; + for i in 0..n { + objs.push(format!( + "<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /NM (root-{i}) /Contents (Review note {i}) >>" + )); + } + pdf(objs) +} + +#[test] +fn collapsed_comments_with_ten_thousand_named_roots_stay_within_the_frame_budget() { + if overloaded() { + return; + } + let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = named_comments(10_000); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("review.pdf", None, bytes).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.collapsed_pages = vec![0]; + app + }); + h.run_steps(4); + { + let state = h.state(); + let info = &state.session.get(state.views[0].id).unwrap().info; + assert_eq!(info.pages.len(), 1); + assert_eq!(info.annotations.len(), 10_000); + for a in &info.annotations { + assert_eq!(a.name.as_deref(), Some(format!("root-{}", a.index).as_str())); + assert!(a.in_reply_to.is_none()); + assert!(a.state.is_none()); + } + assert_eq!(state.views[0].comments.collapsed_pages, [0]); + } + // Folding removes card layout cost, so this measures filtering and thread lookup work. + let avg = best_average("collapsed named comments", 30, |_| h.step()); + assert!(avg < BUDGET, "collapsed comments: average frame {avg:?}"); +} + #[test] fn scrolling_a_500_page_document_stays_within_the_frame_budget() { if overloaded() { diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..72a8d08e2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -113,7 +115,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: the Comments panel groups replies by name and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -179,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M5 #2669, item 5: the Comments panel groups replies and counts all page roots once per frame. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | From f4ee74db05328b3659766482593bc71afadc74a9 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 18:59:04 +0200 Subject: [PATCH 03/17] fix(comments): the note editor takes the keyboard once, so V and H pick tools again After a highlight, its note editor in the Comments panel kept the keyboard, so V and H were typed into the note (#2669, item 2). - The editor asked for the keyboard on every frame when no other widget had it, so it took it back after every click elsewhere. It now takes it once, when it opens (CommentView::open_editor, EditorFocus::Take). - Adding another comment left the editor open, and drawing is a drag, which egui doesn't treat as a click elsewhere. An empty editor on another comment now closes when a comment is added; one with text stays open with it but gives the keyboard back (EditorFocus::Release). Part of #2669. --- crates/ui-egui/src/comments.rs | 32 ++++++++++ crates/ui-egui/src/comments_panel.rs | 10 +-- crates/ui-egui/src/editing.rs | 7 ++- crates/ui-egui/tests/comments.rs | 93 ++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f..6d27b3ff4 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -496,6 +496,19 @@ pub struct CommentView { pub context_at: Option<(usize, [f64; 2])>, /// A one-shot tool just finished; the app returns to the Select tool unless pinned. pub tool_done: bool, + /// What the inline editor (`editing`) does with the keyboard the next time it is drawn. + pub(crate) editor_focus: Option, +} + +/// A one-off change to the keyboard focus of the Comments panel's inline editor. The editor +/// never asks for the keyboard on its own: when it did so on every frame, it took the keyboard +/// back after every click elsewhere, and the tool keys (V, H) were typed into it (#2669). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditorFocus { + /// The editor just opened: it takes the keyboard, once. + Take, + /// Another comment was added: the editor keeps its text but gives the keyboard back. + Release, } impl CommentView { @@ -512,6 +525,25 @@ impl CommentView { } true } + + /// Open the inline editor on comment (page, index) with `text`. It takes the keyboard once, + /// as it opens; after that the keyboard goes wherever the user clicks. + pub(crate) fn open_editor(&mut self, page: usize, index: usize, text: String) { + self.editing = Some((page, index, text)); + self.editor_focus = Some(EditorFocus::Take); + } + + /// A comment was just added. The editor open on another one must not keep the keyboard, or + /// the tool keys pressed next are typed into it (#2669): an empty editor closes, and one + /// holding text stays open with it, without the keyboard. + pub(crate) fn comment_added(&mut self) { + if self.editing.as_ref().is_some_and(|(_, _, text)| !text.is_empty()) { + self.editor_focus = Some(EditorFocus::Release); + } else { + self.editing = None; + self.editor_focus = None; + } + } } /// Comments panel order (Acrobat: Sort by page, author, date, type, colour). diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce..c62709c16 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -323,8 +323,10 @@ fn card( ui.set_width(ui.available_width()); if editing && let Some((_, _, text)) = view.comments.editing.as_mut() { let r = ui.add(crate::widgets::block(text).desired_rows(2).desired_width(f32::INFINITY).id_salt(("comment-edit", key))); - if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) { - r.request_focus(); + match view.comments.editor_focus.take() { + Some(crate::comments::EditorFocus::Take) => r.request_focus(), + Some(crate::comments::EditorFocus::Release) => r.surrender_focus(), + None => {} } let submit = crate::comments::submit_key(ui, &r, false); ui.horizontal(|ui| { @@ -433,7 +435,7 @@ fn card( *nav = Some(Nav::Flash(a.page, a.rect)); } if click.double_clicked() && allowed { - view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); + view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } let menu_id = ui.id().with(("card-menu", key)); if open_menu { @@ -448,7 +450,7 @@ fn card( .show(|ui| { ui.set_min_width(160.0); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { - view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); + view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); ui.close(); } ui.menu_button(tl!("Set status"), |ui| { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68..80feef11f 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -116,6 +116,9 @@ impl PdfCraftApp { } else { view.document_changed(info); } + if matches!(edit, Edit::AddAnnotation(_)) { + view.comments.comment_added(); + } // Keep the pages the user acted on selected, where they now are. match edit { Edit::MovePages { pages, to } => { @@ -142,9 +145,9 @@ impl PdfCraftApp { // unless a note typed into another card is still unsaved. if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) && a.contents.is_empty() - && view.comments.editing.as_ref().is_none_or(|(_, _, text)| text.is_empty()) + && view.comments.editing.is_none() { - view.comments.editing = Some((a.page, n, String::new())); + view.comments.open_editor(a.page, n, String::new()); } } Edit::DeleteAnnotation { .. } => view.comments.selected = None, diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b..d1cdbe5c6 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -231,6 +231,99 @@ fn a_new_highlight_opens_its_note_for_typing() { assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into()))); } +/// The Comments panel's editor for a comment's note (the reply field is the other multiline one). +fn note_editor<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> { + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("note editor") +} + +/// The text in the open note editor. +fn note(h: &Harness<'static, PdfCraftApp>) -> Option { + h.state().views[0].comments.editing.as_ref().map(|(_, _, text)| text.clone()) +} + +/// A letter key as a keyboard sends it: the key goes down, types its letter and comes up. +fn type_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key, letter: &str) { + h.key_down(key); + h.event(egui::Event::Text(letter.into())); + h.key_up(key); + h.run_steps(2); +} + +/// Highlight glyphs `from..to` of page 1's text with the highlighter. +fn highlight_text(h: &mut Harness<'static, PdfCraftApp>, from: usize, to: usize) { + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, from).expect("text layer"), v.glyph_screen_pos(0, to).expect("glyph")) + }; + drag(h, a, b); + h.run_steps(2); +} + +#[test] +fn clicking_the_page_gives_the_keyboard_back_from_a_highlight_note() { + // #2669: the note editor took the keyboard back on every frame, so after a click on the + // page V and H were typed into the note instead of picking a tool. + let mut h = harness(|app| { + app.set_option("quick", "highlight").unwrap(); + app.set_option("panel", "comments").unwrap(); + }); + highlight_text(&mut h, 4, 14); + assert!(note_editor(&h).is_focused(), "a new highlight's note takes the keyboard straight away"); + h.event(egui::Event::Text("Note".into())); + h.run_steps(2); + assert_eq!(note(&h).as_deref(), Some("Note"), "typing goes into the note"); + // A click on an empty part of the page gives the keyboard back to the page. + click_pt(&mut h, (250.0, 20.0)); + type_key(&mut h, egui::Key::V, "v"); + assert_eq!(h.state().quick_tool, QuickTool::Select, "V picks the Select tool"); + assert_eq!(note(&h).as_deref(), Some("Note"), "V is not typed into the note"); + type_key(&mut h, egui::Key::H, "h"); + assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool"); + assert_eq!(note(&h).as_deref(), Some("Note"), "nor is H"); + // Clicking back into the note types into it again, and the letters are text there. + note_editor(&h).click(); + h.run_steps(2); + type_key(&mut h, egui::Key::V, "v"); + assert_eq!(note(&h).as_deref(), Some("Notev"), "the note takes keys while the user types in it"); + assert_eq!(h.state().quick_tool, QuickTool::Hand, "typed letters don't pick tools"); +} + +#[test] +fn drawing_another_comment_takes_the_keyboard_from_a_highlight_note() { + // #2669: a highlight's note kept the keyboard while rectangles and ovals were drawn (a drag + // is not a click, so the editor never let go), and V and H were typed into it. + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| { + app.set_option("quick", "highlight").unwrap(); + app.set_option("panel", "comments").unwrap(); + }); + highlight_text(&mut h, 4, 14); + assert!(note_editor(&h).is_focused()); + // The tools are picked without a click, as a shortcut would: the drawing alone must take + // the keyboard back. An empty note closes. + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + assert_eq!(comments(&h).len(), 2); + assert_eq!(h.state().views[0].comments.editing, None, "the empty note closed"); + type_key(&mut h, egui::Key::H, "h"); + assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool"); + // A note with text in it stays open with its text, but lets the keyboard go. + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.editing, Some((0, 2, String::new())), "the new area highlight's note opens"); + assert!(note_editor(&h).is_focused()); + h.event(egui::Event::Text("Note".into())); + h.run_steps(2); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + drag_pt(&mut h, (40.0, 30.0), (260.0, 8.0)); + assert_eq!(comments(&h).len(), 4); + assert_eq!(h.state().views[0].comments.editing, Some((0, 2, "Note".into())), "a note with text stays open"); + type_key(&mut h, egui::Key::H, "h"); + assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool"); + assert_eq!(note(&h).as_deref(), Some("Note"), "H is not typed into the note"); +} + #[test] fn ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line() { let mut h = harness(|app| { From 7420af6751808908159967ac0892d289e31a36b5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:49:52 +0200 Subject: [PATCH 04/17] Draw a note without an appearance as a fixed-size icon at the corner of its Rect (#3175) --- crates/annot/src/lib.rs | 14 ++++++++++++++ crates/annot/src/tests.rs | 23 +++++++++++++++++++++++ crates/edit/src/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa02..2a8803021 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1355,8 +1355,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> apd.remove(b"D"); apd.remove(b"R"); apd.set(b"N".to_vec(), Object::Ref(ap)); + // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. + // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with + // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. + let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten() + .filter(|q| q.len() == 4 && finite(q)) + .map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(rect) = icon_rect { + d.set(b"Rect".to_vec(), num_array(&rect)); + } d.remove(b"AS"); pdfcraft_fonts::set_undrawn_mark(d, missing); })?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409ab..f15d05e27 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -964,6 +964,29 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() { assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged"); } +/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner +/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it +/// to nothing in one with no width. +#[test] +fn a_note_without_an_appearance_is_shown_at_icon_size() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>", + b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>", + b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>", + ]); + let display = with_missing_appearances(&doc).expect("the notes get a display appearance"); + let rects = |doc: &Document| -> Vec> { + let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::>()); + list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect() + }; + assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]); + let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]]; + assert_eq!(rects(&doc), original, "the source document is unchanged"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7930a7ead..803b9d194 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -284,6 +284,27 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of +/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing +/// while flattening still counted it as drawn and deleted the note. +#[test] +fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>", + "<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn"); + let doc = reopen(&doc); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}"); + assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}"); +} + /// #1470: a form field whose widget is on no page (left behind by page extraction or combining) /// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, /// or other software keeps treating the flattened file as a form. A hidden widget on a page is From 62cde99aef61f489dbb770fd75ee9d77d98bf253 Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:02:56 +0200 Subject: [PATCH 05/17] M5: reveal sidebar comments at the current zoom (#3418) --- crates/ui-egui/src/canvas.rs | 15 ++++++++++ crates/ui-egui/src/panels.rs | 6 +++- crates/ui-egui/tests/comments.rs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16ae63490..69a5823f1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1207,6 +1207,21 @@ impl DocView { self.current = page; } + /// Reveal a user-space rectangle without changing zoom, centred where the scroll bounds allow. + pub(crate) fn reveal_rect(&mut self, page: usize, rect: [f32; 4], info: &DocInfo) { + self.go_to_page(page); + self.goto_point = None; + let Some(p) = info.pages.get(self.current) else { return }; + if !rect.iter().all(|v| v.is_finite()) { + return; + } + // Halve before adding so even large finite coordinates cannot overflow the midpoint. + let x = rect[0] * 0.5 + rect[2] * 0.5; + let y = rect[1] * 0.5 + rect[3] * 0.5; + let [fx, fy] = p.dest_fraction(Some(x), Some(y), self.rotation); + self.goto_point = Some((self.current, fx, fy, vec2(self.viewport_w, self.viewport_h) / 2.0)); + } + /// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled /// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's /// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0f2c60149..03c483c84 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -826,7 +826,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }, Some(Nav::Flash(p, r)) => { let v = &mut app.views[index]; - v.go_to_page(p); + if let Some(doc) = app.session.get(id) { + v.reveal_rect(p, r, &doc.info); + } else { + v.go_to_page(p); + } v.flash = Some((p, r, 0.0)); } None => {} diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 3d53f1e3b..887686fee 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -100,6 +100,53 @@ fn comments(h: &Harness<'static, PdfCraftApp>) -> Vec { s.session.get(s.views[0].id).unwrap().info.annotations.clone() } +/// #3418: choosing a sidebar comment must reveal the annotation, not just its page's top. +#[test] +fn sidebar_comment_centres_the_annotation_at_high_zoom() { + for (layout, rotation) in [("continuous", 0), ("single", 0), ("two-page", 0), ("continuous", 90), ("continuous", 180), ("continuous", 270)] { + let mut h = harness(move |app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("layout", layout).unwrap(); + app.set_option("zoom", "800").unwrap(); + app.views[0].rotation = rotation; + let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment }; + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 1, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reveal this note".into(), + author: "Tester".into(), + }))); + app.views[0].comments.selected = None; + app.set_option("page", "1").unwrap(); + }); + h.get_by_label("Reveal this note").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].comments.selected, Some((1, 0)), "the sidebar card was selected"); + let a = &comments(&h)[0]; + let view = &h.state().views[0]; + let page = view.page_screen_rect(1).expect("the selected comment's page is drawn"); + let (u, v) = ((a.rect[0] + a.rect[2]) / 600.0, 1.0 - (a.rect[1] + a.rect[3]) / 400.0); + let (u, v) = match rotation { + 90 => (1.0 - v, u), + 180 => (1.0 - u, 1.0 - v), + 270 => (v, 1.0 - u), + _ => (u, v), + }; + let centre = pos2(page.left() + u * page.width(), page.top() + v * page.height()); + let viewport = view.viewport_rect(); + assert!(centre.distance(viewport.center()) < 3.0, "{layout}, {rotation}°: comment at {centre:?}, viewport {viewport:?}"); + assert!((view.zoom - 8.0).abs() < 0.01, "revealing a comment preserves zoom"); + if layout == "continuous" + && rotation == 0 + && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") + { + settle(&mut h); + h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap(); + } + } +} + #[test] fn dragging_over_text_with_the_highlighter_highlights_it() { let mut h = harness(|app| app.set_option("quick", "highlight").unwrap()); From 99c78557eba26f67eb3cb6e0c8aa1ede653a2f79 Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 19 Oct 2026 23:42:24 +0530 Subject: [PATCH 06/17] M5: a stamp grows and shrinks with its handles before release (#3518) While a handle of a Fill & Sign line, check, cross, dot, typed signature or comment stamp was dragged, only the selection frame followed the pointer; the stamp kept its old size (or place) until release. Image signatures already had a live layer. Every stamp now gets one: the page without the stamp, and the stamp drawn alone on a transparent page, whose pixels are stretched with the frame. Resizing a stamp only changes its /Rect, which its appearance is stretched to fit, so the preview is what the release produces. - render: RenderConfig.transparent clears the page to transparent. - annot: isolate_annotation leaves a page showing one annotation alone. - engine: Document::stamp_preview builds the two read-only layers; the page beneath gets the appearances drawn for comments without one. - ui-egui: the signature drag layer covers every stamp, rendering the stamp's own tile sharp at the current zoom. --- crates/annot/src/lib.rs | 21 ++++ crates/engine/src/lib.rs | 2 + crates/engine/src/stamp_preview.rs | 83 +++++++++++++ crates/engine/src/tests.rs | 54 ++++++++ crates/render/src/raster.rs | 62 ++++++++-- crates/ui-egui/src/signature_drag.rs | 178 +++++++++++++++++++++------ crates/ui-egui/tests/fill_sign.rs | 68 ++++++++++ 7 files changed, 418 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/stamp_preview.rs diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..908cc34d0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -803,6 +803,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result { + let p = page_ref(doc, page)?; + let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let has_normal = { + let obj = doc.resolve(&entry); + obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) + }; + if !has_normal { + return Ok(false); + } + set_annots(doc, p, vec![entry])?; + doc.update_dict(p, |d| { + d.remove(b"Contents"); + })?; + Ok(true) +} + // ── building ──────────────────────────────────────────────────────────────────────────────── /// Annotation flags (§12.5.3). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f39248ab..66a657684 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ mod scan_colours; mod scan_fields; pub mod signature_image; pub mod signature_scan; +mod stamp_preview; pub mod text_fonts; mod text_preview; pub mod word_count; @@ -41,6 +42,7 @@ pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; +pub use stamp_preview::StampPreview; pub use text_preview::TextEditPreview; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges}; diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs new file mode 100644 index 000000000..6e5758a7c --- /dev/null +++ b/crates/engine/src/stamp_preview.rs @@ -0,0 +1,83 @@ +//! A stamp while it is moved or resized: the page without it, and the stamp alone. + +use std::sync::Arc; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage}; + +use crate::{Document, guard}; + +/// Read-only layers for interactive movement and resizing: the page without the stamp, and the +/// stamp alone on a transparent page. Moving or resizing a stamp only changes its `/Rect`, which +/// its appearance is stretched to fit, so these two show the result before it is applied. +/// Preparing them never changes the working bytes, dirty flag or undo history. +pub struct StampPreview { + page: usize, + background: Arc>, + stamp: Arc>, + config: RenderConfig, +} + +impl StampPreview { + /// One worker for the page beneath the stamp. + pub fn background_renderer(&self) -> RenderPool { + RenderPool::new(self.background.clone(), 1, self.config.clone()) + } + + /// One worker for the stamp alone: its page has no content, no other annotations and no + /// background, so only the stamp's own pixels are drawn. + pub fn stamp_renderer(&self) -> RenderPool { + RenderPool::new(self.stamp.clone(), 1, self.stamp_config()) + } + + /// The page beneath the stamp, rendered synchronously with the renderer's normal pixel limits. + pub fn render_background(&self, scale: f32) -> Result { + self.render(&self.background, self.config.clone(), scale) + } + + /// The stamp alone on a transparent page, rendered like [`Self::render_background`]. + pub fn render_stamp(&self, scale: f32) -> Result { + self.render(&self.stamp, self.stamp_config(), scale) + } + + fn stamp_config(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } + + fn render(&self, bytes: &Arc>, config: RenderConfig, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = PageRenderer::new(bytes.clone(), config).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } +} + +impl Document { + /// The layers that show the stamp at `(page, index)` moved or resized as it is dragged. + /// `None` for an annotation that isn't a stamp, or one without an appearance to draw. + pub fn stamp_preview(&self, page: usize, index: usize) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let is_stamp = self.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp"); + if !is_stamp { + return Ok(None); + } + let mut stamp = editor.cos.clone(); + if !pdfcraft_annot::isolate_annotation(&mut stamp, page, index).map_err(|e| e.to_string())? { + return Ok(None); + } + let mut background = editor.cos.clone(); + pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + // The page beneath as it is displayed: comments without an appearance get theirs. + let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); + let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); + Ok(Some(StampPreview { page, background: write(&background)?, stamp: write(&stamp)?, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..cc9728d86 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2460,6 +2460,60 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A dragged stamp's layers: the page without it (its text and other comments kept) and the +/// stamp alone on a transparent page. Preparing them changes nothing in the document. +#[test] +fn stamp_preview_layers_split_the_stamp_from_its_page() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp"); + let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap()); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y); + let (mut drawn, mut stray) = (0, Vec::new()); + for y in 1..300 { + for x in 0..200 { + match (line(x, y), px(&stamp, x, y)) { + (true, [.., 255]) => drawn += 1, + (false, v) if v != [0, 0, 0, 0] => stray.push((x, y)), + _ => {} + } + } + } + assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels"); + assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay"); + assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp"); + assert!(doc.stamp_preview(0, 9).unwrap().is_none()); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + // Another app's comments without appearances: beneath a dragged stamp they are drawn as on + // screen, and such a stamp has nothing to lay over the page. + let bare = 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 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 45641178d..d35bb768a 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -26,7 +26,7 @@ use hayro::hayro_interpret::hayro_cmap::CidFamily; use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning}; use hayro::hayro_syntax::Pdf; use hayro::vello_cpu::color::AlphaColor; -use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE}; use hayro::{RenderCache, RenderSettings, render_into, render_size}; use crate::{Pixels, ReadingColors}; @@ -65,6 +65,9 @@ pub struct RenderConfig { /// View ▸ Reading colours: solid text and line-art paints are remapped for readability and /// the page clears to the setting's background. Display only; the document never changes. pub reading_colors: ReadingColors, + /// The page clears to transparent instead of white (or the reading background): for a page + /// holding only what is laid over another raster, such as a comment while it is dragged. + pub transparent: bool, } impl RenderConfig { @@ -300,10 +303,10 @@ fn render_page<'a>( pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, - reject_oversize: bool, - reading: ReadingColors, + config: &RenderConfig, req: RenderRequest, ) -> Result { + let reading = request_reading(config, &req); if req.kind == RequestKind::Text { return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) { Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))), @@ -324,6 +327,7 @@ fn render_page<'a>( Some(p) => (AlphaColor::from_rgb8(p.background[0], p.background[1], p.background[2]), Some(hayro::ColorReplacement::from(p))), None => (WHITE, None), }; + let bg_color = if config.transparent { TRANSPARENT } else { bg_color }; let rs = match req.tile { // Tiles are bounded by construction, so the page itself may be arbitrarily large. Some(t) => { @@ -342,7 +346,7 @@ fn render_page<'a>( } None => { let scale = effective_scale(w, h, req.scale); - if reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) { + if config.reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) { return Err(format!( "requested page {} raster scale {req_scale} exceeds renderer limits (maximum scale {scale})", req.page + 1, @@ -463,7 +467,7 @@ impl PageRenderer { let cache = RenderCache::new(); let warnings = Arc::new(Mutex::new(Vec::new())); let settings = warning_settings(&self.settings, warnings.clone()); - let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, request_reading(&self.config, &req), req); + let r = render_page(pdf, &cache, &settings, &self.config, req); let warnings = take_warnings(&warnings); if matches!(r, Err((_, true))) { self.pdf = parse(&self.bytes, self.config.password.as_deref()); @@ -1193,7 +1197,7 @@ fn worker( panic!("injected page panic"); } match pdf { - Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, request_reading(&config, &req), req), + Some(pdf) => render_page(pdf, cache, &settings, &config, req), None => Err(("the document could not be parsed".into(), false)), } })) @@ -6075,6 +6079,46 @@ trailer << /Root 1 0 R >> assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); } + /// A transparent page leaves what nothing paints see-through, so it can be laid over another + /// raster: with reading colours on, and in a tile, too. Painted pixels stay premultiplied. + #[test] + fn transparent_pages_paint_only_their_content() { + let content = "0 0 1 rg 10 10 30 20 re f /GS gs 0.5 g 60 10 30 20 re f"; + let pdf = format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R /Resources << /ExtGState << /GS << /ca 0.5 >> >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let pixel = |page: &RenderedPage, x: u32, y: u32| page.rgba[((y * page.width + x) * 4) as usize..][..4].to_vec(); + for reading_colors in [ReadingColors::default(), ReadingColors::WHITE_ON_BLACK] { + let config = RenderConfig { transparent: true, reading_colors, ..Default::default() }; + let mut r = PageRenderer::new(Arc::new(pdf.clone()), config.clone()); + let page = r.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(pixel(&page, 5, 5), [0, 0, 0, 0], "{reading_colors:?}: nothing painted, nothing shown"); + assert_eq!(pixel(&page, 50, 60)[3], 255, "{reading_colors:?}: the opaque rectangle covers"); + assert_eq!(pixel(&page, 150, 60), [64, 64, 64, 128], "{reading_colors:?}: half-transparent grey, premultiplied"); + let tile = r.render(RenderRequest { page: 0, scale: 2.0, tile: Some(Tile { x: 0, y: 40, w: 100, h: 60 }), ..Default::default() }); + assert_eq!((pixel(&tile, 5, 5), pixel(&tile, 50, 20)), (pixel(&page, 5, 45), pixel(&page, 50, 60)), "{reading_colors:?}"); + let pool = RenderPool::new(Arc::new(pdf.clone()), 1, config); + pool.set_queue(vec![RenderRequest { page: 0, scale: 2.0, ..Default::default() }]); + let pooled = loop { + if let Some(p) = pool.try_recv() { + break p; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert_eq!(pooled.rgba, page.rgba, "{reading_colors:?}: the pool renders the same"); + } + let opaque = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert_eq!(pixel(&opaque, 5, 5), [255, 255, 255, 255], "pages are still white by default"); + } + /// Reading colours replace *document* colours, never annotation colours (issue #1548): a /// highlight keeps its `/C`, so on a dark background the content under it shows through the /// Multiply blend in the highlight's colour instead of being darkened into invisibility. @@ -6411,9 +6455,9 @@ endstream endobj let pdf = parse(&Arc::new(pdf), None).expect("parses"); let cache = RenderCache::new(); let settings = InterpreterSettings::default(); - let preview = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(true)).expect("preview renders"); - let fin = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(false)).expect("final renders"); - let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, ReadingColors::default(), req(false)).expect("final renders"); + let preview = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(true)).expect("preview renders"); + let fin = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(false)).expect("final renders"); + let fresh = render_page(&pdf, &RenderCache::new(), &settings, &RenderConfig::default(), req(false)).expect("final renders"); assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); } diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index 094aa6cbf..788f333e6 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -1,12 +1,16 @@ -//! Cached page/image layers: a signature follows each pointer frame without editing the PDF. +//! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp) +//! follows each pointer frame, moved or resized, without editing the PDF. -use egui::{ColorImage, TextureHandle, TextureOptions}; +use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2}; use pdfcraft_engine::{Document, Edit}; -use pdfcraft_render::{RenderPool, RenderRequest}; +use pdfcraft_render::{PageInfo, RenderPool, RenderRequest, Tile}; use crate::QuickTool; use crate::comments::{CommentView, Gesture, PageCx}; +/// The longest side, in pixels, a stamp's own layer is rendered at. +const MAX_STAMP_SIDE: f32 = 2048.0; + #[derive(Default)] pub(crate) struct SignatureDrag { key: Option<(u64, usize, usize)>, @@ -15,10 +19,7 @@ pub(crate) struct SignatureDrag { } struct Layers { - image: TextureHandle, - opacity: f32, - /// The page /Rotate the PDF draws the image turned back by. - turn: i64, + front: Front, renderer: RenderPool, background: Option, tag: Option, @@ -27,6 +28,29 @@ struct Layers { settling: bool, } +/// What is drawn over the page without the stamp. +enum Front { + /// An image signature's own image, sharp at every zoom. + Image { + texture: TextureHandle, + opacity: f32, + /// The page /Rotate the PDF draws the image turned back by. + turn: i64, + }, + /// Any other stamp, rendered alone. Moving or resizing a stamp only changes its `/Rect`, + /// which its appearance is stretched to fit, so its pixels are stretched with the frame. + Stamp(Box), +} + +struct StampLayer { + renderer: RenderPool, + /// The stamp's rectangle (user space) in the document it is rendered from. + from: [f32; 4], + tag: Option, + /// The rendered stamp: its tag, and the view-space box its pixels cover. + pixels: Option<(u64, TextureHandle, [f32; 4])>, +} + impl SignatureDrag { pub fn prepare(&mut self, ctx: &egui::Context, doc: &Document, selected: Option<(usize, usize)>, scale: f32) { let key = selected.map(|(p, i)| (doc.edit_generation(), p, i)); @@ -35,38 +59,35 @@ impl SignatureDrag { self.aspect_ratio = None; self.layers = None; if let Some((_, page, index)) = key - && doc.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked) - && let Ok(Some(preview)) = doc.image_signature_preview(page, index) + && let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked) { - let [width, height] = preview.image.size(); - let ratio = width as f32 / height as f32; - // As displayed, the image is turned by the page's rotation less the turn it is drawn back by. - let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn; - self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() }); - self.layers = Some(Layers { - opacity: preview.opacity, - turn: preview.turn, - image: ctx.load_texture( + if let Ok(Some(preview)) = doc.image_signature_preview(page, index) { + let [width, height] = preview.image.size(); + let ratio = width as f32 / height as f32; + // As displayed, the image is turned by the page's rotation less the turn it is drawn back by. + let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn; + self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() }); + let texture = ctx.load_texture( "signature-drag-image", ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()), TextureOptions::LINEAR, - ), - renderer: preview.renderer(), - background: None, - tag: None, - received_tag: None, - settling: false, - }); + ); + let front = Front::Image { texture, opacity: preview.opacity, turn: preview.turn }; + self.layers = Some(Layers::new(front, preview.renderer())); + } else if let Ok(Some(preview)) = doc.stamp_preview(page, index) { + let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None }; + self.layers = Some(Layers::new(Front::Stamp(Box::new(stamp)), preview.background_renderer())); + } } } let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return }; - // The image stays sharp at every zoom. The background matches the page view up to one - // whole-page texture; past that the view tiles and this preview stays at that cap. + // The background matches the page view up to one whole-page texture; past that the view + // tiles and this preview stays at that cap. let Some(p) = doc.info.pages.get(page) else { return }; - let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); - let tag = (scale * 1000.0) as u64; + let background_scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); + let tag = (background_scale * 1000.0) as u64; if layers.tag != Some(tag) { - layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, preview: true, ..Default::default() }]); + layers.renderer.set_queue(vec![RenderRequest { page, scale: background_scale, tag, preview: true, ..Default::default() }]); layers.tag = Some(tag); } if let Some(out) = layers.renderer.try_recv() @@ -85,7 +106,34 @@ impl SignatureDrag { return; } } - if layers.received_tag != Some(tag) { + let mut pending = layers.received_tag != Some(tag); + // The stamp itself is small: it stays sharp at every zoom. + if let Front::Stamp(stamp) = &mut layers.front { + let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else { + // Nothing to lay over the page: the frame alone follows the pointer, as before. + self.layers = None; + return; + }; + let tag = (scale * 1000.0) as u64; + if stamp.tag != Some(tag) { + stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); + stamp.tag = Some(tag); + } + if let Some(out) = stamp.renderer.try_recv() + && out.request.tag == tag + { + let (Some(t), None) = (out.request.tile, &out.error) else { + self.layers = None; + return; + }; + let s = out.request.scale; + let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } + pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag); + } + if pending { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } @@ -100,7 +148,7 @@ impl SignatureDrag { self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio) } - /// Movement/resizing only change this signature's geometry, so its background is reusable. + /// Movement/resizing only change this stamp's geometry, so its layers are reusable. pub fn committed(&mut self, edit: &Edit, generation: u64) { if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit && self.contains(*page, *index) @@ -133,18 +181,66 @@ impl SignatureDrag { return; } let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return }; + if let Front::Stamp(stamp) = &layers.front + && stamp.pixels.is_none() + { + return; + } let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift)); let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending); - let rect = cx.rect_to_user(rect); let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); - cx.xf.paint_user_image( - &painter, - layers.image.id(), - p, - rect, - layers.turn, - egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8), - ); + match &layers.front { + Front::Image { texture, opacity, turn } => cx.xf.paint_user_image( + &painter, + texture.id(), + p, + cx.rect_to_user(rect), + *turn, + egui::Color32::from_white_alpha((opacity * 255.0).round() as u8), + ), + Front::Stamp(stamp) => { + let Some((_, texture, area)) = &stamp.pixels else { return }; + // Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`. + let from = cx.xf.user_rect(cx.info, page, stamp.from); + if from.width() < 0.01 || from.height() < 0.01 { + return; + } + let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); + let mut mesh = egui::Mesh::with_texture(texture.id()); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); + let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); + let pos = rect.min + (at - from.min) * stretch; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } + } } } + +impl Layers { + fn new(front: Front, renderer: RenderPool) -> Self { + Self { front, renderer, background: None, tag: None, received_tag: None, settling: false } + } +} + +/// The scale and tile of the page raster that hold a stamp at `rect` (user space), a pixel to +/// spare around it for its anti-aliased edges: at `scale`, or less to keep it within +/// [`MAX_STAMP_SIDE`]. `None` for a page or stamp that has no finite box. +fn stamp_tile(p: &PageInfo, rect: [f32; 4], scale: f32) -> Option<(f32, Tile)> { + let [a, b] = [p.user_to_view(rect[0], rect[1]), p.user_to_view(rect[2], rect[3])]; + let (x0, y0, x1, y1) = (a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])); + let scale = scale.min(MAX_STAMP_SIDE / (x1 - x0).max(y1 - y0).max(1.0)); + let (width, height) = ((p.width * scale).ceil(), (p.height * scale).ceil()); + if ![scale, x0, y0, x1, y1, width, height].iter().all(|v| v.is_finite()) || scale <= 0.0 { + return None; + } + // Float to integer casts saturate, and each edge stays on the page. + let (left, top) = (((x0 * scale).floor() - 1.0).max(0.0).min(width), ((y0 * scale).floor() - 1.0).max(0.0).min(height)); + let (right, bottom) = (((x1 * scale).ceil() + 1.0).min(width).max(left + 1.0), ((y1 * scale).ceil() + 1.0).min(height).max(top + 1.0)); + Some((scale, Tile { x: left as u32, y: top as u32, w: (right - left) as u32, h: (bottom - top) as u32 })) +} diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 27fd6dd8a..9b5d0b952 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1242,6 +1242,74 @@ fn drawn_signatures_resize_from_their_corners() { assert_rect(rects(&h)[0], now); } +/// Whether black ink is drawn within 3 pixels of `p`. +fn ink_near(pixels: &image::RgbaImage, p: Pos2) -> bool { + (-3..=3).any(|dy| (-3..=3).any(|dx| pixels.get_pixel((p.x as i32 + dx) as u32, (p.y as i32 + dy) as u32).0[..3].iter().all(|v| *v < 60))) +} + +/// A Fill & Sign line, like every stamp, is stretched with its frame while a handle is dragged: +/// it grows and shrinks with the pointer instead of keeping its old length until the release. +#[test] +fn lines_grow_and_shrink_with_their_handles_before_release() { + let _gpu = gpu(); + for (document_rotation, view_rotation) in [(0, 0), (90, 0), (0, 90), (90, 270)] { + let rot = (document_rotation, view_rotation); + let mut h = harness(); + if document_rotation != 0 { + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: document_rotation }); + } + while h.state().views[0].rotation != view_rotation { + h.state_mut().views[0].rotate_view(true); + } + h.run_steps(3); + assert!(h.state_mut().execute("sign.fill.line")); + click(&mut h, 100.0, 250.0); + assert_rect(rects(&h)[0], [82.0, 248.0, 118.0, 252.0]); + // In line mode a click on the line picks it up, with its handles. + click(&mut h, 100.0, 250.0); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{rot:?}"); + let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation(); + + // The handle at the line's end, dragged out to 160. + h.drag_at(at(&h, 118.0, 250.0)); + h.run_steps(1); + h.hover_at(at(&h, 160.0, 250.0)); + // Wait only for the one-time layers. Later pointer frames are immediate. + let mut grown = h.render().unwrap(); + for _ in 0..50 { + if ink_near(&grown, at(&h, 150.0, 250.0)) { + break; + } + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(10)); + grown = h.render().unwrap(); + } + assert!(ink_near(&grown, at(&h, 150.0, 250.0)), "{rot:?}: the line grows with the handle before release"); + assert!(ink_near(&grown, at(&h, 90.0, 250.0)), "{rot:?}: from where it starts"); + + // Back in, shorter than it was, still without letting go. + h.hover_at(at(&h, 100.0, 250.0)); + h.run_steps(2); + let shrunk = h.render().unwrap(); + assert!(ink_near(&shrunk, at(&h, 90.0, 250.0)), "{rot:?}: the line still starts where it did"); + for x in [110.0, 150.0] { + assert!(!ink_near(&shrunk, at(&h, x, 250.0)), "{rot:?}: the line shrinks with the handle before release (ink at {x})"); + } + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!((doc.edit_generation(), doc.can_undo()), (generation, Some("Add line")), "{rot:?}: pointer frames never edit the document"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + grown.save(format!("{dir}/line-live-grow-{document_rotation}-{view_rotation}.png")).unwrap(); + shrunk.save(format!("{dir}/line-live-shrink-{document_rotation}-{view_rotation}.png")).unwrap(); + } + h.drop_at(at(&h, 100.0, 250.0)); + h.run_steps(3); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Resize comment"), "{rot:?}"); + let [now] = rects(&h)[..] else { panic!("{rot:?}: still one line: {:?}", rects(&h)) }; + assert!(now.iter().zip([82.0, 248.0, 100.0, 252.0]).all(|(a, b)| (a - b).abs() < 0.01), "{rot:?}: {now:?}"); + } +} + /// A signature placed over a form field is picked up by the Select tool: the field under it /// doesn't take the press. Away from the signature, a click still fills the field. #[test] From 929455ad5b14677c56a53d224a44a10a98497e99 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:20:56 +0300 Subject: [PATCH 07/17] M3: fix multiline annotation hover bounds and paint order (#3541) --- ROADMAP.md | 3 + crates/ui-egui/src/canvas.rs | 9 +- crates/ui-egui/src/comments.rs | 30 ++++--- crates/ui-egui/tests/comments.rs | 137 +++++++++++++++++++++++++++++++ docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- 6 files changed, 163 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..670f14e68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and paint order; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -172,6 +173,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a28..8bb2bf4bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3511,12 +3511,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // freehand, where it would cover the next stroke (#429). let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); let gesturing = view.comments.gesture.is_some() || freehand; - for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) { - let sr = xf.user_rect(info, i, a.rect); + // Match selection's per-line geometry and paint order: a newer highlight's + // comment must not be shadowed by an older one's enclosing rectangle. + for a in info.annotations.iter().rev().filter(|a| a.page == i && !gesturing) { let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); - if sr.contains(p) - && hover_text.is_none() + if hover_text.is_none() && !hidden + && pcx.screen_rects(a).any(|r| r.contains(p)) && let Some(text) = annotation_hover_text(a) { hover_text = Some((p, text)); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 7fd9b7a62..eca50fa01 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -588,21 +588,19 @@ impl PageCx<'_> { } /// Where a comment is on screen: one rectangle, or one per marked line for text markup. - fn screen_rects(&self, a: &Annotation) -> Vec { - if is_markup(&a.subtype) && !a.quads.is_empty() { - a.quads - .iter() - .map(|q| { - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max)); - let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max)); - self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1]) - }) - .collect() - } else { - vec![self.screen_rect(a)] - } + pub(crate) fn screen_rects<'b>(&'b self, a: &'b Annotation) -> impl Iterator + 'b { + let quads = if is_markup(&a.subtype) { a.quads.as_slice() } else { &[] }; + let fallback = quads.is_empty().then(|| self.screen_rect(a)); + quads + .iter() + .map(|q| { + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max)); + let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max)); + self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1]) + }) + .chain(fallback) } /// A redaction mark's areas: one per marked quad, or its rectangle. @@ -624,7 +622,7 @@ impl PageCx<'_> { /// The topmost comment under `p`. fn hit(&self, p: Pos2) -> Option<&Annotation> { - self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last() + self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).last() } pub(crate) fn get(&self, index: usize) -> Option<&Annotation> { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 01166a41d..a91b0d541 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1457,6 +1457,143 @@ fn preference_hides_author_only_highlight_hover_previews() { assert!(h.query_by_label("Ada").is_none(), "author-only hover must be suppressed"); } +/// Contributor-original synthetic markup, with the first highlight ending on the next line. +fn markup_hover_fixture(subtype: &str, rotation: u16, extra: &str, refs: &str) -> Vec { + let content = "BT /F1 14 Tf 20 150 Td (Before the marked words) Tj 0 -40 Td (Marked words then other words) Tj ET\n"; + String::from_utf8_lossy(TEXT_FIXTURE) + .replace("/Contents 5 0 R /Resources", &format!("/Contents 5 0 R /Rotate {rotation} /Annots [8 0 R {refs}] /Resources")) + .replace("/Length 55", &format!("/Length {}", content.len())) + .replace("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET\n", content) + .replace( + "trailer", + &format!( + "8 0 obj << /Type /Annot /Subtype /{subtype} /Rect [20 105 250 165] \ + /QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105] \ + /C [1 1 0] /T (Ada) /Contents (First multiline comment) >> endobj\n{extra}\ntrailer" + ), + ) + .into_bytes() +} + +fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, PdfCraftApp> { + // Give the fixture a complete xref so byte comparisons do not measure repair-on-save. + let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(pdf)).expect("synthetic fixture parses"); + let pdf = pdfcraft_cos::write_full(&doc, &Default::default()).expect("writes complete fixture"); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.views[0].rotation = view_rotation; + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +/// Includes the document's crop/rotation and the independent Rotate View transform. +fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { + let p = { + let app = h.state(); + let v = &app.views[0]; + let info = &app.session.get(v.id).unwrap().info; + let page = &info.pages[0]; + let xf = pdfcraft_ui_egui::canvas::PageXform { + rect: v.page_screen_rect(0).expect("page on screen"), + rot: v.rotation, + pw: page.width, + ph: page.height, + }; + xf.user_rect(info, 0, [x, y, x, y]).center() + }; + h.hover_at(p); + h.run_steps(3); +} + +#[test] +fn multiline_markup_hover_stays_on_marked_lines() { + // #3541: /Rect encloses both lines, including text before/after the selection and the gap. + for subtype in ["Highlight", "Underline", "StrikeOut", "Squiggly"] { + for (page_rotation, view_rotation) in [(0, 0), (90, 0), (180, 0), (270, 0), (0, 90), (0, 180), (0, 270), (90, 270)] { + let pdf = markup_hover_fixture(subtype, page_rotation, "", ""); + let mut h = markup_hover_harness(pdf, view_rotation); + let original = h.state().session.get(h.state().views[0].id).unwrap().saved_bytes(); + for (x, y, marked) in + [(160.0, 155.0, true), (60.0, 115.0, true), (60.0, 155.0, false), (180.0, 115.0, false), (60.0, 135.0, false), (60.0, 75.0, false)] + { + hover_markup_at(&mut h, x, y); + assert_eq!( + h.query_by_label_contains("First multiline comment").is_some(), + marked, + "{subtype}, page/view rotation {page_rotation}/{view_rotation}, user point ({x}, {y})" + ); + } + let app = h.state(); + let id = app.views[0].id; + let doc = app.session.get(id).unwrap(); + assert!(!doc.dirty, "hovering never edits the document"); + assert_eq!(doc.can_undo(), None); + assert_eq!(app.session.save_bytes(id).unwrap().as_slice(), original.as_slice()); + } + } +} + +#[test] +fn neighboring_markup_hover_changes_to_its_own_comment() { + // #3620: the second highlight is in the first one's /Rect, but outside its marked lines. + let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [160 105 240 125] \ + /QuadPoints [160 125 240 125 160 105 240 105] /Contents (Neighbor comment) >> endobj"; + let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0); + for (x, y, first) in [(160.0, 155.0, true), (180.0, 115.0, false), (60.0, 115.0, true)] { + hover_markup_at(&mut h, x, y); + assert_eq!(h.query_by_label_contains("First multiline comment").is_some(), first); + assert_eq!(h.query_by_label_contains("Neighbor comment").is_some(), !first); + } +} + +#[test] +fn overlapping_markup_hover_shows_the_topmost_comment() { + // A later annotation can also cover a genuinely marked part of the older highlight. + let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 165] \ + /QuadPoints [150 165 200 165 150 145 200 145] /Contents (Topmost comment) >> endobj"; + let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0); + hover_markup_at(&mut h, 170.0, 155.0); + h.get_by_label_contains("Topmost comment"); + assert!(h.query_by_label_contains("First multiline comment").is_none()); + hover_markup_at(&mut h, 230.0, 155.0); + h.get_by_label_contains("First multiline comment"); + assert!(h.query_by_label_contains("Topmost comment").is_none()); +} + +#[test] +fn markup_hover_skips_popup_containers() { + // /Popup holds the parent's comment window; it is not a second comment in paint order. + let popup = "9 0 obj << /Type /Annot /Subtype /Popup /Parent 8 0 R /Rect [150 145 200 165] >> endobj"; + let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, popup, "9 0 R"), 0); + hover_markup_at(&mut h, 170.0, 155.0); + h.get_by_label_contains("First multiline comment"); + assert!(h.query_by_label("Popup").is_none()); +} + +#[test] +fn markup_hover_without_quads_keeps_the_rectangle_fallback() { + for quads in ["", "/QuadPoints [20 105 250 165]"] { + let pdf = markup_hover_fixture("Highlight", 0, "", ""); + let pdf = String::from_utf8(pdf) + .unwrap() + .replace("/QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105]", quads) + .into_bytes(); + let mut h = markup_hover_harness(pdf, 0); + assert!(comments(&h)[0].quads.is_empty()); + hover_markup_at(&mut h, 180.0, 115.0); + h.get_by_label_contains("First multiline comment"); + hover_markup_at(&mut h, 270.0, 115.0); + assert!(h.query_by_label_contains("First multiline comment").is_none()); + } +} + #[test] fn the_squiggly_tool_marks_selected_text() { let mut h = harness(|_| {}); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9..2d2918057 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and paint order; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -114,7 +115,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and paint order (#3541/#3620); synthetic rotation, overlap and unchanged-document regressions. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..1d9c4d9f5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1190,8 +1190,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"] -notes = "a popup with the author and the comment text, as Acrobat shows" +evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/comments.rs::preference_hides_author_only_highlight_hover_previews"] +notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620). Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] id = "view.page-size-hover" From 941fbe7cda4be03ab4f48fd45161c38149c818c7 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:58:22 +0300 Subject: [PATCH 08/17] M3: choose overlapping comment hover by annotation index (#3620) --- ROADMAP.md | 4 +++- crates/ui-egui/src/canvas.rs | 27 ++++++++++++++++----------- crates/ui-egui/tests/comments.rs | 19 +++++++++++++++++++ docs/roadmap.md | 5 +++-- parity/acrobat-features.toml | 4 ++-- 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 670f14e68..b18b4fe76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and paint order; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -173,6 +173,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged. + - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8bb2bf4bf..75f65986d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3511,17 +3511,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // freehand, where it would cover the next stroke (#429). let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); let gesturing = view.comments.gesture.is_some() || freehand; - // Match selection's per-line geometry and paint order: a newer highlight's - // comment must not be shadowed by an older one's enclosing rectangle. - for a in info.annotations.iter().rev().filter(|a| a.page == i && !gesturing) { - let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); - if hover_text.is_none() - && !hidden - && pcx.screen_rects(a).any(|r| r.contains(p)) - && let Some(text) = annotation_hover_text(a) - { - hover_text = Some((p, text)); - } + // Inspection sorts annotations spatially, but the renderer paints in /Annots + // order. Keep the shared list intact and choose the last eligible raw index. + if hover_text.is_none() + && !gesturing + && let Some((_, text)) = info + .annotations + .iter() + .filter(|a| a.page == i) + .filter(|a| { + !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) + }) + .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) + .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) + .max_by_key(|(index, _)| *index) + { + hover_text = Some((p, text)); } } for (mp, mr, mc) in &view.compare_marks { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index a91b0d541..00f08ba89 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1567,6 +1567,25 @@ fn overlapping_markup_hover_shows_the_topmost_comment() { assert!(h.query_by_label_contains("Topmost comment").is_none()); } +#[test] +fn overlapping_markup_hover_uses_annotation_index_with_different_top_edges() { + // Inspection sorts by position, while /Annots determines paint order. The later + // highlight wins the overlap whether its upper edge is above or below the first's. + for top in [175, 160] { + let other = format!( + "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 {top}] \ + /QuadPoints [150 {top} 200 {top} 150 145 200 145] /Contents (Topmost comment) >> endobj" + ); + let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, &other, "9 0 R"), 0); + hover_markup_at(&mut h, 170.0, 155.0); + h.get_by_label_contains("Topmost comment"); + assert!(h.query_by_label_contains("First multiline comment").is_none(), "later annotation with top {top} owns the overlap"); + hover_markup_at(&mut h, 230.0, 155.0); + h.get_by_label_contains("First multiline comment"); + assert!(h.query_by_label_contains("Topmost comment").is_none()); + } +} + #[test] fn markup_hover_skips_popup_containers() { // /Popup holds the parent's comment window; it is not a second comment in paint order. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d2918057..b830b57b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and paint order; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -115,7 +115,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and paint order (#3541/#3620); synthetic rotation, overlap and unchanged-document regressions. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -201,6 +201,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | M3/#3620: overlapping comment hover follows original annotation indices independently of spatial inspection sorting; a synthetic differing-upper-edge regression covers paint order. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1d9c4d9f5..bc046ca0f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1190,8 +1190,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/comments.rs::preference_hides_author_only_highlight_hover_previews"] -notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620). Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." +evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/comments.rs::preference_hides_author_only_highlight_hover_previews"] +notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] id = "view.page-size-hover" From 12c66f7113a34b62e8b401e2e6c07e2543755bb1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:44:42 +0200 Subject: [PATCH 09/17] Keep zero annotation flags through XFDF export/import Found while testing crates/xfdf/src/lib.rs:313-317 (xfdf_annots) and :~1010 (annot_from_xml, `unwrap_or("print")`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/xfdf/src/lib.rs | 4 +--- crates/xfdf/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 35bc78154..ca196fcb8 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -312,9 +312,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { } let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect(); - if !fl.is_empty() { - let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); - } + let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) { let _ = write!(attrs, " color=\"{c}\""); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index c33c115e7..93f23ca8c 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -308,6 +308,37 @@ fn xfdf_round_trips_comments_and_values() { assert!(!only.contains("")); } +#[test] +fn xfdf_round_trip_keeps_zero_flags() { + let square = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + let annots = doc.get(page).as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a)).unwrap(); + annots + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .find(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Square")) + .unwrap() + }; + let mut src = blank(); + let shape = Shape::Rectangle { rect: [10.0, 10.0, 100.0, 60.0] }; + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "A".into() }, + &Meta { id: "s1".into(), date: None }, + ) + .unwrap(); + let annot = square(&src); + src.update_dict(annot, |d| d.set(b"F".to_vec(), Object::Int(0))).unwrap(); + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + let mut dst = blank(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + let imported = square(&dst); + let flags = dst.get(imported).as_dict().unwrap().get(b"F").and_then(|f| dst.resolve(f).as_int()); + assert_eq!(flags, Some(0), "flags after round trip: {xfdf}"); +} + #[test] fn fdf_round_trips_values_and_comments() { let src = filled(); From 4cd646811ad579eb9a7a4f007710918e33026e81 Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:58:51 +0200 Subject: [PATCH 10/17] M5: drag a line's ends (and a polygon's corners) to change its length and angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once drawn, a line or arrow could be moved but not changed: selected, it showed a frame and no handles, so a wrong length or angle meant drawing it again. A selected line, connected lines, polygon or cloud now has a handle on each end or corner instead of the frame; dragging one moves that point (Shift holds it to 45° from the point before), the new shape is drawn while dragging, and the release is one undo step. The drawing tools keep drawing from an end (a new line starts there); the Select and Fill & Sign tools pick the handles up. pdfcraft_annot::set_points sets /L or /Vertices, lets /Rect follow with room for the width and arrowheads (the rule a new comment is drawn with) and draws the appearance anew; measurements keep the points they measured, locked comments their shape. Edit::ReshapeAnnotation ("Reshape comment"), comment_edit `points`; the comment list (Summary, DocInfo, comment_list) carries a line's ends and a polygon's corners. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/annot/src/lib.rs | 52 ++++++++++++ crates/annot/src/tests.rs | 47 +++++++++++ crates/automation/src/comments.rs | 28 ++++++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 18 ++++ crates/engine/src/lib.rs | 13 +++ crates/render/src/inspect.rs | 14 ++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/comments.rs | 113 +++++++++++++++++++++++--- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/tests/comments.rs | 52 ++++++++++++ parity/acrobat-features.toml | 12 +++ 35 files changed, 385 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..3dc22e4a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..7c29e9f6d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1774,6 +1774,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m apply_text_box(doc, r, rect, meta, false) } +/// 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. +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)?; + let d = annot_dict(doc, r); + if d.contains(b"Measure") { + return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); + } + if points.len() > MAX_POINTS { + return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); + } + let style = Style { width: border_width_of(&d), ..Style::default() }; + let ends = line_endings_of(&d).unwrap_or_default(); + let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), + ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), + ("Polygon", _) => { + let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); + (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") + } + (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), + }; + // Checks the points too: finite, and far enough apart to draw. + let rect = rect_for(&shape, &style)?; + let before = d; + doc.update_dict(r, |d| { + d.set(key.to_vec(), num_array(&points.concat())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half /// the line width and a little more) stays the same on each side, so the line isn't clipped. fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { @@ -2082,6 +2124,9 @@ pub struct Summary { pub intent: Option, /// A Fill & Sign mark ([`is_fill_sign`]). pub fill_sign: 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. + pub points: Vec<[f32; 2]>, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -2136,6 +2181,12 @@ pub fn summaries(doc: &Document) -> Vec { } } let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Keep in step with `pdfcraft_render::inspect`. + 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(), + _ => Vec::new(), + }; let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); out.push(Summary { page, @@ -2155,6 +2206,7 @@ pub fn summaries(doc: &Document) -> Vec { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), fill_sign: is_fill_sign(doc, d), + points, }); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb106..810c21c8b 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1382,3 +1382,50 @@ fn free_text_properties_show_legacy_or_absent_border_width() { .unwrap(); assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0)); } + +/// A line's ends and the corners of connected lines and polygons can be moved after drawing: +/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is +/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape. +#[test] +fn lines_and_polygons_are_reshaped_by_their_points() { + let mut doc = fixture(); + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }), + &meta("l"), + ) + .unwrap(); + let before = ap_content(&doc, &list(&doc, 0)[l]); + set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[l]; + let ends: Vec = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]); + let r = rect(d); + assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}"); + assert!(r[2] - r[0] < 40.0, "and no more: {r:?}"); + assert_ne!(ap_content(&doc2, d), before, "drawn anew"); + assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]); + // Two ends exactly, far enough apart. + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + // A polygon's corner. + let g = add_annotation( + &mut doc, + &new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }), + &meta("g"), + ) + .unwrap(); + set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[g]; + let v: Vec = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(v.len(), 8); + assert!(rect(d)[2] > 450.0); + // Locked: refused, and nothing changes. + set_locked(&mut doc, 0, l, true).unwrap(); + assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err()); + // Other comments have no points to move. + let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap(); + assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_)))); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..e1e0b15a7 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -174,7 +174,7 @@ impl Automation { let all = self.comments(a)?; let view = |c: &Annotation| -> Value { let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))]; - json!({ + let mut v = json!({ "id": c.name, "page": c.page + 1, "index": c.index + 1, @@ -184,7 +184,14 @@ impl Automation { "modified": c.modified, "rect": rect_to_view(p, c.rect), "color": c.color.map(hex), - }) + }); + // A line's ends, or the corners of connected lines, a polygon or a cloud, as + // comment_edit's `points` takes them. + if !c.points.is_empty() { + let round = |v: f32| (v * 100.0).round() / 100.0; + v["points"] = json!(c.points.iter().map(|q| p.user_to_view(q[0], q[1]).map(round)).collect::>()); + } + v }; let roots: Vec = all .iter() @@ -632,8 +639,23 @@ impl Automation { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] }); } + if let Some(pts) = a.get("points") { + let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into()); + let points = pts + .as_array() + .ok_or_else(wrong)? + .iter() + .map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::>()).as_deref() { + Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + _ => Err(wrong()), + }) + .collect::>>()?; + edits.push(Edit::ReshapeAnnotation { page, index, points }); + } if edits.is_empty() { - return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into())); + return Err(ToolError::InvalidArgs( + "nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(), + )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; self.apply(a, edit) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eeb..acfbd2f9d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -784,7 +784,7 @@ pub fn tools() -> Vec { 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, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). 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, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), 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( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -794,6 +794,7 @@ pub fn tools() -> Vec { "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." }, })), &["doc"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f196..d2de626a6 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6855,3 +6855,21 @@ fn text_box_and_callout_fill_and_border_through_tools() { assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None); } } + +/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and +/// `comment_list` reports the points it takes. +#[test] +fn comments_are_reshaped_through_the_tool() { + let dir = workdir("reshape"); + 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": "line", "from": [20, 40], "to": [120, 40] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]])); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]])); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment"); + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends"); + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c..0682e4ab7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -262,6 +262,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ReplaceText { .. } | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -1435,6 +1436,13 @@ pub enum Edit { dx: f64, dy: f64, }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, @@ -1875,6 +1883,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Lock comment", "Unlock comment", "Move comment", + "Reshape comment", "Resize comment", "Change comment properties", "Fill in {name}", @@ -1999,6 +2008,7 @@ impl Edit { Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { Label::of("Change comment properties") @@ -2154,6 +2164,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::EraseInk { .. } | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -2419,6 +2430,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; @@ -2748,6 +2760,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, } impl Annotation { @@ -1149,6 +1152,16 @@ impl<'a> Inspector<'a> { }; let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); let fill_sign = self.is_fill_sign(d, &subtype); + // Keep in step with `pdfcraft_annot::summaries`. + let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; + let points = match d.get(key).map(|o| self.resolve(o)) { + Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { + let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); + if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } + } + _ => Vec::new(), + }; info.annotations.push(Annotation { page, subtype, @@ -1167,6 +1180,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + points, }); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a28..2677356f6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5392,6 +5392,7 @@ mod tests { locked: false, intent: None, fill_sign, + points: Vec::new(), } } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 7fd9b7a62..8a5679f06 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -385,6 +385,9 @@ pub enum Gesture { Move { page: usize, index: usize, from: Pos2 }, /// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move. Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option }, + /// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points` + /// before the drag, in user space) to where the pointer is. + Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> }, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -681,6 +684,42 @@ fn resizable(a: &Annotation) -> bool { matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout") } +/// 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). +fn reshapable(a: &Annotation) -> bool { + matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon") + && !a.points.is_empty() + && !a.locked + && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) +} + +/// Whether `tool` picks up 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. +fn grabs_points(tool: QuickTool) -> bool { + !matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points()) +} + +/// The point of `a` (an end or a corner) whose handle is at `p` on screen. +fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option { + a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0) +} + +/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point +/// before it (a line's other end; the first corner turns about the second). +pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { + let mut out = points.to_vec(); + let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) }; + let to = match anchor { + Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to), + _ => to, + }; + if let Some(p) = out.get_mut(i) { + *p = to; + } + out +} + const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]; fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 { @@ -993,7 +1032,7 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool return false; } let cv = &view.comments; - if matches!(cv.gesture, Some(Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(cv.gesture, Some(Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { return true; } // A polygon being clicked out keeps its clicks, even near an old shape's handle. @@ -1009,7 +1048,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati if cx.hidden { return false; } - if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) + { return true; } let Some(p) = press_point(ui, cx) else { return false }; @@ -1025,12 +1065,10 @@ fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation) -> bool) -> bool { - view.comments - .selected - .filter(|(page, _)| *page == cx.page) - .and_then(|(_, i)| cx.get(i)) - .filter(|a| pick(a) && cx.allowed && resizable(a)) - .is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0)) + view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed).is_some_and(|a| { + (resizable(a) && HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0)) + || (grabs_points(cx.tool) && reshapable(a) && point_handle(cx, a, p).is_some()) + }) } fn clamp_to(r: Rect, p: Pos2) -> Pos2 { @@ -1055,9 +1093,14 @@ fn select_input( let r = cx.screen_rect(a); HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0) }; + // Ends and corners of a selected line, connected lines, polygon or cloud. + let point_at = + |p: Pos2| -> Option { selected.filter(|a| cx.allowed && grabs_points(cx.tool) && reshapable(a)).and_then(|a| point_handle(cx, a, p)) }; let mut consumed = false; if let Some(p) = pointer { - if let Some(h) = handle_at(p) { + if point_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if let Some(h) = handle_at(p) { ui.ctx().set_cursor_icon(match h { (0, _) => egui::CursorIcon::ResizeVertical, (_, 0) => egui::CursorIcon::ResizeHorizontal, @@ -1073,7 +1116,11 @@ fn select_input( && pressed_here && let Some(o) = origin { - if let (Some(h), Some(a)) = (handle_at(o), selected) { + if let (Some(point), Some(a)) = (point_at(o), selected) { + let points = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); + cv.gesture = Some(Gesture::Reshape { page: cx.page, index: a.index, point, points }); + consumed = true; + } else if let (Some(h), Some(a)) = (handle_at(o), selected) { // On screen, the image is also turned by the view's rotation. let turned = !cx.xf.rot.is_multiple_of(180); let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio }); @@ -1095,7 +1142,7 @@ fn select_input( consumed = true; } } - if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { consumed = true; } if resp.drag_stopped() @@ -1122,6 +1169,14 @@ fn select_input( } } } + Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { + cv.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) }); + } + } _ => {} } } @@ -1232,6 +1287,9 @@ 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 && grabs_points(cx.tool) && reshapable(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); painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); if cx.allowed && resizable(a) { @@ -1244,6 +1302,39 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> paint_gesture(painter, cx, view); } +/// 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. +fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option, shift: bool) { + let cv = &view.comments; + let mut points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); + let mut changed = false; + if let Some(Gesture::Reshape { page, index, point, points: from }) = &cv.gesture + && (*page, *index) == (cx.page, a.index) + && let Some(p) = pointer + { + points = reshaped(from, *point, cx.to_user(p), shift); + changed = true; + } else if let Some(Edit::ReshapeAnnotation { page, index, points: to }) = &view.pending_edit + && (*page, *index) == (cx.page, a.index) + { + points.clone_from(to); + changed = true; + } + let screen: Vec = 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) + }); + } + for c in screen { + painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE)); + } + paint_gesture(painter, cx, view); +} + fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let now = painter.ctx().input(|i| i.time); for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b..5e766d65b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -969,6 +969,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::ReplyToAnnotation { page, .. } | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } + | Edit::ReshapeAnnotation { page, .. } | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e1..8f5a91ba8 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,3 +2711,5 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment إعادة تشكيل التعليق diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae..28614a008 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,3 +2397,5 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Промяна на формата на коментара diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 993632552..82698a497 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,3 +2508,5 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Změnit tvar komentáře diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d9207eb9..bc685e0df 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,3 +2435,5 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Kommentar umformen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06fe2b540..4b5a5ea9b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,3 +2440,5 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Αλλαγή σχήματος σχολίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992..71abbaabe 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2574,3 +2574,5 @@ animation effects Off Desactivados # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Cambiar la forma del comentario diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9bdcf0bc8..5758900f2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,3 +2434,5 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Modifier la forme du commentaire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d537677c7..6966f9800 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,3 +2433,5 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Megjegyzés alakjának módosítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 35cba7a41..bc1d6d132 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,3 +2646,5 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Modifica la forma del commento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e845a736d..16d795186 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,3 +2434,5 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment コメントの形状を変更 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1b..4a9fe2379 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,3 +2432,5 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Пікір пішінін өзгерту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc3304..2899684a6 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,3 +2685,5 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Keisti komentaro formą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c432a6a92..b6539c848 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,3 +2410,5 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Zmień kształt komentarza diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 89ff80f9d..3d2b4ef68 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,3 +2448,5 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Alterar a forma do comentário diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 73f21aa0d..9649324d7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,3 +2347,5 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Изменить форму комментария diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc96..1d1a94146 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Spremeni obliko komentarja diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47aee36f8..b24857baa 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,3 +2343,5 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment వ్యాఖ్య ఆకారాన్ని మార్చు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608..ba4f925ab 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,3 +2430,5 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Yorumun şeklini değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7993a0618..ed2a631a8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,3 +2456,5 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Змінити форму коментаря diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6..6200bc1e9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2687,3 +2687,5 @@ key Space Space # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Đổi hình dạng nhận xét diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddecc655..707bd7d36 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,3 +2398,5 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment 更改注释形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649..b1d26f226 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,3 +2384,5 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment 變更註解形狀 diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 01166a41d..c88c873d0 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -552,6 +552,58 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() { assert_eq!(comments(&h).len(), 1); } +/// A line's ends have handles: dragging one with the Select tool changes the line's length and +/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a +/// new line starts there rather than dragging the one just drawn. +#[test] +fn line_ends_are_dragged_to_change_length_and_angle() { + let mut h = harness(|app| app.set_option("quick", "line").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 100.0)); + let line = comments(&h).into_iter().find(|a| a.subtype == "Line").expect("a line"); + assert_eq!(line.points.len(), 2); + let ends = |h: &Harness<'static, PdfCraftApp>| comments(h).into_iter().find(|a| a.index == line.index).unwrap().points; + // With the line tool, a drag from the end just drawn draws another line. + let first = ends(&h); + drag_pt(&mut h, (first[1][0], first[1][1]), (140.0, 40.0)); + assert_eq!(comments(&h).iter().filter(|a| a.subtype == "Line").count(), 2, "a second line"); + assert_eq!(ends(&h), first, "the first one stays"); + h.state_mut().set_option("quick", "select").unwrap(); + click_pt(&mut h, (90.0, 100.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, line.index))); + // Drag the right end up and further right: longer, and turned. + let [_, end] = [line.points[0], line.points[1]]; + drag_pt(&mut h, (end[0], end[1]), (200.0, 160.0)); + let p = ends(&h); + assert!((p[0][0] - 40.0).abs() < 1.0 && (p[0][1] - 100.0).abs() < 1.0, "the other end stays: {p:?}"); + assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment")); + // Shift: held to the nearest 45° from the other end (39° here). + let a = at(&h, 200.0, 160.0); + let b = at(&h, 150.0, 190.0); + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(3); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); + let p = ends(&h); + let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]); + assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}"); + // Undo puts the line back as it was before the drag. + h.state_mut().undo(); + h.run_steps(2); + let p = ends(&h); + assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..790a461ef 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3959,6 +3959,18 @@ tools = ["comment_delete"] evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" +[[feature]] +id = "comment.reshape" +title = "Change a line's ends and a polygon's corners after drawing" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_edit"] +evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/automation.rs::comments_are_reshaped_through_the_tool"] +notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." + [[feature]] id = "comment.edit-comment-text" title = "Edit comment text" From 15a0b696c51f4a0938190e582d5828ea440c5c85 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Tue, 20 Oct 2026 23:17:21 +0200 Subject: [PATCH 11/17] fix(comments): the eraser's size sets how wide it rubs (#3747) The eraser always rubbed 6 pixels either side and drew a 12-pixel preview, whatever its thickness setting. Both now follow the setting, at three pixels per point, so the default 2 pt keeps today's 12-pixel eraser and the slider's 0.5-12 pt gives 3 to 72 pixels across. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 16 +++++++++++++--- crates/ui-egui/tests/comments.rs | 21 +++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 7fd9b7a62..ee5214fca 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1268,7 +1268,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.add(egui::Shape::line(pts, stroke)); } CommentTool::Eraser => { - painter.add(egui::Shape::line(pts, Stroke::new(12.0, Color32::from_gray(128).gamma_multiply(0.35)))); + // As wide as what it rubs out (see `erase`). + let width = (2.0 * eraser_radius_px(style.width)) as f32; + painter.add(egui::Shape::line(pts, Stroke::new(width, Color32::from_gray(128).gamma_multiply(0.35)))); } CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => { painter.line_segment([pts[0], pts[1]], stroke); @@ -1334,10 +1336,18 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { } } -/// Eraser: rub out drawings along `path` (user space); one undo step for all of them. +/// The eraser's radius on screen, in pixels, from its thickness setting (#3747): three pixels +/// per point, so the default 2 pt keeps the 12-pixel eraser it always had, and the thickness +/// slider's 0.5–12 pt gives 3 to 72 pixels across. A setting that isn't a number is the default. +fn eraser_radius_px(width: f64) -> f64 { + 3.0 * if width.is_finite() { width.clamp(0.5, 12.0) } else { 2.0 } +} + +/// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs +/// as wide on screen as its preview, whatever the zoom. fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64; - let radius = (6.0 / zoom).max(1.0); + let radius = (eraser_radius_px(cx.prefs.style(CommentTool::Eraser).width) / zoom).max(1.0); let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]); let mut hits: Vec = cx .comments() diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 01166a41d..55d699bd4 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1395,6 +1395,27 @@ fn erasing_part_of_a_drawing() { assert!(comments(&h).is_empty(), "{:?}", comments(&h)); } +#[test] +fn the_eraser_rubs_as_wide_as_its_thickness_setting() { + // #3747: the eraser's size changed nothing; it always rubbed 6 pixels either side. + let stroke_near = |width: f64| { + let mut h = harness(|app| app.set_option("quick", "ink").unwrap()); + drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0)); + assert_eq!(comments(&h).len(), 1); + let zoom = h.state().views[0].page_screen_rect(0).expect("page 1 on screen").width() / 300.0; + let app = h.state_mut(); + app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width); + app.set_option("quick", "eraser").unwrap(); + // Along the whole drawing, 20 pixels above it: out of reach of the default eraser + // (6 pixels), within reach of the largest (36). + let y = 60.0 + 20.0 / zoom; + drag_pt(&mut h, (30.0, y), (270.0, y)); + comments(&h).len() + }; + assert_eq!(stroke_near(2.0), 1, "the default eraser passes by"); + assert_eq!(stroke_near(12.0), 0, "the largest rubs the drawing out"); +} + #[test] fn hovering_a_comment_shows_its_author_and_text() { // A square comment by Ada over the first page's text. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..c6a9a0689 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3704,8 +3704,8 @@ milestone = "M5" status = "shipped" commands = ["comment.eraser"] tools = [] -evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"] -notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step" +evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting"] +notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step. Its thickness setting sizes it on screen (3 to 72 pixels across; the default 2 pt is 12), preview included (#3747)" [[feature]] id = "comment.line" From c6f58baa74fe1d8a64e13f74ac94b3624a00d782 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Tue, 20 Oct 2026 23:59:44 +0200 Subject: [PATCH 12/17] fix(comments): the eraser rubs exactly its preview, at any zoom or view rotation From review of #3749: the user-space radius had a 1-unit floor, so at a high zoom the eraser rubbed past its preview; and zoom was read from the screen rectangle's width, which is the page's height when the view is turned a quarter. PageXform::zoom reads the side the page's width is on; the eraser and the drawing preview use it, and the floor is gone. A page with no size on screen erases nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 19 +++++++++++++++++++ crates/ui-egui/src/comments.rs | 11 ++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a28..f66194de1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2121,6 +2121,13 @@ pub struct PageXform { } impl PageXform { + /// Screen pixels per page point. The view's rotation swaps the rectangle's sides, so a page + /// turned a quarter shows its width down the screen. + pub(crate) fn zoom(&self) -> f32 { + let across = if self.rot % 180 == 90 { self.rect.height() } else { self.rect.width() }; + across / self.pw.max(1.0) + } + /// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned /// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it /// reads upright as displayed. @@ -5469,6 +5476,18 @@ mod tests { assert_eq!(badges.len(), 1); } + #[test] + fn page_zoom_reads_the_side_the_page_width_is_on() { + // A 200 x 100 pt page at 2 pixels per point: 400 x 200 on screen, or 200 x 400 turned. + let xf = |rot: u16, size: Vec2| PageXform { rect: Rect::from_min_size(Pos2::ZERO, size), rot, pw: 200.0, ph: 100.0 }; + for rot in [0, 180] { + assert_eq!(xf(rot, vec2(400.0, 200.0)).zoom(), 2.0, "{rot}"); + } + for rot in [90, 270] { + assert_eq!(xf(rot, vec2(200.0, 400.0)).zoom(), 2.0, "{rot}: the width runs down the screen"); + } + } + #[test] fn snapshot_tabs_read_like_their_source_plus_the_suffix() { let snap = Snapshot { kind: SnapshotKind::SignedVersion, revision: 1, from: "report.pdf".into() }; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index ee5214fca..2128d7d56 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1260,7 +1260,7 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, points: &[[f64; 2]]) { { let style = cx.prefs.style(*tool); - let zoom = cx.xf.rect.width() / cx.xf.pw.max(1.0); + let zoom = cx.xf.zoom(); let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color)); let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect(); match tool { @@ -1346,8 +1346,13 @@ fn eraser_radius_px(width: f64) -> f64 { /// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs /// as wide on screen as its preview, whatever the zoom. fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { - let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64; - let radius = (eraser_radius_px(cx.prefs.style(CommentTool::Eraser).width) / zoom).max(1.0); + let zoom = f64::from(cx.xf.zoom()); + // A page with no size on screen has nothing to rub out (and no finite radius). + if !(zoom.is_finite() && zoom > 0.0) { + return None; + } + // Exactly the preview's width: no floor, which at a high zoom rubbed past it. + let radius = eraser_radius_px(cx.prefs.style(CommentTool::Eraser).width) / zoom; let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]); let mut hits: Vec = cx .comments() From 7eb4e5d557c1e972505ca9b842ad8a83e288dcf4 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Wed, 21 Oct 2026 00:20:13 +0200 Subject: [PATCH 13/17] test(comments): the eraser preview is as wide as it rubs while held (#3747) From review of #3749: the hit test alone would pass with a fixed 12-pixel preview. Read the preview stroke from the frame drawn mid-drag: 12 pixels at the default 2 pt, 72 at 12 pt, and gone once released. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/comments.rs | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 55d699bd4..bef43f63d 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1416,6 +1416,48 @@ fn the_eraser_rubs_as_wide_as_its_thickness_setting() { assert_eq!(stroke_near(12.0), 0, "the largest rubs the drawing out"); } +/// The widths of the eraser preview's strokes drawn in the last frame. +fn eraser_preview_widths(h: &Harness<'static, PdfCraftApp>) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + let preview = egui::Color32::from_gray(128).gamma_multiply(0.35); + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(preview) => out.push(p.stroke.width), + _ => {} + } + } + let mut out = Vec::new(); + for clipped in &h.output().shapes { + walk(&clipped.shape, &mut out); + } + out +} + +#[test] +fn the_eraser_preview_is_as_wide_as_it_rubs() { + // #3747: while the eraser is held, its preview stroke is as wide as what it rubs out + // (twice its radius, three pixels per point of thickness): 12 pixels by default, 72 at 12 pt. + for (width, across) in [(2.0, 12.0), (12.0, 72.0)] { + let mut h = harness(move |app| { + app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width); + app.set_option("quick", "eraser").unwrap(); + }); + let (from, to) = (at(&h, 60.0, 100.0), at(&h, 240.0, 100.0)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=3 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + assert_eq!(eraser_preview_widths(&h), [across], "{width} pt: the preview while held"); + h.drop_at(to); + h.run_steps(3); + assert!(eraser_preview_widths(&h).is_empty(), "{width} pt: gone once released"); + } +} + #[test] fn hovering_a_comment_shows_its_author_and_text() { // A square comment by Ada over the first page's text. From a0c106268c394a3d36fe046108fd4b0725500986 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 20 Oct 2026 23:43:07 -0400 Subject: [PATCH 14/17] Fill & Sign marks stay upright on rotated pages (#2392) A checkmark, cross, dot or line placed on a page with /Rotate was drawn in the page's own axes, so on a page turned 180 degrees the checkmark came out upside down, and on a quarter turn it lay on its side. Marks now record the page rotation (PCStampRotate, as rubber stamps do) and their appearance is drawn upright in the displayed box and mapped into /Rect, sharing the stamps' code for it. The mark's box is sized in displayed axes (FillMark::rect_at), used by both the Fill & Sign tool and fill_sign_add, so a line runs across the page as displayed. --- crates/annot/src/appearance.rs | 47 ++++++++++++----------- crates/annot/src/lib.rs | 13 +++++++ crates/automation/src/comments.rs | 3 +- crates/automation/tests/automation.rs | 54 +++++++++++++++++++++++++++ crates/engine/src/tests.rs | 53 ++++++++++++++++++++++++++ crates/ui-egui/src/fill_sign.rs | 3 +- crates/ui-egui/tests/fill_sign.rs | 23 ++++++++++++ 7 files changed, 171 insertions(+), 25 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 3e1dd4a6b..55ed5d353 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -593,7 +593,28 @@ pub fn build(d: &Dict) -> Option { // Only PdfCraft's own Fill & Sign marks are drawn here. let name = d.name(b"Name")?; let col = stroke.unwrap_or([0.0; 3]); - let [x0, y0, x1, y1] = rect; + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; let (w, h) = (x1 - x0, y1 - y0); let lw = w.min(h) * 0.12; match name { @@ -643,28 +664,12 @@ pub fn build(d: &Dict) -> Option { } let kind = crate::StampKind::from_name(other)?; let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res); - if turn != 0 { - // Upright in the stamp's displayed box, then mapped into `/Rect` the way - // the page maps display space into user space. Viewers fit that box to - // `/Rect`; the page rotation then shows the label the right way up. - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); + return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); } } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } } b"FreeText" => { let (text_color, size) = parse_da(d); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..47aabef79 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -125,6 +125,14 @@ impl FillMark { FillMark::Line => "Line", } } + + /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred + /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). + pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { + let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; + [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] + } } /// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). @@ -1081,6 +1089,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"Name".to_vec(), Object::name(mark.name())); border(&mut d); + // Drawn upright for the page as displayed, as a stamp is (#2392). + 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"PCStampRotate".to_vec(), Object::Int(turn)); + } } Shape::Stamp { stamp, by, .. } => { d.set(b"C".to_vec(), rgb(style.color)); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..5fa1d5bb4 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -551,8 +551,7 @@ impl Automation { "line" => FillMark::Line, other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))), }; - let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; - (Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new()) + (Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new()) } }; let style = Style::default_for(&shape); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f196..fe767ce41 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4320,6 +4320,60 @@ fn typed_signatures_stay_upright_on_rotated_pages() { check(&mut a, reopened); } +/// #2392: a Fill & Sign checkmark and line look the same on a turned page as on an unturned one: +/// the checkmark isn't upside down or sideways, and the line runs across as displayed. +#[test] +fn fill_sign_marks_stay_upright_on_rotated_pages() { + let dir = workdir("marks-upright"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "check", "at": [40, 60] })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "line", "at": [120, 60] })); + } + let check = |a: &mut Automation, doc: u64| { + let mut upright = Vec::new(); + for page in 1..=4 { + // 144 dpi: two pixels a point. + let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 144 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let pixels = image::load_from_memory(data).unwrap().to_rgba8(); + let dark: Vec = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect(); + let width = pixels.width(); + // The line, 36 × 4 pt displayed, runs across. + let mut b = [u32::MAX, u32::MAX, 0, 0]; + for (x, y, p) in pixels.enumerate_pixels() { + if x.abs_diff(240) < 60 && y.abs_diff(120) < 60 && p.0[..3].iter().all(|v| *v < 128) { + b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + assert!(b[0] <= b[2], "page {page}: no line drawn"); + assert!(b[2] - b[0] > 60 && b[3] - b[1] < 10, "page {page}: the line should run across: {b:?}"); + if page == 1 { + upright = dark; + continue; + } + // Same pixels as the unturned page where both pages are (pages 2 and 4 are wider). + let (mut differ, mut ink) = (0, 0); + for y in 0..400 { + for x in 0..400 { + let (want, got) = (upright[(y * 400 + x) as usize], dark[(y * width + x) as usize]); + ink += usize::from(want); + differ += usize::from(want != got); + } + } + assert!(ink > 0 && differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page"); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, reopened); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..f53473942 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save: +/// a checkmark's long stroke rises to the right, and a line runs across. +#[test] +fn fill_sign_marks_are_upright_as_displayed_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + // Displayed boxes: a 40 pt checkmark centred at (50, 50), an 80 × 8 pt line at (130, 50). + for (mark, vx, w, h) in [(FillMark::Check, 50.0, 40.0, 40.0), (FillMark::Line, 130.0, 80.0, 8.0)] { + let shape = Shape::Mark { rect: displayed_rect(&info, vx, 50.0, w, h), mark }; + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Test".into(), + }), + ) + .unwrap(); + } + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + // Dark pixels in a displayed box, rendered at scale 2. + let ink = |x0: u32, y0: u32, x1: u32, y1: u32| -> Vec<(u32, u32)> { + (y0 * 2..y1 * 2) + .flat_map(|y| (x0 * 2..x1 * 2).map(move |x| (x, y))) + .filter(|&(x, y)| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128)) + .collect() + }; + let tick = ink(30, 30, 70, 70); + assert!(!tick.is_empty(), "rotation {degrees}: no checkmark drawn"); + let top = tick.iter().min_by_key(|p| p.1).unwrap(); + let bottom = tick.iter().max_by_key(|p| p.1).unwrap(); + // The long stroke ends at the top right; the short one meets it low, left of centre. + assert!(top.0 > bottom.0 + 20, "rotation {degrees}: highest ink {top:?} should be right of the lowest {bottom:?}"); + assert!(bottom.0 < 100 && top.0 > 110, "rotation {degrees}: checkmark {bottom:?} .. {top:?} is not upright"); + let line = ink(85, 40, 175, 60); + assert!(!line.is_empty(), "rotation {degrees}: no line drawn"); + let (x0, x1) = (line.iter().map(|p| p.0).min().unwrap(), line.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (line.iter().map(|p| p.1).min().unwrap(), line.iter().map(|p| p.1).max().unwrap()); + assert!((x1 - x0) > 10 * (y1 - y0 + 1), "rotation {degrees}: line {x0}..{x1} x {y0}..{y1} should run across"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are /// often stored sideways with `/Rotate`), including after save. #[test] diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 794fe0bf1..dfd0b8e2a 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -676,8 +676,7 @@ pub(crate) fn page_input( }, mark => { let mark = mark.mark()?; - let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; - let rect = [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]; + let rect = mark.rect_at(at, rotation); Some(FillAction::Edit(Box::new(new(page, Shape::Mark { rect, mark }, String::new(), author)))) } } diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 27fd6dd8a..c01b1b2b4 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -101,6 +101,29 @@ fn rendered_ink(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) -> image: panic!("signature ink did not render at ({x}, {y})"); } +/// #2392: the Line tool's line runs across the page as displayed, on turned pages too (its box was +/// sized in the page's own axes, so a quarter turn stood it on end). +#[test] +fn the_line_mark_runs_across_turned_pages() { + for degrees in [0, 90, 180, 270] { + let mut h = harness(); + { + let s = h.state_mut(); + let id = s.views[0].id; + s.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + } + h.run_steps(4); + assert!(h.state_mut().execute("sign.fill.line")); + click(&mut h, 150.0, 350.0); + let s = h.state(); + let info = &s.session.get(s.views[0].id).unwrap().info; + let r = info.annotations.last().expect("a line was placed").rect; + let (a, b) = (info.pages[0].user_to_view(r[0], r[1]), info.pages[0].user_to_view(r[2], r[3])); + let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs()); + assert!(w > 4.0 * h, "rotation {degrees}: the line's displayed box is {w} × {h}, not across"); + } +} + #[test] fn text_marks_and_date() { let mut h = harness(); From ea0c1ae106b9643684114fdc6431d673e17a006e Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:26:54 +0200 Subject: [PATCH 15/17] Review: a selected line or polygon moved by its body is drawn where it is going CodeRabbit on #3788: the handles and outline of a selected line, connected lines, polygon or cloud followed a dragged end but not a drag of the whole comment, which kept its old handles until the page was drawn again (the frame such comments had before showed the move). The points now follow a body drag and the released move as well (changing_points, one rule for reshaping and moving). Test: a_moved_or_reshaped_line_is_drawn_at_its_new_points. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 63 ++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 8a5679f06..3d2a3d4f4 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1304,22 +1304,39 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> /// 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. +fn changing_points( + gesture: Option<&Gesture>, + pending: Option<&Edit>, + (page, index): (usize, usize), + points: &[[f64; 2]], + to_user: impl Fn(Pos2) -> [f64; 2], + pointer: Option, + shift: bool, +) -> Option> { + 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)) + } + (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)); + shifted(b[0] - a[0], b[1] - a[1]) + }), + (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), + (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + _ => None, + } +} + fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option, shift: bool) { let cv = &view.comments; - let mut points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); - let mut changed = false; - if let Some(Gesture::Reshape { page, index, point, points: from }) = &cv.gesture - && (*page, *index) == (cx.page, a.index) - && let Some(p) = pointer - { - points = reshaped(from, *point, cx.to_user(p), shift); - changed = true; - } else if let Some(Edit::ReshapeAnnotation { page, index, points: to }) = &view.pending_edit - && (*page, *index) == (cx.page, a.index) - { - points.clone_from(to); - changed = true; - } + 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 changed = changing.is_some(); + let points = changing.unwrap_or(points); let screen: Vec = points.iter().map(|q| cx.to_screen(*q)).collect(); if changed { let stroke = Stroke::new(1.5, SELECT_BLUE); @@ -2043,6 +2060,24 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32) #[cfg(test)] mod tests { use super::*; + + /// A selected line is drawn where it is going: moved by its body (both ends follow the + /// pointer), with an end dragged (that end alone), and as released until the page is drawn. + #[test] + fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { + let line = [[10.0, 10.0], [50.0, 10.0]]; + 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"); + 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]])); + 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"); + } use pdfcraft_render::TextGlyph; /// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn From cf565db3fedae414300818330108fd9dcfd644bf Mon Sep 17 00:00:00 2001 From: Unhumanje <54317404+petarlaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:24:48 +0200 Subject: [PATCH 16/17] fix(annot): a comment listed many times gets one display appearance with_missing_appearances_cached checked each /Annots entry against the source document, which never gets the appearance, so an annotation listed K times added K appearance streams to the display copy. The engine writes that copy on open and after every edit, so the display bytes grew with the number of listings: 20,000 listings of one Ink comment in a 136 KB file came to 428 MB. Each annotation reference is now handled once per call, across all pages. --- crates/annot/src/lib.rs | 7 +++++++ crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..0b8a14490 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1433,8 +1433,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option { /// redrawing it. See [`AppearanceCache`] for when entries stay valid. pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { let mut copy: Option = None; + // An annotation listed more than once, on one page or several, is drawn once. `has_normal` + // reads `doc`, which never gets the appearance, so every repeat used to add another stream to + // the copy, and the display bytes grew with the number of listings. + let mut seen = std::collections::HashSet::new(); for page in page_refs(doc).ok()? { for r in annots(doc, page).iter().filter_map(Object::as_ref) { + if !seen.insert(r) { + continue; + } let d = annot_dict(doc, r); if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { continue; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb106..899c6ea75 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1301,6 +1301,28 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { assert_eq!(a, b, "the reused appearance is byte-identical"); } +/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in +/// the display copy. Every listing used to add another appearance stream, so a short array of +/// repeated references multiplied the display bytes the engine writes on each refresh. +#[test] +fn a_comment_listed_many_times_is_drawn_once_for_display() { + let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" ")); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", + first.as_bytes(), + b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>", + ]); + let mut cache = AppearanceCache::default(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance"); + assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing"); + assert_eq!((cache.builds(), cache.hits()), (1, 0)); + assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it"); + with_missing_appearances_cached(&doc, &mut cache).expect("a refresh"); + assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once"); +} + /// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box /// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused. #[test] From 6411cc803fb02aae8c5486f49a161b091de0bc44 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:47:05 +0800 Subject: [PATCH 17/17] M5: a Replace text comment is named Replace text, not Strikethrough (#3947) The Replace text tool saves a StrikeOut with /IT /StrikeOutTextEdit and a caret reply. The Comments panel named it by subtype only, and tool_for mapped it to the Strikethrough tool, so Make Current Properties Default on it changed the Strikethrough tool's defaults. Both now read the intent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ --- crates/ui-egui/src/comments.rs | 1 + crates/ui-egui/src/comments_panel.rs | 3 ++- crates/ui-egui/tests/comments.rs | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 7fd9b7a62..d06eafdad 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -347,6 +347,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("FreeText", _) => CommentTool::TextBox, ("Highlight", _) => CommentTool::Highlight, ("Underline", _) => CommentTool::Underline, + ("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText, ("StrikeOut", _) => CommentTool::StrikeOut, ("Squiggly", _) => CommentTool::Squiggly, ("Ink", _) => CommentTool::Ink, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 2ac1853f4..7dca40b85 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -33,9 +33,10 @@ pub fn subtype_label(s: &str) -> &str { } } -/// The comment type as the panel names it, telling callouts and clouds apart by `/IT`. +/// The comment type as the panel names it, telling callouts, clouds and replaced text apart by `/IT`. pub fn kind_label(a: &Annotation) -> &str { match a.intent.as_deref() { + Some("StrikeOutTextEdit") => "Replace text", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 01166a41d..daab3ad64 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1231,6 +1231,13 @@ fn replacing_text_strikes_it_and_adds_a_caret() { assert_eq!(caret.in_reply_to, strike.name, "grouped with the strikeout"); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text")); assert_eq!(h.state().quick_tool, QuickTool::Select); + // The strikeout is the Replace text tool's, not Strikethrough's (#3947): the panel names it + // so, and Make Current Properties Default updates the Replace text tool. + assert_eq!(pdfcraft_ui_egui::comments::tool_for(strike), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText)); + h.state_mut().set_option("panel", "comments").unwrap(); + h.run_steps(3); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Replace text").is_some(), "the panel names the comment Replace text"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Strikethrough").is_none(), "not Strikethrough"); } #[test]