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"
