M6: Duplicate a field across pages (right-click ▸ Duplicate…, all pages or a range): widgets of the same field share one value; a field that is its own widget is split into a field and a widget kid. Prepare mode gets a field context menu (Properties, Duplicate, Delete)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 04:20:26 -04:00
co-authored by Claude Opus 5.5
parent cce71c7607
commit aac0db461d
10 changed files with 261 additions and 5 deletions
+12
View File
@@ -609,6 +609,11 @@ pub enum Edit {
props: Box<FieldProps>,
},
/// Delete a field and all its widgets.
/// Duplicate a field onto other pages (same field, shared value).
DuplicateField {
name: String,
pages: Vec<usize>,
},
DeleteField {
name: String,
},
@@ -778,6 +783,7 @@ impl Edit {
Edit::AddField { .. } => "Add field".into(),
Edit::SetFieldProps { .. } => "Change field properties".into(),
Edit::DeleteField { .. } => "Delete field".into(),
Edit::DuplicateField { .. } => "Duplicate field".into(),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => "Set tab order".into(),
Edit::SetInitialView(_) => "Change initial view".into(),
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
@@ -932,6 +938,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
| Edit::Sanitize
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetInitialView(_)
@@ -1079,6 +1086,11 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::SetFieldProps { name, props } => {
printcraft_forms::set_props(doc, name, props)?;
}
Edit::DuplicateField { name, pages } => {
if printcraft_forms::duplicate_field(doc, name, pages)? == 0 {
return Err(EditError::Form(printcraft_forms::FormError::Invalid(format!("{name} is already on those pages"))));
}
}
Edit::DeleteField { name } => printcraft_forms::delete_field(doc, name)?,
Edit::SetTabOrder { pages, order } => printcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => printcraft_forms::move_in_tab_order(doc, name, *earlier)?,
+89
View File
@@ -831,3 +831,92 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
}
Ok(())
}
/// Widget-only keys: they stay with each widget when a merged field is split.
const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];
/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page
/// gets a widget at the same place, belonging to the same field, so they share one value.
/// A field that is its own widget is first split into a field and a widget kid. Returns how
/// many widgets were added (pages that already have one are skipped).
pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result<usize, FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?;
let refs = page_refs(doc);
for p in pages {
if *p >= refs.len() {
return invalid(format!("page {} does not exist", p + 1));
}
}
let have: std::collections::HashSet<usize> = f.widgets.iter().filter_map(|w| w.page).collect();
let targets: Vec<usize> = pages.iter().copied().filter(|p| !have.contains(p)).collect();
if targets.is_empty() {
return Ok(0);
}
// A merged field/widget becomes a field with one widget kid.
let mut model_ref = model.obj;
if model.obj == f.obj {
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
let mut kid = Dict::new();
for k in WIDGET_KEYS {
if let Some(v) = d.get(k) {
kid.set(k.to_vec(), v.clone());
}
}
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
let kr = doc.add(Object::Dict(kid));
doc.update_dict(f.obj, |d| {
for k in WIDGET_KEYS {
d.remove(k);
}
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
})?;
// The page lists the widget now, not the field.
if let Some(p) = model.page {
let pr = refs[p];
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
let swap =
|a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
match annots {
Some(Object::Ref(ar)) => {
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
doc.set(ar, Object::Array(swap(a)));
}
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
_ => {}
}
}
model_ref = kr;
}
let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();
let mut added = Vec::new();
for p in &targets {
let pr = refs[*p];
let mut w = template.clone();
w.set(b"P".to_vec(), Object::Ref(pr));
w.set(b"Parent".to_vec(), Object::Ref(f.obj));
w.remove(b"StructParent");
let wr = doc.add(Object::Dict(w));
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
match annots {
Some(Object::Ref(ar)) => {
let mut a = doc.get(ar).as_array().cloned().unwrap_or_default();
a.push(Object::Ref(wr));
doc.set(ar, Object::Array(a));
}
other => {
let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default();
a.push(Object::Ref(wr));
doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?;
}
}
added.push(wr);
}
doc.update_dict(f.obj, |d| {
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.extend(added.iter().map(|r| Object::Ref(*r)));
d.set(b"Kids".to_vec(), Object::Array(kids));
})?;
redraw_field(doc, name)?;
Ok(added.len())
}
+1 -1
View File
@@ -16,7 +16,7 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
pub mod af;
pub mod appearance;
mod author;
pub use author::{BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, look, redraw_field, set_props};
pub use author::{BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_props};
#[cfg(test)]
mod tests;
+32
View File
@@ -562,3 +562,35 @@ fn ordering_tabs_manually() {
let doc = reopen(&doc);
assert_eq!(order(&doc), "CAB", "saved");
}
#[test]
fn duplicating_a_field_across_pages_shares_its_value() {
let mut doc = one_page();
// A second and third page.
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
for _ in 0..2 {
let mut p = printcraft_cos::Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
let r = doc.add(p);
doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
}
let name = add_field(&mut doc, 0, [50.0, 750.0, 250.0, 770.0], &NewField::Text { multiline: false }, Some("Initials")).unwrap();
assert_eq!(duplicate_field(&mut doc, &name, &[0, 1, 2]).unwrap(), 2, "page 1 already has it");
let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap();
assert_eq!(f.widgets.iter().filter_map(|w| w.page).collect::<Vec<_>>(), vec![0, 1, 2]);
assert!(f.widgets.iter().all(|w| w.rect == [50.0, 750.0, 250.0, 770.0]));
set_value(&mut doc, &name, &FieldValue::Text("AL".into())).unwrap();
let doc = reopen(&doc);
let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap();
assert_eq!((f.value.clone(), f.widgets.len()), (vec!["AL".to_string()], 3), "one field, one value, three widgets");
assert_eq!(fields(&doc).len(), 1);
assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err());
}
+33
View File
@@ -949,6 +949,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
let mut hover_text: Option<(Pos2, String)> = None;
let mut clicked_link: Option<LinkTarget> = None;
let mut canvas_action: Option<comments::CanvasAction> = None;
let mut field_menu: Option<FieldMenu> = None;
let mut open_props: Option<(usize, usize)> = None;
let mut field_props = false;
let mut field_placed = false;
@@ -1317,6 +1318,24 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
resp.context_menu(|ui| {
// Preparing a form: the selected field's menu.
if preparing && let Some((name, _)) = view.prepare.selected.clone() {
if ui.button("Properties…").clicked() {
field_menu = Some(FieldMenu::Properties);
ui.close();
}
if ui.add_enabled(can_modify, egui::Button::new("Duplicate…")).clicked() {
field_menu = Some(FieldMenu::Duplicate(name.clone()));
ui.close();
}
ui.separator();
if ui.add_enabled(can_modify, egui::Button::new("Delete")).clicked() {
view.prepare.selected = None;
view.pending_edit = Some(printcraft_engine::Edit::DeleteField { name });
ui.close();
}
return;
}
canvas_action = comments::context_menu(ui, view, info, prefs, allowed);
});
(wanted, visible_now)
@@ -1411,6 +1430,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if field_placed {
tool = QuickTool::Select;
}
if matches!(field_menu, Some(FieldMenu::Properties)) {
field_props = true;
}
let field_props = field_props.then(|| view.prepare.selected.clone()).flatten();
match canvas_action {
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e),
@@ -1462,6 +1484,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((name, w)) = field_props {
app.open_field_props(&name, w);
}
if let Some(FieldMenu::Duplicate(name)) = field_menu {
let pages = app.session.get(app.views[index].id).map_or(1, |d| d.info.pages.len());
app.duplicate_draft = Some(crate::DuplicateDraft { name, all: true, from: 1, to: pages });
app.dialog = Some(crate::Dialog::DuplicateField);
}
if let Some((page, rect)) = app.views[index].links.open_new.take() {
app.open_link_props(page, Some(rect), None);
}
@@ -1616,6 +1643,12 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t:
}
}
/// The prepare-mode field menu's choices.
enum FieldMenu {
Properties,
Duplicate(String),
}
/// What the notice bar's buttons ask for.
enum Notice {
Security,
+36
View File
@@ -46,6 +46,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut revert_now = false;
let mut summarize_now = false;
let mut optimize_now = false;
let mut duplicate_now: Option<Edit> = None;
let mut replace_now = false;
let t = Tokens::get(ctx);
let mut close = false;
@@ -595,6 +596,37 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
});
return;
}
Dialog::DuplicateField => {
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let Some(d) = app.duplicate_draft.as_mut() else {
close = true;
return;
};
ui.label(egui::RichText::new("Duplicate Field").font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(format!("Duplicate \"{}\" onto:", d.name));
ui.radio_value(&mut d.all, true, "All pages");
ui.horizontal(|ui| {
ui.radio_value(&mut d.all, false, "From");
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label("to");
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
ui.label(format!("of {n}"));
});
d.to = d.to.max(d.from);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, "OK", true).clicked() {
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
close = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
close = true;
}
});
return;
}
Dialog::Optimize => {
let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t);
optimize_now = ok;
@@ -952,6 +984,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
if optimize_now {
app.optimize_with_draft();
}
if let Some(e) = duplicate_now {
app.duplicate_draft = None;
app.apply_edit(e);
}
if extract_now {
app.extract_selection();
}
+14
View File
@@ -167,6 +167,8 @@ pub enum Dialog {
Sign,
/// Optimize PDF ▸ Advanced optimization.
Optimize,
/// Prepare a form ▸ right-click a field ▸ Duplicate.
DuplicateField,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -178,6 +180,15 @@ pub enum PropsTab {
Advanced,
}
/// Duplicate Field: the field and the pages (all, or a range, 1-based).
#[derive(Clone, Debug, PartialEq)]
pub struct DuplicateDraft {
pub name: String,
pub all: bool,
pub from: usize,
pub to: usize,
}
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
#[derive(Clone, Debug)]
pub struct PageClip {
@@ -266,6 +277,8 @@ pub struct PrintCraftApp {
pub system_clipboard: bool,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
/// Duplicate Field: which field and onto which pages.
pub duplicate_draft: Option<DuplicateDraft>,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option<RecoveryStore>,
/// Entries left by a previous session, offered in the Recovery dialog.
@@ -374,6 +387,7 @@ impl PrintCraftApp {
last_snapshot: None,
system_clipboard: true,
attach_override: None,
duplicate_draft: None,
recovery: None,
recoverable: Vec::new(),
recovery_keys: Default::default(),
+8
View File
@@ -260,6 +260,14 @@ pub(crate) fn page_input(
return out;
}
// Right-click a field: select it (the context menu acts on it).
if resp.secondary_clicked()
&& let Some((f, wi)) = form.iter().find_map(|f| {
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i))
})
{
prep.selected = Some((f.name.clone(), wi));
}
// The Select tool: corner handles of the selection, then fields.
let selected_rect = prep
.selected
+32
View File
@@ -217,3 +217,35 @@ fn the_fields_panel_orders_tabs_manually() {
assert_eq!(after[0], second, "{before:?} → {after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Set tab order"));
}
#[test]
fn duplicating_a_field_onto_every_page() {
let mut h = harness();
h.state_mut().apply_edit(printcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 });
h.state_mut().execute("form.prepare");
h.run_steps(3);
let (n, p) = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "city").unwrap();
(doc.info.pages.len(), printcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center())
};
assert!(n >= 2, "the fixture has {n} pages");
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Duplicate…").click();
h.run_steps(2);
h.get_by_label("Duplicate Field");
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "city").unwrap();
let mut pages: Vec<usize> = f.widgets.iter().filter_map(|w| w.page).collect();
pages.sort();
assert_eq!(pages, (0..n).collect::<Vec<_>>());
assert_eq!(doc.can_undo(), Some("Duplicate field"));
}
+4 -4
View File
@@ -4887,11 +4887,11 @@ title = "Duplicate fields across pages"
area = "F"
tier = "P1"
milestone = "M6"
status = "planned"
commands = []
status = "shipped"
commands = ["form.prepare"]
tools = []
evidence = []
notes = ""
evidence = ["crates/forms/src/tests.rs::duplicating_a_field_across_pages_shares_its_value", "crates/ui-egui/tests/prepare.rs::duplicating_a_field_onto_every_page"]
notes = "right-click a field ▸ Duplicate: all pages or a range; one field, shared value"
[[feature]]
id = "form.multiple-copies"