Merge #3773: M9: empty signature fields show a Sign tab, sized to the field

# Conflicts:
#	crates/ui-egui/src/canvas/page_input.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:19:30 +07:00
commit 68abeedc57
6 files changed
+575 -16

No files matched your search

+6 -1
View File
@@ -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;
+116 -1
View File
@@ -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<Pos2>,
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<SignTab> {
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<egui::Galley> {
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<SignTab> {
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<SignTab> {
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<R>(
+52 -2
View File
@@ -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<SignTab>,
}
/// 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'
+375
View File
@@ -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<FormField>, 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<egui::Shape> {
let mut stack: Vec<egui::Shape> = 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<Rect> {
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");
}
+24 -10
View File
@@ -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<Edit> {
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<Hit> {
/// 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<Hit> {
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,
+2 -2
View File
@@ -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"