nigig-org/crates/apps/pdf/pdf-document/tests/acroform_roundtrip.rs
andodeki 3f107bf2a5 feat(pdf): complete Phase 3 document model with real form state
Phase 3 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md. The previous
form.rs was keyed by name strings, had no inheritance and no way to edit
anything, so the review item "delete PdfFormFilling and replace it with a
DocumentFormEditor that returns real errors" had no implementation.

Step 3.1 object identity:
- Fields are keyed by ObjRef, not by name. Widget-to-field identity is
  preserved across parse, and a field defined as a direct object is skipped
  rather than given an invented identity.
- PdfDocument::page_index_of() maps a page reference back to its index and
  returns None for a stranger.

Step 3.2 annotations:
- PdfDocument::page_annotations() reads /Annots and records the real page on
  every annotation; the hardcoded page_index: None is gone.
- Annotations expose a typed action() (OpenUri / GoToNamed / GoToPage). The
  document reports intent only and never opens anything itself (rule 5).
- contains_point() normalises the rectangle: a PDF /Rect is any two opposite
  corners, so an inverted one previously never hit-tested.

Step 3.3 form model:
- /Parent chain inheritance for FT, Ff, V, DV, DA, MaxLen and Opt, with the
  child key overriding the ancestor.
- /Ff resolved into concrete types: checkbox vs radio vs pushbutton, combo
  vs list box.
- DocumentFormEditor takes typed edits and returns FormError. Read-only,
  type-mismatched, over-MaxLen, non-option and unknown-state edits are all
  refused, and a refused edit leaves the field untouched and not dirty.
- MaxLen counts characters, not bytes.
- Checking a box uses the on state the widget declares, not an assumed /Yes.
- The field tree walk is depth-bounded so a cyclic /Kids cannot recurse
  until the stack dies.

Step 3.4 appearance generation (new appearance.rs):
- Document-level, not widget code. Generates text, multiline, choice and
  checkbox appearances as Form XObjects with correct /BBox and /Length.
- /DA parsing resolves font, size and colour, converting gray and CMYK.
- Auto-size (0 Tf) resolves to a size that fits the widget.
- Values are escaped, so a parenthesis in a value cannot terminate the
  string and corrupt the stream; a single-line value cannot break out via
  newlines; content is clipped to the widget box.
- Unsupported kinds (pushbutton, signature) and degenerate rectangles return
  AppearanceError instead of a blank stream that would erase the field.

Wiring: PdfDocument::acroform() parses the form against the real page tree,
which is what makes widget page resolution meaningful.

Tests: adds tests/acroform.pdf, a hand-written two-page AcroForm fixture
with an inherited field, a checkbox with /On and /Off states and a URI link,
all on page index 1 so any code defaulting to page 0 fails. 12 integration
tests assert the Phase 3 exit criterion against that real parsed file.

Validation: TEST_TARGET=pdf ./tools/test-rust-clean.sh
118 tests pass; rustfmt and clippy -D warnings clean.
2026-07-27 15:49:46 +00:00

240 lines
8.5 KiB
Rust

