pdfcraft/crates/ui-egui/src/fill_sign.rs

528 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Fill & Sign (Acrobat's Fill & Sign tool, execution plan M5.7): type text onto the page, place
//! ✓ ✕ ● ─ marks and today's date, and sign with a drawn signature. Everything is an annotation
//! (typewriter text, PdfCraft-drawn stamps, ink), so it can be moved, deleted and undone like
//! any comment.
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, Style};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FillTool {
Text,
Check,
Cross,
Dot,
Line,
Date,
Signature,
Initials,
}
pub const FILL_TOOLS: [FillTool; 8] =
[FillTool::Text, FillTool::Cross, FillTool::Check, FillTool::Dot, FillTool::Line, FillTool::Date, FillTool::Signature, FillTool::Initials];
/// A saved signature or initials: drawn strokes (normalised to the pad width, y up) or typed
/// text (drawn in the script font).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SavedSig {
Drawn(Vec<Vec<[f32; 2]>>),
Typed(String),
}
/// The Create signature / initials dialog.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SigDraft {
pub strokes: Vec<Vec<[f32; 2]>>,
pub text: String,
/// The Draw tab (otherwise Type).
pub drawing: bool,
/// Creating initials (otherwise the signature).
pub initials: bool,
/// Replacing an existing saved value.
pub editing: bool,
}
impl SigDraft {
pub fn new(initials: bool, name: &str) -> Self {
let text = if initials { name.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { name.trim().to_string() };
Self { strokes: Vec::new(), text, drawing: false, initials, editing: false }
}
/// Edit a copy of the saved value; Cancel must leave the original untouched.
pub fn from_saved(initials: bool, saved: &SavedSig) -> Self {
match saved {
SavedSig::Drawn(strokes) => Self { strokes: strokes.clone(), drawing: true, initials, editing: true, ..Default::default() },
SavedSig::Typed(text) => Self { text: text.clone(), initials, editing: true, ..Default::default() },
}
}
fn ready(&self) -> bool {
if self.drawing {
self.strokes.iter().any(|s| s.len() > 1)
} else {
!self.text.trim().is_empty()
&& self.text.chars().take(pdfcraft_engine::MAX_SIGNATURE_CHARS + 1).count() <= pdfcraft_engine::MAX_SIGNATURE_CHARS
}
}
pub fn saved(&self) -> SavedSig {
if self.drawing { SavedSig::Drawn(self.strokes.clone()) } else { SavedSig::Typed(self.text.trim().to_string()) }
}
}
impl FillTool {
pub fn command(self) -> &'static str {
match self {
FillTool::Text => "sign.fill.text",
FillTool::Check => "sign.fill.check",
FillTool::Cross => "sign.fill.cross",
FillTool::Dot => "sign.fill.dot",
FillTool::Line => "sign.fill.line",
FillTool::Date => "sign.fill.date",
FillTool::Signature => "sign.fill.signature",
FillTool::Initials => "sign.fill.initials",
}
}
pub fn from_command(id: &str) -> Option<Self> {
FILL_TOOLS.into_iter().find(|t| t.command() == id)
}
pub fn label(self) -> &'static str {
match self {
FillTool::Text => "Add text",
FillTool::Check => "Checkmark",
FillTool::Cross => "Cross",
FillTool::Dot => "Dot",
FillTool::Line => "Line",
FillTool::Date => "Date",
FillTool::Signature => "Sign",
FillTool::Initials => "Initials",
}
}
pub fn icon(self) -> &'static str {
match self {
FillTool::Text => "type",
FillTool::Check => "check",
FillTool::Cross => "x",
FillTool::Dot => "circle-dot",
FillTool::Line => "minus",
FillTool::Date => "clock-3",
FillTool::Signature | FillTool::Initials => "signature",
}
}
fn mark(self) -> Option<FillMark> {
match self {
FillTool::Check => Some(FillMark::Check),
FillTool::Cross => Some(FillMark::Cross),
FillTool::Dot => Some(FillMark::Dot),
FillTool::Line => Some(FillMark::Line),
_ => None,
}
}
}
/// Text being typed onto a page: (page, top-left in user space, text, focus requested).
#[derive(Clone, Debug, PartialEq)]
pub struct TypeBox {
pub page: usize,
pub at: [f64; 2],
pub text: String,
pub focus: bool,
}
/// The size Fill & Sign uses for typed text (Acrobat's default is 10 pt).
pub const TEXT_SIZE: f64 = 10.0;
fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
let (vx, vy) = xf.screen_to_view(p);
let u = info.pages[page].view_to_user(vx, vy);
[u[0] as f64, u[1] as f64]
}
fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit {
let style = Style::default_for(&shape);
Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author: author.to_string() })
}
/// A typewriter annotation sized to its text.
pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
let rect = crate::comments::text_box_rect(at, text, TEXT_SIZE);
new(page, Shape::Typewriter { rect, font_size: TEXT_SIZE }, text.to_string(), author)
}
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
/// 150 pt wide.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option<Edit> {
let w = 150.0;
let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
if !min_y.is_finite() {
return None;
}
let h = f64::from(max_y - min_y).max(0.05) * w;
let strokes: Vec<Vec<[f64; 2]>> = strokes
.iter()
.filter(|s| !s.is_empty())
.map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect())
.collect();
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
}
/// Place typed text in the script font with its left edge at `at`, `height` points tall.
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, author: &str) -> Option<Edit> {
pdfcraft_engine::typed_signature_shape(at, text, height).map(|shape| new(page, shape, String::new(), author))
}
/// Place a saved signature or initials.
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
match sig {
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
SavedSig::Typed(text) => {
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
let height = if initials { 24.0_f64 } else { 32.0_f64 };
// Keep long names within the same placement width as drawn signatures.
let height = height.min(150.0 * (top - bottom).max(0.1) / (right - left).max(0.01));
typed_signature_at(page, at, text, height, author)
}
}
}
/// The text in the script font as a picture (`w`×`h` px, black on transparent), for previews.
pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage {
let o = pdfcraft_engine::script_outline(text);
let mut img = egui::ColorImage::filled([w, h], Color32::TRANSPARENT);
let [left, bottom, right, top] = o.bounds();
let span = (top - bottom).max(0.1);
let width = (right - left).max(0.01);
if o.contours.is_empty() {
return img;
}
let k = ((h as f64 * 0.9) / span).min((w as f64 * 0.95) / width);
let x0 = (w as f64 - width * k) / 2.0;
// Device points (y down), then an even-odd scanline fill.
let polys: Vec<Vec<(f64, f64)>> = o
.contours
.iter()
.map(|c| c.iter().map(|p| (x0 + (p[0] - left) * k, h as f64 * 0.5 + (top + bottom) / 2.0 * k - p[1] * k)).collect())
.collect();
for y in 0..h {
let sy = y as f64 + 0.5;
let mut xs: Vec<f64> = Vec::new();
for poly in &polys {
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
if (a.1 <= sy) != (b.1 <= sy) {
xs.push(a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for pair in xs.as_chunks::<2>().0 {
let (from, to) = (pair[0].round().clamp(0.0, w as f64) as usize, pair[1].round().clamp(0.0, w as f64) as usize);
for x in from..to {
img[(x, y)] = Color32::BLACK;
}
}
}
img
}
/// Saved previews and add/remove controls, shared by the left panel and quick-tool picker.
pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, t: &Tokens) -> Option<&'static str> {
ui.set_width(ui.available_width().clamp(240.0, 248.0));
let mut command = None;
for (i, (saved, what, use_id, change_id, remove_id)) in [
(app.signature.as_ref(), "signature", "sign.fill.signature", "sign.fill.signature.change", "sign.fill.signature.remove"),
(app.initials.as_ref(), "initials", "sign.fill.initials", "sign.fill.initials.change", "sign.fill.initials.remove"),
]
.into_iter()
.enumerate()
{
let label = |template: &str| crate::i18n::fmt(tl!(template), &[("what", tl!(what))]);
let Some(saved) = saved else {
if crate::widgets::ghost_button(ui, "plus", &label("Add {what}")).clicked() {
command = Some(use_id);
}
continue;
};
ui.horizontal(|ui| {
let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click());
let use_label = label("Use {what}");
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone()));
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);
painter.rect_stroke(
rect,
CornerRadius::same(4),
Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }),
egui::StrokeKind::Inside,
);
let preview_rect = rect.shrink(8.0);
match saved {
SavedSig::Typed(text) => {
let cache = &mut app.saved_signature_previews[i];
if cache.as_ref().is_none_or(|(s, _)| s != text) {
*cache = Some((
text.clone(),
ui.ctx().load_texture(format!("saved-{what}"), script_preview(text, 480, 104), egui::TextureOptions::LINEAR),
));
}
if let Some((_, tex)) = cache {
let size = vec2(480.0, 104.0) * (preview_rect.width() / 480.0).min(preview_rect.height() / 104.0);
painter.image(
tex.id(),
egui::Rect::from_center_size(preview_rect.center(), size),
egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
Color32::WHITE,
);
}
}
SavedSig::Drawn(strokes) => {
let bounds = strokes
.iter()
.flatten()
.filter(|p| p.iter().all(|v| v.is_finite()))
.fold(egui::Rect::NOTHING, |r, p| r.union(egui::Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1]))));
if bounds.is_finite() {
let scale = (preview_rect.width() / bounds.width().max(0.01)).min(preview_rect.height() / bounds.height().max(0.01));
for stroke in strokes {
let pts = stroke
.iter()
.filter(|p| p.iter().all(|v| v.is_finite()))
.map(|p| preview_rect.center() + vec2(p[0] - bounds.center().x, bounds.center().y - p[1]) * scale)
.collect();
painter.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::BLACK)));
}
}
}
}
if response.on_hover_text(label("Place saved {what}")).clicked() {
command = Some(use_id);
}
if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() {
command = Some(change_id);
}
if crate::icons::button(ui, "x", 26.0, false, &label("Remove saved {what}")).clicked() {
command = Some(remove_id);
}
});
ui.add_space(4.0);
}
command
}
/// Clicks with a Fill & Sign tool on one page. Returns `true` when the click was used.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
page: usize,
info: &DocInfo,
tool: FillTool,
view: &mut DocView,
signature: Option<&SavedSig>,
initials: Option<&SavedSig>,
author: &str,
today: (i64, u32, u32),
) -> Option<FillAction> {
let pointer = ui.input(|i| i.pointer.hover_pos())?;
if !xf.rect.contains(pointer) {
return None;
}
ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
if !resp.clicked() {
return None;
}
let at = to_user(xf, info, page, pointer);
match tool {
FillTool::Text => {
view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
None
}
FillTool::Date => {
let (y, m, d) = today;
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
}
FillTool::Signature => match signature {
Some(s) => place(page, at, s, false, author).map(|e| FillAction::Edit(Box::new(e))),
None => Some(FillAction::CreateSignature),
},
FillTool::Initials => match initials {
Some(s) => place(page, at, s, true, author).map(|e| FillAction::Edit(Box::new(e))),
None => Some(FillAction::CreateInitials),
},
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];
Some(FillAction::Edit(Box::new(new(page, Shape::Mark { rect, mark }, String::new(), author))))
}
}
}
/// What a Fill & Sign click asks the app to do.
#[derive(Clone, Debug, PartialEq)]
pub enum FillAction {
Edit(Box<Edit>),
/// No signature yet: open the signature pad.
CreateSignature,
/// No initials yet.
CreateInitials,
}
/// The in-place editor for typed text. Returns the edit once committed.
pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, author: &str) -> Option<Edit> {
let tb = view.fill_text.clone()?;
let xf = view.page_xform(tb.page)?;
let v = info.pages.get(tb.page)?.user_to_view(tb.at[0] as f32, tb.at[1] as f32);
let pos = xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph);
let zoom = xf.rect.width() / xf.pw.max(1.0);
let mut commit = false;
let mut cancel = false;
egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
let Some(t) = view.fill_text.as_mut() else { return };
let width = ((t.text.len().max(8) as f32) * TEXT_SIZE as f32 * 0.6 * zoom).clamp(60.0, 600.0);
let r = ui.add(
egui::TextEdit::singleline(&mut t.text)
.font(egui::FontId::proportional((TEXT_SIZE as f32 * zoom).max(8.0)))
.desired_width(width)
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
.text_color(Color32::BLACK)
.hint_text(tl!("Type text"))
.id_salt("fill-text-edit"),
);
if t.focus {
r.request_focus();
t.focus = false;
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
} else if r.lost_focus() {
commit = true;
}
});
if cancel {
view.fill_text = None;
return None;
}
if commit {
let tb = view.fill_text.take()?;
if !tb.text.trim().is_empty() {
return Some(typed(tb.page, tb.at, tb.text.trim(), author));
}
}
None
}
/// The signature pad: draw with the pointer; returns the strokes (normalised) on Apply.
pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, preview: &mut Option<(String, egui::TextureHandle)>) -> (bool, bool) {
let what = if d.initials { tl!("initials") } else { tl!("signature") };
let title = if d.editing { tl!("Change {what}") } else { tl!("Create {what}") };
ui.label(egui::RichText::new(crate::i18n::fmt(title, &[("what", what)])).font(crate::theme::semibold(18.0)));
ui.horizontal(|ui| {
if crate::widgets::pill_button(ui, tl!("Type"), !d.drawing).clicked() {
d.drawing = false;
}
if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing).clicked() {
d.drawing = true;
}
});
ui.add_space(6.0);
if !d.drawing {
let l = ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Type your {what}."), &[("what", what)])).color(t.text_muted));
ui.add(
egui::TextEdit::singleline(&mut d.text)
.char_limit(pdfcraft_engine::MAX_SIGNATURE_CHARS)
.desired_width(460.0)
.hint_text(tl!(if d.initials { "Initials" } else { "Your name" })),
)
.labelled_by(l.id);
let (rect, _) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
if preview.as_ref().is_none_or(|(s, _)| *s != d.text) {
let img = script_preview(&d.text, 920, 300);
*preview = Some((d.text.clone(), ui.ctx().load_texture("typed-signature", img, egui::TextureOptions::LINEAR)));
}
if let Some((_, tex)) = preview {
painter.image(tex.id(), rect.shrink(4.0), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
return pad_buttons(ui, d);
}
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Draw your {what} below."), &[("what", what)])).color(t.text_muted));
let strokes = &mut d.strokes;
let (rect, resp) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::drag());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
painter.hline(rect.x_range().shrink(24.0), rect.bottom() - 34.0, Stroke::new(1.0, t.divider));
// Normalised: x 0–1 across the pad, y up, in units of the pad's width.
let norm = |p: Pos2| [(p.x - rect.left()) / rect.width(), (rect.bottom() - p.y) / rect.width()];
if resp.drag_started() {
strokes.push(Vec::new());
}
if resp.dragged()
&& let (Some(p), Some(s)) = (resp.interact_pointer_pos(), strokes.last_mut())
{
let n = norm(p.clamp(rect.min, rect.max));
if s.last().is_none_or(|l| (l[0] - n[0]).abs() + (l[1] - n[1]).abs() > 0.002) {
s.push(n);
}
}
for s in strokes.iter() {
let pts: Vec<Pos2> = s.iter().map(|p| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.width())).collect();
painter.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::BLACK)));
}
pad_buttons(ui, d)
}
fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
ui.add_space(10.0);
let (mut apply, mut cancel) = (false, false);
ui.horizontal(|ui| {
if ui.button(tl!("Clear")).clicked() {
d.strokes.clear();
d.text.clear();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ready = d.ready();
if ui.add_enabled_ui(ready, |ui| crate::widgets::pill_button(ui, tl!("Apply"), true)).inner.clicked() {
apply = true;
}
if crate::widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
cancel = true;
}
});
});
(apply, cancel)
}
#[cfg(test)]
mod tests {
#[test]
fn long_signature_previews_have_clear_margins() {
for text in ["Alexandria Catherine Elizabeth Montgomery-Wellington", "Jg Jg Jg Jg Jg Jg Jg Jg Jg Jg Jg"] {
for [w, h] in [[920, 300], [480, 104]] {
let image = super::script_preview(text, w, h);
assert!(image.pixels.iter().any(|p| p.a() > 0));
for x in 0..w {
assert_eq!(image[(x, 0)].a(), 0);
assert_eq!(image[(x, h - 1)].a(), 0);
}
for y in 0..h {
assert_eq!(image[(0, y)].a(), 0);
assert_eq!(image[(w - 1, y)].a(), 0);
}
}
}
}
}