paperforge-pdf 0.1.0

PDF object model, serialization, and parsing
Documentation
use std::collections::BTreeMap;
use std::io::{Seek, Write};

use crate::error::PdfResult;
use crate::object::*;
use crate::parser::Document;

pub struct Serializer {
    deterministic: bool,
}

impl Serializer {
    pub fn new() -> Self {
        Self {
            deterministic: false,
        }
    }

    /// When enabled, dictionary entries are emitted in sorted key order and the
    /// output is byte-for-byte reproducible across runs and platforms.
    pub fn with_deterministic(deterministic: bool) -> Self {
        Self { deterministic }
    }

    pub fn serialize(&self, doc: &Document, writer: &mut (impl Write + Seek)) -> PdfResult<()> {
        writeln!(writer, "%PDF-1.7")?;
        writer.write_all(b"%\xe2\xcf\xd3\xe2\n")?;

        let mut offsets: BTreeMap<u32, u64> = BTreeMap::new();
        for (id, obj) in doc.objects() {
            offsets.insert(id.number, writer.stream_position()?);
            writeln!(writer, "{} {} obj", id.number, id.generation)?;
            self.serialize_object(obj, writer)?;
            // A token separator is required between the object body and the
            // `endobj` keyword: scalar bodies (`null`, `42`, `[...]`) would
            // otherwise run into it and corrupt the object.
            writeln!(writer)?;
            writeln!(writer, "endobj")?;
            writeln!(writer)?;
        }

        let xref_offset = writer.stream_position()?;
        let max_number = doc.objects().keys().map(|id| id.number).max().unwrap_or(0);
        let size = max_number + 1;

        writeln!(writer, "xref")?;
        writeln!(writer, "0 {}", size)?;
        for n in 0..=max_number {
            // PDF spec (ISO 32000-1 §7.5.4): each xref entry must be exactly
            // 20 bytes, including the end-of-line marker. The trailing space
            // pads the entry so a single `\n` keeps it at 20 bytes.
            if let Some(offset) = offsets.get(&n) {
                writeln!(writer, "{:010} 00000 n ", offset)?;
            } else {
                writeln!(writer, "0000000000 65535 f ")?;
            }
        }

        writeln!(writer, "trailer")?;
        write!(
            writer,
            "<< /Size {} /Root {}",
            size,
            doc.catalog().unwrap_or(ObjectId::new(1, 0))
        )?;
        if let Some(info) = doc.info() {
            write!(writer, " /Info {}", info)?;
        }
        writeln!(writer, " >>")?;
        writeln!(writer, "startxref")?;
        writeln!(writer, "{}", xref_offset)?;
        writeln!(writer, "%%EOF")?;
        Ok(())
    }

    fn serialize_object(&self, obj: &PdfObject, writer: &mut impl Write) -> PdfResult<()> {
        match obj {
            PdfObject::Null => write!(writer, "null")?,
            PdfObject::Boolean(b) => write!(writer, "{}", b)?,
            PdfObject::Integer(i) => write!(writer, "{}", i)?,
            PdfObject::Real(r) => {
                // Integral reals must keep a decimal point (`2.0`, not `2`):
                // Rust's Display drops it, and a reader would round-trip the
                // object back as an Integer, losing the type. Appending `.0`
                // is exact for any f64 whose value is an integer, so this is
                // safe at any magnitude.
                if r.is_finite() && r.fract() == 0.0 {
                    write!(writer, "{r:.1}")?;
                } else {
                    write!(writer, "{r}")?;
                }
            }
            PdfObject::Name(n) => write!(writer, "{}", n)?,
            PdfObject::String(s) => write!(writer, "{}", s)?,
            PdfObject::Array(a) => {
                write!(writer, "[")?;
                for (i, item) in a.0.iter().enumerate() {
                    if i > 0 {
                        write!(writer, " ")?;
                    }
                    self.serialize_object(item, writer)?;
                }
                write!(writer, "]")?;
            }
            PdfObject::Dictionary(d) => {
                writeln!(writer, "<<")?;
                if self.deterministic {
                    // Sorted key order keeps output byte-for-byte reproducible.
                    let mut entries: Vec<(&PdfName, &PdfObject)> = d.iter().collect();
                    entries.sort_by(|(a, _), (b, _)| a.0.cmp(&b.0));
                    for (key, value) in entries {
                        write!(writer, "{} ", key)?;
                        self.serialize_object(value, writer)?;
                        writeln!(writer)?;
                    }
                } else {
                    for (key, value) in d.iter() {
                        write!(writer, "{} ", key)?;
                        self.serialize_object(value, writer)?;
                        writeln!(writer)?;
                    }
                }
                write!(writer, ">>")?;
            }
            PdfObject::Stream(s) => {
                // ISO 32000-1 §7.3.8: /Length is required. If the stream was
                // built without one, emit the true byte length so the output
                // stays parseable by strict readers.
                let mut dict = s.dictionary.clone();
                dict.insert("Length", PdfObject::Integer(s.data.len() as i64));
                self.serialize_object(&PdfObject::Dictionary(dict), writer)?;
                writeln!(writer)?;
                writeln!(writer, "stream")?;
                writer.write_all(&s.data)?;
                writeln!(writer)?;
                write!(writer, "endstream")?;
            }
            PdfObject::Reference(id) => write!(writer, "{}", id)?,
        }
        Ok(())
    }
}

impl Default for Serializer {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;

    use super::*;
    use crate::{ObjectId, PdfDictionary, PdfDocument, PdfName, PdfObject};

    #[test]
    fn xref_entries_are_exactly_20_bytes() {
        // PDF spec (ISO 32000-1 §7.5.4) requires every xref entry to be exactly
        // 20 bytes including the EOL marker. Strict parsers (e.g. lopdf) read
        // fixed-width records, so under-padded entries break interop.
        let mut doc = PdfDocument::new();
        doc.set_catalog(ObjectId::new(1, 0));
        let mut catalog = PdfDictionary::new();
        catalog.insert("Type", PdfObject::Name(PdfName::new("Catalog")));
        doc.add_object(ObjectId::new(1, 0), PdfObject::Dictionary(catalog));

        let mut buf = Cursor::new(Vec::new());
        Serializer::new()
            .serialize(&doc, &mut buf)
            .expect("serialize");
        let bytes = buf.into_inner();

        // The header's binary comment line is not UTF-8, so scan raw bytes.
        let start = bytes
            .windows(b"startxref".len())
            .position(|w| w == b"startxref")
            .expect("startxref present");
        let xref = &bytes[..start];

        let mut entry_count = 0;
        for line in xref.split(|&b| b == b'\n') {
            // Each entry is 19 content bytes plus a 1-byte `\n` EOL = exactly
            // 20 bytes, per ISO 32000-1 §7.5.4: `nnnnnnnnnn ggggg n `.
            let is_entry = line.len() == 19
                && line[..10].iter().all(u8::is_ascii_digit)
                && (line.ends_with(b"n ") || line.ends_with(b"f "));
            if is_entry {
                entry_count += 1;
            }
        }
        assert!(
            entry_count >= 2,
            "expected at least two 20-byte xref entries, found {entry_count}"
        );
    }
}