diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7ae2a7587..97e06b062 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -609,6 +609,11 @@ pub enum Edit { props: Box, }, /// Delete a field and all its widgets. + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, 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)?, diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index bff3f1c48..79871c826 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -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 { + 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 = f.widgets.iter().filter_map(|w| w.page).collect(); + let targets: Vec = 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| -> Vec { 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()) +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index dbf0a4325..43646c044 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -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; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ec1daf92d..2e5bbfb88 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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![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()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2ad0d334b..0c49b2ec6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = None; let mut canvas_action: Option = None; + let mut field_menu: Option = 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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 226da7c4e..a2afeef67 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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 = 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(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450929cd9..810b80d53 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, + /// Duplicate Field: which field and onto which pages. + pub duplicate_draft: Option, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// 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(), diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 6c2533bec..1ed3c5179 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -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 diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 5e53b7e46..b1f19575b 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -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 = f.widgets.iter().filter_map(|w| w.page).collect(); + pages.sort(); + assert_eq!(pages, (0..n).collect::>()); + assert_eq!(doc.can_undo(), Some("Duplicate field")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 366913783..5c1222b59 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"