1pub 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
23const MAX_PPTX_PAGES: u32 = 500;
26
27const PPTX_TIMEOUT_SECS: u64 = 120;
29
30#[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
64pub 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 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 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 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
118pub 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
124fn 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 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 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 let pptx = pdf_to_pptx(&doc).unwrap();
398 let xml = read_zip_entry(&pptx, "ppt/slides/slide1.xml").unwrap();
399
400 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}