lightweight_pdf_writer/
writer.rs1#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub struct Ref(pub u32);
12
13impl Ref {
14 pub fn write(&self) -> String {
16 format!("{} 0 R", self.0)
17 }
18}
19
20pub struct PdfWriter {
21 buf: Vec<u8>,
22 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 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 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 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 #[cfg(feature = "compress")]
89 pub fn compressed_stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
90 let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
94 self.stream(id, &format!("/Filter /FlateDecode {dict_extra}"), &compressed);
95 }
96
97 #[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 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; 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 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 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
143fn 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
159pub 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}