Cap unbounded editing and text caches; index form fields once (#1115)

* Cap editing and text-layer caches that grew without bound

The cos editing cache kept every touched object until save, so editing
a large document pinned its whole object graph. Bound it like the scan
reader: 1024 objects / 32 MB of owned bytes, 256 object streams /
32 MB, evicting to the newcomer. Returned objects outlive eviction, and
clones share the caches, so this only costs re-parsing.

The viewer text cache had the same shape: every visited page's text
stayed for the session. Retain visible, selected and matched pages plus
a 24-page prefetch window each frame; evicted pages re-extract on
revisit. Skipped while a search is active, where demand still wants
every page. Failures are never evicted: they cost nothing to remember.

cli info on a 1024-page/147 KB file (debug build, same host/command):
before 66.7 MB RSS, after 54.0 MB RSS (3 runs, tight).

Tests: editor count bound, byte bound, snapshot-clone reads (cos);
evict-far/keep-visible+matched+selection, search-active keep-all
(canvas). All mutation-checked: each bound goes red when disabled.

Signed-off-by: Chiranjeevi U <[email protected]>

* Index form fields once per refresh instead of once per field

The form-scope refresh ran a linear find over all fields for every
field, O(F^2) on each keystroke; forms reach 10k fields. Build one
name index up front, O(F). entry().or_insert() keeps the old
first-match semantics for repeated field names.

New test with two same-named widgets pins first-wins and goes red
under insert. 141 engine tests green, clippy warnings and perf lints
clean.

Signed-off-by: Chiranjeevi U <[email protected]>

---------

Signed-off-by: Chiranjeevi U <[email protected]>
This commit is contained in:
Chiranjeevi U authored and GitHub committed 2026-10-11 14:46:19 -04:00
1 parent 92c9de7aad
commit 9644aeb9e2
4 files changed
+269 -1

No files matched your search

+31
View File
@@ -1193,6 +1193,37 @@ fn form_edits_refresh_field_values_without_a_full_inspection() {
assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]);
}
#[test]
fn form_refresh_keeps_first_value_when_field_names_repeat() {
// Two widgets share one name; the refresh reads the first.
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (first) /Rect [20 250 180 270] /P 3 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (second) /Rect [20 220 180 240] /P 3 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (other) /Rect [20 190 180 210] /P 3 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("dup.pdf", None, Arc::new(bytes), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("x".into()) }).unwrap();
let dup = s.get(id).unwrap().info.fields.iter().find(|f| f.name == "dup").unwrap().value.clone();
assert_eq!(dup.as_deref(), Some("first"));
}
#[test]
fn preparing_a_form_adds_renames_and_deletes_fields() {
let (mut s, id) = session_with(1);