1use 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 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 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 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 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 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 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 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 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
363pub 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
413pub 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}