Phase 8 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md, designed in REVIEWS/adr/0003-pdf-incremental-save.md. The review treats Phase 8 as ten independent projects, each needing its own design doc and merge criteria. Incremental save is taken first because it is the only one whose prerequisites are already met, it is listed as a prerequisite by two others (annotation editing and full AcroForm support), and it closes a real credibility gap: DocumentFormEditor has been able to edit form fields since Phase 3, and there was no way to save the result. A grep for a public save API across all four crates returned nothing. Design decision: append a revision, never rewrite. The original bytes are copied verbatim and changed objects are appended with a new xref chained through /Prev. A full rewrite would be easier and wrong: it would silently discard everything this parser does not yet model (structure trees, optional content, embedded files), and it would invalidate any signature, foreclosing a feature listed later in the same phase. ADR 0003 records this in full. Two latent bugs surfaced while building it, both pre-existing: - find_xref_start searched with windows(10) for the 9-byte keyword "startxref", so it never matched. Every parse silently fell through to a forward scan for the first "xref" in the file. On a single-revision document that happens to be correct; on an incrementally saved one it is the *oldest* revision, so a saved edit read back as its pre-edit value. This had no visible effect before because nothing produced multi-revision files. - XRefTable::parse read one section and ignored /Prev entirely, so a multi-revision document lost every object the earlier revisions defined. It now walks the chain newest-first, keeping the first definition of each object, with a visited set against /Prev loops and bounds checks on the offsets, which come from the file and cannot be trusted. A bad link ends the chain instead of indexing out of bounds. The xref unit fixture claimed startxref 408 in a 191-byte file and only ever passed because of the windows(10) defect; it is corrected rather than adjusted to keep passing. Implementation: - pdf-cos/src/incremental.rs: IncrementalUpdate builds one revision. Recomputes stream /Length so a caller cannot write an inconsistent one, emits xref subsections for contiguous runs, sizes /Size over the whole chain, and is byte-reproducible for a given set of edits. - pdf-document/src/save.rs: turns dirty AcroForm fields into a revision, writing the new /V and a regenerated appearance stream referenced from /AP, keyed by state name for checkboxes and radios. Refusals rather than partial saves: an encrypted document returns SaveError::Encrypted, because writing plaintext objects into it would corrupt the file; a source with no startxref or no /Root is refused; and a save with no pending edits returns the input unchanged rather than growing the file and churning its timestamp. Tests: 10 acceptance tests in tests/save_roundtrip.rs covering the ADR merge criteria. The central one reparses from the written bytes rather than reusing in-memory state, so it tests the file rather than the writer against itself. Also asserts three chained revisions still reparse with the newest value winning, that pages and annotations survive a save, and that saving never panics on the malformed corpus. Known limitations, recorded in the ADR rather than glossed: cross-reference streams and object streams are not written, and superseded objects are not compacted. Validation: TEST_TARGET=pdf ./tools/test-rust-clean.sh (307 tests) TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh (351 tests, 6 ignored) Both rustfmt and clippy -D warnings clean.
276 lines
9.8 KiB
Rust
276 lines
9.8 KiB
Rust
//! Incremental save acceptance tests, against the real corpus.
|
|
//!
|
|
//! These are the merge criteria from
|
|
//! `REVIEWS/adr/0003-pdf-incremental-save.md`. The central one is the round
|
|
//! trip: edit, save, reparse **from the written bytes**, and observe the
|
|
//! edit. Anything less tests the writer against itself.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use nigig_pdf_cos::incremental::{find_last_startxref, SaveError};
|
|
use nigig_pdf_cos::{PdfDict, PdfObj};
|
|
use nigig_pdf_document::form::{DocumentFormEditor, FieldType, FieldValue};
|
|
use nigig_pdf_document::save::save_form_edits;
|
|
use nigig_pdf_document::PdfDocument;
|
|
|
|
fn corpus(relative: &str) -> Vec<u8> {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../tests/corpus")
|
|
.join(relative);
|
|
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
|
|
}
|
|
|
|
/// Parse, returning the pieces a save needs.
|
|
fn open(data: &[u8]) -> (PdfDict, nigig_pdf_document::form::AcroForm, u32) {
|
|
let mut doc = PdfDocument::parse(data).expect("parses");
|
|
let form = doc
|
|
.acroform()
|
|
.expect("acroform lookup")
|
|
.expect("fixture has a form");
|
|
(doc.trailer().clone(), form, doc.max_object_number())
|
|
}
|
|
|
|
/// Every form fixture survives edit, save and reparse.
|
|
#[test]
|
|
fn every_form_fixture_round_trips() {
|
|
for fixture in ["forms/all_types.pdf", "forms/inherited.pdf"] {
|
|
let data = corpus(fixture);
|
|
let (trailer, mut form, max) = open(&data);
|
|
|
|
// Edit the first writable text field the fixture has.
|
|
let Some(target) = form
|
|
.fields()
|
|
.iter()
|
|
.find(|f| f.field_type == FieldType::Text && !f.is_read_only())
|
|
.map(|f| f.obj_ref)
|
|
else {
|
|
continue;
|
|
};
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(target, "round tripped")
|
|
.unwrap_or_else(|e| panic!("{fixture}: edit refused: {e}"));
|
|
|
|
let (out, report) = save_form_edits(&data, &trailer, &form, max)
|
|
.unwrap_or_else(|e| panic!("{fixture}: save failed: {e}"));
|
|
assert!(report.wrote_anything(), "{fixture}: nothing was written");
|
|
assert!(
|
|
out.starts_with(&data),
|
|
"{fixture}: the original bytes must be a prefix"
|
|
);
|
|
|
|
let mut reloaded = PdfDocument::parse(&out)
|
|
.unwrap_or_else(|e| panic!("{fixture}: saved file does not reparse: {e}"));
|
|
let reloaded_form = reloaded
|
|
.acroform()
|
|
.expect("acroform")
|
|
.unwrap_or_else(|| panic!("{fixture}: form lost on save"));
|
|
let value = reloaded_form
|
|
.field(target)
|
|
.unwrap_or_else(|| panic!("{fixture}: field lost on save"))
|
|
.value
|
|
.clone();
|
|
assert_eq!(
|
|
value,
|
|
FieldValue::Text("round tripped".into()),
|
|
"{fixture}: the edit did not survive"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_fixture_field_cannot_be_edited_so_nothing_is_saved() {
|
|
// forms/inherited.pdf's leaf inherits /Ff 1 (read-only) from its parent.
|
|
let data = corpus("forms/inherited.pdf");
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("address.city").expect("field").obj_ref;
|
|
|
|
assert!(
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, "nope")
|
|
.is_err(),
|
|
"the field is read-only"
|
|
);
|
|
let (out, report) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
assert!(report.unchanged);
|
|
assert_eq!(out, data, "a refused edit must not produce a revision");
|
|
}
|
|
|
|
#[test]
|
|
fn the_saved_file_gains_exactly_one_new_xref_section() {
|
|
let data = corpus("forms/all_types.pdf");
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("fullName").expect("field").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, "Grace Hopper")
|
|
.expect("edit");
|
|
|
|
let before = find_last_startxref(&data).expect("source startxref");
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
let after = find_last_startxref(&out).expect("saved startxref");
|
|
|
|
assert!(
|
|
after > before,
|
|
"the new xref must be appended after the old"
|
|
);
|
|
assert_eq!(
|
|
&out[after as usize..after as usize + 4],
|
|
b"xref",
|
|
"startxref must point at a real section"
|
|
);
|
|
let text = String::from_utf8_lossy(&out);
|
|
assert!(
|
|
text.contains(&format!("/Prev {before}")),
|
|
"the revision must chain to the previous xref"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkbox_round_trips_with_its_declared_on_state() {
|
|
let data = corpus("forms/all_types.pdf");
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("subscribe").expect("checkbox").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_check(field, true)
|
|
.expect("check");
|
|
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
|
|
let reloaded_form = reloaded.acroform().expect("acroform").expect("present");
|
|
let checkbox = reloaded_form.field(field).expect("checkbox");
|
|
|
|
assert!(checkbox.is_checked(), "got {:?}", checkbox.value);
|
|
assert_eq!(
|
|
checkbox.value,
|
|
FieldValue::State("On".into()),
|
|
"the fixture declares /On, not /Yes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_combo_choice_round_trips() {
|
|
let data = corpus("forms/all_types.pdf");
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("country").expect("combo").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_choice(field, vec!["Tanzania".into()])
|
|
.expect("choose");
|
|
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
|
|
let reloaded_form = reloaded.acroform().expect("acroform").expect("present");
|
|
assert_eq!(
|
|
reloaded_form.field(field).expect("combo").value,
|
|
FieldValue::Text("Tanzania".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn three_revisions_still_reparse() {
|
|
let mut data = corpus("forms/all_types.pdf");
|
|
for value in ["one", "two", "three"] {
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("fullName").expect("field").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, value)
|
|
.expect("edit");
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
assert!(out.starts_with(&data), "each revision appends");
|
|
data = out;
|
|
}
|
|
|
|
let mut reloaded = PdfDocument::parse(&data).expect("reparses");
|
|
let form = reloaded.acroform().expect("acroform").expect("present");
|
|
assert_eq!(
|
|
form.find_field("fullName").expect("field").value,
|
|
FieldValue::Text("three".into()),
|
|
"the newest revision must win after three saves"
|
|
);
|
|
// Other fields must survive the chain untouched.
|
|
assert!(form.find_field("subscribe").is_some());
|
|
assert!(form.find_field("country").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn the_page_tree_survives_a_save() {
|
|
// A revision that writes only form fields must not disturb the pages.
|
|
let data = corpus("forms/all_types.pdf");
|
|
let before = PdfDocument::parse(&data).expect("parses").page_count();
|
|
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("fullName").expect("field").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, "x")
|
|
.expect("edit");
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
|
|
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
|
|
assert_eq!(reloaded.page_count(), before);
|
|
assert!(
|
|
!reloaded.page(0).expect("page 0").content_data.is_empty(),
|
|
"page content must still resolve through the chain"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn annotations_survive_a_save() {
|
|
let data = corpus("forms/all_types.pdf");
|
|
let (trailer, mut form, max) = open(&data);
|
|
let field = form.find_field("fullName").expect("field").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, "x")
|
|
.expect("edit");
|
|
let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save");
|
|
|
|
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
|
|
let annots = reloaded.page_annotations(0).expect("annotations");
|
|
assert!(
|
|
!annots.is_empty(),
|
|
"the fixture's widget annotations must survive"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_an_encrypted_document_is_refused() {
|
|
let data = corpus("forms/all_types.pdf");
|
|
let (mut trailer, mut form, max) = open(&data);
|
|
trailer.set(
|
|
"Encrypt",
|
|
PdfObj::Ref(nigig_pdf_cos::ObjRef { num: 99, gen: 0 }),
|
|
);
|
|
let field = form.find_field("fullName").expect("field").obj_ref;
|
|
DocumentFormEditor::new(&mut form)
|
|
.set_text(field, "x")
|
|
.expect("edit");
|
|
|
|
assert_eq!(
|
|
save_form_edits(&data, &trailer, &form, max),
|
|
Err(SaveError::Encrypted),
|
|
"writing plaintext into an encrypted file would corrupt it"
|
|
);
|
|
}
|
|
|
|
/// Saving must not become a new way to crash on hostile input.
|
|
#[test]
|
|
fn malformed_fixtures_never_panic_a_save() {
|
|
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus/malformed");
|
|
let entries = std::fs::read_dir(&dir).expect("malformed corpus");
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().is_none_or(|e| e != "pdf") {
|
|
continue;
|
|
}
|
|
let Ok(data) = std::fs::read(&path) else {
|
|
continue;
|
|
};
|
|
let Ok(mut doc) = PdfDocument::parse(&data) else {
|
|
continue;
|
|
};
|
|
let trailer = doc.trailer().clone();
|
|
let max = doc.max_object_number();
|
|
let Ok(Some(form)) = doc.acroform() else {
|
|
continue;
|
|
};
|
|
// Whatever happens, it returns.
|
|
let _ = save_form_edits(&data, &trailer, &form, max);
|
|
}
|
|
}
|