mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Haiku 5.5
parent
07762956d2
commit
58d94f6122
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user