M1: incremental saves are allocated once at their exact size (#402)

`write_incremental` copied the original into a buffer with 4 KB to spare,
then appended the revision. A revision above 4 KB (rotating a few dozen pages,
an image, a long comment thread) outgrew it. The buffer was then reallocated
at twice the size: the whole original was copied a second time, and the
result kept up to the original's size again unused. The engine keeps that
result as the document's working bytes, shared with the renderer and handed
to Save, so the unused half stayed alive. A 100 MB document held a 200 MB
allocation after one such edit, and still did after saving.

The revision is now written on its own, with offsets counted from where it
will start. The result is allocated once at exactly the original's size plus
the revision's, and filled in one pass. The output is byte for byte what it
was before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cyfung1031
2026-10-09 09:16:01 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d6c2a694ec
commit 3a35a7d6ac
2 changed files with 47 additions and 14 deletions
+21 -13
View File
@@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let mut doc = doc.clone();
stamp_mod_date(&mut doc, opts);
let original = doc.bytes().clone();
let mut out = Vec::with_capacity(original.len() + 4096);
out.extend_from_slice(&original);
if !matches!(out.last(), Some(b'\n' | b'\r')) {
out.push(b'\n');
}
// The revision is written on its own and joined to the original at the end, so the result is
// allocated once at its exact size: growing a buffer that holds a large original past a
// guessed capacity would copy the whole file again and keep up to as much again unused.
let newline = !matches!(original.last(), Some(b'\n' | b'\r'));
let base = (original.len() + usize::from(newline)) as u64;
let mut out = Vec::new();
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
for (num, generation, obj) in doc.overlay_entries().collect::<Vec<_>>() {
match obj {
Some(o) => {
offsets.insert(num, (out.len() as u64, generation, true));
offsets.insert(num, (base + out.len() as u64, generation, true));
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
}
None => {
@@ -210,16 +211,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
if as_stream {
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
write_xref_stream(&mut out, &mut trailer, rows, size);
write_xref_stream(&mut out, base, &mut trailer, rows, size);
} else {
trailer.set(b"Size".to_vec(), Object::Int(size as i64));
let xref_at = out.len();
let xref_at = base + out.len() as u64;
write_xref_table(&mut out, &offsets, prev.is_none());
out.extend_from_slice(b"trailer\n");
write_dict(&trailer, &mut out);
let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
}
Ok(out)
let mut file = Vec::with_capacity(original.len() + usize::from(newline) + out.len());
file.extend_from_slice(&original);
if newline {
file.push(b'\n');
}
file.extend_from_slice(&out);
Ok(file)
}
/// Write only reachable objects, renumbered from 1, with a classic cross-reference table.
@@ -317,7 +324,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
ensure_id(&mut trailer, opts, doc.bytes());
}
if opts.object_streams {
write_xref_stream(&mut out, &mut trailer, rows, next);
write_xref_stream(&mut out, 0, &mut trailer, rows, next);
} else {
trailer.set(b"Size".to_vec(), Object::Int(next as i64));
let offsets: BTreeMap<u32, (u64, u16, bool)> =
@@ -374,9 +381,10 @@ fn write_xref_table(out: &mut Vec<u8>, offsets: &BTreeMap<u32, (u64, u16, bool)>
}
/// A cross-reference stream section (§7.5.8) that takes object number `num`, ending the file.
/// Rows are Flate-compressed with the PNG Up predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = out.len() as u64;
/// `out` holds the file from byte offset `base` on. Rows are Flate-compressed with the PNG Up
/// predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, base: u64, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = base + out.len() as u64;
rows.insert(num, Row::InFile(xref_at, 0));
if trailer.get(b"Prev").is_none() {
rows.entry(0).or_insert(Row::Free(65535));
+26 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
/// Four pages, a shared font, document info with a non-ASCII title.
fn fixture() -> Vec<u8> {
@@ -87,6 +87,31 @@ fn incremental_saves_stack_on_an_xref_stream_file() {
assert_eq!(hayro_pages(&updated), 4);
}
/// The revision is joined to the original once, at its exact size. A revision of more than a few
/// kilobytes used to outgrow the buffer: the whole original was copied a second time, and the
/// result kept up to as much again unused for as long as it was the document's working bytes.
#[test]
fn incremental_saves_are_allocated_at_their_exact_size() {
let table = Arc::new(fixture());
let stream = Arc::new(write_full(&Document::open(table.clone()).unwrap(), &SaveOptions::default()).unwrap());
let no_newline = Arc::new(fixture().trim_ascii_end().to_vec());
for original in [table, stream, no_newline] {
let mut doc = Document::open(original.clone()).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 updated = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(updated.capacity(), updated.len());
assert!(updated.starts_with(&original), "an incremental save only appends");
let back = Document::open(Arc::new(updated.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
match &*back.get(added) {
Object::Stream(s) => assert_eq!(*s.raw, data),
other => panic!("{other:?}"),
}
assert_eq!(hayro_pages(&updated), 4);
}
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {