diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5af43213de..4ddb913985 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -42,6 +42,7 @@ mod xform; mod zoom; pub(super) mod zoom_snap; +pub(crate) use area::SignTab; pub(crate) use area::signature_badges; use area::{drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor}; #[cfg(test)] @@ -1716,6 +1717,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i area_tool: false, hover: None, released_over: false, + sign_tabs: Vec::new(), } } else { page_tool_input(ui, view, frame, &slot) @@ -3025,7 +3027,8 @@ fn fits_height(r: Rect, view_height: f32, pixels_per_point: f32) -> bool { } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +/// scroll area with this frame's zoom, scrolling, tool input, overlays (form fields, comments, +/// signature badges and sign tabs) and render requests. pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let can_paste_key = egui::Id::unique("can-paste-image"); @@ -3699,5 +3702,7 @@ enum FieldMenu { Duplicate(String), } +#[cfg(test)] +mod sign_tab_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 64cbd6e939..53b1edd328 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -1,4 +1,4 @@ -use super::{LayerOp, PdfCraftApp, Pos2, Rect}; +use super::{Color32, HashSet, LayerOp, PdfCraftApp, Pos2, Rect, Tokens, Vec2, icons, pos2, theme, vec2}; /// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. pub(crate) struct SignatureBadge { @@ -27,6 +27,121 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa .collect() } +/// The empty signature fields on one page that get a sign tab, as their names and widget +/// rectangles (user space). Each is unsigned and visible, on this page, with a rectangle that +/// passes the same screen-space guard as [`signature_badges`]. A click must also be able to sign +/// it, as in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field +/// of that name is a signature field that isn't read-only, with a shown widget at that place +/// that isn't read-only on its own. +pub(crate) fn sign_tabs<'a>( + signatures: &'a [pdfcraft_engine::SignatureInfo], + form: &[pdfcraft_engine::FormField], + page: usize, + can_fill: bool, +) -> Vec<(&'a str, [f64; 4])> { + let mut empty = signatures + .iter() + .filter(|s| can_fill && !s.signed && s.visible && s.page == Some(page)) + .filter_map(|s| s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|r| (s.field.as_str(), r))) + .peekable(); + if empty.peek().is_none() { + return Vec::new(); + } + // The widgets a click signs, by field name and exact rectangle, from one pass over the form: + // a file with thousands of fields costs no more per frame than the field hit test does. + let signable: HashSet<(&str, [u64; 4])> = form + .iter() + .filter(|f| f.kind == pdfcraft_engine::FormFieldKind::Signature && !f.read_only()) + .flat_map(|f| { + f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden && !w.read_only).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits))) + }) + .collect(); + empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).collect() +} + +/// The largest sign tab's height, in points. Every other size is a share of the tab's height, +/// taken from this one: a 3 pt inset, a 12 pt icon, 5 pt padding, an 11 pt label and 4 pt corners. +pub(super) const SIGN_TAB_MAX: f32 = 18.0; +/// The smallest sign tab's height, in points: a field too small for this chip gets no tab. +const SIGN_TAB_MIN: f32 = 10.0; +/// The share of its field's height on screen that a sign tab takes, between the two sizes. +const SIGN_TAB_SHARE: f32 = 0.4; +/// The smallest sign tab that carries the label; below it the tab is the icon alone. +const SIGN_TAB_LABEL_MIN: f32 = 14.0; +/// The label's font size on the largest sign tab; it shrinks with the tab. +const SIGN_TAB_FONT: f32 = 11.0; + +/// Where one sign tab is drawn on screen: its chip, the icon's square and, when the label fits, +/// the point where the label starts (its left edge, on the chip's center line), with the +/// label's font size and the chip's corner radius at this tab's size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SignTab { + pub rect: Rect, + pub icon: Rect, + pub label: Option, + pub font: f32, + pub radius: f32, +} + +/// Lays out a sign tab inside the top-left corner of `field` (the widget on screen), for a label +/// `label_width` points wide at [`SIGN_TAB_FONT`], its size on the largest tab. The tab is +/// [`SIGN_TAB_SHARE`] of the field's height, from [`SIGN_TAB_MIN`] to [`SIGN_TAB_MAX`], and its +/// inset, icon, padding, label and corners scale with it. It carries the label from +/// [`SIGN_TAB_LABEL_MIN`] up when the whole tab fits in half the field's width, and is a square +/// icon chip otherwise. It is left out when even that chip doesn't fit, or the field isn't finite. +pub(crate) fn sign_tab_layout(field: Rect, label_width: f32) -> Option { + if !field.is_finite() { + return None; + } + // Finite, so the clamp has a number to work on (and its bounds are constants in order). + let h = (field.height() * SIGN_TAB_SHARE).clamp(SIGN_TAB_MIN, SIGN_TAB_MAX); + let scale = h / SIGN_TAB_MAX; + let (inset, icon_side, pad) = (3.0 * scale, 12.0 * scale, 5.0 * scale); + if field.height() < h + 2.0 * inset || h > field.width() / 2.0 { + return None; + } + let full = pad + icon_side + pad + label_width * scale + pad; + let with_label = h >= SIGN_TAB_LABEL_MIN && label_width.is_finite() && label_width > 0.0 && full <= field.width() / 2.0; + let width = if with_label { full } else { h }; + let rect = Rect::from_min_size(field.min + Vec2::splat(inset), vec2(width, h)); + let icon_x = if with_label { rect.left() + pad } else { rect.center().x - icon_side / 2.0 }; + let icon = Rect::from_min_size(pos2(icon_x, rect.center().y - icon_side / 2.0), Vec2::splat(icon_side)); + let label = with_label.then(|| pos2(icon.right() + pad, rect.center().y)); + Some(SignTab { rect, icon, label, font: SIGN_TAB_FONT * scale, radius: 4.0 * scale }) +} + +/// The sign tab's label, "Sign", laid out at `size` points. +fn sign_tab_label(ui: &egui::Ui, size: f32) -> std::sync::Arc { + ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)) +} + +/// The sign tab of an empty signature field whose widget is at `field` on screen, on the page at +/// `page`, laid out for its label measured at its largest size: where it is painted and where a +/// click on it signs. +pub(super) fn sign_tab_at(ui: &egui::Ui, field: Rect, page: Rect) -> Option { + sign_tab_on_page(field, page, sign_tab_label(ui, SIGN_TAB_FONT).size().x) +} + +/// [`sign_tab_layout`] in the part of `field` that is on `page`: form fields take clicks on the +/// page only, so a widget that straddles the page's edge gets its tab where a click reaches it, +/// or none when that part has no room for one. +pub(crate) fn sign_tab_on_page(field: Rect, page: Rect, label_width: f32) -> Option { + sign_tab_layout(field.intersect(page), label_width) +} + +/// Paints a sign tab laid out by [`sign_tab_at`]: an accent chip with the signature icon and +/// "Sign" in white, as the app's primary buttons are. The label is drawn at the tab's size; text +/// scales with its size, and the padding takes up any rounding. Screen-only chrome, like the +/// validation badges. +pub(super) fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, tab: &SignTab, t: &Tokens) { + painter.rect_filled(tab.rect, egui::epaint::CornerRadiusF32::same(tab.radius), t.accent); + icons::paint(ui, tab.icon, "signature", tab.icon.width(), Color32::WHITE); + if let Some(at) = tab.label { + let galley = sign_tab_label(ui, tab.font); + painter.galley(at - vec2(0.0, galley.size().y / 2.0), galley, Color32::WHITE); + } +} + // Keep the viewer's scroll identity tied to the document instead of its parent UI. Without // `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only. pub(super) fn page_scroll( diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f96ff4e91e..48a94806a0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -4,6 +4,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{DocInfo, LinkTarget, PageText}; +use super::area::{SignTab, paint_sign_tab, sign_tab_at, sign_tabs}; use super::{ DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor, signature_badges, verdict_mark_rect, @@ -30,6 +31,8 @@ pub(super) struct PageInput { /// The press's click came up over what the press began on: a link's or a field's click counts /// only then, as a button's does. pub(super) released_over: bool, + /// The sign tabs on empty fields a click would sign, painted over the page. + pub(super) sign_tabs: Vec, } /// Common geometry and pointer state for a tool acting on one page. @@ -83,6 +86,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; // While a table is being set up, nothing else is drawn or moved. let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + // An empty field a click would sign carries a small tab saying so, shown only while + // a click on the field signs it (the Select tool, not while preparing a form). The tab + // is drawn over every field, so a click on it signs its field (`forms_ui::hit`). + let tabs = page_sign_tabs(ui, doc, form, info, slot, can_fill, tool == QuickTool::Select && !preparing); let hits = PageHits { page: i, xf: &xf, @@ -102,6 +109,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut images: &images, clip: ui.clip_rect(), text_links, + tabs: &tabs, }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it @@ -254,7 +262,39 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); - PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over } + PageInput { + press, + text_drag: !view.router.keeps_drag(), + consumed, + area_tool, + hover, + released_over, + sign_tabs: tabs.into_iter().map(|(_, tab)| tab).collect(), + } +} + +/// The sign tabs on the slot's page, on empty fields a click would sign, each with the field it +/// signs; none unless `shown`. +fn page_sign_tabs<'a>( + ui: &egui::Ui, + doc: &'a pdfcraft_engine::Document, + form: &[pdfcraft_engine::FormField], + info: &DocInfo, + slot: &PageSlot, + can_fill: bool, + shown: bool, +) -> Vec<(&'a str, SignTab)> { + let PageSlot { i, xf, .. } = slot; + let i = *i; + if !shown { + return Vec::new(); + } + sign_tabs(&doc.signatures, form, i, can_fill) + .into_iter() + .filter_map(|(name, [x0, y0, x1, y1])| { + sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), xf.rect).map(|tab| (name, tab)) + }) + .collect() } /// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, @@ -457,6 +497,9 @@ struct PageHits<'a> { clip: Rect, /// Addresses written as plain page text open like links (#4207). text_links: bool, + /// The sign tabs on empty signature fields, with the field each one signs: drawn over every + /// field, so the pointer on one is on its own field. + tabs: &'a [(&'a str, SignTab)], } impl PageHits<'_> { @@ -539,6 +582,7 @@ impl PageHits<'_> { images, clip, text_links, + tabs, } = *self; // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { @@ -561,7 +605,7 @@ impl PageHits<'_> { let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) + crate::forms_ui::hit(xf, info, page, form, tabs, can_fill, p) } else { None }; @@ -844,6 +888,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P prefs, preparing, redact_mark, + t, table_preview, tool, unobstructed, @@ -969,6 +1014,11 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P view.flash = None; } } + // The sign tabs (above). Like the validation badges, they never enter the file, + // exports or printing. + for tab in &input.sign_tabs { + paint_sign_tab(ui, painter, tab, t); + } // Validation badges are viewer chrome over the widget. A recognised named-layer appearance may // show the verdict in the display copy too; saves retain its original streams. Badges never // enter document streams, exports or either print path. Their tips show where the comments' diff --git a/crates/ui-egui/src/canvas/sign_tab_tests.rs b/crates/ui-egui/src/canvas/sign_tab_tests.rs new file mode 100644 index 0000000000..f402fd93d7 --- /dev/null +++ b/crates/ui-egui/src/canvas/sign_tab_tests.rs @@ -0,0 +1,375 @@ +//! Unit tests for the sign tabs on empty signature fields (`area.rs`). + +use super::area::{SIGN_TAB_MAX, sign_tab_layout, sign_tab_on_page, sign_tabs}; +use super::*; +use pdfcraft_engine::{FormField, FormFieldKind, FormWidget, SignatureInfo}; + +/// The widget rectangle the unit tests use (user space). +const RECT: [f64; 4] = [20.0, 110.0, 140.0, 180.0]; + +/// An unsigned, visible signature listing for field `name` with its widget on `page` at +/// `rect`. Each test changes the one thing it is about. +fn listing(name: &str, page: usize, rect: [f64; 4]) -> SignatureInfo { + SignatureInfo { + field: name.into(), + signed: false, + page: Some(page), + rect: Some(rect), + visible: true, + signer: None, + certificate: None, + chain: Vec::new(), + date: None, + signing_time: None, + reason: None, + location: None, + contact: None, + sub_filter: None, + doc_timestamp: false, + certify: None, + revision: 1, + signed_len: 0, + digest: None, + algorithm: None, + timestamp: false, + timestamp_time: None, + status: pdfcraft_engine::SignatureStatus::Unknown, + modification: pdfcraft_engine::sign::Modification::None, + details: Vec::new(), + verdict_shown: false, + } +} + +/// The signature form field `name` with one shown widget on `page` at `rect`. +fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField { + FormField { + name: name.into(), + obj: pdfcraft_cos::ObjRef::new(4, 0), + kind: FormFieldKind::Signature, + value: Vec::new(), + default: Vec::new(), + flags: 0, + max_len: None, + options: Vec::new(), + da: String::new(), + quadding: 0, + tooltip: None, + widgets: vec![FormWidget { + obj: pdfcraft_cos::ObjRef::new(4, 0), + page: Some(page), + rect, + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + list: None, + round: false, + }], + actions: Default::default(), + button: None, + } +} + +/// The cases (by name) whose single listing and form still got a tab on page 0. +fn tabbed<'a>(cases: &[(&'a str, SignatureInfo, Vec, bool)]) -> Vec<&'a str> { + cases.iter().filter(|(_, s, form, can_fill)| !sign_tabs(std::slice::from_ref(s), form, 0, *can_fill).is_empty()).map(|c| c.0).collect() +} + +/// An empty, visible signature field that a click signs gets one tab, at its widget, on its +/// own page only. +#[test] +fn sign_tabs_mark_an_empty_field_that_a_click_signs() { + let approver = [listing("Approver", 0, RECT)]; + assert_eq!(sign_tabs(&approver, &[field("Approver", 0, RECT)], 0, true), vec![("Approver", RECT)]); + let later = [listing("Witness", 1, RECT)]; + assert_eq!(sign_tabs(&later, &[field("Witness", 1, RECT)], 1, true), vec![("Witness", RECT)]); + assert!(sign_tabs(&later, &[field("Witness", 1, RECT)], 0, true).is_empty(), "not on the page before"); +} + +/// Signed, invisible, other-page and unplaced fields get no tab. Each case is checked on its +/// own, so a failure names every case that wrongly got one. +#[test] +fn sign_tabs_skip_signed_invisible_other_page_and_unplaced_fields() { + let form = || vec![field("Approver", 0, RECT)]; + let cases = [ + ("signed", SignatureInfo { signed: true, ..listing("Approver", 0, RECT) }, form(), true), + ("invisible", SignatureInfo { visible: false, ..listing("Approver", 0, RECT) }, form(), true), + ("on another page", listing("Approver", 1, RECT), vec![field("Approver", 1, RECT)], true), + ("on no page", SignatureInfo { page: None, ..listing("Approver", 0, RECT) }, form(), true), + ("without a rectangle", SignatureInfo { rect: None, ..listing("Approver", 0, RECT) }, form(), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// NaN, infinite and gigantic rectangles never reach the painter, even when the form's +/// widget carries the same numbers. +#[test] +fn sign_tabs_skip_rectangles_that_cannot_reach_the_screen() { + let hostile = |name: &'static str, rect: [f64; 4]| (name, listing("Approver", 0, rect), vec![field("Approver", 0, rect)], true); + let cases = [ + hostile("NaN", [f64::NAN, 110.0, 140.0, 180.0]), + hostile("infinite", [20.0, f64::INFINITY, 140.0, 180.0]), + hostile("minus infinite", [20.0, 110.0, f64::NEG_INFINITY, 180.0]), + hostile("huge", [20.0, 110.0, 1e300, 180.0]), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// Fields a click would not sign get no tab: a read-only field, a document that refuses +/// filling, a listing with no form field of its name, a field of another kind of that name, +/// and a widget that is hidden, read-only on its own or elsewhere. +#[test] +fn sign_tabs_skip_fields_that_a_click_cannot_sign() { + let empty = || listing("Approver", 0, RECT); + let with = |change: fn(&mut FormField)| { + let mut f = field("Approver", 0, RECT); + change(&mut f); + vec![f] + }; + let cases = [ + ("read-only", empty(), with(|f| f.flags = pdfcraft_engine::field_flags::READ_ONLY), true), + ("filling refused", empty(), with(|_| {}), false), + ("no form field", empty(), Vec::new(), true), + ("another kind", empty(), with(|f| f.kind = FormFieldKind::Text), true), + ("hidden widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.hidden = true)), true), + ("read-only widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.read_only = true)), true), + ("widget elsewhere", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.rect = [160.0, 110.0, 280.0, 180.0])), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// Whether two layout sizes match, to a hundredth of a point. +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 +} + +/// What a field `width` by `height` points on screen shows, for a label 30 pt wide on the +/// largest tab: "label" (the full tab), "icon" (the square chip) or "none". +fn shows(width: f32, height: f32) -> &'static str { + match sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(width, height)), 30.0) { + Some(tab) if tab.label.is_some() => "label", + Some(_) => "icon", + None => "none", + } +} + +/// The tab is 0.4 of the field's height on screen, at most 18 pt: a tall, wide field gets the +/// 18 pt tab with its label 3 pt inside the corner, and a 29 pt field (a 22 pt box at fit +/// width) an 11.6 pt icon chip, too small for the label, with the inset and icon scaled to it. +#[test] +fn sign_tab_layout_scales_the_chip_with_the_field_height() { + let large = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 60.0)); + let tab = sign_tab_layout(large, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 18.0) && tab.rect.min == large.min + vec2(3.0, 3.0), "{tab:?}"); + assert!(near(tab.icon.width(), 12.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + let at = tab.label.expect("the label fits"); + assert!(at.x >= tab.icon.right() && at.x + 30.0 <= tab.rect.right() && at.y == tab.rect.center().y, "{tab:?}"); + + let short = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 29.0)); + let tab = sign_tab_layout(short, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 11.6) && near(tab.rect.width(), 11.6) && tab.label.is_none(), "{tab:?}"); + let inset = tab.rect.min - short.min; + assert!(near(inset.x, 11.6 / 6.0) && near(inset.y, 11.6 / 6.0) && short.contains_rect(tab.rect), "{tab:?}"); + assert!(near(tab.icon.width(), 11.6 * 2.0 / 3.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); +} + +/// A 19 pt field (a 14 pt box at fit width) still gets the smallest chip, 10 pt with the icon +/// alone. A 12 pt field is too short for even that, and a rectangle that isn't finite gets +/// nothing. A label that can't be measured is left out instead of drawn at a NaN position. +#[test] +fn sign_tab_layout_keeps_a_small_icon_on_short_fields_and_none_below() { + let field = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 19.0)); + let tab = sign_tab_layout(field, 30.0).expect("the smallest chip fits"); + assert!(near(tab.rect.height(), 10.0) && near(tab.rect.width(), 10.0) && tab.label.is_none(), "{tab:?}"); + assert!(field.contains_rect(tab.rect) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + let cases = [ + ("12 pt tall", Rect::from_min_size(Pos2::ZERO, vec2(400.0, 12.0))), + ("infinite", Rect::from_min_max(Pos2::ZERO, pos2(f32::INFINITY, 40.0))), + ("NaN", Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(400.0, 40.0))), + ]; + let laid_out: Vec<&str> = cases.iter().filter(|(_, r)| sign_tab_layout(*r, 30.0).is_some()).map(|c| c.0).collect(); + assert_eq!(laid_out, Vec::<&str>::new()); + let roomy = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)); + assert_eq!(sign_tab_layout(roomy, f32::NAN).map(|t| t.label), Some(None)); +} + +/// The label's font and the chip's corners shrink with the tab: 11 pt and 4 pt on the largest +/// tab, 8.8 pt and 3.2 pt on the 14.4 pt tab of a 36 pt field. +#[test] +fn sign_tab_layout_scales_the_label_font_and_corners_with_the_chip() { + let large = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)), 30.0).expect("fits"); + assert!(near(large.font, 11.0) && near(large.radius, 4.0), "{large:?}"); + let small = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 36.0)), 30.0).expect("fits"); + assert!(near(small.font, 8.8) && near(small.radius, 3.2) && small.label.is_some(), "{small:?}"); +} + +/// As a 36 pt field (a 14.4 pt tab, its label 24 pt wide at that size) narrows, the tab keeps +/// its label while the whole tab fits in half the width (91.2 pt), then shows the icon alone +/// while the chip does (28.8 pt), then nothing. +#[test] +fn sign_tab_layout_drops_the_label_then_the_tab_as_the_field_narrows() { + let widths = [400.0, 92.0, 90.0, 29.0, 28.0]; + assert_eq!(widths.map(|w| shows(w, 36.0)), ["label", "label", "icon", "icon", "none"]); + let tab = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(92.0, 36.0)), 30.0).expect("fits"); + assert!(near(tab.rect.height(), 14.4) && tab.rect.width() <= 46.0, "{tab:?}"); +} + +/// A field that straddles the page's edge gets its tab in the part on the page, where clicks +/// reach it, or none when that part has no room for one. +#[test] +fn a_sign_tab_stays_on_the_page() { + let page = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 800.0)); + let straddling = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 90.0)); + let tab = sign_tab_on_page(straddling, page, 30.0).expect("the part on the page has room"); + assert!(page.contains_rect(tab.rect), "{tab:?} on {page:?}"); + assert_eq!(tab.rect.height(), SIGN_TAB_MAX, "sized to the 60 px on the page"); + let sliver = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 36.0)); + assert_eq!(sign_tab_on_page(sliver, page, 30.0), None, "6 px on the page"); + let off = Rect::from_min_size(pos2(60.0, 900.0), vec2(300.0, 90.0)); + assert_eq!(sign_tab_on_page(off, page, 30.0), None, "wholly off the page"); +} + +/// Where two sign tabs overlap, a click takes the one painted last, which is on top. +#[test] +fn the_sign_tab_painted_last_takes_the_click() { + let tab = |x: f32| sign_tab_layout(Rect::from_min_size(pos2(x, 0.0), vec2(300.0, 60.0)), 30.0).expect("fits"); + let tabs = [("Approver", tab(0.0)), ("Witness", tab(10.0))]; + let both = tabs[1].1.rect.left_center() + vec2(2.0, 0.0); + assert!(tabs[0].1.rect.contains(both), "the tabs overlap there"); + assert_eq!(crate::forms_ui::tab_under(&tabs, both), Some("Witness")); + assert_eq!(crate::forms_ui::tab_under(&tabs, tabs[0].1.rect.left_center() + vec2(2.0, 0.0)), Some("Approver")); + assert_eq!(crate::forms_ui::tab_under(&tabs, pos2(-5.0, -5.0)), None); +} + +/// One page with three signature fields: Approver is empty, Witness is signed (a signature +/// dictionary as its value is enough to list it as signed) and Locked is empty but read-only. +const SIGN_FIELDS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Witness) /V << /Type /Sig >> /Rect [160 110 280 180] /P 3 0 R /F 4 >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Locked) /Ff 1 /Rect [20 20 140 90] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Every shape painted in the last frame, with nested lists flattened. +fn painted(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { + let mut stack: Vec = h.output().shapes.iter().map(|c| c.shape.clone()).collect(); + let mut out = Vec::new(); + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(inner) => stack.extend(inner), + shape => out.push(shape), + } + } + out +} + +/// Where the named field's first widget is on screen. +fn field_on_screen(h: &egui_kittest::Harness<'static, PdfCraftApp>, name: &str) -> Rect { + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(view.id).expect("open"); + let f = doc.form.iter().find(|f| f.name == name).expect("field"); + crate::forms_ui::field_screen_rect(view, &doc.info, f, 0).expect("on screen") +} + +/// One page where the text field Name, first in the form's order, covers the top-left corner +/// of the empty signature field Approver, where its sign tab is drawn. +const SIGN_UNDER_TEXT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [10 120 130 190] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The real shell with `bytes` open, the side panes closed, after the first frames. +fn shell(bytes: &'static [u8]) -> egui_kittest::Harness<'static, PdfCraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("sign-fields.pdf", None, bytes.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The accent chips painted in the last frame: the sign tabs. +fn chips(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { + let accent = Tokens::get(&h.ctx).accent; + painted(h) + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) if r.fill == accent => Some(r.rect), + _ => None, + }) + .collect() +} + +/// Clicks at `at` and lets the shell answer. +fn click_at(h: &mut egui_kittest::Harness<'static, PdfCraftApp>, at: Pos2) { + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); +} + +/// In the real shell, an empty signature field shows its accent tab with "Sign" in its +/// top-left corner, and the signed and read-only fields show none. The tab is part of the +/// field: hovering it shows the pointing hand, and clicking it starts signing that field. +#[test] +fn empty_signature_fields_show_a_sign_tab_that_signs_them() { + let mut h = shell(SIGN_FIELDS); + let (shapes, chips) = (painted(&h), chips(&h)); + let (approver, witness, locked) = (field_on_screen(&h, "Approver"), field_on_screen(&h, "Witness"), field_on_screen(&h, "Locked")); + let tab = chips.iter().copied().find(|c| approver.contains_rect(*c)).unwrap_or_else(|| panic!("no tab in {approver:?}: {chips:?}")); + assert!(tab.min.x - approver.min.x < 8.0 && tab.min.y - approver.min.y < 8.0, "in the top-left corner: {tab:?} in {approver:?}"); + let label = shapes.iter().any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == "Sign" && tab.contains(t.pos))); + assert!(label, "the tab says Sign"); + assert!(!chips.iter().any(|c| witness.intersects(*c)), "a signed field has no tab: {chips:?}"); + assert!(!chips.iter().any(|c| locked.intersects(*c)), "a read-only field has no tab: {chips:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + click_at(&mut h, tab.center()); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); +} + +/// A sign tab is drawn over every field, so a click on it signs its field even where a field +/// earlier in the form's order overlaps it, which would otherwise take the click. Beside the +/// tab, the overlapping field still takes clicks as before. +#[test] +fn a_sign_tab_signs_its_field_where_an_earlier_field_overlaps_it() { + let mut h = shell(SIGN_UNDER_TEXT); + let (name, approver) = (field_on_screen(&h, "Name"), field_on_screen(&h, "Approver")); + let tab = chips(&h).into_iter().find(|c| approver.contains_rect(*c)).expect("Approver has a tab"); + assert!(name.contains_rect(tab), "the text field covers the tab: {tab:?} in {name:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + click_at(&mut h, tab.center()); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); + assert!(h.state().views[0].forms.focus.is_none(), "the text field didn't open"); + + let beside = pos2(tab.right() + 6.0, tab.center().y); + assert!(name.contains(beside) && approver.contains(beside), "beside the tab, both fields are under the pointer"); + h.state_mut().dialog = None; + h.state_mut().sign_draft = None; + h.run_steps(1); + click_at(&mut h, beside); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("Name"), "the earlier field takes the click"); +} diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 046747ccf2..2e87bd5ead 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -14,7 +14,7 @@ use pdfcraft_cos::ObjRef; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; -use crate::canvas::{DocView, PageToolContext, PageXform}; +use crate::canvas::{DocView, PageToolContext, PageXform, SignTab}; use crate::page_cursor::Layer; use crate::page_router::{Hit, Owner, PagePress}; @@ -256,6 +256,17 @@ fn toggle_edit(f: &FormField, wi: usize) -> Option { Some(Edit::SetFieldValue { name: f.name.clone(), value }) } +/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. +fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, p: egui::Pos2) -> Option<(usize, &'a FormWidget)> { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) +} + +/// The field whose sign tab is under `p`, of `tabs` in the order they are painted: where tabs +/// overlap, the one painted last is on top. +pub(crate) fn tab_under<'a>(tabs: &[(&'a str, SignTab)], p: egui::Pos2) -> Option<&'a str> { + tabs.iter().rev().find(|(_, tab)| tab.rect.contains(p)).map(|(name, _)| *name) +} + /// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action, or an /// empty signature field (signed by clicking it, Use a certificate). A radio button of a group can /// be read-only on its own. @@ -265,19 +276,22 @@ fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool { } /// The form widget under `p` on `page`: the one hit test of the fields, for the cursor and the -/// press. Over the page's text too: a check box drawn with a glyph, or set on printed text, is a -/// button, not text to select. -pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], allowed: bool, p: Pos2) -> Option { +/// press. It is the first widget under the pointer in the form's order, except on a sign tab +/// (`tabs`, with the signature field each one signs), which is drawn over every field and so is +/// on its own field. Over the page's text too: a check box drawn with a glyph, or set on printed +/// text, is a button, not text to select. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], tabs: &[(&str, SignTab)], allowed: bool, p: Pos2) -> Option { if !xf.rect.contains(p) { return None; } - let (field, f, widget, w) = form.iter().enumerate().find_map(|(fi, f)| { - f.widgets - .iter() + let under = |only: Option<&str>| { + form.iter() .enumerate() - .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) - .map(|(wi, w)| (fi, f, wi, w)) - })?; + .filter(|(_, f)| only.is_none_or(|name| f.name == name && f.kind == FormFieldKind::Signature)) + .find_map(|(fi, f)| widget_at(xf, info, page, f, p).map(|(wi, w)| (fi, f, wi, w))) + }; + // A tab lies inside its field's widget, so that widget is under the pointer too. + let (field, f, widget, w) = tab_under(tabs, p).and_then(|name| under(Some(name))).or_else(|| under(None))?; let icon = match (usable(f, w, allowed), f.kind) { (false, _) => egui::CursorIcon::NotAllowed, (true, FormFieldKind::Text) => egui::CursorIcon::Text, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e88161b9c6..13593b813e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6223,8 +6223,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/it/signing.rs::clicking_an_empty_signature_field_signs_it"] -notes = "" +evidence = ["crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/it/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas/sign_tab_tests.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas/sign_tab_tests.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] +notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab in its top-left corner, sized to the field (0.4 of its height on screen, from 10 to 18 pt): the signature icon and Sign from 14 pt up, the icon alone below that or when the label would take more than half the field's width, and no tab on a field too small for the 10 pt chip. The tab is screen-only chrome, never written into the file, exports or printing, and is hidden while preparing a form. Headless, the fields it marks are the ones sign_list reports unsigned and visible on a page, with their rect, that form_fields doesn't report read-only. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" [[feature]] id = "sign.invisible-signature"