Skip to main content

lightweight_pdf_writer/
writer.rs

1//! Thin, allocation-light layer directly over PDF object syntax: typed
2//! object IDs, dict/stream writing, xref table + trailer. Architecturally
3//! modeled after `pdf-writer` (ADR-004) but self-written for full control
4//! over every emitted byte, and (still) no required dependencies — the one
5//! optional dependency, `miniz_oxide` behind the `compress` feature
6//! (ADR-016), is FlateDecode compression, not object-model plumbing.
7
8/// A PDF indirect object reference (generation is always 0 in V1 — we never
9/// rewrite an existing file).
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub struct Ref(pub u32);
12
13impl Ref {
14    /// `"N 0 R"` as used inside dictionaries/arrays.
15    pub fn write(&self) -> String {
16        format!("{} 0 R", self.0)
17    }
18}
19
20pub struct PdfWriter {
21    buf: Vec<u8>,
22    /// Byte offset of each object, indexed by `id - 1` (object 0 is the
23    /// reserved free-list head and is not stored here).
24    offsets: Vec<usize>,
25    next_id: u32,
26}
27
28impl Default for PdfWriter {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl PdfWriter {
35    pub fn new() -> Self {
36        let mut buf = Vec::new();
37        buf.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
38        PdfWriter {
39            buf,
40            offsets: Vec::new(),
41            next_id: 1,
42        }
43    }
44
45    pub fn alloc(&mut self) -> Ref {
46        let id = self.next_id;
47        self.next_id += 1;
48        Ref(id)
49    }
50
51    fn record_offset(&mut self, id: Ref) {
52        // `id.0` is a `u32` object id; `usize` is at least 32 bits on every
53        // platform this crate targets, so this widening conversion never
54        // fails.
55        let idx = usize::try_from(id.0 - 1).expect("PDF object ids fit in usize for any realistic document, see round 2 rationale");
56        if self.offsets.len() <= idx {
57            self.offsets.resize(idx + 1, 0);
58        }
59        self.offsets[idx] = self.buf.len();
60    }
61
62    /// Writes a plain indirect object: `id 0 obj\n<body>\nendobj\n`.
63    pub fn object(&mut self, id: Ref, body: &str) {
64        self.record_offset(id);
65        self.buf.extend_from_slice(format!("{} 0 obj\n", id.0).as_bytes());
66        self.buf.extend_from_slice(body.as_bytes());
67        self.buf.extend_from_slice(b"\nendobj\n");
68    }
69
70    /// Writes an indirect stream object, uncompressed. `dict_extra` are
71    /// additional dictionary entries (e.g. `/Length1 1234`); `/Length` is
72    /// computed and added automatically.
73    pub fn stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
74        self.record_offset(id);
75        self.buf
76            .extend_from_slice(format!("{} 0 obj\n<< /Length {} {} >>\nstream\n", id.0, data.len(), dict_extra).as_bytes());
77        self.buf.extend_from_slice(data);
78        self.buf.extend_from_slice(b"\nendstream\nendobj\n");
79    }
80
81    /// `Self::stream`'s DEFLATE-compressed counterpart (ADR-016): adds
82    /// `/Filter /FlateDecode` and zlib-wraps `data` (RFC 1950 — what
83    /// `/FlateDecode` expects per PDF 32000-1 7.4.4) before writing. Used
84    /// for content streams, embedded font programs (`FontFile2`) and
85    /// `ToUnicode` CMaps — never for data that's already compressed (e.g.
86    /// JPEG `/DCTDecode` samples), where re-deflating near-random bytes
87    /// wastes CPU for ~0 size benefit.
88    #[cfg(feature = "compress")]
89    pub fn compressed_stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
90        // Level 6 (zlib's own default): a reasonable ratio/speed balance
91        // for a "generate once" library — no hard requirement pushed
92        // this any higher, and this crate has no benchmarked need to.
93        let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
94        self.stream(id, &format!("/Filter /FlateDecode {dict_extra}"), &compressed);
95    }
96
97    /// Without the `compress` feature, `compressed_stream` is exactly
98    /// `stream` — the previous, always-uncompressed behavior.
99    #[cfg(not(feature = "compress"))]
100    pub fn compressed_stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
101        self.stream(id, dict_extra, data);
102    }
103
104    /// Writes the xref table, trailer and `%%EOF`, consuming the writer.
105    /// Crate-internal: only [`crate::doc::PdfDocument::write`] calls this —
106    /// `PdfWriter` is `PdfDocument`'s implementation detail, not part of
107    /// this crate's public surface.
108    pub(crate) fn finish(mut self, root: Ref, info: Option<Ref>) -> Vec<u8> {
109        let xref_offset = self.buf.len();
110        let count = self.next_id; // includes object 0
111        self.buf.extend_from_slice(format!("xref\n0 {count}\n").as_bytes());
112        self.buf.extend_from_slice(b"0000000000 65535 f \n");
113        for i in 0..(count - 1) {
114            // `i` is a `u32` object index; `usize` is at least 32 bits on
115            // every platform this crate targets, so this widening
116            // conversion never fails.
117            let idx = usize::try_from(i).expect("PDF object counts fit in usize for any realistic document, see round 2 rationale");
118            let offset = *self.offsets.get(idx).unwrap_or(&0);
119            self.buf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
120        }
121        let info_str = match info {
122            Some(r) => format!(" /Info {}", r.write()),
123            None => String::new(),
124        };
125        // Deterministic /ID: a hash over every object written so far
126        // (everything up to, but not including, this xref/trailer), never
127        // a random source — `wasm32-unknown-unknown` has none, and two
128        // renders of the same `Document` must produce byte-identical PDFs.
129        // Both array entries are equal, as is conventional for a document
130        // written in a single revision (no prior version to diff against).
131        let id = document_id_hex(&self.buf[..xref_offset]);
132        self.buf.extend_from_slice(
133            format!(
134                "trailer\n<< /Size {count} /Root {}{info_str} /ID [<{id}> <{id}>] >>\nstartxref\n{xref_offset}\n%%EOF",
135                root.write()
136            )
137            .as_bytes(),
138        );
139        self.buf
140    }
141}
142
143/// 16 bytes (32 hex chars), hashed from `content` with a fixed-seed,
144/// deterministic hasher (`DefaultHasher::new()` always starts from the
145/// same internal state — unlike `HashMap`'s `RandomState`, it never reads
146/// OS randomness). Two calls with the same `content` always agree.
147fn document_id_hex(content: &[u8]) -> String {
148    use std::collections::hash_map::DefaultHasher;
149    use std::hash::{Hash, Hasher};
150
151    let mut h1 = DefaultHasher::new();
152    content.hash(&mut h1);
153    let mut h2 = DefaultHasher::new();
154    1u8.hash(&mut h2);
155    content.hash(&mut h2);
156    format!("{:016x}{:016x}", h1.finish(), h2.finish())
157}
158
159/// Formats an `f32` with a fixed, compact precision suitable for PDF
160/// content streams (avoids Rust's default float `Display`, which can emit
161/// scientific notation or excessive digits).
162pub fn fmt_num(v: f32) -> String {
163    let rounded = (v * 1000.0).round() / 1000.0;
164    let mut s = format!("{rounded:.3}");
165    while s.ends_with('0') {
166        s.pop();
167    }
168    if s.ends_with('.') {
169        s.pop();
170    }
171    if s.is_empty() || s == "-" {
172        s = "0".to_string();
173    }
174    s
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn produces_a_minimal_valid_structure() {
183        let mut w = PdfWriter::new();
184        let catalog = w.alloc();
185        let pages = w.alloc();
186        w.object(pages, "<< /Type /Pages /Kids [] /Count 0 >>");
187        w.object(catalog, &format!("<< /Type /Catalog /Pages {} >>", pages.write()));
188        let bytes = w.finish(catalog, None);
189        let text = String::from_utf8_lossy(&bytes);
190        assert!(text.starts_with("%PDF-1.7"));
191        assert!(text.contains("trailer"));
192        assert!(text.contains("startxref"));
193        assert!(text.ends_with("%%EOF"));
194    }
195
196    #[test]
197    fn fmt_num_is_compact() {
198        assert_eq!(fmt_num(12.0), "12");
199        assert_eq!(fmt_num(12.5), "12.5");
200        assert_eq!(fmt_num(0.0), "0");
201        assert_eq!(fmt_num(-3.14149), "-3.141");
202    }
203}