From 6fc17441ac6e4a074032488e01f1fea895070ebd Mon Sep 17 00:00:00 2001 From: Perchenko Anton <101743951+PerchAnton@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:04:49 +0300 Subject: [PATCH] fix(forms): edit button labels in field properties (#3064) * M6: edit push-button labels in field properties (#2892) * M6: keep roadmap status blockquotes valid * M6: keep button-caption roadmap notes minimal --- ROADMAP.md | 3 + crates/automation/README.md | 16 +++- crates/automation/src/forms.rs | 27 +++++- crates/automation/src/tools.rs | 6 +- crates/automation/tests/button_caption.rs | 91 +++++++++++++++++++ crates/forms/README.md | 7 ++ crates/forms/src/author.rs | 35 +++++++- crates/forms/src/lib.rs | 9 ++ crates/forms/src/tests.rs | 102 ++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 11 ++- crates/ui-egui/tests/prepare.rs | 53 +++++++++++ docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- 13 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 crates/automation/tests/button_caption.rs diff --git a/ROADMAP.md b/ROADMAP.md index 907beb98a..79c8fdfa1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -169,6 +170,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. diff --git a/crates/automation/README.md b/crates/automation/README.md index 4e27702a3..669ea6146 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -64,6 +64,20 @@ semantics; they never silently act on partial text. ## Shared field properties +For an individual push button, `form_set_props` also edits its visible normal label: + +```json +{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1} +``` + +`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the +label. Neither renames the field. `form_fields` returns the first widget's `caption` and all +`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts +keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen. +The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and +icon layout are not edited here; the existing caption font and unsupported-character warning +policy apply. + `form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names): ```json @@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to unlock and change fields together. Only supplied properties change: each field/widget keeps its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border -or fill colour. Names, rectangles and rotations require the single-field form. Results keep +or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep `field` for single edits and return `fields` for batches. CLI and MCP use the same tool. ## MCP server diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7c60425a8..f2eab5ddc 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -233,6 +233,19 @@ impl Automation { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } match f.kind { + FormFieldKind::PushButton => { + o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default())); + o.insert( + "captions".into(), + json!( + f.widgets + .iter() + .enumerate() + .map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) }) + .collect::>() + ), + ); + } FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } @@ -327,6 +340,9 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + if a.get("widget").is_some() && a.get("caption").is_none() { + return Err(bad("widget requires caption")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +360,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "caption", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -398,6 +414,15 @@ impl Automation { rect, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), + caption: a + .opt_str("caption")? + .map(|caption| -> Result<_> { + let wi = a.opt_int("widget")?.unwrap_or(1); + let wi = + usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((wi, caption.to_owned())) + }) + .transpose()?, read_only: a.opt_bool("read_only")?, required: a.opt_bool("required")?, multiline: a.opt_bool("multiline")?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53a5ac57a..91cd489c0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -321,7 +321,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -374,7 +374,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ @@ -383,6 +383,8 @@ pub fn tools() -> Vec { "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, "tooltip": { "type": "string" }, + "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, "multiline": { "type": "boolean" }, diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/button_caption.rs new file mode 100644 index 000000000..cb1a22a02 --- /dev/null +++ b/crates/automation/tests/button_caption.rs @@ -0,0 +1,91 @@ +//! Editing a button's visible label through the CLI/MCP tool table (#2892). + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{DocId, Edit, NewField, Session}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn button_caption_can_be_changed_after_creation() { + let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("buttons.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, 300.0, 200.0, 330.0], + kind: NewField::Button { caption: String::new() }, + name: Some("clear_button".into()), + }, + ) + .unwrap(); + std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); + let before = a.session().save_bytes(DocId(id)).unwrap(); + let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); + assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); + let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"})); + let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap()); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let root = doc.get(doc.root().unwrap()); + let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()); + let button = doc.resolve(&fields.as_array().unwrap()[0]); + let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap()); + assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form"); + assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button"); + let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({"doc": reopened})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap(); + assert_eq!(pixels, labelled_pixels); + let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"})); + assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0"))); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported"); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + for invalid in [ + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}), + json!({"doc": id, "field": "clear_button", "caption": 42}), + json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}), + json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}), + ] { + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}"); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}"); + } + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true})); + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err()); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false})); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], ""); + let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_eq!(pixels, empty_pixels); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc243..66fb919fe 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?; Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). + `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears + that label, independently of the field name. The index is 0-based. A label-only edit redraws + just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing. + Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only + layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi + limits and reports unavailable characters as above. ## Not yet diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index bb45f38d9..521d1f527 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -63,6 +63,9 @@ impl NewField { pub struct FieldProps { pub name: Option, pub tooltip: Option, + /// Normal push-button label (`/MK /CA`) of one widget (0-based index). + /// An empty string clears the label; other widgets and icon layouts are preserved. + pub caption: Option<(usize, String)>, pub read_only: Option, pub required: Option, pub multiline: Option, @@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel locked: false, hidden: false, rotation: 0, + caption: None, list: None, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); @@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel /// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see /// `ask_viewers_to_redraw`), as when a value is set. pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + redraw_widgets(doc, name, None) +} + +fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; let mut undrawn = false; - for w in &f.widgets { + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { @@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, } @@ -802,6 +804,13 @@ fn walk( locked: annot_flags & 128 != 0, hidden: annot_flags & (2 | 32) != 0, rotation: appearance::mk_rotation(doc, wd), + caption: (kind == FieldKind::PushButton) + .then(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), }) }) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 60066e473..fef5bcfbd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String { String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() } +#[test] +fn button_caption_preserves_other_widgets_and_shared_dictionaries() { + for indirect_caption in [false, true] { + let mut doc = one_page(); + let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap(); + let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap(); + let all = fields(&doc); + let (first, second) = (field(&all, &a).obj, field(&all, &b).obj); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + parent.set(b"T".to_vec(), PdfString::text("clear_button")); + parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("ResetForm")); + parent.set(b"A".to_vec(), Object::Dict(action.clone())); + let parent = doc.add(parent); + let mut mk = Dict::new(); + let ca = Object::String(PdfString::text("Original")); + let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca }; + mk.set(b"CA".to_vec(), ca); + mk.set(b"TP".to_vec(), Object::Int(0)); + mk.set(b"R".to_vec(), Object::Int(90)); + mk.set(b"VendorNote".to_vec(), PdfString::text("kept")); + let mk = doc.add(mk); + let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let normal = shared_ap.get(b"N").unwrap().clone(); + shared_ap.set(b"R".to_vec(), normal.clone()); + shared_ap.set(b"D".to_vec(), normal); + shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept")); + let shared_ap = doc.add(shared_ap); + for widget in [first, second] { + doc.update_dict(widget, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + d.set(b"MK".to_vec(), Object::Ref(mk)); + d.set(b"AP".to_vec(), Object::Ref(shared_ap)); + }) + .unwrap(); + } + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let original = Document::open(Arc::new(original)).unwrap(); + // Full saves can renumber objects: reacquire references from the reopened model. + let f = field(&fields(&original), "clear_button").clone(); + let first = f.widgets[0].obj; + let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap(); + for full in [false, true] { + let mut doc = original.clone(); + let untouched = doc.get(first).clone(); + let shared_mk = doc.get(mk).clone(); + assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original")); + set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap(); + assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched"); + assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place"); + let bytes = if full { + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() + } else { + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.starts_with(original.bytes().as_slice())); + assert!(bytes.len() > original.bytes().len()); + bytes + }; + let doc = Document::open(Arc::new(bytes)).unwrap(); + let f = field(&fields(&doc), "clear_button").clone(); + assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Original"), Some("Clear Form")]); + assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false })); + assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone()))); + let w = doc.get(f.widgets[1].obj); + let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap(); + assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept"); + assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90)); + assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0)); + let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap(); + assert!(apd.contains(b"VendorAP")); + assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); + assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); + } + } +} + +#[test] +fn button_caption_rejects_wrong_type_and_index_before_mutation() { + let mut doc = fixture(); + for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] { + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() }; + assert!(set_props(&mut doc, name, &props).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + } + set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交")); + assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]); + set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("")); + assert!(undrawn_fields(&doc).is_empty()); + assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); +} + /// Read the drawn strings together with the appearance's actual font after saving and reopening. fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { let widget = doc.get(w.obj); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 386c42e87..a46611274 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -734,6 +734,7 @@ pub struct FieldDraft { pub tab: FieldTab, pub name: String, pub tooltip: String, + pub caption: String, pub read_only: bool, pub required: bool, pub multiline: bool, @@ -805,6 +806,7 @@ impl FieldDraft { tab: FieldTab::General, name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), tooltip: f.tooltip.clone().unwrap_or_default(), + caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), required: f.has(pdfcraft_engine::field_flags::REQUIRED), multiline: f.has(pdfcraft_engine::field_flags::MULTILINE), @@ -848,7 +850,7 @@ impl FieldDraft { fn tabs(&self) -> Vec<(FieldTab, &'static str)> { let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")]; - if !matches!(self.kind, FormFieldKind::PushButton | FormFieldKind::Signature) { + if self.kind != FormFieldKind::Signature { t.push((FieldTab::Options, "Options")); } if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { @@ -865,6 +867,7 @@ impl FieldDraft { let p = FieldProps { name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), + caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())), read_only: ch(self.read_only, o.read_only), required: ch(self.required, o.required), multiline: ch(self.multiline, o.multiline), @@ -1087,6 +1090,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FieldTab::Calculate => calculate_tab(ui, d, t), FieldTab::Actions => actions_tab(ui, d, t), FieldTab::Options => match d.kind { + FormFieldKind::PushButton => { + ui.horizontal(|ui| { + let l = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id); + }); + } FormFieldKind::Text => { use pdfcraft_engine::field_flags as ff; egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index eb25e42a0..d63df5232 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -250,6 +250,59 @@ fn options_tab_sets_the_check_box_style() { assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross)); } +#[test] +fn button_label_is_editable_without_renaming_the_field() { + use pdfcraft_engine::{Edit, NewField}; + let mut h = harness(); + h.state_mut().apply_edit(Edit::AddField { + page: 0, + rect: [40.0, 300.0, 190.0, 330.0], + kind: NewField::Button { caption: String::new() }, + name: Some("clear_button".into()), + }); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Clear Form"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + + let app = h.state(); + let id = app.views[0].id; + let field = app.session.get(id).unwrap().form.iter().find(|f| f.name == "clear_button").unwrap(); + let bytes = app.session.save_bytes(id).unwrap(); + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let widget = doc.get(field.widgets[0].obj); + let mk = doc.resolve(widget.as_dict().unwrap().get(b"MK").unwrap()); + assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form"); + assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties")); + + // Read the existing label back, and leave both Cancel and an unchanged OK harmless. + let before = app.session.save_bytes(id).unwrap(); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().caption, "Clear Form"); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Discard this"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().session.save_bytes(id).unwrap(), before); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().session.save_bytes(id).unwrap(), before); +} + #[test] fn dropdown_alignment_is_editable_and_preserved() { choice_alignment(pdfcraft_engine::NewField::Combo { options: vec!["One".into(), "Two".into()], editable: false }, "Center", 1); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9b5100f37..61793f629 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,6 +44,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -131,7 +132,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 547c5561c..1a608878e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4920,8 +4920,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/ui-egui/tests/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] +notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen; icon-only layout and existing Helvetica limits retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions"