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
@@ -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.@@ -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) => {@@ -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" } },@@ -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();@@ -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)@@ -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;@@ -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};@@ -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> {@@ -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)?; }@@ -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,@@ -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,@@ -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)@@ -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)))?; }@@ -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);+}@@ -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())), };@@ -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"));@@ -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.@@ -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"