From 315e83b88c8e06f308fbbd1826ddc3c864e4ba18 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 15:22:50 -0500 Subject: [PATCH 01/21] perf(cos): an object that can't be read is searched for once, not on every read Document::try_get kept only objects that loaded. An object whose entry points at something that doesn't parse falls back to a whole-file search (scan_for); that failed too, nothing was kept, and the next read searched the whole file again. A caller that reads such an object repeatedly, such as the window title of a file whose document information dictionary is damaged and that asks for its title to be shown, paid one whole-file search per read, several per frame. The cache now keeps the load result, a failure as well. It is the same map with the same bounds, clears and per-reader privacy, so every place that drops loaded objects (authentication, reconstruction, private scan readers, eviction) drops remembered failures too. Edits are read from the overlay before the cache, as before, so a set or freed number still answers for itself. A failure is kept in nested loads too, as a success already is; only a poisoned lock is never kept. Failures count towards the cache's object caps and hold no bytes of their own. Sharing the map between clones stays sound because entries, data and the stream limit don't change after open. Test: an_unreadable_object_is_searched_for_once counts whole-file searches through a test-only counter (like SECTION_READS): one for eleven reads and a clone's read. With failures not kept it counts 22. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document.rs | 10 +++++---- crates/cos/src/document/objects.rs | 35 +++++++++++++++++------------- crates/cos/src/document/state.rs | 12 +++++++++- crates/cos/src/document/tests.rs | 31 +++++++++++++++++++++++--- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 082d1910d1..6e5891d1a4 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -24,10 +24,10 @@ pub(crate) use reader::find; #[cfg(test)] use reader::rfind; pub(crate) use state::ObjectReader; -use state::{ObjStm, Slot}; -pub use state::{ReadState, SourceIdentity}; +use state::{Loaded, ObjStm, Slot}; #[cfg(test)] -use state::{SECTION_READS, objstm_member, owned_bytes}; +use state::{OBJECT_SEARCHES, SECTION_READS, objstm_member, owned_bytes}; +pub use state::{ReadState, SourceIdentity}; use xref::XrefIndex; pub use xref::{Revision, XrefEntry}; @@ -47,7 +47,9 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, - cache: Arc>>>, + /// What loading each file object gave, a failure as well: a damaged object is otherwise + /// searched for across the whole file on every read (`Document::load`). + cache: Arc>>, objstms: Arc>>>, /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..2ca327de74 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::state::{LENGTHS, LoadGuard, Loaded, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, objstm_member}; use super::xref::{Revision, XrefEntry}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, @@ -388,20 +388,23 @@ impl Document { Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)), None => {} } - if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { - return Ok(o.clone()); + if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + return loaded.clone(); } // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; - let obj = self.load(num, 0)?; - let obj = Arc::new(obj); - // The editing cache is bounded like the scan reader's: re-parsing a file-backed view - // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; - Ok(obj) + let loaded = self.load(num, 0).map(Arc::new); + // A failure is kept like an object, or every read of a damaged object searches the whole + // file again. A poisoned lock says nothing about the object. + if !matches!(loaded, Err(CosError::Poisoned)) { + // The editing cache is bounded like the scan reader's: re-parsing a file-backed view + // is cheap, and returned objects outlive eviction. + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone()); + self.evict_editor_cache_if_over_budget(num, &loaded)?; + } + loaded } /// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB) @@ -430,17 +433,17 @@ impl Document { /// byte budget can double. const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Loaded) -> Result<(), CosError> { // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer // below the count cap. `len` is copied out first; nothing here holds the lock. let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { + if len <= Self::EDITOR_CACHE_OBJECTS && loaded_bytes(fresh, &self.data) <= Self::EDITOR_LARGE_OBJECT { return Ok(()); } // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); + let live: Vec<(u32, Loaded)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); + let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(loaded_bytes(o, data))); if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { return Ok(()); } @@ -450,7 +453,7 @@ impl Document { let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; if cache.len() <= Self::EDITOR_CACHE_OBJECTS { let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); + let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(loaded_bytes(o, data))); if owned <= Self::EDITOR_CACHE_BYTES { return Ok(()); } @@ -602,6 +605,8 @@ impl Document { /// Last-resort lookup: scan the file for `num G obj`. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { + #[cfg(test)] + super::OBJECT_SEARCHES.with(|c| c.set(c.get() + 1)); let needle = format!("{num} "); let data = &self.data; let mut found = None; diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index b4323f982e..121a59c2e7 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -30,6 +30,14 @@ impl LoadGuard { } } +/// What loading a file object gave, kept in the document's cache either way. +pub(super) type Loaded = Result, CosError>; + +/// The bytes a load result holds of its own ([`owned_bytes`]); a failure holds none. +pub(super) fn loaded_bytes(loaded: &Loaded, data: &Arc>) -> usize { + loaded.as_ref().map_or(0, |o| owned_bytes(o, data, 0)) +} + impl Drop for LoadGuard { fn drop(&mut self) { let outermost = LOADING.with(|l| { @@ -165,7 +173,7 @@ impl ObjectReader { // have cached large objects. if let Ok(mut cache) = self.document.cache.lock() { let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { + if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(loaded_bytes(o, data))) > Self::STREAM_BYTES { cache.clear(); } } @@ -177,6 +185,8 @@ impl ObjectReader { thread_local! { /// Cross-reference sections read on this thread. pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Whole-file searches for an object (`Document::scan_for`) on this thread. + pub(super) static OBJECT_SEARCHES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..3cebe0136d 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -588,6 +588,31 @@ fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } +/// An object that can't be read at its entry or anywhere else is searched for in the whole file +/// once: every later read, in a clone too, gives the same error without searching again. An +/// edit of that number still answers for it. +#[test] +fn an_unreadable_object_is_searched_for_once() { + let bodies = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Odd Key(x) >>"]; + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R /Info 3 0 R"))).unwrap(); + // Counted wherever the whole-file search is done (`scan_for`). + OBJECT_SEARCHES.with(|c| c.set(0)); + let first = doc.try_get(3); + assert!(first.is_err(), "{first:?}"); + for _ in 0..10 { + assert_eq!(doc.try_get(3), first); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + } + let mut edited = doc.clone(); + assert_eq!(edited.try_get(3), first); + assert_eq!(OBJECT_SEARCHES.with(|c| c.get()), 1, "searches for an object that isn't there"); + edited.set(ObjRef::new(3, 0), Object::Int(7)); + assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Int(7))); + assert_eq!(doc.try_get(3), first, "the edit is the clone's alone"); + edited.free(ObjRef::new(3, 0)); + assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Null)); +} + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered /// `1 n` instead of `0 n` (#2676): every row is one number off. fn misnumbered_table(start: u32) -> Vec { @@ -1109,7 +1134,7 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned))); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -1198,8 +1223,8 @@ fn editor_cache_counts_deeply_nested_strings() { let Object::Ref(reference) = reference else { panic!("indirect object") }; drop(doc.get(*reference)); let cache = doc.cache.lock().unwrap(); - let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); - let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + let owned = cache.values().flatten().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().flatten().filter(|o| o.as_array().is_some()).count(); assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); } } From 84d4b8cab9b8ee1d6d0c3fbed70ea3291c22a1b4 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 16:21:45 -0500 Subject: [PATCH 02/21] fix(cos): a load that read one clone's edits is not shared with the others Clones share the object cache and the parsed object streams. A clone that freed or replaced an object stream then read a member of it put the result in those shared maps: a failure (which this branch now caches) or, as on main, the replacement stream and the member parsed from it. Another clone whose stream is intact then read that instead of the file. A load that reads an unsaved edit (an edited object stream, or an edited indirect /Length) is no longer cached, and an edited object stream is parsed for its own document only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 30 ++++++++++++++++++++++-------- crates/cos/src/document/state.rs | 11 +++++++++++ crates/cos/src/document/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ca327de74..55a01e117c 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,5 +1,7 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, Loaded, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, objstm_member}; +use super::state::{ + LENGTHS, LoadGuard, Loaded, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads, +}; use super::xref::{Revision, XrefEntry}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, @@ -383,10 +385,12 @@ impl Document { } pub fn try_get(&self, num: u32) -> Result, CosError> { - match self.overlay.get(&num) { - Some(Slot::Set(_, o)) => return Ok(o.clone()), - Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)), - None => {} + if let Some(slot) = self.overlay.get(&num) { + note_overlay_read(); + return Ok(match slot { + Slot::Set(_, o) => o.clone(), + Slot::Freed(_) => Arc::new(Object::Null), + }); } if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return loaded.clone(); @@ -395,10 +399,12 @@ impl Document { // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; + let edits_read = overlay_reads(); let loaded = self.load(num, 0).map(Arc::new); // A failure is kept like an object, or every read of a damaged object searches the whole - // file again. A poisoned lock says nothing about the object. - if !matches!(loaded, Err(CosError::Poisoned)) { + // file again. A poisoned lock says nothing about the object. Clones share the cache, so + // a load that read this document's edits (an edited object stream or `/Length`) is not kept. + if overlay_reads() == edits_read && !matches!(loaded, Err(CosError::Poisoned)) { // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone()); @@ -540,6 +546,9 @@ impl Document { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { let resolve = |r: ObjRef| { + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { return known; } @@ -570,7 +579,9 @@ impl Document { } pub(super) fn objstm(&self, num: u32) -> Result, CosError> { - if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + // An edited or freed stream is this document's alone: clones share the parsed streams. + let edited = self.overlay.contains_key(&num); + if !edited && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } // A file entry that cannot place the stream (one that says it is itself compressed @@ -596,6 +607,9 @@ impl Document { index.push(objstm_member(onum, off, first, data.len())); } let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); + if edited { + return Ok(stm); + } // Same bound as the object cache: decoded object streams can be megabytes each. // Insert first so the retained `num` is actually in the map. self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone()); diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 121a59c2e7..286973943e 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -8,6 +8,17 @@ thread_local! { /// retries a parse several ways, and each retry would resolve the same lengths again /// (exponential in the chain of streams whose lengths point at further streams). pub(super) static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); + /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). + static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Unsaved edits read so far on this thread: a load during which this changes depended on one. +pub(super) fn overlay_reads() -> u64 { + OVERLAY_READS.with(std::cell::Cell::get) +} + +pub(super) fn note_overlay_read() { + OVERLAY_READS.with(|c| c.set(c.get().wrapping_add(1))); } /// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 3cebe0136d..ee7fd28750 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -613,6 +613,27 @@ fn an_unreadable_object_is_searched_for_once() { assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Null)); } +/// Clones share the cache, and an edit is one clone's alone: a member of an object stream that +/// one clone freed or replaced still reads, in the others, from the file's stream. +#[test] +fn an_edited_object_stream_leaves_other_clones_members_alone() { + let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0)); + let doc = Document::open(file.clone()).unwrap(); + let mut freed = doc.clone(); + freed.free(ObjRef::new(4, 0)); + assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4))); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the free reached another clone"); + let doc = Document::open(file).unwrap(); + let mut replaced = doc.clone(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + replaced.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec())); + assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no")); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); +} + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered /// `1 n` instead of `0 n` (#2676): every row is one number off. fn misnumbered_table(start: u32) -> Vec { From 8cc4d378476ddfa13c8a7540f05e8edfef6728cf Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 17:18:30 -0500 Subject: [PATCH 03/21] fix(cos): an object stream framed by one clone's edited /Length is not shared An unedited object stream whose indirect /Length is edited in one clone was parsed with that clone's length and put in the object-stream map the clones share, so the other clones read its members within the edited length. A container load that read the clone's edits is now kept to that clone, like an edited stream. Test: an_edited_object_stream_length_leaves_other_clones_members_alone fails without the fix (the other clone reads None) and passes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 5 ++++- crates/cos/src/document/tests.rs | 32 ++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 55a01e117c..b8f9a64870 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -589,6 +589,7 @@ impl Document { // header, as a plain object with a wrong offset is. An edited or freed stream, in the // overlay or in a saved revision's table, is what that says it is: the file's copy // must not come back. + let edits_read = overlay_reads(); let loaded = match self.try_get(num) { Ok(o) if matches!(*o, Object::Stream(_)) => o, _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { @@ -596,6 +597,8 @@ impl Document { } _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), }; + // A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one. + let own = edited || overlay_reads() != edits_read; let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); @@ -607,7 +610,7 @@ impl Document { index.push(objstm_member(onum, off, first, data.len())); } let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); - if edited { + if own { return Ok(stm); } // Same bound as the object cache: decoded object streams can be megabytes each. diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index ee7fd28750..15c9d8cd61 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -634,6 +634,20 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); } +/// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone: +/// the others still read its members within the file's length. +#[test] +fn an_edited_object_stream_length_leaves_other_clones_members_alone() { + let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true)); + let doc = Document::open(file).unwrap(); + let mut edited = doc.clone(); + // Ends the stream at the `endstream` inside the member's string. + let cut = "3 0 << /Hidden (cut".len(); + edited.set(ObjRef::new(6, 0), Object::Int(cut as i64)); + assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone"); +} + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered /// `1 n` instead of `0 n` (#2676): every row is one number off. fn misnumbered_table(start: u32) -> Vec { @@ -1367,8 +1381,14 @@ fn hybrid_reference_files_read_the_xref_stream_too() { /// stream puts it at position `member` of object stream 4, which holds `header` (the number /// and offset pairs) and then `members`. fn object_stream_file(header: &str, members: &str, member: u8) -> Vec { + object_stream_file_with(header, members, member, false) +} + +/// [`object_stream_file`], with the stream's `/Length` in object 6 when `indirect_length`. +fn object_stream_file_with(header: &str, members: &str, member: u8, indirect_length: bool) -> Vec { let mut out = b"%PDF-1.5\n".to_vec(); - let mut offs = [0usize; 6]; + let size = if indirect_length { 7 } else { 6 }; + let mut offs = vec![0usize; size]; let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { offs[n] = out.len(); out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); @@ -1379,7 +1399,8 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec { obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); let count = header.split_whitespace().count() / 2; let inner = format!("{header}{members}"); - let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {} >>\nstream\n", header.len(), inner.len()).into_bytes(); + let length = if indirect_length { "6 0 R".to_string() } else { inner.len().to_string() }; + let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {length} >>\nstream\n", header.len()).into_bytes(); stm.extend_from_slice(inner.as_bytes()); stm.extend_from_slice(b"\nendstream"); obj(&mut out, 4, &stm); @@ -1388,13 +1409,16 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec { xs.extend_from_slice(&data); xs.extend_from_slice(b"\nendstream"); obj(&mut out, 5, &xs); + if indirect_length { + obj(&mut out, 6, inner.len().to_string().as_bytes()); + } let table = out.len(); - out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); for (n, off) in offs.iter().enumerate().skip(1) { let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; out.extend_from_slice(line.as_bytes()); } - out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); out } From 96d3d1e073b61a00ab4bea312fdb8cdf5e974013 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 17:56:44 -0500 Subject: [PATCH 04/21] fix(cos): a reused /Length keeps the edits it was read through The indirect /Length memo kept only the value. A length first resolved through one clone's edited object stream and reused later in the same load did not count as an edit read, so an object stream framed by it could still be parsed with the edited length and put in the object-stream map and cache the clones share. The memo now keeps whether resolving the length read edits, and a reuse counts it again. Test: a_reused_edited_length_leaves_other_clones_members_alone fails without the fix (the other clone reads None) and passes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 10 ++++-- crates/cos/src/document/state.rs | 5 +-- crates/cos/src/document/tests.rs | 58 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index b8f9a64870..976e5e360c 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -549,11 +549,17 @@ impl Document { if self.overlay.contains_key(&r.num) { note_overlay_read(); } - if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { + // A length resolved through this document's edits counts as an edit read + // on every reuse. + if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { + if edits { + note_overlay_read(); + } return known; } + let edits_read = overlay_reads(); let length = self.try_get(r.num).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); + LENGTHS.with(|m| m.borrow_mut().insert(r.num, (length, overlay_reads() != edits_read))); length }; let off = offset as usize; diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 286973943e..29e2ea4879 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -6,8 +6,9 @@ thread_local! { static LOADING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; /// Indirect `/Length` values resolved during the current outermost load: a damaged file /// retries a parse several ways, and each retry would resolve the same lengths again - /// (exponential in the chain of streams whose lengths point at further streams). - pub(super) static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); + /// (exponential in the chain of streams whose lengths point at further streams). Each is + /// kept with whether resolving it read unsaved edits. + pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 15c9d8cd61..84dc17cc7f 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -648,6 +648,64 @@ fn an_edited_object_stream_length_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone"); } +/// A `/Length` read through one clone's edits and reused within the same load still keeps that +/// load to the clone. Here the file's offsets are header-relative behind junk, so object 10's +/// offset first lands on object 12, whose length (object 8, in the edited object stream 9) is +/// read; object 10's own length then loads object stream 4, framed by object 8 too. +#[test] +fn a_reused_edited_length_leaves_other_clones_members_alone() { + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 13]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + let stream = |dict: &str, data: &str| format!("<< {dict} >>\nstream\n{data}\nendstream").into_bytes(); + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let inner = "11 0 3 2 5 << /Hidden (cut endstream here) >>"; + obj(&mut out, 4, &stream("/Type /ObjStm /N 2 /First 9 /Length 8 0 R", inner)); + let lengths = format!("8 0 {}", inner.len()); + obj(&mut out, 9, &stream(&format!("/Type /ObjStm /N 1 /First 4 /Length {}", lengths.len()), &lengths)); + obj(&mut out, 12, &stream("/Length 8 0 R", &"x".repeat(40))); + obj(&mut out, 10, &stream("/Length 11 0 R", "PDATA")); + // A cross-reference stream: a hybrid file's /XRefStm is not looked for behind junk. + let compressed = [(3, 4, 1), (8, 9, 0), (11, 4, 0)]; + let mut data = Vec::new(); + for (n, off) in offs.iter().enumerate() { + let row = match compressed.iter().find(|c| c.0 == n) { + Some(&(_, stream, index)) => [2, 0, stream, index], + None if *off == 0 => [0, 0, 0, 0], + None => { + let [.., hi, lo] = u32::try_from(*off).unwrap().to_be_bytes(); + [1, hi, lo, 0] + } + }; + data.extend_from_slice(&row); + } + let table = out.len(); + let dict = format!("<< /Type /XRef /Size 14 /W [1 2 1] /Index [0 13] /Root 1 0 R /Length {} >>", data.len()); + out.extend_from_slice(format!("13 0 obj\n{dict}\nstream\n").as_bytes()); + out.extend_from_slice(&data); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{table}\n%%EOF\n").as_bytes()); + let mut file = vec![b'J'; offs[10] - offs[12] - 1]; + file.push(b'\n'); + file.extend_from_slice(&out); + let doc = Document::open(Arc::new(file)).unwrap(); + let mut edited = doc.clone(); + let cut = "11 0 3 2 5 << /Hidden (cut".len(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + edited.set(ObjRef::new(9, 0), crate::Stream::from_raw(dict, format!("8 0 {cut}").into_bytes())); + assert!(matches!(&*edited.get(ObjRef::new(10, 0)), Object::Stream(s) if &s.raw[..] == b"PDATA")); + assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone"); +} + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered /// `1 n` instead of `0 n` (#2676): every row is one number off. fn misnumbered_table(start: u32) -> Vec { From bad9a8b44c89cf8384252fcc57a20ad8cf33041b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 12:47:31 -0500 Subject: [PATCH 05/21] cos: an edited object stream forgets its members' cached failures A member that failed to load from the file's object stream was cached as a failure. Replacing or freeing that stream left the failure in place, so the member kept reading as null instead of from the edit (CodeRabbit on #5802). set and free now drop the cached failures of the stream's members. Cached objects are untouched; a failure holds no bytes, so the cache's total stands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 11 +++++++++++ crates/cos/src/document/state.rs | 5 +++++ crates/cos/src/document/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 96d3a9dee4..619e64eb5f 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -642,6 +642,7 @@ impl Document { pub fn set(&mut self, r: ObjRef, obj: impl Into) { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); + self.forget_member_failures(r.num); if r.num >= self.next_num { self.next_num = r.num.saturating_add(1); } @@ -669,6 +670,16 @@ impl Document { /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); + self.forget_member_failures(r.num); + } + + /// Forget cached failures of the members `stream` held in the file: they were failures of + /// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are + /// searched again too, which costs time only. A poisoned lock leaves the cache as it is. + fn forget_member_failures(&self, stream: u32) { + if let Ok(mut cache) = self.cache.lock() { + cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream)); + } } /// Free what was added since the file was read and nothing refers to any more: the objects diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 5f9bb49852..b3e6c1314a 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -250,6 +250,11 @@ impl ObjectCache { self.owned = 0; } + /// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands. + pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num)); + } + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { self.entries.retain(|num, _| keep(num)); self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 3b18521a4e..62481df4bc 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -634,6 +634,26 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); } +/// A member that failed to load from the file's object stream is kept as a failure, but replacing +/// or freeing that stream forgets it: the member then reads from the edit. +#[test] +fn an_edited_object_stream_forgets_its_members_failures() { + let file = Arc::new(object_stream_file("3 0 ", ") broken", 0)); + let mut doc = Document::open(file.clone()).unwrap(); + assert!(doc.try_get(3).is_err(), "the file's member does not parse"); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + doc.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (fixed) >>".to_vec())); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("fixed"), "the cached failure outlived the edit"); + assert!(doc.try_get(3).is_ok()); + let mut doc = Document::open(file).unwrap(); + assert!(matches!(doc.try_get(3), Err(CosError::Syntax { .. })), "the file's member does not parse"); + doc.free(ObjRef::new(4, 0)); + assert_eq!(doc.try_get(3), Err(CosError::MissingObject(4)), "the freed stream's member"); +} + /// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone: /// the others still read its members within the file's length. #[test] From 352355ab77163db6c7bfecdac0f0ca96a5f6aacf Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 11:58:06 -0500 Subject: [PATCH 06/21] M10: copy keeps formatting as HTML (Copy with formatting) Copying selected page text now also puts an HTML fragment on the system clipboard, so a paste into a word processor or mail client keeps paragraphs, bold, italic, size, colour and font family. The plain text is unchanged and stays on the clipboard as the alternative. - export: clip.rs, the clipboard model (ClipText of paragraphs of styled spans), its builder with caps (20 000 spans, 2 MiB of text, 16 MiB of HTML), font_family (subset tag and style suffixes off, camel case spaced) and clip_html. Family names are reduced to letters, digits, space, '-', '_' and '.', so a font name cannot leave the CSS string; text goes through esc, which drops control characters. - engine: Document::rich_copy(page, text, glyphs). Glyphs (view space) take the style of the reading block their centre lies in; blocks are style-homogeneous. A line wrap inside a block is a space (none after a hyphen), a change of block a new paragraph. Refused when the document does not allow copying. compare.rs's display unit and weight/slant inference are shared, not duplicated. - automation: text_extract takes format "text" (default) or "html"; html adds each page's html and styles_incomplete, for the whole page or only what rect takes. Other values are InvalidArgs. - ui-egui: Copy of a run or column selection writes HTML plus the plain text in one arboard set (desktop only); the web build and Select All of the whole document stay plain text. last_copied_html records what was produced, for tests. - parity: view.copy-with-layout is partial; notes list what is missing (RTF, underline/strike-through, tables, Select All, web, glyphs outside reading blocks). Formatting inference and the HTML serialiser are adapted from pdfcer (MIT, same author) at 540b00ef1a405d1baa67c92f3858535037c7cde8. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/pages.rs | 22 +- crates/automation/src/tools.rs | 5 +- crates/automation/tests/it/automation.rs | 30 +++ crates/engine/src/compare.rs | 24 +- crates/engine/src/lib.rs | 2 + crates/engine/src/rich_copy.rs | 220 ++++++++++++++++ crates/export/src/clip.rs | 322 +++++++++++++++++++++++ crates/export/src/lib.rs | 2 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/clipboard.rs | 23 +- crates/ui-egui/src/canvas/selection.rs | 15 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/tests/it/text_select.rs | 56 +++- parity/acrobat-features.toml | 10 +- 14 files changed, 718 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/rich_copy.rs create mode 100644 crates/export/src/clip.rs diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 8c793be153..ad6a819027 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -201,6 +201,11 @@ impl Automation { } pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + let html = match a.opt_str("format")?.unwrap_or("text") { + "text" => false, + "html" => true, + other => return Err(ToolError::InvalidArgs(format!("format must be \"text\" or \"html\", not {other:?}"))), + }; let doc = self.doc_to_copy(a)?; let id = doc.id; let pages = match a.opt_ints("pages")? { @@ -214,7 +219,22 @@ impl Automation { Some(r) => t.column_text(&t.glyphs_in(r)), None => t.plain_text(), }; - let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); + let doc = self.doc(a)?; + let mut out = Vec::with_capacity(pages.len()); + for (&p, t) in pages.iter().zip(texts) { + let mut page = json!({ "page": p + 1, "text": text(&t) }); + if html { + // Copy with formatting: the same glyphs as an HTML fragment. + let glyphs: Vec = match rect { + Some(r) => t.glyphs_in(r), + None => (0..t.glyphs.len()).collect(), + }; + let copy = doc.rich_copy(p, &t, &glyphs).map_err(failed)?; + page["html"] = json!(copy.html); + page["styles_incomplete"] = json!(copy.styles_incomplete); + } + out.push(page); + } Ok(json!({ "pages": out })) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1b4b6ac4ba..dae824cf5b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -195,13 +195,14 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out.") .ro() .cmd("edit.column_select") .with(schema( json!({ "doc": doc(), "pages": pages("to extract (default: all)"), - "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." } + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }, + "format": { "type": "string", "enum": ["text", "html"], "description": "text (default) or html: also return each page's text as formatted HTML." } }), &["doc"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 13f9af4371..72abdf71a5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2420,6 +2420,7 @@ fn protecting_through_tools() { // Copying wasn't allowed either (#568): no text or images come out, though search works. for (tool, args) in [ ("text_extract", json!({ "doc": re })), + ("text_extract", json!({ "doc": re, "format": "html" })), ("text_word_count", json!({ "doc": re })), ("text_paragraphs", json!({ "doc": re, "page": 1 })), ("doc_export_text", json!({ "doc": re, "path": "out.txt" })), @@ -8978,6 +8979,35 @@ fn text_extract_with_a_rect_takes_one_column() { } } +/// Copy with formatting, headless: `format: "html"` adds each page's text as the HTML fragment +/// Copy puts on the clipboard, for the whole page or only what `rect` takes. +#[test] +fn text_extract_as_html_keeps_the_formatting() { + let dir = workdir("rich-copy"); + std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap(); + let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "format": "html" }))["pages"][0]; + let html = page["html"].as_str().unwrap(); + assert!(html.starts_with("

Name"), "{html}"); + assert!(html.contains("Pear") && html.contains("Qty"), "{html}"); + assert_eq!(page["text"], "Name\nApple\nPear\nQty\n12\n7", "the plain text is unchanged"); + assert_eq!(page["styles_incomplete"], false); + // Plain text by default: no html key. + assert!(ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }))["pages"][0].get("html").is_none()); + // Only what the rect takes; nothing inside gives empty HTML. + let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [120, 0, 300, 300], "format": "html" })); + let html = column["pages"][0]["html"].as_str().unwrap(); + assert!(html.contains("Qty") && !html.contains("Name"), "{html}"); + let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5], "format": "html" })); + assert_eq!(none["pages"][0]["html"], ""); + // An unknown format is refused. + for bad in [json!("rtf"), json!(1)] { + let e = a.call("text_extract", &json!({ "doc": doc, "format": bad })).unwrap_err(); + assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}"); + } +} + #[test] fn mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools() { use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 9be1954b09..fe5af55706 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -187,6 +187,20 @@ pub struct OfficeExport { /// `Err(false)`: it can't be encoded). type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; +/// Displayed points per user-space unit on `info`'s page (`/UserUnit`), 1 when the page has no +/// usable size. +pub(crate) fn display_unit(info: &pdfcraft_render::PageInfo) -> f64 { + let crop_width = f64::from(info.crop[2] - info.crop[0]); + let display_width = f64::from(if info.rotation.is_multiple_of(180) { info.width } else { info.height }); + if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 } +} + +/// Bold and italic as a font's name says them (`Arial-BoldItalicMT`). +pub(crate) fn weight_and_slant(base_font: &str) -> (bool, bool) { + let f = base_font.to_ascii_lowercase(); + (f.contains("bold") || f.contains("black") || f.contains("heavy"), f.contains("italic") || f.contains("oblique")) +} + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. @@ -228,9 +242,7 @@ impl crate::Document { let b = info.user_to_view(r[2] as f32, r[3] as f32); [a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64] }; - let crop_width = f64::from(info.crop[2] - info.crop[0]); - let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); - let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; + let unit = display_unit(info); let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); let forms_unread = reading.truncated; let blocks = reading @@ -238,7 +250,7 @@ impl crate::Document { .into_iter() .filter(|b| !b.text.trim().is_empty()) .map(|b| { - let f = b.base_font.to_ascii_lowercase(); + let (bold, italic) = weight_and_slant(&b.base_font); // `text` is trimmed; the editor text keeps the source's own spaces. let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)]; pdfcraft_export::Block { @@ -246,8 +258,8 @@ impl crate::Document { text: b.text, rect: visible_rect(b.rect), size: b.size * unit, - bold: f.contains("bold") || f.contains("black") || f.contains("heavy"), - italic: f.contains("italic") || f.contains("oblique"), + bold, + italic, color: b.color, rotation: b.rotation - f64::from(info.rotation), } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..bf142bd037 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -43,6 +43,7 @@ mod pictures; pub mod print_raster; pub mod printcheck; mod protection; +mod rich_copy; mod scan_colours; mod scan_fields; mod session; @@ -66,6 +67,7 @@ pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; pub use pdfcraft_edit::strip_subset_tag; +pub use rich_copy::RichCopy; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::{CommentLayer, StampPreview}; diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs new file mode 100644 index 0000000000..99c2938350 --- /dev/null +++ b/crates/engine/src/rich_copy.rs @@ -0,0 +1,220 @@ +//! Copy with formatting: the selected glyphs of a page as paragraphs of styled spans and an +//! HTML fragment ([`pdfcraft_export::clip_html`]) for the clipboard, next to the plain text. +//! +//! Glyphs come from the text layer (view space); style comes from the page's reading blocks, +//! which never mix styles, mapped into view space. A glyph takes the style of the block its +//! centre lies in; a glyph in no block is copied unstyled. + +use pdfcraft_export::{ClipBuilder, ClipStyle, ClipText, clip_html}; +use pdfcraft_render::{PageInfo, PageText}; + +use crate::EditError; +use crate::compare::{display_unit, weight_and_slant}; + +/// Block tests spent matching glyphs to blocks for one copy; past it the rest is unstyled. +const MAX_MATCH_STEPS: usize = 20_000_000; +/// How far outside a block's box (points) a glyph centre still belongs to it. +const SLACK: f32 = 0.5; + +/// Selected text with its formatting. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RichCopy { + /// The paragraphs and spans. + pub text: ClipText, + /// The HTML fragment for the clipboard (empty when nothing was selected). + pub html: String, + /// Styles were left out: reading stopped at its limits on this page, or matching glyphs to + /// blocks ran out of steps. + pub styles_incomplete: bool, +} + +struct StyledBlock { + rect: [f32; 4], + style: ClipStyle, +} + +impl crate::Document { + /// Selected `glyphs` of `page` (indices into `text`, in copy order) with their formatting. + /// Refused when the document does not allow copying. + pub fn rich_copy(&self, page: usize, text: &PageText, glyphs: &[usize]) -> Result { + self.check_copying()?; + let (blocks, mut incomplete) = self.styled_blocks(page); + let mut b = ClipBuilder::new(); + let mut steps = MAX_MATCH_STEPS; + // The previous glyph's line and block, and whether it was a hyphen. + let mut prev: Option<(usize, Option, bool)> = None; + for &i in glyphs { + let (Some(g), Some(&line)) = (text.glyphs.get(i), text.line_of.get(i)) else { continue }; + let found = block_at(&blocks, g.rect, prev.and_then(|(_, b, _)| b), &mut steps); + incomplete |= steps == 0; + let line = usize::try_from(line).unwrap_or(usize::MAX); + if let Some((prev_line, prev_block, hyphen)) = prev { + if line != prev_line { + if found == prev_block && found.is_some() { + // A paragraph wrapping: one space, none after a hyphen. + if !hyphen { + b.space(); + } + } else if found.is_none() && prev_block.is_none() { + b.line_break(); + } else { + b.paragraph(); + } + } else if text.space_before.get(i).copied().unwrap_or(false) { + b.space(); + } + } + let style = found.and_then(|k| blocks.get(k)).map(|s| &s.style); + if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) { + break; + } + prev = Some((line, found, g.text.ends_with('-'))); + } + let text = b.finish(); + Ok(RichCopy { html: clip_html(&text), text, styles_incomplete: incomplete }) + } + + /// `page`'s reading blocks in view space with their style, and whether reading stopped at + /// its limits. + fn styled_blocks(&self, page: usize) -> (Vec, bool) { + let (Some(editor), Some(info)) = (self.editor.as_ref(), self.info.pages.get(page)) else { return (Vec::new(), false) }; + let Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) }; + let unit = display_unit(info); + let blocks = reading + .blocks + .into_iter() + .filter(|b| !b.text.trim().is_empty()) + .map(|b| { + let (bold, italic) = weight_and_slant(&b.base_font); + let style = ClipStyle { + family: pdfcraft_export::font_family(&b.base_font), + size: b.size * unit, + bold: b.bold || bold, + italic: b.italic || italic, + color: b.color, + }; + StyledBlock { rect: view_rect(info, b.rect), style } + }) + .collect(); + (blocks, reading.truncated) + } +} + +/// A user-space box as a view-space box (points from the displayed page's top-left, y down). +fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] +} + +/// The block whose box holds the centre of `glyph` (view space): `hint` first, then the +/// smallest that holds it. Each block tested costs a step; with none left, no block. +fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: &mut usize) -> Option { + let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0); + let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK; + let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]); + if let Some(k) = hint + && *steps > 0 + { + *steps -= 1; + if blocks.get(k).is_some_and(|b| holds(&b.rect)) { + return Some(k); + } + } + let mut best: Option<(usize, f32)> = None; + for (k, b) in blocks.iter().enumerate() { + if *steps == 0 { + return None; + } + *steps -= 1; + if holds(&b.rect) && best.is_none_or(|(_, a)| area(&b.rect) < a) { + best = Some((k, area(&b.rect))); + } + } + best.map(|(k, _)| k) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::Session; + + /// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red + /// word on the body's first line. + fn styled_page() -> Vec { + let content = b"BT /F1 24 Tf 20 250 Td (Title) Tj ET \ + BT /F2 12 Tf 20 200 Td (Body text) Tj ET \ + BT 1 0 0 rg /F2 12 Tf 90 200 Td (red) Tj ET \ + BT /F2 12 Tf 20 186 Td (a>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .build() + } + + fn page_text(s: &Session, id: crate::DocId) -> Arc { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + r.render(request).text.unwrap() + } + + #[test] + fn copy_keeps_headings_body_and_colour_as_html() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap(); + let text = page_text(&s, id); + let all: Vec = (0..text.glyphs.len()).collect(); + let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap(); + let html = ©.html; + assert!(!copy.styles_incomplete); + assert!( + html.starts_with("

