M6: Prepare a form ▸ Preview (fill the form as a reader would; Edit fields goes back) and Field Properties ▸ Locked (annotation Locked flag; properties greyed out until unlocked, values still fillable); form_set_props takes locked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 04:28:19 -04:00
co-authored by Claude Opus 5.5
parent 4df7a73a1a
commit c0f9c73ae9
11 changed files with 329 additions and 209 deletions
+25 -1
View File
@@ -82,6 +82,8 @@ pub struct FieldProps {
pub quadding: Option<i64>,
/// Default value (`/DV`): what Reset form restores.
pub default_value: Option<Option<String>>,
/// Lock or unlock the field (its widgets' Locked flag).
pub locked: Option<bool>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@@ -431,7 +433,15 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
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 };
let w = Widget {
obj: widget,
page: Some(page),
rect,
on_state: Some(export.clone()),
state: Some("Off".into()),
tab: usize::MAX,
locked: false,
};
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)))?;
}
@@ -548,6 +558,20 @@ fn empty_box(doc: &Document, w: &Widget) -> Stream {
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// 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());
}
}
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('.') {
+8
View File
@@ -84,6 +84,8 @@ pub struct Widget {
/// Position in the document's tab order (pages in order; within a page by its `/Tabs`:
/// rows, columns, or annotation/structure order). `usize::MAX` when not on a page.
pub tab: usize,
/// The widget's Locked flag (`/F` bit 8): its properties can't be changed.
pub locked: bool,
}
/// A terminal form field.
@@ -122,6 +124,11 @@ impl Field {
self.has(flags::READ_ONLY)
}
/// Locked (Field Properties ▸ General ▸ Locked): its properties can't be changed.
pub fn locked(&self) -> bool {
self.widgets.iter().any(|w| w.locked)
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
@@ -535,6 +542,7 @@ fn walk(
on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)),
state: wd.name(b"AS").map(|s| String::from_utf8_lossy(s).into_owned()),
tab: usize::MAX,
locked: wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 128 != 0,
})
})
.collect();
+14
View File
@@ -594,3 +594,17 @@ fn duplicating_a_field_across_pages_shares_its_value() {
assert_eq!(fields(&doc).len(), 1);
assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err());
}
#[test]
fn locked_fields_only_take_unlocking() {
let mut doc = one_page();
let name = add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("ID")).unwrap();
set_props(&mut doc, &name, &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
assert!(fields(&doc)[0].locked());
assert!(set_props(&mut doc, &name, &FieldProps { required: Some(true), ..FieldProps::default() }).is_err(), "locked");
// Values can still be filled in.
set_value(&mut doc, &name, &FieldValue::Text("42".into())).unwrap();
set_props(&mut doc, &name, &FieldProps { locked: Some(false), required: Some(true), ..FieldProps::default() }).unwrap();
let f = &fields(&doc)[0];
assert!(!f.locked() && f.has(flags::REQUIRED));
}