From bd00a131b67f37ca8b9eb2c5e0f078a6639e7bac Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 12:52:40 +0200 Subject: [PATCH 01/28] M6: form_fill warns when a value does not fit its field (#4018) With a fixed font size, a text field's appearance cuts a long value at the edge of its box, but form_fill only answered "filled": n. A person sees the cut; an agent filling forms through the CLI or MCP did not. The text appearance builder now notes when the text it lays out does not fit: one line wider than the box, or wrapped lines below it. forms::appearance::value_overflow asks that for a field's widget, Document::field_overflow for a field by name, and form_fill adds a warning for each filled field that is cut off. The value is still saved whole and drawn as before; auto-sized fields shrink to fit and are not reported. The automation tests' inline PDF builder moves to a shared pdf_of. Fixes #4018 --- crates/automation/src/forms.rs | 17 ++++++++ crates/automation/tests/automation.rs | 59 +++++++++++++++++++++------ crates/engine/src/lib.rs | 8 ++++ crates/forms/src/appearance.rs | 54 ++++++++++++++++++++++-- crates/forms/src/tests.rs | 37 +++++++++++++++++ 5 files changed, 159 insertions(+), 16 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 26e7b9d06..763fd0efd 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -288,6 +288,23 @@ impl Automation { let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } }; let mut out = self.apply(a, edit)?; out["filled"] = json!(values.len()); + // A value the box cuts off at a fixed font size is saved whole but shows only in part: + // said, not silent (#4018). + let doc = self.doc(a)?; + let cut: Vec = values + .keys() + .filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o))) + .map(|(name, page, o)| { + let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default(); + json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)")) + }) + .collect(); + if !cut.is_empty() { + match out.get_mut("warnings").and_then(Value::as_array_mut) { + Some(w) => w.extend(cut), + None => out["warnings"] = Value::Array(cut), + } + } Ok(out) } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index bf6169f42..9cf0b768c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2293,6 +2293,51 @@ fn forms_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #4018: `form_fill` warns about a value its box cuts off at the field's fixed font size (the +/// value is saved whole), and not about one that fits. +#[test] +fn form_fill_warns_when_a_value_does_not_fit() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (narrow) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (wide) /Rect [10 200 310 218] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("form-overflow"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let long = "Doe, Jane (represented by John Doe)"; + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": long, "wide": long } })); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].starts_with("form field \"narrow\" (page 1): the value does not fit"), "{}", warnings[0]); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["value"], long, "saved whole"); + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": "Ada" } })); + assert!(r.get("warnings").is_none(), "{r}"); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -2308,19 +2353,7 @@ fn orphan_form_fields_through_tools() { "<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7 ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } let dir = workdir("orphan-forms"); std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596..7255cfec0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -843,6 +843,14 @@ impl Document { matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) } + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..651f8e320 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -449,6 +449,40 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format); + (stream, unencodable) +} + +/// A text value that its box cuts off at the field's font size (#4018). +#[derive(Clone, Debug, PartialEq)] +pub enum Overflow { + /// One line `width` points wide, with `room` for it, at `size` points. + Wide { width: f64, room: f64, size: f64 }, + /// `lines` wrapped lines, of which the box shows `room`, at `size` points. + Tall { lines: usize, room: usize, size: f64 }, +} + +impl std::fmt::Display for Overflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Wide { width, room, size } => { + write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) + } + Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + } + } +} + +/// Whether the text field's value, drawn in widget `w`, is cut off by its box. +pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option { + if f.kind != FieldKind::Text { + return None; + } + field_appearance_fit(doc, f, w, &f.value, true).2 +} + +/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. +fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -513,6 +547,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } }; let mut size = da.size; + let mut overflow = None; match f.kind { FieldKind::List => { // Every option, one per line from the top (from `/TI`); selected ones highlighted. @@ -539,13 +574,22 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; - for line in wrap_with(&text, size, inner_w, width_of) { + let lines = wrap_with(&text, size, inner_w, width_of); + // The lines whose descent stays inside the border; the rest are cut off. + let mut room = 0; + for line in &lines { if y < -size { break; } - show(&mut body, x_for(&line, size), y, &line); + if y - 0.207 * size >= bw - 0.01 { + room += 1; + } + show(&mut body, x_for(line, size), y, line); y -= size * 1.15; } + if room < lines.len() { + overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } } else { if size == 0.0 { // Comb cells hold one character each, so only the height limits them. @@ -565,6 +609,10 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { + let tw = width_of(&text.replace(['\n', '\r'], " "), size); + if tw > inner_w + 0.01 { + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } show(&mut body, x_for(&text, size), y, &text); } } @@ -595,7 +643,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - (Stream::flate(d, &content), unencodable) + (Stream::flate(d, &content), unencodable, overflow) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ec..84ce87984 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,40 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut +/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. +#[test] +fn a_value_wider_or_taller_than_its_box_is_reported() { + let long = "Doe, Jane (represented by John Doe)"; + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("single", "50 700 190 718", "", long), + field("multi", "50 650 190 668", "/Ff 4096", long), + field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), + field("wide", "50 550 350 568", "", long), + field("short", "50 500 190 518", "/Ff 4096", "Ada"), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + match overflow("single") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); + } + other => panic!("single: {other:?}"), + } + assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); + assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); + assert_eq!(overflow("wide"), None); + assert_eq!(overflow("short"), None); +} From b98d8a28191b89f4b3a068543887af647f8ec953 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 15:47:54 +0200 Subject: [PATCH 02/28] M6: form_fill also warns about lines too tall or too wide for their box (#4018) Two cases still drew a value cut off without saying so. A single-line value at a fixed font size taller than the box (12 pt text in an 8 pt field) was checked only for width, so it fit sideways and was cut at the top and bottom. In a multiline field, a character wider than the box becomes a line of its own, which fits vertically but is cut at the sides. The single-line and comb branch now compares the line's ascent to descent against the clip box (a new `Overflow::High`), and the multiline branch reports the widest wrapped line that is still wider than the box. An empty value draws nothing, so it is never reported, however small the box. Fixes #4018 --- crates/forms/src/appearance.rs | 17 +++++++++++- crates/forms/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 651f8e320..9b52c5a33 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -460,6 +460,8 @@ pub enum Overflow { Wide { width: f64, room: f64, size: f64 }, /// `lines` wrapped lines, of which the box shows `room`, at `size` points. Tall { lines: usize, room: usize, size: f64 }, + /// One line `height` points high (ascent to descent), with `room` for it, at `size` points. + High { height: f64, room: f64, size: f64 }, } impl std::fmt::Display for Overflow { @@ -469,6 +471,9 @@ impl std::fmt::Display for Overflow { write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) } Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + Self::High { height, room, size } => { + write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room)) + } } } } @@ -589,6 +594,9 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } if room < lines.len() { overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) { + // A character wider than the box is a line of its own, still cut at the sides. + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } } else { if size == 0.0 { @@ -601,6 +609,11 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // The clip box is inside the border; a line taller than it is cut at the top and bottom. + let room_h = height - 2.0 * bw; + if 0.925 * size > room_h + 0.01 { + overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + } if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; @@ -610,7 +623,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { let tw = width_of(&text.replace(['\n', '\r'], " "), size); - if tw > inner_w + 0.01 { + if overflow.is_none() && tw > inner_w + 0.01 { overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } show(&mut body, x_for(&text, size), y, &text); @@ -643,6 +656,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); + // Nothing drawn is nothing cut, however small the box. + let overflow = overflow.filter(|_| !text.is_empty()); (Stream::flate(d, &content), unencodable, overflow) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 84ce87984..237b9d109 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3018,3 +3018,50 @@ fn a_value_wider_or_taller_than_its_box_is_reported() { assert_eq!(overflow("wide"), None); assert_eq!(overflow("short"), None); } + +/// #4018: a single line taller than its box is cut at the top and bottom, and a character wider +/// than a multiline box is a line of its own that is still cut at the sides; both are reported. +/// An empty value draws nothing, so nothing is cut however small the box. +#[test] +fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("low", "50 700 190 708", "", "Ada"), + field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), + field("narrow", "50 500 56 560", "/Ff 4096", "WW"), + field("roomy", "50 400 190 418", "", "Ada"), + field("empty", "50 350 190 358", "", ""), + field("empty-multi", "50 300 190 304", "/Ff 4096", ""), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + for name in ["low", "comb"] { + match overflow(name) { + Some(o @ appearance::Overflow::High { height, room, size }) => { + assert_eq!(size, 12.0); + assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); + assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); + } + other => panic!("{name}: {other:?}"), + } + } + match overflow("narrow") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); + } + other => panic!("narrow: {other:?}"), + } + assert_eq!(overflow("roomy"), None); + assert_eq!(overflow("empty"), None); + assert_eq!(overflow("empty-multi"), None); +} From f41abf921f28283f4ff19110c71663d7382b0775 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:31:13 +0800 Subject: [PATCH 03/28] M6: dotted field names make parent fields (#2696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2), so Prepare a form refused MASTER.first. Acrobat reads such a name as a full name: the field `first` under a parent field `MASTER`. - add_field: a dotted name makes or joins the parent fields it names. - set_props: a dotted name is the full name and moves the field there, copying what it inherited onto it first; a plain name still renames the field within its parent. Either way the new full name may not be taken by, or hold or sit inside, another field. - A field joining an existing parent doesn't take its Ff or Q. - Field Properties and the Prepare a form name tags show the full name instead of the last part. - Names are capped at 32 parts; empty parts are refused. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 4 +- crates/forms/src/author.rs | 242 ++++++++++++++++++++++++++------- crates/forms/src/tests.rs | 61 +++++++++ crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/prepare.rs | 6 +- parity/acrobat-features.toml | 4 +- 6 files changed, 263 insertions(+), 60 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4fff59c07..0830a7c49 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -349,7 +349,7 @@ pub fn tools() -> Vec { t( "form_add_field", "Prepare form: add a field", - "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", + "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1); a dotted name such as MASTER.first makes the field first under the parent field MASTER. Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", ) .with(schema( json!({ @@ -408,7 +408,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 544d0a7f8..ab4403fcc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -486,6 +486,163 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo Ok(()) } +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + let found = list.iter().filter_map(|o| o.as_ref()).find(|r| { + doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(*part) + }); + let next = match found { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// Copy onto `field` what it inherits from its parents, so it keeps them when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + /// The first free "N" name. fn free_name(existing: &[Field], base: &str) -> String { (1..=u64::MAX) @@ -527,20 +684,18 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel _ => name, }; let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => { - if n.contains('.') { - return invalid("field names can't contain a period"); - } - n.to_string() - } + Some(n) if !n.is_empty() => n.to_string(), _ => free_name(&existing, kind.base_name()), }; + let parts = name_parts(&name)?; let radio_group = match kind { NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), _ => None, }; - if radio_group.is_none() && existing.iter().any(|f| f.name == name) { - return invalid(format!("a field named {name:?} already exists")); + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); } let af = ensure_form(doc)?; let mut w = widget_dict(page_ref, rect); @@ -637,12 +792,11 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let mut g = Dict::new(); g.set(b"FT".to_vec(), Object::name("Btn")); g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"T".to_vec(), PdfString::text(&field_name)); g.set(b"V".to_vec(), Object::name("Off")); g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); g.set(b"Kids".to_vec(), Object::Array(Vec::new())); let g = doc.add(Object::Dict(g)); - add_to_fields(doc, af, g)?; + attach(doc, af, g, &parts)?; g } }; @@ -669,10 +823,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } - _ => { - doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; - add_to_fields(doc, af, widget)?; - } + _ => attach(doc, af, widget, &parts)?, } add_to_page(doc, page_ref, widget)?; redraw_field(doc, &field_name)?; @@ -919,18 +1070,31 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { } } } - // Out of the field tree: from its parent's /Kids, or from /Fields. - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } + detach(doc, f.obj)?; // Out of the calculation order too: a `/CO` entry would keep the field, its value and its // widgets reachable for a full save. drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4d5a14f57..3b8eae66a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3056,3 +3056,64 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field +/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` +/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; +/// and a name that would make one field both hold a value and hold fields is refused. +#[test] +fn dotted_field_names_are_made_with_parent_fields() { + let mut doc = fixture(); + let top = |doc: &Document| { + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); + doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() + }; + let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); + let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + let text = NewField::Text { multiline: false }; + let before = top(&doc); + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); + add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); + assert_eq!(top(&doc), before + 1, "one parent field at the top"); + let saved = reopen(&doc); + let all = fields(&saved); + let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); + assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); + let master = parent_of(&saved, a); + assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); + assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); + + // Renaming to a dotted name moves the field: zip joins the existing address parent. + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); + assert_eq!(top(&doc), before, "zip left the top"); + let all = fields(&doc); + let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); + assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); + assert_eq!(zip.max_len, Some(5), "its own settings move with it"); + + // What a moved field inherited stays; what a new field would inherit from a parent it + // joins doesn't apply to it. + let address = parent_of(&doc, city); + doc.update_dict(address, |d| { + d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); + }) + .unwrap(); + let city = field(&fields(&doc), "address.city").clone(); + assert!(city.read_only() && city.value == ["Springfield"]); + set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); + add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")).unwrap(); + let all = fields(&doc); + let moved = field(&all, "billing.city"); + assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); + assert!(!field(&all, "address.street").read_only(), "a new field doesn't take its parent's flags"); + + for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + } + let deep = vec!["p"; 33].join("."); + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbf..6f6bf41cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1360,8 +1360,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { && let Some(i) = app.active { // Keep the (possibly renamed) field selected. - let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - let name = format!("{prefix}{}", d.name.trim()); + // A dotted name is the full name; a plain one renames the field within its parent. + let typed = d.name.trim(); + let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); + let name = format!("{prefix}{typed}"); if let (Some(before), Some(after)) = (before, form(app)) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index f79ebf17f..bb94d52e5 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -560,7 +560,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. - let label = f.name.rsplit('.').next().unwrap_or(&f.name); + // The full name, as Acrobat shows it: MASTER.first, not first (#2696). + let label = f.name.as_str(); let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); let galley = painter.layout_no_wrap(label.to_string(), font, Color32::WHITE); let size = galley.size() + vec2(6.0, 2.0); @@ -887,7 +888,8 @@ impl FieldDraft { kind: f.kind, widget, tab: FieldTab::General, - name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), + // The full name: a dotted name moves the field under those parent fields (#2696). + name: f.name.clone(), tooltip: f.tooltip.clone().unwrap_or_default(), caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..c7f160665 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5069,8 +5069,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] -notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names", "crates/forms/src/tests.rs::dotted_field_names_are_made_with_parent_fields"] +notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order" From 224a64e3d071e3fb8d53c2ab22a9fb152f95a1ca Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:22:04 +0800 Subject: [PATCH 04/28] M6: a dotted name checks the place it goes: an empty parent's name, a parent's value (#2696) Review on #2957. The field list holds only terminal fields, so a parent left empty by a move wasn't seen and a second field of its name could be made beside it, by adding or by renaming. And a new field joining a parent that holds /V, /DV or /MaxLen inherited them, with no neutral value of its own to turn them off: such a parent now takes no field that doesn't hold its own. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 52 ++++++++++++++++++++++++++----- crates/forms/src/tests.rs | 63 +++++++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index ab4403fcc..edb6f58f6 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -530,22 +530,51 @@ fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) - Ok(()) } +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + /// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, /// and the parts before it name parent fields, reused where the tree has them and made where /// not (Acrobat makes `a.b` a field `b` under a field `a`). fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + // First check the place, changing nothing. The field list holds only terminal fields, so a + // parent left empty by a move isn't in it: a node of the same name there is found here. + let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // `field` is new or already out of the tree, so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } let mut parent: Option = None; let mut inherits = false; for part in parents { - let list = match parent { - Some(p) => list_of(doc, p, b"Kids"), - None => list_of(doc, af, b"Fields"), - }; - let found = list.iter().filter_map(|o| o.as_ref()).find(|r| { - doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(*part) - }); - let next = match found { + let next = match child_named(doc, af, parent, part) { Some(r) => { inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); r @@ -1093,6 +1122,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Mon, 19 Oct 2026 09:49:10 +0800 Subject: [PATCH 05/28] M6: a move the destination refuses leaves the field in place (#2696) Review on #2957: set_props took the field out of the tree before attach checked the destination, so a refused move (a parent holding /MaxLen, say) left it out of /Fields for a direct caller of the forms API. The read-only check is now its own function, run before the field is detached. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 24 +++++++++++++++++------- crates/forms/src/tests.rs | 10 ++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index edb6f58f6..cdbd24793 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -545,13 +545,11 @@ fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) - /// the field a neutral one of its own, as `/Ff` and `/Q` can. const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; -/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, -/// and the parts before it name parent fields, reused where the tree has them and made where -/// not (Acrobat makes `a.b` a field `b` under a field `a`). -fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - // First check the place, changing nothing. The field list holds only terminal fields, so a - // parent left empty by a move isn't in it: a node of the same name there is found here. let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); let mut at: Option = None; let mut whole_path = true; @@ -567,10 +565,20 @@ fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Resu } at = Some(r); } - // `field` is new or already out of the tree, so a node found is another. + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. if whole_path && child_named(doc, af, at, own).is_some() { return invalid(format!("a field named {:?} already exists", parts.join("."))); } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts)?; let mut parent: Option = None; let mut inherits = false; for part in parents { @@ -1107,6 +1115,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Mon, 19 Oct 2026 10:48:01 +0800 Subject: [PATCH 06/28] M6: a refused move doesn't copy inherited values onto the field first (#2696) Review on #2957: own_inherited wrote what the field inherits onto it before check_place could refuse the move, so a refused move still changed the field. What it inherits is now read first, counted as its own by the check, and written only once the move is allowed. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 26 ++++++++++++++++++-------- crates/forms/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index cdbd24793..33a237440 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -548,9 +548,13 @@ const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; /// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The /// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node /// of the same name there is found here. -fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } let mut at: Option = None; let mut whole_path = true; for part in parents { @@ -578,7 +582,7 @@ fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Res /// not (Acrobat makes `a.b` a field `b` under a field `a`). fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - check_place(doc, af, field, parts)?; + check_place(doc, af, field, parts, &Dict::new())?; let mut parent: Option = None; let mut inherits = false; for part in parents { @@ -621,8 +625,8 @@ fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Resu link(doc, af, parent, field) } -/// Copy onto `field` what it inherits from its parents, so it keeps them when it moves. -fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +fn inherited(doc: &Document, field: ObjRef) -> Dict { let own = doc.get(field).as_dict().cloned().unwrap_or_default(); let mut add = Dict::new(); let mut seen = std::collections::HashSet::new(); @@ -639,6 +643,11 @@ fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { } parent = d.reference(b"Parent"); } + add +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { if add.iter().next().is_some() { doc.update_dict(field, |d| { for (k, v) in add.iter() { @@ -1114,9 +1123,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Thu, 22 Oct 2026 10:47:53 +0800 Subject: [PATCH 07/28] M6: a dotted rename's calculations follow the field (#2696) main now renames a field in the calculations that read it. After rebasing, that runs for a dotted name too, which moves the field under new parents: the sum and the script that read `a` read `Group.a`, and still compute after a save and reopen. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index e76be2de8..211f5f7b6 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1321,6 +1321,32 @@ fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); } +/// A dotted name moves the field under new parent fields (#2696), and the calculations that read +/// it follow it to its full new name, as they follow a plain rename. +#[test] +fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = + FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); + assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); + set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(field(&fields(&doc), "sum").value, ["12"]); +} + /// The rename reaches a script stored as a stream, an indirect action and the only widget's /// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. #[test] From 5e4ca789b8cf07d1463c57f440005686fd3c9687 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:52:38 +0800 Subject: [PATCH 08/28] M6: note why a dotted move that fails half way never reaches the document; the parity note says the table tool still refuses periods (#2696) Review on #2957 (benandez, points 2 and 3). Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 4 ++++ parity/acrobat-features.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 33a237440..5370f2590 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1126,6 +1126,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Fri, 23 Oct 2026 08:39:57 +0800 Subject: [PATCH 09/28] M6: a typed field name loses its ends but keeps spaces beside periods (#2696) Review on #2957, point 1. Both entry points already trim the whole name before splitting it, and a space beside a period stays in its part, which matches black-box observation of Acrobat and ISO 32000-2 12.7.4.2 (only a period is barred from a partial name). The test pins that down; three mutants (no trim in add_field, no trim in set_props, untrimmed empty-part check) each fail it. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 211f5f7b6..ae532f8ff 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3228,3 +3228,35 @@ fn dotted_field_names_are_made_with_parent_fields() { assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } + +/// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat +/// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` +/// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 +/// allows: only a period is barred from a partial name. +#[test] +fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { + let mut doc = fixture(); + let own_name = |doc: &Document, full: &str| { + let all = fields(doc); + doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + let text = NewField::Text { multiline: false }; + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); + assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); + assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); + let saved = reopen(&doc); + assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); + assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); + assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); + assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); + let parent = |full: &str| { + let all = fields(&saved); + let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); + assert_eq!(parent("e. f").as_deref(), Some("e")); + // A part that is only spaces is still empty. + assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); +} From 941077715ae1c21425846381642bddb27a98518e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:38:58 +0800 Subject: [PATCH 10/28] fix(forms): write each /DR font as its own object, so Acrobat keeps typed text (#3441) A text field PdfCraft added showed its typed text in Acrobat only while the field had focus: as soon as the user clicked elsewhere the text vanished (it was still stored, and printed). PDF-XChange Editor and PDFgear were fine, and the same field made by MuPDF was fine in Acrobat. Bisected in Acrobat with one-change variants of the same file: only the variants whose /DR fonts are objects (or that have no /DR) keep the text. The appearance stream's own fonts, /P, the appearance layout and /DA 0 made no difference. PdfCraft wrote Helv, TiRo and Cour inline in /DR. ensure_form now adds them as objects, and turns fonts an earlier save wrote inline into objects too (same dictionaries). An indirect /DR or /Font stays the object it is. The XFA-to-AcroForm writer does the same. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 25 ++++- crates/forms/src/tests.rs | 219 +++++++++++++++++++++++++++++++++++++ crates/xfa/src/pdf.rs | 7 ++ crates/xfa/src/tests.rs | 44 +++++++- 4 files changed, 290 insertions(+), 5 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 544d0a7f8..dc36de729 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -136,8 +136,17 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { } let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - // Acrobat's resource names for the standard fonts its Appearance tab offers. + // Each /DR font is its own object: Acrobat, redrawing a text field after it is typed in, + // shows nothing when the font is a dictionary written inline in /DR (#3441). Fonts an + // earlier save wrote inline become objects too; what they say is unchanged. let mut added = false; + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + added = true; + } + // Acrobat's resource names for the standard fonts its Appearance tab offers. for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { continue; @@ -147,12 +156,20 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { f.set(b"Subtype".to_vec(), Object::name("Type1")); f.set(b"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); added = true; } if added { - dr.set(b"Font".to_vec(), Object::Dict(fonts)); - af.set(b"DR".to_vec(), Object::Dict(dr)); + // An indirect /DR or /Font stays the object it is. + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } } doc.set(r, Object::Dict(af)); Ok(r) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 05b14d933..601dc7014 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3073,3 +3073,222 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. +fn dr_fonts(doc: &Document) -> Dict { + let af = acroform(doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap()); + doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() +} + +/// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` +/// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. +#[test] +fn a_new_form_holds_its_fonts_as_objects() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); + } +} + +/// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is +/// added, saying the same; an indirect `/Font` dictionary stays the object it was. +#[test] +fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { + for indirect in [false, true] { + let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; + let (fonts_entry, extra) = + if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), + "null".into(), + ]; + objs.extend(extra); + let mut doc = document(&objs); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); + } + let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); + assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); + } +} + +/// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are +/// all already there (inline) still gets them as objects, though none is added. +#[test] +fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { + let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let all = format!( + "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" + ); + for indirect_dr in [false, true] { + let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + format!("<< /Font << {all} >> >>"), + ]); + let mut doc = doc; + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); + } + assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); + } +} + +/// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline +/// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that +/// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft +/// adds use WinAnsi, as Acrobat's do. +#[test] +fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); + assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); + assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + + // Every font an object already, /DR an object: adding a field leaves /DR as it was. + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), + "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an +/// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, +/// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or +/// an object. After a field is added, whatever the shape: +/// - no font is a dictionary written inline; +/// - a font that was an object is the same object, and one that was inline says the same as an +/// object; +/// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; +/// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; +/// - Helv, TiRo and Cour are there. +#[test] +fn every_shape_of_form_fonts_ends_as_objects() { + let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); + let mut cases = 0; + for dr_shape in ["missing", "inline", "object"] { + for font_shape in ["missing", "inline", "object", "number"] { + for helv in ["missing", "inline", "object", "dangling", "null", "number"] { + for mine in ["missing", "inline", "object"] { + if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { + continue; + } + if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + continue; + } + cases += 1; + let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); + let mut entries = String::new(); + match helv { + "inline" => entries += &format!("/Helv {} ", font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match mine { + "inline" => entries += &format!("/Mine {} ", font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match font_shape { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match dr_shape { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + af, + if dr_shape == "object" { dr_dict } else { "null".into() }, + if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, + font("Helvetica-Oblique"), + font("ZapfDingbats"), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = reopen(&doc); + let af = acroform(&doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); + let fonts = dr_fonts(&doc); + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); + } + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); + } + let base = + |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), + } + match mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), + _ => assert!(!fonts.contains(b"Mine"), "{what}"), + } + if dr_shape == "object" { + assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); + } + if font_shape == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); + } + } + } + } + } + assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); +} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..1fcb904e0 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -769,6 +769,13 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z)); } + // Each font its own object: Acrobat shows nothing in a text field it redraws after typing + // when the field's /DR font is a dictionary written inline (#3441). + let inline: Vec<(Vec, Dict)> = dr_fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + dr_fonts.set(name, Object::Ref(r)); + } dr.set(b"Font".to_vec(), Object::Dict(dr_fonts)); acro.set(b"DR".to_vec(), Object::Dict(dr)); if !acro.contains(b"DA") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..3ffb115aa 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_incremental}; use super::*; use crate::data::{ @@ -324,6 +324,12 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { let dr = doc.resolve(acro.get(b"DR").unwrap()); let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb")); + // Each one its own object: Acrobat shows nothing in a text field it redraws after typing + // when the /DR font is written inline (#3441). + for (name, font) in fonts.as_dict().unwrap().iter() { + let r = font.as_ref().unwrap_or_else(|| panic!("{} is an object", String::from_utf8_lossy(name))); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"Type")), Some(&b"Font"[..])); + } // A field carries its SOM path for data round trips. let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]); assert!(first.as_dict().unwrap().contains(SOM_KEY)); @@ -2832,3 +2838,39 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// #3441: a font the form already holds as an object stays that object when the XFA layout +/// writes its fields' fonts into `/DR`. +#[test] +fn rendering_keeps_the_forms_own_font_objects() { + let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Symbol")); + let mine = doc.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"Mine".to_vec(), Object::Ref(mine)); + let mut dr = Dict::new(); + dr.set(b"Font".to_vec(), Object::Dict(fonts)); + let root = doc.root().unwrap(); + match doc.get(root).as_dict().unwrap().get(b"AcroForm").cloned().unwrap() { + Object::Ref(af) => doc.update_dict(af, |d| d.set(b"DR".to_vec(), Object::Dict(dr))).unwrap(), + _ => doc + .update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get(b"AcroForm").cloned() { + let mut af = af; + af.set(b"DR".to_vec(), Object::Dict(dr)); + c.set(b"AcroForm".to_vec(), Object::Dict(af)); + } + }) + .unwrap(), + } + render_into(&mut doc).unwrap(); + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let dr = doc.resolve(acro.as_dict().unwrap().get(b"DR").unwrap()); + let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); + assert_eq!(fonts.as_dict().unwrap().reference(b"Mine"), Some(mine), "the form's own font object"); + assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); +} From fa146bbecd7b89514b08c510737ff3d321af9ce2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:09:43 +0800 Subject: [PATCH 11/28] fix(forms): filling a field also stores inline /DR fonts as objects (#3441) Review follow-up (CodeRabbit on #3488): a form an earlier PdfCraft made should work in Acrobat once it has been filled here too, not only once a field is added or edited. set_value now runs the same conversion (shared with ensure_form through inline_fonts_as_objects and store_form_fonts); it adds nothing, only changes where the existing fonts are stored. Not in a form a signature covers (a signature field with /V): there the file stays as the signer saw it apart from the value. PdfCraft's own change check counts a /DR change after a form-fill certification as form fill, but another validator may not. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 80 ++++++++++++++++++++++++++++---------- crates/forms/src/lib.rs | 1 + crates/forms/src/tests.rs | 49 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index dc36de729..4b421c31f 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -134,18 +134,8 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { if !af.contains(b"DA") { af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); } - let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - // Each /DR font is its own object: Acrobat, redrawing a text field after it is typed in, - // shows nothing when the font is a dictionary written inline in /DR (#3441). Fonts an - // earlier save wrote inline become objects too; what they say is unchanged. - let mut added = false; - let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); - for (name, font) in inline { - let r = doc.add(Object::Dict(font)); - fonts.set(name, Object::Ref(r)); - added = true; - } + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); // Acrobat's resource names for the standard fonts its Appearance tab offers. for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { @@ -161,20 +151,68 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { added = true; } if added { - // An indirect /DR or /Font stays the object it is. - match dr.get(b"Font").and_then(Object::as_ref) { - Some(fr) => doc.set(fr, Object::Dict(fonts)), - None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), - } - match af.get(b"DR").and_then(Object::as_ref) { - Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), - None => af.set(b"DR".to_vec(), Object::Dict(dr)), - } + store_form_fonts(doc, &mut af, dr, fonts); } doc.set(r, Object::Dict(af)); Ok(r) } +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + // A form dictionary that isn't an object of its own is left as it is. + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"AcroForm")) else { return Ok(()) }; + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + Ok(()) +} + /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 82c1c8e74..d00d46a69 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -932,6 +932,7 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, &all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 601dc7014..6773b5b0b 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3292,3 +3292,52 @@ fn every_shape_of_form_fonts_ends_as_objects() { } assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } + +/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form +/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A +/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects +/// already leaves its `/DR` object unwritten. +#[test] +fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { + let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let form = |signed: bool, dr: &str| { + let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; + let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + sig.into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Font << /Helv 9 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]) + }; + let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); + + // An empty signature field signs nothing: the form is converted as an unsigned one. + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.remove(b"V"); + }) + .unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); + + let mut doc = form(false, "8 0 R"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); +} From 0e883c44a00815a40e00dcf2532c63ceee952e21 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:30:44 +0800 Subject: [PATCH 12/28] M6: values typed into Password fields are written to no file (#3926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text field with the Password flag kept its typed value in /V, so Save, a full rewrite, Reduce File Size and the recovery snapshots all wrote it in plain text. ISO 32000-2 §12.7.5.3, Table 231 (note to the Password flag): PDF processors should never store the value in the PDF file. Each of those paths now writes a copy without the values; the open document keeps them in memory, and the masked appearance is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 34 ++++++++++++++++++++++--- crates/engine/src/optimizer.rs | 2 ++ crates/engine/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 24 ++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3..56a1a2409 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2905,6 +2905,19 @@ fn renderer_password(bytes: &[u8]) -> String { } } +/// The document as it may be written to a file (#3926): a copy without the values of text fields +/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 +/// (a note to the Password flag): "it is imperative that PDF processors never store the value of +/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { + if !pdfcraft_forms::has_password_values(cos) { + return Ok(std::borrow::Cow::Borrowed(cos)); + } + let mut copy = cos.clone(); + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + Ok(std::borrow::Cow::Owned(copy)) +} + /// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error /// message, so one bad file or edit can't take the app and its other documents down. It is a /// safety net for bugs, not a substitute for returning errors. @@ -4270,12 +4283,13 @@ impl Session { // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; guard(|| { if on_saved || rewrite { - write_incremental(&editor.cos, &opts) + write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; + base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } }) @@ -4292,7 +4306,8 @@ impl Session { } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this @@ -4916,12 +4931,23 @@ impl Session { let mut out = Vec::new(); for d in &mut self.docs { if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926): a recovery file is + // written from a copy without them, and none is written if that copy fails. + let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; d.snapshot_generation = d.generation; out.push(RecoverySnapshot { doc: d.id, name: d.name.clone(), path: d.path.clone(), - bytes: d.bytes.clone(), + bytes, encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), }); } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f9..444c428ca 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -83,6 +83,8 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; + // Like every file written, without the values of Password-flag fields (#3926). + pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afe6fd06c..c676510e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2025,6 +2025,52 @@ fn form_fixture() -> Vec { out } +/// Whether `needle` appears in `bytes`, or in any stream of the PDF they hold once decoded (so +/// compressed object streams are searched too), as literal or hex string bytes. +fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { + let hex: String = needle.bytes().map(|b| format!("{b:02x}")).collect(); + let forms = [needle.as_bytes().to_vec(), hex.clone().into_bytes(), hex.to_uppercase().into_bytes()]; + let found = |hay: &[u8]| forms.iter().any(|f| hay.windows(f.len()).any(|w| w == f.as_slice())); + if found(bytes) { + return true; + } + let doc = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + doc.object_numbers().into_iter().any(|num| match &*doc.get(pdfcraft_cos::ObjRef { num, generation: 0 }) { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|d| found(&d)), + _ => false, + }) +} + +/// #3926: a value typed into a text field with the Password flag stays in the open document but +/// is written to no file: Save, a full rewrite, Reduce File Size or a recovery snapshot. ISO +/// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store +/// the value in the PDF file. +#[test] +fn password_field_values_are_kept_in_memory_but_written_to_no_file() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // Offsets shift by the inserted text; the parser rebuilds the cross-reference table. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let field = s.get(id).unwrap().form.iter().find(|f| f.name == "name").cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes do hold it (the test can see it)"); + + let saved = s.save_bytes(id).unwrap(); + assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); + assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // Reopened, the field is empty, as in any reader. + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved, None).unwrap(); + assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 82c1c8e74..fe04f0fba 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1293,6 +1293,30 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } +/// Whether any text field with the Password flag holds a value (`/V`) in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + fields(doc).iter().any(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) +} + +/// Remove the value (`/V`) of every text field with the Password flag, for a copy of the +/// document about to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password +/// flag, note: "it is imperative that PDF processors never store the value of the text field in +/// the PDF file if this flag is set". The appearance keeps its masking characters. Returns how +/// many values were removed. +pub fn clear_password_values(doc: &mut Document) -> Result { + let holders: Vec = fields(doc) + .into_iter() + .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) + .map(|f| f.obj) + .collect(); + for r in &holders { + doc.update_dict(*r, |d| { + d.remove(b"V"); + })?; + } + Ok(holders.len()) +} + /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) From 509b5d5cf350682d1b4476dc79d9c75d94928db3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:54:56 +0800 Subject: [PATCH 13/28] M6: clear Password values inherited from an ancestor or held by a widget (#3926) Review follow-up: the helpers checked only the field dictionary's own /V. A value inherited through /Parent or carried by a separate widget was missed, so the document could be written as it was. Detection now uses the resolved value and the widgets; clearing removes /V from the field, its widgets and every ancestor only password fields inherit from, and gives the field an empty value of its own where a shared ancestor value has to stay. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/lib.rs | 88 ++++++++++++++++++++++++++++++++------- crates/forms/src/tests.rs | 34 +++++++++++++++ 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index fe04f0fba..e5ce6a00e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1293,28 +1293,84 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } -/// Whether any text field with the Password flag holds a value (`/V`) in `doc`. -pub fn has_password_values(doc: &Document) -> bool { - fields(doc).iter().any(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty, or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + fields(doc) + .into_iter() + .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (f.value.iter().any(|v| !v.is_empty()) || on_widget(f))) + .collect() } -/// Remove the value (`/V`) of every text field with the Password flag, for a copy of the -/// document about to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password -/// flag, note: "it is imperative that PDF processors never store the value of the text field in -/// the PDF file if this flag is set". The appearance keeps its masking characters. Returns how -/// many values were removed. +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 32 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". The appearance keeps its masking characters. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. pub fn clear_password_values(doc: &mut Document) -> Result { - let holders: Vec = fields(doc) - .into_iter() - .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) - .map(|f| f.obj) - .collect(); - for r in &holders { - doc.update_dict(*r, |d| { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let all = fields(doc); + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { d.remove(b"V"); })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = all.iter().filter(|g| field_chain(doc, g.obj).contains(a)).all(is_password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + if shared_value_kept { + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?; + } } - Ok(holders.len()) + Ok(targets.len()) } /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 05b14d933..d72c0da0e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3073,3 +3073,37 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a +/// separate widget. Clearing removes both; an ancestor's value shared with a non-password field +/// stays for that field, and the password field gets an empty value of its own instead. +#[test] +fn inherited_and_widget_password_values_are_cleared() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited + "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget + "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 + "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 + ]; + let mut doc = document(&objs); + assert!(has_password_values(&doc)); + let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); + assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 3); + assert!(!has_password_values(&doc)); + for n in ["acct.pin", "code", "grp.pw"] { + assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); + } + assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); + let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); + assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); + assert!(!has_v(&doc, 7), "a widget's value is removed"); + assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); +} From 048cf14d313ad52740546df28ef68a910e72ad71 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:24:08 +0800 Subject: [PATCH 14/28] M6: Extract, Split and Combine write no Password-field values either (#3926) CodeRabbit on #3943: Extract Pages copies the fields of the pages it takes, and write_new (Combine, Extract, Split, Create) wrote that copy without the writable() boundary saves use, so the new file kept a typed Password value. write_unchecked, which write_new and Split's size measuring both go through, now writes the sanitized copy. The other write_full/write_incremental calls in the engine build in-memory bytes (display, previews, the working copy, print rasters), not files. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 5 ++++- crates/engine/src/tests.rs | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56a1a2409..63dcce44b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4915,9 +4915,12 @@ impl Session { } /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Whether a changed document still needs a recovery snapshot. This does not consume it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c676510e8..df8039fc2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2042,7 +2042,8 @@ fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { } /// #3926: a value typed into a text field with the Password flag stays in the open document but -/// is written to no file: Save, a full rewrite, Reduce File Size or a recovery snapshot. ISO +/// is written to no file: Save, a full rewrite, Reduce File Size, a recovery snapshot, or a new +/// file made from its pages (Extract, Split, Combine). ISO /// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store /// the value in the PDF file. #[test] @@ -2065,6 +2066,15 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { let snaps = s.autosave_snapshots(); assert_eq!(snaps.len(), 1); assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // New files built from its pages too (#3943 review): Extract copies the fields of the pages it + // takes, and Split writes each part the same way. + let extracted = s.extract(id, &[0]).unwrap(); + assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages"); + assert!(appears_in_pdf(&extracted, "*******"), "the extracted page keeps the masked appearance"); + for (_, _, part) in s.split_by_size(id, 1).unwrap() { + assert!(!appears_in_pdf(&part, "hunter2"), "Split"); + } + assert!(!appears_in_pdf(&s.combine(&[("form.pdf".into(), s.get(id).unwrap().bytes.clone())]).unwrap(), "hunter2"), "Combine"); // Reopened, the field is empty, as in any reader. let mut t = Session::new(); let reopened = t.open("saved.pdf", None, saved, None).unwrap(); From 64c5fd8ef0e7f612849876df748eab8463345348 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:00:54 +0800 Subject: [PATCH 15/28] M6: signing, timestamping and DSS writes carry no Password-field values (#3926) CodeRabbit on #3943: sign, sign_with_timestamp, timestamp_document and dss::embed each write a new incremental file from editor.cos, outside the writable() boundary saves use, so a typed Password value was signed into the file. Each now writes from the writable() copy. The new test signs a form with a typed value and checks the signed file holds the masked appearance but not the value; the three TSA/DSS paths need a timestamp server or validation data, and use the same one-line change. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 13 +++++++++---- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 63dcce44b..dd5c4aafa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5104,7 +5104,9 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?)) + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) } /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by @@ -5121,7 +5123,8 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); // Checked like a save before anyone writes it (#2166). self.check_save(doc, &signed)?; Ok(signed) @@ -5133,7 +5136,8 @@ impl Session { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; let date = if date.is_empty() { self.signing_date() } else { date }; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); self.check_save(doc, &stamped)?; Ok(stamped) } @@ -5143,7 +5147,8 @@ impl Session { pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); self.check_save(doc, &embedded)?; Ok(embedded) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index df8039fc2..e8016fb8c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2081,6 +2081,23 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); } +/// #3926 / #3943 review: signing writes the signed file from the same copy a save does, so a +/// typed Password-field value is not signed into the file either. +#[test] +fn a_signed_file_carries_no_password_field_value() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it"); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file"); + assert!(appears_in_pdf(&signed, "*******"), "the masked appearance is signed in"); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other From adc8307c0bc885f1443ebb35b279fb352df07882 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:18:11 +0800 Subject: [PATCH 16/28] M6: a saved Password field returns to its default value and is redrawn from it (#3926) Review on #3943. A black-box test of Acrobat (synthetic form, Save As and Ctrl+S, every decoded object searched) showed how it saves a Password field: the typed value is nowhere in the file; a field with /DV is saved holding /DV, one without is left empty; the asterisks are drawn from the saved value (5 for a 5-letter default, not 13 for the typed password); /DV and /AA are kept. clear_password_values now does the same: it restores the (inherited) /DV as the value, or leaves the field empty, and redraws the widgets, so the appearance no longer shows how long the typed password was. A field already at its default no longer counts as holding a password, so such a form needs no cleaned copy. The parity note for form.fill-text says so and lists the two limits Acrobat shares (a script copying the password into an ordinary field, and an earlier revision kept by an incremental save). Mutants, each failing a test: no /DV restore, no redraw, the default ignored in password_fields, emptiness ignored (an empty string in an array). Dropping the empty-default filter was equivalent, so the filter is gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 8 +++++--- crates/forms/src/lib.rs | 22 ++++++++++++++-------- crates/forms/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e8016fb8c..1cd6f5f1c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2060,7 +2060,9 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { let saved = s.save_bytes(id).unwrap(); assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); - assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept"); + // As Acrobat saves it (#3943 review): no default value, so an empty field whose appearance + // no longer shows how long the typed password was. + assert!(!appears_in_pdf(&saved, "*******"), "the typed length is not kept in the appearance"); assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); let snaps = s.autosave_snapshots(); @@ -2070,7 +2072,7 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { // takes, and Split writes each part the same way. let extracted = s.extract(id, &[0]).unwrap(); assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages"); - assert!(appears_in_pdf(&extracted, "*******"), "the extracted page keeps the masked appearance"); + assert!(!appears_in_pdf(&extracted, "*******"), "nor on the extracted page"); for (_, _, part) in s.split_by_size(id, 1).unwrap() { assert!(!appears_in_pdf(&part, "hunter2"), "Split"); } @@ -2095,7 +2097,7 @@ fn a_signed_file_carries_no_password_field_value() { assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it"); let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file"); - assert!(appears_in_pdf(&signed, "*******"), "the masked appearance is signed in"); + assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); } /// A value its field's font can't draw is kept, and the edit that drew it reports it in diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index e5ce6a00e..08c9686a0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1294,13 +1294,12 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> } /// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or -/// their own) is not empty, or one of whose separate widgets carries a `/V`. +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. fn password_fields(doc: &Document) -> Vec { let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); - fields(doc) - .into_iter() - .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (f.value.iter().any(|v| !v.is_empty()) || on_widget(f))) - .collect() + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() } /// Whether any text field with the Password flag holds a value in `doc`. @@ -1326,7 +1325,10 @@ fn field_chain(doc: &Document, r: ObjRef) -> Vec { /// Remove the values of the text fields with the Password flag, for a copy of the document about /// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is /// imperative that PDF processors never store the value of the text field in the PDF file if -/// this flag is set". The appearance keeps its masking characters. +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. /// /// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from /// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it @@ -1366,9 +1368,13 @@ pub fn clear_password_values(doc: &mut Document) -> Result { shared_value_kept = true; } } - if shared_value_kept { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?; + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} } + redraw(doc, f, &restored, &mut NoScripts)?; } Ok(targets.len()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d72c0da0e..29593ddbd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3107,3 +3107,35 @@ fn inherited_and_widget_password_values_are_cleared() { assert!(!has_v(&doc, 7), "a widget's value is removed"); assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); } + +/// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a +/// default value gets it back, and its appearance shows as many masking characters as the +/// default has, not as the typed password had; one without is left empty and draws none. The +/// default value and the scripts stay, and a field already at its default needs no clearing. +#[test] +fn a_cleared_password_field_returns_to_its_default_value() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array) + ]; + let mut doc = document(&objs); + assert_eq!(clear_password_values(&mut doc).unwrap(), 2); + let all = fields(&doc); + let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3")); + assert_eq!(pw.value, ["DEF-1"], "back to its default"); + assert_eq!(pw.default, ["DEF-1"], "the default stays"); + assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay"); + assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty"); + let shown = ap(&doc, &pw.widgets[0]); + assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}"); + let shown3 = ap(&doc, &pw3.widgets[0]); + assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}"); + assert!(!has_password_values(&doc), "a field at its default holds no typed password"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..8c28a7663 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision." [[feature]] id = "form.fill-checkbox-radio" From 9ebc237ecfa8e6dea02c6a1bc2bf684b71df5610 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:23:06 +0800 Subject: [PATCH 17/28] M6: skip forms without a Password field quickly, and find ancestors once (#3926) Review on #3943, point 1. has_password_values ran a full fields() walk on every save and recovery snapshot, and clear_password_values walked every field's ancestors again for each ancestor of each target. has_password_flag now answers first, reading only /Ff, /Kids and /Parent: the field tree from /Fields (seen set, 32 levels) and, as fields() does, the widgets on the pages with the fields above them. A form without such a field returns before any Field is built. clear_password_values finds each field's ancestors once. The test puts a page-only widget, a flag inherited from an unlisted parent and a nested field whose widget is on no page each alone in a document. Mutants that drop the page scan, the parent chain, the tree walk or the descent into kids each fail it, as does treating every ancestor as exclusive. Dropping the seen set or the early-out changes only speed (equivalent). Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++-- crates/forms/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 08c9686a0..d892f9296 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1297,11 +1297,44 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> /// their own) is not empty and not their default value (`/DV`, which the form's author wrote and /// a save restores), or one of whose separate widgets carries a `/V`. fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() } +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep. +fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 32 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + /// Whether any text field with the Password flag holds a value in `doc`. pub fn has_password_values(doc: &Document) -> bool { !password_fields(doc).is_empty() @@ -1340,8 +1373,10 @@ pub fn clear_password_values(doc: &mut Document) -> Result { if targets.is_empty() { return Ok(0); } - let all = fields(doc); let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); for f in &targets { let chain = field_chain(doc, f.obj); for w in &f.widgets { @@ -1359,7 +1394,7 @@ pub fn clear_password_values(doc: &mut Document) -> Result { if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { continue; } - let exclusive = all.iter().filter(|g| field_chain(doc, g.obj).contains(a)).all(is_password); + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); if exclusive { doc.update_dict(*a, |d| { d.remove(b"V"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 29593ddbd..e152b0868 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3139,3 +3139,44 @@ fn a_cleared_password_field_returns_to_its_default_value() { assert!(!has_password_values(&doc), "a field at its default holds no typed password"); assert_eq!(clear_password_values(&mut doc).unwrap(), 0); } + +/// #3943 review: the quick check that skips forms without a Password field still sees one whose +/// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent +/// that is not listed either; and one deeper in the field tree whose widget is on no page. Each +/// is alone in its own document, so each path of the check is needed. +#[test] +fn a_password_field_missing_from_the_field_list_is_still_cleared() { + let form = |fields: &str, annots: &str, rest: &[&str]| { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), + ]; + objs.extend(rest.iter().map(|o| o.to_string())); + document(&objs) + }; + let page_only = + form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); + let inherited = form( + "", + "5 0 R", + &[ + "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", + "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", + ], + ); + let off_page = form( + "5 0 R", + "", + &[ + "<< /T (grp) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", + ], + ); + for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { + assert!(has_password_values(&doc), "{what}"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); + assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&doc)); + } +} From eed2afa16558bb753bc1cf0fe88d27817394f899 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:00:42 +0800 Subject: [PATCH 18/28] fix(forms): an inherited signature value and a direct /AcroForm are handled when filling (#3441) Review follow-ups on filling a field converting inline /DR fonts: - A signature field whose /V sits on its parent counted as unsigned, so filling another field rewrote /DR in a signed form. The check now follows the /Parent chain (bounded at 64, like the field walk). - A catalog holding /AcroForm as a direct dictionary was skipped; its inline fonts are now converted and written back into the catalog. Both tests failed before the change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/forms/src/author.rs | 48 +++++++++++++++++++++++++++++++------- crates/forms/src/tests.rs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 4b421c31f..8b2e53c3f 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -197,22 +197,54 @@ fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict /// Not in a form a signature covers: there the file stays as the signer saw it, apart from the /// value itself. pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { - let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))); + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); if signed { return Ok(()); } let Some(root) = doc.root() else { return Ok(()) }; - // A form dictionary that isn't an object of its own is left as it is. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"AcroForm")) else { return Ok(()) }; - let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.set(r, Object::Dict(af)); + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} } Ok(()) } +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 6773b5b0b..ea124be3a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3341,3 +3341,43 @@ fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); } + +/// #3441 (review follow-up): a signature value held by a parent field signs its kid too (`/V` is +/// inheritable, ISO 32000-2 Table 226), so the form still counts as signed and keeps its `/DR`. +#[test] +fn a_signature_value_on_a_parent_field_keeps_the_form_fonts_as_signed() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + "<< /FT /Sig /T (sig) /V 7 0 R /Kids [8 0 R] >>".into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Type /Annot /Subtype /Widget /T (s1) /Parent 6 0 R /Rect [0 0 0 0] /P 3 0 R >>".into(), + ]); + let kid = pdfcraft_cos::ObjRef::new(8, 0); + assert!(fields(&doc).iter().any(|f| f.obj == kid && f.kind == FieldKind::Signature), "the kid is the signature field"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "an inherited signature value keeps /DR as it was"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3441 (review follow-up): a catalog that holds `/AcroForm` as a direct dictionary gets its +/// inline `/DR` fonts converted too when a field is filled. +#[test] +fn filling_a_field_converts_inline_fonts_of_a_direct_form_dictionary() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + ]); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} From 9efc1727a1b7b474ef3d125418d941c83e03617b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:48:22 +0800 Subject: [PATCH 19/28] M6: default names skip empty parents; a full name under the same parents is a plain rename (#2696) Review follow-up on #2957: - free_name picked a default name (Text1) that a parent field left empty by a move still held at the top of /Fields, and attach then refused it for every new field of that kind. The own names (/T) of the top-level /Fields nodes are now skipped too. - the editor sends nested fields' full names, so renaming address.city to address.town took the move path: it copied the inherited keys onto the field and moved it to the end of the parent's /Kids. A full name whose parents are the field's current parents now renames only its own /T. Field tree per ISO 32000-2 12.7.4 (Arlington Field*.tsv: /Parent, /Kids, /T; FT, Ff, V, DV, DA, MaxLen inheritable). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/forms/src/author.rs | 26 ++++++++++++++++++++++---- crates/forms/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 5370f2590..56df0dc9d 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -689,14 +689,25 @@ fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { Ok(()) } -/// The first free "N" name. -fn free_name(existing: &[Field], base: &str) -> String { +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +fn free_name(existing: &[Field], held: &[String], base: &str) -> String { (1..=u64::MAX) .map(|n| format!("{base}{n}")) - .find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) .unwrap_or_else(|| base.to_string()) } +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} + fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); @@ -731,7 +742,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel }; let name = match name.map(str::trim) { Some(n) if !n.is_empty() => n.to_string(), - _ => free_name(&existing, kind.base_name()), + _ => free_name(&existing, &top_names(doc), kind.base_name()), }; let parts = name_parts(&name)?; let radio_group = match kind { @@ -1116,6 +1127,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, + _ => n, + }; if n.contains('.') { // A full name: the field moves under the parent fields it names (#2696). let parts = name_parts(n)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ae532f8ff..a5bc2b17a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3229,6 +3229,37 @@ fn dotted_field_names_are_made_with_parent_fields() { assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } +/// Review on #2957: a parent left empty by a move keeps its name from the next default name +/// (`Text1` after `Text1.x` moved away); and a full name that keeps the parents is a plain +/// rename, so the field stays in its place among its parent's /Kids and keeps inheriting. +#[test] +fn empty_parents_and_same_parent_renames() { + let mut doc = fixture(); + let text = NewField::Text { multiline: false }; + let auto = |doc: &mut Document, y: f64| add_field(doc, 0, [50.0, y, 200.0, y + 20.0], &text, None); + let mut copy = doc.clone(); + let first = auto(&mut copy, 100.0).unwrap(); + add_field(&mut doc, 0, [50.0, 140.0, 200.0, 160.0], &text, Some(&format!("{first}.x"))).unwrap(); + set_props(&mut doc, &format!("{first}.x"), &FieldProps { name: Some("other.x".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with(&first)), "{first} is an empty parent now"); + let next = auto(&mut doc, 100.0).expect("a default name the empty parent doesn't hold"); + assert_ne!(next, first); + + let all = fields(&doc); + let city = field(&all, "address.city").obj; + let address = doc.get(city).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())))).unwrap(); + let kids = |doc: &Document| doc.get(address).as_dict().and_then(|d| d.get(b"Kids").and_then(|o| o.as_array().cloned())).unwrap(); + let before = kids(&doc); + assert_eq!( + set_props(&mut doc, "address.city", &FieldProps { name: Some("address.town".into()), ..FieldProps::default() }).unwrap(), + "address.town" + ); + assert_eq!(kids(&doc), before, "same place among the kids"); + assert!(!doc.get(city).as_dict().unwrap().contains(b"V"), "still inherits its value rather than owning a copy"); + assert_eq!(field(&fields(&doc), "address.town").value, ["Springfield"]); +} + /// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat /// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` /// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 From 8b9d5017fe025967708b7a07ccdd641f4298f67a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 19:54:02 +0200 Subject: [PATCH 20/28] Check boxes and radio buttons draw their mark in the field's text colour (fixes #4261) --- crates/forms/src/appearance.rs | 22 ++++++++++-------- crates/forms/src/author.rs | 4 ++-- crates/forms/src/lib.rs | 2 +- crates/forms/src/tests.rs | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..488237374 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -600,8 +600,10 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). +/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the +/// Appearance tab's Text Color (#4261). /// The streams are added to `doc` as indirect objects (streams can't be direct objects). -pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); @@ -609,6 +611,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -617,7 +620,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); - let mark = check_mark(style, width / 2.0, height / 2.0, s); + let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color); let mut nd = Dict::new(); nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -627,12 +630,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name } /// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is -/// `s`, drawn in black. -fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { +/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`). +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String { use crate::author::CheckStyle; + let (fill, stroke) = (color, stroke_op(color)); // A filled polygon through `pts`. let polygon = |pts: &[(f64, f64)]| { - let mut c = String::from("0 g\n"); + let mut c = format!("{fill}\n"); for (i, (x, y)) in pts.iter().enumerate() { c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); } @@ -642,7 +646,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let line = n((s * 0.1).max(1.0)); match style { CheckStyle::Check => format!( - "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + "{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", n(cx - s * 0.28), n(cy), n(cx - s * 0.08), @@ -653,7 +657,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str CheckStyle::Cross => { let d = s * 0.25; format!( - "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + "{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", n(cx - d), n(cy - d), n(cx + d), @@ -666,7 +670,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } CheckStyle::Square => { let d = s * 0.22; - format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) } CheckStyle::Diamond => { let d = s * 0.3; @@ -688,7 +692,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let r = s * 0.25; let k = 0.5523 * r; format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + "{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", n(cx + r), n(cy), n(cx + r), diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index c3424050d..ad6495948 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -666,7 +666,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel caption: None, list: None, }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } _ => { @@ -709,7 +709,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result } FieldKind::CheckBox | FieldKind::Radio => { let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on) + appearance::check_box_states(doc, w, f.kind, &on, &f.da) } FieldKind::PushButton => { let (s, m) = button_appearance(doc, w, &f.da); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3..d6044d980 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1191,7 +1191,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); if !has_ap { let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..38076165a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1726,6 +1726,47 @@ fn check_box_styles_change_the_mark() { ); } +#[test] +fn check_mark_takes_the_fields_text_colour() { + // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button + // is drawn in it (Acrobat does the same), black when the /DA has no colour. + let mut doc = one_page(); + let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap(); + let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap(); + let on_ap = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let on = w.on_state.clone().unwrap(); + let n = doc + .get(w.obj) + .as_dict() + .unwrap() + .get(b"AP") + .unwrap() + .as_dict() + .unwrap() + .get(b"N") + .unwrap() + .as_dict() + .unwrap() + .reference(on.as_bytes()) + .unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); + let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; + for name in [&check, &radio] { + set_props(&mut doc, name, &blue).unwrap(); + let ap = on_ap(&reopen(&doc), name); + assert!(ap.contains("0 0 1 RG") || ap.contains("0 0 1 rg"), "{name}: the mark is blue: {ap}"); + assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); + } + // A style change redraws too, and keeps the colour. + set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); + assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); +} + #[test] fn ordering_tabs_manually() { let mut doc = one_page(); From 6062535e0eea73e2e9f8cf22661f7b706590e3bd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:06:20 +0200 Subject: [PATCH 21/28] Test: the check strokes and the circle fills in the field's colour (#4261) --- crates/forms/src/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 38076165a..b1c66f2cb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1759,7 +1759,9 @@ fn check_mark_takes_the_fields_text_colour() { for name in [&check, &radio] { set_props(&mut doc, name, &blue).unwrap(); let ap = on_ap(&reopen(&doc), name); - assert!(ap.contains("0 0 1 RG") || ap.contains("0 0 1 rg"), "{name}: the mark is blue: {ap}"); + // The check strokes, the radio's default circle fills. + let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; + assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); } // A style change redraws too, and keeps the colour. From 61fe4b56a71617f75eaa5652a8109b41884e39a7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:28:13 +0200 Subject: [PATCH 22/28] =?UTF-8?q?M6:=20Field=20Properties=20=E2=96=B8=20Ac?= =?UTF-8?q?tions:=20Edit=20opens=20an=20existing=20script=20(#4006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit on a Run a JavaScript action loads its script into the editor; Update replaces it on the same trigger, Discard leaves it. Saving the tab now keeps actions that did not change as they are, and an edited script keeps the actions chained after it (/Next). --- crates/forms/src/actions.rs | 21 +++++++++- crates/forms/src/tests.rs | 41 ++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 62 +++++++++++++++++++++++------- crates/ui-egui/tests/it/prepare.rs | 62 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 173 insertions(+), 17 deletions(-) diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index cdc89e563..25609b3cb 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -207,11 +207,13 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> } /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose -/// theirs. `Other` actions are left as they are. +/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with +/// their chain and the keys this module doesn't read); a script edited in place keeps its chain. pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); let pages = crate::page_refs(doc); + let mut scripts = crate::script_budget(); for w in &f.widgets { let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); @@ -221,10 +223,27 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi if matches!(new, Some(FieldAction::Other(_))) { continue; } + let old = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }; + let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts)); + if new.is_some() && new == old_action.as_ref() { + continue; + } + let next = match (new, &old_action) { + (Some(FieldAction::JavaScript(_)), Some(FieldAction::JavaScript(_))) => { + old.and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())) + } + _ => None, + }; let obj = match new { Some(a) => write(doc, a, &pages)?, None => None, }; + if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { + doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; + } match t.key() { None => { a_entry = obj; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..8b83c69b7 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1907,6 +1907,47 @@ fn field_actions_round_trip_on_every_trigger() { assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into()))); } +#[test] +fn editing_one_script_keeps_the_other_actions_and_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| { + let mut a = Dict::new(); + for (k, v) in entries { + a.set(k.to_vec(), v); + } + doc.add(Object::Dict(a)) + }; + let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]); + let up = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))], + ); + let blur = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))], + ); + let mut aa = Dict::new(); + aa.set(b"Bl".to_vec(), Object::Ref(blur)); + doc.update_dict(go, |d| { + d.set(b"A".to_vec(), Object::Ref(up)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + }) + .unwrap(); + let mut acts = field_actions(&doc, "go").unwrap(); + assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into()))); + acts[0].1 = FieldAction::JavaScript("app.alert(2);".into()); + set_field_actions(&mut doc, "go", &acts).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + // The action that did not change is the same object, with its keys this crate doesn't read. + let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone(); + assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur))); + // The edited script keeps the actions chained after it. + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next))); + assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); +} + #[test] fn push_buttons_read_hide_actions() { let mut doc = fixture(); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index f79ebf17f..1cba5d89f 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -852,6 +852,8 @@ pub struct FieldDraft { /// Actions tab: each trigger's action, and the one being added. pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>, pub new_action: ActionDraft, + /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. + pub editing: Option, original: Box>, } @@ -913,6 +915,7 @@ impl FieldDraft { others: Vec::new(), actions: Vec::new(), new_action: ActionDraft::default(), + editing: None, original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -1533,21 +1536,27 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { use pdfcraft_engine::FieldTrigger as T; - ui.label(egui::RichText::new(tl!("Add an Action")).font(theme::semibold(13.0))); + let editing = d.editing.is_some(); + ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0))); let a = &mut d.new_action; egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Select Trigger:")); - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { - for tr in T::ALL { - ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); - } + // Editing changes the script of one trigger's action: its trigger and kind stay. + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + for tr in T::ALL { + ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); + } + }); }); ui.end_row(); ui.label(tl!("Select Action:")); - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { - for (i, k) in ACTION_KINDS.iter().enumerate() { - ui.selectable_value(&mut a.kind, i, tl!(k)); - } + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + for (i, k) in ACTION_KINDS.iter().enumerate() { + ui.selectable_value(&mut a.kind, i, tl!(k)); + } + }); }); ui.end_row(); let hint = match a.kind { @@ -1568,7 +1577,12 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) ui.end_row(); }); let mut error = None; - if ui.button(tl!("Add")).clicked() { + let (mut commit, mut discard) = (false, false); + ui.horizontal(|ui| { + commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked(); + discard = editing && ui.button(tl!("Discard")).clicked(); + }); + if commit { match d.new_action.action() { Ok(act) => { let tr = d.new_action.trigger; @@ -1576,10 +1590,15 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) d.actions.push((tr, act)); d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); d.new_action.text.clear(); + d.editing = None; } Err(e) => error = Some(e), } } + if discard { + d.new_action = ActionDraft::default(); + d.editing = None; + } if let Some(e) = error { ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted)); } @@ -1590,19 +1609,34 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) if d.actions.is_empty() { ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); } - let mut remove = None; + let (mut remove, mut edit) = (None, None); for (i, (tr, act)) in d.actions.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(tr.label())).strong()); ui.label(act.describe()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.push_id(i, |ui| ui.button(tl!("Delete"))).inner.clicked() { - remove = Some(i); - } + ui.push_id(i, |ui| { + if ui.button(tl!("Delete")).clicked() { + remove = Some(i); + } + if let pdfcraft_engine::FieldAction::JavaScript(js) = act + && ui.add_enabled(d.editing != Some(*tr), egui::Button::new(tl!("Edit"))).clicked() + { + edit = Some((*tr, js.clone())); + } + }); }); }); } + if let Some((trigger, text)) = edit { + d.new_action = ActionDraft { trigger, kind: 0, text }; + d.editing = Some(trigger); + } if let Some(i) = remove { + if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) { + d.new_action = ActionDraft::default(); + d.editing = None; + } d.actions.remove(i); } }); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 106d2d539..84305fa7e 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -712,6 +712,68 @@ fn actions_tab_adds_and_removes_actions() { ); } +#[test] +fn actions_tab_edits_the_script_of_an_existing_action() { + use pdfcraft_engine::{FieldAction, FieldTrigger}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let script = "if (event.value > 10) {\n app.alert('Too many');\n}"; + let actions = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_actions("city") + }; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + // A script on Mouse Up and a link on Blur, saved with the field. + open(&mut h); + for (trigger, kind, text) in [(FieldTrigger::MouseUp, 0, script), (FieldTrigger::OnBlur, 1, "https://example.org")] { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger, kind, text: text.into() }; + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let saved = actions(&h); + // Edit shows the script exactly as stored; Discard leaves everything as it was. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.get_by_label("Edit Action"); + assert_eq!(h.state().field_props.as_ref().unwrap().new_action.text, script); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Discard").click(); + h.run_steps(2); + h.get_by_label("Add an Action"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(actions(&h), saved); + // Update replaces that script only: same trigger, the link untouched. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Update").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!( + actions(&h), + [ + (FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('changed');".into())), + (FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into())) + ] + ); +} + /// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by /// 120 points from (40, 240) as displayed. The table's window opens for it. fn draw_table(h: &mut Harness<'static, PdfCraftApp>) { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71c..06c38dfd2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4956,8 +4956,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_actions", "form_set_actions"] -evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions"] -notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept." +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain"] +notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions and the chain after an edited script are kept as they are. Edit for the other action types, and several actions per trigger, are missing." [[feature]] id = "form.props-format" From c5b0e6aa61526ff5a2b6a3c55a72c01f5604d4ad Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:02:47 +0200 Subject: [PATCH 23/28] M6: Actions tab: replacing a trigger's first action keeps its chain, and the row shows it (#4006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actions chained after a trigger's first one (/Next) are kept whatever replaces it, not only another script, and the row reads "…, then N more actions". Edit is disabled on every row while a script is open, so it can't be overwritten. --- crates/engine/src/js.rs | 5 ++ crates/forms/src/actions.rs | 64 +++++++++++++++++++++++--- crates/forms/src/lib.rs | 2 +- crates/forms/src/tests.rs | 33 +++++++++++++ crates/ui-egui/src/prepare.rs | 15 +++++- crates/ui-egui/tests/it/prepare.rs | 74 +++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 7 files changed, 183 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a59743dbb..aa43d3dba 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -200,6 +200,11 @@ impl crate::Document { self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default() } + /// Field Properties ▸ Actions: how many actions are chained after each trigger's first one. + pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> { + self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default() + } + /// Document JavaScripts (name, source), in name order. pub fn document_scripts(&self) -> Vec<(String, String)> { self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 25609b3cb..f58bd1078 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result usize { + let mut seen = std::collections::HashSet::new(); + if let Object::Ref(r) = action { + seen.insert(*r); + } + let mut stack: Vec = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect(); + let mut count = 0; + while let Some(o) = stack.pop() { + if count >= MAX_CHAIN { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + match &*doc.resolve(&o) { + Object::Array(items) => stack.extend(items.iter().cloned()), + Object::Dict(d) => { + count += 1; + stack.extend(d.get(b"Next").cloned()); + } + _ => {} + } + } + count +} + +/// For each trigger of field `name` (its first widget) whose action has others chained after it, +/// how many there are. +pub fn field_action_chains(doc: &Document, name: &str) -> Result, FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.first() else { return Ok(Vec::new()) }; + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + Ok(Trigger::ALL + .into_iter() + .filter_map(|t| { + let o = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }?; + let n = chained(doc, &o); + (n > 0).then_some((t, n)) + }) + .collect()) +} + fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result, FormError> { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Action")); @@ -208,7 +262,7 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose /// theirs. `Other` actions, and actions that read the same as before, are left as they are (with -/// their chain and the keys this module doesn't read); a script edited in place keeps its chain. +/// their chain and the keys this module doesn't read); a replaced action keeps its chain. pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); @@ -231,12 +285,8 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi if new.is_some() && new == old_action.as_ref() { continue; } - let next = match (new, &old_action) { - (Some(FieldAction::JavaScript(_)), Some(FieldAction::JavaScript(_))) => { - old.and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())) - } - _ => None, - }; + // The list shows a trigger's first action: replacing it keeps the ones chained after it. + let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); let obj = match new { Some(a) => write(doc, a, &pages)?, None => None, diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3..6977fbcb1 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -24,7 +24,7 @@ mod calc_order; pub mod detect; mod scripting; pub mod table; -pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 8b83c69b7..8a18be44f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1948,6 +1948,39 @@ fn editing_one_script_keeps_the_other_actions_and_the_chain() { assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); } +#[test] +fn replacing_a_chained_action_with_another_type_keeps_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let s = |t: &str| Object::String(PdfString::text(t)); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let named = doc.add(Object::Dict(named)); + // A chain as an array: a link, then a menu item that points back at the link (a loop). + let mut uri = Dict::new(); + uri.set(b"S".to_vec(), Object::name("URI")); + uri.set(b"URI".to_vec(), s("https://example.org")); + let uri = doc.add(Object::Dict(uri)); + doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap(); + let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]); + let mut up = Dict::new(); + up.set(b"S".to_vec(), Object::name("JavaScript")); + up.set(b"JS".to_vec(), s("app.alert(1);")); + up.set(b"Next".to_vec(), chain.clone()); + doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&chain)); + assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + // Deleting the trigger's action deletes its chain with it. + set_field_actions(&mut doc, "go", &[]).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), []); +} + #[test] fn push_buttons_read_hide_actions() { let mut doc = fixture(); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 1cba5d89f..acf4930ae 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -740,6 +740,7 @@ impl crate::PdfCraftApp { self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); d.others = others.clone(); d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); + d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default(); if let Some(o) = d.original.as_mut() { o.others = others; o.actions = d.actions.clone(); @@ -854,6 +855,9 @@ pub struct FieldDraft { pub new_action: ActionDraft, /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. pub editing: Option, + /// Actions tab: how many actions the document chains after each trigger's first one. They are + /// kept when that first action is replaced. + pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>, original: Box>, } @@ -916,6 +920,7 @@ impl FieldDraft { actions: Vec::new(), new_action: ActionDraft::default(), editing: None, + chained: Vec::new(), original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -1613,14 +1618,20 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) for (i, (tr, act)) in d.actions.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(tr.label())).strong()); - ui.label(act.describe()); + let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n); + if more == 0 { + ui.label(act.describe()); + } else { + let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions"); + ui.label(format!("{}, {then}", act.describe())); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.push_id(i, |ui| { if ui.button(tl!("Delete")).clicked() { remove = Some(i); } if let pdfcraft_engine::FieldAction::JavaScript(js) = act - && ui.add_enabled(d.editing != Some(*tr), egui::Button::new(tl!("Edit"))).clicked() + && ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked() { edit = Some((*tr, js.clone())); } diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 84305fa7e..13aa5b981 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -9,11 +9,15 @@ use pdfcraft_engine::field_table::TableColumn; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| { + harness_with("form.pdf", include_bytes!("../data/form.pdf").to_vec()) +} + +fn harness_with(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true); let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.open_bytes(name, None, bytes).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -774,6 +778,72 @@ fn actions_tab_edits_the_script_of_an_existing_action() { ); } +/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur +/// runs another script. +fn chained_action_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>", + "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>", + "<< /S /URI /URI (https://example.org) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { + use egui_kittest::kittest::NodeT; + use pdfcraft_engine::FieldTrigger; + let mut h = harness_with("chain.pdf", chained_action_fixture()); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("go", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + open(&mut h); + h.get_by_label("Run a JavaScript, then 1 more action"); + // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 3); + h.get_all_by_label("Edit").nth(1).unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); + // While one script is open, no other Edit can overwrite it. + assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + h.get_by_label("Discard").click(); + h.run_steps(2); + assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + // Replace the Mouse Up script with a link: the chained link stays after it. + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.actions.retain(|(t, _)| *t != FieldTrigger::MouseUp); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger: FieldTrigger::MouseUp, kind: 1, text: "https://example.com".into() }; + } + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + open(&mut h); + h.get_by_label("Open a web link: https://example.com, then 1 more action"); +} + /// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by /// 120 points from (40, 240) as displayed. The table's window opens for it. fn draw_table(h: &mut Harness<'static, PdfCraftApp>) { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 06c38dfd2..cf65548f9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4956,8 +4956,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_actions", "form_set_actions"] -evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain"] -notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions and the chain after an edited script are kept as they are. Edit for the other action types, and several actions per trigger, are missing." +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain", "crates/forms/src/tests.rs::replacing_a_chained_action_with_another_type_keeps_the_chain", "crates/ui-egui/tests/it/prepare.rs::actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced"] +notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions are kept as they are, and replacing a trigger's first action keeps the actions chained after it, which the row counts. Edit for the other action types, and editing the chained actions, are missing." [[feature]] id = "form.props-format" From 64bdea4e61b795dc3728adc40ce0984fd39158db Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:25:32 -0400 Subject: [PATCH 24/28] M6: fields that don't scroll stop taking text when full --- crates/engine/src/lib.rs | 11 ++++ crates/engine/src/tests.rs | 19 +++++++ crates/forms/src/appearance.rs | 94 ++++++++++++++++++++++++++++++++ crates/forms/src/tests.rs | 85 +++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/forms_ui.rs | 35 +++++++++++- crates/ui-egui/tests/it/forms.rs | 69 +++++++++++++++++++++++ crates/xfa/src/layout.rs | 3 + crates/xfa/src/model.rs | 3 + crates/xfa/src/parse.rs | 4 +- crates/xfa/src/pdf.rs | 6 ++ crates/xfa/src/tests.rs | 39 +++++++++++++ parity/acrobat-features.toml | 4 +- 13 files changed, 370 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b9..4db920976 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -787,6 +787,17 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.modify()) } + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + /// Filling in form fields is allowed (Table 22, bits 6 and 9). pub fn allows_form_filling(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..d52ae144d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4675,6 +4675,25 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with +/// DoNotScroll, and its text box says what fits it: what the editor takes no more than. +#[test] +fn an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let doc = s.get(id).unwrap(); + let field = |end: &str| doc.form.iter().find(|f| f.name.ends_with(end)).unwrap_or_else(|| panic!("no field {end}")); + assert!(field("code").has(field_flags::DO_NOT_SCROLL) && !field("note").has(field_flags::DO_NOT_SCROLL)); + let tb = doc.field_text_box(&field("code").name, 0).expect("a text box"); + assert!(tb.fits("A1B2") && !tb.fits(&"W".repeat(40)), "a 1-inch field at 10 pt"); + assert!(doc.field_text_box("no such field", 0).is_none() && doc.field_text_box(&field("code").name, 5).is_none()); +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..9d5fb43c7 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -72,6 +72,100 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } +/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking +/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`, +/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA` +/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside +/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a +/// multi-line field; the two must change together. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBox { + /// The `/DA` font from `/DR`, when it is one the appearance can draw with. + font: Option, + /// Width and height inside the border and padding. + inner_w: f64, + inner_h: f64, + /// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit. + size: Option, + multiline: bool, + password: bool, + /// A comb field: its cells, not its width, bound it (`/MaxLen`). + comb: bool, +} + +/// The [`TextBox`] widget `w` of text field `f` lays its value out in. +pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let pad = 2.0 + border_width(doc, &wd); + let multiline = f.has(flags::MULTILINE); + TextBox { + font: dr_font(doc, &da.font).map(|(_, enc)| enc), + inner_w: (place.layout_w - 2.0 * pad).max(1.0), + inner_h: place.layout_h - 2.0 * pad, + size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), + multiline, + password: f.has(flags::PASSWORD), + comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + } +} + +impl TextBox { + /// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the + /// height, as the appearance draws it. An automatic size shrinks before text stops fitting, + /// so such a field is full only when the text no longer fits at its smallest size (4 pt). A + /// comb field always fits (its `/MaxLen` bounds it). + pub fn fits(&self, text: &str) -> bool { + if self.comb || text.is_empty() { + return true; + } + let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() }; + // The font the appearance would choose for this text. + let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) }; + let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi); + let width_of = |t: &str, size: f64| font.width(t, size); + // The smallest an automatic size goes ([`auto_text_size_by`]). + let size = self.size.unwrap_or(4.0); + if self.multiline { + let lines = wrap_with(&shown, size, self.inner_w, width_of).len() as f64; + lines * size * 1.15 <= self.inner_h + 1e-6 + } else { + // A line break in a single-line value is drawn as a space. + let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + width_of(&line, size) <= self.inner_w + 1e-6 + } + } + + /// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when + /// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still + /// fits, keeping what it removed removed. Returns the text and where the caret goes (in + /// characters, after what was kept of the insertion). + pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) { + let (b, a): (Vec, Vec) = (before.chars().collect(), after.chars().collect()); + // The edit replaced b[p..b.len() - q] with a[p..a.len() - q]. + let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count(); + let most = b.len().min(a.len()).saturating_sub(p); + let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count(); + // q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes. + let end = a.len().saturating_sub(q); + let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default()); + let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::(); + if self.fits(after) { + return (after.to_string(), p + inserted.len()); + } + // The most of the insertion that fits: text only grows as it gets longer, so halve. + let (mut lo, mut hi) = (0, inserted.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + (with(lo), p + lo) + } +} + /// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { let Some(raw) = wd.get(b"MK") else { return 0 }; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..32eea6631 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,91 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// What fits a text field is measured as its appearance draws it: in the field's own font (its +/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its +/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, +/// and without a limit for a comb field. +#[test] +fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { + // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt + // padding (36 pt high for the multi-line one). + let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), + tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), + tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), + tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), + tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), + tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); + // Single line at 12 pt: as wide as the 96 pt inside, and no wider. + let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); + assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); + // An automatic size shrinks first: full only when 4 pt no longer fits. + let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); + assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); + // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. + assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); + let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); + assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); + // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). + let i_s: String = std::iter::repeat_n('i', 14).collect(); + assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); + // A password is drawn, so measured, as asterisks. + let thirty_i: String = std::iter::repeat_n('i', 30).collect(); + assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); + // A comb field's cells bound it, not its width. + assert!(tb("comb").fits(&"W".repeat(50))); +} + +/// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the +/// caret was, and always keeps a deletion. +#[test] +fn an_edit_to_a_full_field_keeps_what_fits() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // Typing in the middle of "Short": the X's go in until the field is full, the end stays. + let typed = format!("Sh{}ort", "X".repeat(40)); + let (kept, caret) = tb.fitted_edit("Short", &typed); + let xs = kept.chars().filter(|c| *c == 'X').count(); + assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); + assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); + assert_eq!(caret, 2 + xs, "the caret after the last X kept"); + // What fits is kept whole. + assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); + // A deletion is kept even from text that already overflows (set by a script, say). + let long = "W".repeat(20); + assert!(!tb.fits(&long)); + assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); + // Replacing a selection with too much keeps the removal and what fits of the replacement. + let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); + assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f..5e52d1d98 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4204,7 +4204,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) { + let text_box = crate::forms_ui::text_box(doc, view, &form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e092672..0de859a7d 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -516,8 +516,26 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, } } +/// How the focused text field lays text out, when it is one that doesn't scroll (`DoNotScroll`): +/// its editor then takes no more than fits ([`overlay`]). +pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[FormField]) -> Option { + let fx = view.forms.focus.as_ref()?; + let f = form.iter().find(|f| f.name == fx.name)?; + let no_scroll = f.kind == FormFieldKind::Text && f.has(field_flags::DO_NOT_SCROLL); + no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option { +/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor +/// then takes no more than fits. +pub(crate) fn overlay( + ctx: &egui::Context, + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + text_box: Option<&form_appearance::TextBox>, + today: (i64, u32, u32), +) -> Option { let pressed = view.forms.pressed; if !ctx.input(|i| i.pointer.primary_down()) { view.forms.pressed = false; @@ -566,6 +584,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). + let before = text_box.map(|_| fx.text.clone()); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te // Wraps (inside the 3 px side margins) at the width the size was fitted to: @@ -589,6 +609,19 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f laid_out = Some((out.galley, out.galley_pos)); out.response.response }); + if let (Some(tb), Some(before)) = (text_box, before) + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } if let Some(press) = fx.press.take() && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..fee012a11 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -105,6 +105,75 @@ fn a_blank_tooltip_shows_nothing() { assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx).is_infinite(), "no tooltip was shown"); } +/// A text field that doesn't scroll (`DoNotScroll`) takes no more than it shows, measured as its +/// appearance draws it: a long paste keeps what fits, typing into a full field adds nothing, and +/// a field without the flag takes it all. +#[test] +fn a_field_that_doesnt_scroll_stops_taking_text_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], font_size: Some(12.0), ..Default::default() }, + ); + let text_box = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let typing = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + let kept = typing(&h); + let tb = text_box(&h); + assert!(!kept.is_empty() && kept.len() < 80, "{} of 80 kept", kept.len()); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}W")), "as much as fits: {}", kept.len()); + h.event(egui::Event::Text("WWW".into())); + h.run_steps(2); + assert_eq!(typing(&h), kept, "a full field takes no more"); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "city"), [kept]); + // A field without the flag takes the whole paste. + click_field(&mut h, "name", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["W".repeat(80)]); +} + +/// A multi-line field that doesn't scroll, with an automatic size, takes lines until even the +/// smallest size it shrinks to can't show them all. +#[test] +fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { + flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], + multiline: Some(true), + font_size: Some(0.0), + ..Default::default() + }, + ); + let da = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().da.clone() + }; + assert!(da.contains(" 0 Tf"), "an automatic size: {da}"); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("line\n".repeat(60))); + h.run_steps(2); + let kept = h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + let tb = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let lines = kept.lines().count(); + assert!(lines > 1 && lines < 60, "{lines} of 60 lines kept"); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e..8efee1029 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -126,6 +126,8 @@ pub struct Widget { pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -1804,6 +1806,7 @@ impl Layouter<'_> { default: template_default, action, items, + no_scroll: f.no_scroll, }))) } } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7f..793950bfb 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -486,6 +486,9 @@ pub struct Field { pub check_size: Option, pub check_round: bool, pub multiline: bool, + /// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one + /// line, `vScrollPolicy="off"` for several): it takes no more text than it shows. + pub no_scroll: bool, pub caption: Option, pub value: Value, /// Check buttons: the on value then the off value; choice lists: the items as shown. diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a24..500465168 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -478,6 +478,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { } _ => ChoiceList::default(), }; + let multiline = ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")); let validate_el = child(n, "validate"); let validate_message = validate_el .and_then(|v| child(v, "message")) @@ -492,7 +493,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))), check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0), check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")), - multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")), + multiline, + no_scroll: ui_el.is_some_and(|e| e.attribute(if multiline { "vScrollPolicy" } else { "hScrollPolicy" }) == Some("off")), caption: parse_caption(child(n, "caption"), warnings), value: parse_value(value_el, warnings), items, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..c2d71ad91 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -244,6 +244,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -285,6 +287,10 @@ impl Emitter<'_> { if w.kind == WidgetKind::Password { ff |= 1 << 13; } + // DoNotScroll (ISO 32000-2 Table 228): the field takes no more than it shows. + if w.no_scroll { + ff |= 1 << 23; + } if let Some(m) = w.max_chars { d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64)); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..1d3f541e4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,45 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for +/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it +/// takes no more text than it shows; the other direction's policy, or the default, doesn't. +#[test] +fn fields_that_dont_scroll_are_written_with_do_not_scroll() { + let field = |name: &str, ui: &str| format!(r#"{ui}"#); + let xdp = format!( + r##" +"##, + field("line", r#""#), + field("lines", r#""#), + field("number", r#""#), + field("when", r#""#), + field("linesAcross", r#""#), + field("lineDown", r#""#), + field("free", r#""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); + let mut no_scroll = std::collections::BTreeMap::new(); + for r in fields.iter().filter_map(Object::as_ref) { + let f = doc.get(r); + let d = f.as_dict().unwrap(); + let name = d.get(b"T").and_then(Object::as_string).map(|t| t.to_text()).unwrap_or_default(); + no_scroll.insert(name, d.int(b"Ff").unwrap_or(0) & (1 << 23) != 0); + } + let expected: std::collections::BTreeMap = + [("line", true), ("lines", true), ("number", true), ("when", true), ("linesAcross", false), ("lineDown", false), ("free", false)] + .into_iter() + .map(|(n, v)| (n.to_string(), v)) + .collect(); + assert_eq!(no_scroll, expected); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..f2e6632e5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." [[feature]] id = "form.fill-checkbox-radio" From b12965ea8eb8f1c31b3d350ea21e279e4b1ff243 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:39:42 +0800 Subject: [PATCH 25/28] M6: the date picker has a Today button (#4274) The calendar under a date field had no way to pick today: from another month the only way back was the arrows. A Today button now sits under the days, centred under the grid. It picks today from whichever month the calendar shows, exactly as a click on today's cell would: written with the field's own format through format_date, then the same commit path, so the field's scripts treat it as they treat a clicked day, the pick is one undoable edit, and the field and its calendar close. A year the field's date can't hold offers no button. Hovering it shows the full date. One UI string in all 33 catalogues, marked AI-assisted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/ui-egui/src/forms_ui.rs | 24 +++++++++++++-- crates/ui-egui/src/i18n/ar.tsv | 3 ++ crates/ui-egui/src/i18n/bg.tsv | 3 ++ crates/ui-egui/src/i18n/ca.tsv | 3 ++ crates/ui-egui/src/i18n/ckb.tsv | 3 ++ crates/ui-egui/src/i18n/cs.tsv | 3 ++ crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/el.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fi.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/hi.tsv | 3 ++ crates/ui-egui/src/i18n/hu.tsv | 3 ++ crates/ui-egui/src/i18n/id.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/kk.tsv | 3 ++ crates/ui-egui/src/i18n/ko.tsv | 3 ++ crates/ui-egui/src/i18n/lo.tsv | 3 ++ crates/ui-egui/src/i18n/lt.tsv | 3 ++ crates/ui-egui/src/i18n/nb.tsv | 3 ++ crates/ui-egui/src/i18n/pl.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ro.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/sl.tsv | 3 ++ crates/ui-egui/src/i18n/sq.tsv | 3 ++ crates/ui-egui/src/i18n/te.tsv | 3 ++ crates/ui-egui/src/i18n/th.tsv | 3 ++ crates/ui-egui/src/i18n/tr.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/vi.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/tests/it/forms.rs | 47 +++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 36 files changed, 169 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e092672..a8924dc4c 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -290,8 +290,8 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -/// The date picker under a focused date field. Returns the picked date, formatted with the -/// field's own format. +/// The date picker under a focused date field: a month of days, and a Today button under them. +/// Returns the picked date, formatted with the field's own format. fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option { use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date}; let fx = view.forms.focus.as_mut()?; @@ -345,7 +345,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st (Some(a), Some(b)) => (b - a).num_days() as usize, _ => 31, }; - egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { + let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } @@ -381,6 +381,24 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st } } }); + // Today, whichever month the calendar shows: picked as a click on its day would + // pick it. A year the field's date can't hold offers no button. + if let Ok(ty) = i32::try_from(today.0) { + ui.add_space(4.0); + // As wide as the grid, so the button sits centred under it without widening the popup. + ui.allocate_ui_with_layout(vec2(grid.response.rect.width(), 0.0), egui::Layout::top_down(egui::Align::Center), |ui| { + let mut button = ui.small_button(tl!("Today")); + if let Some(month) = today.1.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) { + button = button.on_hover_text(crate::i18n::fmt( + tl!("{month} {day}, {y}"), + &[("month", tl_ctx!("calendar date", month)), ("day", &today.2.to_string()), ("y", &ty.to_string())], + )); + } + if button.clicked() { + picked = Some(format_date(DateTime { y: ty, m: today.1, d: today.2, hh: 0, mm: 0, ss: 0 }, fmt)); + } + }); + } }); }); if let Some(fx) = view.forms.focus.as_mut() { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..fa508aa77 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2791,3 +2791,6 @@ certificate Version الإصدار field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} a field named {name} already exists يوجد حقل باسم {name} بالفعل + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today اليوم diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..02f83a31a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2478,3 +2478,6 @@ animation effects Off Изключени field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} two cells would both be named {name} две клетки биха имали едно и също име {name} a field named {name} already exists вече съществува поле с име {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Днес diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..80d70568f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Avui diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..6e35d31bb 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2713,3 +2713,6 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ئەمڕۆ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..2511ccfe4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2822,3 +2822,6 @@ certificate Version Verze field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} two cells would both be named {name} dvě buňky by se jmenovaly {name} a field named {name} already exists pole s názvem {name} už existuje + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Dnes diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..6c2ed2a5c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2516,3 +2516,6 @@ animation effects Off Aus field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} two cells would both be named {name} zwei Zellen hätten beide den Namen {name} a field named {name} already exists ein Feld namens {name} ist bereits vorhanden + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Heute diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..e749b907d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2521,3 +2521,6 @@ animation effects Off Ανενεργά field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Σήμερα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..567ac72e2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2838,3 +2838,6 @@ theme Appearance Aspecto field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} two cells would both be named {name} dos celdas se llamarían {name} a field named {name} already exists ya existe un campo llamado {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hoy diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..d8e550f7d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,3 +2783,6 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..70e1c3ea0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2781,3 +2781,6 @@ certificate Version Version field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} two cells would both be named {name} deux cellules porteraient le nom {name} a field named {name} already exists un champ nommé {name} existe déjà + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Aujourd'hui diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..ef017fbbf 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2750,3 +2750,6 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..4a7c165f4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2514,3 +2514,6 @@ animation effects Off Ki field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} two cells would both be named {name} két cella neve is {name} lenne a field named {name} already exists már van {name} nevű mező + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Ma diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..53355ef79 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2751,3 +2751,6 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..491b56a03 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2747,3 +2747,6 @@ certificate SHA-256 digest Impronta SHA-256 field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} two cells would both be named {name} due celle si chiamerebbero entrambe {name} a field named {name} already exists esiste già un campo chiamato {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Oggi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..14d7aafcc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2515,3 +2515,6 @@ animation effects Off オフ field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} two cells would both be named {name} 2 つのセルが同じ名前 {name} になります a field named {name} already exists {name} という名前のフィールドは既にあります + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今日 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..2f9f3f526 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2513,3 +2513,6 @@ animation effects Off Өшірулі field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді a field named {name} already exists {name} атты өріс бар + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Бүгін diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..ac6b5ba8b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2750,3 +2750,6 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 오늘 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..975aff65b 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2429,3 +2429,6 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ມື້ນີ້ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..5be485d1f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2776,3 +2776,6 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Šiandien diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..b49187b2a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2694,3 +2694,6 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today I dag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..998cfb871 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2611,3 +2611,6 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Dzisiaj diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..25f009620 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2800,3 +2800,6 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hoje diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 2bd680f91..5a276ec75 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..5a8d76946 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2428,3 +2428,6 @@ animation effects Off Выкл field names can't contain a period: {name} имена полей не могут содержать точку: {name} two cells would both be named {name} две ячейки получили бы одно имя {name} a field named {name} already exists поле с именем {name} уже существует + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Сегодня diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..31e6ac632 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2520,3 +2520,6 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Danes diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..22de86f84 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2500,3 +2500,6 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..65da5a2c0 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2424,3 +2424,6 @@ animation effects Off ఆఫ్ field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ఈరోజు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..7efff8485 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2681,3 +2681,6 @@ key Space Space Page properties คุณสมบัติหน้า The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today วันนี้ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..1dce9cf4c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2511,3 +2511,6 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Bugün diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..706a5d6c2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2537,3 +2537,6 @@ animation effects Off Вимкнено field names can't contain a period: {name} імена полів не можуть містити крапку: {name} two cells would both be named {name} дві клітинки мали б однакове ім'я {name} a field named {name} already exists поле з іменем {name} уже існує + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Сьогодні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..d2ac75ebb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2780,3 +2780,6 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hôm nay diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..975fce279 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2479,3 +2479,6 @@ animation effects Off 关 field names can't contain a period: {name} 字段名称不能包含句点:{name} two cells would both be named {name} 两个单元格的名称都会是 {name} a field named {name} already exists 已存在名为 {name} 的字段 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今天 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..78bcd62ca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2465,3 +2465,6 @@ animation effects Off 關 field names can't contain a period: {name} 欄位名稱不能包含句點:{name} two cells would both be named {name} 兩個儲存格的名稱都會是 {name} a field named {name} already exists 已有名為 {name} 的欄位 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今天 diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..2a0401d2e 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -451,6 +451,53 @@ fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() { h.get_by_label("March 2025"); } +/// #4274: the calendar had no way to pick today; from another month the only way back was the +/// arrows. Today is picked in each field's own format, whichever month the calendar shows, and +/// is one edit as a clicked day is. +#[test] +fn the_calendar_picks_today_in_the_fields_format_from_any_month() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + // 2023-11-14 on the injected clock. + let s = h.state_mut(); + s.session = std::mem::take(&mut s.session).with_clock(|| 1_700_000_000); + assert_eq!(h.state().session.today(), (2023, 11, 14)); + for (name, rect) in [("due", [50.0, 40.0, 200.0, 60.0]), ("start", [50.0, 80.0, 200.0, 100.0])] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect, kind: pdfcraft_engine::NewField::Date, name: Some(name.into()) }); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "start".into(), + props: Box::new(pdfcraft_engine::FieldProps { + format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyy-mm-dd".into())), + ..Default::default() + }), + })); + h.run_steps(4); + // Two months on, and two months back: Today still picks the 14th of November. + for (name, arrow, shown, want) in [("due", "›", "January 2024", "11/14/2023"), ("start", "‹", "September 2023", "2023-11-14")] { + click_field(&mut h, name, 0); + h.get_by_label("November 2023"); + for _ in 0..2 { + h.get_by_label(arrow).click(); + h.run_steps(2); + } + h.get_by_label(shown); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/calendar-today-{name}.png")).unwrap(); + } + h.get_by_label("Today").click(); + h.run_steps(4); + assert_eq!(value(&h, name), [want], "today in {name}'s format"); + assert!(h.state().views[0].forms.focus.is_none(), "picking closes the field, as a clicked day does"); + assert!(h.query_by_label("Today").is_none(), "and its calendar"); + } + // One edit each, as a clicked day is. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "start"), Vec::::new()); + assert_eq!(value(&h, "due"), ["11/14/2023"]); +} + #[test] fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { use egui_kittest::kittest::Queryable; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..0d31f5288 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4644,8 +4644,8 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] -notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise" +evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open", "crates/ui-egui/tests/it/forms.rs::the_calendar_picks_today_in_the_fields_format_from_any_month"] +notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise; a Today button under the days picks today from whichever month it shows, as a click on its day would (#4274)" [[feature]] id = "form.auto-complete" From 6d7e887efe70ece1464f70ee16969a872418b30b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:04:52 -0400 Subject: [PATCH 26/28] M6: a paste into a full field keeps the longest part that fits, across a change of font --- crates/forms/src/appearance.rs | 40 +++++++++++++++++++++++++++++----- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 9d5fb43c7..e283c5ee3 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -156,13 +156,41 @@ impl TextBox { if self.fits(after) { return (after.to_string(), p + inserted.len()); } - // The most of the insertion that fits: text only grows as it gets longer, so halve. - let (mut lo, mut hi) = (0, inserted.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + // The longest of the insertion's first `from..=to` characters that fits, halving: in one + // font a longer text is never narrower. `None` when even `from` doesn't fit. + let longest = |from: usize, to: usize| -> Option { + if from > to || !self.fits(&with(from)) { + return None; + } + let (mut lo, mut hi) = (from, to); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(lo) + }; + // The font can change once, from the form's own to Helvetica, at the first inserted + // character the form's font can't show: each side of that point is searched on its own. + let n = inserted.len(); + let kept = match self.font_switch(prefix, inserted, suffix) { + Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(), + None => longest(0, n), } - (with(lo), p + lo) + .unwrap_or(0); + (with(kept), p + kept) + } + + /// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted` + /// (between `prefix` and `suffix`) first includes a character the form's own font can't show, + /// when everything around it can be shown in that font. `None` when the font never changes. + fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option { + let enc = self.font.as_ref().filter(|_| !self.password)?; + // As `fits` decides: a multi-line value is checked line by line, its breaks aside. + let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string()); + if !prefix.iter().chain(suffix).all(shows) { + return None; + } + inserted.iter().position(|c| !shows(c)).map(|i| i + 1) } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 32eea6631..317f23c78 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -472,6 +472,34 @@ fn an_edit_to_a_full_field_keeps_what_fits() { assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); } +/// When the field's own font can't show a character of a paste, the text from there on is drawn +/// in Helvetica, which can be narrower: the longest part that fits may come after that point, and +/// is still the one kept. +#[test] +fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { + // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings + // Helvetica in. + assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); + let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); + let (kept, caret) = tb.fitted_edit("", &pasted); + assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); + assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); + assert_eq!(caret, 11); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] From 5233e12281222076603ba7ef12b4ef971e0a53db Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:50:59 +0800 Subject: [PATCH 27/28] M6: check boxes, radio buttons and push buttons without an appearance are drawn, and comb fields show their cells (ISO 32000-2 Table 192, 12.7.5) A form that leaves its appearances to the viewer had its text and choice fields drawn for display (#3475), but not its buttons. Now, also for display only, without changing the file: - Check boxes and radio buttons with no normal appearance for their state show their /MK background and border (Table 192), and when on, the /MK /CA caption as a standard-14 ZapfDingbats character (12.7.5.2.3), 4 or l by default, in the /DA colour, auto-sized for 0 Tf. - Push buttons with none show their /MK background, their /BS border (beveled per Table 168) and their centred caption. - Comb fields with a border colour draw the MaxLen - 1 lines between their positions (Table 228), up to 500 cells. This also applies to the appearances PdfCraft saves for comb fields. - A combo box drawn for display shows a drop-down button on screen, with its text kept clear of it. Saved appearances and Print as Image leave it out (with_printed_field_appearances, printed_display_bytes). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/lib.rs | 10 ++ crates/engine/src/print_raster.rs | 4 +- crates/engine/src/tests.rs | 61 +++++++++++ crates/forms/README.md | 11 +- crates/forms/src/appearance.rs | 168 +++++++++++++++++++++++++++++- crates/forms/src/author.rs | 2 +- crates/forms/src/lib.rs | 81 ++++++++++++-- crates/forms/src/tests.rs | 156 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +-- 9 files changed, 479 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 86fc9dce4..a4987a17f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -934,6 +934,16 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc Option>> { + let e = doc.editor.as_ref()?; + let comments = pdfcraft_annot::with_missing_appearances(&e.cos); + let fields = pdfcraft_forms::with_printed_field_appearances(comments.as_ref().unwrap_or(&e.cos))?; + write_incremental(&fields, &SaveOptions::default()).ok().map(Arc::new) +} + /// `comments` (the display copy with comment appearances, if it needed any; else the document /// itself) with its form fields drawn from their values where the file leaves that to the viewer. fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 4ed589586..f54d64eed 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -23,7 +23,9 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont // The copy below holds only what prints, so the viewer's own comment hiding plays no part. config.hide_comments = false; let printed = guard(|| -> Result, String> { - let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + // The display copy, less what it shows only on screen. + let shown = crate::printed_display_bytes(doc).unwrap_or_else(|| src.bytes.clone()); + let mut cos = pdfcraft_cos::Document::open_with_password(shown, config.password.as_deref()).map_err(|e| e.to_string())?; pdfcraft_print::keep_printed(&mut cos, content); write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) })??; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..01789a3d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7438,3 +7438,64 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert!(pages[0].images.is_empty(), "the picture is past the budget"); assert_eq!(truncated, [0]); } + +/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb +/// field and a combo box the file leaves to the viewer. The page as displayed shows the check +/// box's caption symbol in its text colour, the button's grey face, the comb's cells in the border +/// colour and the combo box's drop-down button; printing leaves that button out, and saving +/// writes the file as it was. +#[test] +fn buttons_and_combs_left_to_the_viewer_are_displayed() { + let pdf = b"%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R 9 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\ + 9 0 obj << /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (m) /Opt [(Apple) (Banana)] /V (Apple) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [20 20 220 50] /F 4 /P 3 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Pixels of the page (drawn at scale 1, 400 x 200) in user-space box x0..x1, y0..y1 that `keep`. + let count = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32), keep: &dyn Fn(&[u8; 4]) -> bool| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(keep)) + .count() + }; + let red = |p: &[u8; 4]| p[0] > 200 && p[1] < 80 && p[2] < 80; + let grey = |p: &[u8; 4]| (180..=230).contains(&p[0]) && p[0] == p[1] && p[1] == p[2]; + // Bluish: a 1 pt line on a pixel edge is half blue on each side. + let blue = |p: &[u8; 4]| p[2] > 200 && p[0] < 180 && p[1] < 180 && p[0] + 40 < p[2]; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + assert!(count(&shown, (24, 154, 56, 186), &red) > 60, "the check box's red cross"); + assert!(count(&shown, (104, 154, 216, 186), &grey) > 1000, "the push button's grey face"); + // The comb's three inner lines at x = 70, 120 and 170, between its cells. + for x in [70, 120, 170] { + assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}"); + } + assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); + // The combo box's drop-down button: a 28 pt square inside the right edge. + let button = (193, 22, 218, 48); + assert!(count(&shown, button, &grey) > 300, "the drop-down button on screen"); + // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + assert_eq!(count(&printed, button, &grey), 0, "not on paper"); + assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); + // The file is saved as it was: no widget has an appearance. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for f in pdfcraft_forms::fields(&saved) { + assert!(saved.get(f.widgets[0].obj).as_dict().is_some_and(|d| !d.contains(b"AP")), "{} has no appearance", f.name); + } +} diff --git a/crates/forms/README.md b/crates/forms/README.md index ac48d6c58..88a086794 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -39,8 +39,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table - Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the field tree; the form's `/DA` and `/Q` are the last fallback. -- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, - multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. +- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb + (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` + background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat @@ -55,7 +56,11 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawn from their values, as typing them would draw them; the engine shows and prints that copy and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in - one field redraws that field, not the form. + one field redraws that field, not the form. Check boxes and radio buttons with no appearance for + their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats + symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. On + screen a drawn combo box also shows a drop-down button; `with_printed_field_appearances` leaves it + out for printing. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..13464bbd3 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -351,7 +351,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { fn border_width(doc: &Document, wd: &Dict) -> f64 { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); - let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let bw = line_width(doc, wd); if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 { return 0.0; } @@ -359,6 +359,56 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 { if style == b"B" || style == b"I" { 2.0 * bw } else { bw } } +/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it. +fn line_width(doc: &Document, wd: &Dict) -> f64 { + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + // `f64::max` turns NaN into 0, as it does a negative width. + bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Comb fields stop drawing their cells past this many; they would be a solid block anyway. +const MAX_COMB_DIVIDERS: usize = 500; + +/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228, +/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and +/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells. +fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let inset = border_width(doc, wd); + let lw = line_width(doc, wd); + let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) { + return String::new(); + } + let cell = w / cells as f64; + let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw)); + for i in 1..cells { + let x = cell * i as f64; + c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset))); + } + c.push_str("S\n"); + c +} + +/// A combo box's drop-down button for the screen: a `size`-wide light grey face at the right +/// edge inside the `bw` border of a `w` × `h` widget, parted from the text by a line in the +/// border's colour, with a downward triangle. +fn drop_down_button(doc: &Document, wd: &Dict, w: f64, h: f64, bw: f64, size: f64) -> String { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let (x0, y0, x1, y1) = (w - bw - size, bw, w - bw, h - bw); + let mut c = format!("0.85 g\n{} {} {} {} re f\n", n(x0), n(y0), n(size), n(y1 - y0)); + let lw = line_width(doc, wd); + if let Some(bc) = color_array(doc, mk.as_ref(), b"BC") + && lw > 0.0 + && lw.is_finite() + { + c.push_str(&format!("{}\n{} w\n{} {} m {} {} l S\n", stroke_op(&bc), n(lw), n(x0), n(y0), n(x0), n(y1))); + } + let (cx, cy, d) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, size * 0.22); + c.push_str(&format!("0 g\n{} {} m {} {} l {} {} l h f\n", n(cx - d), n(cy + d / 2.0), n(cx + d), n(cy + d / 2.0), n(cx), n(cy - d / 2.0))); + c +} + /// Where a list box draws its options: one row per option from the top, starting with option /// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share /// it, so a click picks the option drawn under the pointer (#1524). @@ -449,6 +499,19 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + field_look(doc, f, w, values, format, false) +} + +/// [`field_appearance_drawn`] as the page view shows a field the file leaves to the viewer: a +/// combo box also gets its drop-down button at the right edge, which the text keeps clear of. +/// The button is something to click on screen, not part of the field's printed look, so it is +/// drawn only here and never into an appearance PdfCraft saves or prints. +pub(crate) fn field_appearance_on_screen(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + field_look(doc, f, w, values, format, true) +} + +/// The appearance of a text or choice field, with a combo box's drop-down button when `screen`. +fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool, screen: bool) -> (Stream, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -489,7 +552,15 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S let width_of = |text: &str, size: f64| enc.width(text, size); let (mut c, bw) = frame(doc, &wd, width, height); let pad = 2.0 + bw; - let inner_w = (width - 2.0 * pad).max(1.0); + // A combo box's drop-down button on screen: a square as tall as the room inside the border, + // never wider than that room. + let button = if screen && f.kind == FieldKind::Combo { (height - 2.0 * bw).min(width - 2.0 * bw).max(0.0) } else { 0.0 }; + if button > 0.0 { + c.push_str(&drop_down_button(doc, &wd, width, height, bw, button)); + } + // The text and its clip keep to the room left of the button. + let text_w = width - button; + let inner_w = (text_w - 2.0 * pad).max(1.0); let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); let mut body: Vec = Vec::new(); // The first character drawn that the font can't hold (drawn as `?`), for the caller to report. @@ -508,7 +579,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S let tw = width_of(text, size); match q { 1 => pad + (inner_w - tw) / 2.0, - 2 => width - pad - tw, + 2 => text_w - pad - tw, _ => pad, } }; @@ -560,6 +631,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; + c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); @@ -577,7 +649,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S "/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n", n(bw), n(bw), - n(width - 2.0 * bw), + n(text_w - 2.0 * bw), n(height - 2.0 * bw), font_name, n(size), @@ -720,6 +792,94 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } } +/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when +/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and +/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes +/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is +/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most +/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. +pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, bw) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let mut res = Dict::new(); + if on { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let dingbats = pdfcraft_fonts::Std14::ZapfDingbats; + // The caption's first character as a ZapfDingbats code, when the font has a glyph there. + let code = caption + .and_then(|s| s.chars().next()) + .and_then(|ch| u8::try_from(u32::from(ch)).ok()) + .filter(|&code| dingbats.width(code) > 0.0) + .unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' }); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + // The glyph's advance and (about) its height, in ems. + let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75); + let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0); + // An automatic size leaves a margin of a tenth of the room on each side. + let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) }; + let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size); + content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes()); + content.extend(literal(&[code])); + content.extend_from_slice(b" Tj\nET\nQ\n"); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); + let mut fonts = Dict::new(); + fonts.set(b"ZaDb".to_vec(), Object::Dict(font)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + +/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK` +/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I` +/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour. +/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included. +pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + if mk.contains(b"I") { + return crate::author::button_appearance(doc, w, &f.da).0; + } + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, _) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default(); + let mut fonts = Dict::new(); + if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) { + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + // The automatic size PdfCraft's own buttons use. + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 95cd9d9a8..5bfd00fa3 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -775,7 +775,7 @@ fn form_stream(place: crate::appearance::Placement, content: Vec, resources: /// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). /// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to /// mark and report. -fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { let (mut c, place) = frame_only(doc, w); let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3..2a8761856 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1298,11 +1298,15 @@ pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) } -/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying -/// the document (`None` when no widget needs it): every such widget when the form sets -/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer), -/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value -/// would draw it (#3470). `doc` is not changed: saving writes the file as it was. +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192); the page view +/// also shows a drawn combo box's drop-down button. `doc` is not changed: saving writes the file +/// as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1310,20 +1314,60 @@ pub fn with_field_appearances(doc: &Document) -> Option { /// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are /// the same objects as then (see [`FieldAppearanceCache`]). pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { + fields_shown(doc, cache, true) +} + +/// [`with_field_appearances`] for printing: the same, without the drop-down buttons of combo +/// boxes, which are for clicking on screen and not part of a field's printed look. +pub fn with_printed_field_appearances(doc: &Document) -> Option { + fields_shown(doc, &mut FieldAppearanceCache::default(), false) +} + +/// [`with_field_appearances_cached`]; `screen` adds the on-screen parts (a combo box's button). +/// One cache serves one of the two: the engine keeps one for the screen and prints uncached. +fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool) -> Option { let all = need_appearances(doc); let mut copy: Option = None; - for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) { + for f in fields(doc) { for w in &f.widgets { let widget = doc.get(w.obj); - let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if drawn && !all { + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else a check box's value, else Off. An Off state may go without one (it shows + // nothing); an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { continue; } let inputs = appearance_inputs(doc, w); let stream = match cache.get(w.obj, &inputs) { Some(stream) => stream, None => { - let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ if screen => appearance::field_appearance_on_screen(doc, &f, w, &f.value, true).0, + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; cache.insert(w.obj, inputs, &stream); stream } @@ -1331,10 +1375,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance let target = copy.get_or_insert_with(|| doc.clone()); let ap = target.add(Object::Stream(stream)); let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } let apd = merged_appearance(target, w.obj, &fresh); // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok(); + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); } } copy diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..27b64f5a1 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3097,3 +3097,159 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field +/// and a combo box, for the display copy. +fn left_buttons() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6 + widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"), + widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"), + widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"), + widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10 + widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"), + widget( + "o", + "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]", + ), + widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"), + widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"), + widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15 + widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"), + widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"), + widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"), + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19 + ]) +} + +/// The appearance stream of widget `w` in `state` (a check box's `/AP /N /`). +fn state_ap(doc: &Document, w: &Widget, state: &str) -> String { + let wd = doc.get(w.obj).as_dict().cloned().unwrap(); + let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap(); + let r = n.reference(state.as_bytes()).expect("the state's appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`. +fn saved_look(doc: &Document, name: &str) -> String { + let all = fields(doc); + let f = field(&all, name); + String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC` +/// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour, +/// sized to the box when the `/DA` size is 0. +#[test] +fn check_boxes_without_an_appearance_show_their_caption_symbol() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] { + let shown = state_ap(©, &field(&all, name).widgets[0], "Yes"); + assert!(shown.contains(symbol), "{name}: {shown}"); + assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}"); + assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}"); + } + // A /DA size is kept; the automatic one fills the box inside the border. + let l = state_ap(©, &field(&all, "l").widgets[0], "Yes"); + assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}"); + let i = state_ap(©, &field(&all, "i").widgets[0], "Yes"); + let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + assert!((18.0..=26.0).contains(&size), "{size}"); + let w = copy.get(field(&all, "i").widgets[0].obj); + let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap()); + let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); + let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() }; + let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb")); + assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program"); +} + +/// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box +/// whose file has only an Off look gets an on look, and one with its own look keeps it. +#[test] +fn button_states_without_an_appearance() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "{off}"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "A").contains("(l) Tj")); + assert!(state_ap(©, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj")); + let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©), own(&doc), "a check box with its own look keeps it"); + // Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic. + let bad = state_ap(©, &field(&all, "bad").widgets[0], "Yes"); + assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}"); + // The document itself is untouched. + assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); +} + +/// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its +/// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred. +#[test] +fn push_buttons_without_an_appearance_show_their_caption_and_frame() { + let copy = with_field_appearances(&left_buttons()).expect("buttons to draw"); + let shown = ap(©, &field(&fields(©), "o").widgets[0]); + for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] { + assert!(shown.contains(part), "{part}: {shown}"); + } +} + +/// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a +/// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too. +#[test] +fn comb_fields_show_their_cells() { + let mut doc = left_buttons(); + let shown = saved_look(&doc, "d"); + let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect(); + assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}"); + assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}"); + // Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either. + let r = field(&fields(&doc), "d").widgets[0].obj; + doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap(); + let huge = saved_look(&doc, "d"); + assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}"); + doc.update_dict(r, |w| { + w.set(b"MaxLen".to_vec(), Object::Int(6)); + w.remove(b"MK"); + }) + .unwrap(); + let plain = saved_look(&doc, "d"); + assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); +} + +/// A combo box drawn for display shows its drop-down button at the right edge and keeps its text +/// clear of it; a list box, a text field, the combo box's saved look and its printed one have none. +#[test] +fn combo_boxes_shown_on_screen_have_a_drop_down_button() { + let doc = left_buttons(); + let screen = with_field_appearances(&doc).expect("fields to draw"); + let printed = with_printed_field_appearances(&doc).expect("fields to draw"); + let m = ap(&screen, &field(&fields(&screen), "m").widgets[0]); + // A 22 pt square inside the 1 pt border, a line in the border colour, a triangle. + assert!(m.contains("0.85 g\n97 1 22 22 re f\n0 0 0 RG\n1 w\n97 1 m 97 23 l S\n0 g\n"), "{m}"); + // The text's clip stops at the button. + assert!(m.contains("1 1 96 22 re W n"), "{m}"); + let printed_m = ap(&printed, &field(&fields(&printed), "m").widgets[0]); + assert!(!printed_m.contains("0.85 g") && printed_m.contains("1 1 118 22 re W n"), "{printed_m}"); + let saved = saved_look(&doc, "m"); + assert!(!saved.contains("0.85 g"), "{saved}"); + // Right-aligned text moves left by the button's width. + let x = |s: &str| -> f64 { + s.split(" Tm ").next().and_then(|s| s.rsplit("1 0 0 1 ").next()).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap() + }; + assert!((x(&saved) - x(&m) - 22.0).abs() < 0.01, "{saved} / {m}"); + for other in ["n", "d"] { + let s = ap(&screen, &field(&fields(&screen), other).widgets[0]); + assert!(!s.contains("0.85 g"), "{other}: {s}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..a2281b009 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::combo_boxes_shown_on_screen_have_a_drop_down_button"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), push buttons with none from /MK and /BS with their caption, and a drawn combo box shows a drop-down button on screen only (not in Print as Image); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation" @@ -4428,8 +4428,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"] -notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved." +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] +notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn." [[feature]] id = "form.fill-text" @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/forms/src/tests.rs::comb_fields_show_their_cells"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." [[feature]] id = "form.fill-checkbox-radio" From dd1a894b2dd252791a7cc8639991ad0bd5850156 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:01:55 +0800 Subject: [PATCH 28/28] M6: leave the combo box drop-down arrow to #3567 The on-screen drop-down button for combo boxes drawn display-only, the narrowed text area beside it, and the Print as Image copy without it (with_printed_field_appearances, printed_display_bytes) are removed. Open PR #3567 draws the arrow for every combo box in the page view. Check boxes, radio buttons, push buttons and comb lines are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/lib.rs | 10 ------- crates/engine/src/print_raster.rs | 4 +-- crates/engine/src/tests.rs | 17 ++++-------- crates/forms/README.md | 4 +-- crates/forms/src/appearance.rs | 46 ++----------------------------- crates/forms/src/lib.rs | 18 ++---------- crates/forms/src/tests.rs | 31 ++------------------- parity/acrobat-features.toml | 4 +-- 8 files changed, 17 insertions(+), 117 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a4987a17f..86fc9dce4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -934,16 +934,6 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc Option>> { - let e = doc.editor.as_ref()?; - let comments = pdfcraft_annot::with_missing_appearances(&e.cos); - let fields = pdfcraft_forms::with_printed_field_appearances(comments.as_ref().unwrap_or(&e.cos))?; - write_incremental(&fields, &SaveOptions::default()).ok().map(Arc::new) -} - /// `comments` (the display copy with comment appearances, if it needed any; else the document /// itself) with its form fields drawn from their values where the file leaves that to the viewer. fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index f54d64eed..4ed589586 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -23,9 +23,7 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont // The copy below holds only what prints, so the viewer's own comment hiding plays no part. config.hide_comments = false; let printed = guard(|| -> Result, String> { - // The display copy, less what it shows only on screen. - let shown = crate::printed_display_bytes(doc).unwrap_or_else(|| src.bytes.clone()); - let mut cos = pdfcraft_cos::Document::open_with_password(shown, config.password.as_deref()).map_err(|e| e.to_string())?; + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; pdfcraft_print::keep_printed(&mut cos, content); write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) })??; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 01789a3d7..154682601 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7440,22 +7440,20 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { } /// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb -/// field and a combo box the file leaves to the viewer. The page as displayed shows the check -/// box's caption symbol in its text colour, the button's grey face, the comb's cells in the border -/// colour and the combo box's drop-down button; printing leaves that button out, and saving -/// writes the file as it was. +/// field the file leaves to the viewer. The page as displayed and printed as an image shows the +/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in +/// the border colour, and saving writes the file as it was. #[test] fn buttons_and_combs_left_to_the_viewer_are_displayed() { let pdf = b"%PDF-1.7\n\ 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R 9 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\ 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\ 8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\ - 9 0 obj << /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (m) /Opt [(Apple) (Banana)] /V (Apple) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [20 20 220 50] /F 4 /P 3 0 R >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF" .to_vec(); let render = |bytes: Arc>| { @@ -7484,15 +7482,12 @@ fn buttons_and_combs_left_to_the_viewer_are_displayed() { assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}"); } assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); - // The combo box's drop-down button: a 28 pt square inside the right edge. - let button = (193, 22, 218, 48); - assert!(count(&shown, button, &grey) > 300, "the drop-down button on screen"); // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); let printed = render(Arc::new(pictures)); - assert_eq!(count(&printed, button, &grey), 0, "not on paper"); assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); + assert!(count(&printed, (104, 154, 216, 186), &grey) > 1000, "and the push button"); // The file is saved as it was: no widget has an appearance. let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); for f in pdfcraft_forms::fields(&saved) { diff --git a/crates/forms/README.md b/crates/forms/README.md index 88a086794..4aec223ee 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -58,9 +58,7 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in one field redraws that field, not the form. Check boxes and radio buttons with no appearance for their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats - symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. On - screen a drawn combo box also shows a drop-down button; `with_printed_field_appearances` leaves it - out for printing. + symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 13464bbd3..7558b120a 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -390,25 +390,6 @@ fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> Str c } -/// A combo box's drop-down button for the screen: a `size`-wide light grey face at the right -/// edge inside the `bw` border of a `w` × `h` widget, parted from the text by a line in the -/// border's colour, with a downward triangle. -fn drop_down_button(doc: &Document, wd: &Dict, w: f64, h: f64, bw: f64, size: f64) -> String { - let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); - let (x0, y0, x1, y1) = (w - bw - size, bw, w - bw, h - bw); - let mut c = format!("0.85 g\n{} {} {} {} re f\n", n(x0), n(y0), n(size), n(y1 - y0)); - let lw = line_width(doc, wd); - if let Some(bc) = color_array(doc, mk.as_ref(), b"BC") - && lw > 0.0 - && lw.is_finite() - { - c.push_str(&format!("{}\n{} w\n{} {} m {} {} l S\n", stroke_op(&bc), n(lw), n(x0), n(y0), n(x0), n(y1))); - } - let (cx, cy, d) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, size * 0.22); - c.push_str(&format!("0 g\n{} {} m {} {} l {} {} l h f\n", n(cx - d), n(cy + d / 2.0), n(cx + d), n(cy + d / 2.0), n(cx), n(cy - d / 2.0))); - c -} - /// Where a list box draws its options: one row per option from the top, starting with option /// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share /// it, so a click picks the option drawn under the pointer (#1524). @@ -499,19 +480,6 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { - field_look(doc, f, w, values, format, false) -} - -/// [`field_appearance_drawn`] as the page view shows a field the file leaves to the viewer: a -/// combo box also gets its drop-down button at the right edge, which the text keeps clear of. -/// The button is something to click on screen, not part of the field's printed look, so it is -/// drawn only here and never into an appearance PdfCraft saves or prints. -pub(crate) fn field_appearance_on_screen(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { - field_look(doc, f, w, values, format, true) -} - -/// The appearance of a text or choice field, with a combo box's drop-down button when `screen`. -fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool, screen: bool) -> (Stream, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -552,15 +520,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: let width_of = |text: &str, size: f64| enc.width(text, size); let (mut c, bw) = frame(doc, &wd, width, height); let pad = 2.0 + bw; - // A combo box's drop-down button on screen: a square as tall as the room inside the border, - // never wider than that room. - let button = if screen && f.kind == FieldKind::Combo { (height - 2.0 * bw).min(width - 2.0 * bw).max(0.0) } else { 0.0 }; - if button > 0.0 { - c.push_str(&drop_down_button(doc, &wd, width, height, bw, button)); - } - // The text and its clip keep to the room left of the button. - let text_w = width - button; - let inner_w = (text_w - 2.0 * pad).max(1.0); + let inner_w = (width - 2.0 * pad).max(1.0); let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); let mut body: Vec = Vec::new(); // The first character drawn that the font can't hold (drawn as `?`), for the caller to report. @@ -579,7 +539,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: let tw = width_of(text, size); match q { 1 => pad + (inner_w - tw) / 2.0, - 2 => text_w - pad - tw, + 2 => width - pad - tw, _ => pad, } }; @@ -649,7 +609,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: "/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n", n(bw), n(bw), - n(text_w - 2.0 * bw), + n(width - 2.0 * bw), n(height - 2.0 * bw), font_name, n(size), diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 2a8761856..04de78ba1 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1304,9 +1304,8 @@ pub fn need_appearances(doc: &Document) -> bool { /// writer left the appearances to the viewer), and otherwise each widget without a normal /// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons /// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192); the page view -/// also shows a drawn combo box's drop-down button. `doc` is not changed: saving writes the file -/// as it was. +/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not +/// changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1314,18 +1313,6 @@ pub fn with_field_appearances(doc: &Document) -> Option { /// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are /// the same objects as then (see [`FieldAppearanceCache`]). pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { - fields_shown(doc, cache, true) -} - -/// [`with_field_appearances`] for printing: the same, without the drop-down buttons of combo -/// boxes, which are for clicking on screen and not part of a field's printed look. -pub fn with_printed_field_appearances(doc: &Document) -> Option { - fields_shown(doc, &mut FieldAppearanceCache::default(), false) -} - -/// [`with_field_appearances_cached`]; `screen` adds the on-screen parts (a combo box's button). -/// One cache serves one of the two: the engine keeps one for the screen and prints uncached. -fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool) -> Option { let all = need_appearances(doc); let mut copy: Option = None; for f in fields(doc) { @@ -1365,7 +1352,6 @@ fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool) let stream = match f.kind { FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), - _ if screen => appearance::field_appearance_on_screen(doc, &f, w, &f.value, true).0, _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, }; cache.insert(w.obj, inputs, &stream); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 27b64f5a1..e3c7a1d13 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3098,8 +3098,8 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { assert_eq!(widgets, [("b", 1)]); } -/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field -/// and a combo box, for the display copy. +/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field, +/// a combo box and a list box, for the display copy. fn left_buttons() -> Document { let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); document(&[ @@ -3226,30 +3226,3 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } - -/// A combo box drawn for display shows its drop-down button at the right edge and keeps its text -/// clear of it; a list box, a text field, the combo box's saved look and its printed one have none. -#[test] -fn combo_boxes_shown_on_screen_have_a_drop_down_button() { - let doc = left_buttons(); - let screen = with_field_appearances(&doc).expect("fields to draw"); - let printed = with_printed_field_appearances(&doc).expect("fields to draw"); - let m = ap(&screen, &field(&fields(&screen), "m").widgets[0]); - // A 22 pt square inside the 1 pt border, a line in the border colour, a triangle. - assert!(m.contains("0.85 g\n97 1 22 22 re f\n0 0 0 RG\n1 w\n97 1 m 97 23 l S\n0 g\n"), "{m}"); - // The text's clip stops at the button. - assert!(m.contains("1 1 96 22 re W n"), "{m}"); - let printed_m = ap(&printed, &field(&fields(&printed), "m").widgets[0]); - assert!(!printed_m.contains("0.85 g") && printed_m.contains("1 1 118 22 re W n"), "{printed_m}"); - let saved = saved_look(&doc, "m"); - assert!(!saved.contains("0.85 g"), "{saved}"); - // Right-aligned text moves left by the button's width. - let x = |s: &str| -> f64 { - s.split(" Tm ").next().and_then(|s| s.rsplit("1 0 0 1 ").next()).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap() - }; - assert!((x(&saved) - x(&m) - 22.0).abs() < 0.01, "{saved} / {m}"); - for other in ["n", "d"] { - let s = ap(&screen, &field(&fields(&screen), other).widgets[0]); - assert!(!s.contains("0.85 g"), "{other}: {s}"); - } -} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a2281b009..9d98027d5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::combo_boxes_shown_on_screen_have_a_drop_down_button"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), push buttons with none from /MK and /BS with their caption, and a drawn combo box shows a drop-down button on screen only (not in Print as Image); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation"