mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
cce71c7607
commit
aac0db461d
@@ -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)?,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user