mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:50:14 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user