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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:21:11 -04:00
co-authored by Claude Opus 5.5
parent ba944746e5
commit 2c69c02699
20 changed files with 781 additions and 43 deletions
Generated
+1
View File
@@ -3099,6 +3099,7 @@ dependencies = [
name = "printcraft-engine"
version = "0.1.0"
dependencies = [
"chrono",
"png 0.18.1",
"printcraft-annot",
"printcraft-cos",
+50
View File
@@ -275,6 +275,56 @@ pub fn build(d: &Dict) -> Option<Stream> {
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") {
+69 -7
View File
@@ -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<Vec<[f64; 2]>>,
},
}
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::<Vec<_>>())).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()));
+31
View File
@@ -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<usize> = [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());
}
+39
View File
@@ -249,6 +249,45 @@ impl Automation {
Ok(out)
}
pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result<Value> {
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<Value> {
let (page, index) = self.comment_target(a)?;
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
+1
View File
@@ -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)?,
+17
View File
@@ -352,6 +352,23 @@ pub fn tools() -> Vec<ToolDef> {
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.")
+15
View File
@@ -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(_))));
}
+4
View File
@@ -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"] }
+8 -6
View File
@@ -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),
],
}],
},
+7
View File
@@ -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"),
+28 -3
View File
@@ -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<Vec<u8>>);
@@ -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<String>,
/// 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<printcraft_render::Annota
.collect()
}
/// Seconds to add to UTC for local time (0 where unknown).
fn local_utc_offset() -> 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() {
+82 -3
View File
@@ -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<crate::fill_sign::TypeBox>,
/// A non-edit action requested by the organize toolbar, handled by the app.
pub pending_action: Option<ViewAction>,
}
@@ -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<LinkTarget> = None;
let mut canvas_action: Option<comments::CanvasAction> = 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));
+8
View File
@@ -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 });
}
+1
View File
@@ -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(),
+10
View File
@@ -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;
+266
View File
@@ -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<Self> {
FILL_TOOLS.into_iter().find(|t| t.command() == id)
}
pub fn label(self) -> &'static str {
match self {
FillTool::Text => "Add text",
FillTool::Check => "Checkmark",
FillTool::Cross => "Cross",
FillTool::Dot => "Dot",
FillTool::Line => "Line",
FillTool::Date => "Date",
FillTool::Signature => "Sign",
}
}
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<FillMark> {
match self {
FillTool::Check => Some(FillMark::Check),
FillTool::Cross => Some(FillMark::Cross),
FillTool::Dot => Some(FillMark::Dot),
FillTool::Line => Some(FillMark::Line),
_ => None,
}
}
}
/// Text being typed onto a page: (page, top-left in user space, text, focus requested).
#[derive(Clone, Debug, PartialEq)]
pub struct TypeBox {
pub page: usize,
pub at: [f64; 2],
pub text: String,
pub focus: bool,
}
/// The size Fill & Sign uses for typed text (Acrobat's default is 10 pt).
pub const TEXT_SIZE: f64 = 10.0;
fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
let (vx, vy) = xf.screen_to_view(p);
let u = info.pages[page].view_to_user(vx, vy);
[u[0] as f64, u[1] as f64]
}
fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit {
let style = Style::default_for(&shape);
Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author: author.to_string() })
}
/// A typewriter annotation sized to its text.
pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
let rect = crate::comments::text_box_rect(at, text, TEXT_SIZE);
new(page, Shape::Typewriter { rect, font_size: TEXT_SIZE }, text.to_string(), author)
}
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
/// 150 pt wide.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option<Edit> {
let w = 150.0;
let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
if !min_y.is_finite() {
return None;
}
let h = f64::from(max_y - min_y).max(0.05) * w;
let strokes: Vec<Vec<[f64; 2]>> = strokes
.iter()
.filter(|s| !s.is_empty())
.map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect())
.collect();
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
}
/// 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<Vec<[f32; 2]>>>,
author: &str,
today: (i64, u32, u32),
) -> Option<FillAction> {
let pointer = ui.input(|i| i.pointer.hover_pos())?;
if !xf.rect.contains(pointer) {
return None;
}
ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
if !resp.clicked() {
return None;
}
let at = to_user(xf, info, page, pointer);
match tool {
FillTool::Text => {
view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
None
}
FillTool::Date => {
let (y, m, d) = today;
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
}
FillTool::Signature => match signature {
Some(s) => 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<Edit>),
/// 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<Edit> {
let tb = view.fill_text.clone()?;
let xf = view.page_xform(tb.page)?;
let v = info.pages.get(tb.page)?.user_to_view(tb.at[0] as f32, tb.at[1] as f32);
let pos = xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph);
let zoom = xf.rect.width() / xf.pw.max(1.0);
let mut commit = false;
let mut cancel = false;
egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
let 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<Vec<[f32; 2]>>) -> (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<Pos2> = s.iter().map(|p| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.width())).collect();
painter.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::BLACK)));
}
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)
}
+21 -1
View File
@@ -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<export_ui::ExportStatus>,
/// The saved Fill & Sign signature (strokes normalised to the pad width, y up).
pub signature: Option<Vec<Vec<[f32; 2]>>>,
/// Strokes being drawn in the signature pad.
pub signature_draft: Vec<Vec<[f32; 2]>>,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
}
@@ -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::<ThemeKind>(v["theme"].clone()) {
self.theme = t;
}
if let Ok(s) = serde_json::from_value::<Vec<Vec<[f32; 2]>>>(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;
+100
View File
@@ -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<String>)> {
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());
}
+23 -23
View File
@@ -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"