#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Ref(pub u32);
impl Ref {
pub fn write(&self) -> String {
format!("{} 0 R", self.0)
}
}
pub struct PdfWriter {
buf: Vec<u8>,
offsets: Vec<usize>,
next_id: u32,
}
impl Default for PdfWriter {
fn default() -> Self {
Self::new()
}
}
impl PdfWriter {
pub fn new() -> Self {
let mut buf = Vec::new();
buf.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
PdfWriter {
buf,
offsets: Vec::new(),
next_id: 1,
}
}
pub fn alloc(&mut self) -> Ref {
let id = self.next_id;
self.next_id += 1;
Ref(id)
}
fn record_offset(&mut self, id: Ref) {
let idx = usize::try_from(id.0 - 1).expect("PDF object ids fit in usize for any realistic document, see round 2 rationale");
if self.offsets.len() <= idx {
self.offsets.resize(idx + 1, 0);
}
self.offsets[idx] = self.buf.len();
}
pub fn object(&mut self, id: Ref, body: &str) {
self.record_offset(id);
self.buf.extend_from_slice(format!("{} 0 obj\n", id.0).as_bytes());
self.buf.extend_from_slice(body.as_bytes());
self.buf.extend_from_slice(b"\nendobj\n");
}
pub fn stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
self.record_offset(id);
self.buf
.extend_from_slice(format!("{} 0 obj\n<< /Length {} {} >>\nstream\n", id.0, data.len(), dict_extra).as_bytes());
self.buf.extend_from_slice(data);
self.buf.extend_from_slice(b"\nendstream\nendobj\n");
}
#[cfg(feature = "compress")]
pub fn compressed_stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
self.stream(id, &format!("/Filter /FlateDecode {dict_extra}"), &compressed);
}
#[cfg(not(feature = "compress"))]
pub fn compressed_stream(&mut self, id: Ref, dict_extra: &str, data: &[u8]) {
self.stream(id, dict_extra, data);
}
pub(crate) fn finish(mut self, root: Ref, info: Option<Ref>) -> Vec<u8> {
let xref_offset = self.buf.len();
let count = self.next_id; self.buf.extend_from_slice(format!("xref\n0 {count}\n").as_bytes());
self.buf.extend_from_slice(b"0000000000 65535 f \n");
for i in 0..(count - 1) {
let idx = usize::try_from(i).expect("PDF object counts fit in usize for any realistic document, see round 2 rationale");
let offset = *self.offsets.get(idx).unwrap_or(&0);
self.buf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
let info_str = match info {
Some(r) => format!(" /Info {}", r.write()),
None => String::new(),
};
let id = document_id_hex(&self.buf[..xref_offset]);
self.buf.extend_from_slice(
format!(
"trailer\n<< /Size {count} /Root {}{info_str} /ID [<{id}> <{id}>] >>\nstartxref\n{xref_offset}\n%%EOF",
root.write()
)
.as_bytes(),
);
self.buf
}
}
fn document_id_hex(content: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h1 = DefaultHasher::new();
content.hash(&mut h1);
let mut h2 = DefaultHasher::new();
1u8.hash(&mut h2);
content.hash(&mut h2);
format!("{:016x}{:016x}", h1.finish(), h2.finish())
}
pub fn fmt_num(v: f32) -> String {
let rounded = (v * 1000.0).round() / 1000.0;
let mut s = format!("{rounded:.3}");
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
if s.is_empty() || s == "-" {
s = "0".to_string();
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn produces_a_minimal_valid_structure() {
let mut w = PdfWriter::new();
let catalog = w.alloc();
let pages = w.alloc();
w.object(pages, "<< /Type /Pages /Kids [] /Count 0 >>");
w.object(catalog, &format!("<< /Type /Catalog /Pages {} >>", pages.write()));
let bytes = w.finish(catalog, None);
let text = String::from_utf8_lossy(&bytes);
assert!(text.starts_with("%PDF-1.7"));
assert!(text.contains("trailer"));
assert!(text.contains("startxref"));
assert!(text.ends_with("%%EOF"));
}
#[test]
fn fmt_num_is_compact() {
assert_eq!(fmt_num(12.0), "12");
assert_eq!(fmt_num(12.5), "12.5");
assert_eq!(fmt_num(0.0), "0");
assert_eq!(fmt_num(-3.14149), "-3.141");
}
}