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 <noreply@openai.com>

* cargo fmt after the rename

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DWAA1660 2026-10-07 19:37:28 -06:00 committed by GitHub
parent d0991a8cd5
commit 89896af68b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 402 additions and 22 deletions

View File

@ -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%.

View File

@ -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),
],
}],
},

View File

@ -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"),

View File

@ -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<Shape> {
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.

View File

@ -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 {

View File

@ -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<GlyphOutline, GlyphError> {
}
/// 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.");

View File

@ -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());

View File

@ -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);

View File

@ -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<Edit> {
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<Vec<(f64, f64)>> = 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);
}
}
}
}
}

View File

@ -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<comment_props::PropsDraft>,
pub field_props: Option<prepare::FieldDraft>,
@ -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(),

View File

@ -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);

View File

@ -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();

View File

@ -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);
}

View File

@ -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"