//! Prepare a form: create, change and delete fields (execution plan M6.6). //! //! New fields follow Acrobat's conventions: names "Text1", "Check Box1", "Group1" (radio groups), //! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; Helvetica at Acrobat's Auto size //! for text and date fields (12 pt in a box that has room, smaller in a short one), 12 pt in //! choice fields, a thin grey border and a white background, and appearance streams generated //! immediately so every viewer shows the empty field. use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; /// The kind of field to add. #[derive(Clone, Debug, PartialEq)] pub enum NewField { Text { multiline: bool, }, /// A date field: a text field with Acrobat's date format action (`AFDate_FormatEx`). Date, CheckBox, /// One radio button in `group` (a new group is created when none of that name exists), /// selected by `export`. Radio { group: Option, export: String, }, Combo { options: Vec, editable: bool, }, List { options: Vec, multi: bool, }, Button { caption: String, }, /// An image field: an icon-only push button that asks for a picture when clicked. Image, Signature, } impl NewField { fn base_name(&self) -> &'static str { match self { NewField::Text { .. } => "Text", NewField::Date => "Date", NewField::CheckBox => "Check Box", NewField::Radio { .. } => "Group", NewField::Combo { .. } => "Dropdown", NewField::List { .. } => "List Box", NewField::Button { .. } => "Button", NewField::Image => "Image", NewField::Signature => "Signature", } } } /// Field properties the Properties dialog edits (`None` leaves a value unchanged). #[derive(Clone, Debug, Default, PartialEq)] pub struct FieldProps { pub name: Option, pub tooltip: Option, pub read_only: Option, pub required: Option, pub multiline: Option, pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in /// user space. pub rect: Option<(usize, [f64; 4])>, /// Appearance tab (border, fill, line, text colour, font). pub look: Option, /// Change only these appearance properties. Unlike `look`, this preserves each widget's /// other colours, border settings and default appearance, including custom font resources. pub appearance: Option, /// Format, Validate and Calculate tabs (written as Acrobat's AF scripts). pub format: Option, pub validate: Option, pub calculate: Option, /// Options-tab flags (`/Ff` bits from [`crate::flags`]) to set or clear. pub flags: Vec<(u32, bool)>, /// Alignment (`/Q`): 0 left, 1 centre, 2 right. pub quadding: Option, /// Default value (`/DV`): what Reset form restores. pub default_value: Option>, /// Lock or unlock the field (its widgets' Locked flag). pub locked: Option, /// Actions tab: every trigger's action (replacing the field's). pub actions: Option>, /// Options tab: the mark of a check box or radio button (every widget). pub check_style: Option, /// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise. /// Changing between horizontal and vertical swaps that widget's width and height around its /// center, unless [`Self::rect`] is also set. pub rotation: Option<(usize, i64)>, } fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. pub(crate) fn ensure_form(doc: &mut Document) -> Result { let root = doc.root().ok_or(FormError::NoForm)?; let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); let r = match existing { Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, other => { let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let r = doc.add(Object::Dict(d)); doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; r } }; let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); if !af.contains(b"Fields") { af.set(b"Fields".to_vec(), Object::Array(Vec::new())); } if !af.contains(b"DA") { af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); } let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); // Acrobat's resource names for the standard fonts its Appearance tab offers. let mut added = false; for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { continue; } let mut f = Dict::new(); f.set(b"Type".to_vec(), Object::name("Font")); f.set(b"Subtype".to_vec(), Object::name("Type1")); f.set(b"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); added = true; } if added { dr.set(b"Font".to_vec(), Object::Dict(fonts)); af.set(b"DR".to_vec(), Object::Dict(dr)); } doc.set(r, Object::Dict(af)); Ok(r) } /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { pub border: Option<[f64; 3]>, pub fill: Option<[f64; 3]>, /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). pub width: f64, pub style: BorderStyle, pub text: [f64; 3], pub font: FieldFont, } /// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a /// border or fill colour. The patch is applied to each widget's own dictionaries. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct LookPatch { pub border: Option>, pub fill: Option>, pub width: Option, pub style: Option, pub text: Option<[f64; 3]>, pub font: Option, } impl LookPatch { fn validate(&self) -> Result<(), FormError> { for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { return invalid("appearance colours must be finite RGB values between 0 and 1"); } } if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { return invalid("border width must be between 0 and 12 points"); } Ok(()) } } /// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last /// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a /// custom font resource, colour space or other graphics state) is kept, and repeated edits don't /// grow the string. fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { if patch.font.is_none() && size.is_none() && patch.text.is_none() { return da.to_string(); } let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); let numeric = |s: &str| s.parse::().is_ok(); if patch.font.is_some() || size.is_some() { let old = appearance::parse_da(da); let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); // `parse_da` reads sizes up to 300 points; keep the same bound when writing. let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); match at { Some(i) => { if let Some(slot) = toks.get_mut(i - 2) { *slot = format!("/{font}"); } if let Some(slot) = toks.get_mut(i - 1) { *slot = size; } } None => toks.extend([format!("/{font}"), size, "Tf".into()]), } } if let Some(c) = patch.text { let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; let at = toks.iter().enumerate().rev().find_map(|(i, t)| { let n = match t.as_str() { "g" => 1, "rg" => 3, "k" => 4, _ => return None, }; let start = i.checked_sub(n)?; toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) }); match at { Some((start, end)) => { toks.splice(start..=end, rgb); } None => toks.extend(rgb), } } toks.join(" ") } fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { let touches_mk = patch.border.is_some() || patch.fill.is_some(); let touches_bs = patch.width.is_some() || patch.style.is_some(); // A size- or font-only edit changes /DA, not the widget: leave it out of the save. if !touches_mk && !touches_bs { return Ok(()); } let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; if touches_mk { let indirect = wd.get(b"MK").and_then(Object::as_ref); let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { if let Some(change) = change { match change { Some(c) => mk.set(key.to_vec(), rgb(c)), None => { mk.remove(key); } } } } // A shared indirect /MK is updated where it lives, not copied into each widget. match indirect { Some(r) => doc.set(r, Object::Dict(mk)), None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, } } if touches_bs { let indirect = wd.get(b"BS").and_then(Object::as_ref); let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); if let Some(width) = patch.width { bs.set(b"W".to_vec(), Object::Real(width)); } if let Some(style) = patch.style { bs.set(b"S".to_vec(), Object::name(style.code())); if style == BorderStyle::Dashed && !bs.contains(b"D") { bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); } } match indirect { Some(r) => doc.set(r, Object::Dict(bs)), None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, } } Ok(()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BorderStyle { Solid, Dashed, Beveled, Inset, Underline, } impl BorderStyle { pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; fn code(self) -> &'static str { match self { BorderStyle::Solid => "S", BorderStyle::Dashed => "D", BorderStyle::Beveled => "B", BorderStyle::Inset => "I", BorderStyle::Underline => "U", } } pub fn label(self) -> &'static str { match self { BorderStyle::Solid => "Solid", BorderStyle::Dashed => "Dashed", BorderStyle::Beveled => "Beveled", BorderStyle::Inset => "Inset", BorderStyle::Underline => "Underline", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldFont { Helvetica, Times, Courier, } impl FieldFont { pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; fn resource(self) -> &'static str { match self { FieldFont::Helvetica => "Helv", FieldFont::Times => "TiRo", FieldFont::Courier => "Cour", } } pub fn label(self) -> &'static str { match self { FieldFont::Helvetica => "Helvetica", FieldFont::Times => "Times Roman", FieldFont::Courier => "Courier", } } } /// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). /// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the /// mark as paths, so no symbol font is needed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CheckStyle { Check, Circle, Cross, Diamond, Square, Star, } impl CheckStyle { pub const ALL: [CheckStyle; 6] = [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; fn code(self) -> &'static str { match self { CheckStyle::Check => "4", CheckStyle::Circle => "l", CheckStyle::Cross => "8", CheckStyle::Diamond => "u", CheckStyle::Square => "n", CheckStyle::Star => "H", } } pub fn label(self) -> &'static str { match self { CheckStyle::Check => "Check", CheckStyle::Circle => "Circle", CheckStyle::Cross => "Cross", CheckStyle::Diamond => "Diamond", CheckStyle::Square => "Square", CheckStyle::Star => "Star", } } /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { CheckStyle::Circle } else { CheckStyle::Check }) } } /// A check box's or radio button's style (from its first widget). pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); CheckStyle::of_widget(doc, &wd, f.kind) } fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); match v.len() { 1 => Some([v[0]; 3]), 3 => Some([v[0], v[1], v[2]]), 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), _ => None, } } /// A field's current look (from its first widget and its default appearance). pub fn look(doc: &Document, f: &Field) -> Look { let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); let parsed = appearance::parse_da(&da); let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let text = match nums.len() { 1 => [nums[0]; 3], 3 => [nums[0], nums[1], nums[2]], 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], _ => [0.0; 3], }; Look { border: rgb_of(doc, mk.get(b"BC")), fill: rgb_of(doc, mk.get(b"BG")), width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), style: match bs.name(b"S") { Some(b"D") => BorderStyle::Dashed, Some(b"B") => BorderStyle::Beveled, Some(b"I") => BorderStyle::Inset, Some(b"U") => BorderStyle::Underline, _ => BorderStyle::Solid, }, text, font: match parsed.font.as_str() { "TiRo" | "Times-Roman" => FieldFont::Times, "Cour" | "Courier" => FieldFont::Courier, _ => FieldFont::Helvetica, }, } } /// A `/DA` string with the given font, size and colour. fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { let f = crate::appearance::fmt; format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) } fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { Some(r) => { let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); a.push(Object::Ref(widget)); doc.set(r, Object::Array(a)); } None => { let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); a.push(Object::Ref(widget)); doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; } } Ok(()) } fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); list.push(Object::Ref(field)); d.set(b"Fields".to_vec(), Object::Array(list)); doc.set(af, Object::Dict(d)); Ok(()) } /// The first free "N" name. fn free_name(existing: &[Field], base: &str) -> String { (1..=u64::MAX) .map(|n| format!("{base}{n}")) .find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) .unwrap_or_else(|| base.to_string()) } fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); d.set(b"Subtype".to_vec(), Object::name("Widget")); d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); d.set(b"P".to_vec(), Object::Ref(page)); d.set(b"F".to_vec(), Object::Int(4)); let mut mk = Dict::new(); mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); d.set(b"MK".to_vec(), Object::Dict(mk)); let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Int(1)); bs.set(b"S".to_vec(), Object::name("S")); d.set(b"BS".to_vec(), Object::Dict(bs)); d } /// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { let pages = page_refs(doc); let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 { return invalid("the field is too small"); } let existing = fields(doc); // A radio button names its group: a new group takes that name. let name = match (name, kind) { (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), _ => name, }; let name = match name.map(str::trim) { Some(n) if !n.is_empty() => { if n.contains('.') { return invalid("field names can't contain a period"); } n.to_string() } _ => free_name(&existing, kind.base_name()), }; let radio_group = match kind { NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), _ => None, }; if radio_group.is_none() && existing.iter().any(|f| f.name == name) { return invalid(format!("a field named {name:?} already exists")); } let af = ensure_form(doc)?; let mut w = widget_dict(page_ref, rect); let field_name = match kind { NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), _ => name.clone(), }; match kind { NewField::Text { multiline } => { w.set(b"FT".to_vec(), Object::name("Tx")); // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); if *multiline { w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); } } NewField::Date => { w.set(b"FT".to_vec(), Object::name("Tx")); w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). let js = |s: &str| { let mut a = Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); Object::Dict(a) }; let mut aa = Dict::new(); aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); w.set(b"AA".to_vec(), Object::Dict(aa)); } NewField::CheckBox => { w.set(b"FT".to_vec(), Object::name("Btn")); w.set(b"V".to_vec(), Object::name("Off")); w.set(b"AS".to_vec(), Object::name("Off")); w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); } NewField::Radio { export, .. } => { w.set(b"AS".to_vec(), Object::name("Off")); if export.trim().is_empty() || export == "Off" { return invalid("a radio button needs an export value other than Off"); } } NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { w.set(b"FT".to_vec(), Object::name("Ch")); w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); let combo = matches!(kind, NewField::Combo { .. }); let mut ff = 0; if combo { ff |= flags::COMBO; if *editable { ff |= flags::EDIT; } } else if *editable { ff |= flags::MULTI_SELECT; } w.set(b"Ff".to_vec(), Object::Int(ff as i64)); w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); } NewField::Button { caption } => { w.set(b"FT".to_vec(), Object::name("Btn")); w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); mk.set(b"CA".to_vec(), PdfString::text(caption)); w.set(b"MK".to_vec(), Object::Dict(mk)); } NewField::Image => { w.set(b"FT".to_vec(), Object::name("Btn")); w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); // Icon only, no border or fill: the picture is the field. mk.remove(b"BC"); mk.remove(b"BG"); mk.set(b"TP".to_vec(), Object::Int(1)); w.set(b"MK".to_vec(), Object::Dict(mk)); let mut a = Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); w.set(b"A".to_vec(), Object::Dict(a)); } NewField::Signature => { w.set(b"FT".to_vec(), Object::name("Sig")); } } let widget = doc.add(Object::Dict(w)); match kind { NewField::Radio { export, .. } => { let group = match &radio_group { Some(g) => g.obj, None => { let mut g = Dict::new(); g.set(b"FT".to_vec(), Object::name("Btn")); g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); g.set(b"T".to_vec(), PdfString::text(&field_name)); g.set(b"V".to_vec(), Object::name("Off")); g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); g.set(b"Kids".to_vec(), Object::Array(Vec::new())); let g = doc.add(Object::Dict(g)); add_to_fields(doc, af, g)?; g } }; doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; doc.update_dict(group, |d| { let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); kids.push(Object::Ref(widget)); d.set(b"Kids".to_vec(), Object::Array(kids)); })?; let w = Widget { obj: widget, page: Some(page), rect, on_state: Some(export.clone()), state: Some("Off".into()), tab: usize::MAX, locked: false, hidden: false, rotation: 0, list: None, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } _ => { doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; add_to_fields(doc, af, widget)?; } } add_to_page(doc, page_ref, widget)?; redraw_field(doc, &field_name)?; Ok(field_name) } /// Regenerate every widget appearance of a field from its current value and settings. A value or /// button caption its font can't hold is drawn with `?` for those characters, the widget is marked /// (`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> { 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 { let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); missing = m; let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); d } FieldKind::CheckBox | FieldKind::Radio => { let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); appearance::check_box_states(doc, w, f.kind, &on) } FieldKind::PushButton => { let (s, m) = button_appearance(doc, w); missing = m; let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); d } FieldKind::Signature => { let s = empty_box(doc, w); let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); d } }; undrawn |= missing.is_some(); let existing = crate::merged_appearance(doc, w.obj, &ap); doc.update_dict(w.obj, |d| { d.set(b"AP".to_vec(), Object::Dict(existing)); pdfcraft_fonts::set_undrawn_mark(d, missing); })?; } if undrawn { crate::ask_viewers_to_redraw(doc)?; } Ok(()) } fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); let (width, height) = (place.layout_w, place.layout_h); let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); let col = |k: &[u8]| -> Option { let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) }; let mut c = String::new(); if let Some(bg) = col(b"BG") { c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); } if let Some(bc) = col(b"BC") { c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); } (c, place) } fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); crate::appearance::set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(resources)); Stream::flate(d, &content) } /// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its /// centred caption (unless the layout is icon only, `/TP 1`). The caption is drawn in Helvetica, /// with the first character it can't draw (drawn as `?`), for the caller to mark and report. fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option) { let (mut c, place) = frame_only(doc, w); let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); let mut content = std::mem::take(&mut c).into_bytes(); let mut xobjects = Dict::new(); if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) && let Object::Stream(s) = &*doc.get(icon) { let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); if let [x0, y0, x1, y1] = bb[..] && x1 > x0 && y1 > y0 { // Proportional, as large as fits (Acrobat's default icon placement), centred. let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); } } let mut missing = None; if !icon_only && !caption.is_empty() { missing = pdfcraft_fonts::first_undrawable(&caption); let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0); let tw = helvetica_width(&caption, size); content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes()); content.extend(literal(&win_ansi(&caption))); content.extend_from_slice(b" Tj ET\n"); } let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } (form_stream(place, content, res), missing) } /// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): /// it becomes the button's icon (`/MK /I`) and its appearance. pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; if f.kind != FieldKind::PushButton { return invalid(format!("{name} is not a button or image field")); } // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); let mut xo = Dict::new(); xo.set(b"Im0".to_vec(), Object::Ref(image)); let mut res = Dict::new(); res.set(b"XObject".to_vec(), Object::Dict(xo)); let icon = doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); for wd in &f.widgets { let mut mk = doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); mk.set(b"I".to_vec(), Object::Ref(icon)); if !mk.contains(b"TP") { mk.set(b"TP".to_vec(), Object::Int(1)); } doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; } redraw_field(doc, name) } fn empty_box(doc: &Document, w: &Widget) -> Stream { let (c, place) = frame_only(doc, w); form_stream(place, c.into_bytes(), Dict::new()) } /// Swap width and height, keeping the center. `None` when that is not a usable widget. fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { if !r.iter().all(|v| v.is_finite()) { return None; } let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); let (nw, nh) = (y1 - y0, x1 - x0); let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) } /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { return invalid("font size must be a finite, non-negative number"); } if let Some(patch) = &props.appearance { if props.look.is_some() { return invalid("give an appearance patch or a complete look, not both"); } patch.validate()?; } let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); // Validated before anything is written, so a refused rotation leaves the document unchanged. // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. let mut rotated_rect: Option> = None; if let Some((wi, rot)) = props.rotation { if !matches!(rot, 0 | 90 | 180 | 270) { return invalid("rotation must be 0, 90, 180 or 270 degrees"); } let Some(w) = f.widgets.get(wi) else { return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); }; let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); rotated_rect = Some(None); if axis_change && props.rect.is_none() { let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; rotated_rect = Some(Some(rect)); } } // A locked field only takes unlocking (Acrobat greys its properties out). if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { return invalid(format!("{name} is locked: unlock it first")); } if let Some(lock) = props.locked { for w in &f.widgets { let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); let v = if lock { flags | 128 } else { flags & !128 }; doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; } if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { return Ok(name.to_string()); } } if let Some(acts) = &props.actions { crate::set_field_actions(doc, name, acts)?; } let mut new_name = name.to_string(); if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { if n.is_empty() || n.contains('.') { return invalid("field names can't be empty or contain a period"); } if all.iter().any(|x| x.name == n) { return invalid(format!("a field named {n:?} already exists")); } // Rename the terminal part (the field's own /T). let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); new_name = format!("{prefix}{n}"); doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; } // The new default appearance when the size or the look changes. let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); let new_da = match &props.look { Some(l) => da_string(l.font, size, l.text), None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), }; if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { ensure_form(doc)?; } let mut ff = f.flags; let mut set_flag = |flag: u32, on: Option| { if let Some(on) = on { if on { ff |= flag; } else { ff &= !flag; } } }; set_flag(flags::READ_ONLY, props.read_only); set_flag(flags::REQUIRED, props.required); if f.kind == FieldKind::Text { set_flag(flags::MULTILINE, props.multiline); } for (flag, on) in &props.flags { set_flag(*flag, Some(*on)); } // A comb spreads a fixed number of characters across the field: it needs a limit and // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). if f.kind == FieldKind::Text && ff & flags::COMB != 0 { let limit = match props.max_len { Some(m) => m, None => f.max_len, }; if limit.is_none_or(|m| m == 0) { return invalid("a comb of characters needs a character limit"); } ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); } if let Some(q) = props.quadding && !(0..=2).contains(&q) { return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); } if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { return invalid("a list needs at least one option"); } if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { return invalid("only check boxes and radio buttons have a check style"); } doc.update_dict(f.obj, |d| { d.set(b"Ff".to_vec(), Object::Int(ff as i64)); if let Some(t) = &props.tooltip { if t.is_empty() { d.remove(b"TU"); } else { d.set(b"TU".to_vec(), PdfString::text(t)); } } if let Some(m) = props.max_len { match m { Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), None => { d.remove(b"MaxLen"); } } } if let Some(opts) = &props.options { d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); } // "Sort items": the list itself is kept in order (export values stay with their items). if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { let mut items: Vec<(String, String)> = match &props.options { Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), None => f.options.clone(), }; items.sort_by_key(|(_, display)| display.to_lowercase()); let opt = items .iter() .map(|(e, disp)| { if e == disp { Object::String(PdfString::text(disp)) } else { Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) } }) .collect(); d.set(b"Opt".to_vec(), Object::Array(opt)); } if let Some(q) = props.quadding { d.set(b"Q".to_vec(), Object::Int(q)); } match &props.default_value { // Buttons name their default state; text and choices hold a string. Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), Some(None) => { d.remove(b"DV"); } None => {} } if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); } })?; if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { let patch = &props.appearance.unwrap_or_default(); for w in &f.widgets { patch_widget_look(doc, w, patch)?; // A widget's own /DA overrides its field's /DA. Patch that override separately // rather than replacing its font or colour with the first widget's defaults. if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { let da = doc .get(w.obj) .as_dict() .and_then(|d| d.get(b"DA").cloned()) .map(|v| doc.resolve(&v)) .and_then(|v| v.as_string().map(|s| s.to_text())); if let Some(da) = da { let da = patch_da(&da, props.font_size, patch); doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; } } } } if let Some(l) = &props.look { let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); for w in &f.widgets { // Resolved first: an indirect /MK keeps its mark, rotation and other entries. let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); doc.update_dict(w.obj, |d| { match l.border { Some(c) => mk.set(b"BC".to_vec(), arr(c)), None => { mk.remove(b"BC"); } } match l.fill { Some(c) => mk.set(b"BG".to_vec(), arr(c)), None => { mk.remove(b"BG"); } } d.set(b"MK".to_vec(), Object::Dict(mk)); let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); bs.set(b"S".to_vec(), Object::name(l.style.code())); if l.style == BorderStyle::Dashed { bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); } d.set(b"BS".to_vec(), Object::Dict(bs)); })?; } } if let Some(style) = props.check_style { for w in &f.widgets { // Resolved first: an indirect /MK keeps its colours. let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); mk.set(b"CA".to_vec(), PdfString::text(style.code())); doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; } } if let Some((wi, r)) = props.rect { let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 4.0 || r[3] - r[1] < 4.0 { return invalid("the field is too small"); } doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; } if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); } let mut aa = doc .get(f.obj) .as_dict() .and_then(|d| d.get(b"AA").cloned()) .map(|a| doc.resolve(&a)) .and_then(|a| a.as_dict().cloned()) .unwrap_or_default(); let action = |js: &str| -> Object { let mut a = Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); Object::Dict(a) }; if let Some(fm) = &props.format { aa.remove(b"F"); aa.remove(b"K"); if let Some((fjs, kjs)) = crate::af::format_js(fm) { if !fjs.is_empty() { aa.set(b"F".to_vec(), action(&fjs)); } aa.set(b"K".to_vec(), action(&kjs)); } } if let Some(v) = &props.validate { aa.remove(b"V"); if let Some(js) = crate::af::validate_js(v) { aa.set(b"V".to_vec(), action(&js)); } } if let Some(c) = &props.calculate { aa.remove(b"C"); if let Some(js) = crate::af::calculate_js(c) { aa.set(b"C".to_vec(), action(&js)); } // The calculation order lists calculated fields. let af_ref = ensure_form(doc)?; let mut co: Vec = doc .get(af_ref) .as_dict() .and_then(|d| d.get(b"CO").cloned()) .map(|c| doc.resolve(&c)) .and_then(|c| c.as_array().cloned()) .unwrap_or_default(); co.retain(|o| o.as_ref() != Some(f.obj)); if *c != crate::af::Calculate::None { co.push(Object::Ref(f.obj)); } doc.update_dict(af_ref, |d| { if co.is_empty() { d.remove(b"CO"); } else { d.set(b"CO".to_vec(), Object::Array(co)); } })?; } doc.update_dict(f.obj, |d| { if aa.is_empty() { d.remove(b"AA"); } else { d.set(b"AA".to_vec(), Object::Dict(aa)); } })?; } // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { for w in &f.widgets { if w.obj != f.obj { doc.update_dict(w.obj, |d| { if d.contains(b"DA") { d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); } })?; } } } if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) && let Some(w) = f.widgets.get(wi) { if let Some(rect) = swapped { doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; } let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); if rot == 0 { mk.remove(b"R"); } else { mk.set(b"R".to_vec(), Object::Int(rot)); } doc.update_dict(w.obj, |d| { if mk.is_empty() { d.remove(b"MK"); } else { d.set(b"MK".to_vec(), Object::Dict(mk)); } })?; } redraw_field(doc, &new_name)?; if props.calculate.is_some() { crate::recalculate(doc)?; } Ok(new_name) } /// Delete a field: its widgets leave their pages and the field leaves the form. pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); for p in page_refs(doc) { let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); if kept.len() != list.len() { match a.as_ref() { Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, } } } // Out of the field tree: from its parent's /Kids, or from /Fields. let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); d.set(key.to_vec(), Object::Array(kept)); doc.set(holder, Object::Dict(d)); Ok(()) }; match parent { Some(p) => remove_from(doc, p, b"Kids")?, None => { let root = doc.root().ok_or(FormError::NoForm)?; match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, // A form dictionary held directly in the catalog. Some(Object::Dict(mut af)) => { let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); af.set(b"Fields".to_vec(), Object::Array(kept)); doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; } _ => {} } } } 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()) }