Skip to main content

ppt_rs/generator/slide_content/
ink_annotations.rs

1//! Ink annotations for slides
2//!
3//! Supports freehand ink strokes on slides using the OOXML `<p:inkGrp>` element.
4//! Each stroke is a series of points with pen properties (color, width).
5
6/// Pen tip style
7#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
8pub enum PenTip {
9    #[default]
10    Ball,
11    Flat,
12}
13
14impl PenTip {
15    pub fn to_xml(&self) -> &'static str {
16        match self {
17            PenTip::Ball => "ball",
18            PenTip::Flat => "flat",
19        }
20    }
21}
22
23/// Ink pen properties
24#[derive(Clone, Debug)]
25pub struct InkPen {
26    pub color: String,
27    pub width: u32,
28    pub tip: PenTip,
29    pub opacity: f32,
30}
31
32impl InkPen {
33    /// Create a pen with color (RGB hex) and width in hundredths of a mm
34    pub fn new(color: &str, width: u32) -> Self {
35        Self {
36            color: color.trim_start_matches('#').to_uppercase(),
37            width,
38            tip: PenTip::default(),
39            opacity: 1.0,
40        }
41    }
42
43    pub fn tip(mut self, tip: PenTip) -> Self {
44        self.tip = tip;
45        self
46    }
47
48    /// Set opacity (0.0 - 1.0)
49    pub fn opacity(mut self, opacity: f32) -> Self {
50        self.opacity = crate::core::clamp_unit_interval(opacity as f64) as f32;
51        self
52    }
53
54    /// Default red pen
55    pub fn red() -> Self {
56        Self::new("FF0000", 50)
57    }
58
59    /// Default blue pen
60    pub fn blue() -> Self {
61        Self::new("0000FF", 50)
62    }
63
64    /// Default black pen
65    pub fn black() -> Self {
66        Self::new("000000", 50)
67    }
68
69    /// Highlighter (wide, semi-transparent yellow)
70    pub fn highlighter() -> Self {
71        Self::new("FFFF00", 300).tip(PenTip::Flat).opacity(0.5)
72    }
73}
74
75/// A single point in an ink stroke
76#[derive(Clone, Debug, Copy, PartialEq)]
77pub struct InkPoint {
78    pub x: f64,
79    pub y: f64,
80}
81
82impl InkPoint {
83    pub fn new(x: f64, y: f64) -> Self {
84        Self { x, y }
85    }
86}
87
88/// A single ink stroke (continuous pen path)
89#[derive(Clone, Debug)]
90pub struct InkStroke {
91    pub points: Vec<InkPoint>,
92    pub pen: InkPen,
93}
94
95impl InkStroke {
96    pub fn new(pen: InkPen) -> Self {
97        Self {
98            points: Vec::new(),
99            pen,
100        }
101    }
102
103    /// Add a point to the stroke
104    pub fn add_point(mut self, x: f64, y: f64) -> Self {
105        self.points.push(InkPoint::new(x, y));
106        self
107    }
108
109    /// Add multiple points
110    pub fn add_points(mut self, points: &[(f64, f64)]) -> Self {
111        for &(x, y) in points {
112            self.points.push(InkPoint::new(x, y));
113        }
114        self
115    }
116
117    /// Number of points
118    pub fn len(&self) -> usize {
119        self.points.len()
120    }
121
122    pub fn is_empty(&self) -> bool {
123        self.points.is_empty()
124    }
125
126    /// Generate points string for ink XML (space-separated x y pairs)
127    fn points_str(&self) -> String {
128        self.points
129            .iter()
130            .map(|p| format!("{} {}", p.x as i64, p.y as i64))
131            .collect::<Vec<_>>()
132            .join(" ")
133    }
134
135    /// Generate XML for this stroke as an `<ink:trace>` element
136    pub fn to_xml(&self, trace_id: u32) -> String {
137        format!(
138            "<ink:trace contextRef=\"#{}\" brushRef=\"#br{}\" id=\"{}\">{}</ink:trace>",
139            trace_id,
140            trace_id,
141            trace_id,
142            self.points_str(),
143        )
144    }
145}
146
147/// Collection of ink annotations on a slide
148#[derive(Clone, Debug, Default)]
149pub struct InkAnnotations {
150    strokes: Vec<InkStroke>,
151}
152
153impl InkAnnotations {
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// Add a stroke
159    pub fn add_stroke(&mut self, stroke: InkStroke) {
160        self.strokes.push(stroke);
161    }
162
163    /// Get all strokes
164    pub fn strokes(&self) -> &[InkStroke] {
165        &self.strokes
166    }
167
168    /// Number of strokes
169    pub fn len(&self) -> usize {
170        self.strokes.len()
171    }
172
173    pub fn is_empty(&self) -> bool {
174        self.strokes.is_empty()
175    }
176
177    /// Clear all strokes
178    pub fn clear(&mut self) {
179        self.strokes.clear();
180    }
181
182    /// Generate the standalone ink part XML (`ppt/ink/inkN.xml`).
183    pub fn part_xml(&self) -> String {
184        if self.strokes.is_empty() {
185            return String::new();
186        }
187
188        let mut xml = String::from(
189            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
190<ink:ink xmlns:ink="http://www.w3.org/2003/InkML">"#,
191        );
192
193        // Brush definitions
194        for (i, stroke) in self.strokes.iter().enumerate() {
195            let opacity_attr = if (stroke.pen.opacity - 1.0).abs() > 0.01 {
196                format!(r#" transparency="{:.0}""#, (1.0 - stroke.pen.opacity) * 255.0)
197            } else {
198                String::new()
199            };
200            xml.push_str(&format!(
201                "<ink:brush id=\"br{}\" color=\"#{}\" width=\"{}\" tip=\"{}\"{} />\n",
202                i, stroke.pen.color, stroke.pen.width, stroke.pen.tip.to_xml(), opacity_attr,
203            ));
204        }
205
206        // Traces
207        for (i, stroke) in self.strokes.iter().enumerate() {
208            xml.push_str(&stroke.to_xml(i as u32));
209        }
210
211        xml.push_str("</ink:ink>");
212        xml
213    }
214
215    /// Generate the slide-level `<mc:AlternateContent>` reference to an ink part.
216    pub fn to_xml(&self) -> String {
217        if self.strokes.is_empty() {
218            return String::new();
219        }
220        format!(
221            r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="p14"><p:contentPart xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="{}"/></mc:Choice></mc:AlternateContent>"#,
222            self.rel_id()
223        )
224    }
225
226    /// Default relationship id placeholder used by the slide-level reference.
227    pub fn rel_id(&self) -> String {
228        "rId5".to_string()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_pen_tip_default() {
238        assert_eq!(PenTip::default(), PenTip::Ball);
239        assert_eq!(PenTip::Ball.to_xml(), "ball");
240        assert_eq!(PenTip::Flat.to_xml(), "flat");
241    }
242
243    #[test]
244    fn test_ink_pen_new() {
245        let pen = InkPen::new("FF0000", 50);
246        assert_eq!(pen.color, "FF0000");
247        assert_eq!(pen.width, 50);
248        assert_eq!(pen.tip, PenTip::Ball);
249        assert!((pen.opacity - 1.0).abs() < f32::EPSILON);
250    }
251
252    #[test]
253    fn test_ink_pen_presets() {
254        let red = InkPen::red();
255        assert_eq!(red.color, "FF0000");
256        let blue = InkPen::blue();
257        assert_eq!(blue.color, "0000FF");
258        let black = InkPen::black();
259        assert_eq!(black.color, "000000");
260    }
261
262    #[test]
263    fn test_ink_pen_highlighter() {
264        let h = InkPen::highlighter();
265        assert_eq!(h.color, "FFFF00");
266        assert_eq!(h.tip, PenTip::Flat);
267        assert!((h.opacity - 0.5).abs() < f32::EPSILON);
268        assert_eq!(h.width, 300);
269    }
270
271    #[test]
272    fn test_ink_pen_opacity_clamp() {
273        let pen = InkPen::new("000000", 10).opacity(2.0);
274        assert!((pen.opacity - 1.0).abs() < f32::EPSILON);
275        let pen2 = InkPen::new("000000", 10).opacity(-1.0);
276        assert!((pen2.opacity - 0.0).abs() < f32::EPSILON);
277    }
278
279    #[test]
280    fn test_ink_point() {
281        let p = InkPoint::new(100.0, 200.0);
282        assert!((p.x - 100.0).abs() < f64::EPSILON);
283        assert!((p.y - 200.0).abs() < f64::EPSILON);
284    }
285
286    #[test]
287    fn test_ink_stroke_new() {
288        let stroke = InkStroke::new(InkPen::black());
289        assert!(stroke.is_empty());
290        assert_eq!(stroke.len(), 0);
291    }
292
293    #[test]
294    fn test_ink_stroke_add_points() {
295        let stroke = InkStroke::new(InkPen::red())
296            .add_point(0.0, 0.0)
297            .add_point(100.0, 100.0)
298            .add_point(200.0, 50.0);
299        assert_eq!(stroke.len(), 3);
300        assert!(!stroke.is_empty());
301    }
302
303    #[test]
304    fn test_ink_stroke_add_points_batch() {
305        let stroke = InkStroke::new(InkPen::blue())
306            .add_points(&[(0.0, 0.0), (50.0, 50.0), (100.0, 0.0)]);
307        assert_eq!(stroke.len(), 3);
308    }
309
310    #[test]
311    fn test_ink_stroke_xml() {
312        let stroke = InkStroke::new(InkPen::black())
313            .add_point(10.0, 20.0)
314            .add_point(30.0, 40.0);
315        let xml = stroke.to_xml(0);
316        assert!(xml.contains("ink:trace"));
317        assert!(xml.contains("10 20"));
318        assert!(xml.contains("30 40"));
319    }
320
321    #[test]
322    fn test_ink_annotations_new() {
323        let ann = InkAnnotations::new();
324        assert!(ann.is_empty());
325        assert_eq!(ann.len(), 0);
326    }
327
328    #[test]
329    fn test_ink_annotations_add() {
330        let mut ann = InkAnnotations::new();
331        ann.add_stroke(InkStroke::new(InkPen::red()).add_point(0.0, 0.0));
332        ann.add_stroke(InkStroke::new(InkPen::blue()).add_point(10.0, 10.0));
333        assert_eq!(ann.len(), 2);
334    }
335
336    #[test]
337    fn test_ink_annotations_clear() {
338        let mut ann = InkAnnotations::new();
339        ann.add_stroke(InkStroke::new(InkPen::black()).add_point(0.0, 0.0));
340        ann.clear();
341        assert!(ann.is_empty());
342    }
343
344    #[test]
345    fn test_ink_annotations_xml_empty() {
346        let ann = InkAnnotations::new();
347        assert_eq!(ann.to_xml(), "");
348    }
349
350    #[test]
351    fn test_ink_annotations_xml() {
352        let mut ann = InkAnnotations::new();
353        ann.add_stroke(
354            InkStroke::new(InkPen::red())
355                .add_point(0.0, 0.0)
356                .add_point(100.0, 100.0),
357        );
358        let slide_xml = ann.to_xml();
359        assert!(slide_xml.contains("mc:AlternateContent"));
360        assert!(slide_xml.contains("p:contentPart"));
361
362        let part_xml = ann.part_xml();
363        assert!(part_xml.contains("ink:ink"));
364        assert!(part_xml.contains("ink:brush"));
365        assert!(part_xml.contains("ink:trace"));
366        assert!(part_xml.contains("FF0000"));
367    }
368
369    #[test]
370    fn test_ink_annotations_xml_highlighter() {
371        let mut ann = InkAnnotations::new();
372        ann.add_stroke(
373            InkStroke::new(InkPen::highlighter())
374                .add_point(0.0, 0.0)
375                .add_point(500.0, 0.0),
376        );
377        let xml = ann.part_xml();
378        assert!(xml.contains("FFFF00"));
379        assert!(xml.contains("flat"));
380        assert!(xml.contains("transparency"));
381    }
382}