lo_core 0.3.5

Core data models and XML utilities for ODF document generation
Documentation
//! Multi-page PDF canvas built on top of [`pdf_from_objects`].
//!
//! This is a thin layer that lets higher-level layout code build PDF
//! pages with text positioning, lines, and filled rectangles. It's not
//! a full layout engine — callers wrap text and place glyphs manually
//! — but it's enough to render real-looking Writer documents and
//! Impress slides without an external PDF library.
//!
//! Resources for every page reference the same four base fonts:
//!
//! - `F1` Helvetica
//! - `F2` Helvetica-Bold
//! - `F3` Helvetica-Oblique
//! - `F4` Courier

use crate::pdf::pdf_from_objects;
use crate::{LoError, Result};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PdfFont {
    Helvetica,
    HelveticaBold,
    HelveticaOblique,
    Courier,
}

impl PdfFont {
    fn resource_name(self) -> &'static str {
        match self {
            Self::Helvetica => "F1",
            Self::HelveticaBold => "F2",
            Self::HelveticaOblique => "F3",
            Self::Courier => "F4",
        }
    }

    fn base_font(self) -> &'static str {
        match self {
            Self::Helvetica => "Helvetica",
            Self::HelveticaBold => "Helvetica-Bold",
            Self::HelveticaOblique => "Helvetica-Oblique",
            Self::Courier => "Courier",
        }
    }
}

#[derive(Clone, Debug)]
pub struct PdfPage {
    pub width: f32,
    pub height: f32,
    commands: String,
}

impl PdfPage {
    pub fn new(width: f32, height: f32) -> Self {
        Self {
            width,
            height,
            commands: String::new(),
        }
    }

    pub fn text(&mut self, x: f32, y: f32, size: f32, font: PdfFont, text: &str) {
        self.commands.push_str("BT\n");
        self.commands
            .push_str(&format!("/{} {} Tf\n", font.resource_name(), size));
        self.commands
            .push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", x, y));
        self.commands.push('(');
        self.commands.push_str(&pdf_escape(text));
        self.commands.push_str(") Tj\nET\n");
    }

    pub fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
        self.commands
            .push_str(&format!("{:.2} {:.2} m {:.2} {:.2} l S\n", x1, y1, x2, y2));
    }

    pub fn rect_stroke(&mut self, x: f32, y: f32, width: f32, height: f32) {
        self.commands.push_str(&format!(
            "{:.2} {:.2} {:.2} {:.2} re S\n",
            x, y, width, height
        ));
    }

    pub fn rect_fill_rgb(
        &mut self,
        x: f32,
        y: f32,
        width: f32,
        height: f32,
        r: f32,
        g: f32,
        b: f32,
    ) {
        self.commands
            .push_str(&format!("{:.3} {:.3} {:.3} rg\n", r, g, b));
        self.commands.push_str(&format!(
            "{:.2} {:.2} {:.2} {:.2} re f\n",
            x, y, width, height
        ));
        self.commands.push_str("0 0 0 rg\n");
    }

    pub fn raw(&mut self, command: &str) {
        self.commands.push_str(command);
        if !command.ends_with('\n') {
            self.commands.push('\n');
        }
    }
}

#[derive(Clone, Debug, Default)]
pub struct PdfDocument {
    pages: Vec<PdfPage>,
}

impl PdfDocument {
    pub fn new() -> Self {
        Self { pages: Vec::new() }
    }

    pub fn add_page(&mut self, width: f32, height: f32) -> usize {
        self.pages.push(PdfPage::new(width, height));
        self.pages.len() - 1
    }

    pub fn page_mut(&mut self, index: usize) -> Result<&mut PdfPage> {
        self.pages
            .get_mut(index)
            .ok_or_else(|| LoError::InvalidInput(format!("pdf page index out of range: {index}")))
    }

    pub fn pages(&self) -> &[PdfPage] {
        &self.pages
    }

    /// Serialize the canvas into a `%PDF-1.4` byte stream. Empty
    /// documents produce a single blank page so the result is always
    /// a valid PDF.
    pub fn finish(self) -> Vec<u8> {
        if self.pages.is_empty() {
            return empty_pdf();
        }

        let mut objects = Vec::new();
        // Slot 1 = catalog, slot 2 = pages tree, 3-6 = the four base fonts.
        objects.push(String::new());
        objects.push(String::new());
        for font in [
            PdfFont::Helvetica,
            PdfFont::HelveticaBold,
            PdfFont::HelveticaOblique,
            PdfFont::Courier,
        ] {
            objects.push(format!(
                "<< /Type /Font /Subtype /Type1 /BaseFont /{} >>",
                font.base_font()
            ));
        }
        let page_start = 7usize;
        let mut kids = Vec::new();
        for (index, page) in self.pages.iter().enumerate() {
            let page_obj = page_start + index * 2;
            let content_obj = page_obj + 1;
            kids.push(format!("{} 0 R", page_obj));
            let resources = "<< /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R >> >>";
            objects.push(format!(
                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] /Resources {} /Contents {} 0 R >>",
                page.width, page.height, resources, content_obj
            ));
            objects.push(format!(
                "<< /Length {} >>\nstream\n{}endstream",
                page.commands.len(),
                page.commands
            ));
        }
        objects[0] = "<< /Type /Catalog /Pages 2 0 R >>".to_string();
        objects[1] = format!(
            "<< /Type /Pages /Count {} /Kids [{}] >>",
            self.pages.len(),
            kids.join(" ")
        );
        pdf_from_objects(&objects)
    }
}

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

fn empty_pdf() -> Vec<u8> {
    let mut doc = PdfDocument::new();
    let page = doc.add_page(595.0, 842.0);
    let _ = doc
        .page_mut(page)
        .map(|p| p.text(50.0, 792.0, 12.0, PdfFont::Helvetica, ""));
    doc.finish()
}

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

    #[test]
    fn finish_emits_valid_pdf() {
        let mut doc = PdfDocument::new();
        let p = doc.add_page(595.0, 842.0);
        doc.page_mut(p)
            .unwrap()
            .text(50.0, 792.0, 12.0, PdfFont::Helvetica, "hello");
        let bytes = doc.finish();
        assert!(bytes.starts_with(b"%PDF-1.4"));
        assert!(bytes.ends_with(b"%%EOF\n"));
    }

    #[test]
    fn multi_page_documents_have_two_kids() {
        let mut doc = PdfDocument::new();
        doc.add_page(595.0, 842.0);
        doc.add_page(595.0, 842.0);
        let bytes = doc.finish();
        let s = String::from_utf8_lossy(&bytes);
        assert!(s.contains("/Count 2"));
    }

    #[test]
    fn empty_document_still_produces_pdf() {
        let bytes = PdfDocument::new().finish();
        assert!(bytes.starts_with(b"%PDF-1.4"));
    }
}