M4: keep the render pool across metadata edits (#750)

* M4: keep the render pool across metadata edits

Metadata edits (SetInfo) no longer rebuild the render pool or the display
bytes. The engine refreshes the document-information fields, the file size,
the signatures (when present) and the working bytes, and keeps the renderer.
Undo and redo of metadata edits reuse the same pool.

Comment and form edits still rebuild the renderer and their view data.

Tests:
- metadata_edits_and_history_reuse_the_render_pool
- field_and_comment_edits_still_rebuild_the_render_pool
- metadata_edit_keeps_the_renderer_without_changing_page_content (the
  metadata invalidation audit; its assertions now expect the kept renderer)

Supersedes #711 and #712.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>

* M4: a metadata edit reads the other Info entries as the full inspection does

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 08:10:32 -04:00
1 parent 1c33e6f851
commit e4e39f4bef
2 files changed
+101 -1

No files matched your search

+23 -1
View File
@@ -146,6 +146,8 @@ struct Keys {
enum Scope {
/// Anything: re-inspect the whole document.
Full,
/// Document-information entries; page content and appearances are unchanged.
Metadata,
/// Only comments: re-read the comment list from the object graph.
Comments,
/// Only form field values (and their widget appearances).
@@ -163,6 +165,7 @@ fn uses_scripts(edit: &Edit) -> bool {
fn scope_of(edit: &Edit) -> Scope {
match edit {
Edit::SetInfo { .. } => Scope::Metadata,
// A file attachment also changes the Attachments list.
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddMeasurement(_)
@@ -2467,7 +2470,8 @@ impl Session {
}
}
/// Rebuild the working bytes and renderer, and the view data that `scope` may have changed.
/// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits
/// keep the renderer; other scopes also replace it.
/// Comment and form edits skip the full re-inspection (seconds on very large files): the
/// comment list and field values are re-read from the object graph instead.
fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> {
@@ -2480,10 +2484,28 @@ impl Session {
} else {
editor.cos.bytes().clone()
};
if scope == Scope::Metadata {
// Read the way the full inspection reads them (NULs and spaces trimmed, empty is
// none), so editing one entry doesn't change how the others display.
let info = |key| pdfcraft_organize::info(&editor.cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
doc.info.title = info("Title");
doc.info.author = info("Author");
doc.info.subject = info("Subject");
doc.info.keywords = info("Keywords");
doc.info.creator = info("Creator");
doc.info.producer = info("Producer");
doc.info.file_size = bytes.len();
if !doc.signatures.is_empty() {
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
}
doc.bytes = bytes;
return Ok(());
}
let mut form = pdfcraft_forms::fields(&editor.cos);
xfa::mark_script_buttons(&editor.cos, &mut form);
let form = Arc::new(form);
match scope {
Scope::Metadata => return Ok(()),
Scope::Comments => doc.info.annotations = comment_list(&editor.cos),
Scope::Form => {
for f in &mut doc.info.fields {
+78
View File
@@ -133,6 +133,84 @@ fn edits_update_view_data_and_mark_dirty() {
assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
}
#[test]
fn metadata_edit_keeps_the_renderer_without_changing_page_content() {
let (mut s, id) = session_with(1);
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() };
let render = |session: &Session| {
let doc = session.get(id).unwrap();
doc.renderer.set_queue(vec![request]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if let Some(page) = doc.renderer.try_recv() {
assert!(page.error.is_none(), "{:?}", page.error);
return (doc.renderer.stats(), page.rgba);
}
assert!(std::time::Instant::now() < deadline, "page render did not finish");
std::thread::yield_now();
}
};
let (before, before_pixels) = render(&s);
assert_eq!(before.page_interpretations, 1);
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
assert_eq!(s.get(id).unwrap().info_value("Title").as_deref(), Some("Report"));
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1, "a metadata edit keeps the renderer and its page interpretation");
let (after, after_pixels) = render(&s);
assert_eq!(after.page_interpretations, 2, "the same renderer serves the second request, so its count continues");
assert_eq!(before_pixels, after_pixels, "document properties do not change page pixels");
}
#[test]
fn metadata_edits_and_history_reuse_the_render_pool() {
let (mut s, id) = session_with(1);
let pixels = shown(&s, id, 0).rgba;
let doc = s.get(id).unwrap();
let stats = doc.renderer.stats();
let display = doc.display.clone();
assert_eq!(stats.page_interpretations, 1);
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Report"));
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
assert_eq!(pixels.as_ref().len(), 200 * 300 * 4);
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title, None);
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
s.redo(id).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Report"));
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
}
#[test]
fn field_and_comment_edits_still_rebuild_the_render_pool() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(scripted_form()), None).unwrap();
let _ = shown(&s, id, 0);
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0);
let _ = shown(&s, id, 0);
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
s.apply(
id,
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Test".into() }),
)
.unwrap();
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0);
}
#[test]
fn undo_and_redo_restore_exact_states() {
let (mut s, id) = session_with(3);