Title

\n"), + "{html}" + ); + assert!(html.contains("font-size:12pt\">Body text "), "{html}"); + assert!(html.contains("font-size:12pt;color:#FF0000\">red"), "{html}"); + assert!(html.contains("a<b & c"), "{html}"); + assert!(!html.contains("= 2, "{html}"); + // The heading and the body are separate paragraphs; the red word shares the body's. + let body = copy.text.paragraphs.iter().find(|p| p.iter().any(|s| s.text.contains("Body"))).unwrap(); + assert!(body.iter().any(|s| s.text == "red" && s.style.color == [1.0, 0.0, 0.0]), "{body:?}"); + + // A part of the selection: only those glyphs. + let part = s.get(id).unwrap().rich_copy(0, &text, &all[..5]).unwrap(); + assert_eq!(part.text.paragraphs.len(), 1); + assert!(part.html.contains("Title"), "{}", part.html); + // Nothing selected, and indices past the text: an empty copy, not a panic. + let none = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap(); + assert!(none.html.is_empty() && none.text.paragraphs.is_empty()); + let past = s.get(id).unwrap().rich_copy(7, &text, &[usize::MAX, text.glyphs.len()]).unwrap(); + assert!(past.html.is_empty()); + } + + #[test] + fn copy_with_formatting_is_refused_when_copying_is_withheld() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: 0b0100, // print only + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, bytes, None).unwrap(); + let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() }; + let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err(); + assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}"); + } +} diff --git a/crates/export/src/clip.rs b/crates/export/src/clip.rs new file mode 100644 index 0000000000..0b214c2bcd --- /dev/null +++ b/crates/export/src/clip.rs @@ -0,0 +1,322 @@ +//! Copy with formatting: selected text as paragraphs of styled spans, written as an HTML +//! fragment for the clipboard (inline styles, so a word processor keeps font, size, weight, +//! slant and colour when it pastes). + +use crate::{esc, hex}; + +/// Spans kept for one copy; text past it is left out and [`ClipText::truncated`] is set. +pub const MAX_CLIP_SPANS: usize = 20_000; +/// Text bytes kept for one copy (before escaping). +pub const MAX_CLIP_TEXT_BYTES: usize = 2 << 20; +/// The HTML written for one copy never passes this many bytes. +pub const MAX_CLIP_HTML_BYTES: usize = 16 << 20; +/// A family name longer than this is not a family name. +const MAX_FAMILY_CHARS: usize = 64; + +/// How a span looks. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ClipStyle { + /// The family a word processor knows ([`font_family`]); `None` keeps the target's own. + pub family: Option, + /// Size in points; zero or not finite leaves the target's size. + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Fill colour as RGB in 0..=1. + pub color: [f64; 3], +} + +/// A run of text in one style. +#[derive(Clone, Debug, PartialEq)] +pub struct ClipSpan { + pub text: String, + pub style: ClipStyle, +} + +/// The copied text: paragraphs of spans, and whether a cap left some out. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ClipText { + pub paragraphs: Vec>, + pub truncated: bool, +} + +/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and +/// stopping at the caps. +#[derive(Debug, Default)] +pub struct ClipBuilder { + out: ClipText, + /// The separator owed before the next piece in this paragraph (a space or a line break). + pending: Option, + new_paragraph: bool, + spans: usize, + bytes: usize, +} + +impl ClipBuilder { + pub fn new() -> Self { + Self { new_paragraph: true, ..Self::default() } + } + + /// A word space before the next piece (dropped at the start of a paragraph). + pub fn space(&mut self) { + self.pending.get_or_insert(' '); + } + + /// A line break inside the paragraph before the next piece. + pub fn line_break(&mut self) { + self.pending = Some('\n'); + } + + /// The next piece starts a new paragraph. + pub fn paragraph(&mut self) { + self.new_paragraph = true; + self.pending = None; + } + + /// Append `text` in `style`. Returns `false` once a cap is reached; nothing more is kept. + pub fn push(&mut self, text: &str, style: &ClipStyle) -> bool { + if self.out.truncated { + return false; + } + if text.is_empty() { + return true; + } + let sep = if self.new_paragraph { None } else { self.pending }; + let need = text.len().saturating_add(sep.map_or(0, char::len_utf8)); + let Some(bytes) = self.bytes.checked_add(need).filter(|b| *b <= MAX_CLIP_TEXT_BYTES) else { + self.out.truncated = true; + return false; + }; + if self.new_paragraph { + self.out.paragraphs.push(Vec::new()); + self.new_paragraph = false; + } + self.pending = None; + let Some(spans) = self.out.paragraphs.last_mut() else { return false }; + match spans.last_mut() { + Some(last) if last.style == *style => { + last.text.extend(sep); + last.text.push_str(text); + } + last => { + if self.spans >= MAX_CLIP_SPANS { + self.out.truncated = true; + return false; + } + // A separator belongs to the span before it, so a new style starts at its text. + if let (Some(last), Some(sep)) = (last, sep) { + last.text.push(sep); + } + spans.push(ClipSpan { text: text.to_owned(), style: style.clone() }); + self.spans += 1; + } + } + self.bytes = bytes; + true + } + + pub fn finish(self) -> ClipText { + self.out + } +} + +/// The family a word processor knows, from a font's `/BaseFont`: subset tag and style suffix +/// removed, `PSMT`/`MT`/`PS` dropped, camel case spaced (`ABCDEF+TimesNewRomanPSMT` → +/// `Times New Roman`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so the name +/// cannot leave its CSS string. `None` when nothing is left. +pub fn font_family(base_font: &str) -> Option { + let name = match base_font.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base_font, + }; + let family = name.split_once([',', '-']).map_or(name, |(family, _)| family); + let family = family.strip_suffix("PSMT").or_else(|| family.strip_suffix("MT")).or_else(|| family.strip_suffix("PS")).unwrap_or(family); + let mut spaced = String::with_capacity(family.len().min(MAX_FAMILY_CHARS * 2)); + let mut prev_lower = false; + for c in family.chars().take(MAX_FAMILY_CHARS) { + if prev_lower && c.is_ascii_uppercase() { + spaced.push(' '); + } + prev_lower = c.is_ascii_lowercase(); + spaced.push(c); + } + safe_family(&spaced) +} + +/// `family` with only letters, digits, spaces, `-`, `_` and `.`, so it cannot leave a CSS +/// string; `None` when nothing is left. +fn safe_family(family: &str) -> Option { + let kept: String = family.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).take(MAX_FAMILY_CHARS * 2).collect(); + let kept = kept.trim(); + (!kept.is_empty()).then(|| kept.to_owned()) +} + +/// The copied text as an HTML fragment: one `

