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);