mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d6c2a694ec
commit
3a35a7d6ac
+21
-13
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user