* #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:
parent
d0991a8cd5
commit
89896af68b
|
|
@ -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%.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue