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.
240 lines
8.5 KiB
Rust
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"
|
|
);
|
|
}
|