Japanese vertical
+
Japanese
Hiragino Mincho ProN, writing-mode: vertical-rl
@@ -706,13 +718,13 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Chapter eight
Code & Images
A monospaced listing with syntax colouring, and the raster that it describes.
-
1 // Escape-time rendering of the Mandelbrot set into an RGB buffer.
+1 // Escape-time rendering of Seahorse Valley into an RGB buffer.
2 fn mandelbrot (width: u32 , height: u32 ) -> Vec <u8 > {
3 let mut rgb = Vec ::with_capacity ((width * height * 3 ) as usize );
4 for py in 0 ..height {
5 for px in 0 ..width {
-6 let c = (-2.2 + 3.0 * px as f64 / width as f64 ,
-7 -1.5 + 3.0 * py as f64 / height as f64 );
+6 let c = (CX + SPAN * (px as f64 / width as f64 - 0.5 ),
+7 CY - SPAN * (py as f64 / height as f64 - 0.5 ));
8 let (mut z, mut n) = ((0.0 , 0.0 ), 0 );
9 while n < MAX_ITER && z.0 * z.0 + z.1 * z.1 <= 4.0 {
10 z = (z.0 * z.0 - z.1 * z.1 + c.0 , 2.0 * z.0 * z.1 + c.1 );
diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml
new file mode 100644
index 000000000..a5623b211
--- /dev/null
+++ b/crates/cos/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "printcraft-cos"
+description = "PDF object layer: lazy tolerant parsing, copy-on-write edits, incremental and full saving"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+printcraft-filters = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+proptest = { workspace = true }
+hayro-syntax = { workspace = true }
diff --git a/crates/cos/examples/cosinfo.rs b/crates/cos/examples/cosinfo.rs
new file mode 100644
index 000000000..a35242b57
--- /dev/null
+++ b/crates/cos/examples/cosinfo.rs
@@ -0,0 +1,17 @@
+//! Print how a file was opened: revisions, repair notes, trailer. `cargo run -p printcraft-cos --example cosinfo f.pdf`
+fn main() {
+ let path = std::env::args().nth(1).expect("usage: cosinfo ");
+ let bytes = std::sync::Arc::new(std::fs::read(&path).expect("readable"));
+ match printcraft_cos::Document::open(bytes) {
+ Ok(doc) => {
+ println!("version {} revisions {:?}", doc.version(), doc.revisions());
+ for r in doc.repair_log() {
+ println!("repair: {r}");
+ }
+ let mut t = Vec::new();
+ printcraft_cos::serialize(&printcraft_cos::Object::Dict(doc.trailer().clone()), &mut t);
+ println!("trailer {}", String::from_utf8_lossy(&t));
+ }
+ Err(e) => println!("error: {e}"),
+ }
+}
diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs
new file mode 100644
index 000000000..39271981e
--- /dev/null
+++ b/crates/cos/src/document.rs
@@ -0,0 +1,708 @@
+//! Document: cross-reference data, lazy object loading, and a copy-on-write overlay of edits.
+//!
+//! `Document` is cheap to clone (the original bytes, xref index and parse caches are shared), so
+//! a clone is a snapshot: undo/redo and background jobs hold clones while the UI edits another.
+//! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites
+//! the file (full save).
+
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::sync::{Arc, Mutex};
+
+use crate::CosError;
+use crate::object::{Dict, ObjRef, Object};
+use crate::parser::{Lexer, is_whitespace, parse_indirect};
+
+thread_local! {
+ static LOAD_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Nesting counter for object loads on this thread (see `Document::try_get`).
+struct LoadGuard;
+
+impl LoadGuard {
+ const MAX: u32 = 32;
+
+ fn enter() -> Option {
+ LOAD_DEPTH.with(|d| {
+ if d.get() >= Self::MAX {
+ return None;
+ }
+ d.set(d.get() + 1);
+ Some(LoadGuard)
+ })
+ }
+}
+
+impl Drop for LoadGuard {
+ fn drop(&mut self) {
+ LOAD_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum XrefEntry {
+ Free { next_generation: u16 },
+ InFile { offset: u64, generation: u16 },
+ InStream { stream: u32, index: u32 },
+}
+
+/// One cross-reference section of the file (one per save; the first is the original).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Revision {
+ /// Offset of the xref section (`startxref` value).
+ pub xref_offset: u64,
+ /// `true` if the section is a cross-reference stream.
+ pub is_stream: bool,
+}
+
+#[derive(Clone, Debug)]
+struct ObjStm {
+ /// (object number, byte offset within `data`).
+ index: Vec<(u32, usize)>,
+ data: Arc>,
+}
+
+/// Where an overlay slot came from.
+#[derive(Clone, Debug, PartialEq)]
+enum Slot {
+ Set(u16, Arc),
+ Freed(u16),
+}
+
+#[derive(Clone)]
+pub struct Document {
+ data: Arc>,
+ entries: Arc>,
+ trailer: Dict,
+ revisions: Arc>,
+ repair_log: Arc>,
+ cache: Arc>>>,
+ objstms: Arc>>>,
+ overlay: BTreeMap,
+ next_num: u32,
+ /// Position of the `%PDF-` header (some files have junk before it).
+ header_offset: usize,
+ version: String,
+}
+
+impl std::fmt::Debug for Document {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Document").field("bytes", &self.data.len()).field("objects", &self.entries.len()).field("edits", &self.overlay.len()).finish()
+ }
+}
+
+impl Document {
+ /// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`.
+ pub fn open(data: Arc>) -> Result {
+ // Viewers accept files whose header is missing or damaged as long as the body looks
+ // like PDF; so do we (a note goes to the repair log).
+ let header = find(&data, b"%PDF-", 0, 1024);
+ let headerless = header.is_none();
+ if headerless && find(&data, b" obj", 0, 4096).is_none() {
+ return Err(CosError::NotPdf);
+ }
+ let header_offset = header.unwrap_or(0);
+ let version = match header {
+ Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(),
+ None => "1.4".to_string(),
+ };
+ let mut doc = Document {
+ data,
+ entries: Arc::new(BTreeMap::new()),
+ trailer: Dict::new(),
+ revisions: Arc::new(Vec::new()),
+ repair_log: Arc::new(Vec::new()),
+ cache: Arc::default(),
+ objstms: Arc::default(),
+ overlay: BTreeMap::new(),
+ next_num: 1,
+ header_offset,
+ version,
+ };
+ let mut log = Vec::new();
+ if headerless {
+ log.push("the %PDF- header is missing; reading the file as PDF 1.4".into());
+ }
+ let parsed = doc.read_xref_chain(&mut log);
+ let ok = match parsed {
+ Ok((entries, trailer, revisions)) => {
+ doc.entries = Arc::new(entries);
+ doc.trailer = trailer;
+ doc.revisions = Arc::new(revisions);
+ doc.root_is_catalog()
+ }
+ Err(e) => {
+ log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
+ false
+ }
+ };
+ if !ok {
+ if !doc.entries.is_empty() {
+ log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
+ }
+ doc.reconstruct(&mut log)?;
+ }
+ doc.repair_log = Arc::new(log);
+ if doc.trailer.contains(b"Encrypt") {
+ return Err(CosError::Encrypted);
+ }
+ let max = doc.entries.keys().next_back().copied().unwrap_or(0);
+ let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32;
+ doc.next_num = max.max(size.saturating_sub(1)) + 1;
+ Ok(doc)
+ }
+
+ pub fn bytes(&self) -> &Arc> {
+ &self.data
+ }
+
+ pub fn version(&self) -> &str {
+ &self.version
+ }
+
+ pub fn trailer(&self) -> &Dict {
+ &self.trailer
+ }
+
+ pub fn trailer_mut(&mut self) -> &mut Dict {
+ &mut self.trailer
+ }
+
+ pub fn revisions(&self) -> &[Revision] {
+ &self.revisions
+ }
+
+ pub fn repair_log(&self) -> &[String] {
+ &self.repair_log
+ }
+
+ /// `true` when there are unsaved edits.
+ pub fn is_modified(&self) -> bool {
+ !self.overlay.is_empty()
+ }
+
+ /// Object numbers changed since the document was opened (or last saved).
+ pub fn modified_objects(&self) -> Vec {
+ self.overlay.keys().copied().collect()
+ }
+
+ pub fn root(&self) -> Option {
+ self.trailer.reference(b"Root")
+ }
+
+ // ── object access ───────────────────────────────────────────────────────────────────────
+
+ /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10).
+ pub fn get(&self, r: ObjRef) -> Arc {
+ self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null))
+ }
+
+ /// Follow a reference if `o` is one; otherwise return `o` itself.
+ pub fn resolve(&self, o: &Object) -> Arc {
+ match o {
+ Object::Ref(r) => self.get(*r),
+ other => Arc::new(other.clone()),
+ }
+ }
+
+ /// Resolve and return a dictionary (a stream's dictionary counts).
+ pub fn dict(&self, o: &Object) -> Option {
+ self.resolve(o).as_dict().cloned()
+ }
+
+ pub fn try_get(&self, num: u32) -> Result, CosError> {
+ match self.overlay.get(&num) {
+ Some(Slot::Set(_, o)) => return Ok(o.clone()),
+ Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
+ None => {}
+ }
+ if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
+ return Ok(o.clone());
+ }
+ // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
+ // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
+ let _guard =
+ LoadGuard::enter().ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
+ let obj = self.load(num, 0)?;
+ let obj = Arc::new(obj);
+ self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
+ Ok(obj)
+ }
+
+ fn load(&self, num: u32, depth: u32) -> Result {
+ if depth > 16 {
+ return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() });
+ }
+ match self.entries.get(&num) {
+ None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
+ Some(XrefEntry::InFile { offset, .. }) => {
+ let resolve = |r: ObjRef| self.try_get(r.num).ok().and_then(|o| o.as_int());
+ let off = *offset as usize;
+ match parse_indirect(&self.data, off, &resolve) {
+ Ok((id, o)) if id.num == num => Ok(o),
+ // Offsets relative to a shifted header, or simply wrong: try both fixes.
+ _ => match parse_indirect(&self.data, off + self.header_offset, &resolve) {
+ Ok((id, o)) if id.num == num => Ok(o),
+ _ => self.scan_for(num).ok_or(CosError::MissingObject(num)),
+ },
+ }
+ }
+ Some(XrefEntry::InStream { stream, index }) => {
+ let stm = self.objstm(*stream)?;
+ let (n, off) = stm
+ .index
+ .get(*index as usize)
+ .copied()
+ .or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied())
+ .ok_or(CosError::MissingObject(num))?;
+ let off =
+ if n == num { off } else { stm.index.iter().find(|(n, _)| *n == num).map(|(_, o)| *o).ok_or(CosError::MissingObject(num))? };
+ Lexer::new(&stm.data, off).object()
+ }
+ }
+ }
+
+ fn objstm(&self, num: u32) -> Result, CosError> {
+ if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
+ return Ok(s.clone());
+ }
+ let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
+ let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
+ let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
+ let data = s.decoded()?;
+ let mut lx = Lexer::new(&data, 0);
+ let mut index = Vec::with_capacity(n);
+ for _ in 0..n {
+ let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
+ index.push((onum.max(0) as u32, first + off.max(0) as usize));
+ }
+ let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
+ self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
+ Ok(stm)
+ }
+
+ /// Last-resort lookup: scan the file for `num G obj`.
+ fn scan_for(&self, num: u32) -> Option {
+ let needle = format!("{num} ");
+ let data = &self.data;
+ let mut found = None;
+ let mut i = 0;
+ while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
+ i = p + 1;
+ if p > 0 && !is_whitespace(data[p - 1]) {
+ continue;
+ }
+ if let Ok((id, o)) = parse_indirect(data, p, &|_| None)
+ && id.num == num
+ {
+ found = Some(o); // keep the last (newest) definition
+ }
+ }
+ found
+ }
+
+ // ── editing ─────────────────────────────────────────────────────────────────────────────
+
+ /// Replace an object (keeping its generation).
+ pub fn set(&mut self, r: ObjRef, obj: impl Into) {
+ self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj.into())));
+ if r.num >= self.next_num {
+ self.next_num = r.num + 1;
+ }
+ }
+
+ /// Add a new indirect object and return its reference.
+ pub fn add(&mut self, obj: impl Into) -> ObjRef {
+ let r = ObjRef::new(self.next_num, 0);
+ self.next_num += 1;
+ self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj.into())));
+ r
+ }
+
+ /// Delete an object (its number becomes free with the next generation).
+ pub fn free(&mut self, r: ObjRef) {
+ self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
+ }
+
+ /// Edit a dictionary (or stream dictionary) object in place.
+ pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> {
+ let mut obj = (*self.get(r)).clone();
+ let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?;
+ f(d);
+ self.set(r, obj);
+ Ok(())
+ }
+
+ /// Current generation of an object number.
+ pub fn generation(&self, num: u32) -> u16 {
+ match self.overlay.get(&num) {
+ Some(Slot::Set(g, _)) => *g,
+ Some(Slot::Freed(g)) => *g,
+ None => match self.entries.get(&num) {
+ Some(XrefEntry::InFile { generation, .. }) => *generation,
+ Some(XrefEntry::Free { next_generation }) => *next_generation,
+ _ => 0,
+ },
+ }
+ }
+
+ pub(crate) fn overlay_entries(&self) -> impl Iterator- >)> + '_ {
+ self.overlay.iter().map(|(n, s)| match s {
+ Slot::Set(g, o) => (*n, *g, Some(o.clone())),
+ Slot::Freed(g) => (*n, *g, None),
+ })
+ }
+
+ pub(crate) fn next_num(&self) -> u32 {
+ self.next_num
+ }
+
+ /// All object numbers known (file and overlay), excluding freed ones.
+ pub fn object_numbers(&self) -> Vec
{
+ let mut set: HashSet = self.entries.iter().filter(|(_, e)| !matches!(e, XrefEntry::Free { .. })).map(|(n, _)| *n).collect();
+ for (n, s) in &self.overlay {
+ match s {
+ Slot::Set(..) => set.insert(*n),
+ Slot::Freed(_) => set.remove(n),
+ };
+ }
+ let mut v: Vec = set.into_iter().collect();
+ v.sort_unstable();
+ v
+ }
+
+ /// Rebase this document on bytes written by the writer (after a save): edits become part of
+ /// the file and the overlay is cleared, so the next incremental save appends only new edits.
+ pub fn reopen_after_save(&self, bytes: Arc>) -> Result {
+ Document::open(bytes)
+ }
+
+ // ── cross-reference parsing ─────────────────────────────────────────────────────────────
+
+ fn root_is_catalog(&self) -> bool {
+ let Some(r) = self.root() else { return false };
+ matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
+ }
+
+ #[allow(clippy::type_complexity)]
+ fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec), CosError> {
+ let data = &self.data;
+ let tail_start = data.len().saturating_sub(4096);
+ let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
+ let mut lx = Lexer::new(data, sx + 9);
+ let first = match lx.object() {
+ Ok(Object::Int(n)) if n >= 0 => n as u64,
+ _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
+ };
+ let mut entries = BTreeMap::new();
+ let mut trailer: Option = None;
+ let mut revisions = Vec::new();
+ let mut visited = HashSet::new();
+ let mut next = Some(first);
+ while let Some(off) = next.take() {
+ if !visited.insert(off) || visited.len() > 10_000 {
+ log.push(format!("cross-reference chain loops at offset {off}; stopped"));
+ break;
+ }
+ let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut entries) {
+ Ok(v) => v,
+ Err(e) if self.header_offset > 0 => {
+ log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
+ self.read_section(off as usize + self.header_offset, &mut entries)?
+ }
+ Err(e) => {
+ if revisions.is_empty() {
+ return Err(e);
+ }
+ log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
+ break;
+ }
+ };
+ revisions.push(Revision { xref_offset: off, is_stream });
+ // Hybrid files: the /XRefStm entries supplement the table (§7.5.8.4).
+ if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0)
+ && let Err(e) = self.read_section(x as usize, &mut entries)
+ {
+ log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
+ }
+ next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
+ if trailer.is_none() {
+ trailer = Some(sect_trailer);
+ }
+ }
+ revisions.reverse();
+ let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
+ for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
+ trailer.remove(k);
+ }
+ Ok((entries, trailer, revisions))
+ }
+
+ /// Read one section at `off`, adding entries not already known (newer sections win).
+ fn read_section(&self, off: usize, entries: &mut BTreeMap) -> Result<(Dict, bool), CosError> {
+ let data = &self.data;
+ let mut lx = Lexer::new(data, off);
+ if lx.eat_keyword(b"xref") {
+ loop {
+ lx.skip_ws();
+ if lx.eat_keyword(b"trailer") {
+ let t = lx.object()?;
+ let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
+ return Ok((d, false));
+ }
+ let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
+ return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
+ };
+ if start < 0 || !(0..=10_000_000).contains(&count) {
+ return Err(CosError::Syntax { offset: lx.pos, detail: "implausible xref subsection".into() });
+ }
+ for i in 0..count {
+ lx.skip_ws();
+ let o = lx.token();
+ lx.skip_ws();
+ let g = lx.token();
+ lx.skip_ws();
+ let kind = lx.token();
+ let num = (start + i) as u32;
+ let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok());
+ let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0).min(u16::MAX as u32) as u16;
+ let entry = match (kind, offset) {
+ (b"n", Some(offset)) => XrefEntry::InFile { offset, generation },
+ (b"f", _) => XrefEntry::Free { next_generation: generation },
+ _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
+ };
+ // Offset 0 "in use" entries are a known producer bug: ignore them.
+ if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
+ continue;
+ }
+ entries.entry(num).or_insert(entry);
+ }
+ }
+ }
+ // Cross-reference stream.
+ let (_, obj) = parse_indirect(data, off, &|_| None)?;
+ let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
+ if s.dict.name(b"Type") != Some(b"XRef") {
+ return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
+ }
+ let w: Vec = s
+ .dict
+ .get(b"W")
+ .and_then(Object::as_array)
+ .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
+ .unwrap_or_default();
+ if w.len() < 3 {
+ return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
+ }
+ let size = s.dict.int(b"Size").unwrap_or(0).max(0);
+ let index: Vec = match s.dict.get(b"Index").and_then(Object::as_array) {
+ Some(a) => a.iter().filter_map(Object::as_int).collect(),
+ None => vec![0, size],
+ };
+ let raw = s.decoded()?;
+ let row = w[0] + w[1] + w[2];
+ if row == 0 {
+ return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
+ }
+ let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 {
+ if len == 0 {
+ return default;
+ }
+ r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64)
+ };
+ let mut rows = raw.chunks_exact(row);
+ for pair in index.chunks(2) {
+ let [start, count] = pair else { break };
+ for i in 0..(*count).max(0) {
+ let Some(r) = rows.next() else { break };
+ let t = field(r, 0, w[0], 1);
+ let a = field(r, w[0], w[1], 0);
+ let b = field(r, w[0] + w[1], w[2], 0);
+ let num = (*start + i).max(0) as u32;
+ let entry = match t {
+ 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
+ 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
+ 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
+ _ => continue, // reserved types are treated as null references (§7.5.8.3)
+ };
+ if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
+ continue;
+ }
+ entries.entry(num).or_insert(entry);
+ }
+ }
+ Ok((s.dict.clone(), true))
+ }
+
+ /// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
+ fn reconstruct(&mut self, log: &mut Vec) -> Result<(), CosError> {
+ let data = self.data.clone();
+ let mut entries: BTreeMap = BTreeMap::new();
+ let mut i = 0;
+ let mut objstms = Vec::new();
+ while i < data.len() {
+ // Object headers start at a line start (or after whitespace).
+ let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
+ if at_start && data[i].is_ascii_digit() {
+ let mut lx = Lexer::new(&data, i);
+ let n = lx.token();
+ lx.skip_ws();
+ let g = lx.token();
+ if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
+ let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::().ok());
+ let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0);
+ if let Some(num) = num {
+ entries.insert(num, XrefEntry::InFile { offset: i as u64, generation });
+ objstms.push(num);
+ }
+ i = lx.pos;
+ continue;
+ }
+ }
+ i += 1;
+ }
+ if entries.is_empty() {
+ return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
+ }
+ self.entries = Arc::new(entries.clone());
+ self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
+ // Objects inside object streams.
+ for num in objstms {
+ if let Ok(o) = self.try_get(num)
+ && let Object::Stream(s) = &*o
+ && s.dict.name(b"Type") == Some(b"ObjStm")
+ && let Ok(stm) = self.objstm(num)
+ {
+ for (idx, (inner, _)) in stm.index.iter().enumerate() {
+ entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
+ }
+ }
+ }
+ self.entries = Arc::new(entries);
+ self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
+ // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
+ let mut trailer = None;
+ let mut from = 0;
+ while let Some(p) = find(&data, b"trailer", from, data.len()) {
+ from = p + 7;
+ let mut lx = Lexer::new(&data, from);
+ if let Ok(Object::Dict(d)) = lx.object()
+ && d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
+ {
+ trailer = Some(d);
+ }
+ }
+ let trailer = match trailer {
+ Some(t) => t,
+ None => {
+ let catalog = self
+ .entries
+ .keys()
+ .copied()
+ .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog")));
+ let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) };
+ let mut t = Dict::new();
+ t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c))));
+ t
+ }
+ };
+ let mut trailer = trailer;
+ for k in [&b"Prev"[..], b"XRefStm"] {
+ trailer.remove(k);
+ }
+ self.trailer = trailer;
+ self.revisions = Arc::new(Vec::new());
+ log.push(format!("rebuilt cross-reference index from {} object headers", self.entries.len()));
+ Ok(())
+ }
+}
+
+pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option {
+ let to = to.min(hay.len());
+ if from >= to || needle.is_empty() || to - from < needle.len() {
+ return None;
+ }
+ hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
+}
+
+fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option {
+ if hay.len() < needle.len() {
+ return None;
+ }
+ (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
+ pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec {
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, b) in bodies.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
+ out
+ }
+
+ #[test]
+ fn self_referential_length_does_not_overflow() {
+ let bytes = build(
+ &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"],
+ "/Root 1 0 R",
+ );
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ // Either recovered via the endstream search or reported — but never a stack overflow.
+ let o = doc.get(ObjRef::new(3, 0));
+ if let Object::Stream(s) = o.as_ref() {
+ assert_eq!(s.raw.as_slice(), b"abc");
+ }
+ }
+
+ #[test]
+ fn mutually_referential_lengths_do_not_overflow() {
+ let bytes = build(
+ &[
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [] /Count 0 >>",
+ "<< /Length 4 0 R >>\nstream\nabc\nendstream",
+ "<< /Length 3 0 R >>\nstream\nxyz\nendstream",
+ ],
+ "/Root 1 0 R",
+ );
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ let _ = doc.get(ObjRef::new(3, 0));
+ let _ = doc.get(ObjRef::new(4, 0));
+ }
+
+ #[test]
+ fn reads_classic_revision_and_edits_are_copy_on_write() {
+ let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ assert_eq!(doc.revisions().len(), 1);
+ assert!(doc.repair_log().is_empty());
+ let mut edited = doc.clone();
+ edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
+ assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot");
+ assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
+ }
+
+ #[test]
+ fn damaged_xref_is_reconstructed() {
+ let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+ let n = bytes.len();
+ bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ assert!(doc.revisions().is_empty());
+ assert!(!doc.repair_log().is_empty());
+ assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
+ }
+}
diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs
new file mode 100644
index 000000000..63152d887
--- /dev/null
+++ b/crates/cos/src/lib.rs
@@ -0,0 +1,38 @@
+//! printcraft-cos — the PDF object layer (L1, standalone with `printcraft-filters`).
+//!
+//! Parses the COS object graph lazily and tolerantly, keeps edits in a copy-on-write overlay
+//! (cheap snapshots for undo), and writes documents back either incrementally (original bytes
+//! untouched) or as a full, garbage-collected rewrite. The PDF object graph *is* PrintCraft's
+//! document model (plan/architecture.md §5, ADR-0010).
+//!
+//! Status (M1 in progress): parsing of all xref forms, object streams, repair by scanning,
+//! incremental and full writing. Not yet: encryption (M1.6), object-stream / xref-stream output
+//! for full saves, linearization (M11).
+
+mod document;
+mod object;
+mod parser;
+mod writer;
+
+pub use document::{Document, Revision, XrefEntry};
+pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
+pub use parser::{Lexer, parse_indirect};
+pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
+
+#[derive(Debug, thiserror::Error, Clone, PartialEq)]
+pub enum CosError {
+ #[error("not a PDF file (no %PDF- header)")]
+ NotPdf,
+ #[error("syntax error at byte {offset}: {detail}")]
+ Syntax { offset: usize, detail: String },
+ #[error("object {0} is missing")]
+ MissingObject(u32),
+ #[error("object {0} is not a dictionary")]
+ NotADictionary(ObjRef),
+ #[error("stream data could not be decoded: {0}")]
+ Filter(String),
+ #[error("the document is encrypted; editing encrypted documents arrives with M1.6")]
+ Encrypted,
+ #[error("internal lock poisoned")]
+ Poisoned,
+}
diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs
new file mode 100644
index 000000000..01ad7824e
--- /dev/null
+++ b/crates/cos/src/object.rs
@@ -0,0 +1,386 @@
+//! The PDF object model (ISO 32000-2 §7.3).
+//!
+//! Dictionaries keep their key order so rewritten objects stay recognisable in diffs and
+//! byte-stable when re-serialized. Streams keep their *encoded* bytes; decoding is on demand.
+
+use std::sync::Arc;
+
+use printcraft_filters::{Filter, Params};
+
+use crate::CosError;
+
+/// An indirect reference: object number and generation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct ObjRef {
+ pub num: u32,
+ pub generation: u16,
+}
+
+impl ObjRef {
+ pub const fn new(num: u32, generation: u16) -> Self {
+ Self { num, generation }
+ }
+}
+
+impl std::fmt::Display for ObjRef {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{} {} R", self.num, self.generation)
+ }
+}
+
+/// A string object. `hex` records how it was written, so it is re-emitted the same way.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PdfString {
+ pub bytes: Vec,
+ pub hex: bool,
+}
+
+impl PdfString {
+ pub fn literal(bytes: impl Into>) -> Self {
+ Self { bytes: bytes.into(), hex: false }
+ }
+
+ /// A text string (ISO 32000-2 §7.9.2.2): PDFDocEncoding when possible, else UTF-16BE with BOM.
+ pub fn text(s: &str) -> Self {
+ if s.chars().all(|c| (' '..='~').contains(&c) || c == '\n' || c == '\r' || c == '\t') {
+ return Self::literal(s.as_bytes().to_vec());
+ }
+ let mut bytes = vec![0xFE, 0xFF];
+ for u in s.encode_utf16() {
+ bytes.extend_from_slice(&u.to_be_bytes());
+ }
+ Self { bytes, hex: false }
+ }
+
+ /// Decode a text string: UTF-16BE (BOM), UTF-8 (BOM, PDF 2.0) or PDFDocEncoding.
+ pub fn to_text(&self) -> String {
+ let b = &self.bytes;
+ if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
+ let units: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
+ return String::from_utf16_lossy(&units);
+ }
+ if b.len() >= 3 && b[..3] == [0xEF, 0xBB, 0xBF] {
+ return String::from_utf8_lossy(&b[3..]).into_owned();
+ }
+ b.iter().map(|&c| pdfdoc_char(c)).collect()
+ }
+}
+
+/// PDFDocEncoding → Unicode (ISO 32000-2 Annex D). Bytes 0x18–0x1F and 0x80–0xA0 differ from Latin-1.
+fn pdfdoc_char(c: u8) -> char {
+ const HIGH: [char; 33] = [
+ '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł',
+ 'œ', 'š', 'ž', '\u{FFFD}', '€',
+ ];
+ const LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜'];
+ match c {
+ 0x18..=0x1F => LOW[(c - 0x18) as usize],
+ 0x80..=0xA0 => HIGH[(c - 0x80) as usize],
+ _ => c as char,
+ }
+}
+
+/// A name object (without the leading `/`, with `#xx` escapes already decoded).
+pub type Name = Vec;
+
+/// An ordered dictionary.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Dict {
+ entries: Vec<(Name, Object)>,
+}
+
+impl Dict {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn get(&self, key: &[u8]) -> Option<&Object> {
+ self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
+ }
+
+ pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> {
+ self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
+ }
+
+ /// Insert or replace, keeping the position of an existing key.
+ pub fn set(&mut self, key: impl Into, value: impl Into) {
+ let key = key.into();
+ let value = value.into();
+ match self.entries.iter_mut().find(|(k, _)| *k == key) {
+ Some((_, v)) => *v = value,
+ None => self.entries.push((key, value)),
+ }
+ }
+
+ pub fn remove(&mut self, key: &[u8]) -> Option {
+ let i = self.entries.iter().position(|(k, _)| k == key)?;
+ Some(self.entries.remove(i).1)
+ }
+
+ pub fn contains(&self, key: &[u8]) -> bool {
+ self.get(key).is_some()
+ }
+
+ pub fn iter(&self) -> impl Iterator- {
+ self.entries.iter().map(|(k, v)| (k, v))
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ pub fn name(&self, key: &[u8]) -> Option<&[u8]> {
+ self.get(key).and_then(Object::as_name)
+ }
+
+ pub fn int(&self, key: &[u8]) -> Option
{
+ self.get(key).and_then(Object::as_int)
+ }
+
+ pub fn reference(&self, key: &[u8]) -> Option {
+ self.get(key).and_then(Object::as_ref)
+ }
+}
+
+impl FromIterator<(Name, Object)> for Dict {
+ fn from_iter>(iter: T) -> Self {
+ let mut d = Dict::new();
+ for (k, v) in iter {
+ d.set(k, v);
+ }
+ d
+ }
+}
+
+/// A stream: dictionary plus its data *as stored* (still filtered).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Stream {
+ pub dict: Dict,
+ pub raw: Arc>,
+}
+
+/// Upper bound for a single decoded stream (decompression-bomb defence).
+pub const MAX_DECODED: usize = 1 << 30;
+
+impl Stream {
+ /// A new stream from already-encoded bytes (`/Length` is set at write time).
+ pub fn from_raw(dict: Dict, raw: Vec) -> Self {
+ Self { dict, raw: Arc::new(raw) }
+ }
+
+ /// A new stream holding `data` compressed with Flate.
+ pub fn flate(mut dict: Dict, data: &[u8]) -> Self {
+ dict.set(b"Filter".to_vec(), Object::Name(b"FlateDecode".to_vec()));
+ dict.remove(b"DecodeParms");
+ Self { dict, raw: Arc::new(printcraft_filters::encode_flate(data)) }
+ }
+
+ /// The filter chain declared in the dictionary.
+ pub fn filters(&self) -> Vec<(Filter, Params)> {
+ let names: Vec<&[u8]> = match self.dict.get(b"Filter") {
+ Some(Object::Name(n)) => vec![n],
+ Some(Object::Array(a)) => a.iter().filter_map(Object::as_name).collect(),
+ _ => Vec::new(),
+ };
+ let parms: Vec> = match self.dict.get(b"DecodeParms") {
+ Some(Object::Dict(d)) => vec![Some(d)],
+ Some(Object::Array(a)) => a.iter().map(Object::as_dict).collect(),
+ _ => Vec::new(),
+ };
+ names
+ .into_iter()
+ .enumerate()
+ .map(|(i, n)| {
+ let p = parms.get(i).copied().flatten();
+ let get = |k: &[u8], d: i64| p.and_then(|p| p.int(k)).unwrap_or(d);
+ let params = Params {
+ predictor: get(b"Predictor", 1),
+ colors: get(b"Colors", 1),
+ bits_per_component: get(b"BitsPerComponent", 8),
+ columns: get(b"Columns", 1),
+ early_change: get(b"EarlyChange", 1),
+ };
+ (Filter::from_name(n), params)
+ })
+ .collect()
+ }
+
+ /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
+ /// decoded before the damage is returned). Streams with image codecs fail with
+ /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
+ pub fn decoded(&self) -> Result, CosError> {
+ let chain = self.filters();
+ if chain.is_empty() {
+ return Ok(self.raw.as_ref().clone());
+ }
+ printcraft_filters::decode_tolerant(&chain, &self.raw, MAX_DECODED).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
+ }
+
+ /// Decoded data; any corruption is an error.
+ pub fn decoded_strict(&self) -> Result, CosError> {
+ let chain = self.filters();
+ if chain.is_empty() {
+ return Ok(self.raw.as_ref().clone());
+ }
+ printcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Object {
+ Null,
+ Bool(bool),
+ Int(i64),
+ Real(f64),
+ String(PdfString),
+ Name(Name),
+ Array(Vec),
+ Dict(Dict),
+ Stream(Stream),
+ Ref(ObjRef),
+}
+
+impl Object {
+ pub fn name(n: &str) -> Self {
+ Object::Name(n.as_bytes().to_vec())
+ }
+
+ pub fn as_int(&self) -> Option {
+ match self {
+ Object::Int(i) => Some(*i),
+ Object::Real(r) if r.fract() == 0.0 && r.is_finite() => Some(*r as i64),
+ _ => None,
+ }
+ }
+
+ pub fn as_f64(&self) -> Option {
+ match self {
+ Object::Int(i) => Some(*i as f64),
+ Object::Real(r) => Some(*r),
+ _ => None,
+ }
+ }
+
+ pub fn as_name(&self) -> Option<&[u8]> {
+ match self {
+ Object::Name(n) => Some(n),
+ _ => None,
+ }
+ }
+
+ pub fn as_ref(&self) -> Option {
+ match self {
+ Object::Ref(r) => Some(*r),
+ _ => None,
+ }
+ }
+
+ pub fn as_dict(&self) -> Option<&Dict> {
+ match self {
+ Object::Dict(d) => Some(d),
+ Object::Stream(s) => Some(&s.dict),
+ _ => None,
+ }
+ }
+
+ pub fn as_dict_mut(&mut self) -> Option<&mut Dict> {
+ match self {
+ Object::Dict(d) => Some(d),
+ Object::Stream(s) => Some(&mut s.dict),
+ _ => None,
+ }
+ }
+
+ pub fn as_array(&self) -> Option<&Vec> {
+ match self {
+ Object::Array(a) => Some(a),
+ _ => None,
+ }
+ }
+
+ pub fn as_string(&self) -> Option<&PdfString> {
+ match self {
+ Object::String(s) => Some(s),
+ _ => None,
+ }
+ }
+}
+
+impl From for Object {
+ fn from(v: i64) -> Self {
+ Object::Int(v)
+ }
+}
+impl From for Object {
+ fn from(v: i32) -> Self {
+ Object::Int(v as i64)
+ }
+}
+impl From for Object {
+ fn from(v: f64) -> Self {
+ Object::Real(v)
+ }
+}
+impl From for Object {
+ fn from(v: bool) -> Self {
+ Object::Bool(v)
+ }
+}
+impl From for Object {
+ fn from(v: ObjRef) -> Self {
+ Object::Ref(v)
+ }
+}
+impl From for Object {
+ fn from(v: Dict) -> Self {
+ Object::Dict(v)
+ }
+}
+impl From> for Object {
+ fn from(v: Vec) -> Self {
+ Object::Array(v)
+ }
+}
+impl From for Object {
+ fn from(v: PdfString) -> Self {
+ Object::String(v)
+ }
+}
+impl From for Object {
+ fn from(v: Stream) -> Self {
+ Object::Stream(v)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn text_strings_round_trip() {
+ for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] {
+ assert_eq!(PdfString::text(s).to_text(), s);
+ }
+ assert!(!PdfString::text("abc").bytes.starts_with(&[0xFE, 0xFF]));
+ }
+
+ #[test]
+ fn pdfdoc_encoding_high_range() {
+ assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
+ }
+
+ #[test]
+ fn dict_preserves_order_and_replaces_in_place() {
+ let mut d = Dict::new();
+ d.set(b"B".to_vec(), 1);
+ d.set(b"A".to_vec(), 2);
+ d.set(b"B".to_vec(), 3);
+ let keys: Vec<_> = d.iter().map(|(k, _)| k.clone()).collect();
+ assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]);
+ assert_eq!(d.int(b"B"), Some(3));
+ }
+}
diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs
new file mode 100644
index 000000000..807471312
--- /dev/null
+++ b/crates/cos/src/parser.rs
@@ -0,0 +1,514 @@
+//! Lexer and object parser (ISO 32000-2 §7.2–7.3), tolerant of common real-world damage.
+//!
+//! The parser works on a byte slice and a position; it never allocates for skipped data and
+//! never panics on malformed input (every index is bounds-checked). Depth is limited to defend
+//! against pathological nesting.
+
+use crate::CosError;
+use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
+
+const MAX_DEPTH: usize = 128;
+
+pub(crate) fn is_whitespace(b: u8) -> bool {
+ matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ')
+}
+
+pub(crate) fn is_delimiter(b: u8) -> bool {
+ matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
+}
+
+fn is_regular(b: u8) -> bool {
+ !is_whitespace(b) && !is_delimiter(b)
+}
+
+/// A cursor over PDF bytes.
+pub struct Lexer<'a> {
+ pub data: &'a [u8],
+ pub pos: usize,
+}
+
+impl<'a> Lexer<'a> {
+ pub fn new(data: &'a [u8], pos: usize) -> Self {
+ Self { data, pos: pos.min(data.len()) }
+ }
+
+ fn peek(&self) -> Option {
+ self.data.get(self.pos).copied()
+ }
+
+ /// Skip whitespace and comments.
+ pub fn skip_ws(&mut self) {
+ while let Some(b) = self.peek() {
+ if is_whitespace(b) {
+ self.pos += 1;
+ } else if b == b'%' {
+ while let Some(c) = self.peek() {
+ if c == b'\n' || c == b'\r' {
+ break;
+ }
+ self.pos += 1;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+
+ /// Read a run of regular characters (a keyword or number).
+ pub fn token(&mut self) -> &'a [u8] {
+ let start = self.pos.min(self.data.len());
+ while self.peek().is_some_and(is_regular) {
+ self.pos += 1;
+ }
+ &self.data[start..self.pos]
+ }
+
+ /// `true` (and consumes it) if the next token is exactly `kw`.
+ pub fn eat_keyword(&mut self, kw: &[u8]) -> bool {
+ self.skip_ws();
+ let end = self.pos + kw.len();
+ if self.data.get(self.pos..end) == Some(kw) && self.data.get(end).is_none_or(|b| !is_regular(*b)) {
+ self.pos = end;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn err(&self, what: &str) -> CosError {
+ CosError::Syntax { offset: self.pos, detail: what.to_string() }
+ }
+
+ /// Parse one direct object. References `N G R` are recognised.
+ pub fn object(&mut self) -> Result {
+ self.object_depth(0)
+ }
+
+ fn object_depth(&mut self, depth: usize) -> Result {
+ if depth > MAX_DEPTH {
+ return Err(self.err("nesting too deep"));
+ }
+ self.skip_ws();
+ let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) };
+ match b {
+ b'/' => {
+ self.pos += 1;
+ Ok(Object::Name(self.name_body()))
+ }
+ b'(' => {
+ self.pos += 1;
+ Ok(Object::String(PdfString { bytes: self.literal_string()?, hex: false }))
+ }
+ b'<' if self.data.get(self.pos + 1) == Some(&b'<') => {
+ self.pos += 2;
+ Ok(Object::Dict(self.dict_body(depth)?))
+ }
+ b'<' => {
+ self.pos += 1;
+ Ok(Object::String(PdfString { bytes: self.hex_string()?, hex: true }))
+ }
+ b'[' => {
+ self.pos += 1;
+ let mut items = Vec::new();
+ loop {
+ self.skip_ws();
+ match self.peek() {
+ None => return Err(self.err("unterminated array")),
+ Some(b']') => {
+ self.pos += 1;
+ break;
+ }
+ _ => {
+ let before = self.pos;
+ match self.object_depth(depth + 1) {
+ Ok(o) => items.push(o),
+ // Tolerate junk inside arrays by skipping one byte.
+ Err(_) if self.pos == before => self.pos += 1,
+ Err(e) => return Err(e),
+ }
+ }
+ }
+ }
+ Ok(Object::Array(items))
+ }
+ b'+' | b'-' | b'.' | b'0'..=b'9' => self.number_or_ref(),
+ _ => {
+ let start = self.pos;
+ let tok = self.token();
+ match tok {
+ b"true" => Ok(Object::Bool(true)),
+ b"false" => Ok(Object::Bool(false)),
+ b"null" => Ok(Object::Null),
+ b"" => {
+ self.pos = start + 1;
+ Err(CosError::Syntax { offset: start, detail: format!("unexpected byte 0x{b:02x}") })
+ }
+ other => Err(CosError::Syntax { offset: start, detail: format!("unexpected keyword {:?}", String::from_utf8_lossy(other)) }),
+ }
+ }
+ }
+ }
+
+ fn name_body(&mut self) -> Vec {
+ let mut out = Vec::new();
+ while let Some(b) = self.peek() {
+ if !is_regular(b) {
+ break;
+ }
+ self.pos += 1;
+ if b == b'#'
+ && let (Some(h), Some(l)) = (self.data.get(self.pos).and_then(|c| hexval(*c)), self.data.get(self.pos + 1).and_then(|c| hexval(*c)))
+ {
+ out.push(h << 4 | l);
+ self.pos += 2;
+ continue;
+ }
+ out.push(b);
+ }
+ out
+ }
+
+ fn literal_string(&mut self) -> Result, CosError> {
+ let mut out = Vec::new();
+ let mut depth = 1usize;
+ while let Some(b) = self.peek() {
+ self.pos += 1;
+ match b {
+ b'(' => {
+ depth += 1;
+ out.push(b);
+ }
+ b')' => {
+ depth -= 1;
+ if depth == 0 {
+ return Ok(out);
+ }
+ out.push(b);
+ }
+ b'\\' => {
+ let Some(e) = self.peek() else { break };
+ self.pos += 1;
+ match e {
+ b'n' => out.push(b'\n'),
+ b'r' => out.push(b'\r'),
+ b't' => out.push(b'\t'),
+ b'b' => out.push(8),
+ b'f' => out.push(12),
+ b'(' | b')' | b'\\' => out.push(e),
+ b'\r' => {
+ if self.peek() == Some(b'\n') {
+ self.pos += 1;
+ }
+ }
+ b'\n' => {}
+ b'0'..=b'7' => {
+ let mut v = (e - b'0') as u32;
+ for _ in 0..2 {
+ match self.peek() {
+ Some(d @ b'0'..=b'7') => {
+ v = v * 8 + (d - b'0') as u32;
+ self.pos += 1;
+ }
+ _ => break,
+ }
+ }
+ out.push((v & 0xFF) as u8);
+ }
+ other => out.push(other),
+ }
+ }
+ // End-of-line markers inside strings are normalised to \n (§7.3.4.2).
+ b'\r' => {
+ if self.peek() == Some(b'\n') {
+ self.pos += 1;
+ }
+ out.push(b'\n');
+ }
+ other => out.push(other),
+ }
+ }
+ Err(self.err("unterminated string"))
+ }
+
+ fn hex_string(&mut self) -> Result, CosError> {
+ let mut out = Vec::new();
+ let mut hi: Option = None;
+ while let Some(b) = self.peek() {
+ self.pos += 1;
+ if b == b'>' {
+ if let Some(h) = hi {
+ out.push(h << 4);
+ }
+ return Ok(out);
+ }
+ if is_whitespace(b) {
+ continue;
+ }
+ let Some(v) = hexval(b) else { return Err(self.err("invalid hex digit")) };
+ match hi.take() {
+ None => hi = Some(v),
+ Some(h) => out.push(h << 4 | v),
+ }
+ }
+ Err(self.err("unterminated hex string"))
+ }
+
+ fn dict_body(&mut self, depth: usize) -> Result {
+ let mut d = Dict::new();
+ loop {
+ self.skip_ws();
+ match self.peek() {
+ None => return Err(self.err("unterminated dictionary")),
+ Some(b'>') if self.data.get(self.pos + 1) == Some(&b'>') => {
+ self.pos += 2;
+ return Ok(d);
+ }
+ Some(b'/') => {
+ self.pos += 1;
+ let key = self.name_body();
+ self.skip_ws();
+ // A key directly followed by `>>` has no value: treat as null. (A following
+ // `/` is a name *value*, so it cannot signal a missing value.)
+ if self.peek() == Some(b'>') && self.data.get(self.pos + 1) == Some(&b'>') {
+ d.set(key, Object::Null);
+ continue;
+ }
+ let value = self.object_depth(depth + 1)?;
+ d.set(key, value);
+ }
+ Some(_) => {
+ // Tolerate garbage between entries (seen in damaged files): skip a token.
+ let before = self.pos;
+ let _ = self.object_depth(depth + 1);
+ if self.pos == before {
+ self.pos += 1;
+ }
+ }
+ }
+ }
+ }
+
+ fn number_or_ref(&mut self) -> Result {
+ let start = self.pos;
+ let first = self.number()?;
+ // Look ahead for "G R" to form a reference.
+ if let Object::Int(n) = first
+ && n >= 0
+ {
+ let save = self.pos;
+ self.skip_ws();
+ if self.peek().is_some_and(|b| b.is_ascii_digit()) {
+ let gstart = self.pos;
+ let g = self.token();
+ if g.iter().all(u8::is_ascii_digit) && self.eat_keyword(b"R") {
+ let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0);
+ if n <= u32::MAX as i64 {
+ return Ok(Object::Ref(ObjRef::new(n as u32, generation.min(u16::MAX as u32) as u16)));
+ }
+ }
+ let _ = gstart;
+ }
+ self.pos = save;
+ }
+ let _ = start;
+ Ok(first)
+ }
+
+ fn number(&mut self) -> Result {
+ let start = self.pos;
+ let tok = self.token();
+ let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?;
+ if s.contains('.') {
+ // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
+ let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
+ let mut end = cleaned.len();
+ while end > 0 {
+ if let Ok(v) = cleaned[..end].parse::() {
+ return Ok(Object::Real(v));
+ }
+ end -= 1;
+ }
+ return Ok(Object::Real(0.0));
+ }
+ match s.trim_start_matches('+').parse::() {
+ Ok(v) => Ok(Object::Int(v)),
+ Err(_) => {
+ // Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
+ s.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
+ }
+ }
+ }
+}
+
+fn hexval(b: u8) -> Option {
+ match b {
+ b'0'..=b'9' => Some(b - b'0'),
+ b'a'..=b'f' => Some(b - b'a' + 10),
+ b'A'..=b'F' => Some(b - b'A' + 10),
+ _ => None,
+ }
+}
+
+/// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`.
+/// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen).
+pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> {
+ let mut lx = Lexer::new(data, offset);
+ lx.skip_ws();
+ let num_tok = lx.token();
+ lx.skip_ws();
+ let gen_tok = lx.token();
+ let parse_u = |t: &[u8]| std::str::from_utf8(t).ok().and_then(|s| s.parse::().ok());
+ let (Some(num), Some(generation)) = (parse_u(num_tok), parse_u(gen_tok)) else {
+ return Err(CosError::Syntax { offset, detail: "expected object header".into() });
+ };
+ if !lx.eat_keyword(b"obj") {
+ return Err(CosError::Syntax { offset: lx.pos, detail: "expected 'obj'".into() });
+ }
+ let id = ObjRef::new(num.min(u32::MAX as u64) as u32, generation.min(u16::MAX as u64) as u16);
+ let obj = match lx.object() {
+ Ok(o) => o,
+ // "N G obj endobj" (empty) is treated as null.
+ Err(_) if lx.eat_keyword(b"endobj") => return Ok((id, Object::Null)),
+ Err(e) => return Err(e),
+ };
+ lx.skip_ws();
+ if let Object::Dict(dict) = &obj
+ && lx.eat_keyword(b"stream")
+ {
+ // The keyword is followed by CRLF or LF (a lone CR is tolerated).
+ match (data.get(lx.pos), data.get(lx.pos + 1)) {
+ (Some(b'\r'), Some(b'\n')) => lx.pos += 2,
+ (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
+ _ => {}
+ }
+ let start = lx.pos;
+ let declared = match dict.get(b"Length") {
+ Some(Object::Int(n)) => Some(*n),
+ Some(Object::Ref(r)) => resolve_length(*r),
+ _ => None,
+ };
+ let valid = |len: i64| -> bool {
+ if len < 0 {
+ return false;
+ }
+ let end = start.saturating_add(len as usize);
+ if end > data.len() {
+ return false;
+ }
+ let mut after = Lexer::new(data, end);
+ after.eat_keyword(b"endstream")
+ };
+ let len = match declared {
+ Some(l) if valid(l) => l as usize,
+ // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
+ _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start,
+ };
+ let raw = data[start..start + len].to_vec();
+ let mut dict = dict.clone();
+ dict.set(b"Length".to_vec(), Object::Int(raw.len() as i64));
+ return Ok((id, Object::Stream(Stream::from_raw(dict, raw))));
+ }
+ Ok((id, obj))
+}
+
+/// Offset where stream data ends (before the EOL preceding "endstream").
+fn find_endstream(data: &[u8], from: usize) -> Option {
+ let hay = data.get(from..)?;
+ let at = hay.windows(9).position(|w| w == b"endstream")? + from;
+ let mut end = at;
+ if end > from && data[end - 1] == b'\n' {
+ end -= 1;
+ }
+ if end > from && data[end - 1] == b'\r' {
+ end -= 1;
+ }
+ Some(end)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn obj(s: &str) -> Object {
+ Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
+ }
+
+ #[test]
+ fn scalars() {
+ assert_eq!(obj("true"), Object::Bool(true));
+ assert_eq!(obj("null"), Object::Null);
+ assert_eq!(obj(" -17 "), Object::Int(-17));
+ assert_eq!(obj("+42"), Object::Int(42));
+ assert_eq!(obj("-.5"), Object::Real(-0.5));
+ assert_eq!(obj("4."), Object::Real(4.0));
+ assert_eq!(obj("99999999999999999999"), Object::Real(1e20));
+ assert_eq!(obj("12 0 R"), Object::Ref(ObjRef::new(12, 0)));
+ assert_eq!(obj("12 0 obj").as_int(), Some(12), "12 followed by 0 obj is not a reference");
+ }
+
+ #[test]
+ fn names_decode_hex_escapes() {
+ assert_eq!(obj("/A#20B"), Object::Name(b"A B".to_vec()));
+ assert_eq!(obj("/Adobe#"), Object::Name(b"Adobe#".to_vec()));
+ assert_eq!(obj("/"), Object::Name(Vec::new()));
+ }
+
+ #[test]
+ fn literal_strings() {
+ let s = |x: &str| obj(x).as_string().expect("string").bytes.clone();
+ assert_eq!(s("(a (nested) b)"), b"a (nested) b");
+ assert_eq!(s(r"(\n\t\\\(\))"), b"\n\t\\()");
+ assert_eq!(s(r"(\101\1011)"), b"AA1");
+ assert_eq!(s("(line\\\ncontinued)"), b"linecontinued");
+ assert_eq!(s("(cr\r\nlf)"), b"cr\nlf");
+ }
+
+ #[test]
+ fn hex_strings() {
+ let s = obj("<48 65 6C6c6f7>");
+ assert_eq!(s.as_string().unwrap().bytes, b"Hellop");
+ assert!(s.as_string().unwrap().hex);
+ }
+
+ #[test]
+ fn arrays_and_dicts() {
+ let o = obj("<< /Type /Page /Kids [1 0 R 2 0 R] /Box [0 0 612.5 792] /Nested << /A true >> /Empty >>");
+ let d = o.as_dict().unwrap();
+ assert_eq!(d.name(b"Type"), Some(&b"Page"[..]));
+ assert_eq!(d.get(b"Kids").unwrap().as_array().unwrap().len(), 2);
+ assert_eq!(d.get(b"Box").unwrap().as_array().unwrap()[2], Object::Real(612.5));
+ assert_eq!(d.get(b"Empty"), Some(&Object::Null));
+ }
+
+ #[test]
+ fn comments_are_whitespace() {
+ assert_eq!(obj("% hello\n [1 % two\n 3]"), Object::Array(vec![Object::Int(1), Object::Int(3)]));
+ }
+
+ #[test]
+ fn indirect_stream_with_wrong_length_recovers() {
+ let data = b"7 0 obj\n<< /Length 999 >>\nstream\nHELLO\nendstream\nendobj\n";
+ let (id, o) = parse_indirect(data, 0, &|_| None).unwrap();
+ assert_eq!(id, ObjRef::new(7, 0));
+ let Object::Stream(s) = o else { panic!("stream expected") };
+ assert_eq!(s.raw.as_slice(), b"HELLO");
+ assert_eq!(s.dict.int(b"Length"), Some(5));
+ }
+
+ #[test]
+ fn indirect_length_is_resolved() {
+ let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
+ let (_, o) = parse_indirect(data, 0, &|r| (r.num == 8).then_some(2)).unwrap();
+ let Object::Stream(s) = o else { panic!() };
+ assert_eq!(s.raw.as_slice(), b"AB");
+ }
+
+ #[test]
+ fn garbage_never_panics() {
+ for input in ["<<", "[", "(", "<", "<< /A", "[1 2", ")", "]", ">>", "<<>>>", "1 0", "\0\0", "/A#", "", "((("] {
+ let _ = Lexer::new(input.as_bytes(), 0).object();
+ }
+ let deep = "[".repeat(10_000);
+ assert!(Lexer::new(deep.as_bytes(), 0).object().is_err());
+ }
+}
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
new file mode 100644
index 000000000..aa2710597
--- /dev/null
+++ b/crates/cos/src/writer.rs
@@ -0,0 +1,386 @@
+//! Serialization and saving (ISO 32000-2 §7.5).
+//!
+//! * `write_incremental` appends the edited objects, a cross-reference section in the same style
+//! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
+//! never modified, which keeps digital signatures over earlier revisions valid.
+//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
+//! classic cross-reference table — the "Save As" / garbage-collecting path.
+
+use std::collections::{BTreeMap, HashMap, VecDeque};
+use std::io::Write;
+
+use crate::CosError;
+use crate::document::Document;
+use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
+
+/// Serialize one object. Deterministic: same object → same bytes.
+pub fn serialize(o: &Object, out: &mut Vec) {
+ match o {
+ Object::Null => out.extend_from_slice(b"null"),
+ Object::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
+ Object::Int(i) => {
+ let _ = write!(out, "{i}");
+ }
+ Object::Real(r) => write_real(*r, out),
+ Object::String(s) => write_string(s, out),
+ Object::Name(n) => write_name(n, out),
+ Object::Array(a) => {
+ out.push(b'[');
+ for (i, x) in a.iter().enumerate() {
+ if i > 0 {
+ out.push(b' ');
+ }
+ serialize(x, out);
+ }
+ out.push(b']');
+ }
+ Object::Dict(d) => write_dict(d, out),
+ Object::Stream(s) => write_stream(s, out),
+ Object::Ref(r) => {
+ let _ = write!(out, "{} {} R", r.num, r.generation);
+ }
+ }
+}
+
+fn write_real(r: f64, out: &mut Vec) {
+ if !r.is_finite() {
+ out.push(b'0');
+ return;
+ }
+ if r.fract() == 0.0 && r.abs() < 1e15 {
+ let _ = write!(out, "{}", r as i64);
+ return;
+ }
+ // Plain decimal notation (no exponents, §7.3.3), trimmed.
+ let s = format!("{r:.6}");
+ let s = s.trim_end_matches('0').trim_end_matches('.');
+ out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() });
+}
+
+fn write_name(n: &[u8], out: &mut Vec) {
+ out.push(b'/');
+ for &b in n {
+ let regular = (b'!'..=b'~').contains(&b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'#');
+ if regular {
+ out.push(b);
+ } else {
+ let _ = write!(out, "#{b:02X}");
+ }
+ }
+}
+
+fn write_string(s: &PdfString, out: &mut Vec) {
+ let binary = s.bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
+ if s.hex || binary * 4 > s.bytes.len().max(1) {
+ out.push(b'<');
+ for b in &s.bytes {
+ let _ = write!(out, "{b:02X}");
+ }
+ out.push(b'>');
+ return;
+ }
+ out.push(b'(');
+ for &b in &s.bytes {
+ match b {
+ b'(' | b')' | b'\\' => {
+ out.push(b'\\');
+ out.push(b);
+ }
+ b'\n' => out.extend_from_slice(b"\\n"),
+ b'\r' => out.extend_from_slice(b"\\r"),
+ b'\t' => out.extend_from_slice(b"\\t"),
+ 0x08 => out.extend_from_slice(b"\\b"),
+ 0x0C => out.extend_from_slice(b"\\f"),
+ b' '..=b'~' => out.push(b),
+ _ => {
+ let _ = write!(out, "\\{b:03o}");
+ }
+ }
+ }
+ out.push(b')');
+}
+
+fn write_dict(d: &Dict, out: &mut Vec) {
+ out.extend_from_slice(b"<<");
+ for (k, v) in d.iter() {
+ write_name(k, out);
+ out.push(b' ');
+ serialize(v, out);
+ }
+ out.extend_from_slice(b">>");
+}
+
+fn write_stream(s: &Stream, out: &mut Vec) {
+ let mut d = s.dict.clone();
+ d.set(b"Length".to_vec(), Object::Int(s.raw.len() as i64));
+ write_dict(&d, out);
+ out.extend_from_slice(b"\nstream\n");
+ out.extend_from_slice(&s.raw);
+ out.extend_from_slice(b"\nendstream");
+}
+
+fn write_indirect(num: u32, generation: u16, o: &Object, out: &mut Vec) {
+ let _ = writeln!(out, "{num} {generation} obj");
+ serialize(o, out);
+ out.extend_from_slice(b"\nendobj\n");
+}
+
+/// Options for saving.
+#[derive(Clone, Debug)]
+pub struct SaveOptions {
+ /// Replace or add `/Info /ModDate` with this PDF date string (e.g. from `pdf_date`).
+ pub mod_date: Option,
+ /// Seed for a new file identifier when the document has none (deterministic in tests).
+ pub id_seed: u64,
+}
+
+impl Default for SaveOptions {
+ fn default() -> Self {
+ Self { mod_date: None, id_seed: 0x5052_494E_5443_5241 }
+ }
+}
+
+/// Append the document's edits to its original bytes (incremental update, §7.5.6).
+///
+/// A document that had to be reconstructed (no usable cross-reference chain) cannot be updated
+/// incrementally — other readers would follow the broken chain — so it is rewritten in full,
+/// which also repairs the file (what users expect after "the file was damaged and repaired").
+pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, CosError> {
+ if doc.revisions().is_empty() {
+ return write_full(doc, opts);
+ }
+ 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');
+ }
+ 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));
+ write_indirect(num, generation, &o, &mut out);
+ }
+ None => {
+ offsets.insert(num, (0, generation, false));
+ }
+ }
+ }
+ let prev = doc.revisions().last().map(|r| r.xref_offset);
+ let as_stream = doc.revisions().last().is_some_and(|r| r.is_stream);
+ let mut trailer = doc.trailer().clone();
+ ensure_id(&mut trailer, opts, &original);
+ if let Some(p) = prev {
+ trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
+ }
+ let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
+ if as_stream {
+ write_xref_stream(&mut out, &mut trailer, &offsets, size, &[])?;
+ } else {
+ trailer.set(b"Size".to_vec(), Object::Int(size as i64));
+ let xref_at = out.len();
+ 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)
+}
+
+/// Write only reachable objects, renumbered from 1, with a classic cross-reference table.
+pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosError> {
+ let mut doc = doc.clone();
+ stamp_mod_date(&mut doc, opts);
+ let trailer_in = doc.trailer().clone();
+ // Breadth-first walk from the trailer, assigning new numbers in visit order.
+ let mut map: HashMap = HashMap::new();
+ let mut order: Vec = Vec::new();
+ let mut queue: VecDeque = VecDeque::new();
+ let visit_refs = |o: &Object, queue: &mut VecDeque| collect_refs(o, &mut |r| queue.push_back(r));
+ for key in [&b"Root"[..], b"Info"] {
+ if let Some(r) = trailer_in.reference(key) {
+ queue.push_back(r);
+ }
+ }
+ while let Some(r) = queue.pop_front() {
+ if map.contains_key(&r) {
+ continue;
+ }
+ let o = doc.get(r);
+ if matches!(*o, Object::Null) {
+ continue; // dangling reference: written as null by renumbering below
+ }
+ map.insert(r, (order.len() + 1) as u32);
+ order.push(r);
+ visit_refs(&o, &mut queue);
+ }
+ let mut out = Vec::new();
+ let _ = writeln!(out, "%PDF-{}\n%\u{e2}\u{e3}\u{cf}\u{d3}", doc.version().chars().take(3).collect::());
+ // The binary comment must be raw high bytes, not UTF-8.
+ out.truncate(out.len() - "\u{e2}\u{e3}\u{cf}\u{d3}\n".len());
+ out.extend_from_slice(&[0xE2, 0xE3, 0xCF, 0xD3, b'\n']);
+ let mut offsets: BTreeMap = BTreeMap::new();
+ for (i, r) in order.iter().enumerate() {
+ let num = (i + 1) as u32;
+ let o = renumber(&doc.get(*r), &map);
+ offsets.insert(num, (out.len() as u64, 0, true));
+ write_indirect(num, 0, &o, &mut out);
+ }
+ let mut trailer = Dict::new();
+ trailer.set(b"Size".to_vec(), Object::Int(order.len() as i64 + 1));
+ for key in [&b"Root"[..], b"Info"] {
+ if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) {
+ trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0)));
+ }
+ }
+ if let Some(id) = trailer_in.get(b"ID") {
+ trailer.set(b"ID".to_vec(), id.clone());
+ }
+ ensure_id(&mut trailer, opts, doc.bytes());
+ let xref_at = out.len();
+ write_xref_table(&mut out, &offsets, true);
+ out.extend_from_slice(b"trailer\n");
+ write_dict(&trailer, &mut out);
+ let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
+ Ok(out)
+}
+
+fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) {
+ match o {
+ Object::Ref(r) => f(*r),
+ Object::Array(a) => a.iter().for_each(|x| collect_refs(x, f)),
+ Object::Dict(d) => d.iter().for_each(|(_, v)| collect_refs(v, f)),
+ Object::Stream(s) => s.dict.iter().for_each(|(_, v)| collect_refs(v, f)),
+ _ => {}
+ }
+}
+
+fn renumber(o: &Object, map: &HashMap) -> Object {
+ match o {
+ Object::Ref(r) => map.get(r).map(|n| Object::Ref(ObjRef::new(*n, 0))).unwrap_or(Object::Null),
+ Object::Array(a) => Object::Array(a.iter().map(|x| renumber(x, map)).collect()),
+ Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), renumber(v, map))).collect()),
+ Object::Stream(s) => Object::Stream(Stream { dict: s.dict.iter().map(|(k, v)| (k.clone(), renumber(v, map))).collect(), raw: s.raw.clone() }),
+ other => other.clone(),
+ }
+}
+
+/// Classic xref table with one subsection per run of consecutive numbers (§7.5.4).
+fn write_xref_table(out: &mut Vec, offsets: &BTreeMap, include_zero: bool) {
+ out.extend_from_slice(b"xref\n");
+ let mut all: Vec<(u32, (u64, u16, bool))> = offsets.iter().map(|(k, v)| (*k, *v)).collect();
+ if include_zero {
+ all.insert(0, (0, (0, 65535, false)));
+ }
+ let mut i = 0;
+ while i < all.len() {
+ let start = all[i].0;
+ let mut j = i;
+ while j + 1 < all.len() && all[j + 1].0 == all[j].0 + 1 {
+ j += 1;
+ }
+ let _ = writeln!(out, "{} {}", start, j - i + 1);
+ for (_, (off, generation, used)) in &all[i..=j] {
+ let _ = writeln!(out, "{:010} {:05} {} ", off, generation, if *used { 'n' } else { 'f' });
+ }
+ i = j + 1;
+ }
+}
+
+/// A cross-reference stream section (for files whose last section is a stream, §7.5.8).
+fn write_xref_stream(
+ out: &mut Vec,
+ trailer: &mut Dict,
+ offsets: &BTreeMap,
+ size: u32,
+ _extra: &[u32],
+) -> Result<(), CosError> {
+ let xref_num = size;
+ let xref_at = out.len() as u64;
+ let mut rows: BTreeMap =
+ offsets.iter().map(|(n, (o, g, used))| (*n, if *used { (1, *o, *g) } else { (0, 0, *g) })).collect();
+ rows.insert(xref_num, (1, xref_at, 0));
+ let mut data = Vec::new();
+ let mut index = Vec::new();
+ let nums: Vec = rows.keys().copied().collect();
+ let mut i = 0;
+ while i < nums.len() {
+ let mut j = i;
+ while j + 1 < nums.len() && nums[j + 1] == nums[j] + 1 {
+ j += 1;
+ }
+ index.push(Object::Int(nums[i] as i64));
+ index.push(Object::Int((j - i + 1) as i64));
+ i = j + 1;
+ }
+ for (t, a, b) in rows.values() {
+ data.push(*t);
+ data.extend_from_slice(&(*a as u32).to_be_bytes());
+ data.extend_from_slice(&b.to_be_bytes());
+ }
+ let mut d = trailer.clone();
+ d.set(b"Type".to_vec(), Object::name("XRef"));
+ d.set(b"Size".to_vec(), Object::Int(xref_num as i64 + 1));
+ d.set(b"W".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(4), Object::Int(2)]));
+ d.set(b"Index".to_vec(), Object::Array(index));
+ let stream = Stream::flate(d, &data);
+ write_indirect(xref_num, 0, &Object::Stream(stream), out);
+ let _ = write!(out, "startxref\n{xref_at}\n%%EOF\n");
+ trailer.set(b"Size".to_vec(), Object::Int(xref_num as i64 + 1));
+ Ok(())
+}
+
+fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) {
+ let Some(date) = &opts.mod_date else { return };
+ let date = Object::String(PdfString::literal(date.as_bytes().to_vec()));
+ match doc.trailer().get(b"Info").cloned() {
+ Some(Object::Ref(r)) => {
+ let _ = doc.update_dict(r, |d| d.set(b"ModDate".to_vec(), date.clone()));
+ }
+ _ => {
+ let mut info = Dict::new();
+ info.set(b"ModDate".to_vec(), date);
+ let r = doc.add(info);
+ doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
+ }
+ }
+}
+
+/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4).
+fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
+ if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) {
+ return;
+ }
+ // FNV-1a over a sample of the content and the seed: stable, dependency-free.
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed;
+ for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x0100_0000_01b3);
+ }
+ let mut id = Vec::with_capacity(16);
+ id.extend_from_slice(&h.to_be_bytes());
+ id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes());
+ let s = Object::String(PdfString { bytes: id, hex: true });
+ trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
+}
+
+/// A PDF date string for "now" given seconds since the Unix epoch (UTC).
+pub fn pdf_date(unix_secs: i64) -> String {
+ let days = unix_secs.div_euclid(86_400);
+ let secs = unix_secs.rem_euclid(86_400);
+ // Civil-from-days (Howard Hinnant's algorithm).
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = if m <= 2 { y + 1 } else { y };
+ format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
+}
diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/corpus.rs
new file mode 100644
index 000000000..c2cbf1bcb
--- /dev/null
+++ b/crates/cos/tests/corpus.rs
@@ -0,0 +1,101 @@
+//! Corpus parity: every file hayro-syntax can open must also open with our reader, agree on the
+//! page count, and survive an incremental save that touches the catalog (the appended revision
+//! must be readable by both readers and leave the original bytes untouched).
+//!
+//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches it); otherwise
+//! it is a no-op so CI without the corpus stays green. Run with `--ignored --nocapture`.
+
+use std::sync::Arc;
+
+use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+
+/// Leaf pages reachable from the catalog (cycle-safe), like viewers count them.
+fn page_count(doc: &Document) -> Option {
+ let root = doc.get(doc.root()?);
+ let mut stack = vec![root.as_dict()?.get(b"Pages")?.clone()];
+ let (mut seen, mut n) = (std::collections::HashSet::new(), 0);
+ while let Some(o) = stack.pop() {
+ if let Object::Ref(r) = o
+ && !seen.insert(r)
+ {
+ continue;
+ }
+ let node = doc.resolve(&o);
+ let Some(d) = node.as_dict() else { continue };
+ match d.get(b"Kids").map(|k| doc.resolve(k)) {
+ Some(k) if d.name(b"Type") != Some(b"Page") => stack.extend(k.as_array().cloned().unwrap_or_default().into_iter().rev()),
+ _ => n += 1,
+ }
+ }
+ Some(n)
+}
+
+#[test]
+#[ignore = "needs corpus/; run with --ignored"]
+fn corpus_parity() {
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs/test/pdfs");
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ eprintln!("corpus not present at {}", dir.display());
+ return;
+ };
+ let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
+ files.sort();
+ let (mut checked, mut encrypted, mut failures) = (0, 0, Vec::new());
+ for path in &files {
+ let bytes = Arc::new(std::fs::read(path).unwrap());
+ let Ok(reference) = std::panic::catch_unwind(|| hayro_syntax::Pdf::new(bytes.clone())) else { continue };
+ let Ok(reference) = reference else { continue }; // hayro can't open it either: not a parity case
+ let name = path.file_name().unwrap().to_string_lossy().into_owned();
+ if std::env::var("COS_ONLY").is_ok_and(|o| o != name) {
+ continue;
+ }
+ if std::env::var_os("COS_TRACE").is_some() {
+ eprintln!("{name}");
+ }
+ let expected = reference.pages().len();
+ let result = std::panic::catch_unwind(|| -> Result<(), String> {
+ let mut doc = match Document::open(bytes.clone()) {
+ Ok(d) => d,
+ Err(printcraft_cos::CosError::Encrypted) => return Err("encrypted".into()),
+ Err(e) => return Err(format!("open: {e}")),
+ };
+ // hayro may repair a broken /Count; compare against the leaf walk count loosely.
+ if page_count(&doc) != Some(expected) && expected > 0 {
+ return Err(format!("page count {:?} vs hayro {expected}", page_count(&doc)));
+ }
+ let root = doc.root().ok_or("no root")?;
+ doc.update_dict(root, |d| d.set(b"PrintCraftTest".to_vec(), Object::Bool(true))).map_err(|e| e.to_string())?;
+ let saved = write_incremental(&doc, &SaveOptions::default()).map_err(|e| format!("save: {e}"))?;
+ // Reconstructed files are rewritten in full; everything else must keep its bytes.
+ if !doc.revisions().is_empty() && saved.get(..bytes.len()) != Some(&bytes[..]) {
+ return Err("prefix modified".into());
+ }
+ let again = Document::open(Arc::new(saved.clone())).map_err(|e| format!("reopen: {e}"))?;
+ let flag = again.get(again.root().ok_or("no root after save")?).as_dict().and_then(|d| d.get(b"PrintCraftTest").cloned());
+ if flag != Some(Object::Bool(true)) {
+ return Err("edit lost after reopen".into());
+ }
+ let hay = hayro_syntax::Pdf::new(Arc::new(saved)).map_err(|e| format!("hayro reopen: {e:?}"))?;
+ if hay.pages().len() != expected {
+ return Err(format!("hayro sees {} pages after save, expected {expected}", hay.pages().len()));
+ }
+ let full = write_full(&doc, &SaveOptions::default()).map_err(|e| format!("full: {e}"))?;
+ let hay = hayro_syntax::Pdf::new(Arc::new(full)).map_err(|e| format!("hayro full: {e:?}"))?;
+ if hay.pages().len() != expected {
+ return Err(format!("hayro sees {} pages after full save, expected {expected}", hay.pages().len()));
+ }
+ Ok(())
+ });
+ match result {
+ Ok(Ok(())) => checked += 1,
+ Ok(Err(e)) if e == "encrypted" => encrypted += 1,
+ Ok(Err(e)) => failures.push(format!("{name}: {e}")),
+ Err(_) => failures.push(format!("{name}: PANIC")),
+ }
+ }
+ eprintln!("cos corpus parity: {checked} ok, {encrypted} encrypted (skipped), {} failures", failures.len());
+ for f in &failures {
+ eprintln!(" {f}");
+ }
+ assert!(failures.iter().all(|f| !f.ends_with("PANIC")), "no panics");
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ebeff37d6..fe70e6980 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
printcraft-render = { workspace = true }
+printcraft-cos = { workspace = true }
+printcraft-organize = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 2db3584fb..eaeef388e 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1,32 +1,174 @@
//! printcraft-engine — the façade every frontend talks to (architecture §4).
//!
-//! This first slice holds open documents and the tool catalogue. The command registry, history
-//! and jobs arrive in M4; the types here are shaped so that the UI never touches rendering or
-//! parsing crates directly.
+//! Holds open documents, their edit history and the tool catalogue. Frontends never touch the
+//! parsing, rendering or editing crates directly.
+//!
+//! **Editing model.** Each document keeps a `printcraft_cos::Document` (the object graph with a
+//! copy-on-write overlay of edits). An edit runs on a clone, and on success the previous state is
+//! pushed onto the undo stack (clones share all unchanged data, so this is cheap). After every
+//! edit the *working file* is produced by an incremental write — original bytes plus one
+//! appended revision — and the view is refreshed from it, so what you see is exactly what Save
+//! will write. Saving rebases onto the written bytes, so the next save appends only new edits.
pub mod catalog;
use std::sync::Arc;
-use printcraft_render::{DocInfo, OpenError, RenderPool, inspect};
+use printcraft_cos::{SaveOptions, write_full, write_incremental};
+use printcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
/// Stable identifier of an open document within a session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DocId(pub u64);
+/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
+const MAX_UNDO: usize = 100;
+
+/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
+#[derive(Clone)]
+struct Editor {
+ cos: printcraft_cos::Document,
+ undo: Vec<(String, printcraft_cos::Document)>,
+ redo: Vec<(String, printcraft_cos::Document)>,
+}
+
pub struct Document {
pub id: DocId,
pub name: String,
pub path: Option,
+ /// The working file: what Save writes and what is displayed.
pub bytes: Arc>,
pub info: DocInfo,
pub renderer: RenderPool,
+ /// The password the document was opened with (needed to read attachments, etc.).
+ pub password: Option,
+ /// `true` when there are edits that have not been saved.
+ pub dirty: bool,
+ /// Why the document cannot be edited (e.g. encryption), if so.
+ pub read_only_reason: Option,
+ editor: Option,
+ config: RenderConfig,
+}
+
+impl Document {
+ pub fn can_undo(&self) -> Option<&str> {
+ self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _)| l.as_str())
+ }
+
+ pub fn can_redo(&self) -> Option<&str> {
+ self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _)| l.as_str())
+ }
+
+ pub fn editable(&self) -> bool {
+ self.editor.is_some()
+ }
+
+ /// Current value of a document-information entry (Title, Author, …).
+ pub fn info_value(&self, key: &str) -> Option {
+ self.editor.as_ref().and_then(|e| printcraft_organize::info(&e.cos, key))
+ }
+
+ /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
+ pub fn repair_log(&self) -> Vec {
+ self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
+ }
+}
+
+fn render_threads() -> usize {
+ std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
+}
+
+/// Edits that can be applied to a document. Page indices are 0-based.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Edit {
+ RotatePages {
+ pages: Vec,
+ degrees: i64,
+ },
+ DeletePages {
+ pages: Vec,
+ },
+ MovePages {
+ pages: Vec,
+ to: usize,
+ },
+ InsertBlankPage {
+ at: usize,
+ width: f64,
+ height: f64,
+ },
+ SetInfo {
+ key: String,
+ value: String,
+ },
+ /// Several edits applied as one undoable step (all or nothing).
+ Batch {
+ label: String,
+ edits: Vec,
+ },
+}
+
+impl Edit {
+ /// Label for the Edit menu and history ("Undo Rotate pages").
+ pub fn label(&self) -> String {
+ match self {
+ Edit::RotatePages { pages, .. } => plural("Rotate page", pages.len()),
+ Edit::DeletePages { pages } => plural("Delete page", pages.len()),
+ Edit::MovePages { pages, .. } => plural("Move page", pages.len()),
+ Edit::InsertBlankPage { .. } => "Insert blank page".into(),
+ Edit::SetInfo { key, .. } => format!("Change {key}"),
+ Edit::Batch { label, .. } => label.clone(),
+ }
+ }
+}
+
+/// Perform an edit on a working copy (the caller discards it on error).
+fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditError> {
+ match edit {
+ Edit::RotatePages { pages, degrees } => printcraft_organize::rotate_pages(doc, pages, *degrees)?,
+ Edit::DeletePages { pages } => printcraft_organize::delete_pages(doc, pages)?,
+ Edit::MovePages { pages, to } => printcraft_organize::move_pages(doc, pages, *to)?,
+ Edit::InsertBlankPage { at, width, height } => {
+ printcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
+ }
+ Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?,
+ Edit::Batch { edits, .. } => {
+ for e in edits {
+ run_edit(doc, e)?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn plural(s: &str, n: usize) -> String {
+ if n == 1 { s.to_string() } else { format!("{s}s") }
+}
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum EditError {
+ #[error("no such document")]
+ NoDocument,
+ #[error("this document can't be edited: {0}")]
+ ReadOnly(String),
+ #[error("{0}")]
+ Organize(#[from] printcraft_organize::OrganizeError),
+ #[error("the edited document could not be written: {0}")]
+ Write(String),
+ #[error("the edited document could not be reopened: {0}")]
+ Reopen(String),
+ #[error("nothing to undo")]
+ NothingToUndo,
+ #[error("nothing to redo")]
+ NothingToRedo,
}
#[derive(Default)]
pub struct Session {
docs: Vec,
next_id: u64,
+ /// Seconds since the Unix epoch, injected so saves are deterministic in tests.
+ clock: Option i64>,
}
impl Session {
@@ -34,18 +176,177 @@ impl Session {
Self::default()
}
+ /// Use a fixed clock (tests) instead of the system time.
+ pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
+ self.clock = Some(clock);
+ self
+ }
+
+ fn now(&self) -> Option {
+ if let Some(c) = self.clock {
+ return Some(c());
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64);
+ #[cfg(target_arch = "wasm32")]
+ return None;
+ }
+
/// Open a document from bytes. Rendering starts lazily when pages are requested.
- pub fn open(&mut self, name: impl Into, path: Option, bytes: Vec) -> Result {
- let bytes = Arc::new(bytes);
- let info = inspect(bytes.clone())?;
- let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1;
- let renderer = RenderPool::new(bytes.clone(), threads);
+ ///
+ /// `password` is tried as either the user or owner password when the file is encrypted.
+ pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result {
+ let info = inspect(bytes.clone(), password)?;
+ let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
+ let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
+ let (editor, read_only_reason) = match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
+ Ok(Ok(cos)) => (Some(Editor { cos, undo: Vec::new(), redo: Vec::new() }), None),
+ Ok(Err(e)) => (None, Some(e.to_string())),
+ Err(_) => (None, Some("the document structure could not be read for editing".into())),
+ };
self.next_id += 1;
let id = DocId(self.next_id);
- self.docs.push(Document { id, name: name.into(), path, bytes, info, renderer });
+ self.docs.push(Document {
+ id,
+ name: name.into(),
+ path,
+ bytes,
+ info,
+ renderer,
+ password: password.map(str::to_owned),
+ dirty: false,
+ read_only_reason,
+ editor,
+ config,
+ });
Ok(id)
}
+ fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
+ self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument)
+ }
+
+ /// Apply an edit. On success the previous state is undoable and the view data is refreshed.
+ pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
+ let doc = self.doc_mut(id)?;
+ let reason = doc.read_only_reason.clone().unwrap_or_default();
+ let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?;
+ let mut next = editor.cos.clone();
+ run_edit(&mut next, &edit)?;
+ let previous = std::mem::replace(&mut editor.cos, next);
+ editor.undo.push((edit.label(), previous));
+ if editor.undo.len() > MAX_UNDO {
+ editor.undo.remove(0);
+ }
+ editor.redo.clear();
+ if let Err(e) = Self::refresh(doc) {
+ // Roll back: the edit produced something we cannot display.
+ if let Some(ed) = doc.editor.as_mut()
+ && let Some((_, prev)) = ed.undo.pop()
+ {
+ ed.cos = prev;
+ }
+ let _ = Self::refresh(doc);
+ return Err(e);
+ }
+ doc.dirty = true;
+ Ok(())
+ }
+
+ pub fn undo(&mut self, id: DocId) -> Result {
+ let doc = self.doc_mut(id)?;
+ let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
+ let (label, prev) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
+ let current = std::mem::replace(&mut editor.cos, prev);
+ editor.redo.push((label.clone(), current));
+ Self::refresh(doc)?;
+ doc.dirty = true;
+ Ok(label)
+ }
+
+ pub fn redo(&mut self, id: DocId) -> Result {
+ let doc = self.doc_mut(id)?;
+ let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
+ let (label, next) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
+ let current = std::mem::replace(&mut editor.cos, next);
+ editor.undo.push((label.clone(), current));
+ Self::refresh(doc)?;
+ doc.dirty = true;
+ Ok(label)
+ }
+
+ /// Rebuild working bytes, inspection and renderer from the current edit state.
+ fn refresh(doc: &mut Document) -> Result<(), EditError> {
+ let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
+ let bytes = if editor.cos.is_modified() {
+ Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
+ } else {
+ editor.cos.bytes().clone()
+ };
+ let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
+ // Keep the user's layer choices where the layers still exist.
+ let mut info = info;
+ for l in &mut info.layers {
+ if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) {
+ l.visible = old.visible;
+ }
+ }
+ doc.info = info;
+ doc.bytes = bytes.clone();
+ doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
+ Ok(())
+ }
+
+ /// The bytes to write for Save: an incremental update of the file as opened/last saved,
+ /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully.
+ pub fn save_bytes(&self, id: DocId) -> Result>, EditError> {
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
+ if !editor.cos.is_modified() {
+ return Ok(editor.cos.bytes().clone());
+ }
+ let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
+ write_incremental(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
+ }
+
+ /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork).
+ pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> {
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
+ let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
+ write_full(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
+ }
+
+ /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file
+ /// so the next save appends only newer edits. Undo history is kept.
+ pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> {
+ let doc = self.doc_mut(id)?;
+ if let Some(editor) = doc.editor.as_mut() {
+ editor.cos = printcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
+ }
+ if let Some(p) = path {
+ doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
+ doc.path = Some(p);
+ }
+ doc.dirty = false;
+ Self::refresh(doc)
+ }
+
+ /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed;
+ /// callers must drop cached rasters and text for the document.
+ pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool {
+ let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
+ let Some(l) = doc.info.layers.get_mut(layer) else { return false };
+ if l.visible == visible {
+ return false;
+ }
+ l.visible = visible;
+ let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
+ doc.config.layers = Arc::new(overrides);
+ doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
+ true
+ }
+
pub fn close(&mut self, id: DocId) {
self.docs.retain(|d| d.id != id);
}
@@ -58,3 +359,6 @@ impl Session {
&self.docs
}
}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
new file mode 100644
index 000000000..fd7540387
--- /dev/null
+++ b/crates/engine/src/tests.rs
@@ -0,0 +1,191 @@
+use std::sync::Arc;
+
+use super::*;
+
+/// A minimal `n`-page document; page `i` shows "Page i+1".
+pub(crate) fn fixture(n: usize) -> Vec {
+ let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
+ let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
+ objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
+ objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
+ for i in 0..n {
+ objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
+ let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
+ objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
+ }
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn session_with(n: usize) -> (Session, DocId) {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
+ (s, id)
+}
+
+fn page_texts(s: &Session, id: DocId) -> Vec {
+ let doc = s.get(id).unwrap();
+ let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
+ (0..doc.info.pages.len())
+ .map(|p| {
+ let r = r.render(RenderRequestFor::text(p));
+ r.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
+ })
+ .collect()
+}
+
+/// Shorthand for building text-extraction requests.
+struct RenderRequestFor;
+impl RenderRequestFor {
+ fn text(page: usize) -> printcraft_render::RenderRequest {
+ printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }
+ }
+}
+
+#[test]
+fn edits_update_view_data_and_mark_dirty() {
+ let (mut s, id) = session_with(3);
+ assert!(s.get(id).unwrap().editable());
+ assert!(!s.get(id).unwrap().dirty);
+ s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
+ let doc = s.get(id).unwrap();
+ assert!(doc.dirty);
+ assert_eq!(doc.info.pages.len(), 2, "inspection refreshed");
+ assert_eq!(doc.can_undo(), Some("Delete page"));
+ assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
+}
+
+#[test]
+fn undo_and_redo_restore_exact_states() {
+ let (mut s, id) = session_with(3);
+ let original = s.get(id).unwrap().bytes.clone();
+ s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap();
+ s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
+ assert_eq!(s.undo(id).unwrap(), "Rotate page");
+ assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
+ assert_eq!(s.undo(id).unwrap(), "Move page");
+ assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes");
+ assert_eq!(s.undo(id), Err(EditError::NothingToUndo));
+ assert_eq!(s.redo(id).unwrap(), "Move page");
+ assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]);
+ // A new edit clears the redo stack.
+ s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap();
+ assert_eq!(s.redo(id), Err(EditError::NothingToRedo));
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 4);
+}
+
+#[test]
+fn failed_edit_changes_nothing() {
+ let (mut s, id) = session_with(2);
+ let before = s.get(id).unwrap().bytes.clone();
+ let err = s.apply(id, Edit::DeletePages { pages: vec![0, 1] }).unwrap_err();
+ assert_eq!(err, EditError::Organize(printcraft_organize::OrganizeError::WouldRemoveAllPages));
+ let doc = s.get(id).unwrap();
+ assert!(!doc.dirty);
+ assert_eq!(doc.can_undo(), None);
+ assert_eq!(doc.bytes, before);
+}
+
+#[test]
+fn save_is_incremental_stamps_mod_date_and_rebases() {
+ let (mut s, id) = session_with(2);
+ let original = s.get(id).unwrap().bytes.clone();
+ s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ assert_eq!(&saved[..original.len()], &original[..], "incremental save keeps the original bytes");
+ let text = String::from_utf8_lossy(&saved[original.len()..]);
+ assert!(text.contains("/ModDate (D:20231114221320Z)"), "mod date stamped: {text}");
+ s.mark_saved(id, saved.clone(), Some("/tmp/out/report.pdf".into())).unwrap();
+ let doc = s.get(id).unwrap();
+ assert!(!doc.dirty);
+ assert_eq!(doc.name, "report.pdf");
+ assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report"));
+ assert_eq!(doc.info.title.as_deref(), Some("Quarterly report"));
+ // Saving again without edits writes the same bytes; a later edit appends just one revision.
+ assert_eq!(s.save_bytes(id).unwrap(), saved);
+ s.apply(id, Edit::RotatePages { pages: vec![1], degrees: -90 }).unwrap();
+ let second = s.save_bytes(id).unwrap();
+ assert_eq!(&second[..saved.len()], &saved[..]);
+ let reopened = printcraft_cos::Document::open(second).unwrap();
+ assert_eq!(reopened.revisions().len(), 3);
+}
+
+#[test]
+fn undo_still_works_after_save() {
+ let (mut s, id) = session_with(2);
+ s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ s.mark_saved(id, saved, None).unwrap();
+ s.undo(id).unwrap();
+ let doc = s.get(id).unwrap();
+ assert!(doc.dirty, "undoing past a save makes the document dirty again");
+ assert_eq!(doc.info.pages.len(), 2);
+}
+
+#[test]
+fn full_save_is_readable_and_smaller_after_deletes() {
+ let (mut s, id) = session_with(5);
+ s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap();
+ let full = s.save_full_bytes(id).unwrap();
+ assert!(full.len() < s.save_bytes(id).unwrap().len());
+ let mut s2 = Session::new();
+ let id2 = s2.open("full.pdf", None, full, None).unwrap();
+ assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]);
+}
+
+#[test]
+fn layer_choices_survive_edits() {
+ // No layers in the fixture: just make sure refresh keeps working with an empty list and
+ // that unknown layers are rejected.
+ let (mut s, id) = session_with(1);
+ assert!(!s.set_layer_visible(id, 0, false));
+ s.apply(id, Edit::InsertBlankPage { at: 1, width: 100.0, height: 100.0 }).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
+}
+
+#[test]
+fn edits_to_unknown_documents_are_rejected() {
+ let (mut s, _) = session_with(1);
+ assert_eq!(s.apply(DocId(999), Edit::DeletePages { pages: vec![0] }), Err(EditError::NoDocument));
+ assert_eq!(s.undo(DocId(999)), Err(EditError::NoDocument));
+ assert!(s.save_bytes(DocId(999)).is_err());
+}
+
+#[test]
+fn batch_is_one_undo_step_and_all_or_nothing() {
+ let (mut s, id) = session_with(3);
+ let batch = Edit::Batch {
+ label: "Change properties".into(),
+ edits: vec![Edit::SetInfo { key: "Title".into(), value: "T".into() }, Edit::SetInfo { key: "Author".into(), value: "A".into() }],
+ };
+ s.apply(id, batch).unwrap();
+ assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A"));
+ assert_eq!(s.undo(id).unwrap(), "Change properties");
+ assert_eq!(s.get(id).unwrap().info_value("Title"), None);
+ assert_eq!(s.get(id).unwrap().can_undo(), None, "one step");
+ // A failing member rolls back the whole batch.
+ let bad = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, Edit::DeletePages { pages: vec![9] }] };
+ assert!(s.apply(id, bad).is_err());
+ assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
+}
+
+#[test]
+fn insert_blank_between_pages_keeps_both_neighbours() {
+ let (mut s, id) = session_with(2);
+ s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap();
+ assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]);
+}
diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml
new file mode 100644
index 000000000..f8ea7c198
--- /dev/null
+++ b/crates/filters/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "printcraft-filters"
+description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+flate2.workspace = true
+thiserror.workspace = true
+
+[dev-dependencies]
+proptest.workspace = true
+weezl.workspace = true
diff --git a/crates/filters/README.md b/crates/filters/README.md
new file mode 100644
index 000000000..a35749d41
--- /dev/null
+++ b/crates/filters/README.md
@@ -0,0 +1,50 @@
+# printcraft-filters
+
+PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
+
+- **Layer:** L0, standalone. No dependency on any other PrintCraft crate. `cos` depends on it (architecture §3 rule 3).
+- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters).
+- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
+
+## API
+
+```rust
+pub enum Filter { Flate, Lzw, AsciiHex, Ascii85, RunLength, Dct, Jpx, Jbig2, CcittFax, Crypt, Unknown(String) }
+Filter::from_name(b"FlateDecode" | b"Fl" | ...) -> Filter // full names and inline-image abbreviations
+Filter::name(&self) -> &str; Filter::is_image_codec(&self) -> bool
+
+pub struct Params { predictor, colors, bits_per_component, columns, early_change } // i64s; Default = 1, 1, 8, 1, 1
+pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) }
+
+decode(chain, data, max_output) -> Result, FilterError>
+decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError>
+encode(filter, params, data) -> Result, FilterError>
+encode_flate(data) -> Vec // zlib, level 6
+```
+
+- `chain` is applied first to last, as in a `/Filter` array.
+- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
+- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`.
+- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
+
+## Behaviour
+
+| Filter | Decode | Encode |
+|---|---|---|
+| FlateDecode | zlib header checked, then inflated raw: a missing or wrong Adler-32 is tolerated; no valid header → raw deflate; trailing bytes ignored; preset dictionary → Corrupt; truncated → Corrupt (partial in tolerant mode) | zlib level 6 |
+| LZWDecode | 9–12-bit MSB-first codes, clear (256), EOD (257), `EarlyChange` 0/1; missing EOD/initial clear tolerated; a full table stops growing | greedy, leading clear, clear-table when the table fills, EOD |
+| Predictors (Flate/LZW) | 2 = TIFF (1/2/4/8/16 bpc), 10–15 = PNG with per-row tags 0–4; partial final rows decoded; bad params or tags → Corrupt | 2, 10 None, 11 Sub, 12 Up, 13 Average, 14 Paeth, 15 per-row optimum (min sum of signed residuals) |
+| ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` |
+| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
+| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
+
+## Tests
+
+- Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows).
+- LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings.
+- `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams).
+- `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`).
+
+## Status
+
+M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6.
diff --git a/crates/filters/fuzz/.gitignore b/crates/filters/fuzz/.gitignore
new file mode 100644
index 000000000..ab0eaa1a4
--- /dev/null
+++ b/crates/filters/fuzz/.gitignore
@@ -0,0 +1,5 @@
+target/
+corpus/
+artifacts/
+coverage/
+Cargo.lock
diff --git a/crates/filters/fuzz/Cargo.toml b/crates/filters/fuzz/Cargo.toml
new file mode 100644
index 000000000..efa0774d7
--- /dev/null
+++ b/crates/filters/fuzz/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "printcraft-filters-fuzz"
+version = "0.0.0"
+publish = false
+edition = "2024"
+license = "MIT OR Apache-2.0"
+
+[package.metadata]
+cargo-fuzz = true
+
+# Detached from the PrintCraft workspace: run with `cargo +nightly fuzz run decode`
+# from crates/filters/.
+[workspace]
+
+[dependencies]
+libfuzzer-sys = "0.4"
+printcraft-filters = { path = ".." }
+
+[[bin]]
+name = "decode"
+path = "fuzz_targets/decode.rs"
+test = false
+doc = false
+bench = false
diff --git a/crates/filters/fuzz/fuzz_targets/decode.rs b/crates/filters/fuzz/fuzz_targets/decode.rs
new file mode 100644
index 000000000..c9923ed73
--- /dev/null
+++ b/crates/filters/fuzz/fuzz_targets/decode.rs
@@ -0,0 +1,32 @@
+//! Feeds arbitrary bytes to every decoder, with parameters taken from the first bytes.
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use printcraft_filters::{Filter, Params, decode, decode_tolerant};
+
+const FILTERS: [Filter; 5] = [Filter::Flate, Filter::Lzw, Filter::AsciiHex, Filter::Ascii85, Filter::RunLength];
+const PREDICTORS: [i64; 8] = [1, 2, 10, 11, 12, 13, 14, 15];
+const BPC: [i64; 5] = [1, 2, 4, 8, 16];
+
+fuzz_target!(|data: &[u8]| {
+ let Some((head, body)) = data.split_first_chunk::<4>() else { return };
+ // Up to two chained filters; the second only when head[0] says so.
+ let params = Params {
+ predictor: PREDICTORS[usize::from(head[1] & 7)],
+ colors: i64::from(head[1] >> 3 & 3) + 1,
+ bits_per_component: BPC[usize::from(head[2]) % BPC.len()],
+ columns: i64::from(head[3]) + 1,
+ early_change: i64::from(head[2] >> 7),
+ };
+ let mut chain = vec![(FILTERS[usize::from(head[0] & 7) % FILTERS.len()].clone(), params.clone())];
+ if head[0] & 0x80 != 0 {
+ chain.insert(0, (FILTERS[usize::from(head[0] >> 3 & 7) % FILTERS.len()].clone(), Params::default()));
+ }
+ let max = 1 << 22;
+ if let Ok(out) = decode(&chain, body, max) {
+ assert!(out.len() <= max);
+ }
+ if let Ok((out, _)) = decode_tolerant(&chain, body, max) {
+ assert!(out.len() <= max);
+ }
+});
diff --git a/crates/filters/src/ascii85.rs b/crates/filters/src/ascii85.rs
new file mode 100644
index 000000000..ff3b39219
--- /dev/null
+++ b/crates/filters/src/ascii85.rs
@@ -0,0 +1,156 @@
+//! ASCII85Decode (ISO 32000-2 §7.4.3): groups of five characters `!`..`u` encode four
+//! bytes base-85, `z` stands for four zero bytes, `~>` is EOD, white space is ignored and
+//! a final partial group of n characters (2–5) yields n − 1 bytes. A leading `<~` and a
+//! missing EOD are tolerated.
+
+use crate::{Failure, Step, is_pdf_whitespace};
+
+const NAME: &str = "ASCII85Decode";
+
+fn push(out: &mut Vec, bytes: &[u8], max: usize) -> Result<(), Failure> {
+ if out.len() + bytes.len() > max {
+ return Err(Failure::limit(max));
+ }
+ out.extend_from_slice(bytes);
+ Ok(())
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+ let start = data.iter().position(|&b| !is_pdf_whitespace(b)).unwrap_or(data.len());
+ let body = data[start..].strip_prefix(b"<~").unwrap_or(&data[start..]);
+ let mut out = Vec::with_capacity((body.len() / 5 * 4 + 4).min(max));
+ let mut acc: u64 = 0;
+ let mut n = 0usize;
+ for (i, &b) in body.iter().enumerate() {
+ match b {
+ b'!'..=b'u' => {
+ acc = acc * 85 + u64::from(b - b'!');
+ n += 1;
+ if n == 5 {
+ let Ok(v) = u32::try_from(acc) else {
+ return Err(Failure::corrupt(NAME, format!("group ending at {i} exceeds 2^32"), out));
+ };
+ push(&mut out, &v.to_be_bytes(), max)?;
+ acc = 0;
+ n = 0;
+ }
+ }
+ b'z' if n == 0 => push(&mut out, &[0; 4], max)?,
+ b'z' => return Err(Failure::corrupt(NAME, format!("'z' inside a group at {i}"), out)),
+ // `~` starts the EOD marker; tolerate it without the following `>`.
+ b'~' => break,
+ b if is_pdf_whitespace(b) => {}
+ b => return Err(Failure::corrupt(NAME, format!("invalid character 0x{b:02x} at {i}"), out)),
+ }
+ }
+ match n {
+ 0 => {}
+ 1 => return Err(Failure::corrupt(NAME, "final group has a single character", out)),
+ _ => {
+ // Pad with `u` (84) to a full group and keep n − 1 bytes.
+ for _ in n..5 {
+ acc = acc * 85 + 84;
+ }
+ let Ok(v) = u32::try_from(acc) else {
+ return Err(Failure::corrupt(NAME, "final group exceeds 2^32", out));
+ };
+ push(&mut out, &v.to_be_bytes()[..n - 1], max)?;
+ }
+ }
+ Ok(out)
+}
+
+/// Encodes with `z` for zero groups, a newline every 75 characters, and a `~>` EOD.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+ let mut out = Vec::with_capacity(data.len() * 5 / 4 + data.len() / 60 + 8);
+ let mut col = 0;
+ let mut put = |out: &mut Vec, s: &[u8]| {
+ for &c in s {
+ if col == 75 {
+ out.push(b'\n');
+ col = 0;
+ }
+ out.push(c);
+ col += 1;
+ }
+ };
+ for chunk in data.chunks(4) {
+ let mut word = [0u8; 4];
+ word[..chunk.len()].copy_from_slice(chunk);
+ let mut v = u32::from_be_bytes(word);
+ if v == 0 && chunk.len() == 4 {
+ put(&mut out, b"z");
+ continue;
+ }
+ let mut digits = [0u8; 5];
+ for d in digits.iter_mut().rev() {
+ *d = (v % 85) as u8 + b'!';
+ v /= 85;
+ }
+ put(&mut out, &digits[..chunk.len() + 1]);
+ }
+ out.extend_from_slice(b"~>");
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::FilterError;
+
+ fn dec(d: &[u8]) -> Result, FilterError> {
+ decode(d, 1000).map_err(|f| f.error)
+ }
+
+ #[test]
+ fn vectors() {
+ // "Man " = 0x4D616E20 = 24·85⁴ + 73·85³ + 80·85² + 78·85 + 61 -> "9jqo^".
+ assert_eq!(dec(b"9jqo^~>").unwrap(), b"Man ");
+ assert_eq!(encode(b"Man "), b"9jqo^~>");
+ // "M" padded to 0x4D000000 -> "9`" (24, 63); decoding pads with 'u'.
+ assert_eq!(encode(b"M"), b"9`~>");
+ assert_eq!(dec(b"9`~>").unwrap(), b"M");
+ assert_eq!(dec(b"z~>").unwrap(), [0; 4]);
+ assert_eq!(encode(&[0; 4]), b"z~>");
+ // A partial zero group is not `z`.
+ assert_eq!(encode(&[0; 3]), b"!!!!~>");
+ assert_eq!(dec(b"!!!!~>").unwrap(), [0; 3]);
+ assert_eq!(dec(b"s8W-!~>").unwrap(), [0xff; 4]);
+ }
+
+ #[test]
+ fn syntax_tolerance() {
+ assert_eq!(dec(b"<~9jqo^~>").unwrap(), b"Man ");
+ assert_eq!(dec(b" \n<~9j\r\nq o\t^z~>").unwrap(), b"Man \0\0\0\0");
+ assert_eq!(dec(b"9jqo^").unwrap(), b"Man "); // missing EOD
+ assert_eq!(dec(b"9jqo^~").unwrap(), b"Man "); // bare ~
+ assert_eq!(dec(b"9jqo^~>garbage{").unwrap(), b"Man ");
+ assert_eq!(dec(b"~>").unwrap(), b"");
+ assert_eq!(dec(b"").unwrap(), b"");
+ }
+
+ #[test]
+ fn corrupt() {
+ assert!(matches!(dec(b"9jz~>"), Err(FilterError::Corrupt { .. })));
+ assert!(matches!(dec(b"9jqo^9~>"), Err(FilterError::Corrupt { .. })));
+ assert!(matches!(dec(b"uuuuu~>"), Err(FilterError::Corrupt { .. }))); // > 2^32 - 1
+ assert!(matches!(dec(b"9jqo^v~>"), Err(FilterError::Corrupt { .. })));
+ let f = decode(b"9jqo^{", 100).err().unwrap();
+ assert_eq!(f.partial, b"Man ");
+ }
+
+ #[test]
+ fn limit() {
+ assert!(matches!(decode(b"zz", 7).map_err(|f| f.error), Err(FilterError::LimitExceeded(7))));
+ assert!(matches!(decode(b"z9`", 4).map_err(|f| f.error), Err(FilterError::LimitExceeded(4))));
+ assert_eq!(decode(b"zz", 8).ok().unwrap().len(), 8);
+ }
+
+ #[test]
+ fn line_wrapping() {
+ let data: Vec = (0..=255u8).collect();
+ let e = encode(&data);
+ assert!(e.split(|&b| b == b'\n').all(|l| l.len() <= 77));
+ assert_eq!(dec(&e).unwrap_or_default().len(), 256);
+ }
+}
diff --git a/crates/filters/src/asciihex.rs b/crates/filters/src/asciihex.rs
new file mode 100644
index 000000000..5ba0574e8
--- /dev/null
+++ b/crates/filters/src/asciihex.rs
@@ -0,0 +1,106 @@
+//! ASCIIHexDecode (ISO 32000-2 §7.4.2): pairs of hex digits, white space ignored,
+//! `>` is EOD, an odd final digit is padded with 0. A missing EOD is tolerated.
+
+use crate::{Failure, Step, is_pdf_whitespace};
+
+const NAME: &str = "ASCIIHexDecode";
+
+fn nibble(b: u8) -> Option {
+ match b {
+ b'0'..=b'9' => Some(b - b'0'),
+ b'a'..=b'f' => Some(b - b'a' + 10),
+ b'A'..=b'F' => Some(b - b'A' + 10),
+ _ => None,
+ }
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+ let mut out = Vec::with_capacity((data.len() / 2).min(max));
+ let mut hi: Option = None;
+ for (i, &b) in data.iter().enumerate() {
+ if is_pdf_whitespace(b) {
+ continue;
+ }
+ if b == b'>' {
+ break;
+ }
+ let Some(n) = nibble(b) else {
+ return Err(Failure::corrupt(NAME, format!("invalid character 0x{b:02x} at offset {i}"), out));
+ };
+ match hi.take() {
+ None => hi = Some(n),
+ Some(h) => {
+ if out.len() == max {
+ return Err(Failure::limit(max));
+ }
+ out.push(h << 4 | n);
+ }
+ }
+ }
+ if let Some(h) = hi {
+ if out.len() == max {
+ return Err(Failure::limit(max));
+ }
+ out.push(h << 4);
+ }
+ Ok(out)
+}
+
+/// Upper-case hex, a newline every 64 digits, terminated by `>`.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+ const HEX: &[u8; 16] = b"0123456789ABCDEF";
+ let mut out = Vec::with_capacity(data.len() * 2 + data.len() / 32 + 1);
+ for (i, &b) in data.iter().enumerate() {
+ if i > 0 && i % 32 == 0 {
+ out.push(b'\n');
+ }
+ out.push(HEX[usize::from(b >> 4)]);
+ out.push(HEX[usize::from(b & 15)]);
+ }
+ out.push(b'>');
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::FilterError;
+
+ fn dec(d: &[u8]) -> Result, FilterError> {
+ decode(d, 1000).map_err(|f| f.error)
+ }
+
+ #[test]
+ fn vectors() {
+ assert_eq!(dec(b"48656c6C6F>").unwrap(), b"Hello");
+ assert_eq!(dec(b" 4 8\n6\r5\t6\x0c c\x006c 6f >").unwrap(), b"Hello");
+ assert_eq!(dec(b"901FA>").unwrap(), [0x90, 0x1f, 0xa0]);
+ assert_eq!(dec(b"901FA").unwrap(), [0x90, 0x1f, 0xa0]); // missing EOD
+ assert_eq!(dec(b"41>zz garbage after EOD").unwrap(), b"A");
+ assert_eq!(dec(b">").unwrap(), b"");
+ assert_eq!(dec(b"").unwrap(), b"");
+ }
+
+ #[test]
+ fn invalid_char() {
+ let f = decode(b"4142G43>", 1000).err().unwrap();
+ assert!(matches!(f.error, FilterError::Corrupt { .. }));
+ assert_eq!(f.partial, b"AB");
+ }
+
+ #[test]
+ fn limit() {
+ assert!(matches!(decode(b"414243", 2).map_err(|f| f.error), Err(FilterError::LimitExceeded(2))));
+ assert!(matches!(decode(b"41424", 2).map_err(|f| f.error), Err(FilterError::LimitExceeded(2))));
+ assert_eq!(decode(b"4142", 2).ok().unwrap(), b"AB");
+ }
+
+ #[test]
+ fn encoding() {
+ assert_eq!(encode(b"Hello"), b"48656C6C6F>");
+ assert_eq!(encode(b""), b">");
+ let long = encode(&[0xab; 40]);
+ assert_eq!(long[64], b'\n');
+ assert_eq!(dec(&long).unwrap(), [0xab; 40]);
+ }
+}
diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs
new file mode 100644
index 000000000..7bb8f53fc
--- /dev/null
+++ b/crates/filters/src/flate.rs
@@ -0,0 +1,166 @@
+//! FlateDecode (ISO 32000-2 §7.4.4): zlib (RFC 1950) wrapping deflate (RFC 1951).
+//!
+//! Real-world quirks handled:
+//! - the Adler-32 trailer is not required (some producers omit or corrupt it), because the
+//! deflate stream is inflated raw after the 2-byte zlib header;
+//! - streams without a valid zlib header are inflated as raw deflate;
+//! - data after the end of the deflate stream is ignored.
+
+use std::io::Write;
+
+use flate2::{Compression, Decompress, FlushDecompress, Status};
+
+use crate::{Failure, Step};
+
+const NAME: &str = "FlateDecode";
+
+/// Checks the RFC 1950 header: CM = 8, CINFO ≤ 7 and the FCHECK multiple-of-31 rule.
+fn zlib_header(data: &[u8]) -> Option<(u8, u8)> {
+ let (&cmf, &flg) = (data.first()?, data.get(1)?);
+ let ok = cmf & 0x0f == 8 && cmf >> 4 <= 7 && (u16::from(cmf) << 8 | u16::from(flg)) % 31 == 0;
+ ok.then_some((cmf, flg))
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+ match zlib_header(data) {
+ Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
+ Some(_) => match inflate_raw(&data[2..], max) {
+ // A header that only looked valid: retry the whole input as raw deflate.
+ Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f),
+ r => r,
+ },
+ None => inflate_raw(data, max),
+ }
+}
+
+fn inflate_raw(input: &[u8], max: usize) -> Step {
+ let mut d = Decompress::new(false);
+ let mut out: Vec = Vec::new();
+ // One byte beyond the limit lets us tell "exactly max" from "more than max".
+ let cap = max.saturating_add(1);
+ loop {
+ if out.len() == out.capacity() {
+ if out.len() >= cap {
+ return Err(Failure::limit(max));
+ }
+ let grow = out.capacity().max(input.len().saturating_mul(2)).max(4096).min(cap - out.len());
+ out.reserve_exact(grow);
+ }
+ let consumed = d.total_in() as usize;
+ let produced = out.len();
+ let res = d.decompress_vec(&input[consumed.min(input.len())..], &mut out, FlushDecompress::None);
+ if out.len() > max {
+ return Err(Failure::limit(max));
+ }
+ match res {
+ Err(e) => return Err(Failure::corrupt(NAME, e.to_string(), out)),
+ Ok(Status::StreamEnd) => return Ok(out),
+ Ok(Status::Ok | Status::BufError) => {
+ let progressed = d.total_in() as usize != consumed || out.len() != produced;
+ let room_left = out.len() < out.capacity();
+ if room_left && (d.total_in() as usize >= input.len() || !progressed) {
+ return Err(Failure::corrupt(NAME, "unexpected end of deflate data", out));
+ }
+ }
+ }
+ }
+}
+
+/// zlib at level 6.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+ let mut e = flate2::write::ZlibEncoder::new(Vec::with_capacity(data.len() / 2 + 64), Compression::new(6));
+ // Writing into a Vec cannot fail.
+ e.write_all(data).expect("in-memory write");
+ e.finish().expect("in-memory write")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::FilterError;
+
+ fn dec(d: &[u8]) -> Result, FilterError> {
+ decode(d, 1 << 24).map_err(|f| f.error)
+ }
+
+ #[test]
+ fn known_zlib_vector() {
+ // zlib("a"): header 78 9C, fixed-Huffman block, Adler-32 0x00620062.
+ assert_eq!(dec(&[0x78, 0x9c, 0x4b, 0x04, 0x00, 0x00, 0x62, 0x00, 0x62]).unwrap(), b"a");
+ }
+
+ #[test]
+ fn hand_built_stored_block() {
+ // RFC 1951 §3.2.4 stored block: BFINAL=1 BTYPE=00, LEN=5, NLEN=!5, "hello",
+ // then Adler-32("hello") = 0x062C0215.
+ let v = [0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, b'h', b'e', b'l', b'l', b'o', 0x06, 0x2c, 0x02, 0x15];
+ assert_eq!(dec(&v).unwrap(), b"hello");
+ // Missing Adler-32 is tolerated.
+ assert_eq!(dec(&v[..12]).unwrap(), b"hello");
+ // Wrong Adler-32 is tolerated (as in other readers).
+ let mut bad = v;
+ bad[15] ^= 0xff;
+ assert_eq!(dec(&bad).unwrap(), b"hello");
+ // Trailing garbage after the stream is ignored.
+ let mut trailing = v.to_vec();
+ trailing.extend_from_slice(b"\r\nendstream");
+ assert_eq!(dec(&trailing).unwrap(), b"hello");
+ }
+
+ #[test]
+ fn raw_deflate_fallback() {
+ let data = b"raw deflate without any zlib header, repeated repeated repeated".repeat(4);
+ let mut e = flate2::write::DeflateEncoder::new(Vec::new(), Compression::new(6));
+ e.write_all(&data).unwrap();
+ let raw = e.finish().unwrap();
+ assert!(zlib_header(&raw).is_none());
+ assert_eq!(dec(&raw).unwrap(), data);
+ }
+
+ #[test]
+ fn round_trip_and_missing_checksum() {
+ let data: Vec = (0..50_000u32).map(|i| (i * i % 251) as u8).collect();
+ let z = encode(&data);
+ assert_eq!(&z[..2], &[0x78, 0x9c]);
+ assert_eq!(dec(&z).unwrap(), data);
+ assert_eq!(dec(&z[..z.len() - 4]).unwrap(), data);
+ assert_eq!(dec(&z[..z.len() - 2]).unwrap(), data);
+ }
+
+ #[test]
+ fn empty_input() {
+ assert_eq!(dec(&encode(b"")).unwrap(), b"");
+ assert!(matches!(dec(b""), Err(FilterError::Corrupt { .. })));
+ }
+
+ #[test]
+ fn corrupt_and_truncated() {
+ let data: Vec = (0..10_000u32).map(|i| (i % 13) as u8).collect();
+ let z = encode(&data);
+ match decode(&z[..z.len() / 2], 1 << 24) {
+ Err(f) => {
+ assert!(matches!(f.error, FilterError::Corrupt { .. }));
+ assert_eq!(&data[..f.partial.len()], &f.partial[..]);
+ }
+ Ok(_) => panic!("truncated stream decoded"),
+ }
+ // BTYPE = 11 is reserved (RFC 1951 §3.2.3).
+ assert!(matches!(dec(&[0x78, 0x9c, 0x07, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
+ // Preset dictionary.
+ assert!(matches!(dec(&[0x78, 0x20, 0, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
+ }
+
+ #[test]
+ fn bomb_is_bounded() {
+ let z = encode(&vec![0u8; 16 << 20]);
+ assert!(z.len() < 32 * 1024);
+ assert!(matches!(dec_limit(&z, 1 << 20), Err(FilterError::LimitExceeded(n)) if n == 1 << 20));
+ assert_eq!(dec_limit(&z, 16 << 20).unwrap().len(), 16 << 20);
+ assert!(matches!(dec_limit(&z, (16 << 20) - 1), Err(FilterError::LimitExceeded(_))));
+ assert!(matches!(dec_limit(&z, 0), Err(FilterError::LimitExceeded(0))));
+ }
+
+ fn dec_limit(d: &[u8], max: usize) -> Result, FilterError> {
+ decode(d, max).map_err(|f| f.error)
+ }
+}
diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs
new file mode 100644
index 000000000..ce91500ee
--- /dev/null
+++ b/crates/filters/src/lib.rs
@@ -0,0 +1,325 @@
+//! PDF stream filters (ISO 32000-2 §7.4).
+//!
+//! Layer L0, standalone: this crate depends on no other PrintCraft crate.
+//!
+//! Decoding covers the general-purpose filters (`FlateDecode`, `LZWDecode` with
+//! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`).
+//! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`),
+//! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so
+//! callers keep such data encoded.
+//!
+//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
+//! defence) and never panics on malformed input.
+#![forbid(unsafe_code)]
+
+mod ascii85;
+mod asciihex;
+mod flate;
+mod lzw;
+mod predictor;
+mod runlength;
+
+use std::borrow::Cow;
+
+/// A PDF stream filter, identified by its `/Filter` name.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub enum Filter {
+ Flate,
+ Lzw,
+ AsciiHex,
+ Ascii85,
+ RunLength,
+ Dct,
+ Jpx,
+ Jbig2,
+ CcittFax,
+ Crypt,
+ /// Any other name (kept verbatim, lossily converted to UTF-8).
+ Unknown(String),
+}
+
+impl Filter {
+ /// Parses a filter name. Accepts the full names (ISO 32000-2 Table 6) and the
+ /// inline-image abbreviations (Table 92): `Fl`, `LZW`, `AHx`, `A85`, `RL`, `DCT`, `CCF`.
+ /// A leading `/` is ignored.
+ pub fn from_name(name: &[u8]) -> Filter {
+ let n = name.strip_prefix(b"/").unwrap_or(name);
+ match n {
+ b"FlateDecode" | b"Fl" => Filter::Flate,
+ b"LZWDecode" | b"LZW" => Filter::Lzw,
+ b"ASCIIHexDecode" | b"AHx" => Filter::AsciiHex,
+ b"ASCII85Decode" | b"A85" => Filter::Ascii85,
+ b"RunLengthDecode" | b"RL" => Filter::RunLength,
+ b"DCTDecode" | b"DCT" => Filter::Dct,
+ b"JPXDecode" => Filter::Jpx,
+ b"JBIG2Decode" => Filter::Jbig2,
+ b"CCITTFaxDecode" | b"CCF" => Filter::CcittFax,
+ b"Crypt" => Filter::Crypt,
+ other => Filter::Unknown(String::from_utf8_lossy(other).into_owned()),
+ }
+ }
+
+ /// The full PDF name (without the leading `/`).
+ pub fn name(&self) -> &str {
+ match self {
+ Filter::Flate => "FlateDecode",
+ Filter::Lzw => "LZWDecode",
+ Filter::AsciiHex => "ASCIIHexDecode",
+ Filter::Ascii85 => "ASCII85Decode",
+ Filter::RunLength => "RunLengthDecode",
+ Filter::Dct => "DCTDecode",
+ Filter::Jpx => "JPXDecode",
+ Filter::Jbig2 => "JBIG2Decode",
+ Filter::CcittFax => "CCITTFaxDecode",
+ Filter::Crypt => "Crypt",
+ Filter::Unknown(s) => s,
+ }
+ }
+
+ /// True for the image codecs (DCT, JPX, JBIG2, CCITT fax).
+ pub fn is_image_codec(&self) -> bool {
+ matches!(self, Filter::Dct | Filter::Jpx | Filter::Jbig2 | Filter::CcittFax)
+ }
+
+ fn is_decodable(&self) -> bool {
+ matches!(self, Filter::Flate | Filter::Lzw | Filter::AsciiHex | Filter::Ascii85 | Filter::RunLength)
+ }
+}
+
+/// `/DecodeParms` values relevant to the general-purpose filters (ISO 32000-2 Tables 8–9).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Params {
+ /// 1 = none, 2 = TIFF predictor 2, 10–15 = PNG predictors.
+ pub predictor: i64,
+ pub colors: i64,
+ pub bits_per_component: i64,
+ pub columns: i64,
+ /// LZW only: 1 (default) switches code width one code early, 0 does not.
+ pub early_change: i64,
+}
+
+impl Default for Params {
+ fn default() -> Self {
+ Params { predictor: 1, colors: 1, bits_per_component: 8, columns: 1, early_change: 1 }
+ }
+}
+
+/// Errors from decoding or encoding.
+#[derive(Debug, thiserror::Error)]
+pub enum FilterError {
+ #[error("{filter}: corrupt data: {detail}")]
+ Corrupt { filter: &'static str, detail: String },
+ #[error("decoded output exceeds the limit of {0} bytes")]
+ LimitExceeded(usize),
+ #[error("unsupported filter: {0}")]
+ Unsupported(String),
+}
+
+/// A decoder failure carrying whatever was decoded before the error.
+pub(crate) struct Failure {
+ pub(crate) error: FilterError,
+ pub(crate) partial: Vec,
+}
+
+impl Failure {
+ pub(crate) fn corrupt(filter: &'static str, detail: impl Into, partial: Vec) -> Self {
+ Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial }
+ }
+
+ pub(crate) fn limit(max: usize) -> Self {
+ Failure { error: FilterError::LimitExceeded(max), partial: Vec::new() }
+ }
+}
+
+pub(crate) type Step = Result, Failure>;
+
+/// Decodes `data` through `chain` (first element applied first).
+///
+/// Image codecs, `Crypt` and unknown filters anywhere in the chain give
+/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds
+/// every intermediate buffer.
+pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> {
+ run(chain, data, max_output, true).map(|(v, _)| v)
+}
+
+/// Like [`decode`], but on corrupt data returns what was decoded before the error and
+/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
+/// chain still run on the partial data. Limit and unsupported errors are still errors.
+pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> {
+ run(chain, data, max_output, false)
+}
+
+fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> {
+ if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) {
+ return Err(FilterError::Unsupported(f.name().to_owned()));
+ }
+ let mut cur: Cow<[u8]> = Cow::Borrowed(data);
+ let mut partial = false;
+ for (filter, params) in chain {
+ cur = Cow::Owned(match decode_one(filter, params, &cur, max) {
+ Ok(v) => v,
+ Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => {
+ partial = true;
+ out
+ }
+ Err(f) => return Err(f.error),
+ });
+ }
+ Ok((cur.into_owned(), partial))
+}
+
+fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step {
+ match filter {
+ Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"),
+ Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"),
+ Filter::AsciiHex => asciihex::decode(data, max),
+ Filter::Ascii85 => ascii85::decode(data, max),
+ Filter::RunLength => runlength::decode(data, max),
+ other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }),
+ }
+}
+
+/// Applies the predictor to a decoder's result, including to partial output on failure.
+fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
+ match step {
+ Ok(v) => predictor::decode(params, v, name),
+ Err(Failure { error, partial }) => {
+ let partial = match predictor::decode(params, partial, name) {
+ Ok(v) => v,
+ Err(f) => f.partial,
+ };
+ Err(Failure { error, partial })
+ }
+ }
+}
+
+/// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex,
+/// ASCII85, RunLength. For Flate and LZW, `params.predictor >= 2` applies the
+/// predictor first (TIFF 2, PNG 10–15; 15 chooses the per-row optimum).
+pub fn encode(filter: &Filter, params: &Params, data: &[u8]) -> Result, FilterError> {
+ match filter {
+ Filter::Flate => Ok(flate::encode(&predictor::encode(params, data)?)),
+ Filter::Lzw => Ok(lzw::encode(&predictor::encode(params, data)?, params.early_change != 0)),
+ Filter::AsciiHex => Ok(asciihex::encode(data)),
+ Filter::Ascii85 => Ok(ascii85::encode(data)),
+ Filter::RunLength => Ok(runlength::encode(data)),
+ other => Err(FilterError::Unsupported(format!("encoding with {}", other.name()))),
+ }
+}
+
+/// zlib (RFC 1950) at compression level 6.
+pub fn encode_flate(data: &[u8]) -> Vec {
+ flate::encode(data)
+}
+
+/// PDF white-space characters (ISO 32000-2 Table 1).
+pub(crate) fn is_pdf_whitespace(b: u8) -> bool {
+ matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn names_and_abbreviations() {
+ for (full, abbr, f) in [
+ (&b"FlateDecode"[..], &b"Fl"[..], Filter::Flate),
+ (b"LZWDecode", b"LZW", Filter::Lzw),
+ (b"ASCIIHexDecode", b"AHx", Filter::AsciiHex),
+ (b"ASCII85Decode", b"A85", Filter::Ascii85),
+ (b"RunLengthDecode", b"RL", Filter::RunLength),
+ (b"DCTDecode", b"DCT", Filter::Dct),
+ (b"CCITTFaxDecode", b"CCF", Filter::CcittFax),
+ ] {
+ assert_eq!(Filter::from_name(full), f);
+ assert_eq!(Filter::from_name(abbr), f);
+ assert_eq!(f.name().as_bytes(), full);
+ }
+ assert_eq!(Filter::from_name(b"/JPXDecode"), Filter::Jpx);
+ assert_eq!(Filter::from_name(b"JBIG2Decode"), Filter::Jbig2);
+ assert_eq!(Filter::from_name(b"Crypt"), Filter::Crypt);
+ assert_eq!(Filter::from_name(b"Foo"), Filter::Unknown("Foo".into()));
+ assert_eq!(Filter::Unknown("Foo".into()).name(), "Foo");
+ assert!(Filter::Dct.is_image_codec() && Filter::CcittFax.is_image_codec());
+ assert!(!Filter::Flate.is_image_codec() && !Filter::Crypt.is_image_codec());
+ }
+
+ #[test]
+ fn default_params() {
+ let p = Params::default();
+ assert_eq!((p.predictor, p.colors, p.bits_per_component, p.columns, p.early_change), (1, 1, 8, 1, 1));
+ }
+
+ #[test]
+ fn unsupported_filters() {
+ for f in [Filter::Dct, Filter::Jpx, Filter::Jbig2, Filter::CcittFax, Filter::Crypt, Filter::Unknown("X".into())] {
+ let chain = [(Filter::AsciiHex, Params::default()), (f.clone(), Params::default())];
+ assert!(matches!(decode(&chain, b"00>", 100), Err(FilterError::Unsupported(_))));
+ assert!(matches!(decode_tolerant(&chain, b"00>", 100), Err(FilterError::Unsupported(_))));
+ assert!(matches!(encode(&f, &Params::default(), b"x"), Err(FilterError::Unsupported(_))));
+ }
+ }
+
+ #[test]
+ fn empty_chain_is_identity() {
+ assert_eq!(decode(&[], b"abc", 1).unwrap(), b"abc");
+ }
+
+ #[test]
+ fn chain_ascii85_flate() {
+ let data = b"Hello, chained filters! Hello, chained filters!".repeat(10);
+ let z = encode_flate(&data);
+ let a = encode(&Filter::Ascii85, &Params::default(), &z).unwrap();
+ let chain = [(Filter::Ascii85, Params::default()), (Filter::Flate, Params::default())];
+ assert_eq!(decode(&chain, &a, 1 << 20).unwrap(), data);
+ }
+
+ #[test]
+ fn chain_three_filters_with_predictor() {
+ let p = Params { predictor: 12, colors: 3, columns: 5, ..Params::default() };
+ let data: Vec = (0..300u32).map(|i| (i * 7 % 251) as u8).collect();
+ let lzw = encode(&Filter::Lzw, &p, &data).unwrap();
+ let rl = encode(&Filter::RunLength, &Params::default(), &lzw).unwrap();
+ let hex = encode(&Filter::AsciiHex, &Params::default(), &rl).unwrap();
+ let chain = [(Filter::AsciiHex, Params::default()), (Filter::RunLength, Params::default()), (Filter::Lzw, p)];
+ assert_eq!(decode(&chain, &hex, 1 << 20).unwrap(), data);
+ }
+
+ #[test]
+ fn limit_applies_to_intermediate_buffers() {
+ let hex = encode(&Filter::AsciiHex, &Params::default(), &[7u8; 100]).unwrap();
+ let chain = [(Filter::AsciiHex, Params::default())];
+ assert!(matches!(decode(&chain, &hex, 99), Err(FilterError::LimitExceeded(99))));
+ assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100);
+ }
+
+ #[test]
+ fn tolerant_flate_returns_partial() {
+ let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
+ let mut z = encode_flate(&data);
+ let cut = z.len() / 2;
+ z.truncate(cut);
+ let chain = [(Filter::Flate, Params::default())];
+ assert!(matches!(decode(&chain, &z, 1 << 20), Err(FilterError::Corrupt { .. })));
+ let (out, partial) = decode_tolerant(&chain, &z, 1 << 20).unwrap();
+ assert!(partial);
+ assert!(!out.is_empty() && out.len() < data.len());
+ assert_eq!(&data[..out.len()], &out[..]);
+ // Complete data is not partial.
+ let (out, partial) = decode_tolerant(&chain, &encode_flate(&data), 1 << 20).unwrap();
+ assert!(!partial);
+ assert_eq!(out, data);
+ }
+
+ #[test]
+ fn tolerant_partial_flows_through_predictor() {
+ let p = Params { predictor: 12, columns: 4, ..Params::default() };
+ let data: Vec = (0..4000u32).map(|i| (i * 31 % 256) as u8).collect();
+ let mut z = encode(&Filter::Flate, &p, &data).unwrap();
+ z.truncate(z.len() - 20);
+ let (out, partial) = decode_tolerant(&[(Filter::Flate, p)], &z, 1 << 20).unwrap();
+ assert!(partial);
+ assert_eq!(&data[..out.len()], &out[..]);
+ }
+}
diff --git a/crates/filters/src/lzw.rs b/crates/filters/src/lzw.rs
new file mode 100644
index 000000000..9653ac2fb
--- /dev/null
+++ b/crates/filters/src/lzw.rs
@@ -0,0 +1,314 @@
+//! LZWDecode (ISO 32000-2 §7.4.4.2): variable-width codes of 9–12 bits, MSB first,
+//! 256 = clear-table, 257 = EOD, first free code 258. With `EarlyChange` 1 (the
+//! default) the code width grows one code early.
+//!
+//! Tolerated: data ending without EOD, a missing initial clear-table code, a table that
+//! fills up without a clear-table code (entries are then no longer added).
+
+use std::collections::HashMap;
+
+use crate::{Failure, Step};
+
+const NAME: &str = "LZWDecode";
+const CLEAR: u16 = 256;
+const EOD: u16 = 257;
+const FIRST: usize = 258;
+const TABLE: usize = 4096;
+/// The encoder emits clear-table when its next free code reaches this value.
+const ENCODER_RESET_AT: usize = TABLE;
+
+/// The code width a decoder uses when its next free code is `next`.
+fn width(next: usize, early: bool) -> u32 {
+ match next + usize::from(early) {
+ 0..512 => 9,
+ 512..1024 => 10,
+ 1024..2048 => 11,
+ _ => 12,
+ }
+}
+
+struct BitReader<'a> {
+ data: &'a [u8],
+ pos: usize,
+ acc: u32,
+ bits: u32,
+}
+
+impl BitReader<'_> {
+ fn read(&mut self, n: u32) -> Option {
+ while self.bits < n {
+ let &b = self.data.get(self.pos)?;
+ self.pos += 1;
+ self.acc = (self.acc << 8) | u32::from(b);
+ self.bits += 8;
+ }
+ self.bits -= n;
+ let v = (self.acc >> self.bits) & ((1 << n) - 1);
+ self.acc &= (1 << self.bits) - 1;
+ Some(v as u16)
+ }
+}
+
+pub(crate) fn decode(data: &[u8], early: bool, max: usize) -> Step {
+ // Table entry = (prefix code, last byte, first byte, length).
+ let mut prefix = vec![0u16; TABLE];
+ let mut last = vec![0u8; TABLE];
+ let mut first = vec![0u8; TABLE];
+ let mut len = vec![0u16; TABLE];
+ for i in 0..256 {
+ last[i] = i as u8;
+ first[i] = i as u8;
+ len[i] = 1;
+ }
+ let mut next = FIRST;
+ let mut w = 9;
+ let mut prev: Option = None;
+ let mut r = BitReader { data, pos: 0, acc: 0, bits: 0 };
+ let mut out = Vec::with_capacity(data.len().saturating_mul(2).min(max));
+
+ while let Some(code) = r.read(w) {
+ match code {
+ CLEAR => {
+ next = FIRST;
+ w = 9;
+ prev = None;
+ continue;
+ }
+ EOD => break,
+ _ => {}
+ }
+ let code = usize::from(code);
+ if let Some(p) = prev {
+ let head = if code < next {
+ first[code]
+ } else if code == next && next < TABLE {
+ first[p]
+ } else {
+ return Err(Failure::corrupt(NAME, format!("code {code} not in table (next {next})"), out));
+ };
+ if next < TABLE {
+ prefix[next] = p as u16;
+ last[next] = head;
+ first[next] = first[p];
+ len[next] = len[p] + 1;
+ next += 1;
+ }
+ } else if code >= 256 {
+ return Err(Failure::corrupt(NAME, format!("code {code} before any table entry"), out));
+ }
+ // Emit the string for `code`, walking the prefix chain backwards.
+ let n = usize::from(len[code]);
+ if out.len() + n > max {
+ return Err(Failure::limit(max));
+ }
+ let start = out.len();
+ out.resize(start + n, 0);
+ let mut c = code;
+ for slot in out[start..].iter_mut().rev() {
+ *slot = last[c];
+ c = usize::from(prefix[c]);
+ }
+ prev = Some(code);
+ w = width(next, early);
+ }
+ Ok(out)
+}
+
+struct BitWriter {
+ out: Vec,
+ acc: u32,
+ bits: u32,
+}
+
+impl BitWriter {
+ fn write(&mut self, code: u16, n: u32) {
+ self.acc = (self.acc << n) | u32::from(code);
+ self.bits += n;
+ while self.bits >= 8 {
+ self.bits -= 8;
+ self.out.push((self.acc >> self.bits) as u8);
+ }
+ self.acc &= (1 << self.bits) - 1;
+ }
+
+ fn finish(mut self) -> Vec {
+ if self.bits > 0 {
+ self.out.push((self.acc << (8 - self.bits)) as u8);
+ }
+ self.out
+ }
+}
+
+/// Greedy LZW with a leading clear-table code and a trailing EOD.
+pub(crate) fn encode(data: &[u8], early: bool) -> Vec {
+ let mut wr = BitWriter { out: Vec::with_capacity(data.len() / 2 + 8), acc: 0, bits: 0 };
+ let mut dict: HashMap<(u16, u8), u16> = HashMap::new();
+ // `next` is the encoder's next free code; `dnext` mirrors the decoder's, which lags
+ // one entry behind (the decoder adds an entry only once it sees the following code).
+ let mut next = FIRST;
+ let mut dnext = FIRST;
+ let mut codes_since_clear = 0usize;
+ wr.write(CLEAR, 9);
+ let emit = |wr: &mut BitWriter, code: u16, dnext: &mut usize, since: &mut usize| {
+ wr.write(code, width(*dnext, early));
+ if *since > 0 && *dnext < TABLE {
+ *dnext += 1;
+ }
+ *since += 1;
+ };
+ let Some((&b0, rest)) = data.split_first() else {
+ wr.write(EOD, 9);
+ return wr.finish();
+ };
+ let mut cur = u16::from(b0);
+ for &b in rest {
+ if let Some(&c) = dict.get(&(cur, b)) {
+ cur = c;
+ continue;
+ }
+ emit(&mut wr, cur, &mut dnext, &mut codes_since_clear);
+ dict.insert((cur, b), next as u16);
+ next += 1;
+ if next == ENCODER_RESET_AT {
+ wr.write(CLEAR, width(dnext, early));
+ dict.clear();
+ next = FIRST;
+ dnext = FIRST;
+ codes_since_clear = 0;
+ }
+ cur = u16::from(b);
+ }
+ emit(&mut wr, cur, &mut dnext, &mut codes_since_clear);
+ wr.write(EOD, width(dnext, early));
+ wr.finish()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::FilterError;
+
+ fn dec(d: &[u8], early: bool) -> Result, FilterError> {
+ decode(d, early, 1 << 26).map_err(|f| f.error)
+ }
+
+ /// ISO 32000-2 §7.4.4.2 example: codes 256 45 258 258 65 259 66 257.
+ const SPEC_IN: [u8; 10] = [45, 45, 45, 45, 45, 65, 45, 45, 45, 66];
+ const SPEC_OUT: [u8; 9] = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01];
+
+ #[test]
+ fn spec_example() {
+ assert_eq!(dec(&SPEC_OUT, true).unwrap(), SPEC_IN);
+ assert_eq!(encode(&SPEC_IN, true), SPEC_OUT);
+ // Below 511 codes the early-change setting makes no difference.
+ assert_eq!(dec(&SPEC_OUT, false).unwrap(), SPEC_IN);
+ }
+
+ #[test]
+ fn missing_eod_and_clear() {
+ // Drop the EOD (last 9 bits span the final 2 bytes; keep bits for 66 only).
+ let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+ for c in [45u16, 258, 258, 65, 259, 66] {
+ w.write(c, 9);
+ }
+ assert_eq!(dec(&w.finish(), true).unwrap(), SPEC_IN);
+ }
+
+ #[test]
+ fn invalid_codes() {
+ let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+ for c in [256u16, 65, 300] {
+ w.write(c, 9);
+ }
+ let f = decode(&w.finish(), true, 1000).err().unwrap();
+ assert!(matches!(f.error, FilterError::Corrupt { .. }));
+ assert_eq!(f.partial, b"A");
+ let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+ w.write(258, 9);
+ assert!(matches!(dec(&w.finish(), true), Err(FilterError::Corrupt { .. })));
+ }
+
+ #[test]
+ fn widths() {
+ assert_eq!((width(510, true), width(511, true), width(511, false), width(512, false)), (9, 10, 9, 10));
+ assert_eq!((width(1022, true), width(1023, true), width(2047, true), width(4095, false)), (10, 11, 12, 12));
+ }
+
+ fn noisy(n: usize, seed: u32) -> Vec {
+ let mut s = seed;
+ (0..n)
+ .map(|_| {
+ s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345);
+ ((s >> 16) % 7) as u8 * 31
+ })
+ .collect()
+ }
+
+ #[test]
+ fn all_widths_and_clears_round_trip() {
+ for early in [false, true] {
+ for data in [noisy(200_000, 1), (0..=255u8).cycle().take(70_000).collect(), vec![9u8; 100_000]] {
+ let e = encode(&data, early);
+ assert_eq!(dec(&e, early).unwrap(), data);
+ }
+ }
+ }
+
+ #[test]
+ fn cross_check_with_weezl() {
+ use weezl::BitOrder::Msb;
+ for data in [noisy(150_000, 7), SPEC_IN.to_vec(), vec![]] {
+ // early change 1 is the TIFF size switch; 0 is the plain variant.
+ let tiff = weezl::encode::Encoder::with_tiff_size_switch(Msb, 8).encode(&data).unwrap();
+ assert_eq!(dec(&tiff, true).unwrap(), data);
+ let plain = weezl::encode::Encoder::new(Msb, 8).encode(&data).unwrap();
+ assert_eq!(dec(&plain, false).unwrap(), data);
+ let ours = encode(&data, true);
+ assert_eq!(weezl::decode::Decoder::with_tiff_size_switch(Msb, 8).decode(&ours).unwrap(), data);
+ let ours = encode(&data, false);
+ assert_eq!(weezl::decode::Decoder::new(Msb, 8).decode(&ours).unwrap(), data);
+ }
+ }
+
+ #[test]
+ fn full_table_without_clear() {
+ // Hand-roll a stream that fills the table and keeps going at 12 bits.
+ let data = noisy(40_000, 3);
+ let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+ let mut dict: HashMap, u16> = (0..=255u8).map(|b| (vec![b], u16::from(b))).collect();
+ let (mut next, mut dnext, mut since) = (FIRST, FIRST, 0usize);
+ let mut cur: Vec = vec![];
+ let mut put = |w: &mut BitWriter, code: u16, dnext: &mut usize| {
+ w.write(code, width(*dnext, true));
+ if since > 0 && *dnext < TABLE {
+ *dnext += 1;
+ }
+ since += 1;
+ };
+ w.write(CLEAR, 9);
+ for &b in &data {
+ let mut ext = cur.clone();
+ ext.push(b);
+ if dict.contains_key(&ext) {
+ cur = ext;
+ continue;
+ }
+ put(&mut w, dict[&cur], &mut dnext);
+ if next < TABLE {
+ dict.insert(ext, next as u16);
+ next += 1;
+ }
+ cur = vec![b];
+ }
+ put(&mut w, dict[&cur], &mut dnext);
+ w.write(EOD, width(dnext, true));
+ assert_eq!(dec(&w.finish(), true).unwrap(), data);
+ }
+
+ #[test]
+ fn limit() {
+ let e = encode(&vec![0u8; 100_000], true);
+ assert!(matches!(decode(&e, true, 50_000).map_err(|f| f.error), Err(FilterError::LimitExceeded(50_000))));
+ assert_eq!(decode(&e, true, 100_000).ok().unwrap().len(), 100_000);
+ }
+}
diff --git a/crates/filters/src/predictor.rs b/crates/filters/src/predictor.rs
new file mode 100644
index 000000000..f9348941b
--- /dev/null
+++ b/crates/filters/src/predictor.rs
@@ -0,0 +1,332 @@
+//! Predictor functions for FlateDecode and LZWDecode (ISO 32000-2 §7.4.4.4).
+//!
+//! - Predictor 2: TIFF 6.0 horizontal differencing (Section 14), per component, for
+//! 1, 2, 4, 8 and 16 bits per component.
+//! - Predictors 10–15: PNG filters (RFC 2083 §6): each row carries its own filter-type
+//! byte (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth), whatever the predictor value is.
+//!
+//! A final partial row is decoded as far as its data goes.
+
+use crate::{Failure, FilterError, Params, Step};
+
+struct Layout {
+ bpc: usize,
+ colors: usize,
+ /// Bytes per complete pixel, at least 1 (the PNG `bpp`).
+ bpp: usize,
+ /// Bytes per row (without the PNG tag byte).
+ row: usize,
+ /// Samples per row (colors × columns).
+ samples: usize,
+}
+
+fn layout(p: &Params) -> Result {
+ let bpc = match p.bits_per_component {
+ b @ (1 | 2 | 4 | 8 | 16) => b as usize,
+ b => return Err(format!("invalid BitsPerComponent {b}")),
+ };
+ if !(1..=255).contains(&p.colors) {
+ return Err(format!("invalid Colors {}", p.colors));
+ }
+ if p.columns < 1 {
+ return Err(format!("invalid Columns {}", p.columns));
+ }
+ let colors = p.colors as usize;
+ let samples = usize::try_from(p.columns).ok().and_then(|c| c.checked_mul(colors)).ok_or_else(|| format!("Columns {} too large", p.columns))?;
+ let bits = samples.checked_mul(bpc).ok_or_else(|| format!("Columns {} too large", p.columns))?;
+ Ok(Layout { bpc, colors, bpp: (colors * bpc).div_ceil(8), row: bits.div_ceil(8), samples })
+}
+
+/// Undoes the predictor named by `p.predictor` (1 = identity).
+pub(crate) fn decode(p: &Params, data: Vec, name: &'static str) -> Step {
+ match p.predictor {
+ 1 => Ok(data),
+ 2 | 10..=15 => {
+ let l = layout(p).map_err(|e| Failure::corrupt(name, e, Vec::new()))?;
+ if p.predictor == 2 { Ok(tiff_decode(&l, data)) } else { png_decode(&l, &data, name) }
+ }
+ other => Err(Failure::corrupt(name, format!("invalid Predictor {other}"), Vec::new())),
+ }
+}
+
+/// Applies the predictor named by `p.predictor` (1 = identity; 15 = per-row optimum).
+pub(crate) fn encode(p: &Params, data: &[u8]) -> Result, FilterError> {
+ match p.predictor {
+ 1 => Ok(data.to_vec()),
+ 2 | 10..=15 => {
+ let l = layout(p).map_err(FilterError::Unsupported)?;
+ Ok(if p.predictor == 2 { tiff_encode(&l, data) } else { png_encode(&l, data, p.predictor) })
+ }
+ other => Err(FilterError::Unsupported(format!("Predictor {other}"))),
+ }
+}
+
+// ---- TIFF predictor 2 ----
+
+fn get(row: &[u8], i: usize, bpc: usize) -> u16 {
+ match bpc {
+ 16 => u16::from_be_bytes([row[2 * i], row[2 * i + 1]]),
+ 8 => u16::from(row[i]),
+ _ => {
+ let bit = i * bpc;
+ let shift = 8 - bpc - bit % 8;
+ u16::from(row[bit / 8] >> shift) & ((1 << bpc) - 1)
+ }
+ }
+}
+
+fn set(row: &mut [u8], i: usize, bpc: usize, v: u16) {
+ match bpc {
+ 16 => row[2 * i..2 * i + 2].copy_from_slice(&v.to_be_bytes()),
+ 8 => row[i] = v as u8,
+ _ => {
+ let bit = i * bpc;
+ let shift = 8 - bpc - bit % 8;
+ let mask = (((1u16 << bpc) - 1) << shift) as u8;
+ row[bit / 8] = (row[bit / 8] & !mask) | (((v << shift) as u8) & mask);
+ }
+ }
+}
+
+/// Complete samples available in a (possibly partial) row.
+fn samples_in(l: &Layout, row_len: usize) -> usize {
+ (row_len * 8 / l.bpc).min(l.samples)
+}
+
+fn tiff_decode(l: &Layout, mut data: Vec) -> Vec {
+ let mask = if l.bpc == 16 { u16::MAX } else { (1u16 << l.bpc) - 1 };
+ for row in data.chunks_mut(l.row) {
+ for i in l.colors..samples_in(l, row.len()) {
+ let v = get(row, i, l.bpc).wrapping_add(get(row, i - l.colors, l.bpc)) & mask;
+ set(row, i, l.bpc, v);
+ }
+ }
+ data
+}
+
+fn tiff_encode(l: &Layout, data: &[u8]) -> Vec {
+ let mask = if l.bpc == 16 { u16::MAX } else { (1u16 << l.bpc) - 1 };
+ let mut out = data.to_vec();
+ for row in out.chunks_mut(l.row) {
+ // Descending, so the left neighbour still holds its original value.
+ for i in (l.colors..samples_in(l, row.len())).rev() {
+ let v = get(row, i, l.bpc).wrapping_sub(get(row, i - l.colors, l.bpc)) & mask;
+ set(row, i, l.bpc, v);
+ }
+ }
+ out
+}
+
+// ---- PNG predictors ----
+
+fn paeth(a: u8, b: u8, c: u8) -> u8 {
+ let p = i16::from(a) + i16::from(b) - i16::from(c);
+ let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
+ if pa <= pb && pa <= pc {
+ a
+ } else if pb <= pc {
+ b
+ } else {
+ c
+ }
+}
+
+/// The PNG predictor value for byte `i` given the reconstructed current row `cur`
+/// (bytes before `i`) and the prior row `up`.
+fn predict(tag: u8, cur: &[u8], up: &[u8], i: usize, bpp: usize) -> u8 {
+ let a = if i >= bpp { cur[i - bpp] } else { 0 };
+ let b = up[i];
+ let c = if i >= bpp { up[i - bpp] } else { 0 };
+ match tag {
+ 1 => a,
+ 2 => b,
+ 3 => ((u16::from(a) + u16::from(b)) / 2) as u8,
+ 4 => paeth(a, b, c),
+ _ => 0,
+ }
+}
+
+fn png_decode(l: &Layout, data: &[u8], name: &'static str) -> Step {
+ let mut out = Vec::with_capacity(data.len());
+ // The prior row: never longer than the input, even for absurd Columns values.
+ let mut up = vec![0u8; l.row.min(data.len())];
+ for chunk in data.chunks(l.row.saturating_add(1)) {
+ let (tag, src) = (chunk[0], &chunk[1..]);
+ if tag > 4 {
+ return Err(Failure::corrupt(name, format!("invalid PNG filter type {tag}"), out));
+ }
+ let start = out.len();
+ for (i, &x) in src.iter().enumerate() {
+ let pred = predict(tag, &out[start..], &up, i, l.bpp);
+ out.push(x.wrapping_add(pred));
+ }
+ up[..src.len()].copy_from_slice(&out[start..]);
+ }
+ Ok(out)
+}
+
+fn png_filter(tag: u8, cur: &[u8], up: &[u8], bpp: usize, dst: &mut Vec) {
+ dst.clear();
+ dst.push(tag);
+ dst.extend(cur.iter().enumerate().map(|(i, &x)| x.wrapping_sub(predict(tag, cur, up, i, bpp))));
+}
+
+/// Sum of residuals as signed bytes: the usual heuristic for choosing a PNG filter.
+fn cost(filtered: &[u8]) -> u64 {
+ filtered[1..].iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum()
+}
+
+fn png_encode(l: &Layout, data: &[u8], predictor: i64) -> Vec {
+ let rows = data.len().div_ceil(l.row.max(1));
+ let mut out = Vec::with_capacity(data.len() + rows);
+ let zeros = vec![0u8; l.row.min(data.len())];
+ let (mut best, mut trial) = (Vec::new(), Vec::new());
+ let mut up: &[u8] = &zeros;
+ for cur in data.chunks(l.row) {
+ let up_row = &up[..cur.len()];
+ if predictor == 15 {
+ png_filter(0, cur, up_row, l.bpp, &mut best);
+ let mut best_cost = cost(&best);
+ for tag in 1..=4 {
+ png_filter(tag, cur, up_row, l.bpp, &mut trial);
+ let c = cost(&trial);
+ if c < best_cost {
+ best_cost = c;
+ std::mem::swap(&mut best, &mut trial);
+ }
+ }
+ } else {
+ png_filter((predictor - 10) as u8, cur, up_row, l.bpp, &mut best);
+ }
+ out.extend_from_slice(&best);
+ up = cur;
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn p(predictor: i64, colors: i64, bpc: i64, columns: i64) -> Params {
+ Params { predictor, colors, bits_per_component: bpc, columns, early_change: 1 }
+ }
+
+ fn dec(params: &Params, data: &[u8]) -> Result, FilterError> {
+ decode(params, data.to_vec(), "FlateDecode").map_err(|f| f.error)
+ }
+
+ #[test]
+ fn png_hand_vectors() {
+ // 3 columns, 1 color, 8 bpc. Rows: None, Sub, Up, Average, Paeth.
+ let enc = [
+ 0, 10, 20, 30, // None -> 10 20 30
+ 1, 5, 1, 1, // Sub -> 5 6 7
+ 2, 1, 1, 1, // Up -> 6 7 8
+ 3, 10, 10, 10, // Avg -> 10+3=13, 10+(13+7)/2=20, 10+(20+8)/2=24
+ 4, 1, 1,
+ 1, // Paeth: a=0,b=13,c=0 -> p=13 -> b: 14; a=14,b=20,c=13: p=21 -> pa=7,pb=1,pc=8 -> b: 21;
+ // a=21,b=24,c=20: p=25 -> pa=4,pb=1,pc=5 -> b: 25
+ ];
+ let out = dec(&p(15, 1, 8, 3), &enc).unwrap();
+ assert_eq!(out, [10, 20, 30, 5, 6, 7, 6, 7, 8, 13, 20, 24, 14, 21, 25]);
+ }
+
+ #[test]
+ fn paeth_tie_breaks() {
+ // RFC 2083 order: a, then b, then c.
+ assert_eq!(paeth(1, 1, 1), 1);
+ assert_eq!(paeth(10, 20, 15), 15); // p=15: pa=5 pb=5 pc=0 -> c
+ assert_eq!(paeth(20, 10, 10), 20); // p=20: pa=0 -> a
+ assert_eq!(paeth(5, 9, 7), 7); // p=7: pa=2 pb=2 pc=0 -> c
+ }
+
+ #[test]
+ fn png_multibyte_pixels() {
+ // colors=3 bpc=16 -> bpp=6; 1 column; Sub on the first pixel uses a=0 throughout.
+ let enc = [1, 1, 2, 3, 4, 5, 6, 2, 1, 1, 1, 1, 1, 1];
+ let out = dec(&p(11, 3, 16, 1), &enc).unwrap();
+ assert_eq!(out, [1, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 7]);
+ // colors=3 bpc=8, 2 columns: Sub looks 3 bytes back.
+ let enc = [1, 1, 2, 3, 1, 1, 1];
+ assert_eq!(dec(&p(11, 3, 8, 2), &enc).unwrap(), [1, 2, 3, 2, 3, 4]);
+ }
+
+ #[test]
+ fn png_partial_final_row() {
+ let enc = [0, 1, 2, 3, 4, 2, 1, 1];
+ assert_eq!(dec(&p(12, 1, 8, 4), &enc).unwrap(), [1, 2, 3, 4, 2, 3]);
+ // A lone tag byte at the end yields nothing.
+ assert_eq!(dec(&p(12, 1, 8, 4), &[0, 1, 2, 3, 4, 2]).unwrap(), [1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn png_invalid_tag() {
+ let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode");
+ let f = r.err().unwrap();
+ assert!(matches!(f.error, FilterError::Corrupt { .. }));
+ assert_eq!(f.partial, [1, 2]);
+ }
+
+ #[test]
+ fn tiff_8bit() {
+ // 2 colors, 3 columns: differences per component.
+ let enc = [10, 20, 1, 2, 3, 4, /* row 2 */ 5, 5, 250, 10, 1, 1];
+ assert_eq!(dec(&p(2, 2, 8, 3), &enc).unwrap(), [10, 20, 11, 22, 14, 26, 5, 5, 255, 15, 0, 16]);
+ }
+
+ #[test]
+ fn tiff_16bit() {
+ let enc = [0x01, 0x00, 0xff, 0xff, 0x00, 0x02];
+ assert_eq!(dec(&p(2, 1, 16, 3), &enc).unwrap(), [0x01, 0x00, 0x00, 0xff, 0x01, 0x01]);
+ }
+
+ #[test]
+ fn tiff_sub_byte() {
+ // 1 bpc, 1 color, 10 columns: bits 1 0 0 1 1 0 0 0 | 1 1 (pad 000000)
+ // Cumulative XOR: 1 1 1 0 1 1 1 1 | 0 1
+ let enc = [0b1001_1000, 0b1100_0000];
+ assert_eq!(dec(&p(2, 1, 1, 10), &enc).unwrap(), [0b1110_1111, 0b0100_0000]);
+ // 4 bpc, 1 color, 3 columns: samples 5, 15, 3 -> 5, 4, 7 (mod 16).
+ assert_eq!(dec(&p(2, 1, 4, 3), &[0x5f, 0x30]).unwrap(), [0x54, 0x70]);
+ // 2 bpc, 2 colors, 2 columns: samples 1 2 3 3 -> 1 2 0 1.
+ assert_eq!(dec(&p(2, 2, 2, 2), &[0b0110_1111]).unwrap(), [0b0110_0001]);
+ // Padding bits are left alone.
+ assert_eq!(dec(&p(2, 1, 4, 1), &[0x5f]).unwrap(), [0x5f]);
+ }
+
+ #[test]
+ fn tiff_partial_rows() {
+ // 16-bit with an odd trailing byte: the lone byte is left as is.
+ assert_eq!(dec(&p(2, 1, 16, 4), &[0, 1, 0, 1, 9]).unwrap(), [0, 1, 0, 2, 9]);
+ assert_eq!(dec(&p(2, 1, 8, 4), &[1, 1, 1, 1, 2, 2]).unwrap(), [1, 2, 3, 4, 2, 4]);
+ }
+
+ #[test]
+ fn invalid_params() {
+ for bad in [p(2, 1, 3, 1), p(12, 0, 8, 1), p(12, 1, 8, 0), p(3, 1, 8, 1), p(12, 1, 8, i64::MAX), p(2, 300, 8, 1)] {
+ assert!(matches!(dec(&bad, &[0, 1, 2]), Err(FilterError::Corrupt { .. })), "{bad:?}");
+ assert!(encode(&bad, &[1, 2]).is_err());
+ }
+ // Huge but representable Columns does not allocate a huge row.
+ assert_eq!(dec(&p(12, 1, 8, 1 << 40), &[2, 7, 8]).unwrap(), [7, 8]);
+ }
+
+ #[test]
+ fn encode_choices() {
+ let data = [1u8, 2, 3, 4, 5, 6];
+ let up = encode(&p(12, 1, 8, 3), &data).unwrap();
+ assert_eq!(up, [2, 1, 2, 3, 2, 3, 3, 3]);
+ let paeth = encode(&p(14, 1, 8, 3), &data).unwrap();
+ assert_eq!(paeth[0], 4);
+ assert_eq!(paeth[4], 4);
+ // Per-row optimum picks Sub for a ramp.
+ let ramp: Vec = (0..64).collect();
+ let opt = encode(&p(15, 1, 8, 64), &ramp).unwrap();
+ assert_eq!(opt[0], 1);
+ for pred in [10, 11, 12, 13, 14, 15] {
+ let e = encode(&p(pred, 1, 8, 3), &data).unwrap();
+ assert_eq!(dec(&p(pred, 1, 8, 3), &e).unwrap(), data);
+ }
+ }
+}
diff --git a/crates/filters/src/runlength.rs b/crates/filters/src/runlength.rs
new file mode 100644
index 000000000..e77b619f9
--- /dev/null
+++ b/crates/filters/src/runlength.rs
@@ -0,0 +1,117 @@
+//! RunLengthDecode (ISO 32000-2 §7.4.5): a length byte L of 0–127 copies the next
+//! L + 1 bytes, 129–255 repeats the next byte 257 − L times, 128 is EOD.
+//! A missing EOD is tolerated; a truncated run is corrupt.
+
+use crate::{Failure, Step};
+
+const NAME: &str = "RunLengthDecode";
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+ let mut out = Vec::with_capacity(data.len().saturating_mul(2).min(max));
+ let mut i = 0;
+ while let Some(&l) = data.get(i) {
+ i += 1;
+ match l {
+ 128 => break,
+ 0..=127 => {
+ let n = usize::from(l) + 1;
+ let avail = n.min(data.len() - i);
+ if out.len() + avail > max {
+ return Err(Failure::limit(max));
+ }
+ out.extend_from_slice(&data[i..i + avail]);
+ i += avail;
+ if avail < n {
+ return Err(Failure::corrupt(NAME, "truncated literal run", out));
+ }
+ }
+ _ => {
+ let Some(&b) = data.get(i) else {
+ return Err(Failure::corrupt(NAME, "truncated repeat run", out));
+ };
+ i += 1;
+ let n = 257 - usize::from(l);
+ if out.len() + n > max {
+ return Err(Failure::limit(max));
+ }
+ out.resize(out.len() + n, b);
+ }
+ }
+ }
+ Ok(out)
+}
+
+pub(crate) fn encode(data: &[u8]) -> Vec {
+ let mut out = Vec::with_capacity(data.len() + data.len() / 128 + 2);
+ let n = data.len();
+ let mut i = 0;
+ while i < n {
+ let mut run = 1;
+ while i + run < n && run < 128 && data[i + run] == data[i] {
+ run += 1;
+ }
+ if run >= 2 {
+ out.push((257 - run) as u8);
+ out.push(data[i]);
+ i += run;
+ continue;
+ }
+ // Literal: extend until a repeat begins or 128 bytes.
+ let start = i;
+ let mut j = i + 1;
+ while j < n && j - start < 128 && !(j + 1 < n && data[j] == data[j + 1]) {
+ j += 1;
+ }
+ out.push((j - start - 1) as u8);
+ out.extend_from_slice(&data[start..j]);
+ i = j;
+ }
+ out.push(128);
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::FilterError;
+
+ fn dec(d: &[u8]) -> Result, FilterError> {
+ decode(d, 10_000).map_err(|f| f.error)
+ }
+
+ #[test]
+ fn vectors() {
+ assert_eq!(dec(&[2, b'a', b'b', b'c', 254, b'x', 128]).unwrap(), b"abcxxx");
+ assert_eq!(dec(&[129, 7, 128]).unwrap(), [7; 128]);
+ assert!(dec(&[127]).is_err());
+ assert_eq!(dec(&[0, b'q']).unwrap(), b"q"); // missing EOD
+ assert_eq!(dec(&[128, 0, b'q']).unwrap(), b""); // data after EOD ignored
+ assert_eq!(dec(&[]).unwrap(), b"");
+ }
+
+ #[test]
+ fn truncated() {
+ let f = decode(&[4, b'a', b'b'], 100).err().unwrap();
+ assert!(matches!(f.error, FilterError::Corrupt { .. }));
+ assert_eq!(f.partial, b"ab");
+ assert!(matches!(dec(&[0, b'a', 200]), Err(FilterError::Corrupt { .. })));
+ }
+
+ #[test]
+ fn limit() {
+ assert!(matches!(decode(&[129, 0, 129, 0], 200).map_err(|f| f.error), Err(FilterError::LimitExceeded(200))));
+ assert!(matches!(decode(&[3, 1, 2, 3, 4], 3).map_err(|f| f.error), Err(FilterError::LimitExceeded(3))));
+ assert_eq!(decode(&[129, 0, 129, 0], 256).ok().unwrap().len(), 256);
+ }
+
+ #[test]
+ fn encoding() {
+ assert_eq!(encode(b"abcxxx"), [2, b'a', b'b', b'c', 254, b'x', 128]);
+ assert_eq!(encode(b""), [128]);
+ assert_eq!(encode(&[5; 300]), [129, 5, 129, 5, 213, 5, 128]);
+ let lit: Vec = (0..200u8).collect();
+ let e = encode(&lit);
+ assert_eq!((e[0], e[129]), (127, 71));
+ assert_eq!(dec(&e).unwrap(), lit);
+ }
+}
diff --git a/crates/filters/tests/proptests.proptest-regressions b/crates/filters/tests/proptests.proptest-regressions
new file mode 100644
index 000000000..0b9bbac5c
--- /dev/null
+++ b/crates/filters/tests/proptests.proptest-regressions
@@ -0,0 +1,7 @@
+# Seeds for failure cases proptest has generated in the past. It is
+# automatically read and these particular cases re-run before any
+# novel cases are generated.
+#
+# It is recommended to check this file in to source control so that
+# everyone who runs the test benefits from these saved cases.
+cc 690714485bf1c84d6f7788b0205b5d62942b9774699a364161224381ebb08f22 # shrinks to filter = Flate, params = Params { predictor: 10, colors: 1, bits_per_component: 1, columns: 1, early_change: 0 }, data = [74, 4, 40, 11, 72, 5, 123, 154, 191, 76, 212, 154, 73, 3, 113, 92, 51, 190, 160, 141, 12, 106, 83, 206, 91, 214, 202, 23, 39, 151, 117, 114, 220, 76, 204, 235, 186, 176, 80, 229, 63, 79, 67, 63, 177, 94, 142, 223, 140, 248, 1, 203, 157, 88, 175, 77, 69, 155, 159, 152, 47, 216, 81, 234, 169, 144, 235, 202, 75, 191, 233, 128, 225, 162, 96, 126, 245, 57, 135, 167, 184, 53, 138, 215, 153, 248, 165, 18, 81, 128, 44, 139, 223, 76, 191, 58, 137, 48, 93, 14, 80, 58, 30, 63, 40, 235, 68, 159, 137, 113, 123, 24, 150, 84, 78, 190, 207, 36, 189, 220, 192, 161, 121, 179, 1, 242, 152, 205, 64, 67, 50, 245, 94, 185, 81, 132, 27, 25, 122, 7, 223, 178, 147, 70, 27, 130, 226, 217, 54, 87, 112, 14, 196, 133, 193, 27, 147, 30, 38, 182, 108, 72, 75, 107, 113, 244, 65, 9, 62, 11, 66, 234, 146, 180, 56, 85, 170, 255, 38, 37, 159, 48, 3, 159, 125, 230, 34, 110, 7, 58, 92, 66, 14, 29, 50, 98, 56, 228, 118, 243, 32, 99, 158, 252, 246, 249, 185, 106, 11, 127, 218, 104, 216, 204, 23, 59, 85, 34, 123, 28, 253, 14, 147, 201, 205, 176, 60, 39, 249, 143, 205, 190, 183, 83, 27, 213, 23, 225, 172, 43, 225, 31, 62, 205, 53, 88, 202, 200, 75, 152, 252, 105, 20, 95, 20, 0, 229, 170, 23, 222, 252, 208, 41, 206, 133, 167, 41, 89, 208, 136, 98, 203, 93, 112, 18, 75, 54, 217, 191, 163, 165, 88, 137, 203, 7, 66, 218, 184, 251, 48, 25, 144, 29, 183, 119, 101, 192, 234, 116, 81, 211, 255, 22, 16, 20, 36, 190, 253, 73, 246, 9, 141, 99, 90, 178, 252, 93, 112, 233, 213, 42, 127, 227, 208, 125, 129, 41, 99, 111, 228, 123, 106, 131, 39, 204, 158, 18, 6, 221, 168, 135, 60, 107, 156, 254, 93, 255, 163, 34, 142, 110, 16, 207, 84, 243, 180, 145, 42, 240, 252, 64, 53, 225, 211, 231, 255, 215, 221, 181, 69, 8, 59, 134, 254, 98, 16, 157, 100, 115, 145, 190, 115, 49, 153, 255, 242, 159, 248, 37, 192, 48, 133, 42, 158, 35, 111, 7, 101, 1, 12, 131, 117, 149, 23, 224, 80, 101, 146, 235, 24, 214, 166, 254, 39, 121, 65, 109, 7, 115, 4, 126, 238, 207, 122, 72, 78, 170, 80, 239, 13, 233, 101, 110, 101, 121, 219, 159, 194, 78, 33, 198, 239, 149, 64, 6, 105, 138, 106, 34, 82, 253, 66, 74, 219, 101, 247, 133, 23, 140, 254, 221, 164, 77, 66, 28, 202, 253, 230, 152, 200, 164, 143, 126, 92, 231, 72, 120, 249, 230, 119, 24, 109, 185, 137, 26, 206, 91, 90, 76, 140, 134, 137, 67, 83, 52, 105, 135, 184, 180, 231, 213, 190, 252, 144, 129, 85, 96, 4, 62, 70, 197, 73, 164, 61, 209, 150, 227, 138, 92, 25, 117, 84, 161, 81, 247, 94, 53, 207, 93, 59, 223, 222, 87, 255, 216, 219, 143, 36, 255, 131, 117, 9, 30, 144, 196, 209, 139, 253, 187, 193, 126, 243, 21, 66, 19, 226, 95, 166, 245, 11, 63, 220, 30, 55, 255, 172, 108, 87, 187, 2, 3, 242, 243, 175, 134, 88, 128, 97, 68, 102, 241, 94, 109, 219, 219, 6, 38, 155, 249, 194, 33, 175, 48, 141, 41, 82, 186, 85, 232, 157, 160, 145, 233, 103, 190, 239, 106, 247, 241, 11, 216, 90, 56, 154, 18, 119, 33, 181, 109, 145, 80, 158, 51, 129, 150, 147, 255, 14, 135, 86, 62, 250, 84, 8, 235, 237, 246, 227, 137, 112, 175, 239, 226, 182, 92, 188, 236, 254, 87, 49, 18, 208, 139, 48, 200, 151, 201, 196, 182, 182], max = 1319
diff --git a/crates/filters/tests/proptests.rs b/crates/filters/tests/proptests.rs
new file mode 100644
index 000000000..a4300e68a
--- /dev/null
+++ b/crates/filters/tests/proptests.rs
@@ -0,0 +1,150 @@
+//! Round-trip property tests (encode → decode) for every encodable filter and predictor,
+//! and fuzz-style tests feeding arbitrary bytes and parameters to every decoder.
+
+use printcraft_filters::{Filter, FilterError, Params, decode, decode_tolerant, encode};
+use proptest::prelude::*;
+
+const MAX: usize = 1 << 24;
+
+fn encodable() -> impl Strategy {
+ prop_oneof![Just(Filter::Flate), Just(Filter::Lzw), Just(Filter::AsciiHex), Just(Filter::Ascii85), Just(Filter::RunLength),]
+}
+
+fn any_filter() -> impl Strategy {
+ prop_oneof![
+ encodable(),
+ Just(Filter::Dct),
+ Just(Filter::Jpx),
+ Just(Filter::Jbig2),
+ Just(Filter::CcittFax),
+ Just(Filter::Crypt),
+ Just(Filter::Unknown("Foo".into())),
+ ]
+}
+
+fn valid_params() -> impl Strategy {
+ (prop::sample::select(vec![1i64, 2, 10, 11, 12, 13, 14, 15]), 1i64..=5, prop::sample::select(vec![1i64, 2, 4, 8, 16]), 1i64..=40, 0i64..=1)
+ .prop_map(|(predictor, colors, bits_per_component, columns, early_change)| Params {
+ predictor,
+ colors,
+ bits_per_component,
+ columns,
+ early_change,
+ })
+}
+
+/// Anything, including nonsense values.
+fn wild_params() -> impl Strategy {
+ let wild = prop_oneof![Just(i64::MIN), Just(i64::MAX), Just(-1i64), Just(0i64), 0i64..=40, any::()];
+ (
+ prop_oneof![valid_params().prop_map(|p| p.predictor), wild.clone()],
+ prop_oneof![1i64..=5, wild.clone()],
+ prop_oneof![Just(8i64), Just(16), Just(1), wild.clone()],
+ prop_oneof![1i64..=40, wild.clone()],
+ wild,
+ )
+ .prop_map(|(predictor, colors, bits_per_component, columns, early_change)| Params {
+ predictor,
+ colors,
+ bits_per_component,
+ columns,
+ early_change,
+ })
+}
+
+/// Low-entropy data (so LZW/Flate/RunLength exercise their repeat paths) mixed with noise.
+fn data() -> impl Strategy> {
+ prop_oneof![prop::collection::vec(any::(), 0..2000), prop::collection::vec(prop::sample::select(vec![0u8, 1, 7, 255]), 0..6000),]
+}
+
+proptest! {
+ #[test]
+ fn round_trip(filter in encodable(), params in valid_params(), data in data()) {
+ let enc = encode(&filter, ¶ms, &data).unwrap();
+ let chain = [(filter, params)];
+ prop_assert_eq!(&decode(&chain, &enc, MAX).unwrap(), &data);
+ let (out, partial) = decode_tolerant(&chain, &enc, MAX).unwrap();
+ prop_assert!(!partial);
+ prop_assert_eq!(&out, &data);
+ }
+
+ #[test]
+ fn round_trip_chain(a in encodable(), b in encodable(), params in valid_params(), data in data()) {
+ let inner = encode(&b, ¶ms, &data).unwrap();
+ let outer = encode(&a, &Params::default(), &inner).unwrap();
+ let chain = [(a, Params::default()), (b, params)];
+ prop_assert_eq!(decode(&chain, &outer, MAX).unwrap(), data);
+ }
+
+ #[test]
+ fn lzw_long_round_trip(early in 0i64..=1, data in prop::collection::vec(0u8..4, 20_000..60_000)) {
+ let p = Params { early_change: early, ..Params::default() };
+ let enc = encode(&Filter::Lzw, &p, &data).unwrap();
+ prop_assert_eq!(decode(&[(Filter::Lzw, p)], &enc, MAX).unwrap(), data);
+ }
+
+ #[test]
+ fn output_limit_is_respected(filter in encodable(), params in valid_params(), data in data(), max in 0usize..3000) {
+ let enc = encode(&filter, ¶ms, &data).unwrap();
+ // PNG predictors add a tag byte per row to the intermediate (pre-predictor) buffer.
+ let png = matches!(filter, Filter::Flate | Filter::Lzw) && params.predictor >= 10;
+ let row = (params.colors * params.bits_per_component * params.columns + 7) as usize / 8;
+ let intermediate = if png { data.len() + data.len().div_ceil(row) } else { data.len() };
+ match decode(&[(filter, params)], &enc, max) {
+ Ok(out) => prop_assert!(out.len() <= max && out == data),
+ Err(FilterError::LimitExceeded(m)) => prop_assert!(m == max && intermediate > max),
+ Err(e) => prop_assert!(false, "unexpected {e}"),
+ }
+ }
+
+ #[test]
+ fn decoders_never_panic(
+ chain in prop::collection::vec((any_filter(), wild_params()), 0..4),
+ data in prop::collection::vec(any::(), 0..3000),
+ max in prop_oneof![Just(0usize), 1usize..100_000],
+ ) {
+ if let Ok(out) = decode(&chain, &data, max) {
+ prop_assert!(out.len() <= max || chain.is_empty());
+ }
+ if let Ok((out, _)) = decode_tolerant(&chain, &data, max) {
+ prop_assert!(out.len() <= max || chain.is_empty());
+ }
+ }
+
+ #[test]
+ fn single_decoders_never_panic(filter in encodable(), params in wild_params(), data in prop::collection::vec(any::(), 0..3000)) {
+ let chain = [(filter, params)];
+ let _ = decode(&chain, &data, 1 << 20);
+ let _ = decode_tolerant(&chain, &data, 1 << 20);
+ }
+
+ #[test]
+ fn encoders_never_panic(filter in any_filter(), params in wild_params(), data in prop::collection::vec(any::(), 0..2000)) {
+ if let Ok(enc) = encode(&filter, ¶ms, &data) {
+ prop_assert_eq!(decode(&[(filter, params)], &enc, MAX).unwrap(), data);
+ }
+ }
+
+ #[test]
+ fn mutated_streams_never_panic(filter in encodable(), params in valid_params(), data in data(), flips in prop::collection::vec((any::(), any::()), 1..8)) {
+ let mut enc = encode(&filter, ¶ms, &data).unwrap();
+ for (i, x) in flips {
+ if !enc.is_empty() {
+ let k = i.index(enc.len());
+ enc[k] ^= x;
+ }
+ }
+ let chain = [(filter, params)];
+ let _ = decode(&chain, &enc, 1 << 20);
+ let _ = decode_tolerant(&chain, &enc, 1 << 20);
+ }
+}
+
+#[test]
+fn flate_bomb_in_chain() {
+ let bomb = printcraft_filters::encode_flate(&vec![0u8; 32 << 20]);
+ let a85 = encode(&Filter::Ascii85, &Params::default(), &bomb).unwrap();
+ let chain = [(Filter::Ascii85, Params::default()), (Filter::Flate, Params::default())];
+ assert!(matches!(decode(&chain, &a85, 1 << 20), Err(FilterError::LimitExceeded(_))));
+ assert!(matches!(decode_tolerant(&chain, &a85, 1 << 20), Err(FilterError::LimitExceeded(_))));
+}
diff --git a/crates/organize/Cargo.toml b/crates/organize/Cargo.toml
new file mode 100644
index 000000000..46fb417e9
--- /dev/null
+++ b/crates/organize/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "printcraft-organize"
+description = "Page and document-structure edits: rotate, delete, move, insert pages; document info"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+printcraft-cos = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+hayro-syntax = { workspace = true }
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
new file mode 100644
index 000000000..e59a8af81
--- /dev/null
+++ b/crates/organize/src/lib.rs
@@ -0,0 +1,214 @@
+//! printcraft-organize — page and document-structure edits (L4).
+//!
+//! Every operation mutates a `printcraft_cos::Document` (copy-on-write), so callers snapshot the
+//! document before an edit for undo. Operations never drop data they do not understand.
+//!
+//! Page-tree strategy: before restructuring, inheritable page attributes (`Resources`,
+//! `MediaBox`, `CropBox`, `Rotate` — ISO 32000-2 §7.7.3.4) are copied onto each page, then the
+//! tree is rebuilt as a single flat `/Pages` node. Intermediate nodes become unreachable (a full
+//! save drops them; an incremental save leaves them untouched). Flat trees are valid for any
+//! page count and are what most producers write for small documents.
+
+use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum OrganizeError {
+ #[error("the document has no page tree")]
+ NoPageTree,
+ #[error("page {0} does not exist")]
+ NoSuchPage(usize),
+ #[error("a document must keep at least one page")]
+ WouldRemoveAllPages,
+ #[error("{0}")]
+ Cos(#[from] printcraft_cos::CosError),
+}
+
+const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
+
+/// A leaf page in document order.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PageRef {
+ pub obj: ObjRef,
+}
+
+fn pages_root(doc: &Document) -> Result