From 89896af68b51fbf5fea68dc6cac26823e5cc7198 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 7 Oct 2026 19:37:28 -0600 Subject: [PATCH] Fix saved signature replacement and long-name clipping (#163) (#178) * #163: allow replacing saved signatures and render long names fully Add saved signature and initials previews with change/remove/add controls in Fill & Sign. Preserve placed annotations and saved values on cancellation. Use bounded line outlines for long names and fit typed placements to 150 pt. Co-authored-by: Codex * cargo fmt after the rename Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/engine/src/catalog.rs | 2 + crates/engine/src/commands.rs | 4 + crates/engine/src/lib.rs | 10 +- crates/fonts/src/lib.rs | 2 +- crates/fonts/src/script.rs | 44 ++++++++- crates/ui-egui/src/canvas.rs | 8 ++ crates/ui-egui/src/commands.rs | 11 +++ crates/ui-egui/src/fill_sign.rs | 155 +++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 9 ++ crates/ui-egui/tests/control.rs | 24 +++++ crates/ui-egui/tests/fill_sign.rs | 145 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 14 files changed, 402 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ef269475..a7214e00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. - **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%. +- **2026-10-07 (community, #163):** Fill & Sign shows saved signature/initials previews with add, change and remove controls in the quick picker and left panel. The editor loads the saved typed text or drawn strokes; Apply replaces future placements and Cancel preserves the saved value. Existing annotations stay unchanged. Long typed names shrink to the placement width and include glyph overhangs in preview/PDF bounds. Regression coverage includes persistence, removal/recreation, long names and the opt-in UI control channel. Parity counts and effort estimate unchanged (50.2% shipped, ≈ 30–35% effort). + - **2026-10-07 (fork, default-workspace-mode):** Preferences ▸ Documents and view remembers the workspace used for newly opened PDFs. All Tools remains the default; explicit CLI/control mode overrides last for the session and PDF Initial View metadata stays independent. Overall estimate unchanged (≈ 30–35%). - **2026-10-07 (session 15):** Five community issues, each with a regression test: in two-page view, Next page moves a whole spread (#77, #70); Export to Word opens in Word again, with control characters dropped and receipt-length pages kept within Word's 22 in, checked in Microsoft Word (#78, #72); macOS opens PDFs from Finder, Open With and the Dock (Apple events through the audited `fmv-macos-events`, PDF declared in the Info.plist), and the packaging no longer describes the image editor it was copied from (#82, #73); Add text keeps the box being typed when you click elsewhere, with Done and Discard beside it, reproduced and checked with real OS input (#86, #74); sharper, higher-contrast text, most visible on Windows, with every text colour at WCAG AA in both themes (#84, #76; the console window was already gone in 0.2.1). Still open: #79 (layered "not verified" signature appearances), #80 (native macOS menu bar), #81 (Homebrew and Scoop). P0 88%, P1 53%; 805 features 50.2% shipped (53.7% weighted). ≈ 30–35%. diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index a941f85e..45d9d6c5 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -99,6 +99,8 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Date", "clock-3", "sign.fill.date", Ready), item("Signature", "signature", "sign.fill.signature", Ready), item("Initials", "signature", "sign.fill.initials", Ready), + item("Change signature", "signature", "sign.fill.signature.change", Ready), + item("Change initials", "signature", "sign.fill.initials.change", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1812a91b..80546afc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -252,6 +252,10 @@ pub const COMMANDS: &[CommandSpec] = &[ c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"), c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"), c("sign.fill.initials", "Fill & Sign: initials", None, None, Annotate, "signature"), + c("sign.fill.signature.change", "Fill & Sign: change signature", None, None, Nothing, "signature"), + c("sign.fill.signature.remove", "Fill & Sign: remove saved signature", None, None, Nothing, "x"), + c("sign.fill.initials.remove", "Fill & Sign: remove saved initials", None, None, Nothing, "x"), + c("sign.fill.initials.change", "Fill & Sign: change initials", None, None, Nothing, "signature"), c("export.image", "Export to image…", FILE, None, Document, "image"), c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 108388ed..88efc67c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,19 +56,21 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{ScriptOutline, script_outline}; +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option { let o = script_outline(text); - let span = (o.ascent - o.descent).max(0.1); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; if o.contours.is_empty() || o.width <= 0.0 { return None; } let k = height / span; - let rect = [at[0], at[1] - height / 2.0, at[0] + o.width * k, at[1] + height / 2.0]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| [p[0] / o.width, (p[1] - o.descent) / span]).collect()).collect(); + let rect = [at[0], at[1] - height / 2.0, at[0] + width * k, at[1] + height / 2.0]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| [(p[0] - left) / width, (p[1] - bottom) / span]).collect()).collect(); Some(Shape::TypedSignature { rect, contours }) } /// Comment geometry helpers (text-box line breaking) for frontends. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 2d39c1a7..80fe750f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -10,7 +10,7 @@ mod encodings; pub mod pdf; mod script; pub use craft::{CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, ui_japanese_fonts}; -pub use script::{GlyphError, GlyphOutline, ScriptOutline, japanese_glyph, script_outline}; +pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, script_outline}; /// Approximate advance of `s` in Helvetica (or Arial) at `size` points. pub fn helvetica_width(s: &str, size: f64) -> f64 { diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index cc285e1c..23000ffb 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -6,6 +6,9 @@ use skrifa::{FontRef, MetadataProvider}; static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf"); +/// Bound signature work while allowing long personal names. +pub const MAX_SIGNATURE_CHARS: usize = 256; + /// Outlines of a line of existing text at a font size of 1 (em units). #[derive(Clone, Debug, Default, PartialEq)] pub struct ScriptOutline { @@ -15,6 +18,19 @@ pub struct ScriptOutline { pub descent: f64, } +impl ScriptOutline { + /// Layout bounds including glyph overhangs, so previews and PDF appearances never clip ink. + pub fn bounds(&self) -> [f64; 4] { + self.contours.iter().flatten().fold([0.0, self.descent, self.width, self.ascent], |mut b, p| { + b[0] = b[0].min(p[0]); + b[1] = b[1].min(p[1]); + b[2] = b[2].max(p[0]); + b[3] = b[3].max(p[1]); + b + }) + } +} + /// One glyph outline in em units, with the baseline at y = 0. #[derive(Clone, Debug, PartialEq)] pub struct GlyphOutline { @@ -38,11 +54,12 @@ struct Flatten { dx: f64, points: usize, too_complex: bool, + max_points: usize, } impl Flatten { fn new(scale: f64) -> Self { - Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false } + Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false, max_points: 4096 } } fn pt(&self, x: f32, y: f32) -> [f64; 2] { @@ -54,8 +71,7 @@ impl Flatten { } fn push(&mut self, p: [f64; 2]) { - const MAX_POINTS: usize = 4096; - if self.points >= MAX_POINTS { + if self.points >= self.max_points { self.too_complex = true; return; } @@ -150,7 +166,11 @@ pub fn japanese_glyph(ch: char) -> Result { } /// The outlines of `text` in the script font (characters it lacks are skipped). +/// Over-limit input returns an empty outline instead of a silently truncated signature. pub fn script_outline(text: &str) -> ScriptOutline { + if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS { + return ScriptOutline::default(); + } let Ok(font) = FontRef::new(FONT) else { return ScriptOutline::default() }; let loc = LocationRef::default(); let metrics = font.metrics(Size::unscaled(), loc); @@ -159,6 +179,9 @@ pub fn script_outline(text: &str) -> ScriptOutline { let glyphs = font.outline_glyphs(); let advances = font.glyph_metrics(Size::unscaled(), loc); let mut pen = Flatten::new(scale); + // The 4096-point budget is for one untrusted document glyph (japanese_glyph), not a + // whole signature in our bundled font. Keep a separate bounded budget for the line. + pen.max_points = 262_144; for ch in text.chars() { let Some(gid) = charmap.map(ch) else { continue }; if let Some(g) = glyphs.get(gid) { @@ -168,11 +191,26 @@ pub fn script_outline(text: &str) -> ScriptOutline { pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale; } pen.close(); + if pen.too_complex { + return ScriptOutline::default(); + } ScriptOutline { contours: pen.contours, width: pen.dx, ascent: metrics.ascent as f64 * scale, descent: metrics.descent as f64 * scale } } #[cfg(test)] mod tests { + #[test] + fn long_names_keep_every_glyph_with_bounded_outline_work() { + let text = "Alexandria Catherine Elizabeth Montgomery-Wellington"; + let o = super::script_outline(text); + let expected: usize = text.chars().map(|c| super::script_outline(&c.to_string()).contours.len()).sum(); + assert_eq!(o.contours.len(), expected, "the entire name must be drawn"); + let max_x = o.contours.iter().flatten().map(|p| p[0]).fold(0.0, f64::max); + assert!(max_x > o.width - 0.5, "ink reaches the last letter: {max_x} / {}", o.width); + assert!(!super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS)).contours.is_empty()); + assert!(super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS + 1)).contours.is_empty()); + } + #[test] fn a_name_becomes_outlines() { let o = super::script_outline("Ada L."); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d739e76..bb09e782 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2050,6 +2050,9 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { .gap(6.0) .show(|ui| { for tool in crate::fill_sign::FILL_TOOLS { + if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) { + continue; + } let on = current_fill == Some(tool); let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); if click.hovered() { @@ -2078,6 +2081,11 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { ui.close(); } } + ui.separator(); + if let Some(command) = crate::fill_sign::signature_entries(ui, app, &t) { + app.execute(command); + ui.close(); + } }); if let QuickTool::Comment(tool) = app.quick_tool { let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 276f9c03..6dd10b7e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -417,6 +417,17 @@ impl PdfCraftApp { } self.notify(format!("Click on the page to add a {}, or drag to set its size", tool.label().to_lowercase())); } + "sign.fill.signature.remove" => self.signature = None, + "sign.fill.initials.remove" => self.initials = None, + "sign.fill.signature.change" | "sign.fill.initials.change" => { + let initials = id == "sign.fill.initials.change"; + let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() }; + self.signature_draft = saved.map_or_else( + || crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author), + |s| crate::fill_sign::SigDraft::from_saved(initials, s), + ); + self.dialog = Some(Dialog::Signature); + } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return false }; self.quick_tool = crate::QuickTool::Fill(tool); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index d569bc7f..61a9bfea 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -42,16 +42,31 @@ pub struct SigDraft { 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 } + 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() } + 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 { @@ -168,7 +183,13 @@ pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, au pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option { match sig { SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author), - SavedSig::Typed(text) => typed_signature_at(page, at, text, if initials { 24.0 } else { 32.0 }, 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) + } } } @@ -176,17 +197,19 @@ pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: 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 span = (o.ascent - o.descent).max(0.1); + 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) / o.width.max(0.01)); - let x0 = (w as f64 - o.width * k) / 2.0; + 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> = o .contours .iter() - .map(|c| c.iter().map(|p| (x0 + p[0] * k, h as f64 * 0.5 + (o.ascent + o.descent) / 2.0 * k - p[1] * k)).collect()) + .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; @@ -201,7 +224,7 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage } xs.sort_by(|a, b| a.total_cmp(b)); for pair in xs.as_chunks::<2>().0 { - let (from, to) = (pair[0].round().max(0.0) as usize, (pair[1].round() as usize).min(w)); + 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; } @@ -210,6 +233,88 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage 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 Some(saved) = saved else { + if crate::widgets::ghost_button(ui, "plus", &format!("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()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Use {what}"))); + 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(format!("Place saved {what}")).clicked() { + command = Some(use_id); + } + if crate::icons::button(ui, "pencil", 26.0, false, &format!("Change {what}")).clicked() { + command = Some(change_id); + } + if crate::icons::button(ui, "x", 26.0, false, &format!("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( @@ -317,7 +422,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, /// 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 { "initials" } else { "signature" }; - ui.label(egui::RichText::new(format!("Create {what}")).font(crate::theme::semibold(18.0))); + let action = if d.editing { "Change" } else { "Create" }; + ui.label(egui::RichText::new(format!("{action} {what}")).font(crate::theme::semibold(18.0))); ui.horizontal(|ui| { if crate::widgets::pill_button(ui, "Type", !d.drawing).clicked() { d.drawing = false; @@ -329,8 +435,14 @@ pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, pre ui.add_space(6.0); if !d.drawing { let l = ui.label(egui::RichText::new(format!("Type your {what}.")).color(t.text_muted)); - ui.add(egui::TextEdit::singleline(&mut d.text).desired_width(460.0).hint_text(if d.initials { "Initials" } else { "Your name" })) - .labelled_by(l.id); + ui.add( + egui::TextEdit::singleline(&mut d.text).char_limit(pdfcraft_engine::MAX_SIGNATURE_CHARS).desired_width(460.0).hint_text(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); @@ -391,3 +503,24 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) { }); (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); + } + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0309a54f..f5553d2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -453,6 +453,7 @@ pub struct PdfCraftApp { /// The Create signature / initials dialog, and its typed preview. pub signature_draft: fill_sign::SigDraft, pub(crate) signature_preview: Option<(String, egui::TextureHandle)>, + pub(crate) saved_signature_previews: [Option<(String, egui::TextureHandle)>; 2], /// The Comment Properties dialog's state. pub comment_props: Option, pub field_props: Option, @@ -617,6 +618,7 @@ impl PdfCraftApp { initials: None, signature_draft: Default::default(), signature_preview: None, + saved_signature_previews: [None, None], comment_props: None, field_props: None, redact_prefs: RedactPrefs::default(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8f05b413..1f6ebd99 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -156,6 +156,9 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static for s in g.sections { widgets::section_title(ui, s.title); for item in s.items { + if g.id == "fill_sign" && (item.command.starts_with("sign.fill.signature") || item.command.starts_with("sign.fill.initials")) { + continue; + } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, item.label)); let ready = item.availability == Availability::Ready; @@ -186,6 +189,12 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } } } + if g.id == "fill_sign" { + widgets::section_title(ui, "Sign yourself"); + if let Some(cmd) = crate::fill_sign::signature_entries(ui, app, t) { + run = Some(cmd); + } + } }); if footer { ui.add_space(8.0); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index fe38a6c8..25da10b3 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -451,6 +451,30 @@ fn select_all_key_selects_every_page_in_organize() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["dirty"], false); } +#[test] +fn saved_signature_can_be_changed_through_the_control_channel() { + let (mut h, c) = harness(); + h.state_mut().signature = Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Ada Lovelace".into())); + ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.signature.change" })); + h.run_steps(3); + assert_eq!(h.state().signature_draft.text, "Ada Lovelace"); + let fields = ok(&mut h, &c, "ui.inspect", json!({ "role": "TextInput" })); + let field = fields["widgets"].as_array().unwrap().iter().find(|w| w["value"] == "Ada Lovelace").expect("the signature text field"); + let r = field["rect"].as_array().unwrap(); + let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| r[i].as_f64().unwrap()); + ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 })); + ok(&mut h, &c, "ui.key", json!({ "key": "A", "modifiers": ["command"] })); + ok(&mut h, &c, "ui.type", json!({ "text": "Grace Hopper" })); + ok(&mut h, &c, "ui.click", json!({ "label": "Apply" })); + assert_eq!(h.state().signature, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Grace Hopper".into()))); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["quick_tool"], "fill-signature"); + ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.signature.remove" })); + assert_eq!(h.state().signature, None); + h.state_mut().initials = Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into())); + ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.initials.remove" })); + assert_eq!(h.state().initials, None); +} + #[test] fn drawing_a_comment_by_drag_and_its_context_menu() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 187bea3a..c256e649 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -3,6 +3,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; const FIXTURE: &[u8] = b"%PDF-1.7 @@ -131,3 +132,147 @@ fn typed_signatures_and_initials() { assert_eq!(again.signature, h.state().signature); assert_eq!(again.initials, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into()))); } + +#[test] +fn changing_saved_signatures_and_initials_preserves_placed_marks() { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "Sign still reuses the saved signature"); + click(&mut h, 40.0, 300.0); + let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations); + + // The quick toolbar exposes replacement without changing the normal placement action. + h.get_by_label("Fill & Sign").click(); + h.run_steps(2); + h.get_by_label("Change signature").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Signature)); + assert_eq!(h.state().signature_draft.text, "Ada Lovelace"); + h.get_by_label("Type your signature.").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Grace Hopper".into())); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into()))); + assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into()))); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations), original); + click(&mut h, 40.0, 200.0); + assert_eq!(items(&h).len(), 2); + + h.get_by_label("Fill & Sign").click(); + h.run_steps(2); + h.get_by_label("Change initials").click(); + h.run_steps(2); + assert!(h.state().signature_draft.initials); + assert_eq!(h.state().signature_draft.text, "AL"); + h.state_mut().signature_draft.text = "GH".into(); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials)); + click(&mut h, 40.0, 100.0); + assert_eq!(items(&h).len(), 3); + let mut again = PdfCraftApp::new(); + again.restore(&h.state().persist()); + assert_eq!(again.signature, Some(SavedSig::Typed("Grace Hopper".into()))); + assert_eq!(again.initials, Some(SavedSig::Typed("GH".into()))); +} + +#[test] +fn changing_drawn_signatures_can_be_cancelled_or_switched_to_type() { + let mut h = harness(); + let drawn = SavedSig::Drawn(vec![vec![[0.1, 0.1], [0.5, 0.2], [0.9, 0.1]]]); + h.state_mut().signature = Some(drawn.clone()); + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check); + assert!(h.state_mut().execute("sign.fill.signature.change")); + h.run_steps(2); + assert!(h.state().signature_draft.drawing); + assert_eq!(h.state().signature_draft.saved(), drawn); + h.get_by_label("Clear").click(); + h.run_steps(2); + assert!(h.state().signature_draft.strokes.is_empty()); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(drawn.clone())); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); + + assert!(h.state_mut().execute("sign.fill.signature.change")); + h.run_steps(2); + assert_eq!(h.state().signature_draft.saved(), drawn); + h.get_all_by_label("Type").last().unwrap().click(); + h.run_steps(2); + h.state_mut().signature_draft.text = "Grace Hopper".into(); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into()))); + assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into()))); +} + +#[test] +fn saved_signature_cards_remove_and_add_without_changing_the_document() { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().execute("sign.fill.signature"); + click(&mut h, 40.0, 300.0); + let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations); + h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("fill_sign"); + h.state_mut().left_open = true; + h.run_steps(3); + h.get_by_label("Use signature").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "the preview reuses the saved value"); + h.get_by_label("Remove saved signature").click(); + h.run_steps(3); + assert_eq!(h.state().signature, None); + assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into()))); + let mut again = PdfCraftApp::new(); + again.restore(&h.state().persist()); + assert_eq!(again.signature, None, "removal survives restart"); + h.get_by_label("Add signature").click(); + h.run_steps(3); + h.state_mut().signature_draft.text = "Grace Hopper".into(); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into()))); + h.get_by_label("Remove saved initials").click(); + h.run_steps(3); + assert_eq!(h.state().initials, None); + h.get_by_label("Add initials").click(); + h.run_steps(3); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().initials, None); + assert_eq!(format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations), original); +} + +#[test] +fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() { + let text = "Alexandria Catherine Elizabeth Montgomery-Wellington"; + let sig = SavedSig::Typed(text.into()); + let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, [40.0, 200.0], &sig, false, "").unwrap() else { + panic!("expected annotation"); + }; + let pdfcraft_engine::Shape::TypedSignature { rect, contours } = a.shape else { panic!("expected typed signature") }; + assert!((rect[2] - rect[0] - 150.0).abs() < 0.001, "long names shrink to fit: {rect:?}"); + assert_eq!(contours.len(), pdfcraft_engine::script_outline(text).contours.len()); + assert!(contours.iter().flatten().all(|p| p.iter().all(|v| (0.0..=1.0).contains(v))), "all ink stays inside the appearance bounds"); + let mut h = harness(); + h.state_mut().signature = Some(sig.clone()); + h.state_mut().execute("sign.fill.signature.change"); + h.run_steps(3); + assert_eq!(h.state().signature_draft.text, text); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(sig)); + click(&mut h, 40.0, 200.0); + let s = h.state(); + let a = &s.session.get(s.views[0].id).unwrap().info.annotations[0]; + assert!(a.rect[2] <= 190.001, "the full name fits on the page: {:?}", a.rect); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9e33ad21..31056b33 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5212,10 +5212,10 @@ area = "F" tier = "P0" milestone = "M5" status = "partial" -commands = ["sign.fill.signature", "sign.fill.initials"] +commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded) or drawn, saved in settings; image signatures not yet" +evidence = ["crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded) or drawn, saved in settings; preview cards with add/change/remove controls in the panel and picker, also through ui.command; changes leave placed annotations untouched; long names fit within 150 pt with overhangs included in preview/PDF bounds; image signatures not yet" [[feature]] id = "form.fill-sign-flatten"