Files
vectorcraft/crates/pdf/src/patch.rs
T
bflatasticandClaude Opus 5.5 8961824876 M4.86: PDF page thumbnails and fast web view (linearised files)
Embed Page Thumbnails gives every page a /Thumb image: the page drawn on
white, 106 px on its long side, without the layers the page leaves out
(the engine draws them; vectorcraft_pdf::page_areas says what each page
shows; PDF/X files get grey ones). Optimize for Fast Web View writes a
linearised file: the objects are put in linearised order and numbering,
encrypted when a password asks (the encryption dictionary goes with the
catalog, PDF layers' groups too), then laid out with the linearization
dictionary, both cross-reference tables and the page offset and shared
object hint tables (the hint stream encrypted like any other). Their
"not applied yet" warnings are gone. Reading the objects back uses the
lexer of encrypt.rs (syntax.rs adds what rewriting needs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00

204 lines
8.6 KiB
Rust

//! Edits to a PDF the export wrote (uncompressed objects, one cross-reference table starting at
//! object 0): bytes replaced or inserted inside objects, new objects and trailer entries. The
//! cross-reference table is written again to match.
use crate::PdfError;
use crate::lab_spot::{find, rfind, xref_offset};
/// The cross-reference table of a written PDF and its trailer.
pub(crate) struct Xref {
/// Where the table (`xref`) starts.
at: usize,
/// Where its first 20-byte entry starts.
entries: usize,
/// Number of entries (the trailer's `/Size`).
count: usize,
/// The trailer dictionary, `<<` to `>>` included.
trailer: (usize, usize),
}
/// Most bytes of a number in PDF syntax read here.
const MAX_DIGITS: usize = 10;
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
pub(crate) fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
let digits = pdf.get(at..)?.iter().take(MAX_DIGITS + 1).take_while(|b| b.is_ascii_digit()).count();
if digits == 0 || digits > MAX_DIGITS {
return None;
}
Some((std::str::from_utf8(pdf.get(at..at + digits)?).ok()?.parse().ok()?, at + digits))
}
impl Xref {
/// The table `startxref` points at; `None` for files written another way.
pub fn read(pdf: &[u8]) -> Option<Self> {
let at = xref_offset(pdf)?;
let (first, after) = number(pdf, at + 4)?;
let (count, after) = number(pdf, after)?;
let entries = find(pdf, b"\n", after)? + 1;
let table_end = entries.checked_add(count.checked_mul(20)?)?;
if first != 0 || count == 0 || table_end > pdf.len() {
return None;
}
let start = find(pdf, b"<<", find(pdf, b"trailer", table_end)?)?;
let end = rfind(pdf.get(start..rfind(pdf, b"startxref")?)?, b">>")? + start + 2;
Some(Self { at, entries, count, trailer: (start, end) })
}
/// Number of entries (objects `0..count`).
pub fn count(&self) -> usize {
self.count
}
/// Where in-use object `n` starts.
pub fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
let entry = pdf.get(e..e + 20)?;
(entry.get(17) == Some(&b'n')).then_some(())?;
number(entry, 0).map(|(off, _)| off)
}
/// The in-use objects: number and offset, in number order.
pub fn objects(&self, pdf: &[u8]) -> Vec<(u32, usize)> {
(1..u32::try_from(self.count).unwrap_or(u32::MAX)).filter_map(|n| Some((n, self.offset(pdf, n)?))).collect()
}
/// Where the table (`xref`) starts.
pub fn at(&self) -> usize {
self.at
}
/// Where the trailer dictionary starts (its `<<`).
pub fn trailer_at(&self) -> usize {
self.trailer.0
}
/// The object the trailer's `key` (`b"/Root"`) refers to.
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
let at = find(dict, key, 0)? + key.len();
// A key that only starts with `key` (`/Info` in `/InfoX`) isn't it.
if dict.get(at).is_some_and(|b| b.is_ascii_alphanumeric()) {
return None;
}
u32::try_from(number(dict, at)?.0).ok()
}
/// The contents of the dictionary object `n` is (just after its `<<`, up to its `endobj`).
pub fn dict(&self, pdf: &[u8], n: u32) -> Option<(usize, usize)> {
let off = self.offset(pdf, n)?;
let (num, after) = number(pdf, off)?;
let head = pdf.get(after..after + 6)?;
if num != n as usize || head != b" 0 obj" {
return None;
}
let mut at = after + 6;
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
(pdf.get(at..at + 2)? == b"<<").then_some(())?;
Some((at + 2, find(pdf, b"endobj", at)?))
}
}
/// A replacement of bytes `start..end` of the file.
type Edit = (usize, usize, Vec<u8>);
/// The edits of one PDF, applied together by [`Self::apply`].
pub(crate) struct Patch {
edits: Vec<Edit>,
/// New objects' bodies, numbered from the table's size on.
objects: Vec<Vec<u8>>,
/// Entries added to the trailer dictionary.
trailer: Vec<u8>,
size: usize,
}
impl Patch {
pub fn new(xref: &Xref) -> Self {
Self { edits: vec![], objects: vec![], trailer: vec![], size: xref.count }
}
/// Replace bytes `start..end` (inside the objects) with `with`; `start == end` inserts.
pub fn replace(&mut self, start: usize, end: usize, with: Vec<u8>) {
self.edits.push((start, end, with));
}
/// Add an object of `body` (a dictionary, array or stream with its dictionary) → its number.
pub fn add_object(&mut self, body: Vec<u8>) -> u32 {
self.objects.push(body);
u32::try_from(self.size + self.objects.len() - 1).unwrap_or(u32::MAX)
}
/// Add `entry` (`/Info 12 0 R`) to the trailer dictionary.
pub fn trailer_entry(&mut self, entry: &[u8]) {
self.trailer.extend_from_slice(entry);
}
/// `pdf` with the edits, new objects and trailer entries, and a cross-reference table that
/// matches. Refused when an edit overlaps another or reaches past the objects.
pub fn apply(mut self, pdf: &[u8], xref: &Xref) -> Result<Vec<u8>, PdfError> {
let bad = |why: &str| PdfError::Write(format!("can't edit the written PDF: {why}"));
self.edits.sort_by_key(|e| (e.0, e.1));
if self.edits.windows(2).any(|w| w[0].1 > w[1].0) || self.edits.iter().any(|e| e.0 > e.1 || e.1 > xref.at) {
return Err(bad("edits overlap"));
}
// Where a byte offset of the old file lands in the new one.
let edits = &self.edits;
let moved = |off: usize| -> usize {
edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize
};
let added: usize = self.edits.iter().map(|e| e.2.len()).sum::<usize>() + self.objects.iter().map(|o| o.len() + 32).sum::<usize>();
let mut out = Vec::with_capacity(pdf.len() + added + 64);
let mut at = 0;
for (start, end, new) in edits.iter() {
out.extend_from_slice(pdf.get(at..*start).ok_or_else(|| bad("bad edit"))?);
out.extend_from_slice(new);
at = *end;
}
out.extend_from_slice(pdf.get(at..xref.at).ok_or_else(|| bad("bad edit"))?);
let mut offsets = Vec::with_capacity(self.objects.len());
for (i, body) in self.objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", self.size + i).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
// The table: the old entries moved (free ones as they are), then the new objects'.
let table = out.len();
let size = self.size + self.objects.len();
out.extend_from_slice(format!("xref\n0 {size}\n").as_bytes());
let old = pdf.get(xref.entries..xref.entries + 20 * xref.count).ok_or_else(|| bad("bad cross-reference table"))?;
for e in old.chunks(20) {
match number(e, 0) {
Some((off, _)) if e.get(17) == Some(&b'n') => {
out.extend_from_slice(format!("{:010}", moved(off)).as_bytes());
out.extend_from_slice(e.get(10..).unwrap_or_default());
}
_ => out.extend_from_slice(e),
}
}
let eol = old.get(18..20).unwrap_or(b"\r\n");
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
out.extend_from_slice(eol);
}
// The trailer, with its size and the new entries.
let (ts, te) = xref.trailer;
let dict = pdf.get(ts..te.saturating_sub(2)).ok_or_else(|| bad("bad trailer"))?;
let size_at = find(dict, b"/Size", 0).ok_or_else(|| bad("the trailer has no size"))? + 5;
let (_, size_end) = number(dict, size_at).ok_or_else(|| bad("the trailer has no size"))?;
let (Some(head), Some(tail)) = (dict.get(..size_at), dict.get(size_end..)) else { return Err(bad("bad trailer")) };
out.extend_from_slice(b"trailer\n");
out.extend_from_slice(head);
out.extend_from_slice(format!(" {size}").as_bytes());
out.extend_from_slice(tail);
out.extend_from_slice(&self.trailer);
out.extend_from_slice(format!(">>\nstartxref\n{table}\n%%EOF").as_bytes());
Ok(out)
}
}