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 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-23 12:38:36 +08:00
co-authored by Claude Opus 5.5
parent 941077715a
commit fa146bbecd
3 changed files with 109 additions and 21 deletions
+59 -21
View File
@@ -134,18 +134,8 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
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<u8>, 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<ObjRef, FormError> {
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<u8>, 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 {
+1
View File
@@ -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.
+49
View File
@@ -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());
}