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
This commit is contained in:
Ridzzz0Alam
2026-10-22 12:52:40 +02:00
parent 9a0ec64568
commit bd00a131b6
5 changed files with 159 additions and 16 deletions
+17
View File
@@ -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<Value> = 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)
}
+46 -13
View File
@@ -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<u8> {
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();
+8
View File
@@ -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<usize>, 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()
+51 -3
View File
@@ -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<char>) {
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<Overflow> {
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<char>, Option<Overflow>) {
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
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,
+37
View File
@@ -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);
}