lo_core 0.3.4

Core data models and XML utilities for ODF document generation
Documentation
//! Minimal pure-Rust PDF writer used by all crates that need PDF export.
//!
//! This is intentionally tiny: it can render a single Helvetica text page from
//! a list of lines, and it can serialize an arbitrary list of PDF objects into
//! a valid `%PDF-1.4` byte stream with a correct cross-reference table.
//!
//! It is *not* a layout engine. Higher-level crates are expected to break
//! their content into lines themselves and pass them in.

use crate::units::Length;

fn pdf_escape(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('(', "\\(")
        .replace(')', "\\)")
}

/// Build a self-contained single-page text PDF.
///
/// `page_width` and `page_height` are interpreted as PDF user-space points.
pub fn write_text_pdf(lines: &[String], page_width: Length, page_height: Length) -> Vec<u8> {
    let width_pt = page_width.as_pt();
    let height_pt = page_height.as_pt();

    let mut content = String::new();
    content.push_str("BT\n/F1 12 Tf\n14 TL\n50 ");
    content.push_str(&format!("{:.2}", height_pt - 50.0));
    content.push_str(" Td\n");
    for (index, line) in lines.iter().enumerate() {
        if index > 0 {
            content.push_str("T*\n");
        }
        content.push('(');
        content.push_str(&pdf_escape(line));
        content.push_str(") Tj\n");
    }
    content.push_str("ET\n");

    let objects = vec![
        "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
        "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
        format!(
            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width_pt:.2} {height_pt:.2}] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>"
        ),
        format!(
            "<< /Length {} >>\nstream\n{}endstream",
            content.len(),
            content
        ),
        "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
    ];

    pdf_from_objects(&objects)
}

/// Serialize a list of already-rendered PDF object bodies into a complete
/// PDF byte stream with header, xref table and trailer.
pub fn pdf_from_objects(objects: &[String]) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(b"%PDF-1.4\n");
    let mut offsets = Vec::with_capacity(objects.len() + 1);
    offsets.push(0usize);
    for (index, object) in objects.iter().enumerate() {
        offsets.push(out.len());
        out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", index + 1, object).as_bytes());
    }
    let xref_pos = out.len();
    out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
    out.extend_from_slice(b"0000000000 65535 f \n");
    for offset in offsets.iter().skip(1) {
        out.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
    }
    out.extend_from_slice(
        format!(
            "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
            objects.len() + 1,
            xref_pos
        )
        .as_bytes(),
    );
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn writes_pdf_header_and_eof() {
        let pdf = write_text_pdf(
            &["Hello, world".to_string(), "Second line".to_string()],
            Length::pt(595.0),
            Length::pt(842.0),
        );
        assert!(pdf.starts_with(b"%PDF-1.4"));
        assert!(pdf.ends_with(b"%%EOF\n"));
        assert!(pdf.windows(4).any(|w| w == b"xref"));
    }

    #[test]
    fn escapes_pdf_text_special_chars() {
        let pdf = write_text_pdf(
            &["a (b) \\c".to_string()],
            Length::pt(100.0),
            Length::pt(100.0),
        );
        let s = String::from_utf8_lossy(&pdf);
        assert!(s.contains("a \\(b\\) \\\\c"));
    }
}