diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 858a306752..73311d2289 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, 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 = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries().collect::>() { 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, 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, 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 = @@ -374,9 +381,10 @@ fn write_xref_table(out: &mut Vec, offsets: &BTreeMap } /// 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, trailer: &mut Dict, mut rows: BTreeMap, 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, base: u64, trailer: &mut Dict, mut rows: BTreeMap, 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)); diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index bfe85882f0..41158f10db 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -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 { @@ -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 = (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 {