Skip to main content

ppt_rs/generator/slide_content/
print_settings.rs

1//! Print settings and handout configuration for presentations
2//!
3//! Controls print layout, handout options, and page setup.
4//! Generates `<p:prnPr>` and handout master XML.
5
6/// Handout layout (slides per page)
7#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
8pub enum HandoutLayout {
9    SlidesPerPage1,
10    SlidesPerPage2,
11    #[default]
12    SlidesPerPage3,
13    SlidesPerPage4,
14    SlidesPerPage6,
15    SlidesPerPage9,
16}
17
18impl HandoutLayout {
19    pub fn slides_per_page(&self) -> u32 {
20        match self {
21            HandoutLayout::SlidesPerPage1 => 1,
22            HandoutLayout::SlidesPerPage2 => 2,
23            HandoutLayout::SlidesPerPage3 => 3,
24            HandoutLayout::SlidesPerPage4 => 4,
25            HandoutLayout::SlidesPerPage6 => 6,
26            HandoutLayout::SlidesPerPage9 => 9,
27        }
28    }
29
30    pub fn to_xml_value(&self) -> &'static str {
31        match self {
32            HandoutLayout::SlidesPerPage1 => "handout1",
33            HandoutLayout::SlidesPerPage2 => "handout2",
34            HandoutLayout::SlidesPerPage3 => "handout3",
35            HandoutLayout::SlidesPerPage4 => "handout4",
36            HandoutLayout::SlidesPerPage6 => "handout6",
37            HandoutLayout::SlidesPerPage9 => "handout9",
38        }
39    }
40}
41
42/// Print color mode
43#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
44pub enum PrintColorMode {
45    #[default]
46    Color,
47    Grayscale,
48    BlackAndWhite,
49}
50
51impl PrintColorMode {
52    pub fn to_xml_value(&self) -> &'static str {
53        match self {
54            PrintColorMode::Color => "clr",
55            PrintColorMode::Grayscale => "gray",
56            PrintColorMode::BlackAndWhite => "bw",
57        }
58    }
59}
60
61/// What to print
62#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
63pub enum PrintWhat {
64    #[default]
65    Slides,
66    Handouts,
67    Notes,
68    Outline,
69}
70
71impl PrintWhat {
72    pub fn to_xml_value(&self) -> &'static str {
73        match self {
74            PrintWhat::Slides => "slides",
75            PrintWhat::Handouts => "handouts",
76            PrintWhat::Notes => "notes",
77            PrintWhat::Outline => "outline",
78        }
79    }
80}
81
82/// Page orientation
83#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
84pub enum Orientation {
85    #[default]
86    Landscape,
87    Portrait,
88}
89
90impl Orientation {
91    pub fn to_xml_value(&self) -> &'static str {
92        match self {
93            Orientation::Landscape => "landscape",
94            Orientation::Portrait => "portrait",
95        }
96    }
97}
98
99/// Print settings for the presentation
100#[derive(Clone, Debug, Default)]
101pub struct PrintSettings {
102    pub print_what: PrintWhat,
103    pub color_mode: PrintColorMode,
104    pub handout_layout: HandoutLayout,
105    pub frame_slides: bool,
106    pub scale_to_fit: bool,
107    pub include_hidden_slides: bool,
108    pub orientation: Orientation,
109    pub header: Option<String>,
110    pub footer: Option<String>,
111    pub print_date: bool,
112    pub print_page_numbers: bool,
113}
114
115impl PrintSettings {
116    pub fn new() -> Self {
117        Self {
118            scale_to_fit: true,
119            ..Default::default()
120        }
121    }
122
123    pub fn print_what(mut self, what: PrintWhat) -> Self {
124        self.print_what = what;
125        self
126    }
127
128    pub fn color_mode(mut self, mode: PrintColorMode) -> Self {
129        self.color_mode = mode;
130        self
131    }
132
133    pub fn handout_layout(mut self, layout: HandoutLayout) -> Self {
134        self.handout_layout = layout;
135        self
136    }
137
138    pub fn frame_slides(mut self, frame: bool) -> Self {
139        self.frame_slides = frame;
140        self
141    }
142
143    pub fn scale_to_fit(mut self, scale: bool) -> Self {
144        self.scale_to_fit = scale;
145        self
146    }
147
148    pub fn include_hidden_slides(mut self, include: bool) -> Self {
149        self.include_hidden_slides = include;
150        self
151    }
152
153    pub fn orientation(mut self, orientation: Orientation) -> Self {
154        self.orientation = orientation;
155        self
156    }
157
158    pub fn header(mut self, header: &str) -> Self {
159        self.header = Some(header.to_string());
160        self
161    }
162
163    pub fn footer(mut self, footer: &str) -> Self {
164        self.footer = Some(footer.to_string());
165        self
166    }
167
168    pub fn print_date(mut self, val: bool) -> Self {
169        self.print_date = val;
170        self
171    }
172
173    pub fn print_page_numbers(mut self, val: bool) -> Self {
174        self.print_page_numbers = val;
175        self
176    }
177
178    /// Generate `<p:prnPr>` XML for presProps.xml
179    pub fn to_prnpr_xml(&self) -> String {
180        let mut attrs = Vec::new();
181        attrs.push(format!(r#"prnWhat="{}""#, self.print_what.to_xml_value()));
182        attrs.push(format!(r#"clrMode="{}""#, self.color_mode.to_xml_value()));
183
184        if self.frame_slides {
185            attrs.push("frameSlides=\"1\"".to_string());
186        }
187        if self.include_hidden_slides {
188            attrs.push("hiddenSlides=\"1\"".to_string());
189        }
190        if self.scale_to_fit {
191            attrs.push("scaleToFitPaper=\"1\"".to_string());
192        }
193
194        format!(r#"<p:prnPr {}/>"#, attrs.join(" "))
195    }
196
197    /// Generate handout master XML (PowerPoint-compatible structure)
198    pub fn to_handout_master_xml(&self) -> String {
199        let mut xml = String::from(
200            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:handoutMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>"#,
201        );
202
203        if let Some(ref header) = self.header {
204            xml.push_str(&format!(
205                r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Header"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="hdr"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{}</a:t></a:r></a:p></p:txBody></p:sp>"#,
206                xml_escape(header)
207            ));
208        }
209
210        if let Some(ref footer) = self.footer {
211            xml.push_str(&format!(
212                r#"<p:sp><p:nvSpPr><p:cNvPr id="3" name="Footer"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="ftr"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{}</a:t></a:r></a:p></p:txBody></p:sp>"#,
213                xml_escape(footer)
214            ));
215        }
216
217        if self.print_date {
218            xml.push_str(
219                r#"<p:sp><p:nvSpPr><p:cNvPr id="4" name="Date"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="dt"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:fld id="{B6F15528-F159-4107-2D14-000000000000}" type="datetimeFigureOut"><a:t/></a:fld><a:endParaRPr/></a:p></p:txBody></p:sp>"#,
220            );
221        }
222
223        if self.print_page_numbers {
224            xml.push_str(
225                r#"<p:sp><p:nvSpPr><p:cNvPr id="5" name="Slide Number"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:fld id="{B6F15528-F159-4107-2D14-000000000001}" type="slidenum"><a:t>‹#›</a:t></a:fld><a:endParaRPr/></a:p></p:txBody></p:sp>"#,
226            );
227        }
228
229        xml.push_str(r#"</p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:handoutMaster>"#);
230        xml
231    }
232}
233
234fn xml_escape(s: &str) -> String {
235    s.replace('&', "&amp;")
236        .replace('<', "&lt;")
237        .replace('>', "&gt;")
238        .replace('"', "&quot;")
239        .replace('\'', "&apos;")
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_handout_layout_default() {
248        assert_eq!(HandoutLayout::default(), HandoutLayout::SlidesPerPage3);
249        assert_eq!(HandoutLayout::SlidesPerPage3.slides_per_page(), 3);
250    }
251
252    #[test]
253    fn test_handout_layout_variants() {
254        assert_eq!(HandoutLayout::SlidesPerPage1.slides_per_page(), 1);
255        assert_eq!(HandoutLayout::SlidesPerPage2.slides_per_page(), 2);
256        assert_eq!(HandoutLayout::SlidesPerPage4.slides_per_page(), 4);
257        assert_eq!(HandoutLayout::SlidesPerPage6.slides_per_page(), 6);
258        assert_eq!(HandoutLayout::SlidesPerPage9.slides_per_page(), 9);
259    }
260
261    #[test]
262    fn test_handout_layout_xml() {
263        assert_eq!(HandoutLayout::SlidesPerPage1.to_xml_value(), "handout1");
264        assert_eq!(HandoutLayout::SlidesPerPage6.to_xml_value(), "handout6");
265    }
266
267    #[test]
268    fn test_print_color_mode() {
269        assert_eq!(PrintColorMode::default(), PrintColorMode::Color);
270        assert_eq!(PrintColorMode::Color.to_xml_value(), "clr");
271        assert_eq!(PrintColorMode::Grayscale.to_xml_value(), "gray");
272        assert_eq!(PrintColorMode::BlackAndWhite.to_xml_value(), "bw");
273    }
274
275    #[test]
276    fn test_print_what() {
277        assert_eq!(PrintWhat::default(), PrintWhat::Slides);
278        assert_eq!(PrintWhat::Slides.to_xml_value(), "slides");
279        assert_eq!(PrintWhat::Handouts.to_xml_value(), "handouts");
280        assert_eq!(PrintWhat::Notes.to_xml_value(), "notes");
281        assert_eq!(PrintWhat::Outline.to_xml_value(), "outline");
282    }
283
284    #[test]
285    fn test_orientation() {
286        assert_eq!(Orientation::default(), Orientation::Landscape);
287        assert_eq!(Orientation::Landscape.to_xml_value(), "landscape");
288        assert_eq!(Orientation::Portrait.to_xml_value(), "portrait");
289    }
290
291    #[test]
292    fn test_print_settings_new() {
293        let s = PrintSettings::new();
294        assert_eq!(s.print_what, PrintWhat::Slides);
295        assert_eq!(s.color_mode, PrintColorMode::Color);
296        assert!(s.scale_to_fit);
297        assert!(!s.frame_slides);
298    }
299
300    #[test]
301    fn test_print_settings_builder() {
302        let s = PrintSettings::new()
303            .print_what(PrintWhat::Handouts)
304            .color_mode(PrintColorMode::Grayscale)
305            .handout_layout(HandoutLayout::SlidesPerPage6)
306            .frame_slides(true)
307            .scale_to_fit(false)
308            .include_hidden_slides(true)
309            .orientation(Orientation::Portrait)
310            .header("My Header")
311            .footer("Page Footer")
312            .print_date(true)
313            .print_page_numbers(true);
314        assert_eq!(s.print_what, PrintWhat::Handouts);
315        assert_eq!(s.color_mode, PrintColorMode::Grayscale);
316        assert_eq!(s.handout_layout, HandoutLayout::SlidesPerPage6);
317        assert!(s.frame_slides);
318        assert!(!s.scale_to_fit);
319        assert!(s.include_hidden_slides);
320        assert_eq!(s.header.as_deref(), Some("My Header"));
321        assert_eq!(s.footer.as_deref(), Some("Page Footer"));
322        assert!(s.print_date);
323        assert!(s.print_page_numbers);
324    }
325
326    #[test]
327    fn test_prnpr_xml() {
328        let s = PrintSettings::new();
329        let xml = s.to_prnpr_xml();
330        assert!(xml.contains("<p:prnPr"));
331        assert!(xml.contains(r#"prnWhat="slides""#));
332        assert!(xml.contains(r#"clrMode="clr""#));
333    }
334
335    #[test]
336    fn test_prnpr_xml_handouts() {
337        let s = PrintSettings::new()
338            .print_what(PrintWhat::Handouts)
339            .color_mode(PrintColorMode::BlackAndWhite)
340            .frame_slides(true);
341        let xml = s.to_prnpr_xml();
342        assert!(xml.contains(r#"prnWhat="handouts""#));
343        assert!(xml.contains(r#"clrMode="bw""#));
344        assert!(xml.contains("frameSlides=\"1\""));
345    }
346
347    #[test]
348    fn test_handout_master_xml_basic() {
349        let s = PrintSettings::new().header("Title");
350        let xml = s.to_handout_master_xml();
351        assert!(xml.contains("<p:handoutMaster"));
352        assert!(xml.contains("</p:handoutMaster>"));
353        assert!(xml.contains("<p:bg>"));
354        assert!(xml.contains("<a:spLocks noGrp=\"1\"/>"));
355        assert!(xml.contains("<p:spTree>"));
356    }
357
358    #[test]
359    fn test_handout_master_xml_with_header_footer() {
360        let s = PrintSettings::new()
361            .header("Report")
362            .footer("Confidential")
363            .print_date(true)
364            .print_page_numbers(true);
365        let xml = s.to_handout_master_xml();
366        assert!(xml.contains("Report"));
367        assert!(xml.contains("Confidential"));
368        assert!(xml.contains(r#"type="hdr""#));
369        assert!(xml.contains(r#"type="ftr""#));
370        assert!(xml.contains(r#"type="dt""#));
371        assert!(xml.contains(r#"type="sldNum""#));
372    }
373
374    #[test]
375    fn test_prnpr_xml_wellformed() {
376        // Regression: raw string r#"frameSlides="1"# ate the closing quote
377        let s = PrintSettings::new()
378            .print_what(PrintWhat::Handouts)
379            .color_mode(PrintColorMode::Grayscale)
380            .frame_slides(true)
381            .include_hidden_slides(true)
382            .scale_to_fit(true);
383        let xml = s.to_prnpr_xml();
384        // Every attribute must have properly closed quotes
385        assert!(xml.contains("frameSlides=\"1\""));
386        assert!(xml.contains("hiddenSlides=\"1\""));
387        assert!(xml.contains("scaleToFitPaper=\"1\""));
388        // Must be self-closing and end with />
389        assert!(xml.ends_with("/>"));
390        // Wrap in root to validate XML well-formedness
391        let wrapped = format!("<root {}/>", &xml[9..xml.len()-2]);
392        assert!(!wrapped.contains("=\"1 "), "attribute value missing closing quote");
393    }
394
395    #[test]
396    fn test_handout_master_xml_escaping() {
397        let s = PrintSettings::new().header("Q&A <Report>");
398        let xml = s.to_handout_master_xml();
399        assert!(xml.contains("Q&amp;A &lt;Report&gt;"));
400    }
401}