Skip to main content

ppt_rs/export/
slide_render.rs

1//! Render slides as PDF vector pages using `pdfrs::pdf_generator::PdfGenerator`.
2//!
3//! Each slide becomes one PDF page with:
4//! - White background
5//! - Title text (bold, large)
6//! - Bullet points / content text
7//! - Slide number footer
8//!
9//! No external binaries required — all rendering is done with PDF
10//! vector/text operators.
11
12use crate::api::Presentation;
13use crate::exc::{PptxError, Result};
14use crate::generator::slide_content::{BulletStyle, SlideLayout, SlideContent};
15
16use pdfrs::pdf_generator::{PageLayout, PdfGenerator};
17
18const FONT_HELVETICA: &str = "Helvetica";
19const FONT_HELVETICA_BOLD: &str = "Helvetica-Bold";
20const FONT_HELVETICA_OBLIQUE: &str = "Helvetica-Oblique";
21const FONT_HELVETICA_BOLD_OBLIQUE: &str = "Helvetica-BoldOblique";
22
23struct FontIds {
24    helvetica: u32,
25    helvetica_bold: u32,
26    helvetica_oblique: u32,
27    helvetica_bold_oblique: u32,
28}
29
30fn add_fonts(generator: &mut PdfGenerator) -> FontIds {
31    let helvetica = generator.add_object(format!(
32        "<< /Type /Font\n/Subtype /Type1\n/BaseFont /{}\n>>\n",
33        FONT_HELVETICA
34    ));
35    let helvetica_bold = generator.add_object(format!(
36        "<< /Type /Font\n/Subtype /Type1\n/BaseFont /{}\n>>\n",
37        FONT_HELVETICA_BOLD
38    ));
39    let helvetica_oblique = generator.add_object(format!(
40        "<< /Type /Font\n/Subtype /Type1\n/BaseFont /{}\n>>\n",
41        FONT_HELVETICA_OBLIQUE
42    ));
43    let helvetica_bold_oblique = generator.add_object(format!(
44        "<< /Type /Font\n/Subtype /Type1\n/BaseFont /{}\n>>\n",
45        FONT_HELVETICA_BOLD_OBLIQUE
46    ));
47    FontIds {
48        helvetica,
49        helvetica_bold,
50        helvetica_oblique,
51        helvetica_bold_oblique,
52    }
53}
54
55fn font_name(bold: bool, italic: bool) -> &'static str {
56    match (bold, italic) {
57        (true, true) => FONT_HELVETICA_BOLD_OBLIQUE,
58        (true, false) => FONT_HELVETICA_BOLD,
59        (false, true) => FONT_HELVETICA_OBLIQUE,
60        (false, false) => FONT_HELVETICA,
61    }
62}
63
64fn hex_to_rgb(hex: &str) -> (f32, f32, f32) {
65    let h = hex.trim_start_matches('#');
66    if h.len() == 6 {
67        let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0);
68        let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0);
69        let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0);
70        (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
71    } else {
72        (0.0, 0.0, 0.0)
73    }
74}
75
76fn escape_pdf_text(s: &str) -> String {
77    s.replace('\\', "\\\\")
78        .replace('(', "\\(")
79        .replace(')', "\\)")
80}
81
82fn set_color(out: &mut Vec<u8>, r: f32, g: f32, b: f32) {
83    out.extend_from_slice(format!("{:.3} {:.3} {:.3} rg\n", r, g, b).as_bytes());
84}
85
86fn set_font(out: &mut Vec<u8>, name: &str, size: f32) {
87    out.extend_from_slice(format!("/{} {} Tf\n", name, size).as_bytes());
88}
89
90fn draw_rect(out: &mut Vec<u8>, x: f32, y: f32, w: f32, h: f32) {
91    out.extend_from_slice(format!("{:.1} {:.1} {:.1} {:.1} re\n", x, y, w, h).as_bytes());
92    out.extend_from_slice(b"f\n");
93}
94
95fn draw_text_line(
96    out: &mut Vec<u8>,
97    text: &str,
98    x: f32,
99    y: f32,
100    font_name: &str,
101    font_size: f32,
102    r: f32,
103    g: f32,
104    b: f32,
105) {
106    set_color(out, r, g, b);
107    set_font(out, font_name, font_size);
108    out.extend_from_slice(b"BT\n");
109    out.extend_from_slice(format!("{} {:.1} Td\n", x, y).as_bytes());
110    out.extend_from_slice(format!("({}) Tj\n", escape_pdf_text(text)).as_bytes());
111    out.extend_from_slice(b"ET\n");
112}
113
114fn approx_text_width(text: &str, font_size: f32) -> f32 {
115    let char_count = text.chars().count() as f32;
116    char_count * font_size * 0.5
117}
118
119fn bullet_prefix(style: BulletStyle, index: u32) -> String {
120    match style {
121        BulletStyle::Bullet => "\u{2022}".to_string(),
122        BulletStyle::Number => format!("{}.", index + 1),
123        BulletStyle::LetterLower => format!("{}.", char::from_u32(b'a' as u32 + index).unwrap_or('a')),
124        BulletStyle::LetterUpper => format!("{}.", char::from_u32(b'A' as u32 + index).unwrap_or('A')),
125        BulletStyle::RomanLower => to_roman(index + 1).to_lowercase(),
126        BulletStyle::RomanUpper => to_roman(index + 1),
127        BulletStyle::Custom(ch) => ch.to_string(),
128        BulletStyle::None => String::new(),
129    }
130}
131
132fn to_roman(mut num: u32) -> String {
133    let pairs = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")];
134    let mut result = String::new();
135    for (val, sym) in pairs {
136        while num >= val {
137            result.push_str(sym);
138            num -= val;
139        }
140    }
141    result
142}
143
144fn render_slide(
145    slide: &SlideContent,
146    slide_num: usize,
147    total: usize,
148    layout: &PageLayout,
149) -> Vec<u8> {
150    let mut cs = Vec::with_capacity(2048);
151    let page_w = layout.width;
152    let page_h = layout.height;
153    let margin = 54.0;
154
155    // White background
156    set_color(&mut cs, 1.0, 1.0, 1.0);
157    draw_rect(&mut cs, 0.0, 0.0, page_w, page_h);
158
159    // Title
160    let title_size = slide.title_size.unwrap_or(36) as f32 * 0.75;
161    let title_bold = slide.title_bold;
162    let title_italic = slide.title_italic;
163    let (tr, tg, tb) = slide
164        .title_color
165        .as_deref()
166        .map(hex_to_rgb)
167        .unwrap_or((0.1, 0.1, 0.1));
168
169    let title_y = page_h - margin - title_size;
170
171    match slide.layout {
172        SlideLayout::CenteredTitle | SlideLayout::SectionHeader => {
173            let tw = approx_text_width(&slide.title, title_size);
174            let tx = (page_w - tw) / 2.0;
175            draw_text_line(
176                &mut cs,
177                &slide.title,
178                tx,
179                title_y,
180                font_name(title_bold, title_italic),
181                title_size,
182                tr, tg, tb,
183            );
184        }
185        _ => {
186            draw_text_line(
187                &mut cs,
188                &slide.title,
189                margin,
190                title_y,
191                font_name(title_bold, title_italic),
192                title_size,
193                tr, tg, tb,
194            );
195            // Underline
196            if slide.title_underline {
197                set_color(&mut cs, tr, tg, tb);
198                cs.extend_from_slice(
199                    format!("{:.1} {:.1} m\n{:.1} {:.1} l\n1 w\nS\n", margin, title_y - 4.0, page_w - margin, title_y - 4.0).as_bytes(),
200                );
201            }
202        }
203    }
204
205    // Content
206    let content_size = slide.content_size.unwrap_or(24) as f32 * 0.75;
207    let content_bold = slide.content_bold;
208    let content_italic = slide.content_italic;
209    let (cr, cg, cb) = slide
210        .content_color
211        .as_deref()
212        .map(hex_to_rgb)
213        .unwrap_or((0.2, 0.2, 0.2));
214
215    let line_height = content_size * 1.5;
216    let mut y = title_y - line_height * 1.5;
217
218    let is_centered = matches!(slide.layout, SlideLayout::CenteredTitle | SlideLayout::SectionHeader);
219
220    if !slide.bullets.is_empty() {
221        let mut numbered = 0u32;
222        for bp in &slide.bullets {
223            if y < margin + line_height {
224                break;
225            }
226
227            let prefix = bullet_prefix(bp.style, numbered);
228            let indent = (bp.level as f32) * 20.0;
229            let bx = margin + indent;
230
231            let fmt = bp.format.as_ref();
232            let b_bold = content_bold || fmt.is_some_and(|f| f.bold);
233            let b_italic = content_italic || fmt.is_some_and(|f| f.italic);
234            let (btr, btg, btb) = fmt
235                .and_then(|f| f.color.as_deref())
236                .map(hex_to_rgb)
237                .unwrap_or((cr, cg, cb));
238            let b_size = fmt
239                .and_then(|f| f.font_size)
240                .map(|s| s as f32 * 0.75)
241                .unwrap_or(content_size);
242
243            if !prefix.is_empty() {
244                draw_text_line(
245                    &mut cs,
246                    &prefix,
247                    bx,
248                    y,
249                    font_name(b_bold, b_italic),
250                    b_size,
251                    btr, btg, btb,
252                );
253            }
254
255            let text_x = bx + if prefix.is_empty() { 0.0 } else { 20.0 };
256            let text = &bp.text;
257            draw_text_line(
258                &mut cs,
259                text,
260                text_x,
261                y,
262                font_name(b_bold, b_italic),
263                b_size,
264                btr, btg, btb,
265            );
266
267            y -= line_height;
268            if matches!(bp.style, BulletStyle::Number | BulletStyle::LetterLower | BulletStyle::LetterUpper | BulletStyle::RomanLower | BulletStyle::RomanUpper) {
269                numbered += 1;
270            }
271        }
272    } else if !slide.content.is_empty() {
273        for line in &slide.content {
274            if y < margin + line_height {
275                break;
276            }
277            if is_centered {
278                let tw = approx_text_width(line, content_size);
279                let tx = (page_w - tw) / 2.0;
280                draw_text_line(&mut cs, line, tx, y, font_name(content_bold, content_italic), content_size, cr, cg, cb);
281            } else {
282                draw_text_line(&mut cs, line, margin, y, font_name(content_bold, content_italic), content_size, cr, cg, cb);
283            }
284            y -= line_height;
285        }
286    }
287
288    // Code blocks
289    for code_block in &slide.code_blocks {
290        if y < margin + line_height {
291            break;
292        }
293        let code_size = content_size * 0.85;
294        let code_line_h = code_size * 1.4;
295        let code_bg_h = (code_block.code.lines().count() as f32 + 1.0) * code_line_h;
296
297        // Background
298        set_color(&mut cs, 0.95, 0.95, 0.95);
299        draw_rect(&mut cs, margin, y - code_bg_h, page_w - 2.0 * margin, code_bg_h);
300
301        // Code text
302        y -= code_line_h;
303        for line in code_block.code.lines() {
304            if y < margin {
305                break;
306            }
307            draw_text_line(&mut cs, line, margin + 8.0, y, "Courier", code_size, 0.1, 0.1, 0.1);
308            y -= code_line_h;
309        }
310        y -= line_height * 0.5;
311    }
312
313    // Slide number footer
314    let footer = format!("{} / {}", slide_num, total);
315    let fw = approx_text_width(&footer, 10.0);
316    draw_text_line(
317        &mut cs,
318        &footer,
319        (page_w - fw) / 2.0,
320        margin / 2.0,
321        FONT_HELVETICA,
322        10.0,
323        0.5, 0.5, 0.5,
324    );
325
326    cs
327}
328
329fn add_page(
330    generator: &mut PdfGenerator,
331    content: Vec<u8>,
332    layout: &PageLayout,
333    font_ids: &FontIds,
334    pages_obj_id: u32,
335) -> u32 {
336    let dict = format!("<< /Length {} >>\n", content.len());
337    let content_id = generator.add_stream_object(dict, content);
338
339    let page_dict = format!(
340        "<< /Type /Page\n\
341         /Parent {} 0 R\n\
342         /MediaBox [0 0 {} {}]\n\
343         /Contents {} 0 R\n\
344         /Resources << /Font << \
345             /{} {} 0 R \
346             /{} {} 0 R \
347             /{} {} 0 R \
348             /{} {} 0 R \
349         >> >>\n\
350         >>\n",
351        pages_obj_id,
352        layout.width,
353        layout.height,
354        content_id,
355        FONT_HELVETICA, font_ids.helvetica,
356        FONT_HELVETICA_BOLD, font_ids.helvetica_bold,
357        FONT_HELVETICA_OBLIQUE, font_ids.helvetica_oblique,
358        FONT_HELVETICA_BOLD_OBLIQUE, font_ids.helvetica_bold_oblique,
359    );
360    generator.add_object(page_dict)
361}
362
363/// Render a presentation to PDF bytes using vector PDF operators.
364///
365/// Each slide becomes one PDF page with title, bullets, and content
366/// rendered as native PDF text and vector graphics.
367pub fn render_to_pdf_bytes(presentation: &Presentation) -> Result<Vec<u8>> {
368    let slides = presentation.slides();
369    if slides.is_empty() {
370        return Err(PptxError::InvalidState(
371            crate::exc::messages::must_not_be_empty("presentation slides for PDF export"),
372        ));
373    }
374
375    let layout = PageLayout::landscape();
376    let mut generator = PdfGenerator::new().with_version(layout.version);
377    let font_ids = add_fonts(&mut generator);
378
379    let total = slides.len();
380    let per_page_objects = 2u32;
381    let pages_obj_id = generator.next_id + per_page_objects * total as u32;
382
383    let mut page_ids = Vec::with_capacity(total);
384    for (i, slide) in slides.iter().enumerate() {
385        let content = render_slide(slide, i + 1, total, &layout);
386        let page_id = add_page(&mut generator, content, &layout, &font_ids, pages_obj_id);
387        page_ids.push(page_id);
388    }
389
390    let kids: Vec<String> = page_ids.iter().map(|id| format!("{} 0 R", id)).collect();
391    let pages_dict = format!(
392        "<< /Type /Pages\n\
393         /Kids [{}]\n\
394         /Count {}\n\
395         >>\n",
396        kids.join(" "),
397        page_ids.len()
398    );
399    let actual_pages_id = generator.add_object(pages_dict);
400    assert_eq!(actual_pages_id, pages_obj_id);
401
402    let catalog = format!(
403        "<< /Type /Catalog\n\
404         /Pages {} 0 R\n\
405         >>\n",
406        pages_obj_id
407    );
408    generator.add_object(catalog);
409
410    Ok(generator.generate())
411}
412
413/// Render a presentation to a PDF file using vector PDF operators.
414pub fn render_to_pdf<P: AsRef<std::path::Path>>(
415    presentation: &Presentation,
416    output_path: P,
417) -> Result<()> {
418    let bytes = render_to_pdf_bytes(presentation)?;
419    std::fs::write(output_path.as_ref(), &bytes)?;
420    Ok(())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::generator::SlideContent;
427
428    #[test]
429    fn test_render_simple_presentation() {
430        let pres = Presentation::with_title("Test")
431            .add_slide(SlideContent::new("Slide 1").add_bullet("Hello"))
432            .add_slide(SlideContent::new("Slide 2").add_bullet("World"));
433
434        let bytes = render_to_pdf_bytes(&pres).unwrap();
435        assert!(!bytes.is_empty());
436        assert_eq!(&bytes[..5], b"%PDF-");
437    }
438
439    #[test]
440    fn test_render_empty_presentation_errors() {
441        let pres = Presentation::with_title("Empty");
442        let result = render_to_pdf_bytes(&pres);
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_render_with_styled_bullets() {
448        let pres = Presentation::with_title("Styled")
449            .add_slide(
450                SlideContent::new("Styled Slide")
451                    .add_styled_bullet("Bold item", BulletStyle::Number)
452                    .add_styled_bullet("Another", BulletStyle::Bullet),
453            );
454
455        let bytes = render_to_pdf_bytes(&pres).unwrap();
456        assert_eq!(&bytes[..5], b"%PDF-");
457    }
458
459    #[test]
460    fn test_render_centered_title_layout() {
461        let pres = Presentation::with_title("Centered")
462            .add_slide(
463                SlideContent::new("Centered Title")
464                    .with_layout(SlideLayout::CenteredTitle)
465                    .add_bullet("Subtitle text"),
466            );
467
468        let bytes = render_to_pdf_bytes(&pres).unwrap();
469        assert_eq!(&bytes[..5], b"%PDF-");
470    }
471
472    #[test]
473    fn test_hex_to_rgb() {
474        assert_eq!(hex_to_rgb("FF0000"), (1.0, 0.0, 0.0));
475        assert_eq!(hex_to_rgb("#00FF00"), (0.0, 1.0, 0.0));
476        assert_eq!(hex_to_rgb("0000FF"), (0.0, 0.0, 1.0));
477        assert_eq!(hex_to_rgb("invalid"), (0.0, 0.0, 0.0));
478    }
479
480    #[test]
481    fn test_bullet_prefix() {
482        assert_eq!(bullet_prefix(BulletStyle::Bullet, 0), "\u{2022}");
483        assert_eq!(bullet_prefix(BulletStyle::Number, 0), "1.");
484        assert_eq!(bullet_prefix(BulletStyle::Number, 2), "3.");
485        assert_eq!(bullet_prefix(BulletStyle::LetterLower, 0), "a.");
486        assert_eq!(bullet_prefix(BulletStyle::None, 0), "");
487    }
488
489    #[test]
490    fn test_to_roman() {
491        assert_eq!(to_roman(1), "I");
492        assert_eq!(to_roman(4), "IV");
493        assert_eq!(to_roman(9), "IX");
494        assert_eq!(to_roman(2024), "MMXXIV");
495    }
496
497    #[test]
498    fn test_escape_pdf_text() {
499        assert_eq!(escape_pdf_text("hello"), "hello");
500        assert_eq!(escape_pdf_text("a(b)c"), "a\\(b\\)c");
501        assert_eq!(escape_pdf_text("a\\b"), "a\\\\b");
502    }
503
504    #[test]
505    fn test_render_to_pdf_file() {
506        let pres = Presentation::with_title("File Test")
507            .add_slide(SlideContent::new("Slide 1").add_bullet("Content"));
508
509        let path = std::env::temp_dir().join(format!(
510            "ppt_rs_vector_pdf_{}.pdf",
511            uuid::Uuid::new_v4()
512        ));
513        render_to_pdf(&pres, &path).unwrap();
514        let read_back = std::fs::read(&path).unwrap();
515        assert_eq!(&read_back[..5], b"%PDF-");
516        let _ = std::fs::remove_file(&path);
517    }
518}