From b98505c810b4eccc8a216c507dc9c984e36c34b5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:18:59 +0900 Subject: [PATCH] M1: full saves are allocated at their exact size (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/cos/src/writer.rs | 3 +++ crates/cos/tests/writer.rs | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 73311d22..418891b3 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -335,6 +335,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, 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) } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 41158f10..6ba65375 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -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 = (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 {