M1: full saves are allocated at their exact size (#523)

`write_full` can't know the output size in advance, so its buffer grows by
doubling and the result kept up to as much again unused: 28 % for a 100 MB
save, 99 % for a 5 MB one. The result is often kept for as long as a document
is open. It becomes the working bytes after edits that need a full rewrite
(repaired files, security changes, redaction) through `write_incremental`, and
it backs Save As ▸ optimized and every new document from Create, Combine,
Extract and Split.

`shrink_to_fit` at the end drops the unused capacity. The bytes are the same.
On macOS the shrink happens in place (2 µs for 100 MB, the buffer doesn't
move), and full-save times are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
cyfung1031andClaude Opus 5.5 authored and GitHub committed 2026-10-09 12:18:59 -04:00
1 parent 8ddcf63ff8
commit b98505c810
2 files changed
+26

No files matched your search

+3
View File
@@ -335,6 +335,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
write_dict(&trailer, &mut out);
let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
}
// The size isn't known until the end, and the buffer grew by doubling. The result is often
// kept as a document's working bytes for as long as it's open: drop the unused capacity.
out.shrink_to_fit();
Ok(out)
}
+23
View File
@@ -112,6 +112,29 @@ fn incremental_saves_are_allocated_at_their_exact_size() {
}
}
/// A full save grows its buffer as it writes, so it used to end with up to as much again unused,
/// kept alive with the document's working bytes. It now ends at its exact size.
#[test]
fn full_saves_are_allocated_at_their_exact_size() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()) }));
let root = doc.root().unwrap();
doc.update_dict(root, |d| d.set(b"Extra".to_vec(), Object::Ref(added))).unwrap();
for object_streams in [true, false] {
let full = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap();
assert_eq!(full.capacity(), full.len(), "object streams: {object_streams}");
let back = Document::open(Arc::new(full.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
let extra = back.dict(&Object::Ref(back.root().unwrap())).and_then(|d| d.get(b"Extra").cloned()).unwrap();
match &*back.resolve(&extra) {
Object::Stream(s) => assert_eq!(*s.raw, data),
other => panic!("{other:?}"),
}
assert_eq!(hayro_pages(&full), 4);
}
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {