Skip to main content

ppt_rs/export/
pdf_export.rs

1//! Pure-Rust PDF export module
2//!
3//! Exports presentations to PDF using the `pdfrs` crate (no LibreOffice /
4//! Poppler / LaTeX required). The presentation is first rendered to a
5//! CommonMark-style Markdown document via [`crate::export::md`], then the
6//! Markdown is parsed into structured elements and rendered to PDF bytes
7//! by `pdfrs`.
8//!
9//! Enable with the `pdf-native` Cargo feature:
10//!
11//! ```toml
12//! [dependencies]
13//! ppt-rs = { version = "0.2", features = ["pdf-native"] }
14//! ```
15//!
16//! # Quick start
17//!
18//! ```rust,no_run
19//! # #[cfg(feature = "pdf-native")] {
20//! use ppt_rs::api::Presentation;
21//! use ppt_rs::generator::SlideContent;
22//! use ppt_rs::export::pdf_export::{export_to_pdf, PdfExportOptions};
23//!
24//! let pres = Presentation::with_title("Demo")
25//!     .add_slide(SlideContent::new("Slide 1").add_bullet("Hello"))
26//!     .add_slide(SlideContent::new("Slide 2").add_bullet("World"));
27//!
28//! export_to_pdf(&pres, "out.pdf", &PdfExportOptions::default()).unwrap();
29//! # }
30//! ```
31
32use crate::api::Presentation;
33use crate::exc::{PptxError, Result};
34use std::path::Path;
35
36/// Page orientation for PDF output.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum PdfOrientation {
39    #[default]
40    Portrait,
41    Landscape,
42}
43
44/// Options for pure-Rust PDF export.
45#[derive(Debug, Clone)]
46pub struct PdfExportOptions {
47    /// Page orientation (default portrait).
48    pub orientation: PdfOrientation,
49    /// Base font name (default `"Helvetica"`). Pass a `pdfrs` Base-14 name or
50    /// the path to a TTF file.
51    pub font: String,
52    /// Base font size in points (default `12.0`).
53    pub font_size: f32,
54    /// Whether to include the YAML frontmatter in the rendered Markdown
55    /// (default `true`).
56    pub include_frontmatter: bool,
57    /// Whether to include speaker notes (default `true`).
58    pub include_notes: bool,
59    /// Whether to include image references (default `true`).
60    pub include_images: bool,
61}
62
63impl Default for PdfExportOptions {
64    fn default() -> Self {
65        Self {
66            orientation: PdfOrientation::Portrait,
67            font: "Helvetica".to_string(),
68            font_size: 12.0,
69            include_frontmatter: true,
70            include_notes: true,
71            include_images: true,
72        }
73    }
74}
75
76impl PdfExportOptions {
77    /// Create new options with defaults.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set page orientation.
83    pub fn with_orientation(mut self, orientation: PdfOrientation) -> Self {
84        self.orientation = orientation;
85        self
86    }
87
88    /// Set base font name (Base-14 or TTF path).
89    pub fn with_font(mut self, font: impl Into<String>) -> Self {
90        self.font = font.into();
91        self
92    }
93
94    /// Set base font size in points.
95    pub fn with_font_size(mut self, size: f32) -> Self {
96        self.font_size = size.max(1.0);
97        self
98    }
99
100    /// Toggle YAML frontmatter in the rendered Markdown.
101    pub fn with_frontmatter(mut self, include: bool) -> Self {
102        self.include_frontmatter = include;
103        self
104    }
105
106    /// Toggle speaker notes in the rendered Markdown.
107    pub fn with_notes(mut self, include: bool) -> Self {
108        self.include_notes = include;
109        self
110    }
111
112    /// Toggle image references in the rendered Markdown.
113    pub fn with_images(mut self, include: bool) -> Self {
114        self.include_images = include;
115        self
116    }
117
118    /// Landscape preset.
119    pub fn landscape() -> Self {
120        Self::default().with_orientation(PdfOrientation::Landscape)
121    }
122}
123
124/// Render a presentation to PDF bytes using the pure-Rust `pdfrs` engine.
125///
126/// No external binaries required. Returns the raw PDF byte buffer.
127pub fn export_to_pdf_bytes(
128    presentation: &Presentation,
129    options: &PdfExportOptions,
130) -> Result<Vec<u8>> {
131    let md = crate::export::md::export_to_markdown_with_options(
132        presentation,
133        &crate::export::md::MarkdownOptions {
134            include_frontmatter: options.include_frontmatter,
135            slide_separator: "---".to_string(),
136            include_notes: options.include_notes,
137            use_gfm_tables: true,
138            include_images: options.include_images,
139            include_slide_numbers: true,
140        },
141    )?;
142
143    let elements = pdfrs::elements::parse_markdown(&md);
144    let layout = match options.orientation {
145        PdfOrientation::Portrait => pdfrs::pdf_generator::PageLayout::portrait(),
146        PdfOrientation::Landscape => pdfrs::pdf_generator::PageLayout::landscape(),
147    };
148
149    pdfrs::pdf_generator::generate_pdf_bytes(&elements, &options.font, options.font_size, layout)
150        .map_err(|e| PptxError::Generic(format!("pdfrs generation failed: {e}")))
151}
152
153/// Render a presentation to a PDF file using the pure-Rust `pdfrs` engine.
154pub fn export_to_pdf<P: AsRef<Path>>(
155    presentation: &Presentation,
156    output_path: P,
157    options: &PdfExportOptions,
158) -> Result<Vec<u8>> {
159    let bytes = export_to_pdf_bytes(presentation, options)?;
160    std::fs::write(output_path.as_ref(), &bytes)?;
161    Ok(bytes)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::generator::SlideContent;
168
169    #[test]
170    fn test_options_default() {
171        let opts = PdfExportOptions::default();
172        assert_eq!(opts.orientation, PdfOrientation::Portrait);
173        assert_eq!(opts.font, "Helvetica");
174        assert!((opts.font_size - 12.0).abs() < f32::EPSILON);
175        assert!(opts.include_frontmatter);
176        assert!(opts.include_notes);
177        assert!(opts.include_images);
178    }
179
180    #[test]
181    fn test_options_builder() {
182        let opts = PdfExportOptions::new()
183            .with_orientation(PdfOrientation::Landscape)
184            .with_font("Times-Roman")
185            .with_font_size(10.0)
186            .with_frontmatter(false)
187            .with_notes(false)
188            .with_images(false);
189
190        assert_eq!(opts.orientation, PdfOrientation::Landscape);
191        assert_eq!(opts.font, "Times-Roman");
192        assert!((opts.font_size - 10.0).abs() < f32::EPSILON);
193        assert!(!opts.include_frontmatter);
194        assert!(!opts.include_notes);
195        assert!(!opts.include_images);
196    }
197
198    #[test]
199    fn test_options_landscape_preset() {
200        let opts = PdfExportOptions::landscape();
201        assert_eq!(opts.orientation, PdfOrientation::Landscape);
202    }
203
204    #[test]
205    fn test_options_font_size_floors_at_one() {
206        let opts = PdfExportOptions::new().with_font_size(0.0);
207        assert!(opts.font_size >= 1.0);
208    }
209
210    #[test]
211    fn test_export_to_pdf_bytes_simple() {
212        let pres = Presentation::with_title("Native PDF")
213            .add_slide(SlideContent::new("Slide 1").add_bullet("Hello"))
214            .add_slide(SlideContent::new("Slide 2").add_bullet("World"));
215
216        let bytes = export_to_pdf_bytes(&pres, &PdfExportOptions::default()).unwrap();
217        assert!(!bytes.is_empty());
218        assert_eq!(&bytes[..5], b"%PDF-");
219    }
220
221    #[test]
222    fn test_export_to_pdf_bytes_landscape() {
223        let pres = Presentation::with_title("Landscape")
224            .add_slide(SlideContent::new("Only"));
225
226        let opts = PdfExportOptions::landscape();
227        let bytes = export_to_pdf_bytes(&pres, &opts).unwrap();
228        assert_eq!(&bytes[..5], b"%PDF-");
229    }
230
231    #[test]
232    fn test_export_to_pdf_file() {
233        let pres = Presentation::with_title("File")
234            .add_slide(SlideContent::new("Hi").add_bullet("Bullet"));
235
236        let path = std::env::temp_dir().join(format!(
237            "ppt_rs_pdf_export_{}.pdf",
238            uuid::Uuid::new_v4()
239        ));
240        let opts = PdfExportOptions::new().with_frontmatter(false).with_notes(false);
241
242        export_to_pdf(&pres, &path, &opts).unwrap();
243        let read_back = std::fs::read(&path).unwrap();
244        assert_eq!(&read_back[..5], b"%PDF-");
245        assert!(pdfrs::pdf::validate_pdf_bytes(&read_back).valid);
246
247        let _ = std::fs::remove_file(&path);
248    }
249
250    #[test]
251    fn test_export_to_pdf_bytes_without_frontmatter() {
252        let pres = Presentation::with_title("NoFrontmatter")
253            .add_slide(SlideContent::new("S1").add_bullet("A"));
254
255        let opts = PdfExportOptions::new().with_frontmatter(false);
256        let bytes = export_to_pdf_bytes(&pres, &opts).unwrap();
257        assert_eq!(&bytes[..5], b"%PDF-");
258    }
259
260    #[test]
261    fn test_export_empty_presentation_still_produces_pdf() {
262        let pres = Presentation::with_title("Empty");
263        let bytes = export_to_pdf_bytes(&pres, &PdfExportOptions::default()).unwrap();
264        assert_eq!(&bytes[..5], b"%PDF-");
265    }
266
267    #[test]
268    fn test_orientation_default_is_portrait() {
269        assert_eq!(PdfOrientation::default(), PdfOrientation::Portrait);
270    }
271}