Skip to main content

pdf_pptx/
lib.rs

1//! PDF to PowerPoint PPTX conversion.
2//!
3//! Each PDF page becomes a PPTX slide with text shapes at their original
4//! positions and images as picture shapes.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12pub mod error;
13pub mod writer;
14
15pub use error::{PptxError, Result};
16pub use writer::{PptxImage, SlideData};
17
18use lopdf::Document;
19use pdf_extract::{extract_blocks_from_page_id, extract_images_from_page_id};
20use std::io::Cursor;
21use writer::{extracted_to_pptx_image, PptxWriter};
22
23/// Maximum number of pages to convert to PPTX. Massive documents (e.g. 1000+
24/// pages) are rarely useful as presentations and cause timeouts.
25const MAX_PPTX_PAGES: u32 = 500;
26
27/// Conversion timeout in seconds.
28const PPTX_TIMEOUT_SECS: u64 = 120;
29
30/// Seconds elapsed since `start`, or 0 where the platform has no clock.
31///
32/// `Instant::now()` compiles for wasm32-unknown-unknown against std's
33/// `unsupported` stub and then panics at runtime with "time not implemented on
34/// this platform". Under `panic = "abort"` that reaches JavaScript as a bare
35/// `RuntimeError: unreachable` — no message, no location.
36///
37/// That is the same fault that took `convertToPdfa` down for three months
38/// (see `pdf-manip/src/clock.rs`). It surfaced here on 23-08-2026 the first
39/// time a wasm test called `pdf_to_pptx`; `cargo check --target wasm32` had
40/// been green all along, because compiling and running are different
41/// questions.
42///
43/// On wasm the timeout is simply not enforced: the browser tab is not a
44/// server, a runaway conversion is the caller's own page, and a wrong-but-
45/// present answer beats a trap.
46#[cfg(not(target_arch = "wasm32"))]
47fn seconds_since(start: &std::time::Instant) -> u64 {
48    start.elapsed().as_secs()
49}
50
51#[cfg(target_arch = "wasm32")]
52fn seconds_since(_start: &()) -> u64 {
53    0
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn clock_start() -> std::time::Instant {
58    std::time::Instant::now()
59}
60
61#[cfg(target_arch = "wasm32")]
62fn clock_start() {}
63
64/// Convert a PDF document to PPTX format.
65///
66/// Returns the PPTX file contents as bytes.
67pub fn pdf_to_pptx(doc: &Document) -> Result<Vec<u8>> {
68    let start = clock_start();
69    let pages = doc.get_pages();
70    let total_pages = pages.len() as u32;
71
72    if total_pages > MAX_PPTX_PAGES {
73        return Err(PptxError::Other(format!(
74            "Document too large for PPTX conversion: {total_pages} pages (limit: {MAX_PPTX_PAGES})"
75        )));
76    }
77
78    let mut writer = PptxWriter::new(Cursor::new(Vec::new()));
79    writer.begin()?;
80
81    let mut img_counter = 0;
82
83    for page_num in 1..=total_pages {
84        // Periodic timeout check.
85        if seconds_since(&start) > PPTX_TIMEOUT_SECS {
86            return Err(PptxError::Other("PPTX conversion timed out".into()));
87        }
88
89        let page_id = *pages.get(&page_num).ok_or(PptxError::Other(format!(
90            "Missing page dictionary for page {page_num}"
91        )))?;
92
93        let page_blocks = extract_blocks_from_page_id(doc, page_id, page_num);
94        let (page_width, page_height) = get_page_dimensions(doc, page_id);
95
96        // Extract images.
97        let mut pptx_images = Vec::new();
98        if let Ok(images) = extract_images_from_page_id(doc, page_id, page_num) {
99            for img in &images {
100                pptx_images.push(extracted_to_pptx_image(img, img_counter));
101                img_counter += 1;
102            }
103        }
104
105        // Add slide and then DROP the page data.
106        writer.add_slide(&SlideData {
107            text_blocks: page_blocks,
108            images: pptx_images,
109            page_width,
110            page_height,
111        })?;
112    }
113
114    let cursor = writer.finish()?;
115    Ok(cursor.into_inner())
116}
117
118/// Convert PDF bytes to PPTX format.
119pub fn convert_pdf_bytes_to_pptx(pdf_bytes: &[u8]) -> Result<Vec<u8>> {
120    let doc = Document::load_mem(pdf_bytes)?;
121    pdf_to_pptx(&doc)
122}
123
124/// Get page dimensions from page dictionary, falling back to letter size.
125fn get_page_dimensions(doc: &Document, page_id: lopdf::ObjectId) -> (f64, f64) {
126    let page_obj = match doc.get_object(page_id) {
127        Ok(obj) => obj,
128        Err(_) => return (612.0, 792.0),
129    };
130
131    let dict = match page_obj {
132        lopdf::Object::Dictionary(ref d) => d,
133        _ => return (612.0, 792.0),
134    };
135
136    if let Ok(lopdf::Object::Array(ref arr)) = dict.get(b"MediaBox") {
137        if arr.len() >= 4 {
138            let w = obj_to_f64(&arr[2]).unwrap_or(612.0);
139            let h = obj_to_f64(&arr[3]).unwrap_or(792.0);
140            return (w, h);
141        }
142    }
143
144    (612.0, 792.0)
145}
146
147fn obj_to_f64(obj: &lopdf::Object) -> Option<f64> {
148    match obj {
149        lopdf::Object::Integer(i) => Some(*i as f64),
150        lopdf::Object::Real(f) => Some(*f as f64),
151        _ => None,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use lopdf::{dictionary, Document, Object, Stream};
159    use std::io::Read;
160
161    fn make_test_pdf(content: &[u8]) -> Document {
162        let mut doc = Document::with_version("1.7");
163
164        let content_stream = Stream::new(dictionary! {}, content.to_vec());
165        let content_id = doc.add_object(Object::Stream(content_stream));
166
167        let page_dict = dictionary! {
168            "Type" => "Page",
169            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
170            "Contents" => Object::Reference(content_id),
171        };
172        let page_id = doc.add_object(Object::Dictionary(page_dict));
173
174        let pages_dict = dictionary! {
175            "Type" => "Pages",
176            "Kids" => vec![Object::Reference(page_id)],
177            "Count" => 1_i64,
178        };
179        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
180
181        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
182            d.set("Parent", Object::Reference(pages_id));
183        }
184
185        let catalog = dictionary! {
186            "Type" => "Catalog",
187            "Pages" => Object::Reference(pages_id),
188        };
189        let catalog_id = doc.add_object(Object::Dictionary(catalog));
190        doc.trailer.set("Root", Object::Reference(catalog_id));
191
192        doc
193    }
194
195    fn read_zip_entry(data: &[u8], name: &str) -> Option<String> {
196        let cursor = std::io::Cursor::new(data);
197        let mut archive = zip::ZipArchive::new(cursor).ok()?;
198        let mut file = archive.by_name(name).ok()?;
199        let mut content = String::new();
200        file.read_to_string(&mut content).ok()?;
201        Some(content)
202    }
203
204    fn zip_file_names(data: &[u8]) -> Vec<String> {
205        let cursor = std::io::Cursor::new(data);
206        let archive = zip::ZipArchive::new(cursor).unwrap();
207        (0..archive.len())
208            .map(|i| {
209                let cursor = std::io::Cursor::new(data);
210                let archive = zip::ZipArchive::new(cursor).unwrap();
211                archive.name_for_index(i).unwrap().unwrap().to_string()
212            })
213            .collect()
214    }
215
216    fn levenshtein_similarity(a: &str, b: &str) -> f64 {
217        let a: Vec<char> = a.chars().collect();
218        let b: Vec<char> = b.chars().collect();
219        let (m, n) = (a.len(), b.len());
220        if m == 0 && n == 0 {
221            return 1.0;
222        }
223        let mut prev: Vec<usize> = (0..=n).collect();
224        let mut curr = vec![0; n + 1];
225        for i in 1..=m {
226            curr[0] = i;
227            for j in 1..=n {
228                let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
229                curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
230            }
231            std::mem::swap(&mut prev, &mut curr);
232        }
233        1.0 - (prev[n] as f64 / m.max(n) as f64)
234    }
235
236    #[test]
237    fn convert_simple_pdf_to_pptx() {
238        let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
239        let pptx = pdf_to_pptx(&doc).unwrap();
240        assert!(pptx.len() > 100);
241        assert_eq!(&pptx[0..2], b"PK");
242    }
243
244    #[test]
245    fn convert_empty_pdf_to_pptx() {
246        let doc = make_test_pdf(b"");
247        let pptx = pdf_to_pptx(&doc).unwrap();
248        assert!(pptx.len() > 100);
249    }
250
251    #[test]
252    fn convert_from_bytes() {
253        let mut doc = make_test_pdf(b"BT /F1 12 Tf (Test) Tj ET");
254        let mut pdf_bytes = Vec::new();
255        doc.save_to(&mut pdf_bytes).unwrap();
256
257        let pptx = convert_pdf_bytes_to_pptx(&pdf_bytes).unwrap();
258        assert!(pptx.len() > 100);
259    }
260
261    #[test]
262    fn pptx_structure_has_required_files() {
263        let doc = make_test_pdf(b"BT /F1 12 Tf (Structure test) Tj ET");
264        let pptx = pdf_to_pptx(&doc).unwrap();
265        let names = zip_file_names(&pptx);
266
267        assert!(names.contains(&"[Content_Types].xml".to_string()));
268        assert!(names.contains(&"_rels/.rels".to_string()));
269        assert!(names.contains(&"ppt/presentation.xml".to_string()));
270        assert!(names.contains(&"ppt/slides/slide1.xml".to_string()));
271        assert!(names.contains(&"ppt/slideMasters/slideMaster1.xml".to_string()));
272        assert!(names.contains(&"ppt/slideLayouts/slideLayout1.xml".to_string()));
273    }
274
275    #[test]
276    fn pptx_presentation_xml_parseable() {
277        let doc = make_test_pdf(b"BT /F1 12 Tf (Presentation test) Tj ET");
278        let pptx = pdf_to_pptx(&doc).unwrap();
279        let xml = read_zip_entry(&pptx, "ppt/presentation.xml").unwrap();
280
281        let mut reader = quick_xml::Reader::from_str(&xml);
282        let mut buf = Vec::new();
283        loop {
284            match reader.read_event_into(&mut buf) {
285                Ok(quick_xml::events::Event::Eof) => break,
286                Err(e) => panic!("Invalid XML in presentation.xml: {e}"),
287                _ => {}
288            }
289            buf.clear();
290        }
291    }
292
293    #[test]
294    fn pptx_slide_xml_parseable() {
295        let doc = make_test_pdf(b"BT /F1 12 Tf (Slide content) Tj ET");
296        let pptx = pdf_to_pptx(&doc).unwrap();
297        let xml = read_zip_entry(&pptx, "ppt/slides/slide1.xml").unwrap();
298
299        let mut reader = quick_xml::Reader::from_str(&xml);
300        let mut buf = Vec::new();
301        loop {
302            match reader.read_event_into(&mut buf) {
303                Ok(quick_xml::events::Event::Eof) => break,
304                Err(e) => panic!("Invalid XML in slide1.xml: {e}"),
305                _ => {}
306            }
307            buf.clear();
308        }
309    }
310
311    #[test]
312    fn pptx_text_preserved_in_slide() {
313        let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
314        let pptx = pdf_to_pptx(&doc).unwrap();
315        let xml = read_zip_entry(&pptx, "ppt/slides/slide1.xml").unwrap();
316
317        assert!(
318            xml.contains("Hello World"),
319            "Expected 'Hello World' in slide1.xml"
320        );
321    }
322
323    #[test]
324    fn pptx_content_types_valid() {
325        let doc = make_test_pdf(b"BT /F1 12 Tf (Content types) Tj ET");
326        let pptx = pdf_to_pptx(&doc).unwrap();
327        let ct = read_zip_entry(&pptx, "[Content_Types].xml").unwrap();
328
329        assert!(ct.contains("ContentType"));
330        assert!(ct.contains("presentationml"));
331    }
332
333    #[test]
334    fn pptx_multi_page_produces_multiple_slides() {
335        // Create a 2-page PDF.
336        let mut doc = Document::with_version("1.7");
337
338        let c1 = Stream::new(dictionary! {}, b"BT /F1 12 Tf (Page One) Tj ET".to_vec());
339        let c1_id = doc.add_object(Object::Stream(c1));
340        let c2 = Stream::new(dictionary! {}, b"BT /F1 12 Tf (Page Two) Tj ET".to_vec());
341        let c2_id = doc.add_object(Object::Stream(c2));
342
343        let p1 = dictionary! {
344            "Type" => "Page",
345            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
346            "Contents" => Object::Reference(c1_id),
347        };
348        let p1_id = doc.add_object(Object::Dictionary(p1));
349
350        let p2 = dictionary! {
351            "Type" => "Page",
352            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
353            "Contents" => Object::Reference(c2_id),
354        };
355        let p2_id = doc.add_object(Object::Dictionary(p2));
356
357        let pages = dictionary! {
358            "Type" => "Pages",
359            "Kids" => vec![Object::Reference(p1_id), Object::Reference(p2_id)],
360            "Count" => 2_i64,
361        };
362        let pages_id = doc.add_object(Object::Dictionary(pages));
363
364        for pid in [p1_id, p2_id] {
365            if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(pid) {
366                d.set("Parent", Object::Reference(pages_id));
367            }
368        }
369
370        let catalog = dictionary! {
371            "Type" => "Catalog",
372            "Pages" => Object::Reference(pages_id),
373        };
374        let catalog_id = doc.add_object(Object::Dictionary(catalog));
375        doc.trailer.set("Root", Object::Reference(catalog_id));
376
377        let pptx = pdf_to_pptx(&doc).unwrap();
378        let names = zip_file_names(&pptx);
379
380        assert!(names.contains(&"ppt/slides/slide1.xml".to_string()));
381        assert!(names.contains(&"ppt/slides/slide2.xml".to_string()));
382    }
383
384    #[test]
385    fn pptx_text_similarity_above_threshold() {
386        let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
387
388        // Extract text from the source PDF.
389        let blocks = pdf_extract::extract_text(&doc);
390        let pdf_text: String = blocks
391            .iter()
392            .map(|b| b.text.as_str())
393            .collect::<Vec<_>>()
394            .join(" ");
395
396        // Convert to PPTX and extract text from slide XML.
397        let pptx = pdf_to_pptx(&doc).unwrap();
398        let xml = read_zip_entry(&pptx, "ppt/slides/slide1.xml").unwrap();
399
400        // Extract text from a:t elements.
401        let mut pptx_texts = Vec::new();
402        let mut reader = quick_xml::Reader::from_str(&xml);
403        let mut buf = Vec::new();
404        let mut in_at = false;
405        loop {
406            match reader.read_event_into(&mut buf) {
407                Ok(quick_xml::events::Event::Start(e)) => {
408                    in_at = e.name().as_ref() == b"a:t";
409                }
410                Ok(quick_xml::events::Event::Text(e)) if in_at => {
411                    pptx_texts.push(e.unescape().unwrap().to_string());
412                }
413                Ok(quick_xml::events::Event::End(_)) => {
414                    in_at = false;
415                }
416                Ok(quick_xml::events::Event::Eof) => break,
417                Err(e) => panic!("XML parse error: {e}"),
418                _ => {}
419            }
420            buf.clear();
421        }
422        let pptx_text = pptx_texts.join(" ");
423
424        if pdf_text.len() >= 5 {
425            let similarity = levenshtein_similarity(&pdf_text, &pptx_text);
426            assert!(
427                similarity >= 0.80,
428                "Text similarity {similarity:.2} below 0.80.\n  PDF:  '{pdf_text}'\n  PPTX: '{pptx_text}'"
429            );
430        }
431    }
432
433    #[test]
434    fn pptx_slide_master_xml_parseable() {
435        let doc = make_test_pdf(b"BT /F1 12 Tf (Master test) Tj ET");
436        let pptx = pdf_to_pptx(&doc).unwrap();
437        let xml = read_zip_entry(&pptx, "ppt/slideMasters/slideMaster1.xml").unwrap();
438
439        let mut reader = quick_xml::Reader::from_str(&xml);
440        let mut buf = Vec::new();
441        loop {
442            match reader.read_event_into(&mut buf) {
443                Ok(quick_xml::events::Event::Eof) => break,
444                Err(e) => panic!("Invalid XML in slideMaster1.xml: {e}"),
445                _ => {}
446            }
447            buf.clear();
448        }
449    }
450}