From e4e39f4bef65740fdb7078774ac733c68fe7067b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:10:32 +0900 Subject: [PATCH] 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 * M4: a metadata edit reads the other Info entries as the full inspection does Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/lib.rs | 24 +++++++++++- crates/engine/src/tests.rs | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3d9766bde..a0320fa71 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 64534bb55..aacd3f9b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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);