Hash PDF page contents without caching a decoded copy per page

Fixes #3570

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-24 01:43:59 +07:00
committed by bflatastic
co-authored by Claude Haiku 5.5
parent 07762956d2
commit 58d94f6122
2 changed files with 91 additions and 1 deletions
+12 -1
View File
@@ -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::<Stream<'_>>(b"Contents") {
h = fnv(h, stream.decoded().as_deref().unwrap_or_default());
} else if let Some(array) = contents.get::<Array<'_>>(b"Contents") {
for stream in array.iter::<Stream<'_>>() {
if let Ok(data) = stream.decoded() {
h = fnv(fnv(h, &data), b" ");
}
}
}
}
format!("{h:016x}")
}
+79
View File
@@ -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<u8> {
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::<String>()),
"<< /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");
}