` per paragraph, a `` per run with its +/// family, size and colour inline, bold as `` and italic as `` (so a target that +/// drops style attributes still keeps them). Text is escaped; a line break is `
` and a run +/// of spaces keeps its width. Never longer than [`MAX_CLIP_HTML_BYTES`]: a span that would pass +/// it ends the fragment. +pub fn clip_html(text: &ClipText) -> String { + const OPEN: &str = "

"; + const CLOSE: &str = "

\n"; + let mut out = String::new(); + for spans in &text.paragraphs { + let mut p = String::from(OPEN); + for span in spans { + let s = span_html(span); + if out.len().saturating_add(p.len()).saturating_add(s.len()).saturating_add(CLOSE.len()) > MAX_CLIP_HTML_BYTES { + if p.len() > OPEN.len() { + out.push_str(&p); + out.push_str(CLOSE); + } + return out; + } + p.push_str(&s); + } + p.push_str(CLOSE); + out.push_str(&p); + } + out +} + +fn span_html(span: &ClipSpan) -> String { + let st = &span.style; + let mut css = Vec::new(); + if let Some(f) = st.family.as_deref().and_then(safe_family) { + css.push(format!("font-family:'{f}'")); + } + if st.size.is_finite() && st.size > 0.0 { + css.push(format!("font-size:{}pt", num(st.size))); + } + if let Some(h) = hex(st.color) { + css.push(format!("color:#{h}")); + } + let mut body = text_html(&span.text); + if st.italic { + body = format!("{body}"); + } + if st.bold { + body = format!("{body}"); + } + if css.is_empty() { body } else { format!("{body}", esc(&css.join(";"))) } +} + +/// Escape for HTML text (control characters dropped by [`esc`]); `\n` becomes `
` and a +/// run of spaces keeps its width with ` `. +fn text_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut prev_space = false; + for (k, line) in text.split('\n').enumerate() { + if k > 0 { + out.push_str("
"); + prev_space = false; + } + for c in line.chars() { + if c == ' ' && prev_space { + out.push_str(" "); + } else { + out.push_str(&esc(c.encode_utf8(&mut [0; 4]))); + } + prev_space = c == ' '; + } + } + out +} + +/// A size with at most one decimal and no trailing `.0`. +fn num(v: f64) -> String { + let r = (v * 10.0).round() / 10.0; + if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle { + ClipStyle { family: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color } + } + + #[test] + fn heading_body_and_coloured_span_keep_their_formatting() { + let mut b = ClipBuilder::new(); + let heading = style(24.0, true, false, [0.0; 3]); + let body = style(11.5, false, false, [0.0; 3]); + let red = style(11.5, false, true, [1.0, 0.0, 0.0]); + b.push("Title", &heading); + b.paragraph(); + b.push("Body", &body); + b.space(); + b.push("text", &body); + b.space(); + b.push("red", &red); + let clip = b.finish(); + assert_eq!(clip.paragraphs.len(), 2); + assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}"); + let html = clip_html(&clip); + assert_eq!( + html, + "

Title

\n\ +

Body text \ + red

\n" + ); + } + + #[test] + fn special_characters_are_escaped_and_cannot_leave_the_style() { + let st = ClipStyle { family: Some("Evil';}