fix(forms): cache field appearances drawn for display, redraw only what changed (#3470)

Review follow-up (CodeRabbit on #3475). A form edit refreshes the display
copy on every keystroke, and with /NeedAppearances that redrew every field
of the form: 68 ms per edit on a 1000-field form against 7 ms on main.

FieldAppearanceCache keeps each widget's drawing with the objects it was
drawn from: the widget and its field ancestors, the form dictionary and
the fonts of its /DR, each with the objects its entries refer to. An
entry is used only while each of them is still the same object
(Arc::ptr_eq); the document replaces an object it edits, so a changed
input is a miss, never a stale hit. The engine keeps one per editor. A
field edit on that form now takes 13 ms.

The repair note on a failed display write now names field appearances
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-19 22:26:31 +08:00
co-authored by Claude Opus 5.5
parent b661ba4584
commit da351cda8f
6 changed files with 251 additions and 6 deletions
+8 -2
View File
@@ -316,6 +316,10 @@ struct Editor {
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
appearances: AppearanceCache,
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
/// for each widget while the objects it was drawn from are unchanged; see
/// [`pdfcraft_forms::FieldAppearanceCache`].
field_appearances: pdfcraft_forms::FieldAppearanceCache,
/// The text blocks last computed for one page, reused while the document is unchanged.
text_blocks: TextBlocksCache,
}
@@ -882,13 +886,14 @@ fn is_placeholder_title(title: &str) -> bool {
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(e) = editor else { return bytes.clone() };
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
let Some(display) = with_field_appearances(comments, &e.cos) else {
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
let Some(display) = fields.or(comments) else {
return bytes.clone();
};
match write_incremental(&display, &SaveOptions::default()) {
Ok(written) => Arc::new(written),
Err(err) => {
e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})"));
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
bytes.clone()
}
}
@@ -3576,6 +3581,7 @@ impl Session {
redo: Vec::new(),
keys,
appearances: AppearanceCache::default(),
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
text_blocks: TextBlocksCache::default(),
}),
None,
+5
View File
@@ -6160,6 +6160,11 @@ fn fields_left_to_the_viewer_are_displayed_from_their_values() {
let n = widget("a").as_dict().and_then(|d| d.get(b"AP").and_then(|ap| saved.resolve(ap).as_dict().and_then(|ap| ap.reference(b"N")))).unwrap();
let pdfcraft_cos::Object::Stream(n) = &*saved.get(n) else { panic!("A's appearance is a stream") };
assert_eq!(n.decoded().unwrap().trim_ascii_end(), b"q\nQ", "A keeps its empty appearance");
// Typing in one field redraws that field only, not the whole form, on each keystroke.
let builds = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().field_appearances.builds();
let before = builds(&s);
s.apply(id, Edit::SetFieldValue { name: "a".into(), value: pdfcraft_forms::FieldValue::Text("Typed".into()) }).unwrap();
assert_eq!(builds(&s), before + 1);
// Without the flag, the empty appearance is the file's own, and only B is drawn.
let id = s.open("form.pdf", None, Arc::new(pdf(false)), None).unwrap();