//! Phase 3 exit criterion, against a real parsed PDF rather than synthetic
//! dictionaries: parse an AcroForm file, modify a text field through
//! `DocumentFormEditor`, and verify `/V` changed and the dirty flag is set.
//!
//! The fixture is a hand-written PDF checked in beside this file. It has two
//! pages so that widget page resolution is genuinely exercised: everything
//! interactive lives on page index 1, so any code that silently defaults to
//! page 0 fails these tests.
use nigig_pdf_document::appearance::{generate_widget_appearance, regenerate_dirty};
use nigig_pdf_document::form::{apply_value_to_dict, DocumentFormEditor, FieldValue, FormError};
use nigig_pdf_document::{AnnotationAction, FieldType, PdfDocument};
const FIXTURE: &[u8] = include_bytes!("acroform.pdf");
#[test]
fn parses_the_page_tree_of_a_real_file() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture should parse");
assert_eq!(doc.page_count(), 2);
assert!(!doc.page(0).expect("page 0").content_data.is_empty());
assert!(!doc.page(1).expect("page 1").content_data.is_empty());
}
#[test]
fn parses_the_acroform_with_inherited_keys_and_qualified_names() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let form = doc
.acroform()
.expect("acroform lookup")
.expect("fixture has an AcroForm");
// "name" inherits /FT and /DA from its structural parent "personal", and
// its fully qualified name is built from both levels.
let name = form
.find_field("personal.name")
.expect("qualified name should resolve");
assert_eq!(name.field_type, FieldType::Text);
assert_eq!(name.value, FieldValue::Text("John".into()));
assert_eq!(
name.default_appearance.as_deref(),
Some("/Helv 10 Tf 0 g"),
"/DA must be inherited from the parent field"
);
assert_eq!(name.max_len, Some(20));
let agree = form.find_field("agree").expect("checkbox");
assert_eq!(agree.field_type, FieldType::Checkbox);
assert!(!agree.is_checked());
}
#[test]
fn widget_pages_resolve_to_the_real_page_and_not_page_zero() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let form = doc.acroform().expect("acroform").expect("present");
for field in form.fields() {
for widget in &field.widgets {
assert_eq!(
widget.page_index,
Some(1),
"field {:?} is on page 1 in the fixture",
field.full_name
);
}
}
assert_eq!(form.widgets_on_page(1).len(), 2);
assert!(
form.widgets_on_page(0).is_empty(),
"nothing in this fixture is on page 0"
);
}
/// The Phase 3 exit criterion.
#[test]
fn editing_a_field_changes_v_and_sets_the_dirty_flag() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
let original = form.field(field_ref).expect("field").value.clone();
assert_eq!(original, FieldValue::Text("John".into()));
let mut editor = DocumentFormEditor::new(&mut form);
editor
.set_text(field_ref, "Jane")
.expect("edit should apply");
assert_eq!(
editor.invalidated_pages(),
&[1],
"editing must invalidate the page the widget is on"
);
let edited = form.field(field_ref).expect("field");
assert_eq!(edited.value, FieldValue::Text("Jane".into()));
assert!(edited.dirty, "an applied edit must mark the field dirty");
assert_eq!(form.dirty_fields().count(), 1);
}
#[test]
fn the_edit_survives_serialization_back_into_the_field_dictionary() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
DocumentFormEditor::new(&mut form)
.set_text(field_ref, "Jane")
.expect("edit");
let field = form.field(field_ref).expect("field");
let updated = apply_value_to_dict(field, &field.raw_dict_snapshot());
assert_eq!(
updated.get_str("V"),
Some(&b"Jane"[..]),
"/V must carry the new value"
);
}
#[test]
fn max_len_from_the_file_is_enforced() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
let mut editor = DocumentFormEditor::new(&mut form);
let too_long = "x".repeat(21);
let err = editor
.set_text(field_ref, &too_long)
.expect_err("21 characters exceeds the fixture's /MaxLen of 20");
assert!(matches!(err, FormError::TooLong { max_len: 20, .. }));
// The refused edit must have changed nothing.
assert_eq!(
form.field(field_ref).expect("field").value,
FieldValue::Text("John".into())
);
assert!(!form.field(field_ref).expect("field").dirty);
}
#[test]
fn checking_the_box_uses_the_on_state_declared_in_the_file() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("agree").expect("checkbox").obj_ref;
DocumentFormEditor::new(&mut form)
.set_check(field_ref, true)
.expect("check");
let field = form.field(field_ref).expect("field");
assert_eq!(
field.value,
FieldValue::State("On".into()),
"the fixture's appearance dictionary names the on state /On"
);
assert!(field.is_checked());
}
#[test]
fn appearance_regeneration_reflects_the_edited_value() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
DocumentFormEditor::new(&mut form)
.set_text(field_ref, "Jane")
.expect("edit");
let (generated, errors) = regenerate_dirty(&form);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(generated.len(), 1);
let content = generated[0].1.content_str();
assert!(content.contains("(Jane) Tj"), "got: {content}");
assert!(!content.contains("(John)"), "stale value must not survive");
}
#[test]
fn a_regenerated_appearance_is_a_wellformed_form_xobject() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
let form = doc.acroform().expect("acroform").expect("present");
let field = form.find_field("personal.name").expect("field");
let ap = generate_widget_appearance(field, &field.widgets[0], &form).expect("appearance");
let xobj = ap.to_form_xobject();
assert_eq!(xobj.dict.get_name("Subtype"), Some("Form"));
assert_eq!(xobj.dict.get_int("Length"), Some(xobj.data.len() as i64));
// The widget is 200x20 in the fixture.
assert_eq!(ap.bbox, [0.0, 0.0, 200.0, 20.0]);
}
#[test]
fn annotations_report_their_real_page_and_typed_action() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
assert!(
doc.page_annotations(0).expect("page 0").is_empty(),
"page 0 has no annotations in this fixture"
);
let annots = doc.page_annotations(1).expect("page 1 annotations");
assert_eq!(annots.len(), 3, "two widgets and one link");
for annot in &annots {
assert_eq!(
annot.page_index,
Some(1),
"every annotation must know its real page"
);
}
let link = annots
.iter()
.find(|a| a.action().is_some())
.expect("fixture has a link with a URI action");
assert_eq!(
link.action(),
Some(AnnotationAction::OpenUri("https://example.org/".into())),
"the document reports the intent; it never opens the URL itself"
);
assert!(link.contains_point(150.0, 510.0));
assert!(!link.contains_point(50.0, 510.0));
}
#[test]
fn annotations_for_a_missing_page_are_an_error() {
let mut doc = PdfDocument::parse(FIXTURE).expect("parse");
assert!(doc.page_annotations(99).is_err());
}
#[test]
fn page_index_of_maps_references_back_and_rejects_strangers() {
let doc = PdfDocument::parse(FIXTURE).expect("parse");
let page1 = doc.page_object_ref(1).expect("page 1 ref");
assert_eq!(doc.page_index_of(page1), Some(1));
let stranger = nigig_pdf_cos::ObjRef { num: 9999, gen: 0 };
assert_eq!(
doc.page_index_of(stranger),
None,
"an unknown reference must be unknown, not page 0"
);
}