Skip to main content

pdf_pptx/
writer.rs

1//! PPTX OOXML writer using quick-xml and zip.
2//!
3//! Each PDF page becomes a PowerPoint slide with text shapes and images.
4
5// Copyright (c) 2026 Innovation Trigger B.V.
6//
7// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
8// the PDFluent Commercial Licence. See the LICENSE file in this repository --
9// that file travels with the copy you received, which a URL does not.
10
11use crate::error::Result;
12use pdf_extract::{ExtractedImage, ImageFilter, TextBlock};
13use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
14use quick_xml::Writer;
15use std::collections::HashSet;
16use std::io::{Cursor, Seek, Write};
17use zip::write::SimpleFileOptions;
18use zip::ZipWriter;
19
20/// EMU per point (1 pt = 12700 EMU).
21const EMU_PER_PT: i64 = 12700;
22
23/// Default slide width in EMU (10 inches = 9144000 EMU).
24const SLIDE_WIDTH: i64 = 9144000;
25
26/// Default slide height in EMU (7.5 inches = 6858000 EMU).
27const SLIDE_HEIGHT: i64 = 6858000;
28
29/// An image to embed in the presentation.
30#[derive(Debug)]
31pub struct PptxImage {
32    pub data: Vec<u8>,
33    pub width: u32,
34    pub height: u32,
35    pub content_type: String,
36    pub filename: String,
37}
38
39/// Data for a single slide.
40pub struct SlideData {
41    pub text_blocks: Vec<TextBlock>,
42    pub images: Vec<PptxImage>,
43    /// Page width in PDF points.
44    pub page_width: f64,
45    /// Page height in PDF points.
46    pub page_height: f64,
47}
48
49/// Incremental PPTX writer to minimize memory usage during conversion.
50pub struct PptxWriter<W: Write + Seek> {
51    // zip 9's ZipWriter is not UnwindSafe; PptxWriter was, so a panic caught around a conversion still
52    // compiles for callers. AssertUnwindSafe keeps the auto trait (semver, vs 1.0.0).
53    zip: std::panic::AssertUnwindSafe<ZipWriter<W>>,
54    slide_count: usize,
55    image_extensions: HashSet<String>,
56    global_image_counter: usize,
57    options: SimpleFileOptions,
58    cached_master_xml: Vec<u8>,
59    cached_master_rels_xml: Vec<u8>,
60    cached_layout_xml: Vec<u8>,
61    cached_layout_rels_xml: Vec<u8>,
62}
63
64impl<W: Write + Seek> PptxWriter<W> {
65    pub fn new(inner: W) -> Self {
66        let zip = std::panic::AssertUnwindSafe(ZipWriter::new(inner));
67        let options =
68            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
69        let cached_master_xml = write_slide_master().unwrap_or_default();
70        let cached_master_rels_xml = write_slide_master_rels().unwrap_or_default();
71        let cached_layout_xml = write_slide_layout().unwrap_or_default();
72        let cached_layout_rels_xml = write_slide_layout_rels().unwrap_or_default();
73        Self {
74            zip,
75            slide_count: 0,
76            image_extensions: HashSet::new(),
77            global_image_counter: 0,
78            options,
79            cached_master_xml,
80            cached_master_rels_xml,
81            cached_layout_xml,
82            cached_layout_rels_xml,
83        }
84    }
85
86    /// Pre-write static files that don't depend on slide content.
87    pub fn begin(&mut self) -> Result<()> {
88        // _rels/.rels
89        self.zip.start_file("_rels/.rels", self.options)?;
90        self.zip.write_all(&write_root_rels()?)?;
91
92        // Slide layout and master (cached)
93        self.zip
94            .start_file("ppt/slideMasters/slideMaster1.xml", self.options)?;
95        self.zip.write_all(&self.cached_master_xml)?;
96
97        self.zip
98            .start_file("ppt/slideMasters/_rels/slideMaster1.xml.rels", self.options)?;
99        self.zip.write_all(&self.cached_master_rels_xml)?;
100
101        self.zip
102            .start_file("ppt/slideLayouts/slideLayout1.xml", self.options)?;
103        self.zip.write_all(&self.cached_layout_xml)?;
104
105        self.zip
106            .start_file("ppt/slideLayouts/_rels/slideLayout1.xml.rels", self.options)?;
107        self.zip.write_all(&self.cached_layout_rels_xml)?;
108
109        Ok(())
110    }
111
112    /// Add a slide to the presentation.
113    pub fn add_slide(&mut self, slide: &SlideData) -> Result<()> {
114        self.slide_count += 1;
115        let slide_num = self.slide_count;
116
117        // Slide XML
118        let slide_path = format!("ppt/slides/slide{slide_num}.xml");
119        self.zip.start_file(slide_path, self.options)?;
120        self.zip
121            .write_all(&write_slide(slide, self.global_image_counter)?)?;
122
123        // Slide rels
124        let rels_path = format!("ppt/slides/_rels/slide{slide_num}.xml.rels");
125        self.zip.start_file(rels_path, self.options)?;
126        self.zip
127            .write_all(&write_slide_rels(slide, self.global_image_counter)?)?;
128
129        // Images
130        for img in &slide.images {
131            let ext = image_ext(&img.content_type);
132            self.image_extensions.insert(ext.to_string());
133
134            let img_path = format!("ppt/media/{}", img.filename);
135            self.zip.start_file(img_path, self.options)?;
136            self.zip.write_all(&img.data)?;
137        }
138
139        self.global_image_counter += slide.images.len();
140        Ok(())
141    }
142
143    /// Finalize the PPTX by writing global metadata and finishing the ZIP.
144    pub fn finish(mut self) -> Result<W> {
145        // [Content_Types].xml
146        self.zip.start_file("[Content_Types].xml", self.options)?;
147        self.zip.write_all(&write_content_types_inc(
148            self.slide_count,
149            &self.image_extensions,
150        )?)?;
151
152        // ppt/presentation.xml
153        self.zip.start_file("ppt/presentation.xml", self.options)?;
154        self.zip.write_all(&write_presentation(self.slide_count)?)?;
155
156        // ppt/_rels/presentation.xml.rels
157        self.zip
158            .start_file("ppt/_rels/presentation.xml.rels", self.options)?;
159        self.zip
160            .write_all(&write_presentation_rels_inc(self.slide_count)?)?;
161
162        let inner = self.zip.0.finish()?;
163        Ok(inner)
164    }
165}
166
167/// Write a complete PPTX file from slide data.
168pub fn write_pptx(slides: &[SlideData], output: &mut Vec<u8>) -> Result<()> {
169    let mut writer = PptxWriter::new(Cursor::new(Vec::new()));
170    writer.begin()?;
171    for slide in slides {
172        writer.add_slide(slide)?;
173    }
174    let cursor = writer.finish()?;
175    *output = cursor.into_inner();
176    Ok(())
177}
178
179// --- XML generation helpers ---
180
181fn xml_decl(w: &mut Writer<&mut Cursor<Vec<u8>>>) -> Result<()> {
182    w.write_event(Event::Decl(BytesDecl::new(
183        "1.0",
184        Some("UTF-8"),
185        Some("yes"),
186    )))?;
187    Ok(())
188}
189
190fn write_content_types_inc(slide_count: usize, image_exts: &HashSet<String>) -> Result<Vec<u8>> {
191    let mut buf = Cursor::new(Vec::new());
192    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
193    xml_decl(&mut w)?;
194
195    let mut types = BytesStart::new("Types");
196    types.push_attribute((
197        "xmlns",
198        "http://schemas.openxmlformats.org/package/2006/content-types",
199    ));
200    w.write_event(Event::Start(types))?;
201
202    // Defaults
203    empty_with_attrs(
204        &mut w,
205        "Default",
206        &[
207            ("Extension", "rels"),
208            (
209                "ContentType",
210                "application/vnd.openxmlformats-package.relationships+xml",
211            ),
212        ],
213    )?;
214    empty_with_attrs(
215        &mut w,
216        "Default",
217        &[("Extension", "xml"), ("ContentType", "application/xml")],
218    )?;
219
220    // Image types
221    for ext in image_exts {
222        let content_type = match ext.as_str() {
223            "jpeg" | "jpg" => "image/jpeg",
224            "png" => "image/png",
225            "gif" => "image/gif",
226            "tiff" => "image/tiff",
227            _ => "image/png",
228        };
229        empty_with_attrs(
230            &mut w,
231            "Default",
232            &[("Extension", ext), ("ContentType", content_type)],
233        )?;
234    }
235
236    // Overrides
237    empty_with_attrs(&mut w, "Override", &[
238        ("PartName", "/ppt/presentation.xml"),
239        ("ContentType", "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"),
240    ])?;
241    empty_with_attrs(
242        &mut w,
243        "Override",
244        &[
245            ("PartName", "/ppt/slideMasters/slideMaster1.xml"),
246            (
247                "ContentType",
248                "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml",
249            ),
250        ],
251    )?;
252    empty_with_attrs(
253        &mut w,
254        "Override",
255        &[
256            ("PartName", "/ppt/slideLayouts/slideLayout1.xml"),
257            (
258                "ContentType",
259                "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
260            ),
261        ],
262    )?;
263
264    for i in 1..=slide_count {
265        let part = format!("/ppt/slides/slide{i}.xml");
266        empty_with_attrs(
267            &mut w,
268            "Override",
269            &[
270                ("PartName", &part),
271                (
272                    "ContentType",
273                    "application/vnd.openxmlformats-officedocument.presentationml.slide+xml",
274                ),
275            ],
276        )?;
277    }
278
279    w.write_event(Event::End(BytesEnd::new("Types")))?;
280    Ok(buf.into_inner())
281}
282
283fn write_root_rels() -> Result<Vec<u8>> {
284    let mut buf = Cursor::new(Vec::new());
285    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
286    xml_decl(&mut w)?;
287
288    let mut rels = BytesStart::new("Relationships");
289    rels.push_attribute((
290        "xmlns",
291        "http://schemas.openxmlformats.org/package/2006/relationships",
292    ));
293    w.write_event(Event::Start(rels))?;
294
295    empty_with_attrs(&mut w, "Relationship", &[
296        ("Id", "rId1"),
297        ("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"),
298        ("Target", "ppt/presentation.xml"),
299    ])?;
300
301    w.write_event(Event::End(BytesEnd::new("Relationships")))?;
302    Ok(buf.into_inner())
303}
304
305fn write_presentation(slide_count: usize) -> Result<Vec<u8>> {
306    let mut buf = Cursor::new(Vec::new());
307    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
308    xml_decl(&mut w)?;
309
310    let mut pres = BytesStart::new("p:presentation");
311    pres.push_attribute((
312        "xmlns:a",
313        "http://schemas.openxmlformats.org/drawingml/2006/main",
314    ));
315    pres.push_attribute((
316        "xmlns:p",
317        "http://schemas.openxmlformats.org/presentationml/2006/main",
318    ));
319    pres.push_attribute((
320        "xmlns:r",
321        "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
322    ));
323    w.write_event(Event::Start(pres))?;
324
325    // Slide master ID list
326    w.write_event(Event::Start(BytesStart::new("p:sldMasterIdLst")))?;
327    let mut smid = BytesStart::new("p:sldMasterId");
328    smid.push_attribute(("id", "2147483648"));
329    smid.push_attribute(("r:id", "rId1"));
330    w.write_event(Event::Empty(smid))?;
331    w.write_event(Event::End(BytesEnd::new("p:sldMasterIdLst")))?;
332
333    // Slide ID list
334    w.write_event(Event::Start(BytesStart::new("p:sldIdLst")))?;
335    for i in 0..slide_count {
336        let id = (256 + i).to_string();
337        let rid = format!("rId{}", i + 2);
338        let mut sid = BytesStart::new("p:sldId");
339        sid.push_attribute(("id", id.as_str()));
340        sid.push_attribute(("r:id", rid.as_str()));
341        w.write_event(Event::Empty(sid))?;
342    }
343    w.write_event(Event::End(BytesEnd::new("p:sldIdLst")))?;
344
345    // Slide size
346    let w_str = SLIDE_WIDTH.to_string();
347    let h_str = SLIDE_HEIGHT.to_string();
348    let mut ssz = BytesStart::new("p:sldSz");
349    ssz.push_attribute(("cx", w_str.as_str()));
350    ssz.push_attribute(("cy", h_str.as_str()));
351    ssz.push_attribute(("type", "screen4x3"));
352    w.write_event(Event::Empty(ssz))?;
353
354    let mut nsz = BytesStart::new("p:notesSz");
355    nsz.push_attribute(("cx", "6858000"));
356    nsz.push_attribute(("cy", "9144000"));
357    w.write_event(Event::Empty(nsz))?;
358
359    w.write_event(Event::End(BytesEnd::new("p:presentation")))?;
360    Ok(buf.into_inner())
361}
362
363fn write_presentation_rels_inc(slide_count: usize) -> Result<Vec<u8>> {
364    let mut buf = Cursor::new(Vec::new());
365    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
366    xml_decl(&mut w)?;
367
368    let mut rels = BytesStart::new("Relationships");
369    rels.push_attribute((
370        "xmlns",
371        "http://schemas.openxmlformats.org/package/2006/relationships",
372    ));
373    w.write_event(Event::Start(rels))?;
374
375    // Slide master
376    empty_with_attrs(
377        &mut w,
378        "Relationship",
379        &[
380            ("Id", "rId1"),
381            (
382                "Type",
383                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
384            ),
385            ("Target", "slideMasters/slideMaster1.xml"),
386        ],
387    )?;
388
389    // Slides
390    for i in 0..slide_count {
391        let rid = format!("rId{}", i + 2);
392        let target = format!("slides/slide{}.xml", i + 1);
393        empty_with_attrs(
394            &mut w,
395            "Relationship",
396            &[
397                ("Id", &rid),
398                (
399                    "Type",
400                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
401                ),
402                ("Target", &target),
403            ],
404        )?;
405    }
406
407    w.write_event(Event::End(BytesEnd::new("Relationships")))?;
408    Ok(buf.into_inner())
409}
410
411fn write_slide_master() -> Result<Vec<u8>> {
412    let mut buf = Cursor::new(Vec::new());
413    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
414    xml_decl(&mut w)?;
415
416    let mut sm = BytesStart::new("p:sldMaster");
417    sm.push_attribute((
418        "xmlns:a",
419        "http://schemas.openxmlformats.org/drawingml/2006/main",
420    ));
421    sm.push_attribute((
422        "xmlns:p",
423        "http://schemas.openxmlformats.org/presentationml/2006/main",
424    ));
425    sm.push_attribute((
426        "xmlns:r",
427        "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
428    ));
429    w.write_event(Event::Start(sm))?;
430
431    w.write_event(Event::Start(BytesStart::new("p:cSld")))?;
432    w.write_event(Event::Start(BytesStart::new("p:spTree")))?;
433
434    // Group shape properties
435    w.write_event(Event::Start(BytesStart::new("p:nvGrpSpPr")))?;
436    let mut cnv = BytesStart::new("p:cNvPr");
437    cnv.push_attribute(("id", "1"));
438    cnv.push_attribute(("name", ""));
439    w.write_event(Event::Empty(cnv))?;
440    w.write_event(Event::Empty(BytesStart::new("p:cNvGrpSpPr")))?;
441    w.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
442    w.write_event(Event::End(BytesEnd::new("p:nvGrpSpPr")))?;
443
444    w.write_event(Event::Start(BytesStart::new("p:grpSpPr")))?;
445    write_xfrm_group(&mut w)?;
446    w.write_event(Event::End(BytesEnd::new("p:grpSpPr")))?;
447
448    w.write_event(Event::End(BytesEnd::new("p:spTree")))?;
449    w.write_event(Event::End(BytesEnd::new("p:cSld")))?;
450
451    // Slide layout ID list
452    w.write_event(Event::Start(BytesStart::new("p:sldLayoutIdLst")))?;
453    let mut lid = BytesStart::new("p:sldLayoutId");
454    lid.push_attribute(("id", "2147483649"));
455    lid.push_attribute(("r:id", "rId1"));
456    w.write_event(Event::Empty(lid))?;
457    w.write_event(Event::End(BytesEnd::new("p:sldLayoutIdLst")))?;
458
459    w.write_event(Event::End(BytesEnd::new("p:sldMaster")))?;
460    Ok(buf.into_inner())
461}
462
463fn write_slide_master_rels() -> Result<Vec<u8>> {
464    let mut buf = Cursor::new(Vec::new());
465    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
466    xml_decl(&mut w)?;
467
468    let mut rels = BytesStart::new("Relationships");
469    rels.push_attribute((
470        "xmlns",
471        "http://schemas.openxmlformats.org/package/2006/relationships",
472    ));
473    w.write_event(Event::Start(rels))?;
474
475    empty_with_attrs(
476        &mut w,
477        "Relationship",
478        &[
479            ("Id", "rId1"),
480            (
481                "Type",
482                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
483            ),
484            ("Target", "../slideLayouts/slideLayout1.xml"),
485        ],
486    )?;
487
488    w.write_event(Event::End(BytesEnd::new("Relationships")))?;
489    Ok(buf.into_inner())
490}
491
492fn write_slide_layout() -> Result<Vec<u8>> {
493    let mut buf = Cursor::new(Vec::new());
494    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
495    xml_decl(&mut w)?;
496
497    let mut sl = BytesStart::new("p:sldLayout");
498    sl.push_attribute((
499        "xmlns:a",
500        "http://schemas.openxmlformats.org/drawingml/2006/main",
501    ));
502    sl.push_attribute((
503        "xmlns:p",
504        "http://schemas.openxmlformats.org/presentationml/2006/main",
505    ));
506    sl.push_attribute((
507        "xmlns:r",
508        "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
509    ));
510    sl.push_attribute(("type", "blank"));
511    w.write_event(Event::Start(sl))?;
512
513    w.write_event(Event::Start(BytesStart::new("p:cSld")))?;
514    w.write_event(Event::Start(BytesStart::new("p:spTree")))?;
515
516    w.write_event(Event::Start(BytesStart::new("p:nvGrpSpPr")))?;
517    let mut cnv = BytesStart::new("p:cNvPr");
518    cnv.push_attribute(("id", "1"));
519    cnv.push_attribute(("name", ""));
520    w.write_event(Event::Empty(cnv))?;
521    w.write_event(Event::Empty(BytesStart::new("p:cNvGrpSpPr")))?;
522    w.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
523    w.write_event(Event::End(BytesEnd::new("p:nvGrpSpPr")))?;
524
525    w.write_event(Event::Start(BytesStart::new("p:grpSpPr")))?;
526    write_xfrm_group(&mut w)?;
527    w.write_event(Event::End(BytesEnd::new("p:grpSpPr")))?;
528
529    w.write_event(Event::End(BytesEnd::new("p:spTree")))?;
530    w.write_event(Event::End(BytesEnd::new("p:cSld")))?;
531
532    w.write_event(Event::End(BytesEnd::new("p:sldLayout")))?;
533    Ok(buf.into_inner())
534}
535
536fn write_slide_layout_rels() -> Result<Vec<u8>> {
537    let mut buf = Cursor::new(Vec::new());
538    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
539    xml_decl(&mut w)?;
540
541    let mut rels = BytesStart::new("Relationships");
542    rels.push_attribute((
543        "xmlns",
544        "http://schemas.openxmlformats.org/package/2006/relationships",
545    ));
546    w.write_event(Event::Start(rels))?;
547
548    empty_with_attrs(
549        &mut w,
550        "Relationship",
551        &[
552            ("Id", "rId1"),
553            (
554                "Type",
555                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
556            ),
557            ("Target", "../slideMasters/slideMaster1.xml"),
558        ],
559    )?;
560
561    w.write_event(Event::End(BytesEnd::new("Relationships")))?;
562    Ok(buf.into_inner())
563}
564
565fn write_slide(slide: &SlideData, _img_offset: usize) -> Result<Vec<u8>> {
566    let mut buf = Cursor::new(Vec::new());
567    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
568    xml_decl(&mut w)?;
569
570    let mut sld = BytesStart::new("p:sld");
571    sld.push_attribute((
572        "xmlns:a",
573        "http://schemas.openxmlformats.org/drawingml/2006/main",
574    ));
575    sld.push_attribute((
576        "xmlns:p",
577        "http://schemas.openxmlformats.org/presentationml/2006/main",
578    ));
579    sld.push_attribute((
580        "xmlns:r",
581        "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
582    ));
583    w.write_event(Event::Start(sld))?;
584
585    w.write_event(Event::Start(BytesStart::new("p:cSld")))?;
586    w.write_event(Event::Start(BytesStart::new("p:spTree")))?;
587
588    // Group shape properties
589    w.write_event(Event::Start(BytesStart::new("p:nvGrpSpPr")))?;
590    let mut cnv = BytesStart::new("p:cNvPr");
591    cnv.push_attribute(("id", "1"));
592    cnv.push_attribute(("name", ""));
593    w.write_event(Event::Empty(cnv))?;
594    w.write_event(Event::Empty(BytesStart::new("p:cNvGrpSpPr")))?;
595    w.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
596    w.write_event(Event::End(BytesEnd::new("p:nvGrpSpPr")))?;
597
598    w.write_event(Event::Start(BytesStart::new("p:grpSpPr")))?;
599    write_xfrm_group(&mut w)?;
600    w.write_event(Event::End(BytesEnd::new("p:grpSpPr")))?;
601
602    // Scale factors: PDF points → slide EMU
603    let sx = SLIDE_WIDTH as f64 / (slide.page_width * EMU_PER_PT as f64 / EMU_PER_PT as f64);
604    let sy = SLIDE_HEIGHT as f64 / (slide.page_height * EMU_PER_PT as f64 / EMU_PER_PT as f64);
605    let scale = sx.min(sy);
606
607    // Shape ID counter
608    let mut shape_id = 2u32;
609
610    // Text shapes
611    for block in &slide.text_blocks {
612        write_text_shape(&mut w, block, slide.page_height, scale, shape_id)?;
613        shape_id += 1;
614    }
615
616    // Image shapes
617    for (i, img) in slide.images.iter().enumerate() {
618        let rid = format!("rId{}", i + 2); // rId1 = slideLayout
619        write_image_shape(&mut w, img, &rid, shape_id)?;
620        shape_id += 1;
621    }
622
623    w.write_event(Event::End(BytesEnd::new("p:spTree")))?;
624    w.write_event(Event::End(BytesEnd::new("p:cSld")))?;
625    w.write_event(Event::End(BytesEnd::new("p:sld")))?;
626    Ok(buf.into_inner())
627}
628
629fn write_slide_rels(slide: &SlideData, img_offset: usize) -> Result<Vec<u8>> {
630    let mut buf = Cursor::new(Vec::new());
631    let mut w = Writer::new_with_indent(&mut buf, b' ', 2);
632    xml_decl(&mut w)?;
633
634    let mut rels = BytesStart::new("Relationships");
635    rels.push_attribute((
636        "xmlns",
637        "http://schemas.openxmlformats.org/package/2006/relationships",
638    ));
639    w.write_event(Event::Start(rels))?;
640
641    // Slide layout
642    empty_with_attrs(
643        &mut w,
644        "Relationship",
645        &[
646            ("Id", "rId1"),
647            (
648                "Type",
649                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
650            ),
651            ("Target", "../slideLayouts/slideLayout1.xml"),
652        ],
653    )?;
654
655    // Images
656    for (i, img) in slide.images.iter().enumerate() {
657        let rid = format!("rId{}", i + 2);
658        // Correctly reference global image index for filename.
659        let ext = image_ext(&img.content_type);
660        let target = format!("../media/image{}.{}", img_offset + i, ext);
661        empty_with_attrs(
662            &mut w,
663            "Relationship",
664            &[
665                ("Id", &rid),
666                (
667                    "Type",
668                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
669                ),
670                ("Target", &target),
671            ],
672        )?;
673    }
674
675    w.write_event(Event::End(BytesEnd::new("Relationships")))?;
676    Ok(buf.into_inner())
677}
678
679/// Write a text block as a PPTX shape.
680fn write_text_shape(
681    w: &mut Writer<&mut Cursor<Vec<u8>>>,
682    block: &TextBlock,
683    page_height: f64,
684    scale: f64,
685    shape_id: u32,
686) -> Result<()> {
687    // Convert PDF coordinates (origin bottom-left) to PPTX (origin top-left).
688    let x_emu = (block.bbox[0] * EMU_PER_PT as f64 * scale / EMU_PER_PT as f64) as i64;
689    let y_pdf = page_height - block.bbox[3]; // flip y
690    let y_emu = (y_pdf * EMU_PER_PT as f64 * scale / EMU_PER_PT as f64) as i64;
691    let w_emu =
692        ((block.bbox[2] - block.bbox[0]) * EMU_PER_PT as f64 * scale / EMU_PER_PT as f64) as i64;
693    let h_emu = (block.font_size * EMU_PER_PT as f64 * scale / EMU_PER_PT as f64 * 1.4) as i64;
694
695    let x_emu = x_emu.max(0);
696    let y_emu = y_emu.max(0);
697    let w_emu = w_emu.max(EMU_PER_PT);
698    let h_emu = h_emu.max(EMU_PER_PT);
699
700    w.write_event(Event::Start(BytesStart::new("p:sp")))?;
701
702    // Non-visual properties
703    w.write_event(Event::Start(BytesStart::new("p:nvSpPr")))?;
704    let name = format!("TextBox {shape_id}");
705    let id_str = shape_id.to_string();
706    let mut cnv = BytesStart::new("p:cNvPr");
707    cnv.push_attribute(("id", id_str.as_str()));
708    cnv.push_attribute(("name", name.as_str()));
709    w.write_event(Event::Empty(cnv))?;
710
711    let mut cnv_sp = BytesStart::new("p:cNvSpPr");
712    cnv_sp.push_attribute(("txBox", "1"));
713    w.write_event(Event::Empty(cnv_sp))?;
714
715    w.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
716    w.write_event(Event::End(BytesEnd::new("p:nvSpPr")))?;
717
718    // Shape properties
719    w.write_event(Event::Start(BytesStart::new("p:spPr")))?;
720    write_xfrm_positioned(w, x_emu, y_emu, w_emu, h_emu)?;
721
722    let mut prst = BytesStart::new("a:prstGeom");
723    prst.push_attribute(("prst", "rect"));
724    w.write_event(Event::Start(prst))?;
725    w.write_event(Event::Empty(BytesStart::new("a:avLst")))?;
726    w.write_event(Event::End(BytesEnd::new("a:prstGeom")))?;
727
728    w.write_event(Event::Empty(BytesStart::new("a:noFill")))?;
729    w.write_event(Event::End(BytesEnd::new("p:spPr")))?;
730
731    // Text body
732    w.write_event(Event::Start(BytesStart::new("p:txBody")))?;
733
734    let mut body_pr = BytesStart::new("a:bodyPr");
735    body_pr.push_attribute(("wrap", "square"));
736    w.write_event(Event::Empty(body_pr))?;
737
738    w.write_event(Event::Empty(BytesStart::new("a:lstStyle")))?;
739
740    // Paragraph
741    w.write_event(Event::Start(BytesStart::new("a:p")))?;
742    w.write_event(Event::Start(BytesStart::new("a:r")))?;
743
744    // Run properties
745    let font_size_hundredths = ((block.font_size * scale) * 100.0) as i64;
746    let sz_str = font_size_hundredths.to_string();
747    let mut rpr = BytesStart::new("a:rPr");
748    rpr.push_attribute(("lang", "en-US"));
749    rpr.push_attribute(("sz", sz_str.as_str()));
750
751    let is_bold = block.font_name.contains("Bold") || block.font_name.contains("bold");
752    let is_italic = block.font_name.contains("Italic")
753        || block.font_name.contains("italic")
754        || block.font_name.contains("Oblique");
755
756    if is_bold {
757        rpr.push_attribute(("b", "1"));
758    }
759    if is_italic {
760        rpr.push_attribute(("i", "1"));
761    }
762
763    w.write_event(Event::Empty(rpr))?;
764
765    w.write_event(Event::Start(BytesStart::new("a:t")))?;
766    w.write_event(Event::Text(BytesText::new(&block.text)))?;
767    w.write_event(Event::End(BytesEnd::new("a:t")))?;
768
769    w.write_event(Event::End(BytesEnd::new("a:r")))?;
770    w.write_event(Event::End(BytesEnd::new("a:p")))?;
771
772    w.write_event(Event::End(BytesEnd::new("p:txBody")))?;
773    w.write_event(Event::End(BytesEnd::new("p:sp")))?;
774
775    Ok(())
776}
777
778/// Write an image as a PPTX picture shape.
779fn write_image_shape(
780    w: &mut Writer<&mut Cursor<Vec<u8>>>,
781    img: &PptxImage,
782    rid: &str,
783    shape_id: u32,
784) -> Result<()> {
785    let dpi = 96.0f64;
786    let cx = (img.width as f64 / dpi * 914400.0) as i64;
787    let cy = (img.height as f64 / dpi * 914400.0) as i64;
788
789    w.write_event(Event::Start(BytesStart::new("p:pic")))?;
790
791    // Non-visual properties
792    w.write_event(Event::Start(BytesStart::new("p:nvPicPr")))?;
793    let name = format!("Image {shape_id}");
794    let id_str = shape_id.to_string();
795    let mut cnv = BytesStart::new("p:cNvPr");
796    cnv.push_attribute(("id", id_str.as_str()));
797    cnv.push_attribute(("name", name.as_str()));
798    w.write_event(Event::Empty(cnv))?;
799
800    let cnv_pic = BytesStart::new("p:cNvPicPr");
801    w.write_event(Event::Start(cnv_pic))?;
802    w.write_event(Event::Empty(BytesStart::new("a:picLocks")))?;
803    w.write_event(Event::End(BytesEnd::new("p:cNvPicPr")))?;
804
805    w.write_event(Event::Empty(BytesStart::new("p:nvPr")))?;
806    w.write_event(Event::End(BytesEnd::new("p:nvPicPr")))?;
807
808    // Blip fill
809    w.write_event(Event::Start(BytesStart::new("p:blipFill")))?;
810    let mut blip = BytesStart::new("a:blip");
811    blip.push_attribute(("r:embed", rid));
812    w.write_event(Event::Empty(blip))?;
813
814    w.write_event(Event::Start(BytesStart::new("a:stretch")))?;
815    w.write_event(Event::Empty(BytesStart::new("a:fillRect")))?;
816    w.write_event(Event::End(BytesEnd::new("a:stretch")))?;
817    w.write_event(Event::End(BytesEnd::new("p:blipFill")))?;
818
819    // Shape properties
820    w.write_event(Event::Start(BytesStart::new("p:spPr")))?;
821    write_xfrm_positioned(w, 0, 0, cx, cy)?;
822
823    let mut prst = BytesStart::new("a:prstGeom");
824    prst.push_attribute(("prst", "rect"));
825    w.write_event(Event::Start(prst))?;
826    w.write_event(Event::Empty(BytesStart::new("a:avLst")))?;
827    w.write_event(Event::End(BytesEnd::new("a:prstGeom")))?;
828    w.write_event(Event::End(BytesEnd::new("p:spPr")))?;
829
830    w.write_event(Event::End(BytesEnd::new("p:pic")))?;
831    Ok(())
832}
833
834// --- Utility functions ---
835
836fn empty_with_attrs(
837    w: &mut Writer<&mut Cursor<Vec<u8>>>,
838    tag: &str,
839    attrs: &[(&str, &str)],
840) -> Result<()> {
841    let mut el = BytesStart::new(tag);
842    for (k, v) in attrs {
843        el.push_attribute((*k, *v));
844    }
845    w.write_event(Event::Empty(el))?;
846    Ok(())
847}
848
849fn write_xfrm_group(w: &mut Writer<&mut Cursor<Vec<u8>>>) -> Result<()> {
850    w.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
851
852    empty_with_attrs(w, "a:off", &[("x", "0"), ("y", "0")])?;
853    empty_with_attrs(w, "a:ext", &[("cx", "0"), ("cy", "0")])?;
854    empty_with_attrs(w, "a:chOff", &[("x", "0"), ("y", "0")])?;
855    empty_with_attrs(w, "a:chExt", &[("cx", "0"), ("cy", "0")])?;
856
857    w.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
858    Ok(())
859}
860
861fn write_xfrm_positioned(
862    w: &mut Writer<&mut Cursor<Vec<u8>>>,
863    x: i64,
864    y: i64,
865    cx: i64,
866    cy: i64,
867) -> Result<()> {
868    w.write_event(Event::Start(BytesStart::new("a:xfrm")))?;
869
870    let xs = x.to_string();
871    let ys = y.to_string();
872    let cxs = cx.to_string();
873    let cys = cy.to_string();
874
875    empty_with_attrs(w, "a:off", &[("x", &xs), ("y", &ys)])?;
876    empty_with_attrs(w, "a:ext", &[("cx", &cxs), ("cy", &cys)])?;
877
878    w.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
879    Ok(())
880}
881
882fn image_ext(content_type: &str) -> &str {
883    match content_type {
884        "image/jpeg" => "jpeg",
885        "image/png" => "png",
886        "image/gif" => "gif",
887        "image/tiff" => "tiff",
888        _ => "png",
889    }
890}
891
892/// Convert ExtractedImage to PptxImage.
893pub fn extracted_to_pptx_image(img: &ExtractedImage, idx: usize) -> PptxImage {
894    let (content_type, ext) = match img.filter {
895        ImageFilter::Jpeg => ("image/jpeg", "jpeg"),
896        _ => ("image/png", "png"),
897    };
898
899    PptxImage {
900        data: img.data.clone(),
901        width: img.width,
902        height: img.height,
903        content_type: content_type.to_string(),
904        filename: format!("image{idx}.{ext}"),
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    /// `PptxWriter` was `UnwindSafe` in 1.0; zip 9's `ZipWriter` is not, and the writer wraps it in
913    /// `AssertUnwindSafe` to keep that auto trait (cargo semver-checks, auto_trait_impl_removed).
914    fn assert_unwind_safe<T: std::panic::UnwindSafe>() {}
915
916    #[test]
917    fn the_writer_stays_unwind_safe() {
918        assert_unwind_safe::<PptxWriter<std::io::Cursor<Vec<u8>>>>();
919    }
920
921    #[test]
922    fn write_minimal_pptx() {
923        let slides = vec![SlideData {
924            text_blocks: vec![TextBlock {
925                text: "Hello World".to_string(),
926                page: 1,
927                bbox: [72.0, 700.0, 200.0, 712.0],
928                font_name: "F1".to_string(),
929                font_size: 12.0,
930                actual_text: None,
931                base_font: None,
932                is_bold: false,
933                is_italic: false,
934                color: None,
935                width_source: Default::default(),
936                char_bounds: vec![],
937            }],
938            images: vec![],
939            page_width: 612.0,
940            page_height: 792.0,
941        }];
942
943        let mut output = Vec::new();
944        write_pptx(&slides, &mut output).unwrap();
945        assert!(output.len() > 100);
946        assert_eq!(&output[0..2], b"PK");
947    }
948
949    #[test]
950    fn write_empty_pptx() {
951        let slides = vec![SlideData {
952            text_blocks: vec![],
953            images: vec![],
954            page_width: 612.0,
955            page_height: 792.0,
956        }];
957
958        let mut output = Vec::new();
959        write_pptx(&slides, &mut output).unwrap();
960        assert!(output.len() > 100);
961    }
962
963    #[test]
964    fn write_multi_slide_pptx() {
965        let slides = vec![
966            SlideData {
967                text_blocks: vec![TextBlock {
968                    text: "Slide 1".to_string(),
969                    page: 1,
970                    bbox: [72.0, 700.0, 200.0, 712.0],
971                    font_name: "F1".to_string(),
972                    font_size: 24.0,
973                    actual_text: None,
974                    base_font: None,
975                    is_bold: false,
976                    is_italic: false,
977                    color: None,
978                    width_source: Default::default(),
979                    char_bounds: vec![],
980                }],
981                images: vec![],
982                page_width: 612.0,
983                page_height: 792.0,
984            },
985            SlideData {
986                text_blocks: vec![TextBlock {
987                    text: "Slide 2".to_string(),
988                    page: 2,
989                    bbox: [72.0, 700.0, 200.0, 712.0],
990                    font_name: "F1".to_string(),
991                    font_size: 18.0,
992                    actual_text: None,
993                    base_font: None,
994                    is_bold: false,
995                    is_italic: false,
996                    color: None,
997                    width_source: Default::default(),
998                    char_bounds: vec![],
999                }],
1000                images: vec![],
1001                page_width: 612.0,
1002                page_height: 792.0,
1003            },
1004        ];
1005
1006        let mut output = Vec::new();
1007        write_pptx(&slides, &mut output).unwrap();
1008        assert!(output.len() > 100);
1009    }
1010}