collective

Browse / github.com/storytold/pdfcraft / patch

M6: Form Field display choice: visible but doesn't print, hidden but printable

Forms: expose the visible-but-does-not-print field setting PdfCraft’s print path already omits annotations without the PDF Print flag, and the JavaScript form model supports `display.noPrint`. The Prepare a form / Field Properties UI does not expose a way to set this for an existing widget. Add a field-display choice that preserves visibility on screen while clearing the widget Print flag, with a UI regression and print-output coverage. This is especially useful for submit/print buttons placed on forms.

Status
open
Score
0.00
Adopters
0 other operators
Author
AdrianTM (imported from pull request #5053, open)
Base
9e90b2a54d6250465ba3d11a6eb0b13f3b690381
Size
+405 −37 in 18 files
Review
pending
Shared
2026-10-28
Id
466129a1c7b0e4d90331747f

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/automation/src/forms.rs crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/engine/src/js.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/forms/src/author.rs crates/forms/src/author/edit.rs crates/forms/src/author/lifecycle.rs crates/forms/src/lib.rs crates/forms/src/read.rs crates/forms/src/scripting.rs crates/forms/src/tests.rs crates/ui-egui/src/tools/prepare/draft.rs crates/ui-egui/src/tools/prepare/properties.rs crates/ui-egui/tests/it/prepare.rs parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -748,3 +748,5 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.  - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.++- **2026-10-27 (M6, #5041):** Field Properties ▸ General has Acrobat's Form Field choice (Visible, Hidden, Visible but doesn't print, Hidden but printable), written to every widget's `/F` and read back (`FieldDisplay`); `form_set_props` takes `display` and `form_fields` reports it. The printout leaves out a field that doesn't print. Estimates unchanged.diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs--- a/crates/automation/src/forms.rs+++ b/crates/automation/src/forms.rs@@ -210,6 +210,20 @@ impl Automation {                 if let Some(t) = &f.tooltip {                     o.insert("tooltip".into(), json!(t));                 }+                // How the field shows and prints, when it is not the usual shown-and-printed. Its own+                // key: `display` is the formatted value below.+                let display = w.map(|w| w.display).unwrap_or_default();+                if display != pdfcraft_engine::FieldDisplay::Visible {+                    o.insert(+                        "field_display".into(),+                        json!(match display {+                            pdfcraft_engine::FieldDisplay::Visible => "visible",+                            pdfcraft_engine::FieldDisplay::Hidden => "hidden",+                            pdfcraft_engine::FieldDisplay::VisibleNoPrint => "visible_no_print",+                            pdfcraft_engine::FieldDisplay::HiddenPrintable => "hidden_printable",+                        }),+                    );+                }                 if let Some(s) = doc.field_check_style(&f.name) {                     o.insert("check_style".into(), json!(s.label().to_lowercase()));                 }@@ -538,6 +552,14 @@ impl Automation {             },             default_value: a.opt_str("default")?.map(|d| (!d.is_empty()).then(|| d.to_owned())),             locked: a.opt_bool("locked")?,+            display: match a.opt_str("display")? {+                None => None,+                Some("visible") => Some(pdfcraft_engine::FieldDisplay::Visible),+                Some("hidden") => Some(pdfcraft_engine::FieldDisplay::Hidden),+                Some("visible_no_print") => Some(pdfcraft_engine::FieldDisplay::VisibleNoPrint),+                Some("hidden_printable") => Some(pdfcraft_engine::FieldDisplay::HiddenPrintable),+                Some(o) => return Err(bad(format!("unknown display {o:?} (visible, hidden, visible_no_print, hidden_printable)"))),+            },             flags: match a.get("flags") {                 None => Vec::new(),                 Some(v) => {diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -358,7 +358,7 @@ pub fn tools() -> Vec<ToolDef> {             }),             &["doc", "from", "to"],         )),-        t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")+        t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags, and field_display (visible_no_print, hidden or hidden_printable) when the field is not the usual shown-and-printed. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")             .ro()             .with(schema(json!({ "doc": doc() }), &["doc"])),         t(@@ -451,6 +451,7 @@ pub fn tools() -> Vec<ToolDef> {                 "options": { "type": "array", "items": { "type": "string" } },                 "align": { "type": "string", "enum": ["left", "center", "right"] },                 "locked": { "type": "boolean", "description": "Lock the field's properties (only unlocking is accepted while locked)." },+                "display": { "type": "string", "enum": ["visible", "hidden", "visible_no_print", "hidden_printable"], "description": "How the field shows and prints, as Acrobat's Form Field choice: visible (shown and printed), hidden, visible_no_print (shown on screen, left out of the printout: a Submit or Print button), hidden_printable. Set on every widget; keeps their other flags." },                 "default": { "type": "string" },                 "check_style": { "type": "string", "enum": ["check", "circle", "cross", "diamond", "square", "star"], "description": "Check boxes and radio buttons: the mark shown when on." },                 "flags": { "type": "object", "additionalProperties": { "type": "boolean" } },diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -539,6 +539,97 @@ fn form_pdf(name: &str) -> Vec<u8> {     bytes } +/// A one-page form with two text fields, "plain" and "submit", both shown and printed (`/F 4`),+/// each with a value and a red or blue fill so the printout shows which of them it drew.+fn print_form_pdf() -> Vec<u8> {+    // Each field has a normal appearance of its own: a solid red or blue box with the value.+    let ap = |rgb: &str, value: &str| {+        let body = format!("{rgb} rg 0 0 120 40 re f BT /Helv 12 Tf 1 g 8 14 Td ({value}) Tj ET");+        format!(+            "<< /Type /XObject /Subtype /Form /BBox [0 0 120 40] /Resources << /Font << /Helv 6 0 R >> >> /Length {} >>\nstream\n{body}\nendstream",+            body.len()+        )+    };+    let objs: Vec<String> = vec![+        "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>".into(),+        "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),+        "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".into(),+        "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /V (KEEPME) /F 4 /AP << /N 7 0 R >> /Rect [20 120 140 160] /P 3 0 R >>".into(),+        "<< /Type /Annot /Subtype /Widget /FT /Tx /T (submit) /V (DROPME) /F 4 /AP << /N 8 0 R >> /Rect [160 120 280 160] /P 3 0 R >>".into(),+        "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),+        ap("1 0 0", "KEEPME"),+        ap("0 0 1", "DROPME"),+    ];+    let mut bytes = b"%PDF-1.7\n".to_vec();+    let mut offsets = Vec::new();+    for (i, o) in objs.iter().enumerate() {+        offsets.push(bytes.len());+        bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());+    }+    let xref = bytes.len();+    bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());+    for o in offsets {+        bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());+    }+    bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());+    bytes+}++/// #5041: a field can be shown on screen and left out of the printout, through the properties+/// tool: `display` is written, listed by form_fields, printed or not accordingly, and undoable.+#[test]+fn a_field_can_be_visible_but_not_print() {+    let dir = workdir("field-display");+    std::fs::write(dir.join("form.pdf"), print_form_pdf()).unwrap();+    let mut a = auto(&dir);+    let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();+    // How many pixels of the printout are the red of "plain" and the blue of "submit" (the page is+    // scaled to the sheet, so the counts are what tells, not the places).+    let printed = |a: &mut Automation, name: &str| -> (bool, bool) {+        ok(a, "doc_print", json!({ "doc": doc, "path": name }));+        let out = ok(a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap();+        let png = a.call("page_render", &json!({ "doc": out, "page": 1, "dpi": 72 })).unwrap();+        let Content::Png { data, width, .. } = &png[0] else { panic!("expected an image") };+        let mut reader = png::Decoder::new(std::io::Cursor::new(data.as_slice())).read_info().unwrap();+        let mut buf = vec![0; reader.output_buffer_size().unwrap()];+        let frame = reader.next_frame(&mut buf).unwrap();+        let channels = frame.line_size / *width as usize;+        let count = |colour: [u8; 3]| buf.chunks(channels).filter(|p| p.len() >= 3 && p[..3] == colour).count();+        (count([255, 0, 0]) > 2000, count([0, 0, 255]) > 2000)+    };+    let display_of = |a: &mut Automation, field: &str| -> Option<String> {+        let list = ok(a, "form_fields", json!({ "doc": doc }));+        let f = list["fields"].as_array().unwrap().iter().find(|f| f["name"] == field).unwrap().clone();+        f["field_display"].as_str().map(str::to_string)+    };+    // Both shown and printed to begin with.+    assert_eq!(display_of(&mut a, "submit"), None);+    assert_eq!(printed(&mut a, "before.pdf"), (true, true), "both fields are printed");++    // Visible but doesn't print: still on screen (it is not hidden), left out of the printout.+    ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "submit", "display": "visible_no_print" }));+    assert_eq!(display_of(&mut a, "submit").as_deref(), Some("visible_no_print"));+    assert_eq!(display_of(&mut a, "plain"), None, "the other field is untouched");+    assert_eq!(printed(&mut a, "after.pdf"), (true, false), "the printout has the first field and not the second");++    // The other choices, and back to visible; each is listed as it was set.+    for (choice, listed) in [("hidden", Some("hidden")), ("hidden_printable", Some("hidden_printable")), ("visible", None)] {+        ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "submit", "display": choice }));+        assert_eq!(display_of(&mut a, "submit").as_deref(), listed, "{choice}");+    }+    assert_eq!(printed(&mut a, "restored.pdf"), (true, true), "visible again, and printed");+    // One undo step each.+    ok(&mut a, "edit_undo", json!({ "doc": doc }));+    assert_eq!(display_of(&mut a, "submit").as_deref(), Some("hidden_printable"));++    // Bad values are refused and change nothing.+    for bad in [json!("shown"), json!("Hidden"), json!(1)] {+        let r = a.call("form_set_props", &json!({ "doc": doc, "field": "submit", "display": bad }));+        assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}");+    }+    assert_eq!(display_of(&mut a, "submit").as_deref(), Some("hidden_printable"));+}+ #[test] fn accessibility_check_locates_field_description_on_page() {     let dir = workdir("accessibility-field-location");@@ -4789,6 +4880,11 @@ fn form_formats_and_calculations_through_tools() {     let f = ok(&mut a, "form_fields", json!({ "doc": doc }));     let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();     assert_eq!((total["value"].as_str(), total["display"].as_str()), (Some("59.97"), Some("$59.97")), "{total}");+    // The form-field display has its own key, so a formatted field keeps both.+    ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "Total", "display": "visible_no_print" }));+    let f = ok(&mut a, "form_fields", json!({ "doc": doc }));+    let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap();+    assert_eq!((total["display"].as_str(), total["field_display"].as_str()), (Some("$59.97"), Some("visible_no_print")), "{total}");     assert_eq!(total["calculate"]["notation"], "Price * Qty");     assert!(page_text(&mut a, doc)[0].contains("$59.97"));     let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Price": "-5" } })).unwrap_err();diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs--- a/crates/engine/src/js.rs+++ b/crates/engine/src/js.rs@@ -92,23 +92,21 @@ fn kind(k: FieldKind) -> pdfcraft_js::FieldType {     } } -/// `field.display` from the first widget's annotation flags.-fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 {-    let flags = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().and_then(|d| d.int(b"F"))).unwrap_or(4);-    if flags & 2 != 0 {-        pdfcraft_js::DISPLAY_HIDDEN-    } else if flags & 32 != 0 {-        pdfcraft_js::DISPLAY_NO_VIEW-    } else if flags & 4 == 0 {-        pdfcraft_js::DISPLAY_NO_PRINT-    } else {-        pdfcraft_js::DISPLAY_VISIBLE+/// `field.display` from the first widget's annotation flags (resolved, an absent `/F` is 0, as+/// the Field Properties dialog reads it).+fn display(f: &Field) -> i32 {+    use crate::FieldDisplay as D;+    match f.widgets.first().map_or(D::Visible, |w| w.display) {+        D::Visible => pdfcraft_js::DISPLAY_VISIBLE,+        D::Hidden => pdfcraft_js::DISPLAY_HIDDEN,+        D::VisibleNoPrint => pdfcraft_js::DISPLAY_NO_PRINT,+        D::HiddenPrintable => pdfcraft_js::DISPLAY_NO_VIEW,     } }  /// A form field as scripts see it.-pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState {-    field_state_with_display(f, display(doc, f))+pub fn field_state(_doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState {+    field_state_with_display(f, display(f)) }  fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState {@@ -157,12 +155,9 @@ pub struct JsRunner {     pub doc: pdfcraft_js::DocInfo,     pub doc_scripts: Vec<String>,     pub output: JsOutput,-    /// Immutable document snapshot from the start of the edit (for widget `display`).-    cos: pdfcraft_cos::Document,-    // `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only-    // its scalar display readings, not COS objects: large forms exceed the object cache and-    // otherwise reparse every widget for every calculation. Values/flags still come from-    // the live `fields` snapshot on each call. A new edit gets a new runner and cache.+    // The first reading of each widget's display is kept for the runner's lifetime (a new edit+    // gets a new runner), so a script that changes a field's display does not change what the+    // next calculation reads. Values/flags still come from the live `fields` snapshot each call.     displays: std::collections::HashMap<pdfcraft_cos::ObjRef, i32>, } @@ -179,7 +174,7 @@ impl JsRunner {         // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts.         let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some();         let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) };-        JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() }+        JsRunner { doc, doc_scripts, output: JsOutput::default(), displays: std::collections::HashMap::new() }     } } @@ -200,7 +195,7 @@ impl Scripts for JsRunner {             .iter()             .map(|f| {                 let display = match f.widgets.first() {-                    Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)),+                    Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(f)),                     None => pdfcraft_js::DISPLAY_VISIBLE,                 };                 field_state_with_display(f, display)diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -85,10 +85,10 @@ pub use pdfcraft_edit::{     Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{-    BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,-    FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,-    Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,-    calculations_changed as form_calculations_changed, flags as field_flags,+    BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldDisplay,+    FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder,+    Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, appearance as form_appearance,+    appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed, flags as field_flags, };  pub use pdfcraft_a11y as a11y;diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -8389,6 +8389,33 @@ fn dynamic_calculation_reads_and_side_effects_reach_later_fields() {     assert!(s.take_js_output(id).errors.is_empty()); } +/// #5041: `field.display` reads the widget's resolved `/F` the way Field Properties does: an+/// absent flag is 0, and a flag behind a reference counts.+#[test]+fn field_display_reads_absent_and_indirect_flags_like_the_properties_dialog() {+    use pdfcraft_cos::Object;+    let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();+    let display = |cos: &pdfcraft_cos::Document, name: &str| {+        let all = pdfcraft_forms::fields(cos);+        crate::js::field_state(cos, all.iter().find(|f| f.name == name).unwrap()).display+    };+    let widget = |cos: &pdfcraft_cos::Document, name: &str| pdfcraft_forms::fields(cos).iter().find(|f| f.name == name).unwrap().widgets[0].obj;+    assert_eq!(display(&cos, "price"), pdfcraft_js::DISPLAY_VISIBLE);+    let (price, qty) = (widget(&cos, "price"), widget(&cos, "qty"));+    // Hidden | Print behind a reference, and a widget with no /F.+    let r = cos.add(Object::Int(2 | 4));+    cos.update_dict(price, |d| d.set(b"F".to_vec(), Object::Ref(r))).unwrap();+    cos.update_dict(qty, |d| {+        d.remove(b"F");+    })+    .unwrap();+    assert_eq!(display(&cos, "price"), pdfcraft_js::DISPLAY_HIDDEN);+    assert_eq!(display(&cos, "qty"), pdfcraft_js::DISPLAY_NO_PRINT);+    let r = cos.add(Object::Int(4 | 32 | 128));+    cos.update_dict(price, |d| d.set(b"F".to_vec(), Object::Ref(r))).unwrap();+    assert_eq!(display(&cos, "price"), pdfcraft_js::DISPLAY_NO_VIEW);+}+ #[test] fn scripted_display_changes_survive_warm_cache_and_new_edit() {     use pdfcraft_forms::{FieldEvent, Scripts};diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs--- a/crates/forms/src/author.rs+++ b/crates/forms/src/author.rs@@ -115,6 +115,8 @@ pub struct FieldProps {     /// 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)>,+    /// General tab ▸ Form Field: how the field shows and prints (every widget).+    pub display: Option<crate::FieldDisplay>, }  fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs--- a/crates/forms/src/author/edit.rs+++ b/crates/forms/src/author/edit.rs@@ -67,6 +67,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S             return Ok(name.to_string());         }     }+    if let Some(display) = props.display {+        for w in &f.widgets {+            let old = 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 new = display.apply_to(old);+            if new != old {+                doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?;+            }+        }+        if *props == (FieldProps { display: props.display, ..FieldProps::default() }) {+            return Ok(name.to_string());+        }+    }     if let Some(acts) = &props.actions {         crate::set_field_actions(doc, name, acts)?;     }diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs--- a/crates/forms/src/author/lifecycle.rs+++ b/crates/forms/src/author/lifecycle.rs@@ -166,6 +166,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel                 tab: usize::MAX,                 locked: false,                 hidden: false,+                display: crate::FieldDisplay::Visible,                 read_only: false,                 rotation: 0,                 caption: None,diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs--- a/crates/forms/src/lib.rs+++ b/crates/forms/src/lib.rs@@ -88,6 +88,61 @@ pub enum FieldKind {     Signature, } +/// How a field shows and prints: Acrobat's Common Properties ▸ Form Field choices, written as the+/// widgets' annotation flags (`/F`: Hidden 2, Print 4, NoView 32; ISO 32000-2 Table 167).+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub enum FieldDisplay {+    /// Shown on screen and printed.+    #[default]+    Visible,+    /// Not shown and takes no input.+    Hidden,+    /// Shown on screen, but not printed (a Submit or Print button, say).+    VisibleNoPrint,+    /// Not shown on screen, but printed.+    HiddenPrintable,+}++impl FieldDisplay {+    /// The display a widget's `/F` flags give it: Hidden first, then NoView (printable only with+    /// the Print flag; without it the widget neither shows nor prints, which is Hidden), then no+    /// Print flag.+    pub fn from_flags(flags: i64) -> Self {+        if flags & 2 != 0 {+            Self::Hidden+        } else if flags & 32 != 0 {+            if flags & 4 != 0 { Self::HiddenPrintable } else { Self::Hidden }+        } else if flags & 4 == 0 {+            Self::VisibleNoPrint+        } else {+            Self::Visible+        }+    }++    /// `old` (a widget's `/F`) with this display's Hidden, Print and NoView bits; every other flag+    /// (Locked, NoRotate…) is kept.+    pub fn apply_to(self, old: i64) -> i64 {+        let base = old & !(2 | 4 | 32);+        match self {+            Self::Visible => base | 4,+            Self::Hidden => base | 2 | 4,+            Self::VisibleNoPrint => base,+            Self::HiddenPrintable => base | 4 | 32,+        }+    }++    /// JavaScript's `field.display`: 0 visible, 1 hidden, 2 noPrint, 3 noView (anything else is+    /// visible).+    pub fn from_script_code(code: i32) -> Self {+        match code {+            1 => Self::Hidden,+            2 => Self::VisibleNoPrint,+            3 => Self::HiddenPrintable,+            _ => Self::Visible,+        }+    }+}+ /// Field flags (§12.7.4, Tables 226, 228, 229, 231). pub mod flags {     pub const READ_ONLY: u32 = 1;@@ -130,6 +185,8 @@ pub struct Widget {     pub locked: bool,     /// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input.     pub hidden: bool,+    /// Whether it shows and prints, from its `/F` flags (Acrobat's Common Properties ▸ Form Field).+    pub display: FieldDisplay,     /// Read-only: the field is, or the widget's own `/Ff` says so (a radio button of a group can     /// be locked on its own, as an XFA exclusion group's layout writes it).     pub read_only: bool,diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs--- a/crates/forms/src/read.rs+++ b/crates/forms/src/read.rs@@ -520,6 +520,7 @@ fn walk(                 tab: usize::MAX,                 locked: annot_flags & 128 != 0,                 hidden: annot_flags & (2 | 32) != 0,+                display: FieldDisplay::from_flags(annot_flags),                 read_only: (ff | own_ff) & flags::READ_ONLY != 0,                 rotation: appearance::mk_rotation(doc, wd),                 caption: (kind == FieldKind::PushButton)diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs--- a/crates/forms/src/scripting.rs+++ b/crates/forms/src/scripting.rs@@ -137,15 +137,8 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:         }         if let Some(display) = c.display {             for w in &f.widgets {-                let old = doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0);-                // Hidden 2, Print 4, NoView 32 (Table 167).-                let base = old & !(2 | 4 | 32);-                let new = match display {-                    1 => base | 2 | 4,-                    2 => base,-                    3 => base | 4 | 32,-                    _ => base | 4,-                };+                let old = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F")).and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);+                let new = crate::FieldDisplay::from_script_code(display).apply_to(old);                 if new != old {                     doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?;                 }diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs--- a/crates/forms/src/tests.rs+++ b/crates/forms/src/tests.rs@@ -4577,3 +4577,108 @@ fn set_values_reads_the_form_once_not_per_value() {     let reads = form_reads() - before;     assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); }++/// #5041: a field's display is the Hidden, Print and NoView bits of its widgets' `/F`; setting it+/// keeps every other flag, and reading it back gives the same choice.+#[test]+fn a_fields_display_is_written_to_every_widget_and_read_back() {+    let flags_of = |doc: &Document, name: &str| -> Vec<i64> {+        let all = fields(doc);+        let f = all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"));+        f.widgets.iter().map(|w| doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0)).collect()+    };+    let display_of = |doc: &Document, name: &str| -> Vec<FieldDisplay> {+        let all = fields(doc);+        all.iter().find(|f| f.name == name).unwrap().widgets.iter().map(|w| w.display).collect()+    };+    let mut doc = fixture();+    // The fixture's widgets have no /F: shown, and (the flag absent) not printed, as the file says.+    assert_eq!(display_of(&doc, "name"), [FieldDisplay::VisibleNoPrint]);+    for (choice, bits) in [+        (FieldDisplay::Visible, 4),+        (FieldDisplay::VisibleNoPrint, 0),+        (FieldDisplay::Hidden, 2 | 4),+        (FieldDisplay::HiddenPrintable, 4 | 32),+        (FieldDisplay::Visible, 4),+    ] {+        set_props(&mut doc, "name", &FieldProps { display: Some(choice), ..Default::default() }).unwrap();+        assert_eq!(flags_of(&doc, "name"), [bits], "{choice:?}");+        assert_eq!(display_of(&doc, "name"), [choice]);+        // And after saving and reopening.+        let again = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();+        assert_eq!(display_of(&again, "name"), [choice], "{choice:?} survives save and reopen");+    }+    // A radio group: every one of its widgets.+    set_props(&mut doc, "size", &FieldProps { display: Some(FieldDisplay::VisibleNoPrint), ..Default::default() }).unwrap();+    assert_eq!(flags_of(&doc, "size"), [0, 0]);+    // Other flags are kept: Locked (128) and NoRotate (16) on a widget stay when its display changes.+    let go = fields(&doc).iter().find(|f| f.name == "go").unwrap().widgets[0].obj;+    doc.update_dict(go, |d| d.set(b"F".to_vec(), Object::Int(4 | 16))).unwrap();+    set_props(&mut doc, "go", &FieldProps { display: Some(FieldDisplay::VisibleNoPrint), ..Default::default() }).unwrap();+    assert_eq!(flags_of(&doc, "go"), [16], "NoRotate kept, Print cleared");+    set_props(&mut doc, "go", &FieldProps { locked: Some(true), ..Default::default() }).unwrap();+    assert_eq!(flags_of(&doc, "go"), [16 | 128]);+    // A locked field only takes unlocking: its display is refused, and nothing changes.+    assert!(set_props(&mut doc, "go", &FieldProps { display: Some(FieldDisplay::Hidden), ..Default::default() }).is_err());+    assert_eq!(flags_of(&doc, "go"), [16 | 128]);+    // Together with another property it applies after the rest, and an unknown field is refused.+    set_props(&mut doc, "id", &FieldProps { tooltip: Some("an id".into()), display: Some(FieldDisplay::Hidden), ..Default::default() }).unwrap();+    assert_eq!(flags_of(&doc, "id"), [2 | 4]);+    assert!(set_props(&mut doc, "nope", &FieldProps { display: Some(FieldDisplay::Hidden), ..Default::default() }).is_err());+}++/// #5041: a script's `display.*` change resolves an indirect `/F`, keeps its unrelated bits, and+/// reads an absent `/F` as 0, as the properties API does.+#[test]+fn script_display_changes_resolve_indirect_flags_and_keep_other_bits() {+    let mut doc = fixture();+    let widget = |doc: &Document, name: &str| fields(doc).iter().find(|f| f.name == name).unwrap().widgets[0].obj;+    let flags = |doc: &Document, obj| doc.get(obj).as_dict().and_then(|d| d.get(b"F")).and_then(|f| doc.resolve(f).as_int());+    let change = |name: &str, display: i32| FieldChange { name: name.into(), display: Some(display), ..Default::default() };+    // Print | NoRotate | Locked, behind a reference: only the display bits change.+    let w = widget(&doc, "name");+    let r = doc.add(Object::Int(4 | 16 | 128));+    doc.update_dict(w, |d| d.set(b"F".to_vec(), Object::Ref(r))).unwrap();+    assert_eq!(fields(&doc).iter().find(|f| f.name == "name").unwrap().widgets[0].display, FieldDisplay::Visible);+    apply_script_changes(&mut doc, &[change("name", 2)], &mut NoScripts).unwrap();+    assert_eq!(flags(&doc, w), Some(16 | 128), "display.noPrint keeps NoRotate and Locked");+    apply_script_changes(&mut doc, &[change("name", 3)], &mut NoScripts).unwrap();+    assert_eq!(flags(&doc, w), Some(4 | 16 | 32 | 128), "display.noView");+    apply_script_changes(&mut doc, &[change("name", 0)], &mut NoScripts).unwrap();+    assert_eq!(flags(&doc, w), Some(4 | 16 | 128), "display.visible");+    // No /F at all is 0: the script setter builds on that, not on 4.+    let w = widget(&doc, "size");+    assert_eq!(flags(&doc, w), None);+    apply_script_changes(&mut doc, &[change("size", 1)], &mut NoScripts).unwrap();+    assert_eq!(flags(&doc, w), Some(2 | 4));+}++/// #5041: the flag arithmetic, and that scripts' `display.*` codes and a field's `/F` agree.+#[test]+fn field_display_flags_round_trip_through_every_other_flag() {+    for old in 0..=255i64 {+        for choice in [FieldDisplay::Visible, FieldDisplay::Hidden, FieldDisplay::VisibleNoPrint, FieldDisplay::HiddenPrintable] {+            let new = choice.apply_to(old);+            assert_eq!(new & !(2 | 4 | 32), old & !(2 | 4 | 32), "only the display bits change: {old} {choice:?}");+            assert_eq!(FieldDisplay::from_flags(new), choice, "{old} {choice:?}");+        }+    }+    for (code, choice) in [+        (0, FieldDisplay::Visible),+        (1, FieldDisplay::Hidden),+        (2, FieldDisplay::VisibleNoPrint),+        (3, FieldDisplay::HiddenPrintable),+        (9, FieldDisplay::Visible),+        (-1, FieldDisplay::Visible),+    ] {+        assert_eq!(FieldDisplay::from_script_code(code), choice, "{code}");+    }+    // What the document has: no flags at all is shown and not printed; Print alone is the usual.+    assert_eq!(FieldDisplay::from_flags(4), FieldDisplay::Visible);+    assert_eq!(FieldDisplay::from_flags(0), FieldDisplay::VisibleNoPrint);+    assert_eq!(FieldDisplay::from_flags(2), FieldDisplay::Hidden);+    assert_eq!(FieldDisplay::from_flags(32 | 4), FieldDisplay::HiddenPrintable);+    // NoView without Print neither shows nor prints: Hidden, not "hidden but printable".+    assert_eq!(FieldDisplay::from_flags(32), FieldDisplay::Hidden);+    assert_eq!(FieldDisplay::from_flags(32 | 128), FieldDisplay::Hidden);+}diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs--- a/crates/ui-egui/src/tools/prepare/draft.rs+++ b/crates/ui-egui/src/tools/prepare/draft.rs@@ -122,6 +122,8 @@ pub struct FieldDraft {     pub flags: u32,     /// Field Properties ▸ General ▸ Locked.     pub locked: bool,+    /// Field Properties ▸ General ▸ Form Field: how the field shows and prints.+    pub display: pdfcraft_engine::FieldDisplay,     /// `/Q`: 0 left, 1 centre, 2 right.     pub quadding: i64,     /// `/DV` (text and choices), or the default state (buttons: empty = off).@@ -198,6 +200,7 @@ impl FieldDraft {             new_option: String::new(),             flags: f.flags,             locked: f.locked(),+            display: f.widgets.get(widget).or(f.widgets.first()).map(|w| w.display).unwrap_or_default(),             quadding: f.quadding,             default: f.default.first().cloned().unwrap_or_default(),             on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(),@@ -272,6 +275,7 @@ impl FieldDraft {             flags: OPTION_FLAGS.iter().filter(|b| (self.flags ^ o.flags) & **b != 0).map(|b| (*b, self.flags & b != 0)).collect(),             quadding: (self.quadding != o.quadding).then_some(self.quadding),             locked: ch(self.locked, o.locked),+            display: (self.display != o.display).then_some(self.display),             actions: (self.actions != o.actions).then(|| self.actions.clone()),             default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())),         };diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs--- a/crates/ui-egui/src/tools/prepare/properties.rs+++ b/crates/ui-egui/src/tools/prepare/properties.rs@@ -65,6 +65,27 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke                     let l = ui.label(tl!("Tooltip:"));                     ui.add(crate::widgets::line(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id);                     ui.end_row();+                    // Shown or hidden on screen, and printed or not: a Submit or Print button is+                    // shown but left out of the printout (#5041).+                    let l = ui.label(tl!("Form Field:"));+                    use pdfcraft_engine::FieldDisplay as Display;+                    let shown = |d: Display| match d {+                        Display::Visible => tl!("Visible"),+                        Display::Hidden => tl!("Hidden"),+                        Display::VisibleNoPrint => tl!("Visible but doesn't print"),+                        Display::HiddenPrintable => tl!("Hidden but printable"),+                    };+                    craft_ui::ComboBox::from_id_salt("field-display")+                        .selected_text(shown(d.display))+                        .width(340.0)+                        .show_ui(ui, |ui| {+                            for choice in [Display::Visible, Display::Hidden, Display::VisibleNoPrint, Display::HiddenPrintable] {+                                ui.selectable_value(&mut d.display, choice, shown(choice));+                            }+                        })+                        .response+                        .labelled_by(l.id);+                    ui.end_row();                 });                 ui.add_space(12.0);                 widgets::section_title(ui, tl!("Common Properties"));diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs--- a/crates/ui-egui/tests/it/prepare.rs+++ b/crates/ui-egui/tests/it/prepare.rs@@ -319,6 +319,35 @@ fn position_tab_rotates_a_field() {     assert!(back.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {:?}", back.rect, before.rect); } +#[test]+fn general_tab_sets_the_form_field_display() {+    use pdfcraft_engine::FieldDisplay as Display;+    let mut h = harness();+    h.state_mut().open_field_props("city", 0);+    h.run_steps(2);+    let widget = |h: &Harness<'static, PdfCraftApp>| {+        let s = h.state();+        s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().widgets[0].clone()+    };+    // The fixture's widget has no Print flag: Acrobat lists that as "Visible but doesn't print".+    assert_eq!(widget(&h).display, Display::VisibleNoPrint);+    h.get_by_label("Form Field:");+    assert_eq!(h.state().field_props.as_ref().unwrap().display, Display::VisibleNoPrint);+    assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit");+    // Pick the choice from the list the way a user does.+    h.get_by_role_and_label(Role::ComboBox, "Form Field:").click();+    h.run_steps(3);+    h.get_by_label("Visible").click();+    h.run_steps(3);+    assert_eq!(h.state().field_props.as_ref().unwrap().display, Display::Visible);+    h.get_by_label("OK").click();+    h.run_steps(3);+    assert_eq!(widget(&h).display, Display::Visible);+    h.state_mut().execute("edit.undo");+    h.run_steps(3);+    assert_eq!(widget(&h).display, Display::VisibleNoPrint);+}+ #[test] fn options_tab_sets_the_check_box_style() {     // #94: Check Box Style on the Options tab.diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -4955,8 +4955,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"]-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/it/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/it/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"]-notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch (visibility pending)"+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/it/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/it/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document", "crates/forms/src/tests.rs::a_fields_display_is_written_to_every_widget_and_read_back", "crates/ui-egui/tests/it/prepare.rs::general_tab_sets_the_form_field_display", "crates/automation/tests/it/automation.rs::a_field_can_be_visible_but_not_print"]+notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch. Form Field: Visible, Hidden, Visible but doesn't print, Hidden but printable (#5041) in Field Properties and through form_set_props display, written to every widget's /F (Hidden, Print, NoView) and read back, also by the field.display script property; a widget with no Print flag lists as Visible but doesn't print, as in Acrobat. Not yet: the shared (multi-field) dialog has no Form Field choice"  [[feature]] id = "form.props-appearance"