mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ba944746e5
commit
2c69c02699
Generated
+1
@@ -3099,6 +3099,7 @@ dependencies = [
|
||||
name = "printcraft-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"png 0.18.1",
|
||||
"printcraft-annot",
|
||||
"printcraft-cos",
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user