diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 979a2de1a..9b5600829 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -137,7 +137,18 @@ fn page_hash(pdf: &Pdf) -> String { for page in pdf.pages().iter() { let b = page.media_box(); h = fnv(h, format!("[{} {} {} {}]", b.x0, b.y0, b.x1, b.y1).as_bytes()); - h = fnv(h, page.page_stream().unwrap_or_default()); + // Decode into a temporary rather than through `Page::page_stream`, which keeps every + // page's copy for as long as the PDF lives, even when pages share one stream. + let contents = page.raw(); + if let Some(stream) = contents.get::>(b"Contents") { + h = fnv(h, stream.decoded().as_deref().unwrap_or_default()); + } else if let Some(array) = contents.get::>(b"Contents") { + for stream in array.iter::>() { + if let Ok(data) = stream.decoded() { + h = fnv(fnv(h, &data), b" "); + } + } + } } format!("{h:016x}") } diff --git a/crates/pdf/tests/editing_memory.rs b/crates/pdf/tests/editing_memory.rs new file mode 100644 index 000000000..ee8e5d916 --- /dev/null +++ b/crates/pdf/tests/editing_memory.rs @@ -0,0 +1,79 @@ +//! Opening a PDF whose pages share one large content stream, with an editing file naming a page +//! hash, keeps a working set that doesn't grow with the page count. One test in its own process, +//! so the allocator's peak is this test's. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +// A counting allocator forwards to the system one. +#![allow(unsafe_code)] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use vectorcraft_pdf::{EDITING_FILE, editing_with}; + +static LIVE: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +struct Counting; + +fn grew(n: usize) { + PEAK.fetch_max(LIVE.fetch_add(n, Relaxed) + n, Relaxed); +} + +// SAFETY: every call is forwarded to the system allocator unchanged. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + grew(l.size()); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LIVE.fetch_sub(l.size(), Relaxed); + grew(new); + unsafe { System.realloc(p, l, new) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +fn raw_pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for at in offsets { + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +#[test] +fn pages_sharing_one_stream_are_hashed_without_keeping_a_copy_each() { + const PAGES: usize = 12; + const STREAM: usize = 4 << 20; + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {PAGES} >>", (0..PAGES).map(|i| format!("{} 0 R ", i + 7)).collect::()), + "<< /Names [(a) 4 0 R] >>".to_string(), + format!("<< /Type /Filespec /UF ({EDITING_FILE}) /Desc (VectorCraft editing data; page hash 0000000000000000) /EF << /F 5 0 R >> >>"), + "<< /Length 5 >>\nstream\nhello\nendstream".to_string(), + format!("<< /Length {STREAM} >>\nstream\n{}\nendstream", "q ".repeat(STREAM / 2)), + ]; + objects.extend((0..PAGES).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 6 0 R >>".to_string())); + let bytes = raw_pdf(&objects); + let base = LIVE.load(Relaxed); + PEAK.store(base, Relaxed); + let e = editing_with(&bytes, None).unwrap(); + let extra = PEAK.load(Relaxed) - base; + assert_eq!(e.data, b"hello"); + assert!(extra < 4 * STREAM, "peak {extra} bytes above the input: each page kept its own decoded copy"); +}