From 2c69c026990f22b1df16074de9778d9c3a176fba Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:21:11 -0400 Subject: [PATCH] M5.7: Fill & Sign: typewriter text, check/cross/dot/line marks (PrintCraft-drawn stamps), today's date (local time), drawn signature (signature pad, saved in settings, placed by click); quick-bar Fill & Sign flyout, 7 commands, catalog Ready; fill_sign_add tool (47 tools) for agents filling flat forms; local UTC offset via chrono (already in the graph) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/annot/src/appearance.rs | 50 +++++ crates/annot/src/lib.rs | 76 +++++++- crates/annot/src/tests.rs | 31 +++ crates/automation/src/comments.rs | 39 ++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 17 ++ crates/automation/tests/automation.rs | 15 ++ crates/engine/Cargo.toml | 4 + crates/engine/src/catalog.rs | 14 +- crates/engine/src/commands.rs | 7 + crates/engine/src/lib.rs | 31 ++- crates/ui-egui/src/canvas.rs | 85 +++++++- crates/ui-egui/src/commands.rs | 8 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/fill_sign.rs | 266 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 22 ++- crates/ui-egui/tests/fill_sign.rs | 100 ++++++++++ parity/acrobat-features.toml | 46 ++--- 20 files changed, 781 insertions(+), 43 deletions(-) create mode 100644 crates/ui-egui/src/fill_sign.rs create mode 100644 crates/ui-egui/tests/fill_sign.rs diff --git a/Cargo.lock b/Cargo.lock index 173b630391..febc7d00ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3099,6 +3099,7 @@ dependencies = [ name = "printcraft-engine" version = "0.1.0" dependencies = [ + "chrono", "png 0.18.1", "printcraft-annot", "printcraft-cos", diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a2610370a8..333127b3b5 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -275,6 +275,56 @@ pub fn build(d: &Dict) -> Option { c.push_str("S\n"); } } + b"Stamp" => { + // Only PrintCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), + n(y0 + h / 2.0), + n(x1), + n(y0 + h / 2.0) + )), + _ => return None, + } + } b"FreeText" => { // Callouts (`/CL`) and rich text without plain contents aren't drawn yet. if d.contains(b"CL") { diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6e74881b0b..0a9550ffc5 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -88,6 +88,36 @@ impl NoteIcon { } } +/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMark { + Check, + Cross, + Dot, + Line, +} + +impl FillMark { + /// The `/Name` of the stamp PrintCraft draws for it. + pub fn name(self) -> &'static str { + match self { + FillMark::Check => "PCCheck", + FillMark::Cross => "PCCross", + FillMark::Dot => "PCDot", + FillMark::Line => "PCLine", + } + } + + pub fn label(self) -> &'static str { + match self { + FillMark::Check => "Checkmark", + FillMark::Cross => "Cross", + FillMark::Dot => "Dot", + FillMark::Line => "Line", + } + } +} + /// Geometry of a new comment, in PDF user space of its page. #[derive(Clone, Debug, PartialEq)] pub enum Shape { @@ -123,6 +153,20 @@ pub enum Shape { rect: [f64; 4], font_size: f64, }, + /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). + Typewriter { + rect: [f64; 4], + font_size: f64, + }, + /// A Fill & Sign mark in `rect`. + Mark { + rect: [f64; 4], + mark: FillMark, + }, + /// A drawn signature (ink strokes). + Signature { + strokes: Vec>, + }, } impl Shape { @@ -133,8 +177,9 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } => "Ink", - Shape::TextBox { .. } => "FreeText", + Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::TextBox { .. } | Shape::Typewriter { .. } => "FreeText", + Shape::Mark { .. } => "Stamp", } } } @@ -169,7 +214,9 @@ impl Style { Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } => ([0.89, 0.13, 0.13], 2.0), Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), - Shape::TextBox { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), }; Self { color, opacity: 1.0, width, fill: None } } @@ -352,7 +399,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { } bounds(quads.iter().flat_map(|q| q.chunks_exact(2).map(|p| [p[0], p[1]]).collect::>())).ok_or_else(|| bad("text area"))? } - Shape::Rectangle { rect } | Shape::Oval { rect } | Shape::TextBox { rect, .. } => { + Shape::Rectangle { rect } + | Shape::Oval { rect } + | Shape::TextBox { rect, .. } + | Shape::Typewriter { rect, .. } + | Shape::Mark { rect, .. } => { let r = normalize(*rect); if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { return Err(bad("rectangle (too small)")); @@ -366,7 +417,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } - Shape::Ink { strokes } => { + Shape::Ink { strokes } | Shape::Signature { strokes } => { if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } @@ -411,6 +462,9 @@ fn subject(shape: &Shape) -> &'static str { Shape::Line { .. } => "Line", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", + Shape::Typewriter { .. } => "Typewriter", + Shape::Mark { mark, .. } => mark.label(), + Shape::Signature { .. } => "Signature", } } @@ -462,13 +516,21 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); } - Shape::Ink { strokes } => { + Shape::Mark { mark, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(mark.name())); + border(&mut d); + } + Shape::Ink { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); d.set(b"InkList".to_vec(), Object::Array(list)); border(&mut d); } - Shape::TextBox { font_size, .. } => { + Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } => { + if matches!(new.shape, Shape::Typewriter { .. }) { + d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); + } let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 944096fc6b..48c42be3c1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -301,3 +301,34 @@ fn wrapping_and_widths() { assert_eq!(n(-0.0001), "0"); assert_eq!(n(2.50), "2.5"); } + +#[test] +fn fill_and_sign_items_are_drawn() { + let mut doc = fixture(); + let add = |doc: &mut Document, shape: Shape, contents: &str| { + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 1, shape, style, contents: contents.into(), author: "Ada".into() }, &meta("x")).unwrap() + }; + let t = add(&mut doc, Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }, "Ada Lovelace"); + let marks: Vec = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line] + .into_iter() + .enumerate() + .map(|(k, m)| add(&mut doc, Shape::Mark { rect: [100.0 + 20.0 * k as f64, 600.0, 114.0 + 20.0 * k as f64, 614.0], mark: m }, "")) + .collect(); + let s = add(&mut doc, Shape::Signature { strokes: vec![vec![[10.0, 10.0], [30.0, 20.0], [50.0, 10.0]]] }, ""); + let doc = reopen(&doc); + let all = list(&doc, 1); + assert_eq!(all[t].name(b"IT"), Some(&b"FreeTextTypeWriter"[..])); + assert!(ap_content(&doc, &all[t]).contains("(Ada Lovelace) Tj")); + for (k, m) in marks.iter().enumerate() { + assert_eq!(all[*m].name(b"Subtype"), Some(&b"Stamp"[..])); + let ap = ap_content(&doc, &all[*m]); + assert!(ap.contains(if k == 2 { " c" } else { " l" }), "mark {k}: {ap}"); + } + assert_eq!(text(&all[marks[0]], b"Subj"), "Checkmark"); + assert_eq!(text(&all[s], b"Subj"), "Signature"); + // Other stamps still can't be restyled; ours can. + let mut doc = doc; + set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err()); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3f4a5e5f3b..7a0745ba24 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -249,6 +249,45 @@ impl Automation { Ok(out) } + pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result { + use printcraft_engine::FillMark; + let page = self.page(a)?; + let info = self.doc(a)?.info.pages[page].clone(); + let [x, y] = a.need::<2>("at", "Fill & Sign")?; + let at = to_user(&info, x, y); + let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); + let size = 10.0; + let text_at = |t: &str| { + let w = (printcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); + let h = size * 1.2 + 6.0; + Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size } + }; + let (shape, contents) = match a.str("type")? { + "text" => { + let t = a.str("text")?.to_string(); + (text_at(&t), t) + } + "date" => { + let (yy, m, d) = self.session.today(); + let t = format!("{m}/{d}/{yy}"); + (text_at(&t), t) + } + kind => { + let mark = match kind { + "check" => FillMark::Check, + "cross" => FillMark::Cross, + "dot" => FillMark::Dot, + "line" => FillMark::Line, + other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))), + }; + let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + (Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new()) + } + }; + let style = Style::default_for(&shape); + self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) + } + pub(crate) fn comment_reply(&mut self, a: &Args) -> Result { let (page, index) = self.comment_target(a)?; let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 45eda985a9..17c8832fb9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -226,6 +226,7 @@ impl Automation { "form_fields" => self.form_fields(&a)?, "form_fill" => self.form_fill(&a)?, "form_reset" => self.form_reset(&a)?, + "fill_sign_add" => self.fill_sign_add(&a)?, "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "comment_reply" => self.comment_reply(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4de3478f4a..98d69a4947 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -352,6 +352,23 @@ pub fn tools() -> Vec { t("doc_export_text", "Export text", "Write the reading-order text of pages to a .txt file (pages separated by form feeds).") .cmd("export.text") .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "pages": pages("to export (default: all)") }), &["doc", "path"])), + t( + "fill_sign_add", + "Fill & Sign: type text or place a mark", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, or today's date, at `at` [x, y] in points from the top-left of the page (the text's top-left; a mark's centre). Creates movable, undoable annotations.", + ) + .cmd("sign.fill.text") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date"] }, + "at": point(), + "text": { "type": "string", "minLength": 1 }, + "author": { "type": "string" }, + }), + &["doc", "page", "type", "at"], + )), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 4451af67a9..daf817471e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -532,3 +532,18 @@ fn exporting_images_and_text_through_tools() { assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "Page 1\n\u{c}Page 2\n\u{c}Page 3\n"); assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root"); } + +#[test] +fn fill_and_sign_through_tools() { + let dir = workdir("fill"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Ada Lovelace" })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [20, 80] })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 100] })); + let texts = page_text(&mut a, doc); + assert!(texts[0].contains("Ada Lovelace") && texts[0].contains("11/14/2023"), "{texts:?}"); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 3); + assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 3186dc065d..62cea75ea7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,3 +18,7 @@ printcraft-edit = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } png = "0.18" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Already in the graph through lopdf; used only for the local UTC offset ("today"). +chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1a52d24609..64ba7a411d 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -85,15 +85,17 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "pen-line", hue: PURPLE, badge: None, - availability: Planned("M5"), + availability: Ready, sections: &[ToolSection { title: "Fill", items: &[ - item("Add text", "type", "sign.fill.text", Planned("M5")), - item("Checkmark", "check", "sign.fill.check", Planned("M5")), - item("Cross", "x", "sign.fill.cross", Planned("M5")), - item("Date", "clock-3", "sign.fill.date", Planned("M5")), - item("Signature", "signature", "sign.fill.signature", Planned("M5")), + item("Add text", "type", "sign.fill.text", Ready), + item("Checkmark", "check", "sign.fill.check", Ready), + item("Cross", "x", "sign.fill.cross", Ready), + item("Dot", "circle-dot", "sign.fill.dot", Ready), + item("Line", "minus", "sign.fill.line", Ready), + item("Date", "clock-3", "sign.fill.date", Ready), + item("Signature", "signature", "sign.fill.signature", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index bee374b924..88fe170ea2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -165,6 +165,13 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.circle", "Draw an oval", None, None, Annotate, "circle"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), + c("sign.fill.text", "Fill & Sign: add text", None, None, Annotate, "type"), + c("sign.fill.check", "Fill & Sign: checkmark", None, None, Annotate, "check"), + c("sign.fill.cross", "Fill & Sign: cross", None, None, Annotate, "x"), + c("sign.fill.dot", "Fill & Sign: dot", None, None, Annotate, "circle-dot"), + c("sign.fill.line", "Fill & Sign: line", None, None, Annotate, "minus"), + c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"), + c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"), c("export.image", "Export to image…", FILE, None, Document, "image"), c("export.text", "Export to text…", FILE, None, Document, "type"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 325d9e77a1..e0f36716af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,7 +26,7 @@ pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, Field /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; -pub use printcraft_annot::{Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; +pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; pub type SplitPart = (usize, usize, Arc>); @@ -566,6 +566,12 @@ fn annotation_noun(s: &Shape) -> &'static str { Shape::Line { .. } => "line", Shape::Ink { .. } => "drawing", Shape::TextBox { .. } => "text box", + Shape::Typewriter { .. } => "text", + Shape::Mark { mark: FillMark::Check, .. } => "checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "cross", + Shape::Mark { mark: FillMark::Dot, .. } => "dot", + Shape::Mark { mark: FillMark::Line, .. } => "line", + Shape::Signature { .. } => "signature", } } @@ -641,6 +647,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), /// Dates and unique ids stamped onto what an edit creates. struct EditCtx { date: Option, + /// Today in local time, for date tokens. + today: (i64, u32, u32), seed: u64, count: u64, } @@ -656,7 +664,7 @@ impl EditCtx { // Real clock: mix in sub-second time so ids from two sessions don't collide. seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; } - Self { date: now.map(printcraft_cos::pdf_date), seed, count: 0 } + Self { date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 } } /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the @@ -747,7 +755,7 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - printcraft_forms::reset(doc, names.as_deref())?; } Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.date.as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)); + let date = cx.today; printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?; } Edit::AddWatermark { pages, settings, replace } => printcraft_edit::add_watermark(doc, pages, settings, *replace)?, @@ -810,6 +818,14 @@ fn comment_list(doc: &printcraft_cos::Document) -> Vec i64 { + #[cfg(not(target_arch = "wasm32"))] + return i64::from(chrono::Local::now().offset().local_minus_utc()); + #[cfg(target_arch = "wasm32")] + return 0; +} + /// (year, month, day) from a PDF date `D:YYYYMMDD…`. fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { let d = d.strip_prefix("D:").unwrap_or(d); @@ -906,6 +922,13 @@ impl Session { return None; } + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + pub fn today(&self) -> (i64, u32, u32) { + let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + self.now().map(|t| printcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + /// Open a document from bytes. Rendering starts lazily when pages are requested. /// /// `password` is tried as either the user or owner password when the file is encrypted. @@ -966,8 +989,10 @@ impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { let now = self.now(); + let today = self.today(); let doc = self.doc_mut(id)?; let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; if let Some(p) = editor.cos.permissions() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b10a7609a..3a0de7b7b3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -132,6 +132,8 @@ pub struct DocView { pub forms: crate::forms_ui::FormView, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, + /// Fill & Sign text being typed. + pub fill_text: Option, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, } @@ -202,6 +204,7 @@ impl DocView { comments: Default::default(), forms: Default::default(), crop_drag: None, + fill_text: None, } } @@ -778,7 +781,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let selects_text = match tool { QuickTool::Select => true, QuickTool::Comment(t) => t.markup().is_some(), - QuickTool::Hand | QuickTool::Crop => false, + QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -786,6 +789,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let can_fill = doc.allows_form_filling(); let can_crop = doc.allows_assembly(); let mut open_boxes = false; + let signature = app.signature.clone(); + let author = app.comment_prefs.author.clone(); + let today = app.session.today(); + let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut canvas_action: Option = None; @@ -899,6 +906,15 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed }; // Form fields take clicks first with the Select tool (as Acrobat fills fields in // every viewing mode); then comments; then text selection. + if let QuickTool::Fill(ft) = tool + && allowed + { + match crate::fill_sign::page_input(ui, &resp, &xf, i, info, ft, view, signature.as_ref(), &author, today) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true, + None => {} + } + } if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { view.current = i; open_boxes = true; @@ -1108,6 +1124,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form) { view.pending_edit = Some(e); } + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { + view.pending_edit = Some(e); + } let form_notice = view.forms.notice.take(); // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, printcraft_engine::Edit::SetPageBox { .. })); @@ -1128,6 +1147,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { tool = QuickTool::Select; } app.quick_tool = tool; + if open_signature { + app.signature_draft.clear(); + app.dialog = Some(crate::Dialog::Signature); + } if open_boxes { app.boxes_draft.range = crate::pageboxes::Range::Current; app.boxes_draft.seeded = None; @@ -1319,9 +1342,65 @@ fn quick_bar(app: &mut PrintCraftApp, area: Rect, ui: &mut egui::Ui) { } }); } - if icons::button(ui, "pen-line", 32.0, false, "Fill & Sign (M5)").clicked() { - app.run_command("sign.fill.text"); + // Fill & Sign ▸ (text, marks, date, signature). + let current_fill = match app.quick_tool { + QuickTool::Fill(f) => Some(f), + _ => None, + }; + let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); + let resp = icons::button( + ui, + if current_fill.is_some() { shown.icon() } else { "pen-line" }, + 32.0, + current_fill.is_some(), + "Fill & Sign", + ); + let r = resp.rect; + let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; + ui.painter().add(egui::Shape::convex_polygon( + tri.to_vec(), + if current_fill.is_some() { Color32::WHITE } else { t.text_muted }, + Stroke::NONE, + )); + if resp.clicked() && current_fill.is_none() { + app.execute(shown.command()); } + let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked(); + egui::Popup::menu(&resp) + .open_memory(open.then_some(egui::SetOpenCommand::Toggle)) + .align(egui::RectAlign::RIGHT_START) + .gap(6.0) + .show(|ui| { + for tool in crate::fill_sign::FILL_TOOLS { + let on = current_fill == Some(tool); + let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); + if click.hovered() { + ui.painter().rect_filled(row, CornerRadius::same(4), t.hover); + } + icons::paint(ui, Rect::from_min_size(row.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); + ui.painter().text( + row.left_center() + vec2(34.0, 0.0), + Align2::LEFT_CENTER, + tool.label(), + theme::regular(13.0), + t.text, + ); + if on { + icons::paint( + ui, + Rect::from_min_size(row.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), + "check", + 14.0, + t.accent, + ); + } + click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tool.label())); + if click.clicked() { + app.execute(tool.command()); + ui.close(); + } + } + }); if let QuickTool::Comment(tool) = app.quick_tool { let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index fee96e36b2..64f6ff87f4 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -195,6 +195,14 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { + let tool = crate::fill_sign::FillTool::from_command(fill).expect("checked"); + self.quick_tool = crate::QuickTool::Fill(tool); + if tool == crate::fill_sign::FillTool::Signature && self.signature.is_none() { + self.signature_draft.clear(); + self.dialog = Some(Dialog::Signature); + } + } "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d73a6f0786..99e6969202 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -538,6 +538,7 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Select => "select".to_string(), crate::QuickTool::Hand => "hand".to_string(), crate::QuickTool::Crop => "crop".to_string(), + crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(), }, "home": self.active.is_none(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 68537c7aa8..dbfd4bae28 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -225,6 +225,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::Signature => { + let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft); + if apply { + app.signature = Some(std::mem::take(&mut app.signature_draft)); + app.quick_tool = crate::QuickTool::Fill(crate::fill_sign::FillTool::Signature); + app.toast = None; + } + close = apply || cancel; + return; + } Dialog::Export(kind) => { let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind); export_now = apply; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs new file mode 100644 index 0000000000..120a8ad994 --- /dev/null +++ b/crates/ui-egui/src/fill_sign.rs @@ -0,0 +1,266 @@ +//! Fill & Sign (Acrobat's Fill & Sign tool, execution plan M5.7): type text onto the page, place +//! ✓ ✕ ● ─ marks and today's date, and sign with a drawn signature. Everything is an annotation +//! (typewriter text, PrintCraft-drawn stamps, ink), so it can be moved, deleted and undone like +//! any comment. + +use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; +use printcraft_engine::{Edit, FillMark, NewAnnotation, Shape, Style}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::Tokens; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FillTool { + Text, + Check, + Cross, + Dot, + Line, + Date, + Signature, +} + +pub const FILL_TOOLS: [FillTool; 7] = + [FillTool::Text, FillTool::Cross, FillTool::Check, FillTool::Dot, FillTool::Line, FillTool::Date, FillTool::Signature]; + +impl FillTool { + pub fn command(self) -> &'static str { + match self { + FillTool::Text => "sign.fill.text", + FillTool::Check => "sign.fill.check", + FillTool::Cross => "sign.fill.cross", + FillTool::Dot => "sign.fill.dot", + FillTool::Line => "sign.fill.line", + FillTool::Date => "sign.fill.date", + FillTool::Signature => "sign.fill.signature", + } + } + + pub fn from_command(id: &str) -> Option { + FILL_TOOLS.into_iter().find(|t| t.command() == id) + } + + pub fn label(self) -> &'static str { + match self { + FillTool::Text => "Add text", + FillTool::Check => "Checkmark", + FillTool::Cross => "Cross", + FillTool::Dot => "Dot", + FillTool::Line => "Line", + FillTool::Date => "Date", + FillTool::Signature => "Sign", + } + } + + pub fn icon(self) -> &'static str { + match self { + FillTool::Text => "type", + FillTool::Check => "check", + FillTool::Cross => "x", + FillTool::Dot => "circle-dot", + FillTool::Line => "minus", + FillTool::Date => "clock-3", + FillTool::Signature => "signature", + } + } + + fn mark(self) -> Option { + match self { + FillTool::Check => Some(FillMark::Check), + FillTool::Cross => Some(FillMark::Cross), + FillTool::Dot => Some(FillMark::Dot), + FillTool::Line => Some(FillMark::Line), + _ => None, + } + } +} + +/// Text being typed onto a page: (page, top-left in user space, text, focus requested). +#[derive(Clone, Debug, PartialEq)] +pub struct TypeBox { + pub page: usize, + pub at: [f64; 2], + pub text: String, + pub focus: bool, +} + +/// The size Fill & Sign uses for typed text (Acrobat's default is 10 pt). +pub const TEXT_SIZE: f64 = 10.0; + +fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] +} + +fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { + let style = Style::default_for(&shape); + Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author: author.to_string() }) +} + +/// A typewriter annotation sized to its text. +pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit { + let rect = crate::comments::text_box_rect(at, text, TEXT_SIZE); + new(page, Shape::Typewriter { rect, font_size: TEXT_SIZE }, text.to_string(), author) +} + +/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`, +/// 150 pt wide. +pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option { + let w = 150.0; + let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1]))); + if !min_y.is_finite() { + return None; + } + let h = f64::from(max_y - min_y).max(0.05) * w; + let strokes: Vec> = strokes + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect()) + .collect(); + (!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author)) +} + +/// Clicks with a Fill & Sign tool on one page. Returns `true` when the click was used. +#[allow(clippy::too_many_arguments)] +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + tool: FillTool, + view: &mut DocView, + signature: Option<&Vec>>, + author: &str, + today: (i64, u32, u32), +) -> Option { + let pointer = ui.input(|i| i.pointer.hover_pos())?; + if !xf.rect.contains(pointer) { + return None; + } + ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); + if !resp.clicked() { + return None; + } + let at = to_user(xf, info, page, pointer); + match tool { + FillTool::Text => { + view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true }); + None + } + FillTool::Date => { + let (y, m, d) = today; + Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author)))) + } + FillTool::Signature => match signature { + Some(s) => signature_at(page, at, s, author).map(|e| FillAction::Edit(Box::new(e))), + None => Some(FillAction::CreateSignature), + }, + mark => { + let mark = mark.mark().expect("marks"); + let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + let rect = [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]; + Some(FillAction::Edit(Box::new(new(page, Shape::Mark { rect, mark }, String::new(), author)))) + } + } +} + +/// What a Fill & Sign click asks the app to do. +#[derive(Clone, Debug, PartialEq)] +pub enum FillAction { + Edit(Box), + /// No signature yet: open the signature pad. + CreateSignature, +} + +/// The in-place editor for typed text. Returns the edit once committed. +pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, author: &str) -> Option { + let tb = view.fill_text.clone()?; + let xf = view.page_xform(tb.page)?; + let v = info.pages.get(tb.page)?.user_to_view(tb.at[0] as f32, tb.at[1] as f32); + let pos = xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph); + let zoom = xf.rect.width() / xf.pw.max(1.0); + let mut commit = false; + let mut cancel = false; + egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let t = view.fill_text.as_mut().expect("checked"); + let width = ((t.text.len().max(8) as f32) * TEXT_SIZE as f32 * 0.6 * zoom).clamp(60.0, 600.0); + let r = ui.add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((TEXT_SIZE as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text("Type text") + .id_salt("fill-text-edit"), + ); + if t.focus { + r.request_focus(); + t.focus = false; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } else if r.lost_focus() { + commit = true; + } + }); + if cancel { + view.fill_text = None; + return None; + } + if commit { + let tb = view.fill_text.take()?; + if !tb.text.trim().is_empty() { + return Some(typed(tb.page, tb.at, tb.text.trim(), author)); + } + } + None +} + +/// The signature pad: draw with the pointer; returns the strokes (normalised) on Apply. +pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, strokes: &mut Vec>) -> (bool, bool) { + ui.label(egui::RichText::new("Create signature").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new("Draw your signature below.").color(t.text_muted)); + ui.add_space(6.0); + let (rect, resp) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::drag()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE); + painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.hline(rect.x_range().shrink(24.0), rect.bottom() - 34.0, Stroke::new(1.0, t.divider)); + // Normalised: x 0–1 across the pad, y up, in units of the pad's width. + let norm = |p: Pos2| [(p.x - rect.left()) / rect.width(), (rect.bottom() - p.y) / rect.width()]; + if resp.drag_started() { + strokes.push(Vec::new()); + } + if resp.dragged() + && let (Some(p), Some(s)) = (resp.interact_pointer_pos(), strokes.last_mut()) + { + let n = norm(p.clamp(rect.min, rect.max)); + if s.last().is_none_or(|l| (l[0] - n[0]).abs() + (l[1] - n[1]).abs() > 0.002) { + s.push(n); + } + } + for s in strokes.iter() { + let pts: Vec = s.iter().map(|p| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.width())).collect(); + painter.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::BLACK))); + } + ui.add_space(10.0); + let (mut apply, mut cancel) = (false, false); + ui.horizontal(|ui| { + if ui.button("Clear").clicked() { + strokes.clear(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ready = strokes.iter().any(|s| s.len() > 1); + if ui.add_enabled_ui(ready, |ui| crate::widgets::pill_button(ui, "Apply", true)).inner.clicked() { + apply = true; + } + if crate::widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + }); + (apply, cancel) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4424c83cf6..6187169b4d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod marks { mod dialogs; mod editing; mod files; +pub mod fill_sign; pub mod forms_ui; mod home; mod icon_data; @@ -78,6 +79,8 @@ pub enum QuickTool { Comment(comments::CommentTool), /// Crop pages by dragging a rectangle. Crop, + /// A Fill & Sign tool. + Fill(fill_sign::FillTool), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,6 +99,8 @@ pub enum Dialog { Marks(printcraft_engine::MarkKind), /// Export a PDF ▸ Image / Text. Export(export_ui::ExportKind), + /// Fill & Sign ▸ Create signature (the drawing pad). + Signature, /// Documents from a session that ended unexpectedly. Recovery, } @@ -193,6 +198,10 @@ pub struct PrintCraftApp { pub export_draft: export_ui::ExportDraft, /// A running export's progress. export_status: Option, + /// The saved Fill & Sign signature (strokes normalised to the pad width, y up). + pub signature: Option>>, + /// Strokes being drawn in the signature pad. + pub signature_draft: Vec>, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -260,6 +269,8 @@ impl PrintCraftApp { marks_draft: Default::default(), export_draft: Default::default(), export_status: None, + signature: None, + signature_draft: Vec::new(), number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } @@ -484,7 +495,7 @@ impl PrintCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { - serde_json::json!({ "recent": self.recent, "theme": self.theme }).to_string() + serde_json::json!({ "recent": self.recent, "theme": self.theme, "signature": self.signature }).to_string() } /// Restore state written by `persist`. Unknown or malformed data is ignored. @@ -499,6 +510,11 @@ impl PrintCraftApp { if let Ok(t) = serde_json::from_value::(v["theme"].clone()) { self.theme = t; } + if let Ok(s) = serde_json::from_value::>>(v["signature"].clone()) + && s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite()))) + { + self.signature = Some(s); + } } /// `true` while any open document still waits for page renders (used by headless capture). @@ -556,6 +572,7 @@ impl PrintCraftApp { "background" => Some(Dialog::Marks(printcraft_engine::MarkKind::Background)), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), + "signature" => Some(Dialog::Signature), "number-pages" => { // Same path as the menu, so the page range is seeded. self.execute("page.number"); @@ -620,6 +637,9 @@ impl PrintCraftApp { "select" => QuickTool::Select, "hand" => QuickTool::Hand, "crop" => QuickTool::Crop, + fill if fill.starts_with("fill-") => QuickTool::Fill( + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, + ), other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs new file mode 100644 index 0000000000..f848466e8c --- /dev/null +++ b/crates/ui-egui/tests/fill_sign.rs @@ -0,0 +1,100 @@ +//! Fill & Sign in the real shell (egui_kittest): text, marks, date and a drawn signature. + +use egui::{Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp, QuickTool}; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app.set_option("author", "Ada").unwrap(); + app + }); + h.run_steps(5); + h +} + +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + pos2(r.left() + x / 300.0 * r.width(), r.top() + (400.0 - y) / 400.0 * r.height()) +} + +fn click(h: &mut Harness<'static, PrintCraftApp>, x: f32, y: f32) { + let p = at(h, x, y); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +fn items(h: &Harness<'static, PrintCraftApp>) -> Vec<(String, Option)> { + let s = h.state(); + let mut v: Vec<_> = s.session.get(s.views[0].id).unwrap().info.annotations.iter().map(|a| (a.subtype.clone(), a.contents.clone())).collect(); + v.sort(); + v +} + +#[test] +fn text_marks_and_date() { + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.check")); + assert!(matches!(h.state().quick_tool, QuickTool::Fill(_))); + click(&mut h, 50.0, 350.0); + h.state_mut().execute("sign.fill.date"); + click(&mut h, 50.0, 300.0); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Ada Lovelace".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(4); + let v = items(&h); + assert_eq!(v.len(), 3, "{v:?}"); + assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref() == Some("Ada Lovelace"))); + assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref().is_some_and(|c| c.matches('/').count() == 2)), "a date: {v:?}"); + assert!(v.iter().any(|(t, _)| t == "Stamp")); +} + +#[test] +fn signing_draws_a_signature_once_and_places_it() { + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.signature")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Signature), "no signature yet: the pad opens"); + let pad = h.get_by_label("Draw your signature below.").rect(); + let start = pos2(pad.left() + 40.0, pad.bottom() + 70.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + for k in 1..=8 { + h.hover_at(start + egui::vec2(k as f32 * 30.0, if k % 2 == 0 { -20.0 } else { 20.0 })); + h.run_steps(1); + } + h.drop_at(start + egui::vec2(240.0, 0.0)); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert!(h.state().signature.is_some()); + assert_eq!(h.state().dialog, None); + click(&mut h, 60.0, 100.0); + assert!(items(&h).iter().any(|(t, _)| t == "Ink")); + // The signature is remembered (persisted with the app's settings). + let saved = h.state().persist(); + let mut again = PrintCraftApp::new(); + again.restore(&saved); + assert!(again.signature.is_some()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7ae51aa0f0..bf2a7358af 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3539,10 +3539,10 @@ title = "Add text comment (typewriter)" area = "E" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.fill.text"] +tools = ["fill_sign_add"] +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date"] notes = "" [[feature]] @@ -5139,11 +5139,11 @@ title = "Fill & Sign: add text on flat forms" area = "F" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.fill.text"] +tools = ["fill_sign_add"] +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] +notes = "typewriter FreeText, 10 pt Helvetica" [[feature]] id = "form.fill-sign-marks" @@ -5151,11 +5151,11 @@ title = "Fill & Sign: check, cross, dot, line marks" area = "F" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.fill.check", "sign.fill.cross", "sign.fill.dot", "sign.fill.line"] +tools = ["fill_sign_add"] +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn"] +notes = "PrintCraft-drawn stamps" [[feature]] id = "form.fill-sign-date" @@ -5163,11 +5163,11 @@ title = "Fill & Sign: today's date" area = "F" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.fill.date"] +tools = ["fill_sign_add"] +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] +notes = "local date, m/d/yyyy" [[feature]] id = "form.fill-sign-signature" @@ -5175,11 +5175,11 @@ title = "Fill & Sign: typed, drawn or image signature and initials" area = "F" tier = "P0" milestone = "M5" -status = "planned" -commands = [] +status = "partial" +commands = ["sign.fill.signature"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it"] +notes = "drawn signature, saved in settings; typed/image signatures and initials not yet" [[feature]] id = "form.fill-sign-flatten"